dsh-vscode-mode 0.25.1 → 0.27.0

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 (42) hide show
  1. package/README.md +25 -11
  2. package/lib/client.js +1969 -1344
  3. package/lib/client.js.map +2 -2
  4. package/lib/index.js +1443 -716
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/compatSummary.ts +4 -0
  8. package/src/client/contextMenusOfficial.ts +80 -0
  9. package/src/client/forgeBridge.ts +28 -0
  10. package/src/client/index.ts +18 -3
  11. package/src/client/monaco/lsp/lspClient.ts +25 -7
  12. package/src/client/officialSidebar.ts +15 -3
  13. package/src/client/reviewBar.ts +3 -0
  14. package/src/client/reviewBatch.ts +9 -1
  15. package/src/client/reviewKeepAll.ts +72 -0
  16. package/src/client/sidebar/menuItems.ts +6 -0
  17. package/src/client/sidebar/panels/FileExplorer.ts +26 -1
  18. package/src/client/sidebar/types.ts +3 -0
  19. package/src/client/state/diffSummary.ts +34 -7
  20. package/src/client/state/recordsFeed.ts +40 -3
  21. package/src/client/state/reviewFeed.ts +9 -2
  22. package/src/client/tabMenu.ts +5 -0
  23. package/src/client/ui/ClaimRouter.ts +11 -5
  24. package/src/client/ui/EditorPane.ts +9 -1
  25. package/src/client/ui/EditorView.ts +164 -39
  26. package/src/client/ui/OfficialSideTab.ts +13 -5
  27. package/src/client/ui/ReviewAllConfirm.ts +1 -1
  28. package/src/index.ts +1 -1
  29. package/src/pending/capture.ts +96 -47
  30. package/src/pending/persist.ts +94 -5
  31. package/src/pending/store.ts +171 -58
  32. package/src/pendingCompat.ts +72 -9
  33. package/src/pendingRpc.ts +10 -10
  34. package/src/reviewCapture.ts +64 -23
  35. package/src/reviewDb.ts +119 -8
  36. package/src/reviewEvidence.ts +1 -1
  37. package/src/reviewRpc.ts +115 -9
  38. package/src/rpc.ts +14 -10
  39. package/src/shared/review.ts +21 -0
  40. package/src/shared/rpc.ts +3 -1
  41. package/src/store.ts +44 -0
  42. package/src/toolTargets.ts +63 -0
@@ -5,10 +5,9 @@
5
5
  import type { PendingDecide, PendingDetail, PendingHistoryMove, PendingItem, PendingPage } from '../shared/pendingReview.js'
6
6
  import type { ReviewBlockState } from '../shared/reviewRegions.js'
7
7
  import { reviewBlocks } from '../shared/reviewRegions.js'
8
- import { unlink } from 'node:fs/promises'
9
- import { resolve, sep } from 'node:path'
8
+ import { addedFiles } from '../reviewDb.js'
10
9
  import type { PendingWrite } from './capture.js'
11
- import { readText } from './capture.js'
10
+ import { readText, pendingGaps } from './capture.js'
12
11
  import type { ShellLike, VcsImportOptions } from './vcs.js'
13
12
  import { importVcsChanges } from './vcs.js'
14
13
  import type { DecideOutcome, WriteResult } from './decide.js'
@@ -16,9 +15,10 @@ import { capabilityOf, decide, diffOf } from './decide.js'
16
15
  import { entryFromNew, isSettled, mergeEntries } from './kernel.js'
17
16
  import type { PendingEntry } from './kernel.js'
18
17
  import type { PendingFlags, StoredEntry } from './persist.js'
18
+ import type { KeepRecord } from './persist.js'
19
19
  import {
20
- dropEntry, earliestUndone, latestApplied, loadBlocks, loadEntries, pushHistory,
21
- readOp, saveBlocks, saveEntry, setHistoryState, writeOp,
20
+ dropEntry, earliestUndone, latestApplied, loadBlocks, loadEntries, pendingDb, pushHistory,
21
+ readOp, saveBlocks, saveDecisions, saveEntry, setHistoryState, writeOp,
22
22
  } from './persist.js'
23
23
 
