dsh-vscode-mode 0.26.0 → 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.
@@ -9,11 +9,9 @@ import { pathKeyFor } from './shared/pathKey.js'
9
9
 
10
10
  /**
11
11
  * 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。
12
- * 按会话登记:待审列表按会话可见,守卫必须同口径,否则会出现"拦得住、看不见"的死锁。
13
- * 无会话归属的登记落在 workspace 集合(兼容只有工作区+路径的旧用法)。
12
+ * 按工作区登记,来源会话仅作追溯;任一对话重建或注销都会更新同一份集合。
14
13
  */
15
- const coveredBySession = new Map<string, Set<string>>()
16
- const coveredWorkspace = new Set<string>()
14
+ const coveredWorkspace = new Map<string, Map<string, string | null>>()
17
15
 
18
16
  /** 待审条目在面板列表里的 id 前缀,用于把请求路由回待审内核。 */
19
17
  export { PENDING_PREFIX } from './shared/pendingReview.js'
@@ -154,16 +152,18 @@ function coverKey(cwd: string, path: string): string {
154
152
  }
155
153
 
156
154
  /**
157
- * @author ddj 2026年11月3号
158
- * @description: 取某会话的覆盖集合;首次登记时就地建集合,避免调用方判空。
159
- * @param sessionId 会话 id
160
- * @returns 该会话的覆盖键集合
155
+ * @private
156
+ * @author ddj 2026年11月04号
157
+ * @description: 取工作区唯一接管集合;来源对话仅保留为诊断信息。
158
+ * @param cwd 工作区路径
159
+ * @returns 路径到登记来源的映射
161
160
  */
