dsh-vscode-mode 0.21.0 → 0.21.2

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.0",
3
+ "version": "0.21.2",
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": [
@@ -206,7 +206,10 @@ export function refOf(mention: string, range: RefRange | undefined): string {
206
206
  : `${mention} L${range.startLine}-${range.endLine}`
207
207
  }
208
208
 
209
- /** 构造 DSH 文件引用插入载荷(source=reference,发送序列化为 mention(+range) 文本)。
209
+ /** 构造 DSH 文件引用插入载荷,模型文本、剪贴板投影与降级文本共用完整行区间。
210
+ * @public
211
+ * @author ddj 2026年10月28号
212
+ * @returns 引用载荷与含可选行区间的完整引用文本
210
213
  * @param path 目标路径(相对 cwd 或绝对)
211
214
  * @param cwd 会话工作区目录(可选)
212
215
  * @param range 行区间(可选)
@@ -218,12 +221,13 @@ export function buildFileRef(
218
221
  range?: RefRange,
219
222
  appearance: RefAppearance = 'file',
220
223
  ): { reference: ReferenceInsertLike; mention: string } {
221
- const mention = mentionOf(path, cwd)
224
+ const mention = refOf(mentionOf(path, cwd), range)
225
+
222
226
  return {
223
227
  mention,
224
228
  reference: {
225
229
  source: REF_SOURCE,
226
- ref: refOf(mention, range),
230
+ ref: mention,
227
231
  label: labelOf(path, range),
228
232
  appearance,
229
233
  clipboardText: mention,
@@ -264,6 +268,41 @@ export interface TokenSpanLike {
264
268
  /** ctx 依赖的最小面(sessions.scope + conversation.input)。 */
265
269
  export interface CtxLike {
266
270
  get: (name: string) => unknown
271
+ effect?: (setup: () => () => void, label?: string) => (() => void)
272
+ }
273
+
274
+ /** Public Controller subset; old Hosts need not provide retain/binding. */
275
+ interface SessionBridge {
276
+ retain?: (id: string, options: { source: 'controllerOperation'; signal: AbortSignal }) => {
277
+ ready: Promise<{ session: { prompt(content: { type: 'text'; text: string }[], mode: 'queue', signal: AbortSignal): Promise<{ ok: boolean }> } }>
278
+ release(): void
279
+ }
280
+ binding?: (id: string) => { ctx: unknown } | undefined
281
+ scope?: (id: string) => unknown
282
+ }
283
+
284
+ /** @private @author ddj 2026年10月28号 @param ctx Lifecycle owner. @param sessions Public Controller. @param id Fixed target. @param text Task body. @returns Acceptance; failures never retry another transport. @sideEffects Retains one generation until completion or unload. */
285
+ async function sendOwned(ctx: CtxLike, sessions: SessionBridge, id: string, text: string): Promise<boolean> {
286
+ const controller = new AbortController()
287
+ const reference = sessions.retain!(id, { source: 'controllerOperation', signal: controller.signal })
288
+ let released = false
289
+ /** @private @author ddj 2026年10月28号 Cancel pending admission and release this exact generation once. */
290
+ function release(): void {
291
+ if (released) return
292
+ released = true
293
+ controller.abort()
294
+ reference.release()
295
+ }
296
+ let remove: (() => void) | undefined
297
+ try {
298
+ remove = ctx.effect?.(() => release, 'vscode-mode: task session reference')
299
+ const binding = await reference.ready
300
+ if (controller.signal.aborted) return false
301
+ return (await binding.session.prompt([{ type: 'text', text }], 'queue', controller.signal)).ok === true
302
+ } finally {
303
+ release()
304
+ remove?.()
305
+ }
267
306
  }
268
307
 
269
308
  /** 新建会话结果(ok=false 供调用方降级回当前对话注入)。 */
@@ -368,8 +407,9 @@ export async function apiSessionPrompt(sessionId: string, text: string): Promise
368
407
 