24
24
  /**
@@ -120,19 +120,43 @@ export function itemOf(stored: StoredEntry, baselineEmpty = false): PendingItem
120
120
  return item
121
121
  }
122
122
 
123
+ /**
124
+ * @private
125
+ * @author ddj 2026年11月04号
126
+ * @description 从条目来源判断创建基线;任何决定历史都阻止展示层复活已推进的旧基线。
127
+ * @param cwd 工作区
128
+ * @param stored 累计条目
129
+ * @param sourceAdds 单次读取内的来源创建集合缓存
130
+ * @returns 是否将展示基线置空
131
+ */
132
+ function emptyBaseOf(cwd: string, stored: StoredEntry, sourceAdds = new Map<string, Set<string>>()): boolean {
133
+ if (isSettled(stored.entry)) return false
134
+ const decided = pendingDb(cwd).prepare('SELECT 1 FROM pending_history WHERE entry_id=? LIMIT 1').get(stored.entry.id)
135
+ if (decided) return false
136
+ for (const sourceId of stored.entry.sessionIds) {
137
+ let createdPaths = sourceAdds.get(sourceId)
138
+ if (!createdPaths) {
139
+ createdPaths = addedFiles(cwd, sourceId)
140
+ sourceAdds.set(sourceId, createdPaths)
141
+ }
142
+ if (createdPaths.has(stored.entry.id)) return true
143
+ }
144
+ return false
145
+ }
146
+
123
147
  /**
124
148
  * @author ddj 2026年10月29号
125
- * @description: 读一页待审条目,按更新时间升序;会话过滤走血缘集合。
149
+ * @description: 读工作区全部待审条目,按更新时间升序;来源会话仅用于追溯。
126
150
  * @param cwd 工作区
127
- * @param sessionId 当前会话
128
- * @param wholeAdd 本次会话新建的文件身份集合,命中时基线置空
151
+ * @param _sessionId 兼容旧调用签名,不再用于可见性过滤
129
152
  * @returns 列表
130
153
  */
