dsh-config-manager 0.1.71 → 0.1.72

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 (548) hide show
  1. package/README.md +111 -96
  2. package/README.zh-CN.md +207 -86
  3. package/lib/adapters/agent-instructions.js +1 -1
  4. package/lib/adapters/file-collection.js +1 -1
  5. package/lib/adapters/index.d.ts +1 -1
  6. package/lib/adapters/index.js +1 -1
  7. package/lib/adapters/link-report.d.ts +1 -0
  8. package/lib/adapters/link-report.js +27 -3
  9. package/lib/adapters/messages.d.ts +18 -0
  10. package/lib/adapters/messages.js +50 -0
  11. package/lib/adapters/plugin-files.js +3 -2
  12. package/lib/adapters/plugins.js +4 -3
  13. package/lib/adapters/prompts.d.ts +1 -0
  14. package/lib/adapters/prompts.js +7 -1
  15. package/lib/adapters/self.d.ts +7 -1
  16. package/lib/adapters/self.js +1 -1
  17. package/lib/adapters/sessions.d.ts +1 -0
  18. package/lib/adapters/sessions.js +4 -2
  19. package/lib/adapters/ui.d.ts +2 -2
  20. package/lib/adapters/ui.js +25 -25
  21. package/lib/adapters/workspaces.d.ts +1 -0
  22. package/lib/adapters/workspaces.js +7 -3
  23. package/lib/cli/actions.d.ts +9 -1
  24. package/lib/cli/actions.js +5 -2
  25. package/lib/cli/help.js +23 -10
  26. package/lib/cli/index.d.ts +23 -0
  27. package/lib/cli/index.js +90 -32
  28. package/lib/cli/sessions-inspect.js +3 -1
  29. package/lib/cli/sessions-repair.js +25 -6
  30. package/lib/cli/web/page.d.ts +12 -0
  31. package/lib/cli/web/page.js +21 -12
  32. package/lib/cli/web/routes.js +2 -2
  33. package/lib/cli/web/server.js +4 -4
  34. package/lib/client.d.ts +1349 -490
  35. package/lib/client.js +43047 -36738
  36. package/lib/core/backup-plan.js +30 -17
  37. package/lib/core/backup-verify.js +24 -23
  38. package/lib/core/backup.d.ts +5 -0
  39. package/lib/core/backup.js +51 -25
  40. package/lib/core/boot-rescue.js +4 -3
  41. package/lib/core/boot-safety.js +2 -1
  42. package/lib/core/command-rollback.d.ts +178 -0
  43. package/lib/core/command-rollback.js +289 -0
  44. package/lib/core/consult-source.js +7 -6
  45. package/lib/core/disk-usage.d.ts +74 -10
  46. package/lib/core/disk-usage.js +67 -4
  47. package/lib/core/engine-locale.d.ts +87 -0
  48. package/lib/core/engine-locale.js +125 -0
  49. package/lib/core/exporter.js +3 -3
  50. package/lib/core/fork-reconcile.d.ts +128 -0
  51. package/lib/core/fork-reconcile.js +150 -0
  52. package/lib/core/journal.js +6 -5
  53. package/lib/core/local-plugin-host.js +3 -2
  54. package/lib/core/local-plugin-pack.js +11 -6
  55. package/lib/core/messages-core.d.ts +292 -0
  56. package/lib/core/messages-core.js +615 -0
  57. package/lib/core/messages.d.ts +60 -2
  58. package/lib/core/messages.js +116 -13
  59. package/lib/core/migration-consult.js +21 -20
  60. package/lib/core/migration-history.js +19 -5
  61. package/lib/core/model-tools.d.ts +0 -22
  62. package/lib/core/model-tools.js +50 -27
  63. package/lib/core/phase3-host.js +30 -5
  64. package/lib/core/plugin-cli.js +18 -19
  65. package/lib/core/reconcile.d.ts +2 -14
  66. package/lib/core/reconcile.js +87 -17
  67. package/lib/core/recovery-orchestrator.js +19 -18
  68. package/lib/core/reinstall.d.ts +4 -0
  69. package/lib/core/reinstall.js +26 -21
  70. package/lib/core/restore.js +20 -17
  71. package/lib/core/rollback-ignore-rules.d.ts +31 -0
  72. package/lib/core/rollback-ignore-rules.js +140 -0
  73. package/lib/core/rollback-point.d.ts +1208 -0
  74. package/lib/core/rollback-point.js +2993 -0
  75. package/lib/core/run-registry.js +6 -5
  76. package/lib/core/session-health.d.ts +15 -0
  77. package/lib/core/session-meta.d.ts +42 -2
  78. package/lib/core/session-meta.js +140 -1
  79. package/lib/core/session-title.d.ts +28 -0
  80. package/lib/core/session-title.js +185 -0
  81. package/lib/core/startup-barrier.d.ts +24 -0
  82. package/lib/core/startup-barrier.js +22 -0
  83. package/lib/core/transaction-coordinator.js +4 -3
  84. package/lib/core/types.d.ts +18 -1
  85. package/lib/core/validator.js +5 -4
  86. package/lib/core/verify-recovery.js +31 -30
  87. package/lib/foreign/bundle.js +3 -2
  88. package/lib/foreign/continue.js +2 -1
  89. package/lib/foreign/crush.js +2 -1
  90. package/lib/foreign/gemini.js +2 -1
  91. package/lib/foreign/goose.js +2 -1
  92. package/lib/foreign/kilocode.js +2 -1
  93. package/lib/foreign/kimi.js +2 -1
  94. package/lib/foreign/messages.d.ts +15 -0
  95. package/lib/foreign/messages.js +40 -0
  96. package/lib/foreign/mimocode.js +2 -1
  97. package/lib/foreign/openclaw.js +2 -1
  98. package/lib/foreign/opencode.js +2 -1
  99. package/lib/foreign/pi.js +2 -1
  100. package/lib/foreign/qoder.js +2 -1
  101. package/lib/foreign/qwen.js +2 -1
  102. package/lib/foreign/registry.js +7 -6
  103. package/lib/foreign/teleagent.js +2 -1
  104. package/lib/foreign/workbuddy.js +2 -1
  105. package/lib/foreign/zcode.js +2 -1
  106. package/lib/foreign/zed.js +2 -1
  107. package/lib/index.d.ts +223 -7
  108. package/lib/index.js +729 -54
  109. package/lib/market/git-file-writer.js +20 -19
  110. package/lib/market/github-repos.js +35 -19
  111. package/lib/market/index-parser.js +31 -30
  112. package/lib/market/messages-market.d.ts +138 -0
  113. package/lib/market/messages-market.js +305 -0
  114. package/lib/market/my-repo.d.ts +2 -2
  115. package/lib/market/my-repo.js +32 -29
  116. package/lib/market/prepare.js +18 -15
  117. package/lib/market/security.js +20 -19
  118. package/lib/market/types.js +2 -1
  119. package/lib/market/url.js +2 -1
  120. package/lib/migrations/index.js +6 -5
  121. package/lib/migrations/messages-migrations.d.ts +32 -0
  122. package/lib/migrations/messages-migrations.js +58 -0
  123. package/lib/profiles/dsh-profile-launcher.js +2 -1
  124. package/lib/profiles/dsh-profile-runtime.js +2 -1
  125. package/lib/profiles/messages.d.ts +7 -0
  126. package/lib/profiles/messages.js +27 -0
  127. package/lib/routes/backup.js +18 -2
  128. package/lib/routes/import.js +3 -3
  129. package/lib/routes/index.d.ts +1 -1
  130. package/lib/routes/kit.d.ts +3 -2
  131. package/lib/routes/kit.js +6 -1
  132. package/lib/routes/market.js +1 -1
  133. package/lib/routes/messages.d.ts +42 -0
  134. package/lib/routes/messages.js +100 -0
  135. package/lib/routes/profiles.js +12 -6
  136. package/lib/routes/recovery.js +378 -114
  137. package/lib/routes/snapshots.js +51 -4
  138. package/lib/routes/sync.js +68 -16
  139. package/lib/schema/config.d.ts +11 -1
  140. package/lib/schema/config.js +52 -20
  141. package/lib/schema/index.d.ts +8 -6
  142. package/lib/schema/index.js +8 -6
  143. package/lib/schema/manifest.js +19 -18
  144. package/lib/schema/messages.d.ts +47 -0
  145. package/lib/schema/messages.js +110 -0
  146. package/lib/schema/section-registry.d.ts +41 -14
  147. package/lib/schema/section-registry.js +35 -29
  148. package/lib/schema/versions.js +6 -5
  149. package/lib/security/encryption.js +16 -15
  150. package/lib/security/integrity.js +8 -7
  151. package/lib/security/messages.d.ts +30 -0
  152. package/lib/security/messages.js +77 -0
  153. package/lib/security/redaction.js +12 -0
  154. package/lib/security/secret-scanner.js +16 -13
  155. package/lib/security/vault.js +2 -1
  156. package/lib/security/zip-security.js +4 -3
  157. package/lib/sync/ancestor.js +2 -1
  158. package/lib/sync/autosync-config.d.ts +11 -0
  159. package/lib/sync/autosync-config.js +12 -0
  160. package/lib/sync/autosync-scheduler.d.ts +11 -1
  161. package/lib/sync/autosync-scheduler.js +115 -76
  162. package/lib/sync/backup-files.js +3 -2
  163. package/lib/sync/backup-scheduler.js +6 -5
  164. package/lib/sync/blob-store.js +2 -1
  165. package/lib/sync/git/git-transport.d.ts +55 -0
  166. package/lib/sync/git/git-transport.js +152 -2
  167. package/lib/sync/github-auth.js +10 -9
  168. package/lib/sync/layout.js +13 -12
  169. package/lib/sync/messages.d.ts +63 -0
  170. package/lib/sync/messages.js +149 -0
  171. package/lib/sync/review-queue.js +6 -5
  172. package/lib/sync/s3/messages.d.ts +7 -0
  173. package/lib/sync/s3/messages.js +16 -0
  174. package/lib/sync/s3/s3-providers.d.ts +4 -1
  175. package/lib/sync/s3/s3-providers.js +5 -5
  176. package/lib/sync/s3/s3-transport.js +1 -1
  177. package/lib/sync/s3/sigv4.js +2 -1
  178. package/lib/sync/snapshot-crypto.js +6 -5
  179. package/lib/sync/snapshot-json.js +3 -2
  180. package/lib/sync/sync-engine.d.ts +37 -15
  181. package/lib/sync/sync-engine.js +67 -37
  182. package/lib/sync/sync-history.js +7 -6
  183. package/lib/sync/sync-selection.d.ts +15 -4
  184. package/lib/sync/sync-selection.js +17 -5
  185. package/lib/sync/webdav/webdav-transport.d.ts +1 -0
  186. package/lib/sync/webdav/webdav-transport.js +4 -1
  187. package/lib/ui/artifact-view.js +4 -3
  188. package/lib/ui/commands.d.ts +1 -1
  189. package/lib/ui/commands.js +10 -0
  190. package/lib/ui/disk-usage-view.d.ts +33 -4
  191. package/lib/ui/disk-usage-view.js +29 -1
  192. package/lib/ui/errors.d.ts +1 -1
  193. package/lib/ui/errors.js +4 -3
  194. package/lib/ui/export-flow.js +24 -20
  195. package/lib/ui/foreign-view.d.ts +5 -1
  196. package/lib/ui/foreign-view.js +26 -2
  197. package/lib/ui/i18n.d.ts +65 -0
  198. package/lib/ui/i18n.js +114 -0
  199. package/lib/ui/import-wizard.d.ts +7 -0
  200. package/lib/ui/import-wizard.js +13 -10
  201. package/lib/ui/progress.d.ts +2 -2
  202. package/lib/ui/progress.js +1 -2
  203. package/lib/ui/report.d.ts +5 -5
  204. package/lib/ui/report.js +6 -6
  205. package/lib/ui/rollback-view.d.ts +756 -0
  206. package/lib/ui/rollback-view.js +724 -0
  207. package/lib/ui/selection-model.d.ts +41 -0
  208. package/lib/ui/selection-model.js +30 -0
  209. package/lib/ui/session-inventory-view.d.ts +97 -3
  210. package/lib/ui/session-inventory-view.js +76 -2
  211. package/lib/ui/session-issue-labels.d.ts +43 -0
  212. package/lib/ui/session-issue-labels.js +48 -0
  213. package/lib/ui/sync-settings-view.d.ts +33 -0
  214. package/lib/ui/sync-settings-view.js +40 -0
  215. package/lib/ui/types.d.ts +8 -3
  216. package/lib/ui/types.js +1 -1
  217. package/lib/utils/atomic-write.js +6 -5
  218. package/lib/utils/bundle-scan.d.ts +11 -1
  219. package/lib/utils/bundle-scan.js +12 -1
  220. package/lib/utils/env-lock.d.ts +10 -0
  221. package/lib/utils/env-lock.js +73 -46
  222. package/lib/utils/json.js +6 -5
  223. package/lib/utils/messages-utils.d.ts +96 -0
  224. package/lib/utils/messages-utils.js +216 -0
  225. package/lib/utils/proxy.js +18 -5
  226. package/lib/utils/recursive-walk.d.ts +22 -0
  227. package/lib/utils/recursive-walk.js +75 -1
  228. package/lib/utils/session-log-repair.js +10 -9
  229. package/lib/utils/zip.js +27 -26
  230. package/package.json +12 -2
  231. package/src/adapters/agent-instructions.ts +1 -1
  232. package/src/adapters/file-collection.test.ts +1 -0
  233. package/src/adapters/file-collection.ts +1 -1
  234. package/src/adapters/files.test.ts +27 -0
  235. package/src/adapters/index.ts +1 -1
  236. package/src/adapters/link-report.test.ts +22 -2
  237. package/src/adapters/link-report.ts +29 -4
  238. package/src/adapters/messages.ts +54 -0
  239. package/src/adapters/plugin-files.ts +3 -2
  240. package/src/adapters/plugins.ts +4 -3
  241. package/src/adapters/prompts.ts +7 -1
  242. package/src/adapters/self.test.ts +26 -0
  243. package/src/adapters/self.ts +8 -2
  244. package/src/adapters/sessions.ts +4 -2
  245. package/src/adapters/settings.test.ts +38 -1
  246. package/src/adapters/ui.test.ts +2 -2
  247. package/src/adapters/ui.ts +27 -26
  248. package/src/adapters/workspaces.ts +7 -3
  249. package/src/cli/actions.test.ts +28 -0
  250. package/src/cli/actions.ts +11 -2
  251. package/src/cli/help.ts +23 -10
  252. package/src/cli/index-guards.test.ts +193 -0
  253. package/src/cli/index.ts +106 -31
  254. package/src/cli/sessions-inspect.test.ts +15 -1
  255. package/src/cli/sessions-inspect.ts +3 -1
  256. package/src/cli/sessions-repair.test.ts +84 -0
  257. package/src/cli/sessions-repair.ts +33 -7
  258. package/src/cli/web/console-route-parity.test.ts +115 -0
  259. package/src/cli/web/page.ts +23 -12
  260. package/src/cli/web/routes.ts +2 -1
  261. package/src/cli/web/server.ts +4 -4
  262. package/src/cli/web/web.test.ts +2 -2
  263. package/src/client/ConfigManagerSection.tsx +30 -2
  264. package/src/client/about/AboutPanel.tsx +2 -2
  265. package/src/client/about/PluginUpdateDialog.tsx +1 -1
  266. package/src/client/about/ReleaseNotesDialog.tsx +2 -2
  267. package/src/client/about/about-view.test.ts +13 -3
  268. package/src/client/about/about-view.ts +10 -6
  269. package/src/client/about/release-notes-view.test.ts +14 -4
  270. package/src/client/about/release-notes-view.ts +4 -2
  271. package/src/client/api.ts +18 -6
  272. package/src/client/chat/ChatEntryView.tsx +391 -0
  273. package/src/client/chat/RollbackDiffTab.tsx +61 -0
  274. package/src/client/chat/RollbackFileIcon.tsx +96 -0
  275. package/src/client/chat/RollbackReviewDialog.tsx +591 -0
  276. package/src/client/chat/chat-entry-view.test.ts +1174 -0
  277. package/src/client/chat/chat-entry-view.ts +758 -0
  278. package/src/client/chat/chat-locales.ts +152 -0
  279. package/src/client/chat/chat-node-registration.ts +64 -0
  280. package/src/client/chat/chat-point-window.ts +173 -0
  281. package/src/client/chat/chat-slots.ts +107 -0
  282. package/src/client/chat/rollback-diff-tab.test.ts +105 -0
  283. package/src/client/chat/rollback-diff-tab.ts +164 -0
  284. package/src/client/chat/rollback-entry-view.test.ts +1469 -0
  285. package/src/client/chat/rollback-entry-view.ts +941 -0
  286. package/src/client/chat/sidebar-slots.ts +37 -0
  287. package/src/client/common/ConfirmDialog.tsx +7 -3
  288. package/src/client/common/ContentPicker.tsx +48 -10
  289. package/src/client/common/DiffTable.tsx +97 -0
  290. package/src/client/common/ErrorBanner.tsx +4 -4
  291. package/src/client/common/Icon.tsx +2 -0
  292. package/src/client/common/Modal.tsx +8 -1
  293. package/src/client/common/ProgressBar.tsx +8 -2
  294. package/src/client/common/ReportView.tsx +4 -4
  295. package/src/client/common/RunsCenter.tsx +1 -0
  296. package/src/client/common/client-route-parity.test.ts +78 -0
  297. package/src/client/common/info-hint-guard.test.ts +46 -13
  298. package/src/client/common/picker-lazy-units.test.ts +42 -0
  299. package/src/client/common/plan-text-redaction.test.ts +17 -6
  300. package/src/client/common/progress-view-edge.test.ts +8 -7
  301. package/src/client/common/progress-view.test.ts +9 -9
  302. package/src/client/common/progress-view.ts +2 -2
  303. package/src/client/common/routes.ts +28 -0
  304. package/src/client/common/ui.tsx +73 -10
  305. package/src/client/config-manager.module.css +996 -21
  306. package/src/client/css-scale-guard.test.ts +30 -0
  307. package/src/client/environment/EnvironmentPanel.tsx +11 -4
  308. package/src/client/environment/maintenance/DiskUsageCard.tsx +23 -2
  309. package/src/client/export/ExportView.tsx +13 -8
  310. package/src/client/history/HistoryPanel.tsx +4 -3
  311. package/src/client/history/history-api.ts +2 -2
  312. package/src/client/history/history-locales.ts +5 -1
  313. package/src/client/home/HomePanel.tsx +29 -8
  314. package/src/client/import/ForeignImportView.tsx +4 -5
  315. package/src/client/import/ImportWizardView.tsx +5 -3
  316. package/src/client/import/import-wizard-split.test.ts +3 -1
  317. package/src/client/import/import-wizard-steps.tsx +9 -9
  318. package/src/client/index.ts +186 -3
  319. package/src/client/library/LibraryActions.tsx +32 -7
  320. package/src/client/library/LibraryPanel.tsx +23 -3
  321. package/src/client/library/RestorePlanView.tsx +9 -66
  322. package/src/client/locales.ts +54 -5
  323. package/src/client/lucide-icons.d.ts +1 -0
  324. package/src/client/market/MarketPanel.tsx +13 -2
  325. package/src/client/market/MyConfigsView.tsx +5 -4
  326. package/src/client/market/MyConfigsWizard.tsx +10 -8
  327. package/src/client/market/market-api.test.ts +9 -8
  328. package/src/client/market/market-api.ts +2 -2
  329. package/src/client/market/market-locales.ts +3 -0
  330. package/src/client/market/market-view.test.ts +6 -6
  331. package/src/client/market/market-view.ts +3 -3
  332. package/src/client/market/my-configs-api.ts +2 -2
  333. package/src/client/market/my-configs-view.test.ts +23 -22
  334. package/src/client/market/my-configs-view.ts +8 -8
  335. package/src/client/recovery/IncompleteCopiesSection.tsx +2 -1
  336. package/src/client/recovery/RecoveryPanel.tsx +442 -283
  337. package/src/client/recovery/RollbackCard.tsx +665 -0
  338. package/src/client/recovery/RollbackDiffPane.tsx +58 -0
  339. package/src/client/recovery/RollbackFileChanges.tsx +163 -0
  340. package/src/client/recovery/incident-api.ts +2 -2
  341. package/src/client/recovery/recovery-api.ts +116 -6
  342. package/src/client/recovery/recovery-locales.test.ts +299 -0
  343. package/src/client/recovery/recovery-locales.ts +488 -149
  344. package/src/client/recovery/recovery-panel-layout.test.ts +132 -2
  345. package/src/client/recovery/rollback-file-changes.test.ts +73 -0
  346. package/src/client/recovery/session-dialog-mount.test.ts +54 -0
  347. package/src/client/run-store.ts +84 -21
  348. package/src/client/sync/AutosyncCard.tsx +25 -1
  349. package/src/client/sync/ChannelClearConfirmDialog.tsx +6 -3
  350. package/src/client/sync/ChannelConfigDialog.tsx +10 -10
  351. package/src/client/sync/DecryptPasswordCard.tsx +2 -2
  352. package/src/client/sync/SecurityOptionsCard.tsx +27 -17
  353. package/src/client/sync/SyncChannelEntryCard.tsx +14 -2
  354. package/src/client/sync/SyncConfirmView.tsx +46 -8
  355. package/src/client/sync/SyncLogList.tsx +1 -1
  356. package/src/client/sync/SyncPanel.tsx +31 -23
  357. package/src/client/sync/SyncRepositoryPicker.tsx +3 -5
  358. package/src/client/sync/SyncSectionPickerDialog.tsx +11 -2
  359. package/src/client/sync/SyncSettingsView.test.ts +20 -1
  360. package/src/client/sync/UnreadableSnapshotsBanner.tsx +1 -1
  361. package/src/client/sync/WorkDirCard.tsx +112 -0
  362. package/src/client/sync/history-model.test.ts +42 -9
  363. package/src/client/sync/history-model.ts +30 -15
  364. package/src/client/sync/sync-api.test.ts +57 -33
  365. package/src/client/sync/sync-api.ts +80 -6
  366. package/src/client/sync/sync-locales.ts +104 -3
  367. package/src/client/sync/sync-view-v2.test.ts +65 -13
  368. package/src/client/sync/sync-view.test.ts +107 -52
  369. package/src/client/sync/sync-view.ts +124 -41
  370. package/src/core/backup-plan.test.ts +28 -0
  371. package/src/core/backup-plan.ts +29 -17
  372. package/src/core/backup-verify.ts +24 -23
  373. package/src/core/backup.test.ts +76 -0
  374. package/src/core/backup.ts +55 -25
  375. package/src/core/boot-rescue.ts +4 -3
  376. package/src/core/boot-safety.ts +2 -1
  377. package/src/core/command-rollback.test.ts +536 -0
  378. package/src/core/command-rollback.ts +402 -0
  379. package/src/core/consult-source.ts +7 -6
  380. package/src/core/disk-usage.test.ts +257 -1
  381. package/src/core/disk-usage.ts +134 -14
  382. package/src/core/engine-locale.test.ts +593 -0
  383. package/src/core/engine-locale.ts +175 -0
  384. package/src/core/exporter.ts +3 -3
  385. package/src/core/fork-reconcile.test.ts +279 -0
  386. package/src/core/fork-reconcile.ts +234 -0
  387. package/src/core/journal.ts +6 -5
  388. package/src/core/local-plugin-host.ts +3 -2
  389. package/src/core/local-plugin-pack.ts +11 -6
  390. package/src/core/messages-core.ts +657 -0
  391. package/src/core/messages.ts +123 -17
  392. package/src/core/migration-consult.ts +21 -20
  393. package/src/core/migration-history.ts +19 -5
  394. package/src/core/model-tools.ts +28 -27
  395. package/src/core/phase3-host.ts +21 -5
  396. package/src/core/phase3-p2b-fingerprint.test.ts +22 -0
  397. package/src/core/plugin-cli.ts +18 -19
  398. package/src/core/reconcile.test.ts +117 -0
  399. package/src/core/reconcile.ts +85 -17
  400. package/src/core/recovery-orchestrator.ts +19 -18
  401. package/src/core/reinstall.ts +26 -21
  402. package/src/core/restore.ts +20 -17
  403. package/src/core/rollback-file-diff.test.ts +159 -0
  404. package/src/core/rollback-ignore-rules.ts +126 -0
  405. package/src/core/rollback-point.test.ts +2603 -0
  406. package/src/core/rollback-point.ts +3755 -0
  407. package/src/core/run-registry.ts +6 -5
  408. package/src/core/session-health.ts +15 -0
  409. package/src/core/session-meta.test.ts +117 -1
  410. package/src/core/session-meta.ts +149 -2
  411. package/src/core/session-title.test.ts +127 -0
  412. package/src/core/session-title.ts +189 -0
  413. package/src/core/startup-barrier.test.ts +69 -0
  414. package/src/core/startup-barrier.ts +27 -0
  415. package/src/core/transaction-coordinator.ts +4 -3
  416. package/src/core/types.ts +18 -1
  417. package/src/core/validator.ts +5 -4
  418. package/src/core/verify-recovery.ts +31 -30
  419. package/src/foreign/bundle.ts +3 -2
  420. package/src/foreign/continue.ts +2 -1
  421. package/src/foreign/crush.ts +2 -1
  422. package/src/foreign/file-budget.test.ts +2 -0
  423. package/src/foreign/gemini.ts +2 -1
  424. package/src/foreign/goose.ts +2 -1
  425. package/src/foreign/kilocode.ts +2 -1
  426. package/src/foreign/kimi.ts +2 -1
  427. package/src/foreign/messages.ts +44 -0
  428. package/src/foreign/mimocode.ts +2 -1
  429. package/src/foreign/openclaw.ts +2 -1
  430. package/src/foreign/opencode.ts +2 -1
  431. package/src/foreign/pi.ts +2 -1
  432. package/src/foreign/qoder.ts +2 -1
  433. package/src/foreign/qwen.ts +2 -1
  434. package/src/foreign/registry.ts +7 -6
  435. package/src/foreign/teleagent.ts +2 -1
  436. package/src/foreign/workbuddy.ts +2 -1
  437. package/src/foreign/zcode.ts +2 -1
  438. package/src/foreign/zed.ts +2 -1
  439. package/src/index.facade.test.ts +340 -1
  440. package/src/index.ts +813 -57
  441. package/src/market/git-file-writer.ts +20 -19
  442. package/src/market/github-repos.test.ts +23 -0
  443. package/src/market/github-repos.ts +37 -19
  444. package/src/market/index-parser.ts +31 -30
  445. package/src/market/messages-market.ts +325 -0
  446. package/src/market/my-repo.ts +32 -29
  447. package/src/market/prepare.ts +18 -15
  448. package/src/market/security.ts +20 -19
  449. package/src/market/types.ts +2 -1
  450. package/src/market/url.ts +2 -1
  451. package/src/migrations/index.ts +6 -5
  452. package/src/migrations/messages-migrations.ts +59 -0
  453. package/src/profiles/dsh-profile-launcher.ts +2 -1
  454. package/src/profiles/dsh-profile-runtime.ts +2 -1
  455. package/src/profiles/messages.ts +31 -0
  456. package/src/routes/backup.ts +18 -2
  457. package/src/routes/import.ts +3 -3
  458. package/src/routes/index.ts +1 -1
  459. package/src/routes/kit.ts +9 -3
  460. package/src/routes/market.ts +1 -1
  461. package/src/routes/messages.ts +104 -0
  462. package/src/routes/profiles.ts +12 -6
  463. package/src/routes/recovery.test.ts +405 -1
  464. package/src/routes/recovery.ts +377 -109
  465. package/src/routes/snapshots.ts +51 -4
  466. package/src/routes/sync.ts +67 -16
  467. package/src/schema/config.ts +53 -20
  468. package/src/schema/index.ts +7 -5
  469. package/src/schema/manifest.ts +19 -18
  470. package/src/schema/messages.ts +120 -0
  471. package/src/schema/registry.test.ts +19 -5
  472. package/src/schema/section-registry.ts +64 -39
  473. package/src/schema/versions.ts +6 -5
  474. package/src/security/encryption.ts +16 -15
  475. package/src/security/integrity.ts +8 -7
  476. package/src/security/messages.ts +87 -0
  477. package/src/security/redaction.test.ts +17 -1
  478. package/src/security/redaction.ts +13 -0
  479. package/src/security/secret-scanner.ts +19 -12
  480. package/src/security/security.test.ts +47 -0
  481. package/src/security/vault.ts +2 -1
  482. package/src/security/zip-security.ts +4 -3
  483. package/src/sync/ancestor.ts +2 -1
  484. package/src/sync/autosync-config.ts +21 -0
  485. package/src/sync/autosync-scheduler.test.ts +63 -7
  486. package/src/sync/autosync-scheduler.ts +118 -78
  487. package/src/sync/backup-files.ts +3 -2
  488. package/src/sync/backup-scheduler.ts +6 -5
  489. package/src/sync/blob-store.ts +2 -1
  490. package/src/sync/git/git-transport.test.ts +170 -8
  491. package/src/sync/git/git-transport.ts +176 -2
  492. package/src/sync/github-auth.ts +10 -9
  493. package/src/sync/layout.ts +13 -12
  494. package/src/sync/messages.ts +153 -0
  495. package/src/sync/review-queue.ts +6 -5
  496. package/src/sync/s3/messages.ts +16 -0
  497. package/src/sync/s3/s3-providers.ts +9 -6
  498. package/src/sync/s3/s3-transport.ts +1 -1
  499. package/src/sync/s3/sigv4.ts +3 -1
  500. package/src/sync/snapshot-crypto.ts +6 -5
  501. package/src/sync/snapshot-json.ts +3 -2
  502. package/src/sync/sync-engine-download-tombstone.test.ts +1 -1
  503. package/src/sync/sync-engine.test.ts +226 -8
  504. package/src/sync/sync-engine.ts +103 -41
  505. package/src/sync/sync-history.ts +7 -6
  506. package/src/sync/sync-selection.test.ts +12 -2
  507. package/src/sync/sync-selection.ts +18 -5
  508. package/src/sync/webdav/webdav-transport.ts +4 -1
  509. package/src/ui/artifact-view.ts +4 -3
  510. package/src/ui/commands.test.ts +1 -0
  511. package/src/ui/commands.ts +11 -1
  512. package/src/ui/disk-usage-view.test.ts +113 -6
  513. package/src/ui/disk-usage-view.ts +63 -7
  514. package/src/ui/errors.ts +4 -3
  515. package/src/ui/export-flow.ts +24 -20
  516. package/src/ui/foreign-view.test.ts +43 -0
  517. package/src/ui/foreign-view.ts +30 -3
  518. package/src/ui/i18n.ts +125 -0
  519. package/src/ui/import-wizard.ts +18 -10
  520. package/src/ui/progress.ts +2 -2
  521. package/src/ui/report.ts +7 -6
  522. package/src/ui/rollback-view.test.ts +819 -0
  523. package/src/ui/rollback-view.ts +1339 -0
  524. package/src/ui/selection-model.test.ts +33 -1
  525. package/src/ui/selection-model.ts +53 -0
  526. package/src/ui/session-inventory-view.test.ts +109 -0
  527. package/src/ui/session-inventory-view.ts +138 -3
  528. package/src/ui/session-issue-labels.test.ts +51 -0
  529. package/src/ui/session-issue-labels.ts +70 -0
  530. package/src/ui/sync-settings-view.test.ts +36 -0
  531. package/src/ui/sync-settings-view.ts +49 -0
  532. package/src/ui/types.ts +10 -4
  533. package/src/utils/atomic-write.ts +6 -5
  534. package/src/utils/bundle-scan.test.ts +2 -2
  535. package/src/utils/bundle-scan.ts +12 -1
  536. package/src/utils/env-lock.ts +83 -49
  537. package/src/utils/json.ts +7 -5
  538. package/src/utils/messages-utils.ts +232 -0
  539. package/src/utils/proxy.test.ts +25 -0
  540. package/src/utils/proxy.ts +19 -5
  541. package/src/utils/recursive-walk.test.ts +43 -0
  542. package/src/utils/recursive-walk.ts +75 -1
  543. package/src/utils/session-log-repair.ts +10 -9
  544. package/src/utils/zip.ts +27 -26
  545. package/lib/core/checkpoint.d.ts +0 -362
  546. package/lib/core/checkpoint.js +0 -1309
  547. package/src/core/checkpoint.test.ts +0 -665
  548. package/src/core/checkpoint.ts +0 -1614