369
408
  /**
370
409
  * 新建独立会话承载旁路任务(02-deep-session-prompt:深度分析不污染当前对话)。
371
- * 主路径 = apiproxy wire 直调(不依赖 service 面形态猜测);回落 remote.session.create
372
- * 与旧 sessions.create 两代 service 面(宽松解析)。任一步不符返回 ok:false,调用方降级。
410
+ * 优先公开 sessions.create;旧 Host 按能力选择 remote 或 wire,调用后不换通道重试。
411
+ * 受理状态不明时返回 ok:false,不重复创建会话。
412
+ * @author ddj 2026年10月28号
373
413
  * @author ddj 2026年09月23号
374
414
  * @param ctx 客户端服务上下文(remote.session + sessions,仅回落用)
375
415
  * @param cwd 新会话工作区目录(与当前会话同工作区)
@@ -391,21 +431,20 @@ export async function newDraftSession(ctx: CtxLike, cwd: string | undefined): Pr
391
431
  : ''
392
432
  if (id) return { ok: true, id }
393
433
  }
394
- } catch { /* 回落下一通道 */ }
434
+ // A create call may have succeeded remotely even when its receipt is unavailable.
435
+ if (typeof sessions?.create === 'function') return { ok: false }
436
+ } catch { return { ok: false } }
395
437
  try {
396
- // 回落 1:api.sessions service 面(返回信封 {rpcId, result},见 rpcOk;多形态容错)
438
+ // Legacy Hosts select one known transport; never retry a non-idempotent create.
397
439
  const remote = ctx.get('remote.session') as { create?: (req: unknown) => Promise<unknown> } | undefined
398
440
  if (typeof remote?.create === 'function') {
399
441
  const payload = cwd ? { cwd } : {}
400
- for (const arg of [slotArg(payload), { request: { rpcId: mintRpcId(), payload } }, { rpcId: mintRpcId(), payload }, payload]) {
401
- try {
402
- const r = rpcOk(await remote.create(arg))
403
- const id = r.value as { sessionId?: unknown } | undefined
404
- if (r.ok && typeof id?.sessionId === 'string') return { ok: true, id: id.sessionId }
405
- } catch { /* 单形态失败试下一形态 */ }
406
- }
442
+ const receipt = rpcOk(await remote.create(slotArg(payload)))
443
+ const created = receipt.value as { sessionId?: unknown } | undefined
444
+ if (receipt.ok && typeof created?.sessionId === 'string') return { ok: true, id: created.sessionId }
445
+ return { ok: false }
407
446
  }
408
- } catch { /* 回落 wire */ }
447
+ } catch { return { ok: false } }
409
448
  // 回落 2:wire 直调(创建成功但 store 无感,open 切换不保证;任务发送仍可用)