131
- export function loadPage(cwd: string, sessionId?: string, wholeAdd?: ReadonlySet<string>): PendingPage {
154
+ export function loadPage(cwd: string, _sessionId?: string): PendingPage {
132
155
  const all = loadEntries(cwd)
133
- const visible = sessionId ? all.filter((stored) => stored.entry.sessionIds.includes(sessionId)) : all
134
- const items = visible.map((stored) => itemOf(stored, wholeAdd?.has(stored.entry.id) === true))
135
- const gaps: string[] = []
156
+ const visible = all
157
+ const sourceAdds = new Map<string, Set<string>>()
158
+ const items = visible.map((stored) => itemOf(stored, emptyBaseOf(cwd, stored, sourceAdds)))
159
+ const gaps: string[] = pendingGaps(cwd)
136
160
  for (const stored of visible) {
137
161
  if (stored.flags.oversized) gaps.push(`${stored.entry.path}: 文本超出上限,仅保留标记`)
138
162
  }
@@ -150,9 +174,10 @@ export function loadPage(cwd: string, sessionId?: string, wholeAdd?: ReadonlySet
150
174
  export function loadDetail(cwd: string, id: string, baselineEmpty = false): PendingDetail | null {
151
175
  const stored = loadEntries(cwd).find((item) => item.entry.id === id)
152
176
  if (!stored) return null
153
- const diff = diffOf(stored.entry, baselineEmpty)
177
+ const emptyBase = baselineEmpty || emptyBaseOf(cwd, stored)
178
+ const diff = diffOf(stored.entry, emptyBase)
154
179
  return {
155
- item: itemOf(stored, baselineEmpty),
180
+ item: itemOf(stored, emptyBase),
156
181
  diff,
157
182
  blocks: blockStatesOf(stored.entry),
158
183
  capability: capabilityOf(stored.entry, stored.flags),
@@ -229,19 +254,19 @@ export function writerFor(
229
254
  } catch {
230
255
  return { ok: false, reason: 'resolve-failed' }
231
256
  }
232
- const current = await readText(fs, target).catch(() => undefined)
233
- if (current === undefined) return { ok: false, reason: 'read-failed' }
234
- // 撤销「Revert 删除新建文件」时文件已不存在,只有显式允许重建才放行
235
- const recreate = allowCreate && current === null
236
- if (!recreate && current !== expected) return { ok: false, reason: 'diverged' }
237
- const currentStat = await fs.stat(target, signal).catch(() => undefined)
257
+ const snapshot = await stableRead(fs, target, signal)
258
+ if (!snapshot.ok) return snapshot
259
+ // 缺失仅允许撤销/重做重建;原子 createIfAbsent 防止检查后被其他写入抢占。
260
+ const recreate = allowCreate && snapshot.content === null
261
+ if (!recreate && snapshot.content !== expected) return { ok: false, reason: 'diverged' }
262
+ if (snapshot.content === null && !recreate) return { ok: false, reason: 'missing' }
263
+ const intent = recreate
264
+ ? { kind: 'createIfAbsent' }
265
+ : { kind: 'replaceIfVersion', version: snapshot.version }
238
266
  try {
239
- // 第五个实参是沙箱策略;缺它时 provider 会以 FS_SANDBOX_DENIED 拒绝写入
240
- const written = await fs.writeText(
241
- target, content ?? '', currentStat ? { kind: 'replaceIfVersion', version: currentStat.version } : undefined, signal, policy,
242
- )
267
+ const written = await fs.writeText(target, content ?? '', intent, signal, policy)
243
268
  const readBack = await readText(fs, target)
244
- if ((content ?? '') !== (readBack ?? '')) return { ok: false, reason: 'read-back-mismatch' }
269
+ if (content !== readBack) return { ok: false, reason: 'read-back-mismatch' }
245
270
  if (content === null && readBack !== null) return { ok: false, reason: 'delete-failed' }
246
271
  void written
247
272
  return { ok: true, content }
@@ -273,7 +298,7 @@ export async function applyDecide(
273
298
  blocks: beforeBlocks,
274
299
  now: Date.now(),
275
300
  write: (content) => {
276
- if (content === null) return deleteTarget(fs, cwd, stored.entry.id, signal)
301
+ if (content === null) return deleteTarget(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)
277
302
  return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)(content)
278
303
  },
279
304
  })
@@ -289,6 +314,80 @@ export async function applyDecide(
289
314
  return outcome
290
315
  }
291
316
 
317
+ /** 批量 Keep 的目标条目:display 只用于回报,operationId 由 host 按批次派生。 */
318
+ export interface PendingKeepTarget {
319
+ id: string
320
+ display: string
321
+ operationId: string
322
+ }
323
+ /** 批量 Keep 的结果:kept 是真正推进到保留态的条目,skipped 说明该条为什么没保留。 */
324
+ export interface PendingKeepOutcome {
325
+ kept: { id: string; path: string }[]
326
+ skipped: { id: string; display: string; reason: string }[]
327
+ }
328
+
329
+ /**
330
+ * @public
331
+ * @author ddj 2026年11月03号
332
+ * @description: 一次读取全表后批量保留待审条目(Keep 不写盘)。写入用刚读到的最新 revision,
333
+ * 客户端快照漂移不会整批中止;单条 oversized、不存在或落库时被抢先推进只跳过自己。
334
+ * @param cwd 工作区
335
+ * @param items 待保留条目
336
+ * @returns 保留成功与跳过的明细
337
+ * @throws 待审库迁移未就绪等存储级异常
338
+ */
339
+ export async function keepAll(cwd: string, items: PendingKeepTarget[]): Promise<PendingKeepOutcome> {
340
+ const stored = new Map(loadEntries(cwd).map((item) => [item.entry.id, item]))
341
+ const now = Date.now()
342
+ const outcome: PendingKeepOutcome = { kept: [], skipped: [] }
343
+ const records: KeepRecord[] = []
344
+
345
+ for (const item of items) {
346
+ const before = stored.get(item.id)
347
+ if (!before) {
348
+ outcome.skipped.push({ id: item.id, display: item.display, reason: '待审条目不存在' })
349
+ continue
350
+ }
351
+ if (before.flags.oversized) {
352
+ outcome.skipped.push({ id: item.id, display: item.display, reason: '文本超出上限,不能整文件保留' })
353
+ continue
354
+ }
355
+ // 已经是保留态:幂等无操作,仍算保留成功
356
+ if (isSettled(before.entry)) {
357
+ outcome.kept.push({ id: item.id, path: before.entry.path })
358
+ continue
359
+ }
360
+
361
+ const beforeBlocks = blockStatesOf(before.entry)
362
+ const outcomeOf = await decide({
363
+ request: { sessionId: before.entry.sessionId, id: item.id, revision: before.flags.revision,
364
+ operationId: item.operationId, decision: 'accepted', scope: 'file' },
365
+ entry: before.entry, flags: before.flags, blocks: beforeBlocks, now,
366
+ // Keep 永不写盘:真走到写回说明逻辑被改坏,宁可炸掉也不静默落盘
367
+ write: () => { throw new Error('批量 Keep 不写盘') },
368
+ })
369
+ if (!outcomeOf.ok) {
370
+ outcome.skipped.push({ id: item.id, display: item.display, reason: outcomeOf.reason })
371
+ continue
372
+ }
373
+ outcome.kept.push({ id: item.id, path: before.entry.path })
374
+ records.push({ entryId: item.id, stored: before, next: { entry: outcomeOf.entry, flags: outcomeOf.flags },
375
+ blocks: outcomeOf.blocks, snapshot: JSON.stringify({
376
+ before: { entry: before.entry, flags: before.flags, blocks: beforeBlocks },
377
+ after: { entry: outcomeOf.entry, flags: outcomeOf.flags, blocks: outcomeOf.blocks },
378
+ wrote: outcomeOf.wrote,
379
+ }) })
380
+ }
381
+
382
+ // 落库阶段被别的决定抢先推进的条目从 kept 挪到 skipped,如实反映磁盘状态
383
+ const displayOf = new Map(items.map((item) => [item.id, item.display]))
384
+ for (const conflict of saveDecisions(cwd, records)) {
385
+ outcome.kept = outcome.kept.filter((entry) => entry.id !== conflict.id)
386
+ outcome.skipped.push({ id: conflict.id, display: displayOf.get(conflict.id) ?? conflict.id, reason: conflict.reason })
387
+ }
388
+ return outcome
389
+ }
390
+
292
391
  /**
293
392
  * @author ddj 2026年10月30号
294
393
  * @description: 版本库导入:把工作副本相对基线的改动折进待审条目,已存在条目按折叠规则并入。
@@ -370,7 +469,7 @@ export async function undoDecide(
370
469
  async function writeBack(
371
470
  fs: any, cwd: string, stored: StoredEntry, target: HistoryState, signal?: AbortSignal, policy?: unknown,
372
471
  ): Promise<WriteResult> {
373
- if (target.flags.missing) return deleteTarget(fs, cwd, stored.entry.id, signal)
472
+ if (target.flags.missing) return deleteTarget(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)
374
473
  const write = writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy, true)
375
474
  return write(target.entry.newText)
376
475
  }
@@ -405,48 +504,62 @@ function parseSnapshot(text: string): HistorySnapshot | null {
405
504
  }
406
505
  }
407
506
 
507
+ /** 稳定快照;缺失与读取失败严格区分,不允许省略现存文件的版本。 */
508
+ type StableFile = { ok: true; content: string | null; version?: string } | { ok: false; reason: string }
509
+
408
510
  /**
409
- * @author ddj 2026年10月29号
410
- * @description: Revert 的删除分支:FileSystem 抽象没有删除能力,只有确认仍在工作区内才用宿主 unlink。
411
- * @param fs 文件系统
412
- * @param cwd 工作区
413
- * @param identity 目标身份
511
+ * @private
512
+ * @author ddj 2026年11月04号
513
+ * @description 版本—内容—版本读取,防止将新版本令牌与旧内容拼成写入依据。
514
+ * @param fs 文件提供方
515
+ * @param target 已解析目标
414
516
  * @param signal 取消信号
415
- * @returns 删除结果
517
+ * @returns 稳定快照或拒绝原因
416
518
  */
417
- async function deleteTarget(fs: any, cwd: string, identity: string, signal?: AbortSignal): Promise<WriteResult> {
418
- let target: any
419
- try {
420
- target = await fs.resolve(identity, { cwd, signal })
421
- } catch {
422
- return { ok: false, reason: 'resolve-failed' }
423
- }
424
- const currentStat = await fs.stat(target, signal).catch(() => undefined)
425
- if (!currentStat) return { ok: true, content: null }
426
- const absolute = String(fs.processPath(target))
427
- if (!insideWorkspace(cwd, absolute)) return { ok: false, reason: 'outside-workspace' }
519
+ async function stableRead(fs: any, target: any, signal?: AbortSignal): Promise<StableFile> {
428
520
  try {
429
- await unlink(absolute)
521
+ const before = await fs.stat(target, signal)
522
+ if (!before) return { ok: true, content: null }
523
+ if (before.type !== 'file' || !before.version) return { ok: false, reason: 'unguarded-file' }
524
+ const content = await readText(fs, target, signal)
525
+ if (content === null) return { ok: false, reason: 'revision-mismatch' }
526
+ const after = await fs.stat(target, signal)
527
+ if (after?.type !== 'file' || after.version !== before.version) return { ok: false, reason: 'revision-mismatch' }
528
+ return { ok: true, content, version: before.version }
430
529
  } catch (error) {
431
530
  return { ok: false, reason: reasonOf(error) }
432
531
  }
433
- const after = await fs.stat(target, signal).catch(() => undefined)
434
- return after ? { ok: false, reason: 'delete-failed' } : { ok: true, content: null }
435
532
  }
436
533
 
437
534
  /**
438
- * @author ddj 2026年10月29号
439
- * @description: 判断宿主路径是否位于工作区内,Windows 下按大小写不敏感比较。
440
- * @param cwd 工作区
441
- * @param absolute 目标绝对路径
442
- * @returns 是否在工作区内
535
+ * @private
536
+ * @author ddj 2026年11月04号
537
+ * @description 通过 provider 受控删除,保留会话策略与原子版本校验;旧 Host 不回落直删。
538
+ * @param fs 文件提供方
539
+ * @param cwd 工作目录
540
+ * @param identity 目标身份
541
+ * @param expected 应有内容
542
+ * @param signal 取消信号
543
+ * @param policy 会话沙箱策略
544
+ * @returns 删除结果;结果不明不提交决定
443
545
  */
444
- function insideWorkspace(cwd: string, absolute: string): boolean {
445
- const root = resolve(cwd)
446
- const candidate = resolve(absolute)
447
- const normalize = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
448
- const rootKey = normalize(root).endsWith(sep) ? normalize(root) : normalize(root) + sep
449
- return normalize(candidate).startsWith(rootKey)
546
+ async function deleteTarget(
547
+ fs: any, cwd: string, identity: string, expected: string, signal?: AbortSignal, policy?: unknown,
548
+ ): Promise<WriteResult> {
549
+ if (typeof fs.deleteFile !== 'function') return { ok: false, reason: 'delete-unavailable' }
550
+ try {
551
+ const target = await fs.resolve(identity, { cwd, signal })
552
+ const snapshot = await stableRead(fs, target, signal)
553
+ if (!snapshot.ok) return snapshot
554
+ if (snapshot.content === null) return { ok: true, content: null }
555
+ if (snapshot.content !== expected) return { ok: false, reason: 'diverged' }
556
+
557
+ await fs.deleteFile(target, { version: snapshot.version }, signal, policy)
558
+ const after = await fs.stat(target, signal)
559
+ return after ? { ok: false, reason: 'delete-failed' } : { ok: true, content: null }
560
+ } catch (error) {
561
+ return { ok: false, reason: reasonOf(error) }
562
+ }
450
563
  }
451
564
 
452
565
  /**
@@ -7,8 +7,11 @@ import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
7
7
  import type { PendingDetail, PendingItem, PendingPage } from './shared/pendingReview.js'
8
8
  import { pathKeyFor } from './shared/pathKey.js'
9
9
 
10
- /** 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。 */
11
- const coveredPaths = new Set<string>()
10
+ /**
11
+ * 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。
12
+ * 按工作区登记,来源会话仅作追溯;任一对话重建或注销都会更新同一份集合。
13
+ */
14
+ const coveredWorkspace = new Map<string, Map<string, string | null>>()
12
15
 
13
16
  /** 待审条目在面板列表里的 id 前缀,用于把请求路由回待审内核。 */
14
17
  export { PENDING_PREFIX } from './shared/pendingReview.js'
@@ -138,28 +141,88 @@ export function pendingDetailOf(detail: PendingDetail, capturedAt: string, cwd?:
138
141
  }
139
142
 
140
143
  /**
141
- * @author ddj 2026年10月29号
144
+ * @author ddj 2026年11月3号
145
+ * @description: 覆盖登记键:工作区与路径各自归一化后再拼接,避免同一文件多种写法登记成两条。
146
+ * @param cwd 工作区
147
+ * @param path 路径
148
+ * @returns 覆盖登记键
149
+ */
150
+ function coverKey(cwd: string, path: string): string {
151
+ return `${pathKey(cwd)}|${pathKey(path)}`
152
+ }
153
+
154
+ /**
155
+ * @private
156
+ * @author ddj 2026年11月04号
157
+ * @description: 取工作区唯一接管集合;来源对话仅保留为诊断信息。
158
+ * @param cwd 工作区路径
159
+ * @returns 路径到登记来源的映射
160
+ */
161
+ function workspaceCovers(cwd: string): Map<string, string | null> {
162
+ const workspaceKey = pathKey(cwd)
163
+ const existing = coveredWorkspace.get(workspaceKey)
164
+ if (existing) return existing
165
+ const created = new Map<string, string | null>()
166
+ coveredWorkspace.set(workspaceKey, created)
167
+ return created
168
+ }
169
+
170
+ /**
171
+ * @author ddj 2026年11月3号
142
172
  * @description: 记住/忘记"该路径由待审投影接管"。进程内集合,绝不在旧写入口上查库(避免撞上写事务锁)。
143
173
  * @param cwd 工作区
144
174
  * @param path 路径
145
175
  * @param covered 是否接管
176
+ * @param sessionId 会话 id;缺省落到无归属的工作区集合
146
177
  * @returns 无
147
178
  */
148
- export function trackPendingCover(cwd: string, path: string, covered: boolean): void {
149
- const key = `${pathKey(cwd)}|${pathKey(path)}`
150
- if (covered) coveredPaths.add(key)
151
- else coveredPaths.delete(key)
179
+ export function trackPendingCover(cwd: string, path: string, covered: boolean, sessionId?: string): void {
180
+ const key = coverKey(cwd, path)
181
+ const target = workspaceCovers(cwd)
182
+ if (covered) target.set(key, sessionId ?? null)
183
+ else target.delete(key)
184
+ }
185
+
186
+ /**
187
+ * @author ddj 2026年11月3号
188
+ * @description: 用完整列表重建工作区接管集合,清除任何对话留下的过期登记。
189
+ * @param cwd 工作区
190
+ * @param sessionId 会话 id
191
+ * @param paths 本会话当前可见的待审条目路径
192
+ * @returns 无
193
+ */
194
+ export function setPendingCovers(cwd: string, sessionId: string, paths: readonly string[]): void {
195
+ const next = new Map<string, string | null>()
196
+ for (const path of paths) {
197
+ if (path) next.set(coverKey(cwd, path), sessionId)
198
+ }
199
+ coveredWorkspace.set(pathKey(cwd), next)
200
+ }
201
+
202
+ /**
203
+ * @author ddj 2026年11月3号
204
+ * @description: 找出接管某路径的会话,用于把"谁在管"写进提示文案;仅读内存,不查库。
205
+ * @param cwd 工作区
206
+ * @param path 路径
207
+ * @returns 首个接管会话 id;无人接管返回 null
208
+ */
209
+ export function pendingCoverOwner(cwd: string, path: string): string | null {
210
+ const key = coverKey(cwd, path)
211
+ return coveredWorkspace.get(pathKey(cwd))?.get(key) ?? null
152
212
  }
153
213
 
154
214
  /**
155
215
  * @author ddj 2026年10月29号
156
216
  * @description: 判断某路径是否已由待审投影接管;仅读进程内集合,不产生任何 IO。
217
+ * 所有对话按同一工作区集合判定,兼容参数不影响可见范围。
157
218
  * @param cwd 工作区
158
219
  * @param path 请求路径
220
+ * @param sessionId 会话 id;缺省按并集判定
159
221
  * @returns 是否已被接管
160
222
  */
161
- export function pendingCovers(cwd: string, path: string): boolean {
162
- return coveredPaths.has(`${pathKey(cwd)}|${pathKey(path)}`)
223
+ export function pendingCovers(cwd: string, path: string, sessionId?: string): boolean {
224
+ const key = coverKey(cwd, path)
225
+ return coveredWorkspace.get(pathKey(cwd))?.has(key) === true
163
226
  }
164
227
 
165
228
  /**
package/src/pendingRpc.ts CHANGED
@@ -5,10 +5,9 @@ import { resolveScope, sessionOf } from './registry.js'
5
5
  import type { Registry } from './registry.js'
6
6
  import { bucketOf } from './registry.js'
7
7
  import { readText } from './pending/capture.js'
8
- import { pendingRawId, trackPendingCover } from './pendingCompat.js'
8
+ import { pendingRawId, setPendingCovers, trackPendingCover } from './pendingCompat.js'
9
9
  import { loadEntries, saveEntry } from './pending/persist.js'
10
10
  import { applyDecide, importFromVcs, itemOf, loadDetail, loadPage, refreshed, removeEntry, undoDecide } from './pending/store.js'
11
- import { addedFiles } from './reviewDb.js'
12
11
  import type { ShellLike } from './pending/vcs.js'
13
12
  import type { Ctx } from './store.js'
14
13
  import { policyOf } from './store.js'
@@ -83,16 +82,16 @@ function readHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
83
82
  return {
84
83
  'edrv.pending.list': async (args: PendingRequest) => {
85
84
  const cwd = await kit.read(args.sessionId)
86
- // 与官方入口同口径:本会话新建的文件基线置空,列表里的增删行数才等于整文件行数
87
- const page = loadPage(cwd, args.sessionId, addedFiles(cwd, args.sessionId))
88
- // 列表即登记接管关系;旧写入口守卫之后只读内存集合,不再产生数据库读
89
- for (const item of page.items) trackPendingCover(cwd, item.path, true)
85
+ // 与统一审查入口共用来源基线,不按查看者对话切分。
86
+ const page = loadPage(cwd, args.sessionId)
87
+ // 完整工作区列表重建唯一接管集合,清除所有来源的过期登记。
88
+ setPendingCovers(cwd, args.sessionId, page.items.map((item) => item.path))
90
89
  return { ok: true, page }
91
90
  },
92
91
  'edrv.pending.detail': async (args: PendingRequest & { id: string }) => {
93
92
  const cwd = await kit.read(args.sessionId)
94
93
  const rawId = pendingRawId(args.id)
95
- const detail = loadDetail(cwd, rawId, addedFiles(cwd, args.sessionId).has(rawId))
94
+ const detail = loadDetail(cwd, rawId)
96
95
  return detail ? { ok: true, detail } : { ok: false, error: '待审条目不存在' }
97
96
  },
98
97
  }
@@ -124,8 +123,8 @@ function decideHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
124
123
  const id = pendingRawId(args.id)
125
124
  const path = loadEntries(cwd).find((stored) => stored.entry.id === id)?.entry.path
126
125
  const removed = removeEntry(cwd, id, args.revision)
127
- // 移除后该路径不再由待审投影接管,旧入口守卫随之放行
128
- if (removed && path) trackPendingCover(cwd, path, false)
126
+ // 移除后注销工作区唯一登记,其他对话不留过期副本。
127
+ if (removed && path) trackPendingCover(cwd, path, false, args.sessionId)
129
128
  return { ok: true, removed }
130
129
  },
131
130
  }
@@ -168,7 +167,8 @@ function refreshHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
168
167
  try {
169
168
  const shell = shellOf(raw, policy)
170
169
  const result = await importFromVcs(shell, cwd, args.sessionId, { includeUntracked: args.includeUntracked, path: args.path })
171
- for (const item of loadPage(cwd, args.sessionId).items) trackPendingCover(cwd, item.path, true)
170
+ const page = loadPage(cwd, args.sessionId)
171
+ setPendingCovers(cwd, args.sessionId, page.items.map((item) => item.path))
172
172
  return { ok: true, imported: result.imported, changes: result.changes }
173
173
  } catch (error) {
174
174
  return { ok: false, error: failureOf(error) }
@@ -3,7 +3,8 @@ import { rawReviewFile, evidenceBlocks } from './reviewEvidence.js'
3
3
  import type { RawReviewFile } from './reviewEvidence.js'
4
4
  import type { ReviewSnapshot } from './shared/review.js'
5
5
  import { reviewDb } from './reviewDb.js'
6
- import { resolveTarget } from './store.js'
6
+ import { cwdOfSession } from './store.js'
7
+ import { toolTargets } from './toolTargets.js'
7
8
  import type { Ctx } from './store.js'
8
9
  import { log } from './log.js'
9
10
 
@@ -18,12 +19,19 @@ export function installEvidence(ctx: Ctx): EvidenceCapture {
18
19
  const chains = new Map<string, Map<string, ByteChain>>()
19
20
  const staged = new Map<string, Map<string, ByteChain>>()
20
21
  let stopped = false
22
+ const disposed = new Set<string>()
21
23
  ctx.on('tools/execute', aroundTool)
22
24
  ctx.on('session/event', onEvent)
23
25
  ctx.on('session/disposed', onDisposed)
24
26
 
25
27
  /** @private @author ddj 2026年10月27号 @param session Disposed owner. Drops optional in-memory evidence, never persisted official facts. */
26
- function onDisposed(session: { id: string }): void { chains.delete(session.id) }
28
+ function onDisposed(session: { id: string }): void {
29
+ disposed.add(session.id)
30
+ chains.delete(session.id)
31
+ for (const key of staged.keys()) {
32
+ if ((JSON.parse(key) as unknown[])[0] === session.id) staged.delete(key)
33
+ }
34
+ }
27
35
 
28
36
  /** @private @author ddj 2026年10月27号 @returns Current bounded capture footprint, excluding transient filesystem reads. */
29
37
  function footprint(): number {
@@ -39,34 +47,67 @@ export function installEvidence(ctx: Ctx): EvidenceCapture {
39
47
  /** @private @author ddj 2026年10月27号 @param exec Allowed tool dispatch. @param next Original dispatch. @returns Original result unchanged, regardless of capture failure. */
40
48
  async function aroundTool(exec: any, next: () => Promise<any>): Promise<any> {
41
49
  const session = exec.agent?.session
42
- const path = exec.arguments?.file_path
43
- if (stopped || !session?.id || !['edit', 'write'].includes(exec.name) || typeof path !== 'string') return next()
44
- let fs: any
45
- let chain: ByteChain | undefined
46
- let target: any
50
+ if (stopped || !session?.id) return next()
51
+ const captures: Array<{ fs: any; target: any; chain: ByteChain }> = []
47
52
  try {
48
- if ((chains.size >= 16 && !chains.has(session.id)) || footprint() >= 32 * 1024 * 1024) return next()
49
- fs = ctx.get('fs')
50
- target = await resolveTarget(ctx, session, path)
51
- const identity = fs.processPath(target)
52
- const before = await readRaw(fs, target, exec.signal)
53
- let files = chains.get(session.id)
54
- if (!files) { files = new Map(); chains.set(session.id, files) }
55
- if (files.size >= 64 && !files.has(identity)) return next()
56
- chain = files.get(identity)
57
- if (!chain) { chain = { before, after: before, busy: false, invalid: false }; files.set(identity, chain) }
58
- if (chain.busy || chain.after.hash !== before.hash) chain.invalid = true
59
- chain.busy = true
60
- } catch { /* No trustworthy read means no undo evidence, not a failed user tool. */ }
53
+ const cwd = cwdOfSession(ctx, session)
54
+ const fs = ctx.get('fs')
55
+ const paths = await toolTargets(exec)
56
+ for (const path of paths) {
57
+ try {
58
+ const capture = await prepareRaw(fs, session.id, cwd ?? undefined, path, exec.signal)
59
+ if (capture) captures.push(capture)
60
+ } catch { /* A failed target never prevents independent targets. */ }
61
+ }
62
+ } catch { /* Optional parser absence must not fail the original tool. */ }
61
63
  try { return await next() }
62
64
  finally {
63
- if (chain) {
64
- try { chain.after = await readRaw(fs, target, exec.signal) } catch { chain.invalid = true }
65
- chain.busy = false
65
+ for (const capture of captures) {
66
+ try {
67
+ const after = await readRaw(capture.fs, capture.target, exec.signal)
68
+ if (!stopped && !disposed.has(session.id)) {
69
+ capture.chain.after = after
70
+ if (footprint() > 32 * 1024 * 1024) {
71
+ capture.chain.invalid = true
72
+ capture.chain.before = rawReviewFile(null); capture.chain.after = rawReviewFile(null)
73
+ }
74
+ }
75
+ } catch { capture.chain.invalid = true }
76
+ capture.chain.busy = false
66
77
  }
67
78
  }
68
79
  }
69
80
 
81
+ /** @private @author ddj 2026年11月04号 @param sessionId Frozen owner. @param cwd Frozen workspace. @param path Patch target. @param signal Tool cancellation. @returns Reserved raw capture or no evidence. */
82
+ async function prepareRaw(fs: any, sessionId: string, cwd: string | undefined, path: string, signal?: AbortSignal) {
83
+ if (stopped || disposed.has(sessionId) || !cwd || (chains.size >= 16 && !chains.has(sessionId)) || footprint() >= 32 * 1024 * 1024) return null
84
+ const target = await fs.resolve(path, { cwd })
85
+ if (stopped || disposed.has(sessionId)) return null
86
+ const identity = fs.processPath(target)
87
+ let files = chains.get(sessionId)
88
+ if (!files) { files = new Map(); chains.set(sessionId, files) }
89
+ if (files.size >= 64 && !files.has(identity)) return null
90
+ let chain = files.get(identity)
91
+ if (chain?.busy) { chain.invalid = true; return null }
92
+ const empty = rawReviewFile(null)
93
+ if (!chain) {
94
+ chain = { before: empty, after: empty, busy: true, invalid: false }
95
+ files.set(identity, chain)
96
+ try { chain.before = await readRaw(fs, target, signal); chain.after = chain.before }
97
+ catch (error) { chain.invalid = true; chain.busy = false; throw error }
98
+ } else {
99
+ chain.busy = true
100
+ try {
101
+ const before = await readRaw(fs, target, signal)
102
+ if (chain.after.hash !== before.hash) chain.invalid = true
103
+ } catch (error) { chain.invalid = true; chain.busy = false; throw error }
104
+ }
105
+ if (stopped || disposed.has(sessionId) || footprint() > 32 * 1024 * 1024) {
106
+ chain.invalid = true; chain.busy = false; files.delete(identity); return null
107
+ }
108
+ return { fs, target, chain }
109
+ }
110
+
70
111
  /** @private @author ddj 2026年10月27号 @param session Source session. @param event Published official turn. Freezes the chain before the next turn can mutate it. */
71
112
  function onEvent(session: any, event: any): void {
72
113
  if (stopped || event.type !== 'workspace/changes') return