dsh-config-manager 0.1.78 → 0.1.80

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 (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
@@ -75,6 +75,8 @@ import { projectKeyOf } from './session-select.ts'
75
75
  import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules, type IgnoreRules } from './rollback-ignore-rules.ts'
76
76
  import type { RollbackWriteAttributionPort, RollbackWriteFact, RollbackWriteRecorder } from './rollback-attribution.ts'
77
77
  import { mergeThreeWay } from './rollback-merge.ts'
78
+ import { decodeTextBytes, encodeTextBytes } from './text-fidelity.ts'
79
+ import { UndoJournalWriter, findInterruptedUndos, readUndoJournal, replayUndoJournal, type InterruptedUndo, type UndoJournalEntry } from './rollback-undo-journal.ts'
78
80
  import { isSessionLogName } from '../utils/session-log.ts'
79
81
  import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.ts'
80
82
  import {
@@ -88,6 +90,74 @@ import {
88
90
  type RollbackLedgerPrepared,
89
91
  } from './rollback-ledger.ts'
90
92
 
93
+ /* ------------------------------------------------ 跨会话回退的归属证据 */
94
+
95
+ /** 合并被拒绝的机器原因(界面映射成字典键,绝不裸渲染) */
96
+ type MergeBlockReason =
97
+ | 'conflict' | 'no-base' | 'ambiguous' | 'binary' | 'non-utf8' | 'degraded'
98
+ | 'theirs-missing' | 'theirs-unreadable' | 'ours-unreadable'
99
+ /** 落点无人认领:改这条路径的那一刻没有任何会话活跃(手改 / 外部工具 / 另一个进程) */
100
+ | 'unclaimed'
101
+
102
+ /**
103
+ * 「这条路径**是不是本会话写的**」—— 只读旁证,**绝不从内容猜**(2026-10-18)。
104
+ *
105
+ * · 'target' = 有证据表明是**本会话**写的(归属台账命中本会话,或 mtime 落在本会话活跃窗口内)
106
+ * ⇒ 允许按旧口径撤销;
107
+ * · 'foreign' = 有证据表明是**别的会话**写的(归属命中别的会话,或 mtime 落在别的会话窗口内)
108
+ * ⇒ 必须合并或拒绝,**绝不整文件覆盖**;
109
+ * · 'unclaimed' = **有覆盖**(时间窗数据可用)却没有任何会话在那一刻活跃 ⇒ 拒绝覆盖;
110
+ * · 'unknown' = 完全拿不到证据(老数据 / 未接时间窗 / stat 失败)⇒ **不得据此拦截**,退回既有口径。
111
+ *
112
+ * 为什么要时间窗:归属台账只从 tool/call 的参数里抽路径,bash / 脚本 / 外部编辑器写的路径拿不到归属
113
+ *(2026-10-17 真机「B 对话加了描述、回退 A 时把 B 那行也抹掉」的根因之一就是归属缺了 B 那次写入)。
114
+ * mtime 落在谁的活跃窗口内与工具参数无关,因此那类写入同样能被判出来。
115
+ */
116
+ type WriteProvenance = 'target' | 'foreign' | 'unclaimed' | 'unknown'
117
+
118
+ /** 一次合并判定所需的全部旁证(调用方算好,本模块只消费) */
119
+ interface ForeignEvidence {
120
+ provenance: WriteProvenance
121
+ /** 别的会话的回合点里**没有**这条路径 ⇒ 当前内容是那个点之后才出现的(新建 / 重建) */
122
+ absentAtForeignPoint: boolean
123
+ }
124
+
125
+ /** 活跃时间窗的**读**端口(core/rollback-activity.ts 的实现注入;缺省 = 没有时间证据) */
126
+ export interface RollbackActivityPort {
127
+ hasDataFor(sessionId: string): boolean
128
+ sessionsActiveAt(at: number, toleranceMs?: number): string[]
129
+ }
130
+
131
+ /** 活跃时间窗的**写**端口(tracker 用;缺省 = 不采集) */
132
+ export interface RollbackActivityRecorder {
133
+ noteActivity(sessionId: string, at?: number): void
134
+ }
135
+
136
+ /** 逐字节相等(救援点当 base 的自证用) */
137
+ function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
138
+ if (left.length !== right.length) return false
139
+ for (let i = 0; i < left.length; i += 1) if (left[i] !== right[i]) return false
140
+ return true
141
+ }
142
+
143
+ /** 前 8 KiB 是否含 NUL(与 text-fidelity / line-diff 同口径) */
144
+ function hasNulByte(bytes: Uint8Array): boolean {
145
+ const head = bytes.subarray(0, 8192)
146
+ for (let i = 0; i < head.length; i += 1) if (head[i] === 0) return true
147
+ return false
148
+ }
149
+
150
+ /**
151
+ * latin1 视图:**字节的双射**(0..255 ↔ U+0000..U+00FF,逐字节一一对应)。
152
+ *
153
+ * 用途:三方合并的输入必须是「文本」,而非 UTF-8 文件(GBK / Latin-1 / 半截序列)用 UTF-8 解码
154
+ * 会把非法字节变成 U+FFFD ⇒ 写回即内容损毁(2026-10-18 真机形状:caf\u00E9 的 0xE9 被写成 EF BF BD)。
155
+ * latin1 视图下「字符串相等」= 「字节相等」,行级 diff / 合并因此**等价于按字节做**:非法字节逐字保留。
156
+ */
157
+ function latin1Of(bytes: Uint8Array): string {
158
+ return Buffer.from(bytes).toString('latin1')
159
+ }
160
+
91
161
  /* ------------------------------------------------------------------ 常量 */
92
162
 
93
163
  /** 增量链的最大长度:到顶就落一个**全量锚点**(重建最多读 1 份全量 + 几份小增量) */
@@ -884,6 +954,25 @@ export interface RollbackPreviewWorkspace {
884
954
  skippedCount: number
885
955
  /** 捕获是否被上限截断(超单文件上限 / 字节预算 / 文件数预算任一命中即为 true) */
886
956
  truncated: boolean
957
+ /**
958
+ * **冲突**:两侧改到同一处(含同一插入点)⇒ 回退会**跳过**该文件(零写入)。
959
+ * 与执行阶段的 `conflictCount` 同一判据(调用同一份 `mergeForeignRecord`)⇒「预览说有冲突的」=「执行时真的跳过」。
960
+ */
961
+ conflictCount: number
962
+ /** 冲突路径逐条清单(上限 {@link ROLLBACK_LEFTOVER_LIST_LIMIT}) */
963
+ conflictPaths: string[]
964
+ /**
965
+ * **无法安全撤销**而会被跳过的文件总数(冲突之外的成因:没有共同祖先 / 二进制 / 差异被降级 /
966
+ * 对方把它改成了不存在 / 读不出)。预览把它们**列出来**而不是静默略过 —— 逐条原因在
967
+ * {@link files} 的 `mergeReason` 上。
968
+ */
969
+ unavailableCount: number
970
+ /**
971
+ * **落点无人认领**而会被跳过的文件总数(时间窗归属判定:文件在目标会话静默之后被改过,
972
+ * 且当时没有任何会话处于活跃窗口 —— 手改 / 外部工具 / 另一个进程)。
973
+ * 成因逐条在 {@link files} 的 `mergeReason`(`external-edits`)上。
974
+ */
975
+ externalCount: number
887
976
  }
888
977
 
