dsh-vscode-mode 0.7.0 → 0.9.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 (60) hide show
  1. package/README.md +37 -6
  2. package/assets/icon.svg +7 -0
  3. package/cordis.patch.yml +16 -0
  4. package/lib/client.js +2804 -300
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1975 -191
  7. package/lib/index.js.map +1 -1
  8. package/locale/en.json +4 -0
  9. package/locale/zh.json +4 -0
  10. package/package.json +4 -2
  11. package/src/ai/inline.ts +19 -2
  12. package/src/ai/svnTriage.ts +150 -0
  13. package/src/capture.ts +225 -51
  14. package/src/client/addToConversation.ts +203 -1
  15. package/src/client/compat.ts +134 -4
  16. package/src/client/imagePreview.ts +76 -2
  17. package/src/client/index.ts +65 -36
  18. package/src/client/monaco/lsp/lspClient.ts +46 -0
  19. package/src/client/monaco/lsp/providers.ts +245 -1
  20. package/src/client/nativeOpenStore.ts +91 -0
  21. package/src/client/officialSidebar.ts +11 -45
  22. package/src/client/outline/index.ts +1 -1
  23. package/src/client/pdf/pdfPanel.ts +18 -0
  24. package/src/client/pendingNav.ts +141 -0
  25. package/src/client/settingsContext.ts +6 -4
  26. package/src/client/sidebar/panels/FileExplorer.ts +2 -15
  27. package/src/client/sidebar/panels/SvnPanel.ts +682 -72
  28. package/src/client/sidebar/panels/index.ts +1 -1
  29. package/src/client/sidebar/panels/svnListBudget.ts +58 -0
  30. package/src/client/state/records.ts +7 -1
  31. package/src/client/styles/editor.css +47 -5
  32. package/src/client/svnStatus.ts +206 -1
  33. package/src/client/svnStore.ts +6 -1
  34. package/src/client/ui/AiSettings.ts +47 -14
  35. package/src/client/ui/DiffBox.ts +1 -1
  36. package/src/client/ui/DiffLauncher.ts +6 -4
  37. package/src/client/ui/EditorView.ts +149 -22
  38. package/src/client/ui/McpSettings.ts +30 -0
  39. package/src/client/ui/PerfSettings.ts +4 -1
  40. package/src/client/ui/SvnAiPlanDialog.ts +293 -0
  41. package/src/client/ui/icons.ts +91 -0
  42. package/src/compat.ts +26 -5
  43. package/src/dshVersion.ts +2 -1
  44. package/src/fileOpenSettings.ts +274 -49
  45. package/src/index.ts +18 -1
  46. package/src/lsp/client.ts +24 -0
  47. package/src/lsp/rpc.ts +57 -0
  48. package/src/lsp/server.ts +272 -2
  49. package/src/model.ts +2 -1
  50. package/src/perf.ts +90 -4
  51. package/src/remoteWorkspace.ts +177 -0
  52. package/src/routes.ts +13 -1
  53. package/src/rpc.ts +115 -39
  54. package/src/shared/ai.ts +12 -0
  55. package/src/shared/lsp.ts +79 -0
  56. package/src/shared/nativeOpen.ts +91 -0
  57. package/src/shared/rpc.ts +162 -2
  58. package/src/shared/svn.ts +296 -10
  59. package/src/shared/types.ts +5 -1
  60. package/src/svn.ts +235 -13