410
449
  try {
411
450
  const id = await apiSessionCreate(cwd)
@@ -441,9 +480,9 @@ export interface AddToConversation {
441
480
  export function inputFor(ctx: CtxLike, sessionId: string | undefined): InputLike | undefined {
442
481
  if (!sessionId) return undefined
443
482
  try {
444
- const sessions = ctx.get('sessions') as { scope?: (id: string) => unknown } | undefined
483
+ const sessions = ctx.get('sessions') as SessionBridge | undefined
445
484
  const conversation = ctx.get('conversation') as { input?: { for: (actx: unknown) => unknown } } | undefined
446
- const actx = sessions?.scope?.(sessionId)
485
+ const actx = typeof sessions?.binding === 'function' ? sessions.binding(sessionId)?.ctx : sessions?.scope?.(sessionId)
447
486
  const shell = actx && conversation?.input?.for?.(actx)
448
487
  // 探针须是我们的**实际依赖**(insertReference),而不是会被整篇重建的 setDraft
449
488
  return (shell && typeof (shell as InputLike).insertReference === 'function') ? shell as InputLike : undefined
@@ -569,13 +608,15 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
569
608
  /**
570
609
  * 新建独立会话(02-deep-session-prompt:旁路任务不污染当前对话)。
571
610
  * 闭包持有真服务 ctx(面板 SidebarCtx 无 .get,会话操作必须经此门面);
572
- * 创建后尽力 `sessions.open(id)` 切换视图(缺失不致命,草稿仍可写入)。
611
+ * 新版仅返回已登记身份,视图选择由宿主负责;旧版保留可用的 open/select。
612
+ * @author ddj 2026年10月28号
573
613
  * @author ddj 2026年09月23号
574
614
  * @param cwd 新会话工作区目录(与当前会话同工作区)
575
615
  * @returns 新会话结果(失败供降级)
576
616
  */
577
617
  const startDraftSession: AddToConversation['startDraftSession'] = async (cwd) => {
578
618
  const result = await newDraftSession(ctx, cwd)
619
+ if (typeof (ctx.get('sessions') as SessionBridge | undefined)?.retain === 'function') return result
579
620
  if (result.ok && result.id) {
580
621
  try {
581
622
  // 主动铸造 scope(scope 对新会话惰性铸造,inputFor/composer 面依赖它)
@@ -598,48 +639,32 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
598
639
 
599
640
  /**
600
641
  * 发送任务给目标会话 agent(点击即发送方案:任务落地会话即非 blank,列表可见)。
601
- * 主路径 wire 直发 session/prompt;回落 remote.session.prompt service 面。
602
- * @author ddj 2026年09月23号
642
+ * 新版持有目标 Controller 到 prompt 结束;旧版按能力选择唯一通道,不跨通道重发。
643
+ * @author ddj 2026年09月23号 / 2026年10月28号
603
644
  * @param sessionId 目标会话 id
604
645
  * @param text 任务文本
605
646
  * @returns 是否受理
606
647
  */
607
648
  const sendTask: AddToConversation['sendTask'] = async (sessionId, text) => {
608
649
  if (!sessionId) return false
609
- // 主路径:**官方 composer 发送链路**(InputLike setDraft + submit —— UI「发送」的内部路径)。
610
- // inputFor 依赖 sessions.scope(id):新会话铸造有时延,重试 3 次后再降级。
611
- const composerOnce = async (): Promise<boolean> => {
612
- const input = inputFor(ctx, sessionId) as { setDraft?: (t: string) => void; submit?: (mode?: unknown) => void } | undefined
613
- if (input && typeof input.setDraft === 'function' && typeof input.submit === 'function') {
650
+ try {
651
+ const sessions = ctx.get('sessions') as SessionBridge | undefined
652
+ if (typeof sessions?.retain === 'function') return await sendOwned(ctx, sessions, sessionId, text)
653
+
654
+ // Legacy composer remains an exclusive channel; never overwrite the draft and then retry elsewhere.
655
+ const input = inputFor(ctx, sessionId) as { setDraft?: (task: string) => void; submit?: () => void } | undefined
656
+ if (typeof input?.setDraft === 'function' && typeof input.submit === 'function') {
614
657
  input.setDraft(text)
615
658
  input.submit()
616
659
  return true
617
660
  }
618
- return false
619
- }
620
- try {
621
- for (let attempt = 3; attempt > 0; attempt--) {
622
- if (await composerOnce()) return true
623
- if (attempt > 1) await new Promise((resolve) => setTimeout(resolve, 500))
624
- }
625
- } catch { /* 降级下一通道 */ }
626
- // 兜底 1:remote.session service 面(多形态容错)
627
- try {
628
661
  const remote = ctx.get('remote.session') as { prompt?: (req: unknown) => Promise<unknown> } | undefined
629
662
  if (typeof remote?.prompt === 'function') {
630
663
  const payload = { sessionId, mode: 'queue', content: [{ type: 'text', text }] }
631
- for (const arg of [slotArg(payload), { request: { rpcId: mintRpcId(), payload } }, { rpcId: mintRpcId(), payload }, payload]) {
632
- try {
633
- if (rpcOk(await remote.prompt(arg)).ok) return true
634
- } catch { /* 单形态失败试下一形态 */ }
635
- }
664
+ return rpcOk(await remote.prompt(slotArg(payload))).ok
636
665
  }
637
- } catch { /* 降级下一通道 */ }
638
- // 兜底 2:HTTP wire
639
- try {
640
666
  return await apiSessionPrompt(sessionId, text)
641
- } catch { /* 全失败 */ }
642
- return false
667
+ } catch { return false }
643
668
  }
644
669
 
645
670
  return { appendReference, appendText, startDraftSession, sendTask }
@@ -0,0 +1,40 @@
1
+ import type { MissingReview } from '../shared/types.js'
2
+ import { splitLines } from '../shared/diff.js'
3
+
4
+ /** @public @author ddj 2026年10月28号 @param review Frozen complete snapshot. @returns One whole-file deletion, including empty files; no invented current lines. */
5
+ export function missingRegion(review: MissingReview) {
6
+ return {
7
+ callId: review.callIds[0], idx: 0, start: 1, end: 1, oldStart: 1,
8
+ oldLines: splitLines(review.content.replace(/\r\n/g, '\n')), newLines: [], whole: true,
9
+ status: 'pending', create: false, superseded: false, sourceKind: 'missing',
10
+ }
11
+ }
12
+
13
+ interface MissingBatchApi {
14
+ query(path: string): Promise<{ ok: true; review: MissingReview | null } | { ok: false; error: string }>
15
+ decide(review: MissingReview, operationId: string): Promise<{ ok: true; callIds: string[] } | { ok: false; error: string }>
16
+ guard(): void
17
+ operation(review: MissingReview): string
18
+ committed(review: MissingReview, callIds: string[]): void
19
+ }
20
+
21
+ /** @public @author ddj 2026年10月28号 @param paths Batch paths. @param api Guarded Host access. @returns Missing paths handled once; a partial failure stops before legacy hunk writes. @throws Missing evidence, changed lifecycle or Host decision failure. */
22
+ export async function missingBatch(paths: string[], api: MissingBatchApi): Promise<Set<string>> {
23
+ const candidates: MissingReview[] = []
24
+ for (const path of new Set(paths)) {
25
+ api.guard()
26
+ const result = await api.query(path)
27
+ if (!result.ok) throw new Error(result.error)
28
+ if (result.review) candidates.push(result.review)
29
+ }
30
+
31
+ const completed = new Set<string>()
32
+ for (const review of candidates) {
33
+ api.guard()
34
+ const result = await api.decide(review, api.operation(review))
35
+ if (!result.ok) throw new Error(`已处理 ${completed.size} 个删除文件,其余未处理。${result.error}`)
36
+ completed.add(review.path)
37
+ api.committed(review, result.callIds)
38
+ }
39
+ return completed
40
+ }
@@ -61,6 +61,7 @@ import { applyOfficial, editorFont, registerThemes, themeNameOf } from '../monac
61
61
  import { createDiffRenderer } from '../monaco/diffRender.js'
62
62
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
63
63
  import { diffRegions } from '../state/regions.js'
64
+ import { missingBatch, missingRegion } from '../missingReview.js'
64
65
  import { QuickOpen } from './QuickOpen.js'
65
66
  import { attachHorizontalWheel } from './horizontalWheel.js'
66
67
  import { CommandPalette } from './CommandPalette.js'
@@ -319,6 +320,9 @@ export function EditorView(props) {
319
320
  const [dirtyMap, setDirtyMap] = React.useState({})
320
321
  const [content, setContent] = React.useState(null)
321
322
  const [contentPath, setContentPath] = React.useState(null)
323
+ const [missingPreview, setMissingPreview] = React.useState(null)
324
+ const missingRef = React.useRef(new Map())
325
+ const missingNow = missingPreview?.path === active && missingPreview?.sessionId === sessionId ? missingPreview : null
322
326
  const [receiptLoad, setReceiptLoad] = React.useState(null) // { sessionId, path, sequence, status, error }
323
327
  const [imageSrc, setImageSrc] = React.useState(null) // 图片预览 data URL(图片 tab 专用,文本 tab 恒为 null)
324
328
  const [imgSize, setImgSize] = React.useState(null) // 图片自然尺寸 { w, h }(路径栏 meta)
@@ -634,17 +638,25 @@ export function EditorView(props) {
634
638
  const trace = isDebug() && contentReady && currentRecords.length > 0
635
639
  const start = trace ? performance.now() : 0
636
640
  // #endregion
637
- const next = officialDetail
638
- ? reviewRegions(officialDetail.diff, officialDetail.item.id, officialDetail.blocks ?? [], contentReady ? content : null)
639
- : diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded)
641
+ let next
642
+ if (officialDetail) {
643
+ next = reviewRegions(officialDetail.diff, officialDetail.item.id, officialDetail.blocks ?? [], contentReady ? content : null)
644
+ } else if (missingNow && contentReady) {
645
+ next = missingNow.resolved ? [] : [missingRegion(missingNow)]
646
+ } else {
647
+ next = diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded)
648
+ }
640
649
  // #region debug log
641
650
  if (trace) {
642
651
  try { dbg(sessionId, `[DEBUG diffRegions] records=${currentRecords.length} bytes=${content.length} regions=${next.length} ms=${(performance.now() - start).toFixed(1)}`) } catch (e) { /* 诊断不可影响渲染 */ }
643
652
  }
644
653
  // #endregion
645
654
  return next
646
- }, [currentRecords, content, contentPath, active, officialDetail])
655
+ }, [currentRecords, content, contentPath, active, officialDetail, missingNow])
647
656
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
657
+ React.useEffect(() => {
658
+ editorRef.current?.updateOptions({ readOnly: !!missingNow })
659
+ }, [missingNow, active, content, monaco])
648
660
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
649
661
  const staleRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions])
650
662
  const staleMissing = staleRegions.filter((region) => region.staleReason === 'missing').length
@@ -1109,7 +1121,10 @@ export function EditorView(props) {
1109
1121
  /** @author ddj 2026年09月28号 @param res Disk content for a still-clean cached model. */
1110
1122
  const accept = (res) => {
1111
1123
  if (!loadCurrent(path, sequence) || dirtyRef.current[path] || model.getValue() !== before) return
1124
+ if (res?.code === 'file-missing') { void loadMissing(path, sid, sequence); return }
1112
1125
  if (!res?.ok) return
1126
+ missingRef.current.delete(path)
1127
+ setMissingPreview(null)
1113
1128
  mark(res.version)
1114
1129
  if (res.content === before) return
1115
1130
  saveViewState(path)
@@ -1130,6 +1145,27 @@ export function EditorView(props) {
1130
1145
  * @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
1131
1146
  * @param version 已知磁盘版本(缺省走 RPC 返回值)
1132
1147
  */
1148
+ /** @private @author ddj 2026年10月28号 @param path Missing text file. @param sid Owning session. @param seq Loader generation. @sideEffects Loads deletion evidence without creating or saving the file. */
1149
+ async function loadMissing(path, sid, seq) {
1150
+ try {
1151
+ const result = await rpc('edrv.missingReview', { sessionId: sid, path })
1152
+ if (!loadCurrent(path, seq)) return
1153
+ if (!result.ok) { failLoad(path, sid, seq, result.error); return }
1154
+ if (!result.review) { loadContent(path, sid, true); return }
1155
+ if (dirtyRef.current[path] || savingRef.current.has(path)) { failLoad(path, sid, seq, '文件已删除,但有未保存编辑;请先处理本地缓冲'); return }
1156
+ const preview = { ...result.review, sessionId: sid, keepId: crypto.randomUUID(), undoId: crypto.randomUUID() }
1157
+ missingRef.current.set(path, preview)
1158
+ setMissingPreview(preview)
1159
+ setContent('')
1160
+ setContentPath(path)
1161
+ setLoadError(null)
1162
+ setError(null)
1163
+ stampLoad(path, sid, seq)
1164
+ setLoadStage({ progress: 100, message: '文件已删除 · 展示最后记录快照' })
1165
+ setStatus('文件已删除 · 展示最后记录快照(只读)')
1166
+ } catch (cause) { if (loadCurrent(path, seq)) failLoad(path, sid, seq, String(cause)) }
1167
+ }
1168
+
1133
1169
  const loadContent = (path, sid, force, version) => {
1134
1170
  const seq = ++loadSeqRef.current
1135
1171
  setReceiptLoad({ path, cwd, sessionId: sid, sequence: seq, status: 'loading' })
@@ -1176,7 +1212,7 @@ export function EditorView(props) {
1176
1212
  loadPdf(path, sid, seq, force === true, version)
1177
1213
  return
1178
1214
  }
1179
- const cachedModel = force ? null : modelsRef.current.get(path)
1215
+ const cachedModel = force || missingRef.current.has(path) ? null : modelsRef.current.get(path)
1180
1216
  if (cachedModel) {
1181
1217
  // model 命中:直接显示其内容(切 tab 零等待),后台静默校验
1182
1218
  setContent(cachedModel.getValue())
@@ -1199,6 +1235,8 @@ export function EditorView(props) {
1199
1235
  // #endregion
1200
1236
  if (!loadCurrent(path, seq)) return
1201
1237
  if (res && res.ok) {
1238
+ missingRef.current.delete(path)
1239
+ setMissingPreview(null)
1202
1240
  mark(res.version, true)
1203
1241
  setContent(res.content)
1204
1242
  setContentPath(path)
@@ -1206,6 +1244,7 @@ export function EditorView(props) {
1206
1244
  setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
1207
1245
  setStatus('已加载')
1208
1246
  } else {
1247
+ if (res?.code === 'file-missing') { void loadMissing(path, sid, seq); return }
1209
1248
  if (res?.code === 'file-too-large') setLargeCandidate(JSON.stringify([scope, sid, path]))
1210
1249
  const base = res?.error ? String(res.error) : '读取失败'
1211
1250
  dapTrace('read-failed', { path, error: base, resolvedPath: res?.resolvedPath, cwd })
@@ -1409,6 +1448,9 @@ export function EditorView(props) {
1409
1448
  emitRefresh({ scope, paths: [change.path], kind: 'text', eventId })
1410
1449
  return
1411
1450
  }
1451
+ if (change.kind === 'deleted' && change.path === activeRef.current && !dirtyRef.current[change.path] && !savingRef.current.has(change.path)) {
1452
+ loadContent(change.path, sessionId, true)
1453
+ }
1412
1454
  const kind = change.kind === 'deleted' ? 'deleted' : 'conflict'
1413
1455
  markDiskFlag(readSync(scope, change.path) ?? { path: change.path, kind, at: Date.now() })
1414
1456
  setStatus(kind === 'deleted' ? '文件已被外部删除' : '外部已修改(未保存的编辑保留)')
@@ -2351,6 +2393,7 @@ export function EditorView(props) {
2351
2393
  /** @private @author ddj 2026年10月14号 @param path Canonical path. @param manual Explicit save. @returns Guarded save, never another pane's content. */
2352
2394
  function saveDocument(path, manual) {
2353
2395
  if (isExcelPath(path)) return Promise.resolve({ ok: false })
2396
+ if (missingRef.current.has(path)) { setStatus('删除预览只读;请使用 Keep / Undo'); return Promise.resolve({ ok: false }) }
2354
2397
  const model = modelsRef.current.get(path)
2355
2398
  if (!model) return Promise.resolve({ ok: false })
2356
2399
  const store = documents()
@@ -2409,7 +2452,7 @@ export function EditorView(props) {
2409
2452
  const ed = editorRef.current
2410
2453
  const model = ed?.getModel()
2411
2454
  const path = [...modelsRef.current].find(([, item]) => item === model)?.[0]
2412
- if (!ed || !path) return
2455
+ if (!ed || !path || missingRef.current.has(path)) return
2413
2456
  setDirtyMap((d) => Object.assign({}, d, { [path]: true }))
2414
2457
  // 编辑即「使用」:需求 2 的淘汰口径是「最近没有改动」,故有改动时刷新 LRU 序号,
2415
2458
  // 使长时间只在某文件里编辑的页签不会被其它文件的打开动作挤出。
@@ -2533,6 +2576,7 @@ export function EditorView(props) {
2533
2576
  const side = layoutRef.current === 'side'
2534
2577
  const ed = m.editor.create(node, {
2535
2578
  value: '',
2579
+ readOnly: missingRef.current.has(activeRef.current),
2536
2580
  language: 'plaintext',
2537
2581
  theme: themeNameOf(),
2538
2582
  fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
@@ -3149,6 +3193,7 @@ export function EditorView(props) {
3149
3193
  * @returns Promise<boolean> 是否成功
3150
3194
  */
3151
3195
  const actHunk = (region, reject, silent) => {
3196
+ if (region.sourceKind === 'missing') return decideMissing(reject)
3152
3197
  if (region.sourceKind === 'official') return decideOfficial(reject, region.id)
3153
3198
  const method = reject ? 'edrv.reject' : 'edrv.accept'
3154
3199
  // #region debug log
@@ -3280,7 +3325,35 @@ export function EditorView(props) {
3280
3325
  finally { batchBusyRef.current = false }
3281
3326
  }
3282
3327
 
3328
+ /** @private @author ddj 2026年10月28号 @param reject Restore the shown snapshot instead of keeping absence. @returns Safe whole-file decision, never a hunk reversal. */
3329
+ async function decideMissing(reject) {
3330
+ if (batchBusyRef.current || !missingNow || missingNow.resolved) return false
3331
+ const preview = missingNow
3332
+ const lifetime = reviewLifeRef.current
3333
+ batchBusyRef.current = true
3334
+ setReviewBatchBusy(true)
3335
+ try {
3336
+ guardReviewAll(reject, lifetime)
3337
+ const result = await rpc('edrv.missingDecide', { sessionId, token: preview.token, operationId: reject ? preview.undoId : preview.keepId, undo: reject })
3338
+ if (!lifetime?.live) return false
3339
+ if (!result.ok) { setError(result.error); return false }
3340
+ setRecords((previous) => Object.fromEntries(Object.entries(previous).filter(([id]) => !result.callIds.includes(id))))
3341
+ missingRef.current.set(preview.path, { ...preview, resolved: true })
3342
+ if (activeRef.current === preview.path) {
3343
+ if (reject) loadContent(preview.path, sessionId, true)
3344
+ else setMissingPreview({ ...preview, resolved: true })
3345
+ }
3346
+ refreshRecords()
3347
+ emitRefresh({ scope, paths: [preview.path], kind: 'structure' })
3348
+ setStatus(reject ? '已恢复展示的完整历史快照' : '已保留文件删除')
3349
+ setError(null)
3350
+ return true
3351
+ } catch (cause) { if (lifetime?.live) setError(String(cause)); return false }
3352
+ finally { batchBusyRef.current = false; if (lifetime?.live) setReviewBatchBusy(false) }
3353
+ }
3354
+
3283
3355
  const acceptFile = () => {
3356
+ if (missingNow && !officialDetail) return decideMissing(false)
3284
3357
  if (officialDetail) return decideOfficial(false)
3285
3358
  if (batchBusyRef.current || !decideRegions.length) return
3286
3359
  batchBusyRef.current = true
@@ -3293,6 +3366,7 @@ export function EditorView(props) {
3293
3366
  }).catch(() => { batchBusyRef.current = false })
3294
3367
  }
3295
3368
  const undoFile = () => {
3369
+ if (missingNow && !officialDetail) return decideMissing(true)
3296
3370
  if (officialDetail) return decideOfficial(true)
3297
3371
  if (batchBusyRef.current || !decideRegions.length) return
3298
3372
  batchBusyRef.current = true
@@ -3325,18 +3399,47 @@ export function EditorView(props) {
3325
3399
  * @author ddj 2026年08月20号
3326
3400
  * @param reject true=全部不采纳(回滚),false=全部采纳
3327
3401
  */
3328
- const actAllPending = (reject) => {
3402
+ const actAllPending = async (reject) => {
3329
3403
  if (batchBusyRef.current || !allPending.length) return
3404
+ const lifetime = reviewLifeRef.current
3330
3405
  batchBusyRef.current = true
3331
3406
  const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
3332
3407
  const paths = [...new Set(list.map((r) => records[r.callId]?.path).filter(Boolean))]
3333
- return actMany(list.map((r) => itemOf(r, reject))).then((result) => {
3334
- batchBusyRef.current = false
3335
- reloadFile(true)
3336
- emitRefresh({ scope, paths: paths.length ? paths : undefined, kind: 'structure' })
3337
- setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result))
3338
- if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
3339
- }).catch(() => { batchBusyRef.current = false })
3408
+ try {
3409
+ const handled = await missingBatch(paths, {
3410
+ query: async (path) => {
3411
+ // The active deletion must use exactly the evidence the user saw.
3412
+ const cached = missingRef.current.get(path)
3413
+ if (cached?.sessionId === sessionId && !cached.resolved) return { ok: true, review: cached }
3414
+ const result = await rpc('edrv.missingReview', { sessionId, path })
3415
+ if (result.ok && result.review) {
3416
+ const preview = { ...result.review, sessionId, keepId: crypto.randomUUID(), undoId: crypto.randomUUID() }
3417
+ missingRef.current.set(path, preview)
3418
+ return { ok: true, review: preview }
3419
+ }
3420
+ return result
3421
+ },
3422
+ decide: (preview, operationId) => rpc('edrv.missingDecide', { sessionId, token: preview.token, operationId, undo: reject }),
3423
+ guard: () => guardReviewAll(reject, lifetime),
3424
+ operation: (preview) => reject ? preview.undoId : preview.keepId,
3425
+ committed: (preview, callIds) => {
3426
+ if (!lifetime?.live) return
3427
+ missingRef.current.set(preview.path, { ...preview, resolved: true })
3428
+ setRecords((previous) => Object.fromEntries(Object.entries(previous).filter(([id]) => !callIds.includes(id))))
3429
+ if (!reject && sameFile(activeRef.current, preview.path)) setMissingPreview({ ...preview, path: activeRef.current, sessionId, resolved: true })
3430
+ emitRefresh({ scope, paths: [preview.path], kind: 'structure' })
3431
+ },
3432
+ })
3433
+ guardReviewAll(reject, lifetime)
3434
+ const remaining = list.filter((item) => !handled.has(records[item.callId]?.path))
3435
+ const result = await actMany(remaining.map((item) => itemOf(item, reject)))
3436
+ if (!lifetime?.live) return
3437
+ if (reject || !missingNow) reloadFile(true)
3438
+ emitRefresh({ scope, paths, kind: 'structure' })
3439
+ setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result) + `;删除文件 ${handled.size} 个`)
3440
+ if (result.fail) setError(result.fail + ' 处差异处理失败,可刷新后重试')
3441
+ } catch (cause) { if (lifetime?.live) setError(String(cause)) }
3442
+ finally { batchBusyRef.current = false; if (lifetime?.live) refreshRecords() }
3340
3443
  }
3341
3444
  /** @private @author ddj 2026年10月27号 @param reject Undo requires every local buffer to be saved. @param token Starting session lifetime. @returns Throws before the next mutation when unsafe. */
3342
3445
  function guardReviewAll(reject, token) {
@@ -4584,6 +4687,7 @@ export function EditorView(props) {
4584
4687
  const reviewErrorText = !rawReviewError ? undefined : sessionDown ? SESSION_DOWN_NOTICE : sessionErrorText(rawReviewError)
4585
4688
  const buildDockSnapshot = () => ({
4586
4689
  mode: editorDockMode(active),
4690
+ missingDeletion: !!missingNow,
4587
4691
  official: officialFile ? { sessionId, page: review.page, path: officialFile,
4588
4692
  pending: sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0,
4589
4693
  detail: officialDetail, records: officialRecords, onSelect: setSelectedReview } : null,
@@ -22,6 +22,7 @@ export interface SidecarRegion {
22
22
  /** 侧车栏输入:字段与官方栏对齐,缺的是官方记录元数据,多的是侧车区域的 keep/undo 能力。 */
23
23
  export interface SidecarBarProps {
24
24
  activePath?: string
25
+ missingDeletion?: boolean
25
26
  pendingRegions?: SidecarRegion[]
26
27
  staleRegions?: SidecarRegion[]
27
28
  diffIdx?: number
@@ -115,6 +116,7 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
115
116
  keepTitle="把这批历史改动全部标记为已看过(只改记录,不动磁盘文件)"
116
117
  undoTitle="逐项撤销这些历史改动;缺少证据或遇到冲突即停止"
117
118
  />
119
+ {props.missingDeletion ? <div className="edrv-review-notice" role="status">文件已删除 · 展示最后记录快照。Keep 保留删除;Undo 恢复展示的完整快照。</div> : null}
118
120
  {busy ? <div className="edrv-review-notice" role="status">正在处理历史改动…</div> : null}
119
121
  {down ? <div className="edrv-review-notice edrv-review-notice-warn edrv-sidecar-down" role="status">
120
122
  <span className="edrv-sidecar-down-text">{down.text}</span>
@@ -137,8 +139,8 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
137
139
  {navButton('down', '下一个差异', props.onNextDiff, !count, view.navNextTitle)}
138
140
  </div>
139
141
  <div className="edrv-review-actions">
140
- <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={!canDecide} title={SIDECAR_UNDO_TITLE}>Undo</Button>
141
- <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={!canDecide} title={SIDECAR_KEEP_TITLE}>Keep</Button>
142
+ <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
+ <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>
142
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>
143
145
  <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }, { id: 'rollback', label: '回滚当前文件' }, { id: 'refresh', label: '刷新' }]} onSelect={(id: string) => {
144
146
  if (id === 'rollback') { setMenu(null); props.onRollback?.(); return }
@@ -0,0 +1,42 @@
1
+ /** Bounded credential-safe MCP failure diagnostics. @author ddj 2026年10月28号 */
2
+ import type { MpcConfig } from './shared/mcp.js'
3
+
4
+ /**
5
+ * Format an initialization failure without serializing the MCP configuration.
6
+ * @public @author ddj 2026年10月28号
7
+ * @param error Original failure and optional cause chain.
8
+ * @param config Effective configuration, used only to remove known secret values.
9
+ * @returns Bounded single-line diagnostic; URLs and explicit credentials are removed.
10
+ */
11
+ export function mcpErrorText(error: unknown, config: MpcConfig): string {
12
+ const messages: string[] = []
13
+ const seen = new Set<unknown>()
14
+ let cause = error
15
+ while (cause !== undefined && !seen.has(cause) && messages.length < 4) {
16
+ seen.add(cause)
17
+ if (cause instanceof Error) {
18
+ const code = (cause as Error & { code?: unknown }).code
19
+ messages.push(cause.name + ': ' + cause.message + (typeof code === 'string' ? ' [' + code + ']' : ''))
20
+ cause = cause.cause
21
+ } else {
22
+ messages.push(typeof cause === 'string' ? cause : 'Unknown MCP failure')
23
+ break
24
+ }
25
+ }
26
+
27
+ let text = messages.join(' <- ')
28
+ const secrets = [...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {})]
29
+ // Arguments may contain credentials; never reproduce their non-option values in an error.
30
+ for (const argument of config.args ?? []) {
31
+ if (!argument.startsWith('-')) secrets.push(argument)
32
+ }
33
+ secrets.sort((left, right) => right.length - left.length)
34
+ for (const secret of secrets) {
35
+ if (secret) text = text.split(secret).join('[REDACTED]')
36
+ }
37
+
38
+ text = text.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]')
39
+ .replace(/\b(authorization|api[-_]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi, '$1=[REDACTED]')
40
+ .replace(/[\r\n\x00-\x1f\x7f]+/g, ' ')
41
+ return text.slice(0, 2048)
42
+ }
package/src/mcpPool.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Connection ownership, bounded cold starts and workspace leases. @author ddj 2026年10月20号 */
2
2
  import { createHash } from 'node:crypto'
3
3
  import { McpBridge } from './mcpBridge.js'
4
+ import { debugRecord } from './debugLog.js'
5
+ import { mcpErrorText } from './mcpDiagnostic.js'
4
6
  import type { McpPolicy, McpPhase, MpcConfig } from './shared/mcp.js'
5
7
  import type { Ctx } from './store.js'
6
8
 
@@ -155,7 +157,9 @@ export class McpPool {
155
157
  entry.connectedAt = entry.finishedAt
156
158
  } catch (error) {
157
159
  entry.error = 'MCP 初始化失败或超时,请查看 Host 日志'
158
- this.ctx.logger?.warn?.('MCP initialization failed: ' + String(error))
160
+ const diagnostic = 'MCP initialization failed; phase=' + entry.phase + '; transport=' + entry.spec.config.transport
161
+ + '; elapsedMs=' + (Date.now() - entry.startAt) + '; ' + mcpErrorText(error, entry.spec.config)
162
+ debugRecord(this.ctx, entry.spec.workspace, diagnostic, 'warn')
159
163
  // Invalidation precedes disposal; neither late module load nor callbacks may publish.
160
164
  entry.live = false
161
165
  entry.phase = 'stopping'
package/src/mcpRuntime.ts CHANGED
@@ -265,8 +265,17 @@ export class McpRuntime {
265
265
  instance ??= this.reserve(agent, record, path, name, def)
266
266
  await waitMcp(instance.ready, signal)
267
267
  if (!this.valid(agent, record, name, instance)) throw new Error('项目 MCP 配置已失效')
268
- const visible = agent.ctx.get('tools')?.view?.(agent)?.visible
269
- const tools = [...instance.lease.entry.bridge!.tools.values()].filter((tool) => visible?.has(tool.name))
268
+ const registry = agent.ctx.get('tools')
269
+ // Discovery is not step admission; the official executor still owns execution permission.
270
+ let visible: ReadonlySet<string>
271
+ if (typeof registry?.definitions === 'function') {
272
+ visible = new Set(registry.definitions(agent).map((tool: { name: string }) => tool.name))
273
+ } else if (typeof registry?.view === 'function') {
274
+ visible = new Set(registry.view(agent).visible.keys())
275
+ } else {
276
+ throw new Error('项目 MCP 工具目录不可用:缺少公开 definitions 或旧版 view 接口')
277
+ }
278
+ const tools = [...instance.lease.entry.bridge!.tools.values()].filter((tool) => visible.has(tool.name))
270
279
  .map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }))
271
280
  return { serverName: name, tools, resources: instance.lease.entry.bridge!.resources.has(name), nextRequest: 'Use these tools in the next model request; PTC must start a new program to refresh its SDK.' }
272
281
  }