889
978
  export interface RollbackPreviewSession {
@@ -1002,6 +1091,14 @@ export interface RollbackSegmentResult {
1002
1091
  * 只在**没有显式点名**(`onlyPaths` 缺省)时生效;显式点名 = 用户明确要它,归属不再拦。
1003
1092
  */
1004
1093
  foreignSkipped?: number
1094
+ /**
1095
+ * **落点无人认领**而未写回的文件总数(时间窗归属判定:文件在目标会话静默之后被改过,
1096
+ * 而那一刻没有任何会话处于活跃窗口 ⇒ 手改 / 外部工具 / 另一个 DSH 进程)。
1097
+ * 与 conflictCount / unavailableCount 一样是「故意没退」,绝不能并进「0 变更」。
1098
+ */
1099
+ externalCount?: number
1100
+ /** 无人认领而未写回的路径逐条清单(上限 {@link ROLLBACK_LEFTOVER_LIST_LIMIT}) */
1101
+ externalPaths?: string[]
1005
1102
  /** 三方合并成功、写回「撤销本会话 + 保留对方改动」的条目数(有界清单同给) */
1006
1103
  mergedCount?: number
1007
1104
  mergedPaths?: string[]
@@ -1073,6 +1170,11 @@ export interface RollbackEngineOptions {
1073
1170
  * 回退行为与旧版**逐字一致**(未判定绝不当作「别的会话写的」)。
1074
1171
  */
1075
1172
  attribution?: RollbackWriteAttributionPort
1173
+ /**
1174
+ * 会话活跃时间窗(2026-10-18 新增)。缺省 = 没有时间证据 ⇒ 归属判定退回归属台账那一条,
1175
+ * 回退行为与加它之前**逐字一致**(未判定绝不当作「别的会话写的」,也绝不凭空拦截)。
1176
+ */
1177
+ activity?: RollbackActivityPort
1076
1178
  /** DSH home(会话根 = homeDir/sessions) */
1077
1179
  homeDir: string
1078
1180
  now?: () => number
@@ -1695,24 +1797,30 @@ interface RewindDraft {
1695
1797
  * 目录名带 pid,崩溃残留由 rollback-ledger 的 `cleanupOrphanTempFiles` 在下次启动回收。
1696
1798
  */
1697
1799
  class UndoStager {
1698
- private readonly entries: { path: string; file: string | null }[] = []
1699
1800
  private readonly dir: string
1700
- private seq = 0
1801
+ private readonly journal: UndoJournalWriter
1701
1802
 
1702
- constructor(dir: string) {
1803
+ constructor(dir: string, journal: UndoJournalWriter) {
1703
1804
  this.dir = dir
1805
+ this.journal = journal
1704
1806
  }
1705
1807
 
1706
- /** 记一条前像:`bytes === null` = 该路径当时**不存在**(回滚时删掉它)。 */
1707
- async stage(target: string, bytes: Uint8Array | null): Promise<void> {
1708
- if (bytes === null) {
1709
- this.entries.push({ path: target, file: null })
1710
- return
1711
- }
1712
- const file = path.join(this.dir, String(this.seq) + '.bin')
1713
- this.seq += 1
1714
- await fs.writeFile(file, bytes)
1715
- this.entries.push({ path: target, file })
1808
+ /**
1809
+ * 记一条前像:`bytes === null` = 该路径当时**不存在**(回滚时删掉它)。
1810
+ *
1811
+ * `after` = 本条**回退动作会写成什么**(写入 = 内容 sha256;删除 = deleted)。它同时是崩溃恢复
1812
+ * 的安全判据:只有「当前内容仍等于 after 镜像」时重放才被允许(绝不覆盖后来又被改过的文件)。
1813
+ */
1814
+ async stage(target: string, bytes: Uint8Array | null, after: { sha256: string } | { deleted: true }): Promise<void> {
1815
+ await this.journal.record(target, bytes, after)
1816
+ }
1817
+
1818
+ /**
1819
+ * 全部写入 / 删除都成功之后才调用:落 `done` 标记 ⇒ 这条前像不再算「中断的回退」。
1820
+ * 崩在它之前 = 启动期能认出「半回退现场」并按 after 镜像安全重放(见 interruptedRollbacks)。
1821
+ */
1822
+ async finish(): Promise<void> {
1823
+ await this.journal.finish()
1716
1824
  }
1717
1825
 
1718
1826
  /**
@@ -1720,11 +1828,21 @@ class UndoStager {
1720
1828
  * 返回失败清单 —— 回滚失败绝不静默。
1721
1829
  */
1722
1830
  async rollback(writer: (target: string, bytes: Uint8Array) => Promise<void>): Promise<string[]> {
1831
+ // 判据来源改成 **journal 本身**(不再是进程内的内存列表):内存路径与崩溃后的磁盘路径共用
1832
+ // 同一份条目,不会各存一套、也不会各有一套口径。
1833
+ let entries: UndoJournalEntry[]
1834
+ try {
1835
+ const snapshot = await readUndoJournal(this.dir)
1836
+ entries = snapshot === null ? [] : snapshot.entries
1837
+ } catch (error) {
1838
+ // 前像读不出来 ⇒ **必须报失败**,绝不静默当成「没有要回滚的」
1839
+ return ['undo-journal-unreadable ' + errorDetail(error)]
1840
+ }
1723
1841
  const failed: string[] = []
1724
- for (const entry of [...this.entries].reverse()) {
1842
+ for (const entry of [...entries].reverse()) {
1725
1843
  try {
1726
- if (entry.file === null) await fs.rm(entry.path, { force: true })
1727
- else await writer(entry.path, await fs.readFile(entry.file))
1844
+ if (entry.bin === null) await fs.rm(entry.path, { force: true })
1845
+ else await writer(entry.path, await fs.readFile(path.join(this.dir, entry.bin)))
1728
1846
  } catch (error) {
1729
1847
  failed.push(entry.path + ' ' + errorDetail(error))
1730
1848
  }
@@ -1854,6 +1972,7 @@ export class RollbackPointEngine {
1854
1972
  private readonly ledger: RollbackLedger
1855
1973
  /** 写入归属(2026-10-17 定案 B)。undefined = 没有归属信息 ⇒ 预览不标 foreign */
1856
1974
  private readonly attribution: RollbackWriteAttributionPort | undefined
1975
+ private readonly activity: RollbackActivityPort | undefined
1857
1976
  private readonly ledgerBytesBudget: number
1858
1977
  /**
1859
1978
  * 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
@@ -1911,6 +2030,7 @@ export class RollbackPointEngine {
1911
2030
  this.guardPolicy = options.guardPolicy ?? 'warn'
1912
2031
  this.probe = options.storage
1913
2032
  this.attribution = options.attribution
2033
+ this.activity = options.activity
1914
2034
  this.forkSeam = options.fork
1915
2035
  this.writeSeam = options.writeFile
1916
2036
  this.readSeam = options.readFile
@@ -1994,7 +2114,16 @@ export class RollbackPointEngine {
1994
2114
  this.warn('rollback undo staging unavailable: ' + errorDetail(error))
1995
2115
  return null
1996
2116
  }
1997
- return new UndoStager(dir)
2117
+ // 前像 journal(2026-10-18):**先落 bin → 再 append 一行索引 + fsync**;全部写完才落 done。
2118
+ // 崩在写入中途时这个目录会被启动期认出是「中断的回退」并保持可重放,
2119
+ // 而不会被 cleanupOrphanTempFiles 当垃圾删掉(见 rollback-ledger 的同名判据)。
2120
+ try {
2121
+ return new UndoStager(dir, await UndoJournalWriter.create(dir, { now: () => this.stamp() }))
2122
+ } catch (error) {
2123
+ // journal 建不出来 = 没有安全网 ⇒ **零写入失败**(与「暂存目录建不出来」同一条判据)
2124
+ this.warn('rollback undo journal unavailable: ' + errorDetail(error))
2125
+ return null
2126
+ }
1998
2127
  }
1999
2128
 
2000
2129
  private async writeFile(target: string, bytes: Uint8Array): Promise<void> {
@@ -3766,6 +3895,67 @@ export class RollbackPointEngine {
3766
3895
  return { added: counts.added, removed: counts.removed, truncated: false }
3767
3896
  }
3768
3897
 
3898
+ /**
3899
+ * **中断的回退**(2026-10-18):进程在「回退写到一半」时没了 ⇒ 工作区停在半回退状态。
3900
+ *
3901
+ * 判据 = 目录名形态(`<pid>-<ts36>-<8hex>`)+ 有 journal 索引 + **没有 done** + 持锁进程已死
3902
+ *(全部在 rollback-undo-journal.ts 里,可注入 isAlive 以便测试)。
3903
+ *
3904
+ * 只读:不改工作区、不删现场;是否重放由用户决定(见 recoverInterruptedRollback)。
3905
+ * 这也是 cleanupOrphanTempFiles 不再整目录删它的原因 —— 删了就等于把半回退现场变成
3906
+ * 「无人知晓的部分修改」,正是任务要求里禁止留下的东西。
3907
+ */
3908
+ async interruptedRollbacks(): Promise<InterruptedUndo[]> {
3909
+ try {
3910
+ return await findInterruptedUndos(path.join(this.root, ROLLBACK_UNDO_DIR_NAME))
3911
+ } catch (error) {
3912
+ this.warn('rollback interrupted scan failed: ' + errorDetail(error))
3913
+ return []
3914
+ }
3915
+ }
3916
+
3917
+ /**
3918
+ * 把一条中断的回退**退回回退前**(重放前像),返回逐条结果。
3919
+ *
3920
+ * 安全边界全在 replayUndoJournal 里:只有「当前内容仍等于 after 镜像」的条目才写回 ——
3921
+ * 之后发生的任何人工 / 外部 / 别的会话的改动一律**跳过**并如实回报(绝不覆盖)。
3922
+ * 只有全部条目都成功(没有 skipped / failed)才清理现场;否则现场留着,可以再看、再试。
3923
+ */
3924
+ async recoverInterruptedRollback(dir: string): Promise<{ ok: boolean; restored: number; skipped: string[]; failed: string[]; detail: string }> {
3925
+ const list = await this.interruptedRollbacks()
3926
+ const hit = list.find((item) => pathKey(item.dir) === pathKey(dir))
3927
+ if (hit === undefined) return { ok: false, restored: 0, skipped: [], failed: [], detail: 'not-an-interrupted-rollback' }
3928
+ let result: { restored: number; skipped: string[]; failed: string[] }
3929
+ try {
3930
+ result = await replayUndoJournal(hit, {
3931
+ readCurrent: async (target) => {
3932
+ try {
3933
+ return await this.readFileBytes(target)
3934
+ } catch (error) {
3935
+ // 读不出来(EACCES 等)**不等于不存在**:抛出去让 replay 记 failed,绝不写
3936
+ if ((error as { code?: unknown }).code === 'ENOENT') return null
3937
+ throw error
3938
+ }
3939
+ },
3940
+ write: (target, bytes) => this.writeFile(target, bytes),
3941
+ })
3942
+ } catch (error) {
3943
+ return { ok: false, restored: 0, skipped: [], failed: [], detail: errorDetail(error) }
3944
+ }
3945
+ const ok = result.failed.length === 0 && result.skipped.length === 0
3946
+ if (ok) {
3947
+ try {
3948
+ await fs.rm(dir, { recursive: true, force: true })
3949
+ } catch {
3950
+ /* 现场清理失败不影响「已经恢复好了」这个事实 */
3951
+ }
3952
+ }
3953
+ return {
3954
+ ok, restored: result.restored, skipped: result.skipped, failed: result.failed,
3955
+ detail: 'restored=' + String(result.restored) + ' skipped=' + String(result.skipped.length) + ' failed=' + String(result.failed.length),
3956
+ }
3957
+ }
3958
+
3769
3959
  /** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
3770
3960
  async preview(id: string): Promise<RollbackPreviewResult> {
3771
3961
  const storage = await this.storageStatus()
@@ -3778,6 +3968,7 @@ export class RollbackPointEngine {
3778
3968
  removals: [], removalsCount: 0, removalsTruncated: false,
3779
3969
  createdSkipped: [], createdSkippedCount: 0, createdSkippedTruncated: false,
3780
3970
  createdRefusedReason: null,
3971
+ conflictCount: 0, conflictPaths: [], unavailableCount: 0, externalCount: 0,
3781
3972
  chunks: [], available: false, reasonCode: null,
3782
3973
  skippedCount: 0, truncated: false,
3783
3974
  }
@@ -3831,8 +4022,13 @@ export class RollbackPointEngine {
3831
4022
  // 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
3832
4023
  const diffBudget = { bytes: 0, work: 0 }
3833
4024
  /** 合并判定的会话身份与 base 缓存(与执行端同一判据:拿目标点的会话当「本会话」) */
3834
- const previewIsolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null
3835
- const previewBaseCache = new Map<string, string | null>()
4025
+ // 救援点(guard)是「回到回退前那一刻」的**精确恢复**语义 ⇒ 关掉隔离与三方合并
4026
+ // (sessionId='' ⇒ isolationSessionId=null)。R-25 冻结了这条口径:回退救援点必须把工作区
4027
+ // **逐字**写回那一刻;让它走合并会变成「什么都不做」(内容与当前磁盘一致)。
4028
+ const previewIsolationSessionId = record.kind === 'guard'
4029
+ ? null
4030
+ : (typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null)
4031
+ const previewBaseCache = new Map<string, { bytes: Uint8Array; fromGuard: boolean } | null>()
3836
4032
  // T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
3837
4033
  const previewFiles = wsPayload.files
3838
4034
  // 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
@@ -3899,15 +4095,18 @@ export class RollbackPointEngine {
3899
4095
  // 也算成「会被删掉」,用户据此以为会丢别人的改动(真机:两行描述都显示要删,实际只删 A 那行)。
3900
4096
  const decision = await (async (): Promise<{ mode: 'overwrite' | 'merged' | 'skip' | 'unchanged'; bytes?: Uint8Array; reason?: string }> => {
3901
4097
  if (current === null || previewIsolationSessionId === null) return { mode: 'overwrite' }
3902
- let baseText = previewBaseCache.get(pathKey(file.path))
3903
- if (baseText === undefined) {
3904
- baseText = await this.baseVersionFor(file.path, wsTrack.capturedAt, previewIsolationSessionId)
3905
- previewBaseCache.set(pathKey(file.path), baseText)
4098
+ let base = previewBaseCache.get(pathKey(file.path))
4099
+ if (base === undefined) {
4100
+ base = await this.baseFor(file.path, wsTrack.capturedAt, previewIsolationSessionId)
4101
+ previewBaseCache.set(pathKey(file.path), base)
4102
+ }
4103
+ const evidence: ForeignEvidence = {
4104
+ provenance: await this.provenanceOf(file.path, wsTrack.capturedAt, previewIsolationSessionId),
4105
+ // 「别人的回合点里没有它」只在**拿不到祖先**时才问(有祖先就不必再读一遍台账)
4106
+ absentAtForeignPoint: base === null
4107
+ && await this.absentAtForeignPointOf(file.path, wsTrack.capturedAt, previewIsolationSessionId),
3906
4108
  }
3907
- const writer = this.attribution?.writerOf(file.path)
3908
- const foreignEvidence = writer !== undefined && writer.sessionId !== previewIsolationSessionId && writer.at > wsTrack.capturedAt
3909
- if (baseText === null && !foreignEvidence) return { mode: 'overwrite' }
3910
- const outcome = await this.mergeForeignRecord(file, baseText, foreignEvidence)
4109
+ const outcome = await this.mergeForeignRecord(file, base, evidence)
3911
4110
  if (outcome.kind === 'write') return { mode: 'merged', bytes: outcome.bytes }
3912
4111
  if (outcome.kind === 'unchanged') return { mode: 'unchanged' }
3913
4112
  if (outcome.kind === 'blocked') return { mode: 'skip', reason: outcome.reason }
@@ -3954,6 +4153,17 @@ export class RollbackPointEngine {
3954
4153
  // 捕获期的跳过计数与截断标记如实回传(列表被记录上限截断时 skippedCount 仍是真实总数)
3955
4154
  workspace.skippedCount = wsPayload.skippedCount
3956
4155
  workspace.truncated = wsPayload.truncated
4156
+ // 冲突 / 无法安全撤销 / 落点无人认领 —— **预览逐条列出**,不是静默略过(任务要求:
4157
+ // 「回退前显示预计撤销的修改、保留的后续修改及潜在冲突」)。三者与执行阶段**同源**:
4158
+ // 都来自同一份 mergeForeignRecord 的 blocked 原因,写进逐文件的 mergeReason。
4159
+ const blockedFiles = changedFiles.filter((item) => item.skipped === true)
4160
+ workspace.conflictCount = blockedFiles.filter((item) => item.mergeReason === 'conflict').length
4161
+ workspace.conflictPaths = blockedFiles
4162
+ .filter((item) => item.mergeReason === 'conflict')
4163
+ .map((item) => item.path)
4164
+ .slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT)
4165
+ workspace.externalCount = blockedFiles.filter((item) => item.mergeReason === 'external-edits').length
4166
+ workspace.unavailableCount = blockedFiles.length - workspace.conflictCount - workspace.externalCount
3957
4167
  workspaceRestorable = wsPayload.files.length > 0
3958
4168
  }
3959
4169
 
@@ -4081,11 +4291,14 @@ export class RollbackPointEngine {
4081
4291
  let mergeSkippedReason: string | null = null
4082
4292
  const isolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null
4083
4293
  if (current !== null && snapshot !== null && isolationSessionId !== null) {
4084
- const baseText = await this.baseVersionFor(file.path, record.workspace.capturedAt, isolationSessionId)
4085
- const writer = this.attribution?.writerOf(file.path)
4086
- const foreignEvidence = writer !== undefined && writer.sessionId !== isolationSessionId && writer.at > record.workspace.capturedAt
4087
- if (baseText !== null || foreignEvidence) {
4088
- const outcome = await this.mergeForeignRecord(file, baseText, foreignEvidence)
4294
+ const base = await this.baseFor(file.path, record.workspace.capturedAt, isolationSessionId)
4295
+ const evidence: ForeignEvidence = {
4296
+ provenance: await this.provenanceOf(file.path, record.workspace.capturedAt, isolationSessionId),
4297
+ absentAtForeignPoint: base === null
4298
+ && await this.absentAtForeignPointOf(file.path, record.workspace.capturedAt, isolationSessionId),
4299
+ }
4300
+ if (base !== null || evidence.provenance !== 'unknown' || evidence.absentAtForeignPoint) {
4301
+ const outcome = await this.mergeForeignRecord(file, base, evidence)
4089
4302
  if (outcome.kind === 'write') {
4090
4303
  mergedAfter = outcome.bytes
4091
4304
  mergedFlag = true
@@ -4250,8 +4463,11 @@ export class RollbackPointEngine {
4250
4463
  const segment = state === 'workspace'
4251
4464
  // 路径级选择(2026-10-17 定案 A)只在显式给出时透传 —— 缺省口径一字不变
4252
4465
  ? await this.restoreWorkspace(record.workspace, input.onlyPaths === undefined
4253
- ? { removeCreated: input.removeCreated === true, sessionId: record.sessionId ?? '' }
4254
- : { removeCreated: input.removeCreated === true, onlyPaths: input.onlyPaths, sessionId: record.sessionId ?? '' })
4466
+ // 救援点(guard)是「回到回退前那一刻」的**精确恢复**语义 ⇒ 关掉隔离与三方合并
4467
+ // (sessionId='' ⇒ isolationSessionId=null)。R-25 冻结了这条口径:回退救援点必须把工作区
4468
+ // **逐字**写回那一刻;让它走合并会变成「什么都不做」(内容与当前磁盘一致)。
4469
+ ? { removeCreated: input.removeCreated === true, sessionId: record.kind === 'guard' ? '' : (record.sessionId ?? '') }
4470
+ : { removeCreated: input.removeCreated === true, onlyPaths: input.onlyPaths, sessionId: record.kind === 'guard' ? '' : (record.sessionId ?? '') })
4255
4471
  : await this.restoreSession(record.session)
4256
4472
  segments.push(segment)
4257
4473
  if (segment.childSessionId !== undefined) childSessionId = segment.childSessionId
@@ -4272,9 +4488,20 @@ export class RollbackPointEngine {
4272
4488
  // 「0 变更」必须真的是 0 变更:**删了文件的段不算无事可做**(restored=0 只表示没写回记录在案的
4273
4489
  // 路径;truncated 的点回退时常常「一条都不用写、但删掉了几个新建文件」—— 那种情形说
4274
4490
  // 「没有可恢复的内容」就是假尾句。真机端到端实测:removed=2 而 code 曾为 nothing-to-restore)
4491
+ /**
4492
+ * **被安全判据主动跳过**的文件总数(冲突 / 无法安全撤销 / 落点无人认领)。
4493
+ *
4494
+ * 与「无事可做」是两件事:用户要求回退,这些文件**是故意没有回退的**。
4495
+ * 把它们算进「成功、0 变更 / 没有可恢复的内容」就是假尾句 —— 竞品口径可以直接引用:
4496
+ * Claude Code 的原文是 Restored the code, but skipped N files(官方文档 errors 一节)。
4497
+ * 判据只读段上已经如实回传的三个计数,不新增任何猜测。
4498
+ */
4499
+ const deliberatelySkipped = segments.reduce((sum, s) => sum
4500
+ + (s.conflictCount ?? 0) + (s.externalCount ?? 0), 0)
4275
4501
  const allNoop = segments.length > 0
4276
4502
  && segments.every((s) => s.status === 'skipped'
4277
4503
  || (s.status === 'restored' && s.restored === 0 && (s.removed ?? 0) === 0))
4504
+ && deliberatelySkipped === 0
4278
4505
  let outcome: RollbackOutcome
4279
4506
  let code: RollbackCode
4280
4507
  if (workspaceFailed) {
@@ -4283,7 +4510,7 @@ export class RollbackPointEngine {
4283
4510
  } else if (allNoop) {
4284
4511
  outcome = 'restored'
4285
4512
  code = 'nothing-to-restore'
4286
- } else if (anyFailed || anySkipped) {
4513
+ } else if (anyFailed || anySkipped || deliberatelySkipped > 0) {
4287
4514
  outcome = 'partial'
4288
4515
  code = anyFailed ? 'restore-failed' : 'ok'
4289
4516
  } else {
@@ -4339,13 +4566,13 @@ export class RollbackPointEngine {
4339
4566
  */
4340
4567
  private async mergeForeignRecord(
4341
4568
  file: RollbackWorkspaceFile,
4342
- baseText: string | null,
4343
- foreignEvidence: boolean,
4569
+ base: { bytes: Uint8Array; fromGuard: boolean } | null,
4570
+ evidence: ForeignEvidence,
4344
4571
  ): Promise<
4345
4572
  | { kind: 'write'; bytes: Uint8Array; sha256: string; expectedCurrent: string }
4346
4573
  | { kind: 'unchanged' }
4347
- | { kind: 'blocked'; reason: 'conflict' | 'no-base' | 'ambiguous' | 'binary' | 'degraded' | 'theirs-missing' | 'theirs-unreadable' | 'ours-unreadable' }
4348
- /** 没有可用祖先、也没有「别人写过」的旁证 ⇒ 退回旧口径(整文件覆盖回目标版本) */
4574
+ | { kind: 'blocked'; reason: MergeBlockReason }
4575
+ /** 没有可用祖先、也没有任何旁证 ⇒ 退回旧口径(整文件覆盖回目标版本) */
4349
4576
  | { kind: 'fallback' }
4350
4577
  > {
4351
4578
  let oursBytes: Uint8Array
@@ -4359,25 +4586,68 @@ export class RollbackPointEngine {
4359
4586
  try {
4360
4587
  theirsBytes = await fs.readFile(file.path)
4361
4588
  } catch (error) {
4362
- return { kind: 'blocked', reason: (error as { code?: unknown }).code === 'ENOENT' ? 'theirs-missing' : 'theirs-unreadable' }
4363
- }
4364
- const oursText = Buffer.from(oursBytes).toString('utf8')
4365
- // **base 必须真的携带「本会话改动之后」的信息**:若它与 ours(回退目标)逐字相同,说明它
4366
- // 提供不了任何「本会话改了什么」的知识 —— 拿它当共同祖先会让合并结论变成「保持当前」,
4367
- // 于是「回退到该点」成了「什么都不做」(真机 R-25 就是这个形态被抓出来的:guard 之后
4368
- // 那个点的内容与目标点相同)。
4369
- if (baseText === null || baseText === oursText) {
4370
- // 有「别人写过」的旁证却没有可用祖先 ⇒ 无法安全撤销,宁可不动它并如实报告;
4371
- // 两样都没有 ⇒ 退回旧口径(整文件覆盖回目标版本)—— 那正是「纯本会话回退」的常见形态。
4372
- if (foreignEvidence) return { kind: 'blocked', reason: 'no-base' }
4589
+ // 读不出来(EACCES / EMFILE…)⇒ **存在性未知**,绝不按「不存在」处理
4590
+ if ((error as { code?: unknown }).code !== 'ENOENT') return { kind: 'blocked', reason: 'theirs-unreadable' }
4591
+ // 记录在案的路径**当前不存在**:旧口径是「按记录重建」—— 但那会连带把「别人删掉它」
4592
+ // 这件事一起撤销。判据只有两条,且都能逐字解释:
4593
+ // ① 存在共同祖先(别的会话的回合点里还有它)⇒ 它是在那个点**之后**才消失的 ⇒ 别人删的,
4594
+ // 绝不复活(真机:B 删掉文件后回退 A);
4595
+ // ② 归属 / 时间窗表明那一刻是别人在写 ⇒ 同样不复活。
4596
+ // 注意**不能**用 absentAtForeignPoint 当判据:本会话自己把它改名 / 删除时,它在别人的回合点里
4597
+ // 本来就不存在(T6-3 真机形状)—— 拿它当「别人删的」会让正常的改名回退失效。
4598
+ if (base !== null) return { kind: 'blocked', reason: 'theirs-missing' }
4599
+ if (evidence.provenance === 'unclaimed') return { kind: 'blocked', reason: 'unclaimed' }
4600
+ if (evidence.provenance === 'foreign') return { kind: 'blocked', reason: 'theirs-missing' }
4373
4601
  return { kind: 'fallback' }
4374
4602
  }
4375
- // 二进制:任何一侧含 NUL 就绝不按文本合并(三方合并算法只对文本有定义)
4376
- if (oursBytes.includes(0) || theirsBytes.includes(0) || baseText.includes('\u0000')) return { kind: 'blocked', reason: 'binary' }
4377
- const theirsText = Buffer.from(theirsBytes).toString('utf8')
4603
+ // 救援点当 base 只在「它之后确实又变过」时才成立:救援点是在**执行回退那一刻**拍的,若它与
4604
+ // 当前磁盘逐字相同,说明它提供不了任何「回退之后发生了什么」的知识 —— 拿它当共同祖先会把
4605
+ // 对方已经改进去的内容当成基线,合并结论变成 ours ⇒ 整文件覆盖、抹掉对方的改动。
4606
+ const usableBase = base !== null && !(base.fromGuard && sameBytes(base.bytes, theirsBytes)) ? base.bytes : null
4607
+ // 二进制(任一侧 8 KiB 内出现 NUL)绝不按文本合并:三方合并算法对它没有定义
4608
+ if (hasNulByte(oursBytes) || hasNulByte(theirsBytes) || (usableBase !== null && hasNulByte(usableBase))) {
4609
+ return { kind: 'blocked', reason: 'binary' }
4610
+ }
4611
+ const oursDecoded = decodeTextBytes(oursBytes)
4612
+ const theirsDecoded = decodeTextBytes(theirsBytes)
4613
+ const baseDecoded = usableBase === null ? null : decodeTextBytes(usableBase)
4614
+ /**
4615
+ * **base 必须真的携带「本会话改动之后」的信息**:若它与 ours(回退目标)逐字相同,说明它
4616
+ * 提供不了任何「本会话改了什么」的知识 —— 拿它当共同祖先会让合并结论变成「保持当前」,
4617
+ * 于是「回退到该点」成了「什么都不做」。
4618
+ *
4619
+ * 但**没有可用祖先**不等于「可以整文件覆盖」:撤销本会话的唯一依据是「这条路径确实是本会话
4620
+ * 写的」,而这条依据只能来自旁证(归属台账 / 活跃时间窗)或「别人的回合点里有没有它」。
4621
+ * 拿不到依据就**一个字节都不写** —— 宁可少退一次,也绝不覆盖别人的(或用户的)内容。
4622
+ * 只有在**完全没有证据**(老数据 / 未接时间窗)时才退回旧口径,与加这套判据之前逐字一致:
4623
+ * 判据只允许「少做」,不允许凭空多拦。
4624
+ */
4625
+ if (usableBase === null || (baseDecoded !== null && baseDecoded.ok && oursDecoded.ok && baseDecoded.text === oursDecoded.text)) {
4626
+ if (evidence.provenance === 'foreign' || evidence.absentAtForeignPoint) return { kind: 'blocked', reason: 'no-base' }
4627
+ if (evidence.provenance === 'unclaimed') return { kind: 'blocked', reason: 'unclaimed' }
4628
+ if (usableBase === null) return { kind: 'fallback' }
4629
+ return evidence.provenance === 'target' ? { kind: 'fallback' } : { kind: 'blocked', reason: 'unclaimed' }
4630
+ }
4631
+ /**
4632
+ * 合并的文本视图:三方都能**无损 UTF-8 往返** ⇒ 按 UTF-8 文本合并,落盘按 result.shape
4633
+ *(行尾 / BOM 由 text-fidelity 描述,encodeTextBytes 负责还原);
4634
+ * 否则退到 **latin1 视图** —— latin1 是字节的双射,「字符串相等」= 「字节相等」,
4635
+ * 行级 diff / 合并因此等价于**按字节**做:非法 UTF-8(GBK / Latin-1 / 半截序列)逐字保留,
4636
+ * 绝不会被替换成 U+FFFD(2026-10-18 修:真机把 0xE9 写成 EF BF BD)。
4637
+ */
4638
+ const baseBytes = usableBase as Uint8Array
4639
+ const useLatin1 = !oursDecoded.ok || !theirsDecoded.ok || baseDecoded === null || !baseDecoded.ok
4640
+ const oursText = useLatin1 ? latin1Of(oursBytes) : (oursDecoded as { ok: true; text: string }).text
4641
+ const theirsText = useLatin1 ? latin1Of(theirsBytes) : (theirsDecoded as { ok: true; text: string }).text
4642
+ const baseText = useLatin1 ? latin1Of(baseBytes) : (baseDecoded as { ok: true; text: string }).text
4378
4643
  const result = mergeThreeWay(oursText, baseText, theirsText)
4379
4644
  if (!result.ok) {
4380
- return { kind: 'blocked', reason: result.reason === 'conflict' ? 'conflict' : result.reason === 'degraded' ? 'degraded' : 'binary' }
4645
+ return {
4646
+ kind: 'blocked',
4647
+ reason: result.reason === 'conflict' ? 'conflict'
4648
+ : result.reason === 'degraded' ? 'degraded'
4649
+ : result.reason === 'non-utf8' ? 'non-utf8' : 'binary',
4650
+ }
4381
4651
  }
4382
4652
  // 调用方只在「当前内容 ≠ 记录内容」时才会走到这里 ⇒ `ours === theirs` 只可能是内容恰好相同
4383
4653
  if (oursText === theirsText) return { kind: 'unchanged' }
@@ -4390,7 +4660,11 @@ export class RollbackPointEngine {
4390
4660
  // 一个字节都不写,让用户看到并自己决定。
4391
4661
  return { kind: 'blocked', reason: 'ambiguous' }
4392
4662
  }
4393
- const bytes = Buffer.from(result.text, 'utf8')
4663
+ // 落盘字节:UTF-8 路径按 shape 还原 BOM(result.text 里不含 BOM);latin1 路径逐字节回写。
4664
+ // 两条路径的行尾都已由 mergeThreeWay 按 shape.eol 收尾 ⇒ CRLF 文件不再被折成 LF。
4665
+ const bytes = useLatin1
4666
+ ? Buffer.from(result.text, 'latin1')
4667
+ : Buffer.from(encodeTextBytes(result.text, result.shape))
4394
4668
  return { kind: 'write', bytes, sha256: sha256Hex(bytes), expectedCurrent: sha256Hex(theirsBytes) }
4395
4669
  }
4396
4670
 
@@ -4406,15 +4680,90 @@ export class RollbackPointEngine {
4406
4680
  * **本会话的点**(见上)、以及内容读不出来的点(继续往后找 —— 找不到 ⇒ 调用方按「无法安全撤销」
4407
4681
  * 处理,绝不用近似值代替)。
4408
4682
  */
4409
- private async baseVersionFor(target: string, afterAt: number, excludeSessionId: string | null): Promise<string | null> {
4683
+ /**
4684
+ * 这条路径**是谁写的** —— 只读旁证(归属台账 + 活跃时间窗),**绝不从内容猜**。
4685
+ *
4686
+ * 优先归属台账(tool/call 参数,精确到调用);没有归属时看文件 mtime 落在谁的活跃窗口内 ——
4687
+ * 这一条与工具无关,因此 bash / 脚本 / 外部编辑器写的路径同样能被判出来。
4688
+ * `hasDataFor` 是「有没有覆盖」的自证:**没有覆盖就返回 unknown**(不拦),有覆盖却没有任何会话
4689
+ * 活跃在那一刻才返回 unclaimed(手改 / 外部工具 / 另一个 DSH 进程)。
4690
+ */
4691
+ private async provenanceOf(target: string, capturedAt: number, sessionId: string | null): Promise<WriteProvenance> {
4692
+ if (sessionId === null) return 'unknown'
4693
+ const writer = this.attribution?.writerOf(target)
4694
+ if (writer !== undefined && writer.at > capturedAt) {
4695
+ return writer.sessionId === sessionId ? 'target' : 'foreign'
4696
+ }
4697
+ const activity = this.activity
4698
+ if (activity === undefined) return 'unknown'
4699
+ let mtimeMs: number
4700
+ try {
4701
+ mtimeMs = (await fs.stat(target)).mtimeMs
4702
+ } catch {
4703
+ return 'unknown'
4704
+ }
4705
+ if (!(mtimeMs > capturedAt)) return 'unknown'
4706
+ const active = activity.sessionsActiveAt(mtimeMs)
4707
+ if (active.length > 0) {
4708
+ // 只有「那一刻唯一活跃的就是本会话」才算自有证据;多会话同时活跃 ⇒ 保守当成别人的
4709
+ if (active.includes(sessionId) && active.every((id) => id === sessionId)) return 'target'
4710
+ return 'foreign'
4711
+ }
4712
+ return activity.hasDataFor(sessionId) ? 'unclaimed' : 'unknown'
4713
+ }
4714
+
4715
+ /**
4716
+ * 目标点之后**别的会话的回合点**里,有没有哪个**不含**这条路径(= 那一刻它不存在)。
4717
+ *
4718
+ * 它证明的是:「当前磁盘上这份内容是该点之后才出现的」⇒ 拿目标点的内容整文件覆盖它,等于
4719
+ * 抹掉对方新建/重建的工作(2026-10-18:A 删文件、B 重建,回退 A 把 B 的内容覆盖回旧版本)。
4720
+ * 截断的点(`payload.truncated`)不能用来证明「不存在」,一律跳过;只扫最早的若干个点(有界)。
4721
+ */
4722
+ private async absentAtForeignPointOf(target: string, afterAt: number, sessionId: string | null): Promise<boolean> {
4410
4723
  const ledger = await this.readManifest()
4411
4724
  const key = pathKey(target)
4412
4725
  const candidates = ledger.entries
4413
4726
  .filter((entry) => entry.createdAt > afterAt
4414
4727
  && entry.kind !== 'guard'
4415
- // 只认**别的会话**的点:本会话后续回合的点当作 base,会把「本会话后续改动」也一并保留下来,
4416
- // 那与「回退到该点之前」的直觉冲突;而别人的点正好是「本会话改动之后、对方改动之前」的版本。
4417
- && (excludeSessionId === null || entry.sessionId !== excludeSessionId))
4728
+ && (sessionId === null || entry.sessionId !== sessionId))
4729
+ .sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
4730
+ .slice(0, 8)
4731
+ for (const entry of candidates) {
4732
+ const resolved = await this.readFullRecord(entry.id)
4733
+ const payload = resolved.record?.workspace.payload
4734
+ if (payload === null || payload === undefined) continue
4735
+ if (payload.truncated === true) continue
4736
+ if (!payload.files.some((item) => pathKey(item.path) === key)) return true
4737
+ }
4738
+ return false
4739
+ }
4740
+
4741
+ /** 两档 base:**别的会话的回合点**优先,其次**救援点**(见 baseVersionFor 的判据注释) */
4742
+ private async baseFor(target: string, afterAt: number, excludeSessionId: string | null): Promise<{ bytes: Uint8Array; fromGuard: boolean } | null> {
4743
+ const turn = await this.baseVersionFor(target, afterAt, excludeSessionId, false)
4744
+ if (turn !== null) return turn
4745
+ return await this.baseVersionFor(target, afterAt, excludeSessionId, true)
4746
+ }
4747
+
4748
+ private async baseVersionFor(
4749
+ target: string,
4750
+ afterAt: number,
4751
+ excludeSessionId: string | null,
4752
+ fromGuard: boolean,
4753
+ ): Promise<{ bytes: Uint8Array; fromGuard: boolean } | null> {
4754
+ const ledger = await this.readManifest()
4755
+ const key = pathKey(target)
4756
+ const candidates = ledger.entries
4757
+ .filter((entry) => entry.createdAt > afterAt
4758
+ // 第一档(fromGuard=false)= **别的会话的回合点**:共同祖先的正统来源。本会话后续回合的点
4759
+ // 不能当 base —— 它会把「本会话后续改动」也一并保留,与「回退到该点之前」的直觉冲突。
4760
+ // 第二档(fromGuard=true)= **救援点**:连续回退(先退 A 再退 B)时,B 的目标点之后往往只有
4761
+ // 第一次回退留下的救援点,而它的内容正是「A 已改、B 未改」那一刻的状态 —— 只有它能算出
4762
+ // 「把 A 的改动也一并撤销、同时保住 B 之后的内容」。旧口径在这时直接整文件覆盖 ⇒ A 的改动
4763
+ // 复活、B 的改动被抹掉(2026-10-18 真机可复现)。救援点与当前磁盘相同的情形由调用方挡掉。
4764
+ && (fromGuard
4765
+ ? entry.kind === 'guard'
4766
+ : entry.kind !== 'guard' && (excludeSessionId === null || entry.sessionId !== excludeSessionId)))
4418
4767
  .sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
4419
4768
  for (const entry of candidates) {
4420
4769
  const resolved = await this.readFullRecord(entry.id)
@@ -4425,7 +4774,7 @@ export class RollbackPointEngine {
4425
4774
  try {
4426
4775
  const bytes = await this.readObject(hit.object)
4427
4776
  if (sha256Hex(bytes) !== hit.sha256) continue
4428
- return Buffer.from(bytes).toString('utf8')
4777
+ return { bytes, fromGuard }
4429
4778
  } catch {
4430
4779
  continue
4431
4780
  }
@@ -4515,7 +4864,7 @@ export class RollbackPointEngine {
4515
4864
  */
4516
4865
  const mergeGuardEnabled = selection === null && isolationSessionId !== null
4517
4866
  /** 同一路径的 base 只查一次(合并判定在「确认需要写」之后,命中数很少) */
4518
- const baseCache = new Map<string, string | null>()
4867
+ const baseCache = new Map<string, { bytes: Uint8Array; fromGuard: boolean } | null>()
4519
4868
  const sink: ChunkWalkSink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false }
4520
4869
  // 删除集与**预览**共用同一份规划(planCreatedRemovals)⇒「预览说会删的」= 「实际删的」。
4521
4870
  // 为什么提到文件循环**之前**:选择模式要拿它当「合法路径」的第二来源(新建候选也允许被点名),
@@ -4553,11 +4902,14 @@ export class RollbackPointEngine {
4553
4902
  let conflictCount = 0
4554
4903
  /** 无法安全撤销:没有中间版本 / 二进制 / 差异被降级 / 对方把它改成了不存在 */
4555
4904
  let unavailableCount = 0
4905
+ /** 落点无人认领(时间窗表明那一刻没有会话活跃)⇒ 拒绝覆盖,交由用户决定 */
4906
+ let externalCount = 0
4556
4907
  /** 写入前发现文件又被改过(并发)⇒ 放弃写入 */
4557
4908
  let concurrentCount = 0
4558
4909
  const mergedPaths: string[] = []
4559
4910
  const conflictPaths: string[] = []
4560
4911
  const unavailablePaths: string[] = []
4912
+ const externalPaths: string[] = []
4561
4913
  const concurrentPaths: string[] = []
4562
4914
  for (const file of payload.files) {
4563
4915
  // 路径级选择:没被点名的记录条目**不写**(用户自己的选择,不是安全判据)
@@ -4593,44 +4945,42 @@ export class RollbackPointEngine {
4593
4945
  // **合并保护**(2026-10-17 修):到这里才确认「这个文件确实需要写」,也才值得去查共同祖先。
4594
4946
  // 触发不吃归属:只要目标点之后存在**别的会话**的回合点(那它就是共同祖先),就走三方合并。
4595
4947
  if (mergeGuardEnabled) {
4596
- let base: string | null
4597
- const cached = baseCache.get(pathKey(file.path))
4598
- if (cached === undefined) {
4599
- base = await this.baseVersionFor(file.path, track.capturedAt, isolationSessionId)
4948
+ let base = baseCache.get(pathKey(file.path))
4949
+ if (base === undefined) {
4950
+ base = await this.baseFor(file.path, track.capturedAt, isolationSessionId)
4600
4951
  baseCache.set(pathKey(file.path), base)
4601
- } else {
4602
- base = cached
4603
4952
  }
4604
- const foreignWriter = this.attribution?.writerOf(file.path)
4605
- const foreignEvidence = foreignWriter !== undefined
4606
- && foreignWriter.sessionId !== isolationSessionId
4607
- && foreignWriter.at > track.capturedAt
4608
- if (base !== null || foreignEvidence) {
4609
- const outcome = await this.mergeForeignRecord(file, base, foreignEvidence)
4610
- if (outcome.kind === 'fallback') {
4611
- // 没有可用祖先、也没有别人的旁证 ⇒ 落到下面的旧口径(整文件覆盖回目标版本)
4612
- } else {
4613
- if (outcome.kind === 'write') {
4614
- writable.push({ file, bytes: outcome.bytes, sha256: outcome.sha256, expectedCurrent: outcome.expectedCurrent })
4615
- mergedCount += 1
4616
- if (mergedPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) mergedPaths.push(file.path)
4617
- continue
4618
- }
4619
- if (outcome.kind === 'unchanged') {
4620
- unchanged += 1
4621
- continue
4622
- }
4953
+ const evidence: ForeignEvidence = {
4954
+ provenance: await this.provenanceOf(file.path, track.capturedAt, isolationSessionId),
4955
+ // 「别人的回合点里没有它」只在**拿不到祖先**时才问(有祖先就不必再读一遍台账)
4956
+ absentAtForeignPoint: base === null
4957
+ && await this.absentAtForeignPointOf(file.path, track.capturedAt, isolationSessionId),
4958
+ }
4959
+ const outcome = await this.mergeForeignRecord(file, base, evidence)
4960
+ if (outcome.kind === 'fallback') {
4961
+ // 没有可用祖先、也没有任何旁证 ⇒ 落到下面的旧口径(整文件覆盖回目标版本)
4962
+ } else if (outcome.kind === 'write') {
4963
+ writable.push({ file, bytes: outcome.bytes, sha256: outcome.sha256, expectedCurrent: outcome.expectedCurrent })
4964
+ mergedCount += 1
4965
+ if (mergedPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) mergedPaths.push(file.path)
4966
+ continue
4967
+ } else if (outcome.kind === 'unchanged') {
4968
+ unchanged += 1
4969
+ continue
4970
+ } else {
4623
4971
  foreignSkipped += 1
4624
4972
  if (outcome.reason === 'conflict') {
4625
4973
  conflictCount += 1
4626
4974
  if (conflictPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) conflictPaths.push(file.path)
4975
+ } else if (outcome.reason === 'unclaimed') {
4976
+ externalCount += 1
4977
+ if (externalPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) externalPaths.push(file.path)
4627
4978
  } else {
4628
4979
  unavailableCount += 1
4629
4980
  if (unavailablePaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) unavailablePaths.push(file.path)
4630
4981
  }
4631
4982
  pushDetail(detail, 'foreign-' + outcome.reason + ' ' + file.path + ' zeroWrites=true')
4632
4983
  continue
4633
- }
4634
4984
  }
4635
4985
  }
4636
4986
  writable.push({ file, bytes })
@@ -4697,10 +5047,17 @@ export class RollbackPointEngine {
4697
5047
  }
4698
5048
  }
4699
5049
  if (failed > 0) {
4700
- // 零写入:一条不合格就不写任何一条
5050
+ // 零写入:一条不合格就不写任何一条。
5051
+ // **reasonCode 必须分得清成因**(2026-10-18 X-12a 真机):这里此前一律写 'invalid-path',
5052
+ // 把「对象仓丢对象 / 哈希不符」也报成「路径非法」—— 界面与排障都会指向错误的方向。
5053
+ // 判据只读本段已累积的 detail(与回传的逐条说明同源,不另算一套口径)。
5054
+ const hasDetail = (prefix: string): boolean => detail.some((line) => line.startsWith(prefix))
5055
+ const reasonCode: RollbackCode = hasDetail('object-missing') ? 'object-missing'
5056
+ : hasDetail('object-hash-mismatch') ? 'object-hash-mismatch'
5057
+ : 'invalid-path'
4701
5058
  detail.push('restored=0 failed=' + String(failed) + ' leftovers=' + String(leftovers.count) + ' zeroWrites=true')
4702
5059
  return {
4703
- state: 'workspace', status: 'failed', reasonCode: 'invalid-path', detail,
5060
+ state: 'workspace', status: 'failed', reasonCode, detail,
4704
5061
  restored: 0, failed,
4705
5062
  leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
4706
5063
  }
@@ -4755,6 +5112,13 @@ export class RollbackPointEngine {
4755
5112
  if (mergedCount > 0) { noop.mergedCount = mergedCount; noop.mergedPaths = mergedPaths }
4756
5113
  if (conflictCount > 0) { noop.conflictCount = conflictCount; noop.conflictPaths = conflictPaths }
4757
5114
  if (unavailableCount > 0) { noop.unavailableCount = unavailableCount; noop.unavailablePaths = unavailablePaths }
5115
+ if (externalCount > 0) { noop.externalCount = externalCount; noop.externalPaths = externalPaths }
5116
+ // **noop 也要保住原因**:冲突 / 无法安全撤销 / 无人认领的逐条说明都累积在 detail 里。
5117
+ // 只留一行 nothingToDo=true 会让用户看到「没有可退的」却看不到「有 3 个文件因冲突没退」
5118
+ //(2026-10-18 X-3 真机形状:conflictCount=1 但 detail 里一个字都没有)。
5119
+ for (const line of detail) {
5120
+ if (line.startsWith('foreign-') || line.startsWith('concurrent-change')) noop.detail.push(line)
5121
+ }
4758
5122
  if (concurrentCount > 0) { noop.concurrentCount = concurrentCount; noop.concurrentPaths = concurrentPaths }
4759
5123
  return noop
4760
5124
  }
@@ -4817,7 +5181,7 @@ export class RollbackPointEngine {
4817
5181
  }
4818
5182
  }
4819
5183
  try {
4820
- await stager.stage(item.file.path, bytes)
5184
+ await stager.stage(item.file.path, bytes, { sha256: item.sha256 ?? item.file.sha256 })
4821
5185
  } catch (error) {
4822
5186
  // 暂存写不下去(ENOSPC / EACCES):**零写入失败** —— 绝不带着不完整的前像开工,
4823
5187
  // 也绝不让异常穿出引擎(那会被路由兜成没有机器码的裸 500,且暂存目录无人清理)。
@@ -4890,7 +5254,7 @@ export class RollbackPointEngine {
4890
5254
  removal.skipped.push(target)
4891
5255
  continue
4892
5256
  }
4893
- await stager.stage(target, bytes)
5257
+ await stager.stage(target, bytes, { deleted: true })
4894
5258
  destroyedCount += 1
4895
5259
  await fs.rm(target, { force: true })
4896
5260
  removed += 1
@@ -4937,9 +5301,12 @@ export class RollbackPointEngine {
4937
5301
  if (mergedCount > 0) { segment.mergedCount = mergedCount; segment.mergedPaths = mergedPaths }
4938
5302
  if (conflictCount > 0) { segment.conflictCount = conflictCount; segment.conflictPaths = conflictPaths }
4939
5303
  if (unavailableCount > 0) { segment.unavailableCount = unavailableCount; segment.unavailablePaths = unavailablePaths }
5304
+ if (externalCount > 0) { segment.externalCount = externalCount; segment.externalPaths = externalPaths }
4940
5305
  if (concurrentCount > 0) { segment.concurrentCount = concurrentCount; segment.concurrentPaths = concurrentPaths }
4941
5306
  // 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
4942
5307
  if (removal.refused !== null) segment.createdRefusedReason = removal.refused
5308
+ // 全部写入 / 删除都成功 ⇒ 先落 done 标记(这条前像不再是「中断的回退」),再清前像字节。
5309
+ await stager.finish()
4943
5310
  // 前像字节用完即清(成功路径):留在磁盘上只会变成无人认领的残留
4944
5311
  await stager.dispose()
4945
5312
  return segment
@@ -5178,6 +5545,11 @@ export interface RollbackTrackerOptions {
5178
5545
  * 回退行为与旧版**逐字一致**。
5179
5546
  */
5180
5547
  attribution?: RollbackWriteRecorder
5548
+ /**
5549
+ * 会话活跃时间窗采集器(2026-10-18)。缺省 = 不采集 ⇒ 归属判定只剩 tool/call 参数那一条。
5550
+ * **每个事件都记一次**(与工具参数无关),因此 bash / 脚本 / 外部编辑器写的路径同样有归属证据。
5551
+ */
5552
+ activity?: RollbackActivityRecorder
5181
5553
  }
5182
5554
 
5183
5555
  export interface RollbackTrackerResult {
@@ -5358,6 +5730,7 @@ export class RollbackAutoTracker {
5358
5730
  private readonly log: ((line: string) => void) | undefined
5359
5731
  private readonly statSessionLog: ((logPath: string) => Promise<{ size: number; mtimeMs: number } | undefined>) | undefined
5360
5732
  private readonly attribution: RollbackWriteRecorder | undefined
5733
+ private readonly activity: RollbackActivityRecorder | undefined
5361
5734
  private readonly bindings = new Map<string, RollbackSessionBinding>()
5362
5735
  private readonly open = new Map<string, { turn: number | null; startSeq: number; boundarySeq: number | null }>()
5363
5736
  /**
@@ -5383,6 +5756,7 @@ export class RollbackAutoTracker {
5383
5756
  this.log = options.log
5384
5757
  this.statSessionLog = options.statSessionLog
5385
5758
  this.attribution = options.attribution
5759
+ this.activity = options.activity
5386
5760
  }
5387
5761
 
5388
5762
  /** 实际采用的留点机制(output / 诊断用) */
@@ -5446,6 +5820,9 @@ export class RollbackAutoTracker {
5446
5820
  async noteEvent(event: RollbackTrackedEvent): Promise<RollbackTrackerResult> {
5447
5821
  try {
5448
5822
  this.mechanism = 'event'
5823
+ // 时间窗归属(2026-10-18):**每个**事件都记一次活跃 —— 与工具参数无关,因此 bash / 脚本 /
5824
+ // 外部编辑器写的文件也能按「mtime 落在谁的活跃窗口内」判定归属。零 I/O(内存 + debounce 落盘)。
5825
+ this.activity?.noteActivity(event.sessionId, this.nowFn())
5449
5826
  const binding = this.bindings.get(event.sessionId)
5450
5827
  if (binding === undefined) return { captured: false, code: 'invalid-input', detail: 'unknown-session ' + event.sessionId }
5451
5828
  // 未开回合时出现的「待消费输入」属于**下一个**回合:边界必须落在它之前,