dsh-vscode-mode 0.21.4 → 0.22.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.21.4",
3
+ "version": "0.22.0",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -1,10 +1,17 @@
1
1
  /** Source-aware metadata projection; official patches never become legacy hunks. @author ddj 2026年10月26号 */
2
+ import { PENDING_PREFIX } from '../../shared/pendingReview.js'
2
3
  import type { ReviewPage } from '../../shared/review.js'
3
4
  import type { RecordView } from '../../shared/types.js'
4
5
  import type { FileSummary, Summary } from './records.js'
5
6
  import { summarize } from './records.js'
6
7
 
7
- export interface DiffFile extends FileSummary { sourceKind: 'official' | 'legacy' }
8
+ export interface DiffFile extends FileSummary {
9
+ sourceKind: 'official' | 'legacy' | 'pending'
10
+ /** 待审行的增删行数;官方汇总行可能缺省。 */
11
+ added?: number
12
+ /** 待审行的删除行数。 */
13
+ deleted?: number
14
+ }
8
15
  export interface DiffSummary extends Summary { files: DiffFile[]; pendingFiles: DiffFile[] }
9
16
 
10
17
  /** @public @author ddj 2026年10月26号 @param path Functional local or remote identity. @returns Comparison key without rewriting remote case. */
@@ -20,10 +27,42 @@ export function officialPath(path: string, page: ReviewPage | null): boolean {
20
27
  return !!page?.overview?.coveredPaths.some((candidate) => diffPathKey(candidate) === key)
21
28
  }
22
29
 
23
- /** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Official-first file counts; legacy paths remain explicitly labelled. */
30
+ /**
31
+ * @author ddj 2026年10月30号
32
+ * @description: 取出本页待审条目(id 带待审前缀)并折算成文件行。
33
+ * @param page 审查页
34
+ * @returns 待审文件行与它们覆盖的路径键
35
+ */
36
+ function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Set<string> } {
37
+ const rows: DiffFile[] = []
38
+ const covered = new Set<string>()
39
+ for (const item of page?.items ?? []) {
40
+ if (!item.id.startsWith(PENDING_PREFIX)) continue
41
+ const path = item.currentPath || item.path
42
+ covered.add(diffPathKey(path))
43
+ rows.push({
44
+ path,
45
+ label: item.display || path,
46
+ recs: [],
47
+ pending: item.decision === 'pending' ? 1 : 0,
48
+ added: item.added,
49
+ deleted: item.deleted,
50
+ sourceKind: 'pending',
51
+ })
52
+ }
53
+ return { rows, covered }
54
+ }
55
+
56
+ /** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Pending first, then official; legacy paths remain explicitly labelled. */
24
57
  export function diffSummary(records: RecordView[], page: ReviewPage | null): DiffSummary {
25
- const legacy = summarize(records.filter((record) => !officialPath(record.path, page)))
26
- const files: DiffFile[] = (page?.overview?.files ?? []).map((file) => ({ ...file, recs: [] }))
58
+ const pending = pendingRowsOf(page)
59
+ // 同一路径只留一个权威:待审接管后,官方汇总行与历史留痕行都让位。
60
+ const legacy = summarize(records.filter((record) => !pending.covered.has(diffPathKey(record.path)) && !officialPath(record.path, page)))
61
+ const files: DiffFile[] = [...pending.rows]
62
+ for (const file of page?.overview?.files ?? []) {
63
+ if (pending.covered.has(diffPathKey(file.path))) continue
64
+ files.push({ ...file, recs: [] })
65
+ }
27
66
  for (const file of legacy.files) files.push({ ...file, sourceKind: 'legacy' })
28
67
  const pendingFiles = files.filter((file) => file.pending > 0)
29
68
  return { files, pendingFiles, totalFiles: pendingFiles.length }
@@ -106,7 +106,7 @@ export function DiffLauncher(props) {
106
106
  }, [tab, sessionId, source])
107
107
 
108
108
  let body