package/src/index.ts CHANGED
@@ -75,7 +75,13 @@ import { createRecoveryOrchestrator, type RecoveryExecutorFns } from './core/rec
75
75
  import { JournalStore } from './core/journal.ts'
76
76
  import { RunRegistry, type RunState } from './core/run-registry.ts'
77
77
  import { registerModelTools } from './core/model-tools.ts'
78
- import { makeMsg, msgOf, zhMsg } from './core/messages.ts'
78
+ import { msgOf, zhMsg } from './core/messages.ts'
79
+ import { engineMsg, getEngineLocale, setEngineLanguageSource, setEngineLocale } from './core/engine-locale.ts'
80
+ import { RollbackAutoTracker, RollbackPointEngine, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.ts'
81
+ import type { RollbackForkCarrier, RollbackRewindResult } from './core/rollback-point.ts'
82
+ // t3:宿主斜杠命令 /rollback(与 HTTP 路由同一道写入门;命令服务惰取,未组合时安静跳过)。
83
+ import { agentSessionFactsOf, registerRollbackCommand } from './core/command-rollback.ts'
84
+ import type { CommandServiceLike, RollbackCommandHost } from './core/command-rollback.ts'
79
85
  import type { MsgFunc } from './core/messages.ts'
80
86
  import { cleanupAbortedInstall, hasDshBundlePatch, installAnchorFromProfileContext, installErrorFor, installSpecFor, listInstalledPlugins, profileNameFromProfileContext, resolveProcessProfileName, resolveProfileDir, readProfileManifest, runDshPlugin, validateProfileName } from './core/plugin-cli.ts'
81
87
  import type { ConfigAdapter, CredentialsFacade, ExportUnit, FileSystemFacade, HostContext, ImportDecisions, ImportPlan, NamedNamespaceInfo, NamespaceInfo, PatchFileFacade, PlanItemKind, PluginInfo, PluginsFacade, SessionMoveResult, SessionParentRelation, SessionRewriteResult, SessionStoreFacade, SettingsFacade, SkillDefinitionView, SkillStoreFacade, SkillSummaryView, WorkspaceFacade } from './core/types.ts'
@@ -89,7 +95,8 @@ import { createLocalPluginPackHook } from './core/local-plugin-host.ts'
89
95
  import { ENCRYPTED_CONTAINER_CODE, createEncryptionProvider, decryptCredentials, readContainerKind, SecurityError, encryptArchive } from './security/index.ts'
90
96
  import { createHardenedZipParser } from './security/zip-security.ts'
91
97
  import { collectCredentialRefs } from './security/credentials-yaml.ts'
92
- import { applySessionMeta, applySessionMetaToPlanItems, applySessionParentLinks, readSessionMeta, subagentParentMap } from './core/session-meta.ts'
98
+ import { applySessionMeta, applySessionMetaToPlanItems, applySessionParentLinks, readSessionMeta, readSessionMetaWithTitles, subagentParentMap } from './core/session-meta.ts'
99
+ import type { SessionMetaIndex } from './core/session-meta.ts'
93
100
  import { projectKeyOf, sessionIdKey } from './core/session-select.ts'
94
101
  import { PROJECT_KEY_RE, readLogCwdFromBytes, rewriteSessionLogDir } from './utils/session-log.ts'
95
102
  import { probeSessionFormats, resolveSessionFormatVersion } from './utils/session-format.ts'
@@ -101,10 +108,11 @@ import { activeProxySummary } from './utils/proxy.ts'
101
108
  import { listRecursiveFollowingLinks } from './utils/recursive-walk.ts'
102
109
  import { Phase3Recovery, TransactionRecoveryRequiredError, mapLockStateForStartup } from './core/phase3-host.ts'
103
110
  import type { JournalRunContext } from './core/phase3-host.ts'
104
- import { classifyStartup, FAIL_CLOSED_STARTUP } from './core/startup-barrier.ts'
111
+ import { classifyStartup, FAIL_CLOSED_STARTUP, resolveStartupSafeMode } from './core/startup-barrier.ts'
105
112
  import type { MutationLockPort } from './utils/env-lock.ts'
106
113
  import type { RecursiveListing } from './utils/recursive-walk.ts'
107
114
  import { GitTransport } from './sync/git/git-transport.ts'
115
+ import type { GitWorkDirFacts } from './sync/git/git-transport.ts'
108
116
  import { WebDavTransport } from './sync/webdav/webdav-transport.ts'
109
117
  import { S3Transport } from './sync/s3/s3-transport.ts'
110
118
  import { GistTransport } from './sync/gist/gist-transport.ts'
@@ -131,7 +139,7 @@ import {
131
139
  } from './routes/session-format.ts'
132
140
  import type { SessionFormatDisposition } from './core/types.ts'
133
141
  import type { SyncConfig, SyncTransportType } from './sync/sync-config.ts'
134
- import { defaultSyncSelection, effectiveSections, normalizeSessionsInclude, OPT_IN_SYNC_SECTIONS, readAllSyncSelections, readSyncSelection } from './sync/sync-selection.ts'
142
+ import { AUTO_SYNC_OPT_IN_SECTIONS, defaultSyncSelection, effectiveSections, normalizeSessionsInclude, OPT_IN_SYNC_SECTIONS, readAllSyncSelections, readSyncSelection } from './sync/sync-selection.ts'
135
143
  import type { SyncSelection, SyncSelectionMode } from './sync/sync-selection.ts'
136
144
  import type { SyncTransport } from './sync/transport.ts'
137
145
  import { GitMarketReader } from './market/reader.ts'
@@ -166,7 +174,7 @@ export const name = 'config-manager'
166
174
  export const inject = ['settings', 'credentials']
167
175
 
168
176
  /** Plugin version, kept in sync with package.json ("version"). */
169
- export const PLUGIN_VERSION = '0.1.71'
177
+ export const PLUGIN_VERSION = '0.1.72'
170
178
 
171
179
  /** Plugin own package name — excluded from its own exported plugins list. */
172
180
  const PLUGIN_NAME = 'dsh-config-manager'
@@ -231,7 +239,7 @@ export interface Config {
231
239
  /**
232
240
  * 本文件保留的 7 条路由的路径(被源码级守卫按文件窗口钉住,见下方 routesList 注释)。
233
241
  *
234
- * 其余 71 条路由的路径**声明在各自的组文件里**(src/routes/*.ts 的 endpoint({ path, methods }))——
242
+ * 其余 72 条路由的路径**声明在各自的组文件里**(src/routes/*.ts 的 endpoint({ path, methods }))——
235
243
  * 新增一条 API 只需那一条声明,不再有「常量表 + 路由对象」两处要同步。
236
244
  */
237
245
  export const API = {
@@ -815,6 +823,149 @@ const SESSION_STORE_PURPOSE_KEY: Record<SessionStorePurpose, string> = {
815
823
  */
816
824
  const PARENT_RELATIONS_CACHE_TTL_MS = 5_000
817
825
 
826
+ /**
827
+ * `ancestorSessionIds` 的上溯深度上限。
828
+ *
829
+ * 取 8:DSH 的子代理树实际深度是个位数(`delegationDepth`),8 层足够覆盖「子代理的子代理」;
830
+ * 上限 + 环保护一道,保证异常数据(自指 / 环)不会把回退路径卡死。
831
+ */
832
+ const ANCESTOR_LOOKUP_MAX_DEPTH = 8
833
+
834
+ /**
835
+ * 宿主会话管理面(`SessionController`)的结构窄化 —— 本仓 core 不 import `@deepseek-ai/*`。
836
+ *
837
+ * `fork(request)` = DSH 自己的「从某条事件前缀分叉」命令(内部 `SessionCommandController.fork`):
838
+ * 建 agent + 落盘 + 挂回原工作区一次到位,返回新子会话 id。**这是回退唯一可用的 fork 路径**
839
+ * (底层 `SessionStore.fork` 造的会话没有 agent、不落盘,见 forkSession 注释)。
840
+ */
841
+ interface SessionForkController {
842
+ fork?: (request: { sessionId: string; atSeq: number }) => Promise<unknown>
843
+ }
844
+
845
+ /** `ctx.get('workspaceRegistry')` 的结构窄化(本仓 core 不 import @deepseek-ai/*)。 */
846
+ interface WorkspaceRegistryLike {
847
+ list?: () => unknown
848
+ /** DSH 的「按规范目录路径解析工作区」:目录不存在时在 realpath 阶段拒绝;目录存在但无人认领返回 undefined。 */
849
+ resolveByPath?: (path: string) => Promise<unknown>
850
+ }
851
+
852
+ /** 归位成功的两种情形(都经 `list()` 复核过)。 */
853
+ export type WorkspaceAttachSuccessReason =
854
+ /** 官方 fork 已经归位(能按成员/血缘解析到工作区)——本兜底只补复核,不重复写。 */
855
+ | 'already-attached'
856
+ /** 本兜底真的写成功且复核通过。 */
857
+ | 'attached'
858
+
859
+ /**
860
+ * 归位失败的具体理由(机器码)。
861
+ *
862
+ * 为什么必须区分而不是一个笼统的 `false`:真机现场「回退后子会话进未分组」长期不可见,
863
+ * 根因之一就是 attach 失败被吞成布尔(无日志、无理由)。每一档都能单独定位:
864
+ * · registry-unavailable / registry-list-unavailable / registry-list-failed —— 服务缺失或形状不符;
865
+ * · attach-session-missing —— 工作区记录上**没有** `attachSession`(形状不符,未发起任何写);
866
+ * · attach-rejected —— `attachSession` 真的抛错(目录不存在 / cwd 对不上 / 域未就绪);
867
+ * · directory-missing —— `resolveByPath` 在 realpath 阶段拒绝,或回落档 realpath 失败(目录不存在);
868
+ * · no-workspace-for-cwd —— 目录存在但不属于任何已注册工作区;
869
+ * · cwd-unavailable —— 读不到子会话 cwd(绝不猜路径);
870
+ * · verify-failed —— 写调用没抛错,但 `list()` 复核不含该子会话(最终判据不通过,**不许报成功**);
871
+ * · unexpected-error —— 其它未预期异常。
872
+ */
873
+ export type WorkspaceAttachFailureReason =
874
+ | 'registry-unavailable'
875
+ | 'registry-list-unavailable'
876
+ | 'registry-list-failed'
877
+ | 'attach-session-missing'
878
+ | 'attach-rejected'
879
+ | 'directory-missing'
880
+ | 'no-workspace-for-cwd'
881
+ | 'cwd-unavailable'
882
+ | 'verify-failed'
883
+ | 'unexpected-error'
884
+
885
+ /** 归位失败(`detail` 是技术细节:errno / 路径 / 形状码,供日志与排查,不是用户文案)。 */
886
+ export interface WorkspaceAttachFailure {
887
+ ok: false
888
+ reason: WorkspaceAttachFailureReason
889
+ detail?: string
890
+ }
891
+
892
+ /** 一次 `attachSession` 尝试(只表达「写调用是否成功」,最终判据在 {@link DshSessionStoreFacade} 的复核里)。 */
893
+ type AttachAttempt = { ok: true } | { ok: false; reason: 'attach-session-missing' | 'attach-rejected'; detail?: string }
894
+
895
+ /** 一次工作区归位的结果(成功/失败都带**具体**理由)。 */
896
+ export type WorkspaceAttachOutcome =
897
+ | { ok: true; reason: WorkspaceAttachSuccessReason; workspaceId?: string }
898
+ | WorkspaceAttachFailure
899
+
900
+ /** 归位失败时给调用方的 warning 串(稳定前缀 + 具体理由码)。 */
901
+ export const WORKSPACE_ATTACH_FAILED_PREFIX = 'workspace-attach-failed'
902
+
903
+ /**
904
+ * DSH 官方 fork 在「子会话已创建、但 `workspace.attachSession` 失败」时抛的错误码。
905
+ *
906
+ * 依据 DSH 源码:`dsh-api-session-controller/lib/index.js` L829-836 抛
907
+ * `new RemoteError('session/workspace-attach-failed', …, { sessionId: childId, workspaceId })`。
908
+ */
909
+ export const WORKSPACE_ATTACH_FAILED_ERROR_CODE = 'session/workspace-attach-failed'
910
+
911
+ /**
912
+ * 从官方 fork 的 `session/workspace-attach-failed` 错误里取回**已经创建好的子会话 id**。
913
+ *
914
+ * 字段名**不是猜的** —— DSH 自己的客户端就是这么取的(同款姿势):
915
+ * `dsh-api-session-controller/lib/client.js` L3013-3015 的 `workspaceAttachSessionId(error)`:
916
+ * `error.code === 'session/workspace-attach-failed' ? error.details.sessionId : undefined`
917
+ * 错误类是 `dsh-typert-protocol/lib/index.js` L11-28 的 `RemoteError`(`code` + `details`;
918
+ * DSH 明文规定「判别只按 code,never by instanceof」,故这里也只按 `code`,不 import 那门包)。
919
+ *
920
+ * 形状不符(不是对象 / code 不是这个 / 没有 details.sessionId / 空串)一律 undefined ——
921
+ * 调用方据此**保持现状**报 fork-failed,绝不假装成功、也绝不猜别的字段名。
922
+ */
923
+ export function workspaceAttachFailureChildId(error: unknown): string | undefined {
924
+ if (error === null || typeof error !== 'object') return undefined
925
+ const code = (error as { code?: unknown }).code
926
+ if (code !== WORKSPACE_ATTACH_FAILED_ERROR_CODE) return undefined
927
+ const details = (error as { details?: unknown }).details
928
+ if (details === null || typeof details !== 'object') return undefined
929
+ const sessionId = (details as { sessionId?: unknown }).sessionId
930
+ return typeof sessionId === 'string' && sessionId !== '' ? sessionId : undefined
931
+ }
932
+
933
+ /**
934
+ * 归位失败 → warning 串;成功 → undefined。
935
+ *
936
+ * 为什么是「前缀:理由码」而不是新字段:`RollbackForkSeam`(core/types.ts,本任务 scope 外)的返回
937
+ * 形状只有 `{ childSessionId, warning? }`,warning 是**唯一**能把具体理由带到调用方的通道;
938
+ * core 只判「非空」并落成 `rollback.guidance.sessionNotAttached`(界面如实上屏),
939
+ * 前缀保持稳定以便既有 grep / 诊断脚本继续命中。
940
+ */
941
+ export function workspaceAttachWarningOf(outcome: WorkspaceAttachOutcome): string | undefined {
942
+ return outcome.ok ? undefined : WORKSPACE_ATTACH_FAILED_PREFIX + ':' + outcome.reason
943
+ }
944
+
945
+ /**
946
+ * 目录同一性比较用的折叠(**只在 `resolveByPath` 拿不到时的回落档**使用)。
947
+ *
948
+ * 分隔符统一为 `/`;win32 大小写不敏感(Windows 路径大小写等价)。
949
+ * 注意:本函数只比较**两侧都经 realpath 规范化**过的路径 —— 绝不拿「原样 cwd」与「规范 path」直接比,
950
+ * 那正是真机 2026-10-12 的根因(工作区 path 经 `realpathNormalize`、会话头 cwd 是原样字符串)。
951
+ */
952
+ export function foldWorkspacePath(value: string): string {
953
+ const unified = value.replace(/\\/g, '/')
954
+ return process.platform === 'win32' ? unified.toLowerCase() : unified
955
+ }
956
+
957
+ /** 工作区记录的稳定 id(形状不符 → undefined;只作诊断/日志,不参与判定)。 */
958
+ function workspaceRecordIdOf(record: Record<string, unknown>): string | undefined {
959
+ const id = record['id']
960
+ return typeof id === 'string' && id !== '' ? id : undefined
961
+ }
962
+
963
+ /** 该记录是否把某会话算作成员(`sessionIds` 非数组 = 形状不符,不算成员)。 */
964
+ function recordHasMember(record: Record<string, unknown>, sessionId: string): boolean {
965
+ const sessionIds = record['sessionIds']
966
+ return Array.isArray(sessionIds) && sessionIds.includes(sessionId)
967
+ }
968
+
818
969
  export class DshSessionStoreFacade implements SessionStoreFacade {
819
970
  private readonly ctx: Context
820
971
  /** DSH 缺省会话根(配置改过时只影响快路径命中率,慢路径仍正确) */
@@ -834,12 +985,373 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
834
985
  private readonly log: Logger
835
986
  /** 文案翻译器(F-1 的形状异常诊断走字典,禁止硬编码用户可见字符串) */
836
987
  private readonly msgFunc: MsgFunc
988
+ /**
989
+ * 目录规范化(t10):工作区归位第 ③ 档的**回落**比较用。
990
+ *
991
+ * 缺省 = `node:fs/promises` 的 `realpath`(与 DSH 的 `realpathNormalize` 同一套系统调用);
992
+ * 可注入是为了让「大小写/短名/分隔符不同」这条判定能被**确定性**单测覆盖(不依赖真实盘符/短名)。
993
+ */
994
+ private readonly realpath: (path: string) => Promise<string>
837
995
 
838
- constructor(ctx: Context, homeDir: string, msg: MsgFunc = zhMsg, log?: Logger) {
996
+ constructor(ctx: Context, homeDir: string, msg: MsgFunc = zhMsg, log?: Logger, realpath?: (path: string) => Promise<string>) {
839
997
  this.ctx = ctx
840
998
  this.root = join(homeDir, 'sessions')
841
999
  this.log = log ?? createLogger({ level: 'warn' })
842
1000
  this.msgFunc = msg
1001
+ this.realpath = realpath ?? (async (value: string): Promise<string> => await fs.realpath(value))
1002
+ }
1003
+
1004
+ /**
1005
+ * 回退到稳定边界的**子会话**(S3:工作区 + 会话回滚)。
1006
+ *
1007
+ * 走 **DSH 自己的 fork 路径**:`ctx.get('sessionController').fork({ sessionId, atSeq })`
1008
+ * (宿主 `SessionController.fork` → `SessionCommandController.fork`;惰取服务,**绝不**属性访问
1009
+ * `ctx.sessionController` —— 未声明 inject 时属性访问会抛错并致宿主启动崩溃)。
1010
+ *
1011
+ * 为什么**必须**走它、而不能直接调底层 `SessionStore.fork`(真机 2026-10-12 定案):
1012
+ * · 官方路径用 `ctx.agents.create(...)` 建子会话 ⇒ 子会话有 agent、**会落盘**、id 是 uuid;
1013
+ * 它**会尝试**挂回工作区,但只按「成员/血缘」两档解析(见 `attachChildToParentWorkspace` 的
1014
+ * 事实锁定)——解析不到就**不归位**;
1015
+ * · 底层 `SessionStore.fork` 只在内存 store 里造一条会话记录(id 还是进程内计数 `session-1`),
1016
+ * 没有 agent、没有持久化 ⇒ 用户回退后想继续对话时,宿主 `resolve → resume →
1017
+ * sessionQuery.observeSession` 在磁盘上找不到它,界面直接报
1018
+ * `resume failed for session "session-1": SessionPersistenceNotFoundError`(用户截图 2026-10-12)。
1019
+ *
1020
+ * 契约:**任何异常与任何形状不符都收成 `{ error }`**,调用方必须如实结构化降级
1021
+ * (`fork-failed` 等),绝不静默假装成功。
1022
+ *
1023
+ * **唯一的例外(t16):官方 fork 的 attach 失败不算 fork 失败。** 那时 `SessionCommandController.fork`
1024
+ * 已经建好子会话(uuid + 落盘),只是 `workspace.attachSession` 抛了错 —— 它把整件事报成
1025
+ * `session/workspace-attach-failed`(细节带 childId)。旧实现把它整体收成 `{ error }` ⇒ 引擎报
1026
+ * fork-failed ⇒ 用户看到「回退失败」,而磁盘上却多出一条「未分组」的子会话。现在:
1027
+ * 从错误里**按 DSH 客户端同款字段**取回 childId(见 workspaceAttachFailureChildId),
1028
+ * 取到 ⇒ 不再报失败,改由 attachChildToParentWorkspace 兜底归位;取不到 ⇒ 保持现状报失败。
1029
+ *
1030
+ * 工作区归位:官方能解析到工作区时它已经写过(本方法只复核);解析不到(官方缺同目录档)才由
1031
+ * attachChildToParentWorkspace 兜底写。**归位失败一定带具体理由**:日志留痕 + warning 串
1032
+ * 形如 `workspace-attach-failed:<reason>`(core 落成 `rollback.guidance.sessionNotAttached` 如实上屏)。
1033
+ */
1034
+ async forkSession(sessionId: string, boundarySeq: number): Promise<{ childSessionId: string; warning?: string } | { error: string }> {
1035
+ let childId: string | undefined
1036
+ try {
1037
+ const controller = readService<SessionForkController>(this.ctx, 'sessionController')
1038
+ const fork = controller?.fork
1039
+ if (typeof fork !== 'function') return { error: 'session-controller-unavailable' }
1040
+ const child = await fork.call(controller, { sessionId, atSeq: boundarySeq })
1041
+ const id = child !== null && typeof child === 'object' ? (child as { sessionId?: unknown }).sessionId : undefined
1042
+ if (typeof id !== 'string' || id === '') return { error: 'fork-returned-no-id' }
1043
+ childId = id
1044
+ } catch (error) {
1045
+ // t16:官方 fork 在 attach 失败时抛 session/workspace-attach-failed —— 子会话**已经建好**,
1046
+ // 只是没归位。把它整体报成 fork-failed 会让用户以为回退失败,而盘上多出一条「未分组」的子会话。
1047
+ // 字段名按 DSH 自己的客户端同款姿势取(见 workspaceAttachFailureChildId 的注释),**不猜**。
1048
+ const recovered = workspaceAttachFailureChildId(error)
1049
+ if (recovered === undefined) return { error: errorMessage(error) }
1050
+ this.log.warn(
1051
+ 'rewind: fork reported ' + WORKSPACE_ATTACH_FAILED_ERROR_CODE + '; keeping child session ' + recovered
1052
+ + ' (the session was created, only the workspace attach failed)',
1053
+ )
1054
+ childId = recovered
1055
+ }
1056
+ // 工作区归位:官方已归位 → 只复核;官方没归位(或 attach 抛错被上面救回)→ 本兜底写;写后一律复核。
1057
+ const outcome = await this.attachChildToParentWorkspace(sessionId, childId)
1058
+ if (outcome.ok) {
1059
+ this.log.info(
1060
+ 'rewind: child session filed into workspace: ' + childId + ' (' + outcome.reason + (outcome.workspaceId !== undefined ? ' ' + outcome.workspaceId : '') + ')',
1061
+ )
1062
+ return { childSessionId: childId }
1063
+ }
1064
+ // 归位失败**带具体理由**(绝不静默):日志留痕 + warning 串交给调用方如实上屏。
1065
+ this.log.warn(
1066
+ 'rewind: child session not filed into any workspace: ' + childId + ' reason=' + outcome.reason
1067
+ + (outcome.detail !== undefined ? ' detail=' + outcome.detail : ''),
1068
+ )
1069
+ const warning = workspaceAttachWarningOf(outcome)
1070
+ return warning === undefined ? { childSessionId: childId } : { childSessionId: childId, warning }
1071
+ }
1072
+
1073
+ /**
1074
+ * 把 fork 出来的子会话挂回**原会话所属工作区**(只读匹配 + 官方写 + 复核)。
1075
+ *
1076
+ * 三档判定,每一档都只相信 DSH 自己的成员语义(真正的成员判据是
1077
+ * “子会话 header 的 canonical cwd === 工作区 path”,`attachSession` 落盘前会再复核一次):
1078
+ * ① **直接命中**:某工作区的 `sessionIds` 含原会话 —— 与 DSH `forkWorkspace` 第一档同口径;
1079
+ * ② **血缘**:原会话是子代理会话时,它的**祖先**才在某个工作区里 —— DSH `forkWorkspace`
1080
+ * 对 `origin === 'subagent'` 正是沿 `parentSession` 链上溯(有环保护 + 深度上限);
1081
+ * ③ **同目录**:子会话 header 的 cwd 与某工作区的 `path` 指向**同一个目录** —— 这正是 DSH 的成员判据本身。
1082
+ * 目录同一性**由 DSH 判定**:优先 `workspaceRegistry.resolveByPath(cwd)`(异步、按注册表同一套
1083
+ * realpath 规范化、目录不存在直接在 realpath 阶段拒绝);拿不到该方法才回落「双端 realpath +
1084
+ * win32 大小写折叠」。**绝不**用未归一化的裸字符串比较 —— 真机 2026-10-12 的根因正是它:
1085
+ * 工作区 path 经 `realpathNormalize`、会话头 cwd 是原样字符串,Windows 上大小写 / 短名 / 分隔符任一
1086
+ * 不同即判不相等 ⇒ 三档全落空 ⇒ 子会话永久停在「未分组」,且失败被吞成布尔、一直不可见。
1087
+ *
1088
+ * **官方 fork 是否已自带归位(要求 D 的事实锁定,依据 DSH 源码)**:
1089
+ * · `dsh-api-session-controller/lib/index.js` 的 `SessionCommandController.fork` 会先调
1090
+ * `forkWorkspace(source.header)`(L802)再 `workspace.attachSession(childId)`(L829-836),
1091
+ * attach 失败时抛 `session/workspace-attach-failed`(错误细节带 childId);
1092
+ * · 但 `forkWorkspace`(L1036-1045)**只有两档**:`sessionIds` 直接命中、`origin==='subagent'` 时沿
1093
+ * 祖先上溯 —— **没有同目录档**;两档都落空就返回 undefined ⇒ 官方**不归位**(本机现场:
1094
+ * 父会话不属于那 8 个工作区中的任何一个,于是每次回退都留下「未分组」的子会话)。
1095
+ * 结论:官方能解析到工作区时**它已经写了**,本兜底只补最后一道复核(`already-attached`);
1096
+ * 官方解析不到(缺同目录档)才由本兜底的 ③ 档写 —— **不重复写、也不两处都写**。
1097
+ *
1098
+ * 任一档写失败(工作区目录已不存在 / cwd 对不上 / 形状不符)**只换下一档继续找**;三档都拿不到才返回带
1099
+ * **具体理由**的失败(调用方经 warning 通道如实上屏 + 日志留痕)—— **绝不猜、也绝不假装已归位**。
1100
+ */
1101
+ private async attachChildToParentWorkspace(parentId: string, childId: string): Promise<WorkspaceAttachOutcome> {
1102
+ try {
1103
+ const registry = readService<WorkspaceRegistryLike>(this.ctx, 'workspaceRegistry')
1104
+ if (registry === undefined) return { ok: false, reason: 'registry-unavailable' }
1105
+ const listed = this.registryRecordsOf(registry)
1106
+ if (!listed.ok) return listed.failure
1107
+ const records = listed.records
1108
+
1109
+ // 要求 D:官方 fork 已归位(能按成员/血缘解析到工作区)⇒ 这里**只补最后一道复核**,绝不重复写。
1110
+ const already = this.workspaceRecordWithMember(records, childId)
1111
+ if (already !== undefined) {
1112
+ const workspaceId = workspaceRecordIdOf(already)
1113
+ return workspaceId === undefined ? { ok: true, reason: 'already-attached' } : { ok: true, reason: 'already-attached', workspaceId }
1114
+ }
1115
+
1116
+ /** 找到工作区但写失败的第一条理由(比「没找到工作区」更可操作,优先回报)。 */
1117
+ let attachFailure: WorkspaceAttachFailure | undefined
1118
+ /** ③ 档「找不到同目录工作区 / 目录不存在 / 读不到 cwd」的理由。 */
1119
+ let cwdFailure: WorkspaceAttachFailure | undefined
1120
+
1121
+ // ① 直接命中(DSH forkWorkspace 的第一档)
1122
+ const direct = this.workspaceRecordWithMember(records, parentId)
1123
+ if (direct !== undefined) {
1124
+ const attempt = await this.attachChildTo(direct, childId)
1125
+ if (attempt.ok) return await this.verifyChildAttached(registry, childId)
1126
+ attachFailure = attempt
1127
+ }
1128
+ // ② 血缘:子代理会话的祖先所属工作区(DSH forkWorkspace 对 subagent 的同款上溯)
1129
+ for (const ancestorId of this.ancestorSessionIds(parentId)) {
1130
+ const record = this.workspaceRecordWithMember(records, ancestorId)
1131
+ if (record === undefined) continue
1132
+ const attempt = await this.attachChildTo(record, childId)
1133
+ if (attempt.ok) return await this.verifyChildAttached(registry, childId)
1134
+ attachFailure ??= attempt
1135
+ }
1136
+ // ③ 同目录:cwd 与某工作区 path 指向同一目录(目录同一性由 DSH 判定,见方法注释)
1137
+ const cwd = this.liveSessionCwd(childId)
1138
+ if (cwd === undefined) {
1139
+ cwdFailure = { ok: false, reason: 'cwd-unavailable' }
1140
+ } else {
1141
+ const resolution = await this.resolveWorkspaceByCwd(registry, records, cwd)
1142
+ if (resolution.records !== undefined && resolution.records.length > 0) {
1143
+ for (const record of resolution.records) {
1144
+ const attempt = await this.attachChildTo(record, childId)
1145
+ if (attempt.ok) return await this.verifyChildAttached(registry, childId)
1146
+ attachFailure ??= attempt
1147
+ }
1148
+ } else {
1149
+ cwdFailure = resolution.failure
1150
+ }
1151
+ }
1152
+ return attachFailure ?? cwdFailure ?? { ok: false, reason: 'no-workspace-for-cwd' }
1153
+ } catch (error) {
1154
+ const detail = errorMessage(error)
1155
+ this.log.warn('rewind: workspace attach failed: ' + detail)
1156
+ return { ok: false, reason: 'unexpected-error', detail }
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * 读一次注册表列举:形状不符(没有 list / 返回非数组)与抛错**分开**报,绝不吞成一个 false。
1162
+ */
1163
+ private registryRecordsOf(
1164
+ registry: WorkspaceRegistryLike,
1165
+ ): { ok: true; records: readonly unknown[] } | { ok: false; failure: WorkspaceAttachFailure } {
1166
+ const list = registry.list
1167
+ if (typeof list !== 'function') return { ok: false, failure: { ok: false, reason: 'registry-list-unavailable', detail: 'list-not-a-function' } }
1168
+ let raw: unknown
1169
+ try {
1170
+ raw = list.call(registry)
1171
+ } catch (error) {
1172
+ return { ok: false, failure: { ok: false, reason: 'registry-list-failed', detail: errorMessage(error) } }
1173
+ }
1174
+ if (!Array.isArray(raw)) return { ok: false, failure: { ok: false, reason: 'registry-list-unavailable', detail: 'list-not-array' } }
1175
+ return { ok: true, records: raw }
1176
+ }
1177
+
1178
+ /** 列举里第一个 `sessionIds` 含该会话的工作区记录(形状不符一律跳过,绝不猜)。 */
1179
+ private workspaceRecordWithMember(records: readonly unknown[], sessionId: string): Record<string, unknown> | undefined {
1180
+ for (const raw of records) {
1181
+ if (raw === null || typeof raw !== 'object') continue
1182
+ const record = raw as Record<string, unknown>
1183
+ if (recordHasMember(record, sessionId)) return record
1184
+ }
1185
+ return undefined
1186
+ }
1187
+
1188
+ /**
1189
+ * 对一条工作区记录调官方 `attachSession(childId)`,并返回**具体理由**(绝不静默返回 false)。
1190
+ *
1191
+ * `attach` 必须 `.call(record, ...)` 保留 receiver(实体方法内部用 `this.host` / `this.record`)。
1192
+ * 两种失败**分开**:记录上根本没有 `attachSession`(形状不符,未发起任何写)vs 真的抛错(写被拒)。
1193
+ */
1194
+ private async attachChildTo(record: Record<string, unknown>, childId: string): Promise<AttachAttempt> {
1195
+ const attach = record['attachSession']
1196
+ if (typeof attach !== 'function') {
1197
+ const detail = 'workspace-record-has-no-attachSession'
1198
+ this.log.warn('rewind: workspace attach skipped for ' + childId + ': ' + detail)
1199
+ return { ok: false, reason: 'attach-session-missing', detail }
1200
+ }
1201
+ try {
1202
+ await (attach as (this: unknown, id: string) => Promise<void>).call(record, childId)
1203
+ return { ok: true }
1204
+ } catch (error) {
1205
+ const detail = errorMessage(error)
1206
+ this.log.warn('rewind: workspace attach rejected for ' + childId + ': ' + detail)
1207
+ return { ok: false, reason: 'attach-rejected', detail }
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * ③ 档:cwd 指向的**已注册工作区**记录(目录同一性由 DSH 判定,绝不裸字符串比较)。
1213
+ *
1214
+ * 优先 DSH 自己的 `resolveByPath(cwd)`:
1215
+ * · 它按注册表同一套 realpath 规范化(Windows 大小写 / 短名 / 分隔符差异都能对上);
1216
+ * · **目录不存在时它在 realpath 阶段拒绝** —— 那正是「目录不存在」的判定,如实转成 `directory-missing`;
1217
+ * · 目录存在但不属于任何工作区 → 返回 undefined(`no-workspace-for-cwd`);
1218
+ * · 返回的工作区实体自带 `attachSession`,直接用它(就是 DSH 判定的那一个目录,不需要再按字符串回查)。
1219
+ * 拿不到该方法(老宿主 / 形状不符)才回落 {@link resolveWorkspaceByCwdFallback}。
1220
+ */
1221
+ private async resolveWorkspaceByCwd(
1222
+ registry: WorkspaceRegistryLike,
1223
+ records: readonly unknown[],
1224
+ cwd: string,
1225
+ ): Promise<{ records: readonly Record<string, unknown>[]; failure?: undefined } | { records?: undefined; failure: WorkspaceAttachFailure }> {
1226
+ const resolveByPath = registry.resolveByPath
1227
+ if (typeof resolveByPath !== 'function') return await this.resolveWorkspaceByCwdFallback(records, cwd)
1228
+ let resolved: unknown
1229
+ try {
1230
+ resolved = await resolveByPath.call(registry, cwd)
1231
+ } catch (error) {
1232
+ const detail = errorMessage(error)
1233
+ this.log.warn('rewind: resolveByPath rejected for ' + cwd + ': ' + detail)
1234
+ return { failure: { ok: false, reason: 'directory-missing', detail } }
1235
+ }
1236
+ if (resolved === undefined || resolved === null) return { failure: { ok: false, reason: 'no-workspace-for-cwd', detail: cwd } }
1237
+ if (typeof resolved !== 'object') {
1238
+ return { failure: { ok: false, reason: 'registry-list-unavailable', detail: 'resolveByPath-returned-non-object' } }
1239
+ }
1240
+ const entity = resolved as Record<string, unknown>
1241
+ if (typeof entity['attachSession'] !== 'function') {
1242
+ return { failure: { ok: false, reason: 'attach-session-missing', detail: 'resolveByPath-result-has-no-attachSession' } }
1243
+ }
1244
+ return { records: [entity] }
1245
+ }
1246
+
1247
+ /**
1248
+ * ③ 档回落(`resolveByPath` 不可用):**双端** realpath + win32 大小写折叠比较。
1249
+ *
1250
+ * 两侧都必须 realpath 过才比:cwd 侧失败 = 目录不存在(`directory-missing`);工作区侧失败 =
1251
+ * 该工作区目录已不在(**跳过它**,不猜「像同一个」)。
1252
+ */
1253
+ private async resolveWorkspaceByCwdFallback(
1254
+ records: readonly unknown[],
1255
+ cwd: string,
1256
+ ): Promise<{ records: readonly Record<string, unknown>[]; failure?: undefined } | { records?: undefined; failure: WorkspaceAttachFailure }> {
1257
+ let cwdCanonical: string
1258
+ try {
1259
+ cwdCanonical = await this.realpath(cwd)
1260
+ } catch (error) {
1261
+ const detail = errorMessage(error)
1262
+ this.log.warn('rewind: realpath failed for ' + cwd + ': ' + detail)
1263
+ return { failure: { ok: false, reason: 'directory-missing', detail } }
1264
+ }
1265
+ const folded = foldWorkspacePath(cwdCanonical)
1266
+ const matches: Record<string, unknown>[] = []
1267
+ for (const raw of records) {
1268
+ if (raw === null || typeof raw !== 'object') continue
1269
+ const record = raw as Record<string, unknown>
1270
+ const path = record['path']
1271
+ if (typeof path !== 'string' || path === '') continue
1272
+ let candidate: string
1273
+ try {
1274
+ candidate = await this.realpath(path)
1275
+ } catch {
1276
+ continue
1277
+ }
1278
+ if (foldWorkspacePath(candidate) === folded) matches.push(record)
1279
+ }
1280
+ // 同一目录理论上只有一条记录(DSH 的 createCanonical 保证);仍逐条返回,让调用方对失败的那条换下一条试。
1281
+ return matches.length === 0 ? { failure: { ok: false, reason: 'no-workspace-for-cwd', detail: cwd } } : { records: matches }
1282
+ }
1283
+
1284
+ /**
1285
+ * 归位后的**复核**(要求 C):`list()` 里该工作区的 `sessionIds` 是否真的包含子会话。
1286
+ *
1287
+ * 为什么必须复核:`sessionIds` 由 DSH 按 `sessionPath(id) === record.path` 过滤(dsh-workspace
1288
+ * 实体 getter),是「这个会话到底归没归位」的**最终判据**;attach 返回成功只代表那次写调用没抛错。
1289
+ * 复核不通过一律按失败处理并给出理由 —— **不许报成功**。
1290
+ */
1291
+ private async verifyChildAttached(registry: WorkspaceRegistryLike, childId: string): Promise<WorkspaceAttachOutcome> {
1292
+ const listed = this.registryRecordsOf(registry)
1293
+ if (!listed.ok) {
1294
+ const detail = listed.failure.reason + (listed.failure.detail !== undefined ? ':' + listed.failure.detail : '')
1295
+ this.log.warn('rewind: workspace attach verify could not list workspaces for ' + childId + ': ' + detail)
1296
+ return { ok: false, reason: 'verify-failed', detail }
1297
+ }
1298
+ const record = this.workspaceRecordWithMember(listed.records, childId)
1299
+ if (record === undefined) {
1300
+ const detail = 'child-not-in-any-workspace-sessionIds'
1301
+ this.log.warn('rewind: workspace attach verify failed for ' + childId + ': ' + detail)
1302
+ return { ok: false, reason: 'verify-failed', detail }
1303
+ }
1304
+ const workspaceId = workspaceRecordIdOf(record)
1305
+ return workspaceId === undefined ? { ok: true, reason: 'attached' } : { ok: true, reason: 'attached', workspaceId }
1306
+ }
1307
+
1308
+ /**
1309
+ * 从**活的**会话存储沿 `parentSession` 上溯祖先 id(不含起点;有环保护与深度上限)。
1310
+ *
1311
+ * 读不到会话 / 读不到 header / 形状不符一律停在这一层返回已收集的祖先 —— 这是只读探测,
1312
+ * 宁少不猜(DSH 官方用 `sessionQuery.traceSession`;这里只依赖 `sessions.get(id).header`)。
1313
+ */
1314
+ private ancestorSessionIds(sessionId: string): string[] {
1315
+ const out: string[] = []
1316
+ const seen = new Set<string>([sessionId])
1317
+ let current = sessionId
1318
+ for (let depth = 0; depth < ANCESTOR_LOOKUP_MAX_DEPTH; depth += 1) {
1319
+ const header = this.liveSessionHeader(current)
1320
+ const parent = header?.['parentSession']
1321
+ if (typeof parent !== 'string' || parent === '' || seen.has(parent)) break
1322
+ seen.add(parent)
1323
+ out.push(parent)
1324
+ current = parent
1325
+ }
1326
+ return out
1327
+ }
1328
+
1329
+ /** 子会话 header 的 cwd(只读;读不到返回 undefined,绝不猜路径)。 */
1330
+ private liveSessionCwd(sessionId: string): string | undefined {
1331
+ const cwd = this.liveSessionHeader(sessionId)?.['cwd']
1332
+ return typeof cwd === 'string' && cwd !== '' ? cwd : undefined
1333
+ }
1334
+
1335
+ /**
1336
+ * 活会话 header(`ctx.get('sessions').get(id).header`)。
1337
+ *
1338
+ * 形状不符(服务未挂载 / 会话不活)返回 undefined —— 与 DSH 的 `readSessionHeader` 第一优先
1339
+ * 同源(它也是先看活会话,再看落盘列举);这里不做落盘回落,避免把「文件系统上的旧 header」
1340
+ * 当成「这次 fork 的孩子」。
1341
+ */
1342
+ private liveSessionHeader(sessionId: string): Record<string, unknown> | undefined {
1343
+ const service = readService<{ get?: (id: string) => unknown }>(this.ctx, 'sessions')
1344
+ const get = service?.get
1345
+ if (typeof get !== 'function') return undefined
1346
+ let session: unknown
1347
+ try {
1348
+ session = get.call(service, sessionId)
1349
+ } catch {
1350
+ return undefined
1351
+ }
1352
+ if (session === null || typeof session !== 'object') return undefined
1353
+ const header = (session as { header?: unknown }).header
1354
+ return header !== null && typeof header === 'object' ? header as Record<string, unknown> : undefined
843
1355
  }
844
1356
 
845
1357
  /**
@@ -1484,8 +1996,10 @@ export class ConfigManagerHostContext implements HostContext {
1484
1996
  readonly profileDir: string
1485
1997
  readonly log: Logger
1486
1998
  readonly msg: MsgFunc
1487
- /** 应用语言(resolveAppLanguage;导出历史报告 locale 用) */
1488
- readonly language: 'zh' | 'en'
1999
+ /** 应用语言(**调用期解析**:resolveAppLanguage 的活源;导出历史报告 locale 用) */
2000
+ get language(): 'zh' | 'en' {
2001
+ return getEngineLocale()
2002
+ }
1489
2003
  readonly settings: SettingsFacade
1490
2004
  readonly credentials: CredentialsFacade
1491
2005
  readonly plugins: PluginsFacade
@@ -1540,8 +2054,15 @@ export class ConfigManagerHostContext implements HostContext {
1540
2054
  dshPackageJsonCandidates(homeDir, profile, installAnchor), sessionFormatTried)
1541
2055
  this.profile = profile
1542
2056
  this.profileDir = resolveProfileDir(homeDir, profile)
1543
- this.language = resolveAppLanguage(ctx)
1544
- this.msg = makeMsg(this.language)
2057
+ // 引擎语言运行时(t2):语言必须在「取文案那一刻」解析,而不是在装配那一刻 ——
2058
+ // 原先 this.msg = makeMsg(resolveAppLanguage(ctx)) 只在 apply() 时求值一次,用户切到
2059
+ // English 后宿主产出的报告/计划/错误文本永远停在加载时语言。
2060
+ // 构造期先按当前语言设定一次(未设定时行为等于 zh),再注册「调用期解析」的活源:
2061
+ // 之后每次取文案都跟随用户语言(resolveAppLanguage 走 ctx.settings.describe,
2062
+ // 非请求路径由 LANGUAGE_TTL_MS 兜底缓存)。
2063
+ setEngineLocale(resolveAppLanguage(ctx))
2064
+ setEngineLanguageSource(() => resolveAppLanguage(ctx))
2065
+ this.msg = engineMsg
1545
2066
  // 日志级别缺省 warn:启动 dsh web 后控制台只留 warn/error —— 挂载横幅、调度器跳过、
1546
2067
  // 导出/备份完成等常规 info 不再刷屏(用户要求移除启动后的日志噪音);
1547
2068
  // 排查时 DSH_CONFIG_MANAGER_LOG_LEVEL=info|debug 恢复逐条输出。
@@ -1549,9 +2070,10 @@ export class ConfigManagerHostContext implements HostContext {
1549
2070
  if (this.sessionFormatVersion === undefined) {
1550
2071
  // 绝不静默:解析不到就明说体检不可用(用户仍能导出/导入,只是拿不到格式告警),
1551
2072
  // 并把已尝试的路径一并给出。
2073
+ const separator = this.msg('host.sessionFormatPathSeparator')
1552
2074
  const triedText = sessionFormatTried.length > 6
1553
- ? sessionFormatTried.slice(0, 6).join('、') + ' 等'
1554
- : sessionFormatTried.join('、')
2075
+ ? this.msg('host.sessionFormatPathsTruncated', { paths: sessionFormatTried.slice(0, 6).join(separator) })
2076
+ : sessionFormatTried.join(separator)
1555
2077
  this.log.warn('无法解析本机 DSH 的会话格式版本:导入/同步不做「会话格式是否可读」体检'
1556
2078
  + `(已尝试 ${sessionFormatTried.length} 处:${triedText})`)
1557
2079
  }
@@ -1671,7 +2193,7 @@ export async function tryDecryptCredentials(
1671
2193
  * (见 W1 报告的重测配方),把结果落到这里;makeRoutes 里的 const routeEnv: RouteEnvInferred =
1672
2194
  * 注解保证两侧不漂移(新增依赖漏登记会在构造点报错)。
1673
2195
  */
1674
- export type RouteEnvInferred = { sessionExportFetch: ((input: string, init: RequestInit) => Promise<Response>) | undefined; sessionExportPort: number | undefined; adapters: ConfigAdapter<unknown>[]; sessionHealth: { homeDir: string; targetFormatVersion: () => number | undefined; workspaceKeys: () => Promise<ReadonlySet<string>>; knownSessionIds: () => Promise<ReadonlySet<string>>; }; backupScheduler: BackupScheduler; bootSafetyAudit: () => Promise<BootSafetyReport>; cancelDecisionTimeoutMs: number; buildMarketSummary: (e: { url: string; addedAt: string; }) => Promise<MarketSummary>; credentials: CredentialProvider; dataDir: string; exportsDir: string; githubAuth: GitHubAuthClient; githubClientId: string | undefined; githubClientSecret: string | undefined; githubFlows: DeviceFlowStore; history: MigrationStore; host: ConfigManagerHostContext; itemCached: (url: string, itemId: string) => Promise<boolean>; knownSyncSectionIds: Set<SectionId>; makeImporter: () => Importer; makeMarketReader: () => GitMarketReader; makeRecoveryExecutors: (runId: string) => RecoveryExecutorFns; makeSyncEngine: (cfg: SyncConfig, engineOpts?: { includeOptInSections?: boolean; }) => SyncEngine; marketBootAutoRefreshed: { value: boolean; }; marketCacheIndex: (url: string) => string; marketDir: string; marketCacheItemDir: (url: string) => string; marketStarCache: StarCache; marketWorkDir: (url: string) => string; meGitHubRest: GitHubAuthRest; meService: MyRepoService; meTokenProvider: () => Promise<string>; msg: MsgFunc; prepareSync: (body: Record<string, unknown>) => Promise<SyncConfig>; profileLauncher: DshProfileLauncher; profileRuntime: DshProfileRuntimeRegistry; profiles: DshProfileManager; pruneStagedMarketZips: () => Promise<void>; readCachedIndexObj: (url: string) => Promise<MarketIndex | null>; recoveryOrchestrator: RecoveryOrchestrator; resolveSyncPassword: (ref: string) => Promise<string | undefined>; roots: string[]; runAbortControllers: Map<string, AbortController>; runCancels: Map<string, { signal: AbortController; settle: (d: 'rollback' | 'keep') => void; decided: boolean }>; runs: RunRegistry; scheduler: AutoSyncScheduler; selectionCache: Partial<Record<SyncTransportType, SyncSelection>>; selectionHasOptInSections: (channel: SyncTransportType) => boolean; selectionView: (channel: SyncTransportType) => Promise<SelectionView>; selectionViewByChannel: () => Promise<Record<SyncTransportType, SelectionView>>; snapshotEntrySections: (snapshotDir: string) => Promise<string[]>; snapshotsDir: string; syncCredentialsByChannelView: () => Promise<Record<SyncTransportType, { encryptPasswordConfigured: boolean; decryptPasswordConfigured: boolean; }>>; syncDir: string; syncPasswordConfigured: (ref: string) => Promise<boolean>; syncSectionCatalog: { id: SectionId; displayName: string; portability: Portability; defaultIncluded: boolean; }[]; syncSessions: SyncSessionStore; tmpDir: string; tryAppendHistory: (raw: { kind: MigrationKind; result: MigrationResult; sections: string[]; operationId?: string; snapshotId?: string; runId?: string; source: 'api' | 'autosync' | 'backup-scheduler' | 'recovery' | 'cli' | 'internal'; summary: string; error?: string; }) => Promise<string | undefined>; withMutationGate: (op: string, handler: (req: IncomingMessage, res: ServerResponse, lockCtx?: MutationLockContext, journalCtx?: JournalRunContext) => Promise<void>, opts?: { journaled?: boolean; deferredSnapshot?: boolean; }) => ((req: IncomingMessage, res: ServerResponse) => Promise<void>); selfUpdate: (targetVersion: string) => Promise<SelfUpdateOutcome>; writeItemCache: (url: string, itemId: string, manifestRaw: string, zipBytes: Uint8Array) => Promise<void>; }
2196
+ export type RouteEnvInferred = { sessionExportFetch: ((input: string, init: RequestInit) => Promise<Response>) | undefined; sessionExportPort: number | undefined; adapters: ConfigAdapter<unknown>[]; sessionHealth: { homeDir: string; targetFormatVersion: () => number | undefined; workspaceKeys: () => Promise<ReadonlySet<string>>; knownSessionIds: () => Promise<ReadonlySet<string>>; sessionMeta: (ids: readonly string[]) => Promise<SessionMetaIndex>; }; backupScheduler: BackupScheduler; bootSafetyAudit: () => Promise<BootSafetyReport>; cancelDecisionTimeoutMs: number; buildMarketSummary: (e: { url: string; addedAt: string; }) => Promise<MarketSummary>; credentials: CredentialProvider; dataDir: string; exportsDir: string; githubAuth: GitHubAuthClient; githubClientId: string | undefined; githubClientSecret: string | undefined; githubFlows: DeviceFlowStore; history: MigrationStore; host: ConfigManagerHostContext; itemCached: (url: string, itemId: string) => Promise<boolean>; knownSyncSectionIds: Set<SectionId>; makeImporter: () => Importer; makeMarketReader: () => GitMarketReader; makeRecoveryExecutors: (runId: string) => RecoveryExecutorFns; makeSyncEngine: (cfg: SyncConfig, engineOpts?: { optInSections?: readonly SectionId[]; }) => SyncEngine; marketBootAutoRefreshed: { value: boolean; }; marketCacheIndex: (url: string) => string; marketDir: string; marketCacheItemDir: (url: string) => string; marketStarCache: StarCache; marketWorkDir: (url: string) => string; meGitHubRest: GitHubAuthRest; meService: MyRepoService; meTokenProvider: () => Promise<string>; msg: MsgFunc; prepareSync: (body: Record<string, unknown>) => Promise<SyncConfig>; profileLauncher: DshProfileLauncher; profileRuntime: DshProfileRuntimeRegistry; profiles: DshProfileManager; pruneStagedMarketZips: () => Promise<void>; readCachedIndexObj: (url: string) => Promise<MarketIndex | null>; recoveryOrchestrator: RecoveryOrchestrator; resolveSyncPassword: (ref: string) => Promise<string | undefined>; roots: string[]; runAbortControllers: Map<string, AbortController>; runCancels: Map<string, { signal: AbortController; settle: (d: 'rollback' | 'keep') => void; decided: boolean }>; runs: RunRegistry; scheduler: AutoSyncScheduler; selectionCache: Partial<Record<SyncTransportType, SyncSelection>>; selectionOptInSections: (channel: SyncTransportType, onlyAuto?: boolean) => SectionId[]; selectionView: (channel: SyncTransportType) => Promise<SelectionView>; selectionViewByChannel: () => Promise<Record<SyncTransportType, SelectionView>>; snapshotEntrySections: (snapshotDir: string) => Promise<string[]>; snapshotsDir: string; syncCredentialsByChannelView: () => Promise<Record<SyncTransportType, { encryptPasswordConfigured: boolean; decryptPasswordConfigured: boolean; }>>; syncDir: string; syncPasswordConfigured: (ref: string) => Promise<boolean>; syncWorkDir: { inspect: (repoUrl: string) => Promise<{ facts: GitWorkDirFacts; } | { error: string; }>; rebuild: (repoUrl: string, mode: 'relink' | 'reclone') => Promise<GitWorkDirFacts>; }; syncSectionCatalog: { id: SectionId; displayName: string; portability: Portability; defaultIncluded: boolean; }[]; syncSessions: SyncSessionStore; tmpDir: string; tryAppendHistory: (raw: { kind: MigrationKind; result: MigrationResult; sections: string[]; operationId?: string; snapshotId?: string; runId?: string; source: 'api' | 'autosync' | 'backup-scheduler' | 'recovery' | 'cli' | 'internal'; summary: string; error?: string; }) => Promise<string | undefined>; withMutationGate: (op: string, handler: (req: IncomingMessage, res: ServerResponse, lockCtx?: MutationLockContext, journalCtx?: JournalRunContext) => Promise<void>, opts?: { journaled?: boolean; deferredSnapshot?: boolean; }) => ((req: IncomingMessage, res: ServerResponse) => Promise<void>); selfUpdate: (targetVersion: string) => Promise<SelfUpdateOutcome>; writeItemCache: (url: string, itemId: string, manifestRaw: string, zipBytes: Uint8Array) => Promise<void>; }
1675
2197
 
1676
2198
  /** 解密错误 → 用户可读文本:BAD_PASSWORD 只报「密码错误」(不泄内部细节),其余原文 */
1677
2199
  export function decryptErrorText(error: unknown, msg: MsgFunc): string {
@@ -1767,7 +2289,7 @@ export async function parseSyncBody(
1767
2289
  } catch (error) {
1768
2290
  const reason = error instanceof Error ? error.message : String(error)
1769
2291
  throw new SyncRouteError(
1770
- `WebDAV 口令写入 DSH credentials 失败:${reason}(请在 DSH 凭据管理里配置 ${SYNC_WEBDAV_CREDENTIAL_REF} 后重试)`,
2292
+ engineMsg('host.syncWebdavPasswordWriteFailed', { reason, ref: SYNC_WEBDAV_CREDENTIAL_REF }),
1771
2293
  )
1772
2294
  }
1773
2295
  }
@@ -1789,7 +2311,7 @@ export async function parseSyncBody(
1789
2311
  } catch (error) {
1790
2312
  const reason = error instanceof Error ? error.message : String(error)
1791
2313
  throw new SyncRouteError(
1792
- `token 写入 DSH credentials 失败:${reason}(请在 DSH 凭据管理里配置 ${SYNC_CREDENTIAL_REF} 后重试)`,
2314
+ engineMsg('host.syncTokenWriteFailed', { reason, ref: SYNC_CREDENTIAL_REF }),
1793
2315
  )
1794
2316
  }
1795
2317
  }
@@ -1809,7 +2331,7 @@ function syncStr(value: unknown): string {
1809
2331
  function cloudCredentialWriteError(ref: string, kind: string, error: unknown): SyncRouteError {
1810
2332
  const reason = error instanceof Error ? error.message : String(error)
1811
2333
  return new SyncRouteError(
1812
- kind + ' 写入 DSH credentials 失败:' + reason + '(请在 DSH 凭据管理里配置 ' + ref + ' 后重试)',
2334
+ engineMsg('host.syncCredentialWriteFailed', { kind, reason, ref }),
1813
2335
  400,
1814
2336
  'cloud.credentialsWriteFailed',
1815
2337
  )
@@ -1854,7 +2376,7 @@ async function parseCloudS3Body(
1854
2376
  try {
1855
2377
  await deps.credentials.set(credentialRef(ref), secret)
1856
2378
  } catch (error) {
1857
- throw cloudCredentialWriteError(ref, 'S3 密钥', error)
2379
+ throw cloudCredentialWriteError(ref, engineMsg('host.syncKindS3Secret'), error)
1858
2380
  }
1859
2381
  }
1860
2382
  return {
@@ -2064,6 +2586,9 @@ export async function buildAutosyncStatus(dir: string, channel: SyncTransportTyp
2064
2586
  ...(cfg.lastRunAt !== undefined ? { lastRunAt: cfg.lastRunAt } : {}),
2065
2587
  ...(cfg.lastRunStatus !== undefined ? { lastRunStatus: cfg.lastRunStatus } : {}),
2066
2588
  ...(cfg.lastRunMessage !== undefined ? { lastRunMessage: cfg.lastRunMessage } : {}),
2589
+ // issue #79:跳过原因以**码**回传(界面本地化);不再只留在 info 级日志里
2590
+ ...(cfg.lastRunSkipReason !== undefined ? { lastRunSkipReason: cfg.lastRunSkipReason } : {}),
2591
+ ...(cfg.lastRunSkipCount !== undefined ? { lastRunSkipCount: cfg.lastRunSkipCount } : {}),
2067
2592
  consecutiveFailures: cfg.consecutiveFailures,
2068
2593
  elapsedMs,
2069
2594
  ...(cfg.lastRunHistoryId !== undefined ? { lastRunHistoryId: cfg.lastRunHistoryId } : {}),
@@ -2084,6 +2609,8 @@ export async function buildAutosyncStatusByChannel(dir: string): Promise<Record<
2084
2609
  ...(cfg.lastRunAt !== undefined ? { lastRunAt: cfg.lastRunAt } : {}),
2085
2610
  ...(cfg.lastRunStatus !== undefined ? { lastRunStatus: cfg.lastRunStatus } : {}),
2086
2611
  ...(cfg.lastRunMessage !== undefined ? { lastRunMessage: cfg.lastRunMessage } : {}),
2612
+ ...(cfg.lastRunSkipReason !== undefined ? { lastRunSkipReason: cfg.lastRunSkipReason } : {}),
2613
+ ...(cfg.lastRunSkipCount !== undefined ? { lastRunSkipCount: cfg.lastRunSkipCount } : {}),
2087
2614
  consecutiveFailures: cfg.consecutiveFailures,
2088
2615
  elapsedMs,
2089
2616
  ...(cfg.lastRunHistoryId !== undefined ? { lastRunHistoryId: cfg.lastRunHistoryId } : {}),
@@ -2102,6 +2629,10 @@ interface AutosyncStatusResponse {
2102
2629
  lastRunAt?: string
2103
2630
  lastRunStatus?: AutosyncRunStatus
2104
2631
  lastRunMessage?: string
2632
+ /** issue #79:本轮跳过的**结构化原因码**(界面按码取字典;语言在渲染那一刻解析) */
2633
+ lastRunSkipReason?: string
2634
+ /** 跳过涉及的分区数(当前仅 conflict:远端另有 N 个分区待确认) */
2635
+ lastRunSkipCount?: number
2105
2636
  consecutiveFailures: number
2106
2637
  elapsedMs: number
2107
2638
  lastRunHistoryId?: string
@@ -2111,7 +2642,7 @@ interface AutosyncStatusResponse {
2111
2642
 
2112
2643
  /** POST /restore 请求体校验(纯函数;snapshotId 拒绝路径分隔符防 join 越界)。 */
2113
2644
  export type BuildRestoreBodyResult =
2114
- | { ok: true; value: { snapshotId: string; dryRun: boolean } }
2645
+ | { ok: true; value: { snapshotId: string; dryRun: boolean; planFingerprint?: string } }
2115
2646
  | { ok: false; error: string }
2116
2647
 
2117
2648
  export function buildRestoreBody(body: unknown): BuildRestoreBodyResult {
@@ -2126,7 +2657,16 @@ export function buildRestoreBody(body: unknown): BuildRestoreBodyResult {
2126
2657
  if (snapshotId === '.' || snapshotId === '..' || snapshotId.includes('/') || snapshotId.includes('\\')) {
2127
2658
  return { ok: false, error: zhMsg('restore.invalidSnapshotId') }
2128
2659
  }
2129
- return { ok: true, value: { snapshotId, dryRun: record['dryRun'] === true } }
2660
+ // 预览-执行一致性(P3):客户端把预览时拿到的计划指纹带回来;缺省 = 旧客户端,行为不变。
2661
+ const planFingerprint = record['planFingerprint']
2662
+ return {
2663
+ ok: true,
2664
+ value: {
2665
+ snapshotId,
2666
+ dryRun: record['dryRun'] === true,
2667
+ ...(typeof planFingerprint === 'string' && planFingerprint !== '' ? { planFingerprint } : {}),
2668
+ },
2669
+ }
2130
2670
  }
2131
2671
 
2132
2672
  /* ------------------------------------------- snapshots/file-diff(git 风格预览) */
@@ -2218,7 +2758,7 @@ export async function executeRestorePlan(
2218
2758
  case 'hostFileRestore':
2219
2759
  case 'fileRestore': {
2220
2760
  if (action.target === undefined || action.blobPath === undefined) {
2221
- throw new Error('恢复动作缺少 target/blobPath')
2761
+ throw new Error(engineMsg('restore.missingTargetBlob'))
2222
2762
  }
2223
2763
  if (await exec.existsHome(action.target)) await exec.savePreRestore(action.target)
2224
2764
  await exec.writeHome(action.target, await exec.readBlob(action.blobPath))
@@ -2227,7 +2767,7 @@ export async function executeRestorePlan(
2227
2767
  }
2228
2768
  case 'hostFileRemove':
2229
2769
  case 'fileRemove': {
2230
- if (action.target === undefined) throw new Error('恢复动作缺少 target')
2770
+ if (action.target === undefined) throw new Error(engineMsg('restore.missingTarget'))
2231
2771
  if (await exec.existsHome(action.target)) {
2232
2772
  await exec.savePreRestore(action.target)
2233
2773
  await exec.removeHome(action.target)
@@ -2236,12 +2776,12 @@ export async function executeRestorePlan(
2236
2776
  break
2237
2777
  }
2238
2778
  case 'pluginRemove': {
2239
- if (action.pluginName === undefined) throw new Error('恢复动作缺少插件名')
2779
+ if (action.pluginName === undefined) throw new Error(engineMsg('restore.missingPluginName'))
2240
2780
  const result = await exec.uninstallPlugin(action.pluginName)
2241
2781
  if (result.ok) {
2242
2782
  report.removedPlugins.push(action.pluginName)
2243
2783
  } else {
2244
- report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? '卸载失败' })
2784
+ report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? engineMsg('restore.pluginRemoveFailed') })
2245
2785
  }
2246
2786
  break
2247
2787
  }
@@ -2252,7 +2792,7 @@ export async function executeRestorePlan(
2252
2792
  report.skipped.push(action.description)
2253
2793
  break
2254
2794
  default:
2255
- report.skipped.push(`未知动作 ${String(action.kind)}: ${action.description}`)
2795
+ report.skipped.push(engineMsg('restore.unknownAction', { kind: String(action.kind), description: action.description }))
2256
2796
  }
2257
2797
  } catch (err) {
2258
2798
  report.failed.push({
@@ -2536,7 +3076,7 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2536
3076
  result: 'success',
2537
3077
  sections: [],
2538
3078
  source: 'api',
2539
- summary: `自动保留清理删除 ${removedIds.length} 个旧快照`,
3079
+ summary: engineMsg('host.snapshotPruneSummary', { count: removedIds.length }),
2540
3080
  })
2541
3081
  } catch (error) {
2542
3082
  host.log.warn('快照保留清理历史写入失败(best-effort)', { error: error instanceof Error ? error.message : String(error) })
@@ -2601,7 +3141,7 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2601
3141
  /** 已知 adapter id 集合(push 请求体 sections 校验用)。 */
2602
3142
  const knownSyncSectionIds = new Set(adapters.map((a) => a.id))
2603
3143
  /** 可同步分区目录(status 回填 UI「同步分区」勾选列表)。
2604
- * 只含 portable + 显式可选项(OPT_IN_SYNC_SECTIONS,目前只有 sessions)——
3144
+ * 只含 portable + 显式可选项(OPT_IN_SYNC_SECTIONS,当前 mcp / sessions)——
2605
3145
  * 后者是 deviceSpecific,UI 必须显示设备相关徽章,且只有用户主动勾选才进同步通道。 */
2606
3146
  const syncSectionCatalog = adapters
2607
3147
  .filter((a) => a.portability === 'portable' || OPT_IN_SYNC_SECTIONS.includes(a.id))
@@ -2622,6 +3162,8 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2622
3162
  retentionPolicy: () => retentionPolicyProvider(),
2623
3163
  // m-retention:分层选择器由宿主注入(core 不反向依赖 sync)
2624
3164
  pruneSelector: retentionPruneSelector,
3165
+ // 保留清理被放弃/失败的唯一可见线索(core 不持 logger,由宿主接 host.log)
3166
+ onWarn: (message) => { host.log.warn(message) },
2625
3167
  }),
2626
3168
  parseZipOverride: createHardenedZipParser(),
2627
3169
  dependencyChecker: dependencyAvailable,
@@ -2811,20 +3353,48 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2811
3353
  return out
2812
3354
  }
2813
3355
 
2814
- /** 该通道的持久化选择是否显式勾选了「可选分区」(sessions)—— 决定用户驱动的拉取侧能否看见它。 */
2815
- const selectionHasOptInSections = (channel: SyncTransportType): boolean => {
3356
+ /**
3357
+ * 该通道持久化选择里**显式勾选**的可选分区(与注册表白名单取交集;缺省 / 默认模式 → 空集)。
3358
+ *
3359
+ * onlyAuto=true 时只保留 'auto' 档(AUTO_SYNC_OPT_IN_SECTIONS)—— 自动同步调用点的口径:
3360
+ * mcp 跟着走,sessions('manual')绝不悄悄下行/上行。
3361
+ */
3362
+ const selectionOptInSections = (channel: SyncTransportType, onlyAuto = false): SectionId[] => {
2816
3363
  const sel = selectionCache[channel]
2817
- if (sel === undefined) return false
3364
+ if (sel === undefined) return []
2818
3365
  const sections = effectiveSections(sel)
2819
- return sections !== undefined && sections.some((id) => OPT_IN_SYNC_SECTIONS.includes(id))
3366
+ if (sections === undefined) return []
3367
+ const allowed = onlyAuto ? AUTO_SYNC_OPT_IN_SECTIONS : OPT_IN_SYNC_SECTIONS
3368
+ return sections.filter((id) => allowed.includes(id))
2820
3369
  }
2821
3370
 
2822
3371
  /** 构造 SyncEngine:按 transport 分支构造对应传输(git → GitTransport;webdav → WebDavTransport)。
2823
3372
  * 同步范围(sections)来自持久化分区选择:advanced 模式 → 只处理勾选分区,
2824
3373
  * 自动同步(merge/apply/push 全链路)与手动 push 共用此配置。
2825
- * opts.includeOptInSections:仅**用户驱动**的拉取/一键同步路由传 true(用户的选择里
2826
- * 确实勾了 sessions 时)—— 自动同步调用点一律不传,会话分区绝不悄悄下行。 */
2827
- const makeSyncEngine = (cfg: SyncConfig, engineOpts: { includeOptInSections?: boolean } = {}): SyncEngine => {
3374
+ * opts.optInSections:本次显式放行的可选分区集合 —— 用户驱动的手动 push/pull 传
3375
+ * 「选择里勾的全部可选分区」,自动同步只传 'auto' 档(sessions 绝不悄悄下行/上行)。 */
3376
+ /**
3377
+ * git 通道的工作副本根(`<dataDir>/sync/work`)——**唯一**决定路径的地方:
3378
+ * 同步引擎与「工作副本自检/重建」(issue #79)必须指向同一个目录,否则用户重建了 A 而同步在跑 B。
3379
+ */
3380
+ const gitWorkDir = join(syncDir, 'work')
3381
+ /**
3382
+ * 构造 git 通道实例(同步引擎与 issue #79 的工作副本自检/重建共用同一份地址与凭据口径)。
3383
+ * token 只经 credentials 端口现取(值永不落盘/日志/回传)。
3384
+ */
3385
+ const makeGitTransport = (repoUrl: string): GitTransport => new GitTransport({
3386
+ repoUrl,
3387
+ workDir: gitWorkDir,
3388
+ credentials: {
3389
+ getToken: async () => {
3390
+ const resolved = await credentials.resolve(credentialRef(SYNC_CREDENTIAL_REF))
3391
+ return resolved?.value ?? ''
3392
+ },
3393
+ },
3394
+ msg,
3395
+ })
3396
+
3397
+ const makeSyncEngine = (cfg: SyncConfig, engineOpts: { optInSections?: readonly SectionId[] } = {}): SyncEngine => {
2828
3398
  let transport: SyncTransport
2829
3399
  if (isWebDavConfig(cfg)) {
2830
3400
  transport = new WebDavTransport({
@@ -2876,17 +3446,7 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2876
3446
  msg,
2877
3447
  })
2878
3448
  } else {
2879
- transport = new GitTransport({
2880
- repoUrl: cfg.git.repoUrl,
2881
- workDir: join(syncDir, 'work'),
2882
- credentials: {
2883
- getToken: async () => {
2884
- const resolved = await credentials.resolve(credentialRef(SYNC_CREDENTIAL_REF))
2885
- return resolved?.value ?? ''
2886
- },
2887
- },
2888
- msg,
2889
- })
3449
+ transport = makeGitTransport(cfg.git.repoUrl)
2890
3450
  }
2891
3451
  const channel: SyncTransportType = channelOf(cfg)
2892
3452
  const sections = effectiveSections(selectionCache[channel] ?? defaultSyncSelection())
@@ -2900,7 +3460,9 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2900
3460
  zipDir: tmpDir,
2901
3461
  msg,
2902
3462
  ...(sections === undefined ? {} : { sections }),
2903
- ...(engineOpts.includeOptInSections === true ? { includeOptInSections: true } : {}),
3463
+ ...(engineOpts.optInSections === undefined || engineOpts.optInSections.length === 0
3464
+ ? {}
3465
+ : { optInSections: engineOpts.optInSections }),
2904
3466
  // m-retention:远端快照裁剪与本地备份产物共用同一份 GFS 保留策略(缺省 = 最近 10 个,
2905
3467
  // 与改造前逐字等价);用户改完策略即时生效(每次 prune 都重读)。
2906
3468
  retentionPolicy: () => retentionPolicyProvider(),
@@ -2914,7 +3476,9 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2914
3476
  const scheduler = new AutoSyncScheduler({
2915
3477
  syncDir,
2916
3478
  host,
2917
- makeSyncEngine,
3479
+ // 自动同步只放行 'auto' 档可选分区(当前 = mcp):sessions('manual')绝不悄悄下行/上行。
3480
+ // 口径钉在**注入处**而不是调度器内部 —— 调度器只负责「用哪个引擎」,准入由宿主决定。
3481
+ makeSyncEngine: (cfg: SyncConfig) => makeSyncEngine(cfg, { optInSections: selectionOptInSections(channelOf(cfg), true) }),
2918
3482
  msg,
2919
3483
  runs,
2920
3484
  mutationLock: host.mutationLock,
@@ -2928,7 +3492,10 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2928
3492
  result: entry.status === 'success' ? 'success' : entry.status === 'skipped' ? 'skipped' : 'failed',
2929
3493
  sections: entry.appliedSections ?? [],
2930
3494
  source: 'autosync',
2931
- summary: `自动同步 ${entry.direction}${entry.transport !== undefined ? `(${entry.transport})` : ''}`,
3495
+ summary: engineMsg('host.autosyncSummary', {
3496
+ direction: entry.direction,
3497
+ transportSuffix: entry.transport !== undefined ? engineMsg('host.autosyncTransportSuffix', { transport: entry.transport }) : '',
3498
+ }),
2932
3499
  error: entry.status === 'failed' ? (entry.error ?? entry.skipReason) : undefined,
2933
3500
  }).catch(() => undefined)
2934
3501
  },
@@ -3179,6 +3746,11 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
3179
3746
  targetFormatVersion: () => host.sessionFormatVersion,
3180
3747
  workspaceKeys: async () => await sessionHealthWorkspaceKeys(host),
3181
3748
  knownSessionIds: async () => await sessionHealthKnownIds(host),
3749
+ // T4+:体检行要显示**对话标题**(此前只有 `session-<uuid>` 目录名,认不出哪条是自己的对话)。
3750
+ // 标题住在 DSH 自己的会话缓存里,读取口径与导出选择器**同一份实现**(core/session-meta.ts:
3751
+ // 只读、mtime 失效缓存、读不到退化成空索引)。这里只把索引交给路由 —— 路由不自己读盘。
3752
+ // ids = 本次要展示的会话(单元 id / 目录名都行):只给这些补标题,逐会话投影文件按需读(不再只吃聚合缓存)
3753
+ sessionMeta: async (ids: readonly string[]) => await readSessionMetaWithTitles(host, ids),
3182
3754
  },
3183
3755
  pruneStagedMarketZips,
3184
3756
  readCachedIndexObj,
@@ -3192,13 +3764,32 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
3192
3764
  runs,
3193
3765
  scheduler,
3194
3766
  selectionCache,
3195
- selectionHasOptInSections,
3767
+ selectionOptInSections,
3196
3768
  selectionView,
3197
3769
  selectionViewByChannel,
3198
3770
  snapshotEntrySections,
3199
3771
  snapshotsDir,
3200
3772
  syncCredentialsByChannelView,
3201
3773
  syncDir,
3774
+ // issue #79:工作副本自检(只读,挂在 /sync/status)与显式重建(写动作,走 mutation gate)。
3775
+ // 路由只传 repoUrl/mode —— 路径、凭据与 git 调用口径都留在宿主这一处。
3776
+ syncWorkDir: {
3777
+ // 自检失败(git 不可用 / 工作副本异常)→ 返回 { error },**不让整个状态接口 500**,
3778
+ // 也不伪装成「工作副本不存在」。
3779
+ inspect: async (repoUrl: string): Promise<{ facts: GitWorkDirFacts } | { error: string }> => {
3780
+ try {
3781
+ return { facts: await makeGitTransport(repoUrl).inspectWorkDir() }
3782
+ } catch (err) {
3783
+ return { error: err instanceof Error ? err.message : String(err) }
3784
+ }
3785
+ },
3786
+ // 重建:relink 非破坏;reclone 删目录重克隆(调用方必须先取得用户确认)
3787
+ rebuild: async (repoUrl: string, mode: 'relink' | 'reclone'): Promise<GitWorkDirFacts> => {
3788
+ const transport = makeGitTransport(repoUrl)
3789
+ await transport.rebuildWorkDir(mode)
3790
+ return await transport.inspectWorkDir()
3791
+ },
3792
+ },
3202
3793
  syncPasswordConfigured,
3203
3794
  syncSectionCatalog,
3204
3795
  // 关于页「立即更新」:用户显式点击后的写动作(官方 dsh plugin 通道,钉住精确版本)。
@@ -3226,7 +3817,7 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
3226
3817
  /**
3227
3818
  * 路由表:本文件只保留被源码级守卫按**文件窗口**钉住的 7 条(host-entry 审计 F-04 记录的
3228
3819
  * tests/host/**、src/core/model-tools.test.ts、src/core/phase1-wiring.test.ts 的窗口断言)。
3229
- * 其余 71 条已按域拆到 src/routes/*.ts,由 buildRoutes 组装。
3820
+ * 其余 72 条已按域拆到 src/routes/*.ts,由 buildRoutes 组装。
3230
3821
  * 注册顺序不影响匹配:命名路由必须互不相同(webServer 契约)。
3231
3822
  */
3232
3823
  const routeEnv: RouteEnvInferred = makeRouteEnv()
@@ -3316,10 +3907,11 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
3316
3907
  return
3317
3908
  }
3318
3909
  const runId = run.runId
3910
+ // 临时明文 ZIP 的路径必须在 try **之外**声明:失败分支也要能清理(见 finally)。
3911
+ const plainZipPath = join(tmpDir, `export-plain-${randomBytes(4).toString('hex')}.zip`)
3319
3912
  try {
3320
3913
  // 先由 Exporter 产出标准明文 ZIP(含 manifest/checksums;若 includeSecrets 需要
3321
3914
  // 加密提供者来生成 secrets.enc —— 整体容器的外层加密再保护整个文件)。core 不感知外层容器。
3322
- const plainZipPath = join(tmpDir, `export-plain-${randomBytes(4).toString('hex')}.zip`)
3323
3915
  const exporter = new Exporter({
3324
3916
  ctx: host,
3325
3917
  adapters,
@@ -3361,9 +3953,9 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
3361
3953
  if (password !== undefined) {
3362
3954
  const plainZip = await fs.readFile(plainZipPath)
3363
3955
  const { blob } = await encryptArchive(plainZip, password)
3364
- await fs.writeFile(outPath, blob)
3365
- // 临时明文 ZIP 立即清理,磁盘不残留明文备份
3366
- await fs.rm(plainZipPath, { force: true }).catch(() => undefined)
3956
+ // 原子落盘:直接 writeFile 中途失败会留下一个**名字正常**的半截 .zip(会出现在产物库里)。
3957
+ // 临时明文 ZIP 的清理统一由 finally 负责(成功 / 失败都清)。
3958
+ await atomicWriteFile(outPath, blob)
3367
3959
  } else {
3368
3960
  await fs.rename(plainZipPath, outPath)
3369
3961
  }
@@ -3385,6 +3977,11 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
3385
3977
  runs.fail(runId, message)
3386
3978
  host.log.error('导出失败', { error: message })
3387
3979
  writeJson(res, 500, { error: message, runId })
3980
+ } finally {
3981
+ // 明文临时 ZIP 必须在这里清:失败分支(加密失败 / 磁盘满 / 超时)此前**不清理**,
3982
+ // 含全部非敏感配置的明文会留在 <dataDir>/tmp 直到 24h 缓存清理 —— 与「磁盘不残留明文备份」
3983
+ // 的承诺不符。成功路径已 rename 或已原子写入,这里对不存在的路径是 no-op(force: true)。
3984
+ await fs.rm(plainZipPath, { force: true }).catch(() => undefined)
3388
3985
  }
3389
3986
  }),
3390
3987
  // ---------------------------------------------------- export-preview
@@ -3466,7 +4063,14 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
3466
4063
  const sessionsEntry = preview.find((p) => p.section === 'sessions')
3467
4064
  if (sessionsEntry !== undefined && sessionsEntry.items.length > 0) {
3468
4065
  try {
3469
- sessionsEntry.items = applySessionMeta(sessionsEntry.items, await readSessionMeta(host), 'sessions', sessionActivityTimes)
4066
+ // 标题要**尽量全**(用户 2026-10-12:始终用对话标题,而不是只取聚合缓存里的):
4067
+ // 聚合缓存缺的那些按需读逐会话投影文件(只读、定向、mtime 缓存)
4068
+ sessionsEntry.items = applySessionMeta(
4069
+ sessionsEntry.items,
4070
+ await readSessionMetaWithTitles(host, sessionsEntry.items.map((u) => u.id)),
4071
+ 'sessions',
4072
+ sessionActivityTimes,
4073
+ )
3470
4074
  } catch {
3471
4075
  /* 保持目录名(旧行为) */
3472
4076
  }
@@ -3582,7 +4186,10 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
3582
4186
  // 只在计划真的含该分区时才读 storages;读不到就保持目录名,绝不让计划生成失败。
3583
4187
  if (plan.items.some((i) => i.adapter === 'sessions')) {
3584
4188
  try {
3585
- plan.items = applySessionMetaToPlanItems(plan.items, await readSessionMeta(host))
4189
+ plan.items = applySessionMetaToPlanItems(
4190
+ plan.items,
4191
+ await readSessionMetaWithTitles(host, plan.items.filter((item) => item.adapter === 'sessions').map((item) => item.unitId ?? item.id)),
4192
+ )
3586
4193
  } catch {
3587
4194
  /* 保持目录名(旧行为) */
3588
4195
  }
@@ -3806,7 +4413,19 @@ export function apply(ctx: Context, config?: Config): void {
3806
4413
  })
3807
4414
  const { state } = await startupState.classify()
3808
4415
  startupStateResolved = true
3809
- phase3Recovery.safeModeActive = phase3Recovery.safeModeActive || ['RECOVERY_REQUIRED', 'NEEDS_ATTENTION', 'UNKNOWN_STATE'].includes(state.kind)
4416
+ // issue #87:分类结论才是本轮权威。NORMAL 且没有活锁者 ⇒ 上一轮留下的 durable SAFE MODE 是**陈旧标记**
4417
+ // (没有任何未决 journal 能解释它),必须连内存标志一起复位:只复位内存会让本会话写操作继续被 423 挡着
4418
+ // (界面却说「无待处理事项」),只复位标记又会在下一次 probe 时重新置起(标记跨重启生效)。
4419
+ const safeModeVerdict = resolveStartupSafeMode({
4420
+ kind: state.kind,
4421
+ lockState: mapLockStateForStartup(lockInsp.state),
4422
+ currentlyBlocked: phase3Recovery.safeModeActive,
4423
+ })
4424
+ phase3Recovery.safeModeActive = safeModeVerdict.blocked
4425
+ if (safeModeVerdict.clearStaleMarker) {
4426
+ await phase3Recovery.clearSafeMode().catch(() => undefined)
4427
+ host.log.info('Phase 3 启动分类 NORMAL:上一轮遗留的 SAFE MODE 标记已复位(无未决事务、无活锁者)')
4428
+ }
3810
4429
  shouldStartSchedulers = (state.kind === 'NORMAL')
3811
4430
  if (state.kind === 'RECOVERY_REQUIRED' || state.kind === 'NEEDS_ATTENTION') {
3812
4431
  host.log.warn(`Phase 3 ${state.kind}:上次 destructive operation 崩溃残留,需显式恢复;destructive 调度器未启动(read-only host 存活)`)
@@ -3990,6 +4609,143 @@ export function apply(ctx: Context, config?: Config): void {
3990
4609
  profileRuntime.withdraw()
3991
4610
  }
3992
4611
  }, 'config-manager: profile runtime heartbeat')
4612
+ // ------------------------------------------- S1:回合自动留点(宿主真接线)
4613
+ //
4614
+ // 订阅 `session/event`(@deepseek-ai/dsh-session 的 post-commit fire-and-forget feed),
4615
+ // 每见到一个 `turn/end` 就为**当前会话**留一个回滚点 = 工作区快照(内容寻址入对象仓)
4616
+ // + 会话游标(事件数 / fork 边界)。没有手动「拍点」入口 —— 留点完全由回合驱动。
4617
+ //
4618
+ // 纪律(AGENTS.md 铁律 + captain 要求):**订阅与每个回调都 try/catch 包裹**,任何异常
4619
+ // 只记 warn:改坏宿主启动 = blocker。事件回调用一条串行链喂给留点器,保证同一会话的
4620
+ // turn/start 与 turn/end **按到达顺序**处理(留点本身是异步 IO)。
4621
+ // fork 端口用**结构性窄化**读取:`SessionStoreFacade`(src/core/types.ts)不在本任务 scope 内,
4622
+ // 不为了这一处调用去改公共接口;端口上确实有 forkSession(DshSessionStoreFacade 实现)才走
4623
+ // 真 fork,否则如实结构化 `session-fork-unavailable`(绝不静默降级)。
4624
+ const forkPort = host.sessions as unknown as RollbackForkCarrier
4625
+ const rollbackEngine = new RollbackPointEngine({
4626
+ dataDir,
4627
+ homeDir,
4628
+ log: (line) => host.log.warn(line),
4629
+ fork: async ({ sessionId, boundarySeq }) => {
4630
+ const forkSession = forkPort.forkSession
4631
+ if (typeof forkSession !== 'function') {
4632
+ return { ok: false as const, code: 'session-fork-unavailable' as const, detail: 'facade has no forkSession' }
4633
+ }
4634
+ const result = await forkSession.call(forkPort, sessionId, boundarySeq)
4635
+ if ('childSessionId' in result) {
4636
+ // warning 一路带到回退结果(引擎落成 rollback.guidance.sessionNotAttached):
4637
+ // 归位失败**不静默** —— 会话确实回退了,但它会出现在侧栏「未分组」。
4638
+ return result.warning !== undefined
4639
+ ? { ok: true as const, childSessionId: result.childSessionId, warning: result.warning }
4640
+ : { ok: true as const, childSessionId: result.childSessionId }
4641
+ }
4642
+ return { ok: false as const, code: 'fork-failed' as const, detail: result.error }
4643
+ },
4644
+ })
4645
+ const rollbackTracker = new RollbackAutoTracker({ engine: rollbackEngine, log: (line) => host.log.warn(line) })
4646
+ ctx.effect(() => {
4647
+ let chain: Promise<unknown> = Promise.resolve()
4648
+ // 「会话没有 cwd」这类**形状合法但信息不全**的情况只记一次日志(避免每回合刷屏);
4649
+ // 该点本身仍带 incomplete=true + workspace.reasonCode='no-chunks',界面能如实展示。
4650
+ const chunklessLogged = new Set<string>()
4651
+ const listener = (session: unknown, event: unknown): void => {
4652
+ try {
4653
+ const bindingResult = rollbackSessionBindingOf(session)
4654
+ if (!bindingResult.ok) {
4655
+ // 形状不匹配**不静默**:给显式机器码,宿主日志留痕
4656
+ host.log.warn('rollback auto point skipped: ' + bindingResult.code + ' ' + bindingResult.detail)
4657
+ return
4658
+ }
4659
+ const binding = bindingResult.binding
4660
+ if (bindingResult.chunkless !== null && !chunklessLogged.has(binding.sessionId)) {
4661
+ chunklessLogged.add(binding.sessionId)
4662
+ host.log.warn('rollback auto point: session ' + binding.sessionId + ' ' + bindingResult.chunkless + ' → 该点只留会话态(incomplete)')
4663
+ }
4664
+ const eventResult = rollbackTrackedEventOf(binding.sessionId, event)
4665
+ if (!eventResult.ok) {
4666
+ host.log.warn('rollback auto point skipped: ' + eventResult.code + ' ' + eventResult.detail)
4667
+ return
4668
+ }
4669
+ rollbackTracker.registerSession(binding)
4670
+ const tracked = eventResult.event
4671
+ chain = chain.then(async () => { await rollbackTracker.noteEvent(tracked) }).catch((error: unknown) => {
4672
+ host.log.warn('rollback auto point failed: ' + (error instanceof Error ? error.message : String(error)))
4673
+ })
4674
+ } catch (error) {
4675
+ host.log.warn('rollback auto point listener failed: ' + (error instanceof Error ? error.message : String(error)))
4676
+ }
4677
+ }
4678
+ let off: (() => boolean) | null = null
4679
+ try {
4680
+ off = ctx.on('session/event', listener)
4681
+ } catch (error) {
4682
+ host.log.warn('rollback auto point subscribe failed: ' + (error instanceof Error ? error.message : String(error)))
4683
+ }
4684
+ return () => {
4685
+ try { off?.() } catch { /* 解绑失败不影响卸载 */ }
4686
+ rollbackTracker.stop()
4687
+ }
4688
+ }, 'config-manager: rollback auto points')
4689
+ // ------------------------------------------- 宿主斜杠命令 /rollback(t3)
4690
+ //
4691
+ // 与 HTTP 路由 /recovery/rollback-points/<id>/rewind **同源的那道写入门**:
4692
+ // runWithMutationLock(= SAFE MODE 门 + 跨进程/跨 kind 环境锁)→ 引擎 preview(零写入)→ 引擎 rewind。
4693
+ // 顺序与路由**逐条一致**(预检在锁内、一切拒绝都发生在写入之前);引擎回 confirmation-required /
4694
+ // protected-point / record-incomplete 时命令只回可读错误结果,**绝不绕过**(preview 已把
4695
+ // protected / incomplete 提前拦下,这里再挡一次 API 误用)。
4696
+ //
4697
+ // 命令服务**惰取**(调用期 ctx.get('commands'),绝不属性访问、绝不进 inject)+ 注册失败只 warn:
4698
+ // 未组合 commands 的部署照常启动。注册点在 webServer 早退**之前** —— 没有 web 端的部署一样能用 /rollback。
4699
+ const commandHost: RollbackCommandHost = {
4700
+ // 会话事实只服务于「列出本会话的点」:回退的写入面由回退点自己的记录决定,不在这里临时指定。
4701
+ agentSession: agentSessionFactsOf,
4702
+ // 只读列表:引擎的 RollbackListResult 天然满足命令层的子集形状(storage/points/error)。
4703
+ list: (sessionId) => rollbackEngine.list(),
4704
+ rewind: async ({ id }) => {
4705
+ // 与路由同一 op 名(审计/锁归属可对照);target = 目标点 id。
4706
+ // ⚠️ 这道门必须与 HTTP 路由同源:runWithMutationLock(host.mutationLock, {op:'rollback-rewind'}, …)。
4707
+ // 绕过它 = 绕过 SAFE MODE 与环境锁(本文件的 route-fence/parity 守卫之外没有第二道兜底)。
4708
+ return await runWithMutationLock(
4709
+ host.mutationLock,
4710
+ { op: 'rollback-rewind', target: id, isBlocked: () => host.safeModeIsBlocked?.() ?? false },
4711
+ async () => {
4712
+ // ① 预检(零写入):先拦住 protected / incomplete / 存储缺失,再动任何字节。
4713
+ const preview = await rollbackEngine.preview(id)
4714
+ if (!preview.ok || !preview.canRestore) {
4715
+ return {
4716
+ ok: false,
4717
+ outcome: 'denied',
4718
+ code: preview.code,
4719
+ id,
4720
+ ...(preview.detail !== undefined ? { detail: preview.detail } : {}),
4721
+ segments: [],
4722
+ skippedSegments: [],
4723
+ storage: preview.storage,
4724
+ guidance: preview.guidance,
4725
+ warnings: preview.warnings,
4726
+ } satisfies RollbackRewindResult
4727
+ }
4728
+ // ② 真执行:userConfirmed **恒 true** —— 用户敲下 /rollback <id> 就是那次确认
4729
+ // (命令入口没有第二个确认按钮;其余门全量生效)。
4730
+ return await rollbackEngine.rewind({
4731
+ id,
4732
+ userConfirmed: true,
4733
+ removeCreated: true,
4734
+ })
4735
+ },
4736
+ )
4737
+ },
4738
+ }
4739
+ try {
4740
+ const disposeRollbackCommand = registerRollbackCommand({
4741
+ readCommands: () => readService<CommandServiceLike>(ctx, 'commands'),
4742
+ deps: { host: commandHost, mutationLock: host.mutationLock, isBlocked: () => host.safeModeIsBlocked?.() ?? false },
4743
+ log: host.log,
4744
+ })
4745
+ ctx.effect(() => () => disposeRollbackCommand(), 'config-manager: /rollback command')
4746
+ } catch (error) {
4747
+ host.log.warn('register /rollback failed: ' + (error instanceof Error ? error.message : String(error)))
4748
+ }
3993
4749
  const webServer = readService<WebServer>(ctx, 'webServer')
3994
4750
  if (webServer === undefined) {
3995
4751
  host.log.warn('webServer 服务不可用:跳过 /api/dsh-config-manager 路由注册(引擎能力仍可用)')