pi-web-ui 0.94.0 → 0.95.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 (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/http-proxy.js +11 -1
  21. package/dist/server/index.js +157 -9
  22. package/dist/server/lsp-tool.js +679 -0
  23. package/dist/server/model-admin.js +20 -0
  24. package/dist/server/office-parse.js +309 -0
  25. package/dist/server/patch-tool.js +91 -0
  26. package/dist/server/plan-manager.js +104 -0
  27. package/dist/server/plugin-api-catalog.js +296 -0
  28. package/dist/server/plugin-install-spec.js +196 -0
  29. package/dist/server/plugin-installer.js +73 -0
  30. package/dist/server/plugin-manifest-validate.js +305 -0
  31. package/dist/server/plugin-project.js +47 -11
  32. package/dist/server/plugin-tool-guard.js +120 -0
  33. package/dist/server/plugins.js +745 -161
  34. package/dist/server/process-utils.js +12 -6
  35. package/dist/server/scheduler-tasks.js +6 -0
  36. package/dist/server/serialize.js +13 -3
  37. package/dist/server/settings-service.js +108 -1
  38. package/dist/server/subagents.js +41 -0
  39. package/dist/server/terminals.js +69 -14
  40. package/dist/server/tool-approval.js +84 -0
  41. package/dist/server/tool-manager.js +214 -6
  42. package/dist/server/workspace-snapshot.js +113 -0
  43. package/package.json +3 -2
  44. package/plugin-sdk/README.md +25 -0
  45. package/plugin-sdk/index.d.ts +31 -38
  46. package/plugin-sdk/index.mjs +35 -19
  47. package/plugins/catalog.json +20 -0
  48. package/themes/ayu-light.css +6 -6
  49. package/themes/catppuccin-latte.css +6 -6
  50. package/themes/claude-code-dark.css +144 -0
  51. package/themes/codex.css +6 -6
  52. package/themes/dark-teal.css +20 -6
  53. package/themes/everforest-light.css +6 -6
  54. package/themes/geist.css +6 -6
  55. package/themes/gruvbox-light.css +6 -6
  56. package/themes/kanagawa-lotus.css +6 -6
  57. package/themes/rose-pine-dawn.css +6 -6
  58. package/themes/solarized-light.css +6 -6
  59. package/themes/vs-code-dark.css +146 -0
  60. package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
  61. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  62. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist/assets/index-18jK6al2.js +0 -364
  65. package/web/dist/assets/index-B3S9MxnN.css +0 -1
@@ -0,0 +1,665 @@
1
+ /**
2
+ * hashline-engine.ts — 基于内容哈希锚定与语法块解析的高可靠 Patch 引擎。
3
+ *
4
+ * 核心设计(借鉴 @oh-my-pi/hashline):
5
+ * 1. [path#TAG] 内容哈希锚点:每个 hunk 绑定文件内容的 4-hex 校验和,杜绝模型基于陈旧行号盲改。
6
+ * 2. PUT N*= 语法块级替换:通过括号嵌套(Brace matching)与缩进(Indentation)自动识别代码块闭合,
7
+ * 模型无需数行号,消除最常见的行号漂移问题。
8
+ * 3. 3-Way Merge 冲突自愈:若磁盘文件已被修改导致哈希不匹配,通过快照与 diff 进行三方合并,
9
+ * 无重叠冲突时平滑合入,冲突时提供带行号的准确定位提示。
10
+ * 4. 寄存器支持(CUT / PUT @reg):支持在跨文件或同文件内部移动代码段。
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
15
+ export const HL_HASH_LENGTH = 4;
16
+ /** 归一化文本换行符与尾随空白(确保哈希跨平台一致) */
17
+ export function normalizeLineEndings(text) {
18
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
19
+ }
20
+ /** 计算文件的 4 位十六进制内容哈希(大写) */
21
+ export function computeFileHash(text) {
22
+ const normalized = normalizeLineEndings(text).replace(/[ \t]+(?=\n|$)/g, "");
23
+ const hash = createHash("sha256").update(normalized, "utf8").digest("hex");
24
+ return hash.slice(0, HL_HASH_LENGTH).toUpperCase();
25
+ }
26
+ /** 为文件生成带哈希的段头:[src/app.ts#A1B2] */
27
+ export function formatHashlineHeader(filePath, hash) {
28
+ return `[${filePath}#${hash}]`;
29
+ }
30
+ /** 为文本输出带行号的展示视图:1:line1\n2:line2 */
31
+ export function formatNumberedLines(text, startLine = 1) {
32
+ const lines = normalizeLineEndings(text).split("\n");
33
+ return lines.map((l, i) => `${startLine + i}:${l}`).join("\n");
34
+ }
35
+ // ----------------------------------------------------------------------------
36
+ // 语法块解析(Block Resolver)
37
+ // ----------------------------------------------------------------------------
38
+ /**
39
+ * 启发式语法块边界解析:从 startLine(1-indexed)开始,定位该语句块的闭合行。
40
+ * 支持:
41
+ * 1. 括号/大括号嵌套语言(JS/TS/Rust/Go/C/C++/Java/PHP):匹配同深度闭合 '}'。
42
+ * 2. 缩进敏感语言(Python/YAML/Makefile):匹配缩进回退的边界。
43
+ * 3. Markdown 标题:匹配同级或更高级标题的边界。
44
+ */
45
+ export function resolveBlockSpan(lines, startLine, filePath = "") {
46
+ const n = lines.length;
47
+ const sIdx = Math.max(0, Math.min(n - 1, startLine - 1));
48
+ const startContent = lines[sIdx] ?? "";
49
+ const trimmedStart = startContent.trim();
50
+ // 1. Markdown 标题:## Title -> 下一个同级或更高级标题
51
+ const isMd = /\.(md|markdown|mdx)$/i.test(filePath);
52
+ const mdHeaderMatch = trimmedStart.match(/^(#{1,6})\s+/);
53
+ if (isMd && mdHeaderMatch) {
54
+ const level = mdHeaderMatch[1].length;
55
+ let endIdx = sIdx;
56
+ for (let i = sIdx + 1; i < n; i++) {
57
+ const line = lines[i].trim();
58
+ const m = line.match(/^(#{1,6})\s+/);
59
+ if (m && m[1].length <= level)
60
+ break;
61
+ endIdx = i;
62
+ }
63
+ return { start: sIdx + 1, end: endIdx + 1 };
64
+ }
65
+ // 2. 缩进语言判定(Python、YAML、Make)
66
+ const isIndentLang = /\.(py|yaml|yml|makefile|dockerfile)$/i.test(filePath) || !startContent.includes("{");
67
+ if (isIndentLang && (trimmedStart.endsWith(":") || !trimmedStart.includes("{"))) {
68
+ const baseIndent = startContent.match(/^[ \t]*/)?.[0].length ?? 0;
69
+ let endIdx = sIdx;
70
+ for (let i = sIdx + 1; i < n; i++) {
71
+ const line = lines[i];
72
+ if (line.trim().length === 0) {
73
+ // 空行不打断块,只要后面还有更深缩进
74
+ continue;
75
+ }
76
+ const curIndent = line.match(/^[ \t]*/)?.[0].length ?? 0;
77
+ if (curIndent <= baseIndent) {
78
+ break;
79
+ }
80
+ endIdx = i;
81
+ }
82
+ return { start: sIdx + 1, end: endIdx + 1 };
83
+ }
84
+ // 3. 大括号 / 括号计数解析(JS/TS/Rust/Go/C/C++ 等)
85
+ let braceDepth = 0;
86
+ let parenDepth = 0;
87
+ let bracketDepth = 0;
88
+ let seenOpen = false;
89
+ let endIdx = sIdx;
90
+ for (let i = sIdx; i < n; i++) {
91
+ const line = lines[i];
92
+ let inString = null;
93
+ let escaped = false;
94
+ for (let j = 0; j < line.length; j++) {
95
+ const char = line[j];
96
+ const next = line[j + 1];
97
+ if (escaped) {
98
+ escaped = false;
99
+ continue;
100
+ }
101
+ if (char === "\\") {
102
+ escaped = true;
103
+ continue;
104
+ }
105
+ if (inString) {
106
+ if (char === inString)
107
+ inString = null;
108
+ continue;
109
+ }
110
+ if (char === '"' || char === "'" || char === "`") {
111
+ inString = char;
112
+ continue;
113
+ }
114
+ // 单行注释
115
+ if (char === "/" && next === "/")
116
+ break;
117
+ if (char === "{") {
118
+ braceDepth++;
119
+ seenOpen = true;
120
+ }
121
+ else if (char === "}") {
122
+ braceDepth--;
123
+ }
124
+ else if (char === "(") {
125
+ parenDepth++;
126
+ }
127
+ else if (char === ")") {
128
+ parenDepth--;
129
+ }
130
+ else if (char === "[") {
131
+ bracketDepth++;
132
+ }
133
+ else if (char === "]") {
134
+ bracketDepth--;
135
+ }
136
+ }
137
+ endIdx = i;
138
+ if (seenOpen && braceDepth <= 0 && parenDepth <= 0 && bracketDepth <= 0) {
139
+ break;
140
+ }
141
+ }
142
+ return { start: sIdx + 1, end: endIdx + 1 };
143
+ }
144
+ /**
145
+ * 解析 Hashline 补丁文本
146
+ */
147
+ export function parseHashlinePatch(patchText) {
148
+ const lines = normalizeLineEndings(patchText).split("\n");
149
+ const sections = [];
150
+ let currentSection = null;
151
+ let currentHunk = null;
152
+ const flushHunk = () => {
153
+ if (currentHunk && currentSection) {
154
+ currentSection.hunks.push(currentHunk);
155
+ currentHunk = null;
156
+ }
157
+ };
158
+ const flushSection = () => {
159
+ flushHunk();
160
+ if (currentSection) {
161
+ sections.push(currentSection);
162
+ currentSection = null;
163
+ }
164
+ };
165
+ for (let i = 0; i < lines.length; i++) {
166
+ const rawLine = lines[i];
167
+ const trimmed = rawLine.trim();
168
+ // 1. 段落头: [path#TAG] 或 [path]
169
+ const secMatch = rawLine.match(/^\s*\[([^#\]]+)(?:#([0-9a-fA-F]{4}))?\]\s*$/);
170
+ if (secMatch) {
171
+ flushSection();
172
+ currentSection = {
173
+ filePath: secMatch[1].trim(),
174
+ expectedHash: secMatch[2] ? secMatch[2].toUpperCase() : undefined,
175
+ hunks: [],
176
+ };
177
+ continue;
178
+ }
179
+ if (!currentSection) {
180
+ // 未进入段落前的空白或注释跳过
181
+ continue;
182
+ }
183
+ // 2. 文件级指令: REM (删除当前文件)
184
+ if (/^REM\b/i.test(trimmed)) {
185
+ flushHunk();
186
+ currentSection.hunks.push({ kind: "remove_file", bodyLines: [] });
187
+ continue;
188
+ }
189
+ // 3. 文件级指令: MV dest (重命名/移动当前文件)
190
+ const mvMatch = trimmed.match(/^MV\s+([^\s]+)/i);
191
+ if (mvMatch) {
192
+ flushHunk();
193
+ currentSection.hunks.push({ kind: "move_file", targetPath: mvMatch[1], bodyLines: [] });
194
+ continue;
195
+ }
196
+ // 4. CUT 指令: CUT N.=M [@reg] 或 CUT N* [@reg]
197
+ const cutMatch = trimmed.match(/^CUT\s+(\d+)(?:\.=(\d+)|\*)\s*(?:@([a-zA-Z0-9_-]+))?/);
198
+ if (cutMatch) {
199
+ flushHunk();
200
+ const start = parseInt(cutMatch[1], 10);
201
+ const isBlock = trimmed.includes("*");
202
+ const end = cutMatch[2] ? parseInt(cutMatch[2], 10) : start;
203
+ const reg = cutMatch[3];
204
+ currentSection.hunks.push({
205
+ kind: isBlock ? "cut_block" : "cut_range",
206
+ lineStart: start,
207
+ lineEnd: isBlock ? undefined : end,
208
+ registerName: reg,
209
+ bodyLines: [],
210
+ });
211
+ continue;
212
+ }
213
+ // 5. 粘贴指令 (无冒号头): PUT <N @reg / PUT >N @reg / PUT N.=M @reg / PUT N* @reg
214
+ const pasteMatch = trimmed.match(/^PUT\s+(<|>)?(\d+)(?:\.=(\d+)|\*)?\s+@([a-zA-Z0-9_-]+)/);
215
+ if (pasteMatch && !trimmed.endsWith(":")) {
216
+ flushHunk();
217
+ const prefix = pasteMatch[1]; // < 或 >
218
+ const num = parseInt(pasteMatch[2], 10);
219
+ const endNum = pasteMatch[3] ? parseInt(pasteMatch[3], 10) : num;
220
+ const isBlock = trimmed.includes("*");
221
+ const reg = pasteMatch[4];
222
+ let kind;
223
+ if (prefix === "<") {
224
+ kind = "insert_before";
225
+ }
226
+ else if (prefix === ">") {
227
+ kind = "insert_after";
228
+ }
229
+ else if (isBlock) {
230
+ kind = "paste_over_block";
231
+ }
232
+ else {
233
+ kind = "paste_over_range";
234
+ }
235
+ currentSection.hunks.push({
236
+ kind,
237
+ lineStart: num,
238
+ lineEnd: endNum,
239
+ registerName: reg,
240
+ bodyLines: [],
241
+ });
242
+ continue;
243
+ }
244
+ // 6. 替换或插入指令头 (带冒号):
245
+ // PUT N.=M: / PUT N*: / PUT <N: / PUT >N: / PUT >N*: / PUT <$: / PUT >$:
246
+ const putHeaderMatch = trimmed.match(/^PUT\s+(<|>)?(\d+|\$)(?:\.=(\d+)|\*)?:$/);
247
+ if (putHeaderMatch) {
248
+ flushHunk();
249
+ const prefix = putHeaderMatch[1];
250
+ const anchorStr = putHeaderMatch[2];
251
+ const start = anchorStr === "$" ? -1 : parseInt(anchorStr, 10);
252
+ const end = putHeaderMatch[3] ? parseInt(putHeaderMatch[3], 10) : start;
253
+ const isBlock = trimmed.includes("*");
254
+ let kind;
255
+ if (prefix === "<") {
256
+ kind = "insert_before";
257
+ }
258
+ else if (prefix === ">") {
259
+ kind = isBlock ? "insert_after_block" : "insert_after";
260
+ }
261
+ else if (isBlock) {
262
+ kind = "put_block";
263
+ }
264
+ else {
265
+ kind = "put_range";
266
+ }
267
+ currentHunk = {
268
+ kind,
269
+ lineStart: start,
270
+ lineEnd: isBlock ? undefined : end,
271
+ bodyLines: [],
272
+ };
273
+ continue;
274
+ }
275
+ // 7. 内容行 (以 '+' 开头)
276
+ if (rawLine.startsWith("+")) {
277
+ if (currentHunk) {
278
+ currentHunk.bodyLines.push(rawLine.slice(1));
279
+ }
280
+ continue;
281
+ }
282
+ }
283
+ flushSection();
284
+ return sections;
285
+ }
286
+ // ----------------------------------------------------------------------------
287
+ // 快照存储与三方合并恢复(Snapshot Store & 3-Way Merge Recovery)
288
+ // ----------------------------------------------------------------------------
289
+ export class HashlineSnapshotStore {
290
+ snapshots = new Map(); // path -> (hash -> content)
291
+ record(filePath, text) {
292
+ const hash = computeFileHash(text);
293
+ let fileMap = this.snapshots.get(filePath);
294
+ if (!fileMap) {
295
+ fileMap = new Map();
296
+ this.snapshots.set(filePath, fileMap);
297
+ }
298
+ fileMap.set(hash, text);
299
+ return hash;
300
+ }
301
+ get(filePath, hash) {
302
+ return this.snapshots.get(filePath)?.get(hash.toUpperCase());
303
+ }
304
+ }
305
+ /** 全局会话级快照单例 */
306
+ export const globalSnapshotStore = new HashlineSnapshotStore();
307
+ /**
308
+ * 启发式三方行合并:
309
+ * baseLines: 制作补丁时的原始文件快照
310
+ * currentLines: 磁盘当前的最新文件
311
+ * targetHunks: 针对 baseLines 计算出的目标改动行区间
312
+ *
313
+ * 核心逻辑:若磁盘文件的改动未侵入 targetHunks 所在的锚点行,则将锚点行映射到最新文件的对应行,安全完成合并。
314
+ */
315
+ export function tryRecoverEdits(baseText, currentText, hunks, filePath) {
316
+ const baseLines = normalizeLineEndings(baseText).split("\n");
317
+ const curLines = normalizeLineEndings(currentText).split("\n");
318
+ // 若内容完全相同,无需恢复
319
+ if (baseText === currentText)
320
+ return { success: true, remappedHunks: hunks };
321
+ // 简单行偏移探测:计算首个不一致行与行数差
322
+ let firstDiff = -1;
323
+ for (let i = 0; i < Math.max(baseLines.length, curLines.length); i++) {
324
+ if (baseLines[i] !== curLines[i]) {
325
+ firstDiff = i + 1;
326
+ break;
327
+ }
328
+ }
329
+ if (firstDiff === -1)
330
+ return { success: true, remappedHunks: hunks };
331
+ // 检查是否有 hunk 的锚点位于差异产生点之前或之后
332
+ const delta = curLines.length - baseLines.length;
333
+ const remapped = [];
334
+ for (const hunk of hunks) {
335
+ const start = hunk.lineStart ?? 1;
336
+ const end = hunk.lineEnd ?? start;
337
+ if (start < firstDiff && end < firstDiff) {
338
+ // 在差异行之前,行号保持不变
339
+ remapped.push({ ...hunk });
340
+ }
341
+ else {
342
+ // 差异行之后,尝试应用行号平移 delta
343
+ const shiftedStart = Math.max(1, start + delta);
344
+ const shiftedEnd = Math.max(1, end + delta);
345
+ // 校验平移后的周围文本是否与 base 时的上下文匹配
346
+ const baseSnippet = baseLines.slice(Math.max(0, start - 2), Math.min(baseLines.length, end + 1)).join("\n");
347
+ const curSnippet = curLines
348
+ .slice(Math.max(0, shiftedStart - 2), Math.min(curLines.length, shiftedEnd + 1))
349
+ .join("\n");
350
+ if (baseSnippet.trim() === curSnippet.trim() || delta === 0) {
351
+ remapped.push({
352
+ ...hunk,
353
+ lineStart: shiftedStart,
354
+ lineEnd: hunk.lineEnd !== undefined ? shiftedEnd : undefined,
355
+ });
356
+ }
357
+ else {
358
+ return {
359
+ success: false,
360
+ reason: `目标文件在第 ${firstDiff} 行附近发生外部修改,补丁锚点(第 ${start} 行)产生冲突`,
361
+ };
362
+ }
363
+ }
364
+ }
365
+ return { success: true, remappedHunks: remapped };
366
+ }
367
+ /**
368
+ * 应用 Hashline 补丁
369
+ * 支持传入虚拟或物理文件读写器(便于单元测试或沙箱执行)
370
+ */
371
+ export function applyHashlinePatch(patchText, options = {}) {
372
+ const cwd = options.cwd ?? process.cwd();
373
+ const store = options.snapshotStore ?? globalSnapshotStore;
374
+ const defaultRead = (p) => {
375
+ const full = resolve(cwd, p);
376
+ if (!existsSync(full))
377
+ return null;
378
+ return readFileSync(full, "utf8");
379
+ };
380
+ const defaultWrite = (p, content) => {
381
+ const full = resolve(cwd, p);
382
+ const dir = dirname(full);
383
+ if (!existsSync(dir)) {
384
+ mkdirSync(dir, { recursive: true });
385
+ }
386
+ writeFileSync(full, content, "utf8");
387
+ };
388
+ const defaultDelete = (p) => {
389
+ const full = resolve(cwd, p);
390
+ if (existsSync(full)) {
391
+ unlinkSync(full);
392
+ }
393
+ };
394
+ const read = options.readFile ?? defaultRead;
395
+ const write = options.writeFile ?? defaultWrite;
396
+ const remove = options.deleteFile ?? defaultDelete;
397
+ const sections = parseHashlinePatch(patchText);
398
+ if (sections.length === 0) {
399
+ return { ok: false, summary: "未能从输入中解析出有效的 [path#TAG] 补丁段", results: [] };
400
+ }
401
+ const clipboard = new Map(); // 命名剪切板寄存器
402
+ const plannedWrites = new Map(); // path -> 新内容
403
+ const plannedDeletes = new Set();
404
+ const results = [];
405
+ // ===== 阶段 1: 预检所有文件并计算目标内容 =====
406
+ for (const sec of sections) {
407
+ // 越界安全防护
408
+ const rel = relative(cwd, resolve(cwd, sec.filePath));
409
+ if (rel === ".." ||
410
+ rel.startsWith(".." + sep) ||
411
+ rel.startsWith("../") ||
412
+ rel.startsWith("..\\") ||
413
+ isAbsolute(rel)) {
414
+ return {
415
+ ok: false,
416
+ summary: `路径越界:禁止修改工作区外的文件 ${sec.filePath}`,
417
+ results: [],
418
+ error: `Path traversal denied: ${sec.filePath}`,
419
+ };
420
+ }
421
+ // 同一次 patch 内对同文件的后续段落,优先使用前面段落生成的新内容
422
+ const currentText = plannedWrites.get(sec.filePath) ?? read(sec.filePath);
423
+ if (currentText === null) {
424
+ return {
425
+ ok: false,
426
+ summary: `文件不存在:${sec.filePath}(hashline 仅支持修改已存在的文件,新建文件请使用 write 工具)`,
427
+ results: [],
428
+ error: `File not found: ${sec.filePath}`,
429
+ };
430
+ }
431
+ const isCrlf = currentText.includes("\r\n");
432
+ const eol = isCrlf ? "\r\n" : "\n";
433
+ const liveHash = computeFileHash(currentText);
434
+ let workingText = currentText;
435
+ let isRecovered = false;
436
+ // 哈希校验与三方冲突自愈
437
+ if (sec.expectedHash && sec.expectedHash !== liveHash) {
438
+ const snapshot = store.get(sec.filePath, sec.expectedHash);
439
+ if (snapshot) {
440
+ const recovery = tryRecoverEdits(snapshot, currentText, sec.hunks, sec.filePath);
441
+ if (recovery.success && recovery.remappedHunks) {
442
+ sec.hunks = recovery.remappedHunks;
443
+ isRecovered = true;
444
+ }
445
+ else {
446
+ return {
447
+ ok: false,
448
+ summary: `文件内容已发生变动,且三方合流失败:${sec.filePath}(预期哈希 #${sec.expectedHash},实际哈希 #${liveHash};${recovery.reason ?? "冲突"})\n请重新使用 read 工具查看该文件最新内容后再提交 patch。`,
449
+ results: [],
450
+ error: `Hash mismatch and recovery failed on ${sec.filePath}`,
451
+ };
452
+ }
453
+ }
454
+ else {
455
+ return {
456
+ ok: false,
457
+ summary: `文件内容与锚点不一致:${sec.filePath}(预期哈希 #${sec.expectedHash},实际哈希 #${liveHash})\n请重新使用 read 工具读取该文件以获取最新行号与 #TAG。`,
458
+ results: [],
459
+ error: `Hash mismatch on ${sec.filePath} (#${sec.expectedHash} vs #${liveHash})`,
460
+ };
461
+ }
462
+ }
463
+ const hasTrailingNewline = workingText.endsWith("\n");
464
+ let lines = normalizeLineEndings(workingText).split("\n");
465
+ if (hasTrailingNewline && lines.length > 0 && lines[lines.length - 1] === "") {
466
+ lines.pop();
467
+ }
468
+ let isDeleted = false;
469
+ let moveDest = undefined;
470
+ // 倒序/顺序处理 hunks。为了保证行号不受前面 hunk 影响,从后往前执行行号替换;
471
+ // 但先提取 CUT 的内容到寄存器
472
+ for (const h of sec.hunks) {
473
+ if (h.kind === "remove_file") {
474
+ isDeleted = true;
475
+ break;
476
+ }
477
+ if (h.kind === "move_file") {
478
+ if (!h.targetPath)
479
+ continue;
480
+ const destRel = relative(cwd, resolve(cwd, h.targetPath));
481
+ if (destRel === ".." ||
482
+ destRel.startsWith(".." + sep) ||
483
+ destRel.startsWith("../") ||
484
+ destRel.startsWith("..\\") ||
485
+ isAbsolute(destRel)) {
486
+ return {
487
+ ok: false,
488
+ summary: `路径越界:禁止移动文件到工作区外 ${h.targetPath}`,
489
+ results: [],
490
+ error: `Path traversal denied on move target: ${h.targetPath}`,
491
+ };
492
+ }
493
+ moveDest = h.targetPath;
494
+ continue;
495
+ }
496
+ // 语法块展开
497
+ if (h.kind === "put_block" || h.kind === "cut_block" || h.kind === "paste_over_block") {
498
+ const span = resolveBlockSpan(lines, h.lineStart ?? 1, sec.filePath);
499
+ h.lineStart = span.start;
500
+ h.lineEnd = span.end;
501
+ }
502
+ else if (h.kind === "insert_after_block") {
503
+ const span = resolveBlockSpan(lines, h.lineStart ?? 1, sec.filePath);
504
+ h.lineStart = span.end;
505
+ h.kind = "insert_after";
506
+ }
507
+ // 捕获到寄存器
508
+ if (h.kind === "cut_range" || h.kind === "cut_block") {
509
+ const start = Math.max(1, h.lineStart ?? 1);
510
+ const end = Math.min(lines.length, h.lineEnd ?? start);
511
+ const captured = lines.slice(start - 1, end);
512
+ const regKey = h.registerName || "_anon";
513
+ clipboard.set(regKey, captured);
514
+ }
515
+ }
516
+ if (isDeleted) {
517
+ plannedDeletes.add(sec.filePath);
518
+ results.push({
519
+ filePath: sec.filePath,
520
+ op: "deleted",
521
+ oldHash: liveHash,
522
+ linesChanged: lines.length,
523
+ });
524
+ continue;
525
+ }
526
+ // 行号越界校验与重叠校验
527
+ for (const h of sec.hunks) {
528
+ if (h.kind === "remove_file" || h.kind === "move_file")
529
+ continue;
530
+ if (h.lineStart !== -1 && typeof h.lineStart === "number") {
531
+ if (h.lineStart < 1 || h.lineStart > Math.max(1, lines.length)) {
532
+ return {
533
+ ok: false,
534
+ summary: `行号越界:${sec.filePath} 第 ${h.lineStart} 行(文件总共只有 ${lines.length} 行)。请重新使用 read 工具核验行号。`,
535
+ results: [],
536
+ error: `Line out of bounds: line ${h.lineStart} in ${sec.filePath} (${lines.length} lines total)`,
537
+ };
538
+ }
539
+ if (h.lineEnd !== undefined && h.lineEnd > Math.max(1, lines.length)) {
540
+ return {
541
+ ok: false,
542
+ summary: `行号越界:${sec.filePath} 结束行 ${h.lineEnd} 超过文件总行数(总共 ${lines.length} 行)。`,
543
+ results: [],
544
+ error: `Line out of bounds: end line ${h.lineEnd} in ${sec.filePath} (${lines.length} lines total)`,
545
+ };
546
+ }
547
+ }
548
+ }
549
+ const replacementSpans = [];
550
+ for (const h of sec.hunks) {
551
+ if (h.kind === "put_range" ||
552
+ h.kind === "put_block" ||
553
+ h.kind === "cut_range" ||
554
+ h.kind === "cut_block" ||
555
+ h.kind === "paste_over_range" ||
556
+ h.kind === "paste_over_block") {
557
+ const s = h.lineStart === -1 ? lines.length : (h.lineStart ?? 1);
558
+ const e = h.lineEnd ?? s;
559
+ for (const prev of replacementSpans) {
560
+ if (!(e < prev.start || s > prev.end)) {
561
+ return {
562
+ ok: false,
563
+ summary: `补丁段存在重叠的行范围:${sec.filePath}(行 ${s}-${e} 与行 ${prev.start}-${prev.end} 发生重叠)。请合并为一个连续的修改块。`,
564
+ results: [],
565
+ error: `Overlapping hunks in ${sec.filePath} (${s}-${e} overlaps with ${prev.start}-${prev.end})`,
566
+ };
567
+ }
568
+ }
569
+ replacementSpans.push({ start: s, end: e });
570
+ }
571
+ }
572
+ // 按行号从大到小排序执行,避免前方修改引起后续行号错位
573
+ const sortedHunks = [...sec.hunks]
574
+ .filter((h) => h.kind !== "remove_file" && h.kind !== "move_file")
575
+ .sort((a, b) => {
576
+ const lineA = a.lineStart === -1 ? lines.length : (a.lineStart ?? 0);
577
+ const lineB = b.lineStart === -1 ? lines.length : (b.lineStart ?? 0);
578
+ return lineB - lineA;
579
+ });
580
+ let linesChangedCount = 0;
581
+ for (const h of sortedHunks) {
582
+ let contentToInsert = [];
583
+ if (h.kind === "cut_range" || h.kind === "cut_block") {
584
+ contentToInsert = [];
585
+ }
586
+ else if (h.registerName) {
587
+ contentToInsert = clipboard.get(h.registerName) ?? [];
588
+ }
589
+ else {
590
+ contentToInsert = h.bodyLines;
591
+ }
592
+ let start = h.lineStart ?? 1;
593
+ if (start === -1)
594
+ start = lines.length; // $ 尾部锚点
595
+ if (h.kind === "insert_before") {
596
+ const idx = Math.max(0, Math.min(lines.length, start - 1));
597
+ lines.splice(idx, 0, ...contentToInsert);
598
+ linesChangedCount += contentToInsert.length;
599
+ }
600
+ else if (h.kind === "insert_after") {
601
+ const idx = Math.max(0, Math.min(lines.length, start));
602
+ lines.splice(idx, 0, ...contentToInsert);
603
+ linesChangedCount += contentToInsert.length;
604
+ }
605
+ else if (h.kind === "put_range" ||
606
+ h.kind === "put_block" ||
607
+ h.kind === "cut_range" ||
608
+ h.kind === "cut_block" ||
609
+ h.kind === "paste_over_range" ||
610
+ h.kind === "paste_over_block") {
611
+ const s = Math.max(1, start);
612
+ const e = Math.min(lines.length, h.lineEnd ?? s);
613
+ const count = Math.max(0, e - s + 1);
614
+ lines.splice(s - 1, count, ...contentToInsert);
615
+ linesChangedCount += Math.max(count, contentToInsert.length);
616
+ }
617
+ }
618
+ const newContent = lines.join(eol) + (hasTrailingNewline ? eol : "");
619
+ if (moveDest) {
620
+ plannedDeletes.add(sec.filePath);
621
+ plannedWrites.set(moveDest, newContent);
622
+ results.push({
623
+ filePath: sec.filePath,
624
+ op: "moved",
625
+ oldHash: liveHash,
626
+ newHash: computeFileHash(newContent),
627
+ linesChanged: linesChangedCount,
628
+ newPath: moveDest,
629
+ recovered: isRecovered,
630
+ });
631
+ }
632
+ else {
633
+ plannedWrites.set(sec.filePath, newContent);
634
+ results.push({
635
+ filePath: sec.filePath,
636
+ op: "updated",
637
+ oldHash: liveHash,
638
+ newHash: computeFileHash(newContent),
639
+ linesChanged: linesChangedCount,
640
+ recovered: isRecovered,
641
+ });
642
+ }
643
+ }
644
+ // ===== 阶段 2: 全部校验通过,统一落盘 =====
645
+ for (const p of plannedDeletes) {
646
+ remove(p);
647
+ }
648
+ for (const [p, content] of plannedWrites.entries()) {
649
+ write(p, content);
650
+ // 记住新快照
651
+ store.record(p, content);
652
+ }
653
+ const summaryParts = results.map((r) => {
654
+ if (r.op === "deleted")
655
+ return `删除 ${r.filePath}`;
656
+ if (r.op === "moved")
657
+ return `移动 ${r.filePath} -> ${r.newPath}(新哈希 #${r.newHash})`;
658
+ return `修改 ${r.filePath}(${r.linesChanged} 行变动,新哈希 #${r.newHash}${r.recovered ? ",三方自愈" : ""})`;
659
+ });
660
+ return {
661
+ ok: true,
662
+ summary: `成功应用补丁:\n${summaryParts.map((s) => "• " + s).join("\n")}`,
663
+ results,
664
+ };
665
+ }