109
- const legacyFiles = sum.pendingFiles.filter((file) => file.sourceKind !== 'official')
109
+ const legacyFiles = sum.pendingFiles.filter((file) => file.sourceKind === 'legacy')
110
110
  if (source === 'official') {
111
111
  body = React.createElement(React.Fragment, null,
112
112
  React.createElement('div', { className: 'edrv-search-empty' }, '官方轮次净差异 · 原始历史只读;编辑按钮打开当前文件,保存不代表已审。'),
@@ -26,6 +26,7 @@ import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
26
26
  import { runReviewBatch } from '../reviewBatch.js'
27
27
  import { ReviewAllConfirm } from './ReviewAllConfirm.js'
28
28
  import { reviewRegions } from '../../shared/reviewRegions.js'
29
+ import { PENDING_PREFIX } from '../../shared/pendingReview.js'
29
30
  import { diffSummary, officialPath } from '../state/diffSummary.js'
30
31
  import { emitFileChanged, emitRefresh } from '../events.js'
31
32
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
@@ -579,7 +580,15 @@ export function EditorView(props) {
579
580
  }, [sessionId, active, selectedReview])
580
581
  React.useEffect(() => {
581
582
  const controller = new AbortController()
582
- if (!active || !review.page?.overview?.coveredPaths.some((path) => sameFile(path, active))) {
583
+ // 待审条目也走同一条行内差异通道:它由累计基线投影而来,形状与官方条目一致,
584
+ // 因此编辑器里天然就能画行内差异与 Keep/Undo 药丸,不必另开一个窄面板。
585
+ const pendingPath = (review.page?.items ?? [])
586
+ .filter((item) => String(item.id).startsWith(PENDING_PREFIX))
587
+ .map((item) => item.currentPath || item.path)
588
+ .find((path) => sameFile(path, active))
589
+ const coveredPath = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active))
590
+ const reviewPath = coveredPath ?? pendingPath
591
+ if (!active || !reviewPath) {
583
592
  setOfficialDetail(null)
584
593
  setOfficialRecords([])
585
594
  return () => controller.abort()
@@ -588,7 +597,6 @@ export function EditorView(props) {
588
597
  async function loadReview() {
589
598
  try {
590
599
  // Tabs use workspace-relative paths; the metadata index owns the backend identity.
591
- const reviewPath = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active)) ?? active
592
600
  const page = await rpc('edrv.review.list', { sessionId, path: reviewPath }, controller.signal)
593
601
  if (controller.signal.aborted) return
594
602
  if (!page.ok) throw new Error(page.error)
@@ -1,7 +1,6 @@
1
1
  import React from 'react'
2
2
  import { Button, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
3
3
  import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
4
- import { ReviewLedger } from './OfficialReview.js'
5
4
  import { reviewBarView } from '../reviewBar.js'
6
5
  import { ReviewAllRow } from './reviewAllRow.js'
7
6
 
@@ -55,11 +54,9 @@ export function barIcon(mark: Mark): React.ReactElement {
55
54
  /** @public @author ddj 2026年10月27号 @param props Existing review callbacks and immutable record metadata. @returns Responsive review toolbar; no disk or decision side effects until an explicit action. */
56
55
  export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
57
56
  const { official, pendingRegions = [], staleRegions = [], diffIdx = 0 } = props
58
- const [expanded, setExpanded] = React.useState(false)
59
- const [menu, setMenu] = React.useState<'records' | 'more' | null>(null)
57
+ const [menu, setMenu] = React.useState<'more' | null>(null)
60
58
  const name = String(props.activePath || '').split(/[\\/]/).pop() || '当前文件'
61
59
  const detail = official.detail
62
- const records = official.records ?? []
63
60
  const count = pendingRegions.length
64
61
  const view = reviewBarView({ jumps: count, stale: staleRegions.length, index: diffIdx, ready: props.contentReady === true,
65
62
  pendingRecords: official.pending, canKeep: detail?.capability.keep === true,
@@ -67,8 +64,6 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
67
64
 
68
65
  /** @private @author ddj 2026年10月27号 @returns Close current popup without changing selection. */
69
66
  function closeMenu(): void { setMenu(null) }
70
- /** @private @author ddj 2026年10月27号 @param id Immutable record ID. @returns Select record and dismiss popup. */
71
- function selectRecord(id: string): void { official.onSelect(id); closeMenu() }
72
67
  /** @private @author ddj 2026年10月27号 @param tab Existing launcher tab. @returns Open existing overview, never create another review entry. */
73
68
  function openOverview(tab: string): void { props.onOpenLauncher(tab); closeMenu() }
74
69
  /** @private @author ddj 2026年10月27号 @param mark Navigation icon. @param label Accessible action. @param action Existing callback. @param disabled Whether navigation is unavailable. @param title Hover reason, defaults to the label. @returns Compact keyboard-focusable navigation control. */
@@ -76,9 +71,6 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
76
71
  return <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label={label} title={title || label} disabled={disabled} onClick={action}>{barIcon(mark)}</Button>
77
72
  }
78
73
 
79
- const recordTrigger = <Button size="sm" variant="ghost" className="edrv-review-record" aria-label="选择官方差异记录" aria-haspopup="menu" aria-expanded={menu === 'records'} disabled={!records.length} onClick={() => setMenu(menu === 'records' ? null : 'records')}>
80
- {detail ? `轮次 ${detail.item.turn}` : '选择记录'}{barIcon('down')}
81
- </Button>
82
74
  const moreTrigger = <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label="更多审查操作" title="更多审查操作" aria-haspopup="menu" aria-expanded={menu === 'more'} onClick={() => setMenu(menu === 'more' ? null : 'more')}>{barIcon('more')}</Button>
83
75
 
84
76
  return <div className="edrv-diffbar edrv-diffbar-dock edrv-reviewbar" data-edrv-diffbox="1" data-edrv-diff-source="official" role="region" aria-label="文件差异审查">
@@ -99,7 +91,6 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
99
91
  <span className="edrv-review-filemark">{barIcon('file')}</span>
100
92
  <span className="edrv-review-filename" title={props.activePath}>{name}</span>
101
93
  <span className="edrv-review-pending" title={view.pendingTitle}>待审 <strong>{official.pending}</strong></span>
102
- <Menu open={menu === 'records'} anchor={recordTrigger} items={records.map((item) => ({ id: item.id, label: `轮次 ${item.turn} / 事件 ${item.seq}` }))} selectedId={detail?.item.id} onSelect={selectRecord} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
103
94
  </div>
104
95
  <div className="edrv-review-tools">
105
96
  <div className="edrv-review-nav" role="group" aria-label="文件导航">
@@ -114,12 +105,10 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
114
105
  <div className="edrv-review-actions">
115
106
  <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={props.reviewBatchBusy || !detail?.capability.undo} title={detail?.capability.reason}>Undo</Button>
116
107
  <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={props.reviewBatchBusy || !detail?.capability.keep} title={detail?.capability.keep ? view.keepHint : detail?.capability.reason}>Keep</Button>
117
- <Button size="sm" variant="ghost" className="edrv-review-details" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label={expanded ? '收起差异' : '展开差异'} title={expanded ? '收起差异' : '展开差异'}>详情{barIcon(expanded ? 'up' : 'down')}</Button>
118
108
  <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }]} onSelect={openOverview} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
119
109
  </div>
120
110
  </div>
121
111
  {detail && view.notice ? <div className={view.noticeTone === 'warn' ? 'edrv-review-notice edrv-review-notice-warn' : 'edrv-review-notice'} role="status">{view.notice}</div> : null}
122
112
  {props.reviewError ? <div className={props.reviewErrorTone === 'info' ? 'edrv-review-notice' : 'edrv-review-notice edrv-review-error'} role={props.reviewErrorTone === 'info' ? 'status' : 'alert'}>{props.reviewError}</div> : null}
123
- {expanded ? <div className="edrv-review-body"><ReviewLedger key={`${official.sessionId}:${official.path}`} sessionId={official.sessionId} first={official.page} path={official.path} /></div> : null}
124
113
  </div>
125
114
  }
@@ -3,6 +3,7 @@ import React from 'react'
3
3
  import { rpc } from '../rpc.js'
4
4
  import { openEditorView } from '../events.js'
5
5
  import { refreshReview } from '../state/reviewFeed.js'
6
+ import { PENDING_PREFIX } from '../../shared/pendingReview.js'
6
7
  import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
7
8
  const h = React.createElement
8
9
  /** @private @author ddj 2026年10月26号 @param props Latest first-page metadata. @returns Paginated rows independent of expanded historical patch. */
@@ -37,18 +38,27 @@ export function ReviewLedger(props: { sessionId: string; first: ReviewPage | nul
37
38
  else setError(result.error)
38
39
  } catch (cause) { if (!controller.signal.aborted) setError(String(cause)) }
39
40
  }
41
+ const gaps = page?.gaps ?? []
40
42
  return h('div', { style: { marginTop: 8 } },
41
43
  h('label', null, h('input', { type: 'checkbox', checked: history, onChange: () => { setHistory(!history); setCursor(0) } }), '显示已处理及被替换的历史'),
42
- page?.gaps.map((gap) => h('div', { key: `${gap.sessionId}:${gap.seq}`, role: 'status' }, `轮次 ${gap.turn} 采集不完整:${gap.error}`)),
43
44
  error ? h('div', { role: 'alert' }, error) : null,
44
45
  !page ? h('p', { role: 'status' }, '正在读取官方审查记录…') : null,
45
46
  page && !page.items.length ? h('p', null, history ? '暂无已落盘历史。' : '没有待处理的官方记录;可切换查看历史。') : null,
46
47
  h('div', { style: { maxHeight: 210, overflow: 'auto' } }, page?.items.map((item) => h('button', {
47
48
  key: item.id, type: 'button', onClick: () => { void select(item) },
48
- style: { display: 'block', width: '100%', textAlign: 'left', padding: '6px 4px' },
49
- }, `轮次 ${item.turn} · ${item.display} +${item.added} −${item.deleted} · ${item.decision}${item.current ? '' : '(已被同轮新版本替换)'}`))),
49
+ title: item.currentPath || item.path,
50
+ style: { display: 'block', width: '100%', textAlign: 'left', padding: '6px 4px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
51
+ }, item.id.startsWith(PENDING_PREFIX)
52
+ ? `【待审】${item.display} +${item.added} −${item.deleted} · ${item.decision}${item.kind === 'oversized' ? ' · 过大' : ''}`
53
+ : `轮次 ${item.turn} · ${item.display} +${item.added} −${item.deleted} · ${item.decision}${item.current ? '' : '(已被同轮新版本替换)'}`))),
50
54
  cursor > 0 ? h('button', { type: 'button', onClick: () => setCursor(Math.max(0, cursor - 50)) }, '上一页') : null,
51
55
  page?.nextCursor !== null && page?.nextCursor !== undefined ? h('button', { type: 'button', onClick: () => setCursor(page.nextCursor!) }, '下一页') : null,
56
+ // 采集缺口是诊断信息,排在条目之后折叠起来:放最前面会把真正要点的待审条目挤出可视区。
57
+ gaps.length
58
+ ? h('details', { key: 'gaps', style: { marginTop: 6 } },
59
+ h('summary', { style: { cursor: 'pointer', fontSize: 11 } }, `采集不完整 ${gaps.length} 条(诊断信息)`),
60
+ gaps.map((gap) => h('div', { key: `${gap.sessionId}:${gap.seq}`, role: 'status', style: { fontSize: 11 } }, `轮次 ${gap.turn} 采集不完整:${gap.error}`)))
61
+ : null,
52
62
  detail ? h(PatchDetail, { key: detail.item.id, sessionId: props.sessionId, detail, onChanged: () => { setDetail(null); refreshReview(props.sessionId) } }) : null)