162
- function sessionCovers(sessionId: string): Set<string> {
163
- const existing = coveredBySession.get(sessionId)
161
+ function workspaceCovers(cwd: string): Map<string, string | null> {
162
+ const workspaceKey = pathKey(cwd)
163
+ const existing = coveredWorkspace.get(workspaceKey)
164
164
  if (existing) return existing
165
- const created = new Set<string>()
166
- coveredBySession.set(sessionId, created)
165
+ const created = new Map<string, string | null>()
166
+ coveredWorkspace.set(workspaceKey, created)
167
167
  return created
168
168
  }
169
169
 
@@ -178,25 +178,25 @@ function sessionCovers(sessionId: string): Set<string> {
178
178
  */
179
179
  export function trackPendingCover(cwd: string, path: string, covered: boolean, sessionId?: string): void {
180
180
  const key = coverKey(cwd, path)
181
- const target = sessionId ? sessionCovers(sessionId) : coveredWorkspace
182
- if (covered) target.add(key)
181
+ const target = workspaceCovers(cwd)
182
+ if (covered) target.set(key, sessionId ?? null)
183
183
  else target.delete(key)
184
184
  }
185
185
 
186
186
  /**
187
187
  * @author ddj 2026年11月3号
188
- * @description: 用一次列表结果整体重建某会话的接管集合:条目被移除或结案后自动失效,不再残留登记。
188
+ * @description: 用完整列表重建工作区接管集合,清除任何对话留下的过期登记。
189
189
  * @param cwd 工作区
190
190
  * @param sessionId 会话 id
191
191
  * @param paths 本会话当前可见的待审条目路径
192
192
  * @returns 无
193
193
  */
194
194
  export function setPendingCovers(cwd: string, sessionId: string, paths: readonly string[]): void {
195
- const next = new Set<string>()
195
+ const next = new Map<string, string | null>()
196
196
  for (const path of paths) {
197
- if (path) next.add(coverKey(cwd, path))
197
+ if (path) next.set(coverKey(cwd, path), sessionId)
198
198
  }
199
- coveredBySession.set(sessionId, next)
199
+ coveredWorkspace.set(pathKey(cwd), next)
200
200
  }
201
201
 
202
202
  /**
@@ -208,16 +208,13 @@ export function setPendingCovers(cwd: string, sessionId: string, paths: readonly
208
208
  */
209
209
  export function pendingCoverOwner(cwd: string, path: string): string | null {
210
210
  const key = coverKey(cwd, path)
211
- for (const [sessionId, set] of coveredBySession) {
212
- if (set.has(key)) return sessionId
213
- }
214
- return null
211
+ return coveredWorkspace.get(pathKey(cwd))?.get(key) ?? null
215
212
  }
216
213
 
217
214
  /**
218
215
  * @author ddj 2026年10月29号
219
216
  * @description: 判断某路径是否已由待审投影接管;仅读进程内集合,不产生任何 IO。
220
- * 给 sessionId 时按该会话的可见范围判定(列表看不到的接管不拦旧入口),缺省按全工作区并集判定。
217
+ * 所有对话按同一工作区集合判定,兼容参数不影响可见范围。
221
218
  * @param cwd 工作区
222
219
  * @param path 请求路径
223
220
  * @param sessionId 会话 id;缺省按并集判定
@@ -225,12 +222,7 @@ export function pendingCoverOwner(cwd: string, path: string): string | null {
225
222
  */
226
223
  export function pendingCovers(cwd: string, path: string, sessionId?: string): boolean {
227
224
  const key = coverKey(cwd, path)
228
- if (coveredWorkspace.has(key)) return true
229
- if (sessionId) return coveredBySession.get(sessionId)?.has(key) === true
230
- for (const set of coveredBySession.values()) {
231
- if (set.has(key)) return true
232
- }
233
- return false
225
+ return coveredWorkspace.get(pathKey(cwd))?.has(key) === true
234
226
  }
235
227
 
236
228
  /**
package/src/pendingRpc.ts CHANGED
@@ -8,7 +8,6 @@ import { readText } from './pending/capture.js'
8
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
- // 列表即登记接管关系:整集重建本会话的登记,条目被移出列表后自动失效
85
+ // 与统一审查入口共用来源基线,不按查看者对话切分。
86
+ const page = loadPage(cwd, args.sessionId)
87
+ // 完整工作区列表重建唯一接管集合,清除所有来源的过期登记。
89
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,7 +123,7 @@ 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
- // 移除后该路径不再由待审投影接管,旧入口守卫随之放行(仅注销本会话的登记)
126
+ // 移除后注销工作区唯一登记,其他对话不留过期副本。
128
127
  if (removed && path) trackPendingCover(cwd, path, false, args.sessionId)
129
128
  return { ok: true, removed }
130
129
  },
@@ -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
@@ -16,7 +16,7 @@ export function rawReviewFile(bytes: Uint8Array | null): RawReviewFile {
16
16
  }
17
17
 
18
18
  /** @private @author ddj 2026年10月27号 @param file Persisted raw image. @returns UTF-8 storage text preserving BOM and terminators. @throws Binary, invalid encoding or corrupt hash. */
19
- function rawText(file: RawReviewFile): string {
19
+ export function rawText(file: RawReviewFile): string {
20
20
  const bytes = Buffer.from(file.bytes, 'base64')
21
21
  if (bytes.length > 8 * 1024 * 1024 || bytes.includes(0)) throw new Error('回滚证据不是受支持的 UTF-8 文本')
22
22
  if (rawReviewFile(file.present ? bytes : null).hash !== file.hash) throw new Error('回滚证据哈希不匹配')
package/src/reviewRpc.ts CHANGED
@@ -3,13 +3,13 @@ import { createHash } from 'node:crypto'
3
3
  import type { RpcHandlerMap } from './shared/rpc.js'
4
4
  import type { KeepAllItem, KeepAllResult, ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
5
5
  import type { PendingPage } from './shared/pendingReview.js'
6
- import { addedFiles, decideReview, keepOfficialAll, reviewDetail, reviewPage } from './reviewDb.js'
6
+ import { decideReview, keepOfficialAll, reviewDetail, reviewPage } from './reviewDb.js'
7
7
  import { undoReview } from './reviewUndo.js'
8
8
  import { applyDecide, keepAll, loadDetail, loadPage } from './pending/store.js'
9
9
  import { PENDING_PREFIX, mergedPage, pendingDetailOf, pendingItemOf, pendingSamePath } from './pendingCompat.js'
10
10
  import { bucketOf, resolveScope, sessionOf } from './registry.js'
11
11
  import type { Registry } from './registry.js'
12
- import { resolveTargetIn } from './store.js'
12
+ import { policyOf, resolveTargetIn } from './store.js'
13
13
  import type { Ctx } from './store.js'
14
14
  import { log } from './log.js'
15
15
 
@@ -154,9 +154,8 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
154
154
  */
155
155
  function pendingPageOf(cwd: string, sessionId: string): PendingPage {
156
156
  try {
157
- // 本会话新建的文件由官方首个块判为整文件新增,待审基线必须跟着置空:
158
- // 否则同一次新建会同时存在「官方说全加 516 行」与「待审说改 8 处」两套相反口径。
159
- const page = loadPage(cwd, sessionId, addedFiles(cwd, sessionId))
157
+ // 基线由累计条目来源及决定历史统一解析,不依赖当前查看者对话。
158
+ const page = loadPage(cwd, sessionId)
160
159
  // 已结算(老文本与新文本一致)的投影没有净变化可审:留着会顶掉同路径的官方待审记录,
161
160
  // 让差异栏选中一条 Keep 不可用的条目,表现为「按钮亮着却点不动」。
162
161
  return page.items.some((item) => item.settled === true)
@@ -279,7 +278,7 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
279
278
  if (args.id.startsWith(PENDING_PREFIX)) {
280
279
  const entryId = args.id.slice(PENDING_PREFIX.length)
281
280
  // 详情侧与列表侧必须同一口径:整文件新增的文件用空基线渲染,差异块才是全绿一整块
282
- const detail = loadDetail(cwd, entryId, addedFiles(cwd, args.sessionId).has(entryId))
281
+ const detail = loadDetail(cwd, entryId)
283
282
  if (!detail) return { ok: false, error: '待审条目不存在' }
284
283
 
285
284
  const mapped = pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd)
@@ -314,7 +313,7 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
314
313
  decision: args.decision,
315
314
  scope: args.scope === 'hunk' ? 'block' : 'file',
316
315
  ...(args.blockId === undefined ? {} : { blockId: args.blockId }),
317
- })
316
+ }, undefined, policyOf(ctx, sessionOf(ctx, args.sessionId)))
318
317
  if (!result.ok) return { ok: false, error: result.reason }
319
318
  return { ok: true, revision: result.flags.revision }
320
319
  }
@@ -0,0 +1,63 @@
1
+ /** Optional Host patch target extraction; results never substitute raw preimages. @author ddj 2026年11月04号 */
2
+ import { loadHostModule } from './hostImport.js'
3
+
4
+ export interface ToolCall {
5
+ name?: string
6
+ id?: string
7
+ arguments?: { file_path?: unknown; path?: unknown; input?: unknown }
8
+ }
9
+ interface PatchTarget { path: string; moveTo?: string }
10
+
11
+ /** @public @author ddj 2026年11月04号 @param result Untrusted tool envelope. @returns Diagnostic-only status; never writable preimages. */
12
+ export function patchDiagnostics(result: unknown): string[] {
13
+ const envelope = result as { value?: unknown } | null
14
+ const payload = (envelope?.value ?? result) as { phase?: unknown; operations?: unknown } | null
15
+ if (payload?.phase !== 'commit' || !Array.isArray(payload.operations)) return ['apply_patch result unavailable or malformed']
16
+ const diagnostics: string[] = []
17
+ for (const operation of payload.operations) {
18
+ if (!operation || typeof operation.path !== 'string' || !['add', 'update', 'delete', 'move'].includes(operation.kind)
19
+ || !['completed', 'failed', 'unknown', 'skipped'].includes(operation.status)) {
20
+ diagnostics.push('apply_patch operation malformed'); continue
21
+ }
22
+ if (operation.status !== 'completed') diagnostics.push(operation.path + ': ' + operation.status)
23
+ if (operation.changes !== undefined && (!Array.isArray(operation.changes) || operation.changes.some((change: any) =>
24
+ !change || typeof change.path !== 'string' || !(change.before === null || typeof change.before === 'string') || !(change.after === null || typeof change.after === 'string')))) {
25
+ diagnostics.push(operation.path + ': changes malformed')
26
+ }
27
+ }
28
+ return diagnostics
29
+ }
30
+ export type PatchParser = (input: string) => PatchTarget[]
31
+ let parserLoad: Promise<PatchParser | null> | undefined
32
+
33
+ /** @public @author ddj 2026年11月04号 @returns Optional official parser; missing capabilities remain retryable. */
34
+ export async function patchParser(): Promise<PatchParser | null> {
35
+ if (!parserLoad) {
36
+ parserLoad = loadHostModule('@deepseek-ai/dsh-fs-patch').then((module) => {
37
+ const parser = (module as { parsePatch?: PatchParser }).parsePatch
38
+ return typeof parser === 'function' ? parser : null
39
+ }).catch(() => null)
40
+ }
41
+ const parser = await parserLoad
42
+ if (!parser) parserLoad = undefined
43
+ return parser
44
+ }
45
+
46
+ /** @public @author ddj 2026年11月04号 @param call Tool identity and arguments. @param parser Optional injectable official parser. @returns Unique unresolved source/destination paths. @throws Unsupported parser or malformed patch. */
47
+ export async function toolTargets(call: ToolCall, parser?: PatchParser | null): Promise<string[]> {
48
+ if (['edit', 'write', 'str_replace_editor'].includes(String(call.name))) {
49
+ const path = call.arguments?.file_path ?? call.arguments?.path
50
+ return typeof path === 'string' && path ? [path] : []
51
+ }
52
+ if (call.name !== 'apply_patch') return []
53
+ if (typeof call.arguments?.input !== 'string') throw new Error('apply_patch input unavailable')
54
+ const parse = parser === undefined ? await patchParser() : parser
55
+ if (!parse) throw new Error('Host patch parser unavailable')
56
+ const paths = new Set<string>()
57
+ for (const operation of parse(call.arguments.input)) {
58
+ if (typeof operation.path !== 'string' || !operation.path) throw new Error('Invalid patch target')
59
+ paths.add(operation.path)
60
+ if (operation.moveTo) paths.add(operation.moveTo)
61
+ }
62
+ return [...paths]
63
+ }