package/locale/en.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "VSCodeMode",
3
+ "description": "VSCode-like coding on DSH: Monaco editor with tabs and QuickOpen, command palette, agent edit review, SVN integration, and LSP smartness."
4
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "VSCodeMode",
3
+ "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(页签/QuickOpen/状态栏)、命令栏、Agent 编辑差异审查、SVN 集成与 LSP 智能。"
4
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.7.0",
3
+ "version": "0.9.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 扩展市场安装),状态持久化到工作区旁车",
5
+ "icon": "assets/icon.svg",
5
6
  "keywords": [
6
7
  "dsh",
7
8
  "dsh-plugin",
@@ -35,6 +36,7 @@
35
36
  "files": [
36
37
  "lib",
37
38
  "assets",
39
+ "locale",
38
40
  "skills",
39
41
  "src",
40
42
  "unity",
@@ -42,6 +44,7 @@
42
44
  ],
43
45
  "license": "MIT",
44
46
  "dependencies": {
47
+ "@deepseek-ai/schemastery": "~3.18.4",
45
48
  "@vscode/ripgrep": "^1.18.0"
46
49
  },
47
50
  "peerDependencies": {
@@ -51,7 +54,6 @@
51
54
  "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
52
55
  "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
53
56
  "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
54
- "@deepseek-ai/schemastery": ">=3.18.0 <4",
55
57
  "cordis": ">=4.0.0-rc <5",
56
58
  "react": "^18.2.0",
57
59
  "react-dom": "^18.2.0",
package/src/ai/inline.ts CHANGED
@@ -42,7 +42,7 @@ export function resetAiDirCache(): void {
42
42
  * @param ctx DSH 上下文
43
43
  * @returns llm 运行时或 null
44
44
  */
45
- function llmOf(ctx: Ctx): LlmRuntimeLike | null {
45
+ export function llmOf(ctx: Ctx): LlmRuntimeLike | null {
46
46
  try {
47
47
  const llm = ctx.get('llm') as LlmRuntimeLike | undefined
48
48
  return llm && typeof llm.stream === 'function' ? llm : null
@@ -58,7 +58,7 @@ function llmOf(ctx: Ctx): LlmRuntimeLike | null {
58
58
  * @param cfg AI 配置
59
59
  * @returns 路由(provider/model);不可路由返回 null
60
60
  */
61
- async function routeOf(llm: LlmRuntimeLike, cfg: AiConfigView): Promise<{ provider: string; model: string } | null> {
61
+ export async function routeOf(llm: LlmRuntimeLike, cfg: AiConfigView): Promise<{ provider: string; model: string } | null> {
62
62
  if (cfg.provider && cfg.model) return { provider: cfg.provider, model: cfg.model }
63
63
  const providers = llm.listProviders()
64
64
  if (!providers.length) return null
@@ -68,6 +68,23 @@ async function routeOf(llm: LlmRuntimeLike, cfg: AiConfigView): Promise<{ provid
68
68
  return { provider, model: models[0].id }
69
69
  }
70
70
 
71
+ /**
72
+ * AI 任务模型路由(非补全场景如 AI 智能整理):任务配置优先,空则回落补全配置链。
73
+ * 回落序:taskProvider/taskModel 双非空直用 → routeOf(补全配置 → 自动首个);
74
+ * effort 同序:taskEffort 非空用之,否则 cfg.effort(空串 = 不携带)。
75
+ * @author ddj 2026年09月23号
76
+ * @param llm llm 运行时
77
+ * @param cfg AI 配置(含可选任务模型字段)
78
+ * @returns 路由(provider/model/effort);不可路由返回 null
79
+ */
80
+ export async function taskRouteOf(llm: LlmRuntimeLike, cfg: AiConfigView): Promise<{ provider: string; model: string; effort: string } | null> {
81
+ const effort = cfg.taskEffort || cfg.effort || ''
82
+ if (cfg.taskProvider && cfg.taskModel) return { provider: cfg.taskProvider, model: cfg.taskModel, effort }
83
+ const base = await routeOf(llm, cfg)
84
+ if (!base) return null
85
+ return { ...base, effort }
86
+ }
87
+
71
88
  /**
72
89
  * 单次 AI 内联补全。
73
90
  * @author ddj
@@ -0,0 +1,150 @@
1
+ /**
2
+ * dsh-vscode-mode host — AI 智能整理分析模块(11-ai-changelist-triage)。
3
+ * 一次性非会话调用:prompt 构造(变更清单 + 可选 diff)→ ctx.llm.stream 流式汇聚
4
+ * (只收 text-delta)→ JSON 解析。llm 复用 inline.ts 的 llmOf;模型路由走独立
5
+ * 「AI 任务模型」配置(taskRouteOf,空则回落补全配置,与内联补全互不干扰)。
6
+ * 产出的原始 JSON 交给 shared 的 normalizeAiPlan 以真实变更清单白名单归一。
7
+ * 作者 ddj 2026年09月23号
8
+ */
9
+ import type { Ctx } from '../store.js'
10
+ import type { SvnChangeEntry } from '../shared/svn.js'
11
+ import { AI_PLAN_TIMEOUT_MS } from '../shared/svn.js'
12
+ import type { AiConfigView } from '../shared/ai.js'
13
+ import type { LlmRuntimeLike, StreamChunk } from './llmTypes.js'
14
+ import { llmOf, taskRouteOf } from './inline.js'
15
+
16
+ /** 方案生成最大输出 token(三段 JSON 上限;再大是幻觉温床)。 */
17
+ export const AI_PLAN_MAX_TOKENS = 8192
18
+
19
+ /** 系统指令:输出唯一 JSON + 三段判据 + 硬约束(不虚构路径/一路径一类)。 */
20
+ export const AI_PLAN_SYSTEM = [
21
+ '你是 SVN 变更整理助手。根据给出的工作副本变更清单(可能附 diff),产出整理方案,',
22
+ '输出唯一 JSON 对象,除 JSON 外不得输出任何文本(不要围栏、不要解释):',
23
+ '{',
24
+ ' "groups": [{ "name": "英文短名", "paths": ["路径1"], "reason": "分组理由" }],',
25
+ ' "reverts": [{ "path": "路径", "reason": "还原理由" }],',
26
+ ' "ignores": [{ "path": "路径", "reason": "忽略理由" }]',
27
+ '}',
28
+ '规则:',
29
+ '1. 只可使用清单中出现的路径,绝不虚构或改写路径。',
30
+ '2. 每个路径至多归入一段(groups/reverts/ignores 互斥),拿不准的路径三段都不列。',
31
+ '3. groups:按功能主题把待提交改动分入 changelist 组;name 用简短英文(不以 - 开头),',
32
+ ' 同一主题合并为一组,组数尽量少。',
33
+ '4. reverts:仅针对受版本控制的改动建议还原——行尾/空白噪音、误改、试验性改动等。',
34
+ '5. ignores:仅针对未版本化的条目建议忽略——构建产物、缓存、日志、临时文件等生成物。',
35
+ '6. reason 用简短中文一句话说明判定依据。',
36
+ ].join('\n')
37
+
38
+ /**
39
+ * 构造分析用户段:变更清单行(status | path | changelist)+ 可选 diff 上下文。
40
+ * @author ddj 2026年09月23号
41
+ * @param entries 变更清单(调用方已按 AI_PLAN_PATHS_CAP 截断)
42
+ * @param diffText diff 文本(null = paths-only 降级)
43
+ * @param truncated 清单是否已截断(prompt 注明,防 AI 误以为是全集)
44
+ * @returns 用户消息文本
45
+ */
46
+ export function buildAiPrompt(entries: readonly SvnChangeEntry[], diffText: string | null, truncated = false): string {
47
+ const lines = entries.map((entry) =>
48
+ entry.status + ' | ' + entry.path + (entry.changelist ? ' | ' + entry.changelist : ''))
49
+ const head = truncated
50
+ ? '变更清单(已截断,仅分析所列条目;status | path | changelist):'
51
+ : '变更清单(status | path | changelist):'
52
+ let text = head + '\n' + lines.join('\n')
53
+ if (diffText) text += '\n\n变更 diff(unified,0 上下文行):\n' + diffText
54
+ else text += '\n\n(无 diff 上下文,仅按路径与状态判断)'
55
+ return text
56
+ }
57
+
58
+ /**
59
+ * 解析 AI 输出 JSON:剥 ``` 围栏 → 取首个 `{` 至末个 `}` → JSON.parse。
60
+ * 失败抛带原文摘要的错误(调用方整体报错零执行,不静默降级)。
61
+ * @author ddj 2026年09月23号
62
+ * @param text 模型输出原文
63
+ * @returns 解析出的对象(形状由 normalizeAiPlan 容错)
64
+ */
65
+ export function parseAiPlanJson(text: string): unknown {
66
+ const raw = String(text ?? '')
67
+ const start = raw.indexOf('{')
68
+ const end = raw.lastIndexOf('}')
69
+ if (start < 0 || end <= start) {
70
+ throw new Error('AI 输出不含 JSON 对象:' + raw.slice(0, 200))
71
+ }
72
+ const body = raw.slice(start, end + 1)
73
+ try {
74
+ return JSON.parse(body)
75
+ } catch (error) {
76
+ throw new Error('AI 输出无法解析为 JSON:' + String(error) + '(原文摘要:' + body.slice(0, 200) + ')')
77
+ }
78
+ }
79
+
80
+ /** AI 分析结果(raw 交 normalizeAiPlan 归一;失败以抛错方式上抛)。 */
81
+ export interface SvnTriageResult {
82
+ /** 解析出的原始方案对象。 */
83
+ raw: unknown
84
+ /** 实际使用的 provider/model(诊断与 UI 展示)。 */
85
+ model: string
86
+ }
87
+
88
+ /**
89
+ * 一次 AI 智能整理分析(一次性非会话流式调用)。
90
+ * llm 缺失/不可路由/流失败/解析失败一律抛错,由 handler 转 `{ok:false, error}`(零执行)。
91
+ * @author ddj 2026年09月23号
92
+ * @param ctx DSH 上下文(取 llm)
93
+ * @param cfg AI 配置(任务模型路由优先,回落补全配置)
94
+ * @param prompt 用户段(buildAiPrompt 产物)
95
+ * @returns 原始方案对象与模型标识
96
+ */
97
+ export async function svnAiPlanOf(ctx: Ctx, cfg: AiConfigView, prompt: string): Promise<SvnTriageResult> {
98
+ const llm: LlmRuntimeLike | null = llmOf(ctx)
99
+ if (!llm) throw new Error('llm 服务不可用(请检查 DSH 模型配置)')
100
+ const route = await taskRouteOf(llm, cfg)
101
+ if (!route) throw new Error('未找到可用模型(请检查 DSH 模型配置)')
102
+ const controller = new AbortController()
103
+ // 超时标志:与普通流失败区分(超时给可操作提示——降档/换快模型),不与适配器报错混文案
104
+ let timedOut = false
105
+ const timer = setTimeout(() => { timedOut = true; controller.abort(new Error('timeout')) }, AI_PLAN_TIMEOUT_MS)
106
+ try {
107
+ const stream = llm.stream({
108
+ provider: route.provider,
109
+ model: route.model,
110
+ ...(route.effort ? { reasoningEffort: route.effort } : {}),
111
+ messages: [
112
+ {
113
+ role: 'user',
114
+ content: [{ type: 'text', text: prompt }],
115
+ source: { kind: 'user' },
116
+ } as never,
117
+ ],
118
+ system: AI_PLAN_SYSTEM,
119
+ temperature: 0.2,
120
+ maxTokens: AI_PLAN_MAX_TOKENS,
121
+ // 不传 stop:llm-pi-ai 等适配器不支持该选项(UNSUPPORTED_OPTION 直接 error finish)
122
+ signal: controller.signal,
123
+ })
124
+ let text = ''
125
+ let failNote = ''
126
+ for await (const chunk of stream) {
127
+ const c = chunk as StreamChunk
128
+ if (c.type === 'text-delta') {
129
+ text += c.text
130
+ } else if (c.type === 'finish') {
131
+ // 适配器失败走 finish(error/aborted) 而非抛异常(inline 同款口径)
132
+ const kind = (c.reason as { kind?: string })?.kind
133
+ if (kind === 'error' || kind === 'aborted') {
134
+ const failure = (c.reason as { failure?: { code?: string; message?: string } }).failure
135
+ failNote = '模型流失败 ' + (failure?.code ?? kind) + ': ' + (failure?.message ?? '模型调用失败')
136
+ }
137
+ break
138
+ }
139
+ }
140
+ if (failNote) throw new Error(failNote)
141
+ return { raw: parseAiPlanJson(text), model: route.provider + '/' + route.model }
142
+ } catch (error) {
143
+ if (timedOut) {
144
+ throw new Error('AI 分析超时(' + Math.round(AI_PLAN_TIMEOUT_MS / 1000) + 's):可在设置页调低任务模型思考强度或换更快模型')
145
+ }
146
+ throw error
147
+ } finally {
148
+ clearTimeout(timer)
149
+ }
150
+ }
package/src/capture.ts CHANGED
@@ -1,18 +1,42 @@
1
1
  /**
2
- * dsh-vscode-mode host — 捕获层:监听 tools/result,把 edit/write 差异落成审查记录。
3
- * 迁移自原 src/index.ts 的 tools/result 处理,语义不改。
4
- * 作者 ddj 2026-08-20
2
+ * dsh-vscode-mode host — 捕获层:监听 tools/result,把 edit/write/remote_ssh_write
3
+ * 差异落成审查记录。
4
+ * 迁移自原 src/index.ts 的 tools/result 处理;2026-09-23(issue #7)增补
5
+ * remote_ssh_write:远端工作区会话里 agent 直写远端的全量覆写——before 取本地
6
+ * 镜像快照、after 取参数 content,捕获后把同一内容**写穿**回镜像(node:fs 直写,
7
+ * 刻意不走 ctx.fs,避开 dsh-remote-ssh 写桥的重复推送与补丁次序问题),使镜像 =
8
+ * 远端最新内容,读/stale/决策口径与远端一致。
9
+ * 作者 ddj 2026-08-20(2026-09-23 扩展 remote_ssh_write 捕获)
5
10
  */
11
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
12
+ import { dirname } from 'node:path'
6
13
  import type { DiffRecord, Hunk } from './shared/types.js'
7
14
  import { annotateHunks, fingerprint } from './shared/diff.js'
8
15
  import type { Ctx, Session } from './store.js'
9
- import { loadBucket, saveBucket } from './store.js'
16
+ import { READ_CAP, loadBucket, saveBucket } from './store.js'
10
17
  import { fileMaxBatch, prune } from './model.js'
11
18
  import type { Registry } from './registry.js'
12
19
  import { cwdOf } from './registry.js'
20
+ import { findRemoteWs, mirrorTargetOf } from './remoteWorkspace.js'
13
21
  import { invalidateIndex } from './treeIndex.js'
14
22
  import { log } from './log.js'
15
23
 
24
+ /** 捕获所需的工具执行最小形状(DSH ToolExecution 的结构子集)。 */
25
+ interface CaptureExec {
26
+ name?: unknown
27
+ callId?: unknown
28
+ }
29
+
30
+ /** 捕获所需的工具结果最小形状(tools/result 的结构子集)。 */
31
+ interface CaptureResult {
32
+ isError?: unknown
33
+ value?: { path?: unknown; before?: unknown; after?: unknown } | null
34
+ meta?: { diffs?: unknown }
35
+ }
36
+
37
+ /** 镜像文件读取状态(区分真实缺失、过大/非文件与真实内容)。 */
38
+ type MirrorState = { kind: 'content'; content: string } | { kind: 'missing' } | { kind: 'unreadable' }
39
+
16
40
  /** 校验并提取工具 result meta 中的文件 hunk。 */
17
41
  function validHunks(raw: unknown): Hunk[] {
18
42
  if (!Array.isArray(raw)) return []
@@ -40,9 +64,193 @@ function fallbackHunks(toolName: string, args: Record<string, unknown>, before:
40
64
  return []
41
65
  }
42
66
 
67
+ // --region 记录构造
68
+
69
+ /**
70
+ * 是否为纳入审查的编辑类工具(本地 edit/write + 远端 remote_ssh_write)。
71
+ * @author ddj 2026年09月23号
72
+ * @param name 工具名
73
+ * @returns 纳入审查返回 true
74
+ */
75
+ function isCapturable(name: unknown): boolean {
76
+ return name === 'edit' || name === 'write' || name === 'remote_ssh_write'
77
+ }
78
+
79
+ /**
80
+ * 本地 edit/write 执行结果 → 审查记录(原 captureToolResult 主体语义不变)。
81
+ * @author ddj 2026年09月23号
82
+ * @param exec 工具执行描述
83
+ * @param args 工具参数
84
+ * @param result 工具执行结果
85
+ * @returns 记录;hunk 为空或缺 callId 返回 null
86
+ */
87
+ function buildLocalRecord(exec: CaptureExec, args: Record<string, unknown>, result: CaptureResult): DiffRecord | null {
88
+ const value = result.value
89
+ const path = value && typeof value.path === 'string' ? value.path : args.file_path
90
+ if (typeof path !== 'string' || !path) return null
91
+ const before = value && typeof value.before === 'string' ? value.before : null
92
+ const after = value && typeof value.after === 'string' ? value.after : (typeof args.content === 'string' ? args.content : null)
93
+ const create = exec.name === 'write' && !!value && value.before === null
94
+ const callHunk = exec.name === 'edit' && typeof args.old_string === 'string' && typeof args.new_string === 'string'
95
+ ? { oldText: args.old_string, newText: args.new_string }
96
+ : null
97
+ const metaHunks = validHunks(result.meta?.diffs)
98
+ const rawHunks = metaHunks.length ? metaHunks : fallbackHunks(String(exec.name), args, before, after, create)
99
+ const hunks = annotateHunks(rawHunks, before, after)
100
+ if (!hunks.length || typeof exec.callId !== 'string') return null
101
+ return {
102
+ callId: exec.callId,
103
+ toolName: exec.name === 'write' ? 'write' : 'edit',
104
+ path,
105
+ before,
106
+ after,
107
+ baseFingerprint: fingerprint(before),
108
+ afterFingerprint: fingerprint(after),
109
+ legacy: false,
110
+ conflict: false,
111
+ create,
112
+ callHunk,
113
+ hunks,
114
+ decisions: { call: 'pending', perHunk: hunks.map(() => 'pending') },
115
+ note: create ? '新建文件:全部拒绝将删除该文件' : before === null ? '未捕获修改前内容(大文件)' : null,
116
+ superseded: false,
117
+ archived: false,
118
+ batch: 0,
119
+ at: new Date().toISOString(),
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 读取镜像文件当前内容(node:fs 直读;缺失 → missing,非文件/过大/读失败 → unreadable)。
125
+ * @author ddj 2026年09月23号
126
+ * @param absPath 镜像内绝对路径
127
+ * @returns 读取状态
128
+ */
129
+ async function readMirrorState(absPath: string): Promise<MirrorState> {
130
+ let info: Awaited<ReturnType<typeof stat>>
131
+ try {
132
+ info = await stat(absPath)
133
+ } catch {
134
+ return { kind: 'missing' }
135
+ }
136
+ if (!info.isFile()) return { kind: 'unreadable' }
137
+ if ((info.size ?? 0) > READ_CAP) return { kind: 'unreadable' }
138
+ try {
139
+ return { kind: 'content', content: await readFile(absPath, 'utf8') }
140
+ } catch {
141
+ return { kind: 'unreadable' }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * 把远端写入内容写穿回本地镜像(node:fs 直写 + mkdir -p 父目录)。
147
+ * 刻意不用 ctx.fs:避免触发 dsh-remote-ssh 写桥把相同内容再推回远端。
148
+ * @author ddj 2026年09月23号
149
+ * @param absPath 镜像内绝对路径
150
+ * @param content 远端写入后的完整内容
151
+ * @returns 成功返回 null;失败返回错误文案(不抛)
152
+ */
153
+ async function syncMirrorFile(absPath: string, content: string): Promise<string | null> {
154
+ try {
155
+ await mkdir(dirname(absPath), { recursive: true })
156
+ await writeFile(absPath, content, 'utf8')
157
+ return null
158
+ } catch (error) {
159
+ return String(error)
160
+ }
161
+ }
162
+
163
+ /**
164
+ * 远端记录的 note 组装(新建/未捕获 before/镜像外路径/写穿失败可叠加)。
165
+ * @author ddj 2026年09月23号
166
+ * @param create 镜像中目标不存在(远端新建)
167
+ * @param before 镜像快照内容(null = 未捕获)
168
+ * @param mirrorAbs 可映射的镜像绝对路径(null = 路径不在镜像内)
169
+ * @param syncErr 写穿失败原因(null = 成功)
170
+ * @returns note 文本;无需提示返回 null
171
+ */
172
+ function remoteNoteOf(create: boolean, before: string | null, mirrorAbs: string | null, syncErr: string | null): string | null {
173
+ const notes: string[] = []
174
+ if (!mirrorAbs) {
175
+ notes.push('远端路径不在工作区镜像内(仅记录,无法本地审查)')
176
+ } else if (create) {
177
+ notes.push('新建文件:全部拒绝将删除该文件')
178
+ } else if (before === null) {
179
+ notes.push('未捕获修改前内容(大文件)')
180
+ }
181
+ if (syncErr) notes.push('远端内容写回镜像失败(记录仍已保存):' + syncErr)
182
+ return notes.length ? notes.join(';') : null
183
+ }
184
+
185
+ /**
186
+ * remote_ssh_write 执行 → 审查记录(含镜像写穿,issue #7)。
187
+ * 非远程工作区、参数不合法或内容无变化返回 null(不产生记录)。
188
+ * @author ddj 2026年09月23号
189
+ * @param cwd 会话工作区(远程会话即镜像根)
190
+ * @param exec 工具执行描述
191
+ * @param args 工具参数(path 相对远端工作区目录,或绝对/`~` 形态)
192
+ * @returns 记录;不满足捕获条件返回 null
193
+ */
194
+ async function buildRemoteRecord(cwd: string, exec: CaptureExec, args: Record<string, unknown>): Promise<DiffRecord | null> {
195
+ const ws = findRemoteWs(cwd)
196
+ if (!ws) return null
197
+ const argPath = typeof args.path === 'string' ? args.path : ''
198
+ const after = typeof args.content === 'string' ? args.content : null
199
+ if (!argPath || after === null || typeof exec.callId !== 'string') return null
200
+ const mirrorAbs = mirrorTargetOf(argPath, ws)
201
+ const state = mirrorAbs ? await readMirrorState(mirrorAbs) : ({ kind: 'unreadable' } as const)
202
+ const before = state.kind === 'content' ? state.content : null
203
+ const create = state.kind === 'missing'
204
+ if (before !== null && before === after) return null
205
+ // 内容无差异已 return;走到这里必须写穿,使镜像与远端一致(失败经 note 降级)
206
+ const syncErr = mirrorAbs ? await syncMirrorFile(mirrorAbs, after) : null
207
+ const hunks = annotateHunks([{ oldText: before, newText: after }], before, after)
208
+ return {
209
+ callId: exec.callId,
210
+ toolName: 'remote_ssh_write',
211
+ path: mirrorAbs ?? argPath,
212
+ before,
213
+ after,
214
+ baseFingerprint: fingerprint(before),
215
+ afterFingerprint: fingerprint(after),
216
+ legacy: false,
217
+ conflict: false,
218
+ create,
219
+ callHunk: null,
220
+ hunks,
221
+ decisions: { call: 'pending', perHunk: hunks.map(() => 'pending') },
222
+ note: remoteNoteOf(create, before, mirrorAbs, syncErr),
223
+ superseded: false,
224
+ archived: false,
225
+ batch: 0,
226
+ at: new Date().toISOString(),
227
+ }
228
+ }
229
+ // --endregion
230
+
231
+ /**
232
+ * 记录入桶:按工作区分桶(缺失先加载)、按文件递增批次、裁剪、落盘。
233
+ * @author ddj 2026年09月23号
234
+ * @param ctx DSH 上下文
235
+ * @param registry 工作区记录桶注册表
236
+ * @param session 会话
237
+ * @param cwd 会话工作区
238
+ * @param record 待入桶记录
239
+ */
240
+ async function persistRecord(ctx: Ctx, registry: Registry, session: Session, cwd: string, record: DiffRecord): Promise<void> {
241
+ let bucket = registry.get(cwd)
242
+ if (!bucket) { bucket = await loadBucket(ctx, cwd); registry.set(cwd, bucket) }
243
+ record.batch = fileMaxBatch(bucket, record.path) + 1
244
+ bucket.set(record.callId, record)
245
+ // 同一文件的多次 edit/write 都保留为独立记录,便于逐条审查。
246
+ // 旧实现按文件 batch 自动归档 prior,会导致界面看起来每个文件只剩一条差异。
247
+ prune(bucket)
248
+ await saveBucket(ctx, cwd, bucket, session)
249
+ }
250
+
43
251
  /**
44
- * 捕获一次工具执行结果(tools/result):edit/write 成功时构造记录、
45
- * 递增文件批次、融合归档被后续修改取代的旧差异、落盘。
252
+ * 捕获一次工具执行结果(tools/result):edit/write/remote_ssh_write 成功时
253
+ * 构造记录、递增文件批次、落盘;远端写入额外做镜像写穿(issue #7)。
46
254
  * @author ddj 2026年08月20号
47
255
  * @param ctx DSH 上下文
48
256
  * @param registry 工作区记录桶注册表
@@ -52,55 +260,21 @@ function fallbackHunks(toolName: string, args: Record<string, unknown>, before:
52
260
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
261
  export async function captureToolResult(ctx: Ctx, registry: Registry, exec: any, result: any): Promise<void> {
54
262
  const session: Session | undefined = exec?.agent?.session
55
- if (!session || (exec?.name !== 'edit' && exec?.name !== 'write')) return
56
- if (result?.isError || !result?.value) return
263
+ if (!session || !isCapturable(exec?.name)) return
264
+ if (result?.isError) return
265
+ // remote_ssh_write 只依赖参数与 isError(其 result.value 形状不受本插件约束)
266
+ if (exec.name !== 'remote_ssh_write' && !result?.value) return
57
267
  try {
58
268
  const cwd = cwdOf(session)
59
269
  if (!cwd) return
60
- const value = result.value
61
- const args = (exec.arguments || {}) as Record<string, unknown>
62
- const path = typeof value.path === 'string' ? value.path : args.file_path
63
- if (typeof path !== 'string' || !path) return
64
- const before = typeof value.before === 'string' ? value.before : null
65
- const after = typeof value.after === 'string' ? value.after : (typeof args.content === 'string' ? args.content : null)
66
- const create = exec.name === 'write' && value.before === null
67
- const callHunk = exec.name === 'edit' && typeof args.old_string === 'string' && typeof args.new_string === 'string'
68
- ? { oldText: args.old_string, newText: args.new_string }
69
- : null
70
- const metaHunks = validHunks(result.meta?.diffs)
71
- const rawHunks = metaHunks.length ? metaHunks : fallbackHunks(exec.name, args, before, after, create)
72
- const hunks = annotateHunks(rawHunks, before, after)
73
- if (!hunks.length || typeof exec.callId !== 'string') return
74
- const record: DiffRecord = {
75
- callId: exec.callId,
76
- toolName: exec.name,
77
- path,
78
- before,
79
- after,
80
- baseFingerprint: fingerprint(before),
81
- afterFingerprint: fingerprint(after),
82
- legacy: false,
83
- conflict: false,
84
- create,
85
- callHunk,
86
- hunks,
87
- decisions: { call: 'pending', perHunk: hunks.map(() => 'pending') },
88
- note: create ? '新建文件:全部拒绝将删除该文件' : before === null ? '未捕获修改前内容(大文件)' : null,
89
- superseded: false,
90
- archived: false,
91
- batch: 0,
92
- at: new Date().toISOString(),
93
- }
94
- let bucket = registry.get(cwd)
95
- if (!bucket) { bucket = await loadBucket(ctx, cwd); registry.set(cwd, bucket) }
96
- record.batch = fileMaxBatch(bucket, path) + 1
97
- bucket.set(exec.callId, record)
98
- // 同一文件的多次 edit/write 都保留为独立记录,便于逐条审查。
99
- // 旧实现按文件 batch 自动归档 prior,会导致界面看起来每个文件只剩一条差异。
100
- prune(bucket)
101
- await saveBucket(ctx, cwd, bucket, session)
270
+ const args = ((exec.arguments || {}) as Record<string, unknown>)
271
+ const record = exec.name === 'remote_ssh_write'
272
+ ? await buildRemoteRecord(cwd, exec as CaptureExec, args)
273
+ : buildLocalRecord(exec as CaptureExec, args, result as CaptureResult)
274
+ if (!record) return
275
+ await persistRecord(ctx, registry, session, cwd, record)
102
276
  // agent 写盘后目录树可能变化(新建/删除文件/目录):父目录+祖先进失效,后台自愈。
103
- invalidateIndex(ctx, cwd, path)
277
+ invalidateIndex(ctx, cwd, record.path)
104
278
  } catch (error) {
105
279
  log.error('capture failed: ' + String(error))
106
280
  }