53
63
  }
54
64
 
@@ -62,7 +62,6 @@ export interface SidecarBarProps {
62
62
  export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
63
63
  const pendingRegions = props.pendingRegions ?? []
64
64
  const staleRegions = props.staleRegions ?? []
65
- const [expanded, setExpanded] = React.useState(false)
66
65
  const [menu, setMenu] = React.useState<'more' | null>(null)
67
66
  const name = String(props.activePath || '').split(/[\\/]/).pop() || '当前文件'
68
67
  const count = pendingRegions.length
@@ -85,25 +84,6 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
85
84
 
86
85
  const moreTrigger = <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label="更多审查操作" title="更多审查操作" aria-haspopup="menu" aria-expanded={menu === 'more'} onClick={() => setMenu(menu === 'more' ? null : 'more')}>{barIcon('more')}</Button>
87
86
 
88
- const body = expanded
89
- ? <div className="edrv-review-body">
90
- {pendingRegions.map((region) => {
91
- const key = callIdAttr(region.callId, region.idx)
92
- return <div key={key} className="edrv-diffrow" data-edrv-hunk={key} title={'跳转到 L' + (region.start ?? '?')} onClick={() => props.onJump?.(region)}>
93
- <span className="edrv-diffrow-l">{'L' + (region.start ?? '?') + '-' + (region.end ?? '?')}</span>
94
- {(region.oldLines?.length ?? 0) > 0 ? <span className="edrv-diffrow-o">{'-' + (region.oldLines?.length ?? 0)}</span> : null}
95
- {(region.newLines?.length ?? 0) > 0 ? <span className="edrv-diffrow-n">{'+' + (region.newLines?.length ?? 0)}</span> : null}
96
- {region.create ? <span className="edrv-diffrow-tag">新建</span> : null}
97
- </div>
98
- })}
99
- {staleRegions.map((region) => <div key={'stale' + callIdAttr(region.callId, region.idx)} className="edrv-diffrow edrv-diffrow-stale" title="差异无法定位(文件可能已被手动修改)">
100
- <span className="edrv-diffrow-l">{'L' + (region.start ?? '?') + ' 无法定位'}</span>
101
- <span className="edrv-diffrow-tag">冲突/需刷新</span>
102
- <span className="edrv-diffrow-tag">{badgeOf(region.status ?? '')}</span>
103
- </div>)}
104
- </div>
105
- : null
106
-
107
87
  return <div className="edrv-diffbar edrv-diffbar-dock edrv-reviewbar" data-edrv-diffbox="1" data-edrv-diff-source="sidecar" role="region" aria-label="文件差异审查">
108
88
  <ReviewAllRow
109
89
  total={props.fileTotal ?? 0}
@@ -141,7 +121,6 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
141
121
  <div className="edrv-review-actions">
142
122
  <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={!canDecide} title={props.missingDeletion ? '恢复展示的完整历史快照(不会覆盖重新出现的文件)' : SIDECAR_UNDO_TITLE}>Undo</Button>
143
123
  <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={!canDecide} title={props.missingDeletion ? '保持文件删除并结清覆盖的待审历史记录,不写文件' : SIDECAR_KEEP_TITLE}>Keep</Button>
144
- <Button size="sm" variant="ghost" className="edrv-review-details" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label={expanded ? '收起差异' : '展开差异'} title={expanded ? '收起差异' : '展开差异'}>详情{barIcon(expanded ? 'up' : 'down')}</Button>
145
124
  <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }, { id: 'rollback', label: '回滚当前文件' }, { id: 'refresh', label: '刷新' }]} onSelect={(id: string) => {
146
125
  if (id === 'rollback') { setMenu(null); props.onRollback?.(); return }
147
126
  if (id === 'refresh') { setMenu(null); props.onRefresh?.(); return }
@@ -151,6 +130,5 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
151
130
  </div>
152
131
  {view.notice ? <div className={view.noticeTone === 'warn' ? 'edrv-review-notice edrv-review-notice-warn' : 'edrv-review-notice'} role="status">{view.notice}</div> : null}
153
132
  {props.reviewError ? <div className={props.reviewErrorTone === 'info' ? 'edrv-review-notice' : 'edrv-review-notice edrv-review-error'} role={props.reviewErrorTone === 'info' ? 'status' : 'alert'}>{props.reviewError}</div> : null}
154
- {body}
155
133
  </div>
156
134
  }
package/src/index.ts CHANGED
@@ -3,6 +3,9 @@ import { registerRoutes } from './routes.js'
3
3
  import { captureToolResult } from './capture.js'
4
4
  import { installCapture } from './officialCapture.js'
5
5
  import { installEvidence } from './reviewCapture.js'
6
+ import { installPendingCapture } from './pending/capture.js'
7
+ import { recordWrite } from './pending/store.js'
8
+ import { trackPendingCover } from './pendingCompat.js'
6
9
  import { closeArchiveDbs } from './archiveDb.js'
7
10
  import { handleRpc, integrationBaseUrlOf } from './rpc.js'
8
11
  import { newSearcher } from './search/orchestrator.js'
@@ -71,6 +74,11 @@ function createRuntime(ctx: Ctx, config: unknown) {
71
74
  function attachRuntime(ctx: Ctx, runtime: Runtime): void {
72
75
  const evidence = installEvidence(ctx)
73
76
  const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
77
+ // 待审累计基线:只有写后内容确实变化才落库,异常一律不打断用户工具调用
78
+ const pending = installPendingCapture(ctx, (write) => {
79
+ try { trackPendingCover(write.cwd, write.path, true) } catch { /* 内存登记失败不阻断采集 */ }
80
+ try { recordWrite(write) } catch (error) { log.warn('待审差异落库失败:' + String(error)) }
81
+ })
74
82
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
75
83
  function onResult(exec: unknown, result: unknown): void {
76
84
  void captureToolResult(ctx, runtime.registry, exec, result)
@@ -90,6 +98,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
90
98
  async function cleanup(): Promise<void> {
91
99
  await stopCapture()
92
100
  evidence.dispose()
101
+ pending.dispose()
93
102
  runtime.fileVersions.dispose()
94
103
  closeArchiveDbs()
95
104
  disposeAllServers()
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'
3
3
  import { resolve } from 'node:path'
4
4
  import type { DiffRecord, MissingReview } from './shared/types.js'
5
5
  import type { Ctx, Session } from './store.js'
6
- import { archiveRecords, policyOf, READ_CAP, resolveTargetIn } from './store.js'
6
+ import { archiveRecords, policyOf, READ_CAP, resolveTargetIn, targetPathOf } from './store.js'
7
7
  import { hasOfficial } from './reviewDb.js'
8
8
  import { recordResolved } from './model.js'
9
9
 
@@ -31,13 +31,20 @@ function revisionOf(records: DiffRecord[]): string {
31
31
  return createHash('sha256').update(JSON.stringify(evidence)).digest('hex')
32
32
  }
33
33
 
34
+ /** @private @author ddj 2026年10月30号 @param ctx Host capability. @param target Resolve 结果:宿主路径字符串或不透明 FsTarget。 @returns 平台一致的物理身份;无法还原时返回空串。 */
35
+ function fileKeyOf(ctx: Ctx, target: unknown): string {
36
+ const path = targetPathOf(ctx, target)
37
+ return path ? fileKey(path) : ''
38
+ }
39
+
34
40
  /** @private @author ddj 2026年10月28号 @param ctx Host capability. @param cwd Workspace. @param bucket Records. @param target Resolved file. @returns Records for the same physical path spelling, oldest first. */
35
41
  async function fileRecords(ctx: Ctx, cwd: string, bucket: Map<string, DiffRecord>, target: string): Promise<DiffRecord[]> {
36
42
  const matches: DiffRecord[] = []
37
- const identity = fileKey(target)
43
+ // 解析结果可能是不透明对象,比较前必须先统一还原成宿主路径。
44
+ const identity = fileKeyOf(ctx, target)
38
45
  for (const record of bucket.values()) {
39
46
  if (record.superseded) continue
40
- if (fileKey(await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
47
+ if (fileKeyOf(ctx, await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
41
48
  }
42
49
  return matches.sort((left, right) => left.at.localeCompare(right.at))
43
50
  }
@@ -57,9 +64,12 @@ export async function fileAbsent(ctx: Ctx, cwd: string, path: string): Promise<b
57
64
  /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Live session. @param cwd Workspace. @param bucket Records. @param path Requested file. @returns Frozen complete snapshot; never constructs text from hunks or writes files. @throws Missing evidence or nonmissing target. */
58
65
  export async function missingReview(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, path: string): Promise<MissingReview> {
59
66
  const target = await resolveTargetIn(ctx, cwd, path)
60
- if (hasOfficial(cwd, path) || hasOfficial(cwd, target)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
61
- if (!await fileAbsent(ctx, cwd, target)) throw new Error('文件已重新出现,请刷新')
62
- const records = await fileRecords(ctx, cwd, bucket, target)
67
+ // 解析结果可能是不透明 FsTarget,凡按路径字符串使用的分支都必须先还原。
68
+ const abs = targetPathOf(ctx, target)
69
+ if (!abs) throw new Error('无法解析目标文件路径')
70
+ if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
71
+ if (!await fileAbsent(ctx, cwd, abs)) throw new Error('文件已重新出现,请刷新')
72
+ const records = await fileRecords(ctx, cwd, bucket, abs)
63
73
  const latest = records.at(-1)
64
74
  if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error('没有待审历史记录')
65
75
  if (!latest || typeof latest.after !== 'string') throw new Error('缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
@@ -88,33 +98,45 @@ async function applyDisk(ctx: Ctx, item: FrozenReview, target: string, undo: boo
88
98
  if (await fs.readText(target) !== item.view.content) throw new Error('恢复内容校验失败,未结清记录')
89
99
  }
90
100
 
101
+ /**
102
+ * @author ddj 2026年10月29号
103
+ * @description: 取删除预览对应的文件路径,供服务端判定该路径是否已由待审投影接管。
104
+ * @param token 冻结预览 token
105
+ * @returns 文件路径,token 无效时为 undefined
106
+ */
107
+ export function missingPath(token: string): string | undefined {
108
+ return frozen.get(token)?.view.path
109
+ }
110
+
91
111
  /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Requesting session. @param cwd Workspace. @param bucket Records. @param token Frozen preview token. @param operationId Stable retry ID. @param undo True restores shown snapshot; false keeps deletion. @returns Covered call IDs. @throws Changed evidence, concurrent action or sandbox/IO failure. */
92
112
  export async function missingDecide(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, token: string, operationId: string, undo: boolean): Promise<string[]> {
93
113
  const item = frozen.get(token)
94
114
  if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error('删除预览已过期,请刷新后重试')
95
115
  if (!operationId || operationId.length > 128) throw new Error('缺少有效操作 ID')
96
116
  const target = await resolveTargetIn(ctx, cwd, item.view.path)
97
- const key = JSON.stringify([cwd, fileKey(target)])
117
+ const abs = targetPathOf(ctx, target)
118
+ if (!abs) throw new Error('无法解析目标文件路径')
119
+ const key = JSON.stringify([cwd, fileKey(abs)])
98
120
  if (locks.has(key)) throw new Error('该文件正在处理,请稍后重试')
99
121
  locks.add(key)
100
122
  item.session = session
101
- try { return await commitMissing(ctx, item, bucket, target, operationId, undo) }
123
+ try { return await commitMissing(ctx, item, bucket, target, abs, operationId, undo) }
102
124
  finally { locks.delete(key) }
103
125
  }
104
126
 
105
- /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
106
- async function commitMissing(ctx: Ctx, item: FrozenReview, bucket: Map<string, DiffRecord>, target: string, operationId: string, undo: boolean): Promise<string[]> {
127
+ /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param abs Host path of the target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
128
+ async function commitMissing(ctx: Ctx, item: FrozenReview, bucket: Map<string, DiffRecord>, target: string, abs: string, operationId: string, undo: boolean): Promise<string[]> {
107
129
  const previous = item.operation
108
130
  if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error('已有不同操作,请刷新后重试')
109
131
  if (previous?.done) return item.view.callIds
110
- if (hasOfficial(item.cwd, target) || hasOfficial(item.cwd, item.view.path)) throw new Error('该文件已有官方审查记录,请刷新')
111
- const records = await fileRecords(ctx, item.cwd, bucket, target)
132
+ if (hasOfficial(item.cwd, abs) || hasOfficial(item.cwd, item.view.path)) throw new Error('该文件已有官方审查记录,请刷新')
133
+ const records = await fileRecords(ctx, item.cwd, bucket, abs)
112
134
  if (revisionOf(records) !== item.revision) throw new Error('历史记录已变化,请刷新删除预览')
113
135
  item.operation ??= { id: operationId, undo, restored: false, done: false }
114
- await applyDisk(ctx, item, target, undo)
136
+ await applyDisk(ctx, item, abs, undo)
115
137
 
116
138
  // Recheck after awaited IO: capture/another review must not silently join this decision.
117
- const current = await fileRecords(ctx, item.cwd, bucket, target)
139
+ const current = await fileRecords(ctx, item.cwd, bucket, abs)
118
140
  if (revisionOf(current) !== item.revision) throw new Error('历史记录已变化,未结清记录,请刷新')
119
141
  const pending = current.filter((record) => item.view.callIds.includes(record.callId))
120
142
  const decisions = pending.map((record) => record.decisions)
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 待审差异采集:环绕一次文件写入读取真实前后内容,只有内容确实变化才落库。
3
+ * @author ddj 2026年10月29号
4
+ */
5
+ import { cwdOf } from '../registry.js'
6
+ import { resolveTarget } from '../store.js'
7
+ import type { Ctx } from '../store.js'
8
+ import { log } from '../log.js'
9
+
10
+ /** 可被采信为待审来源的写文件工具。 */
11
+ const WRITE_TOOLS = ['edit', 'write', 'str_replace_editor']
12
+ /** 单文件文本上限:超过即标注 oversized,不静默截断。 */
13
+ const MAX_TEXT = 1024 * 1024
14
+
15
+ /** 一次已确认的写入。 */
16
+ export interface PendingWrite {
17
+ cwd: string
18
+ sessionId: string
19
+ path: string
20
+ identity: string
21
+ before: string | null
22
+ after: string | null
23
+ now: number
24
+ }
25
+
26
+ /** 采集器。 */
27
+ export interface PendingCapture {
28
+ /** 释放监听并丢弃暂存。 */
29
+ dispose(): void
30
+ }
31
+
32
+ /**
33
+ * @author ddj 2026年10月29号
34
+ * @description: 安装写文件采集;读写失败只影响待审来源,绝不影响用户工具本身。
35
+ * @param ctx Host 生命周期
36
+ * @param sink 已确认写入的接收方
37
+ * @returns 可释放的采集器
38
+ */
39
+ export function installPendingCapture(ctx: Ctx, sink: (write: PendingWrite) => void): PendingCapture {
40
+ const busy = new Set<string>()
41
+ let stopped = false
42
+ ctx.on('tools/execute', aroundTool)
43
+ return { dispose }
44
+
45
+ /**
46
+ * @author ddj 2026年10月29号
47
+ * @description: 写工具执行前后各读一次,内容变化才上报。
48
+ * @param exec 工具分发上下文
49
+ * @param next 原始分发
50
+ * @returns 原始工具结果,永不因采集失败而改变
51
+ */
52
+ async function aroundTool(exec: any, next: () => Promise<any>): Promise<any> {
53
+ const session = exec.agent?.session
54
+ const path = exec.arguments?.file_path ?? exec.arguments?.path
55
+ if (stopped || !session?.id || typeof path !== 'string' || !WRITE_TOOLS.includes(exec.name)) return next()
56
+ const cwd = cwdOf(session)
57
+ if (!cwd) return next()
58
+ let before: string | null = null
59
+ let identity: string | null = null
60
+ try {
61
+ const fs = ctx.get('fs')
62
+ const target = await resolveTarget(ctx, session, path)
63
+ const key = fs.processPath(target)
64
+ if (typeof key !== 'string' || !key) return next()
65
+ if (busy.has(key)) return next()
66
+ before = await readText(fs, target)
67
+ if (before !== null && before.length > MAX_TEXT) return next()
68
+ identity = key
69
+ busy.add(key)
70
+ } catch (error) {
71
+ log.debug('待审采集跳过:' + String(error))
72
+ return next()
73
+ }
74
+ try {
75
+ return await next()
76
+ } finally {
77
+ if (identity !== null) {
78
+ const key: string = identity
79
+ try { report(String(cwd), String(session.id), String(path), key, before, exec) } catch (error) {
80
+ log.debug('待审上报失败:' + String(error))
81
+ }
82
+ busy.delete(key)
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @author ddj 2026年10月29号
89
+ * @description: 写后读回并比较,内容一致视为无净变化不产生条目。
90
+ * @param cwd 工作区
91
+ * @param sessionId 会话
92
+ * @param path 展示路径
93
+ * @param identity 目标身份
94
+ * @param before 写前内容
95
+ * @param exec 工具分发上下文
96
+ * @returns 无
97
+ */
98
+ function report(
99
+ cwd: string, sessionId: string, path: string, identity: string, before: string | null, exec: any,
100
+ ): void {
101
+ const fs = ctx.get('fs')
102
+ void resolveTarget(ctx, exec.agent.session, path).then(async (target: any) => {
103
+ const after = await readText(fs, target)
104
+ if (after !== null && after.length > MAX_TEXT) return
105
+ if (before === after) return
106
+ sink({ cwd, sessionId, path, identity, before, after, now: Date.now() })
107
+ }).catch((error: unknown) => log.debug('待审读回失败:' + String(error)))
108
+ }
109
+
110
+ /**
111
+ * @author ddj 2026年10月29号
112
+ * @description: 释放监听。
113
+ * @returns 无
114
+ */
115
+ function dispose(): void {
116
+ stopped = true
117
+ busy.clear()
118
+ }
119
+ }
120
+
121
+ /**
122
+ * @author ddj 2026年10月29号
123
+ * @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
124
+ * @param fs 文件系统
125
+ * @param target 目标
126
+ * @returns 文本或 null
127
+ */
128
+ export async function readText(fs: any, target: any): Promise<string | null> {
129
+ try {
130
+ return String(await fs.readText(target))
131
+ } catch (error) {
132
+ const code = (error as { code?: string }).code
133
+ if (code === 'ENOENT' || code === 'FS_NOT_FOUND' || code === 'FS_TOO_LARGE') return null
134
+ throw error
135
+ }
136
+ }