@trim21/personal-pi-extensions 0.0.313 → 0.0.315

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": "@trim21/personal-pi-extensions",
3
- "version": "0.0.313",
3
+ "version": "0.0.315",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/aft/tools.ts CHANGED
@@ -120,15 +120,6 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
120
120
  content: [{ type: "text", text }],
121
121
  details: {
122
122
  truncated,
123
- pendant: {
124
- markdown: buildPendantMarkdown({
125
- title: "aft_outline",
126
- input: params,
127
- output: text,
128
- truncated,
129
- }),
130
- expanded: true,
131
- } satisfies ToolPendant,
132
123
  },
133
124
  };
134
125
  },
@@ -15,6 +15,7 @@ import {
15
15
  import { Type } from "typebox";
16
16
 
17
17
  import { createLspService, initLsp, type LspService } from "../lib/lsp/lsp.js";
18
+ import { type ToolPendant } from "../lib/pendant.js";
18
19
  import { guardWriteAccess } from "../lib/write-guard.js";
19
20
  import {
20
21
  type ClaudeCodeState,
@@ -45,6 +46,43 @@ function formatFileSize(bytes: number): string {
45
46
  return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
46
47
  }
47
48
 
49
+ /** Edit/Write 结果的可折叠面板:diff(```diff 代码块)+ LSP errors(有则显示)。 */
50
+ export function buildEditPendant(options: {
51
+ filePath: string;
52
+ diff: string;
53
+ diagnosticText: string;
54
+ }): ToolPendant {
55
+ const lines = [`## Diff — \`${options.filePath}\``, "", "```diff", options.diff, "```"];
56
+ if (options.diagnosticText !== "") {
57
+ lines.push("", "## LSP errors", "", "```text", options.diagnosticText, "```");
58
+ }
59
+ return { markdown: lines.join("\n"), expanded: true };
60
+ }
61
+
62
+ /** Read 结果的可折叠面板:路径、总行数、读取范围与文件大小。 */
63
+ export function buildReadPendant(options: {
64
+ filePath: string;
65
+ byteSize: number;
66
+ offset?: number;
67
+ limit?: number;
68
+ totalLines?: number;
69
+ type?: string;
70
+ }): ToolPendant {
71
+ const lines = ["## Read", "", `- **Path**: \`${options.filePath}\``];
72
+ if (options.type) lines.push(`- **Type**: ${options.type}`);
73
+ if (options.totalLines !== undefined) {
74
+ const start = options.offset ?? 1;
75
+ const end = Math.min(
76
+ options.limit === undefined ? options.totalLines : start + options.limit - 1,
77
+ options.totalLines,
78
+ );
79
+ lines.push(`- **Total lines**: ${options.totalLines}`);
80
+ if (start <= end) lines.push(`- **Range**: ${start}-${end}`);
81
+ }
82
+ lines.push(`- **Size**: ${formatFileSize(options.byteSize)}`);
83
+ return { markdown: lines.join("\n"), expanded: false };
84
+ }
85
+
48
86
  /** Tool guidance, kept in markdown so it reads like documentation. */
49
87
  const READ_PROMPT = readFileSync(fileURLToPath(new URL("read.md", import.meta.url)), "utf8").trim();
50
88
  const EDIT_PROMPT = readFileSync(fileURLToPath(new URL("edit.md", import.meta.url)), "utf8").trim();
@@ -69,6 +107,8 @@ export interface FileToolDetails {
69
107
  * session 文件,resume 后由 session_start 重建 reads state。
70
108
  */
71
109
  reads?: Record<string, FileSnapshot>;
110
+ /** 可折叠的 diff / LSP 结果面板(pi TUI 渲染)。 */
111
+ pendant?: ToolPendant;
72
112
  }
73
113
 
74
114
  function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
@@ -286,7 +326,17 @@ export function registerFileTools(
286
326
  const snapshot = snapshotOf(image, false);
287
327
  const key = await readStateKey(filePath);
288
328
  state.reads.set(key, snapshot);
289
- return { content, details: { reads: { [key]: snapshot } } };
329
+ return {
330
+ content,
331
+ details: {
332
+ reads: { [key]: snapshot },
333
+ pendant: buildReadPendant({
334
+ filePath,
335
+ byteSize: image.length,
336
+ type: imageMime,
337
+ }),
338
+ },
339
+ };
290
340
  }
291
341
 
292
342
  const buffer = await readFile(filePath);
@@ -316,7 +366,16 @@ export function registerFileTools(
316
366
  });
317
367
  return {
318
368
  content: [{ type: "text", text: formatted.text }],
319
- details: { reads: { [key]: snapshot } },
369
+ details: {
370
+ reads: { [key]: snapshot },
371
+ pendant: buildReadPendant({
372
+ filePath,
373
+ byteSize: buffer.length,
374
+ offset: params.offset ?? 1,
375
+ limit: params.limit,
376
+ totalLines: formatted.totalLines,
377
+ }),
378
+ },
320
379
  };
321
380
  },
322
381
  });
@@ -357,127 +416,145 @@ export function registerFileTools(
357
416
  replaceAll: params.replace_all,
358
417
  },
359
418
  });
360
- const [message, details] = await withFileMutationQueue<[string, FileToolDetails]>(
361
- filePath,
362
- async () => {
363
- const oldString = params.old_string;
364
- const newString = params.new_string;
365
- if (oldString === newString) {
366
- throw new Error("No changes to make: old_string and new_string are exactly the same.");
367
- }
368
- // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
369
- if (oldString === "") {
370
- let exists = true;
371
- try {
372
- const value = await stat(filePath);
373
- if (value.isFile()) {
374
- const content = await readFile(filePath, "utf8");
375
- if (content.trim() !== "") {
376
- throw new Error("Cannot create new file - file already exists.");
377
- }
378
- }
379
- } catch (error) {
380
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
381
- exists = false;
382
- } else {
383
- throw error;
384
- }
385
- }
386
- if (!exists) await mkdir(dirname(filePath), { recursive: true });
387
- await writeFile(filePath, newString, "utf8");
388
- const snapshot = snapshotOf(newString);
389
- const key = await readStateKey(filePath);
390
- state.reads.set(key, snapshot);
391
- return [
392
- `The file ${filePath} has been updated successfully.`,
393
- { reads: { [key]: snapshot } },
394
- ];
395
- }
396
- const replaceAll = params.replace_all ?? false;
397
- // 防止 OOM 的大文件检查(对齐 Claude Code)
419
+ const [message, details, diagnosticText] = await withFileMutationQueue<
420
+ [string, FileToolDetails, string]
421
+ >(filePath, async () => {
422
+ const oldString = params.old_string;
423
+ const newString = params.new_string;
424
+ if (oldString === newString) {
425
+ throw new Error("No changes to make: old_string and new_string are exactly the same.");
426
+ }
427
+ // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
428
+ if (oldString === "") {
429
+ let exists = true;
398
430
  try {
399
- const { size } = await stat(filePath);
400
- if (size > MAX_EDIT_FILE_SIZE) {
401
- throw new Error(
402
- `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
403
- );
404
- }
405
- } catch (error) {
406
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
407
- throw error;
431
+ const value = await stat(filePath);
432
+ if (value.isFile()) {
433
+ const content = await readFile(filePath, "utf8");
434
+ if (content.trim() !== "") {
435
+ throw new Error("Cannot create new file - file already exists.");
436
+ }
408
437
  }
409
- }
410
- let content: Buffer;
411
- try {
412
- content = await readFile(filePath);
413
438
  } catch (error) {
414
439
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
415
- const suggestion = await didYouMean(filePath, ctx.cwd);
416
- throw new Error(
417
- `File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
418
- { cause: error },
419
- );
440
+ exists = false;
441
+ } else {
442
+ throw error;
420
443
  }
421
- throw error;
422
- }
423
- if (extname(filePath).toLowerCase() === ".ipynb") {
424
- throw new Error(
425
- "File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
426
- );
427
444
  }
445
+ if (!exists) await mkdir(dirname(filePath), { recursive: true });
446
+ await writeFile(filePath, newString, "utf8");
447
+ const snapshot = snapshotOf(newString);
428
448
  const key = await readStateKey(filePath);
429
- requireCurrentRead(state, key, filePath, content);
430
- await access(filePath, constants.R_OK | constants.W_OK);
431
- throwIfAborted(signal);
432
- const original = content.toString("utf8");
433
-
434
- // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
435
- const crlfCount = (original.match(/\r\n/g) ?? []).length;
436
- const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
437
- const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
438
- const normalized = original.replaceAll("\r\n", "\n");
439
- const actualOldString = findActualString(normalized, oldString) ?? oldString;
440
- const matches = normalized.split(actualOldString).length - 1;
441
- if (matches === 0) {
442
- throw new Error(`String to replace not found in file.\nString: ${oldString}`);
443
- }
444
- if (!replaceAll && matches > 1) {
445
- throw new Error(
446
- `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
447
- );
448
- }
449
- const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
450
- // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
451
- const updated = replaceAll
452
- ? normalized.split(actualOldString).join(actualNewString)
453
- : normalized.replace(actualOldString, () => actualNewString);
454
- const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
455
- await writeFile(filePath, restored, "utf8");
456
- const snapshot = snapshotOf(restored);
457
449
  state.reads.set(key, snapshot);
458
- const diff = generateDiffString(original, restored);
459
- const text = replaceAll
460
- ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
461
- : `The file ${filePath} has been updated successfully.`;
450
+ const diff = generateDiffString("", newString);
451
+ throwIfAborted(signal);
452
+ const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
462
453
  return [
463
- text,
454
+ `The file ${filePath} has been updated successfully.`,
464
455
  {
465
456
  diff: diff.diff,
466
- patch: generateUnifiedPatch(filePath, original, restored),
457
+ patch: generateUnifiedPatch(filePath, "", newString),
467
458
  firstChangedLine: diff.firstChangedLine,
468
459
  reads: { [key]: snapshot },
469
460
  },
461
+ diagnosticText,
470
462
  ];
471
- },
472
- );
463
+ }
464
+ const replaceAll = params.replace_all ?? false;
465
+ // 防止 OOM 的大文件检查(对齐 Claude Code)
466
+ try {
467
+ const { size } = await stat(filePath);
468
+ if (size > MAX_EDIT_FILE_SIZE) {
469
+ throw new Error(
470
+ `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
471
+ );
472
+ }
473
+ } catch (error) {
474
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
475
+ throw error;
476
+ }
477
+ }
478
+ let content: Buffer;
479
+ try {
480
+ content = await readFile(filePath);
481
+ } catch (error) {
482
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
483
+ const suggestion = await didYouMean(filePath, ctx.cwd);
484
+ throw new Error(
485
+ `File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
486
+ { cause: error },
487
+ );
488
+ }
489
+ throw error;
490
+ }
491
+ if (extname(filePath).toLowerCase() === ".ipynb") {
492
+ throw new Error(
493
+ "File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
494
+ );
495
+ }
496
+ const key = await readStateKey(filePath);
497
+ requireCurrentRead(state, key, filePath, content);
498
+ await access(filePath, constants.R_OK | constants.W_OK);
499
+ throwIfAborted(signal);
500
+ const original = content.toString("utf8");
501
+
502
+ // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
503
+ const crlfCount = (original.match(/\r\n/g) ?? []).length;
504
+ const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
505
+ const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
506
+ const normalized = original.replaceAll("\r\n", "\n");
507
+ const actualOldString = findActualString(normalized, oldString) ?? oldString;
508
+ const matches = normalized.split(actualOldString).length - 1;
509
+ if (matches === 0) {
510
+ throw new Error(`String to replace not found in file.\nString: ${oldString}`);
511
+ }
512
+ if (!replaceAll && matches > 1) {
513
+ throw new Error(
514
+ `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
515
+ );
516
+ }
517
+ const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
518
+ // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
519
+ const updated = replaceAll
520
+ ? normalized.split(actualOldString).join(actualNewString)
521
+ : normalized.replace(actualOldString, () => actualNewString);
522
+ const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
523
+ await writeFile(filePath, restored, "utf8");
524
+ const snapshot = snapshotOf(restored);
525
+ state.reads.set(key, snapshot);
526
+ const diff = generateDiffString(original, restored);
527
+ const text = replaceAll
528
+ ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
529
+ : `The file ${filePath} has been updated successfully.`;
530
+ throwIfAborted(signal);
531
+ const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
532
+ return [
533
+ text,
534
+ {
535
+ diff: diff.diff,
536
+ patch: generateUnifiedPatch(filePath, original, restored),
537
+ firstChangedLine: diff.firstChangedLine,
538
+ reads: { [key]: snapshot },
539
+ },
540
+ diagnosticText,
541
+ ];
542
+ });
473
543
 
474
- throwIfAborted(signal);
475
- // 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
476
- const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
477
544
  const text = diagnosticText
478
545
  ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
479
546
  : message;
480
- return { content: [{ type: "text" as const, text }], details };
547
+ return {
548
+ content: [{ type: "text" as const, text }],
549
+ details: {
550
+ ...details,
551
+ pendant: buildEditPendant({
552
+ filePath,
553
+ diff: details.diff ?? "",
554
+ diagnosticText,
555
+ }),
556
+ },
557
+ };
481
558
  },
482
559
  });
483
560
 
@@ -508,55 +585,64 @@ export function registerFileTools(
508
585
  absolutePath: filePath,
509
586
  change: { oldText: "", newText: params.content },
510
587
  });
511
- const [message, details] = await withFileMutationQueue<[string, FileToolDetails]>(
512
- filePath,
513
- async () => {
514
- let original: string | undefined;
515
- let key: string | undefined;
516
- try {
517
- const value = await stat(filePath);
518
- if (value.isFile()) {
519
- const content = await readFile(filePath);
520
- key = await readStateKey(filePath);
521
- requireCurrentRead(state, key, filePath, content);
522
- original = content.toString("utf8");
523
- }
524
- } catch (error) {
525
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
526
- throw error;
527
- }
588
+ const [message, details, diagnosticText] = await withFileMutationQueue<
589
+ [string, FileToolDetails, string]
590
+ >(filePath, async () => {
591
+ let original: string | undefined;
592
+ let key: string | undefined;
593
+ try {
594
+ const value = await stat(filePath);
595
+ if (value.isFile()) {
596
+ const content = await readFile(filePath);
597
+ key = await readStateKey(filePath);
598
+ requireCurrentRead(state, key, filePath, content);
599
+ original = content.toString("utf8");
528
600
  }
529
- throwIfAborted(signal);
530
- await mkdir(dirname(filePath), { recursive: true });
531
- await writeFile(filePath, params.content, "utf8");
532
- const snapshot = snapshotOf(params.content);
533
- // 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
534
- const resolvedKey = key ?? (await readStateKey(filePath));
535
- state.reads.set(resolvedKey, snapshot);
536
- const diff = generateDiffString(original ?? "", params.content);
537
- const text =
538
- original === undefined
539
- ? `File created successfully at: ${filePath}`
540
- : `The file ${filePath} has been updated successfully.`;
541
- return [
542
- text,
543
- {
544
- diff: diff.diff,
545
- patch: generateUnifiedPatch(filePath, original ?? "", params.content),
546
- firstChangedLine: diff.firstChangedLine,
547
- reads: { [resolvedKey]: snapshot },
548
- },
549
- ];
550
- },
551
- );
601
+ } catch (error) {
602
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
603
+ throw error;
604
+ }
605
+ }
606
+ throwIfAborted(signal);
607
+ await mkdir(dirname(filePath), { recursive: true });
608
+ await writeFile(filePath, params.content, "utf8");
609
+ const snapshot = snapshotOf(params.content);
610
+ // 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
611
+ const resolvedKey = key ?? (await readStateKey(filePath));
612
+ state.reads.set(resolvedKey, snapshot);
613
+ const diff = generateDiffString(original ?? "", params.content);
614
+ const text =
615
+ original === undefined
616
+ ? `File created successfully at: ${filePath}`
617
+ : `The file ${filePath} has been updated successfully.`;
618
+ throwIfAborted(signal);
619
+ const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
620
+ return [
621
+ text,
622
+ {
623
+ diff: diff.diff,
624
+ patch: generateUnifiedPatch(filePath, original ?? "", params.content),
625
+ firstChangedLine: diff.firstChangedLine,
626
+ reads: { [resolvedKey]: snapshot },
627
+ },
628
+ diagnosticText,
629
+ ];
630
+ });
552
631
 
553
- throwIfAborted(signal);
554
- // 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
555
- const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
556
632
  const text = diagnosticText
557
633
  ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
558
634
  : message;
559
- return { content: [{ type: "text" as const, text }], details };
635
+ return {
636
+ content: [{ type: "text" as const, text }],
637
+ details: {
638
+ ...details,
639
+ pendant: buildEditPendant({
640
+ filePath,
641
+ diff: details.diff ?? "",
642
+ diagnosticText,
643
+ }),
644
+ },
645
+ };
560
646
  },
561
647
  });
562
648
  }
@@ -506,79 +506,84 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
506
506
  if (signal?.aborted) throw new Error("Operation aborted");
507
507
  };
508
508
 
509
- const [message, details] = await withFileMutationQueue(absolutePath, async () => {
510
- throwIfAborted();
509
+ const [message, details, diagnosticText] = await withFileMutationQueue(
510
+ absolutePath,
511
+ async () => {
512
+ throwIfAborted();
511
513
 
512
- // opencode: 前置校验,先于空 oldString 分支
513
- if (oldString === newString) {
514
- throw new Error("No changes to apply: oldString and newString are identical.");
515
- }
514
+ // opencode: 前置校验,先于空 oldString 分支
515
+ if (oldString === newString) {
516
+ throw new Error("No changes to apply: oldString and newString are identical.");
517
+ }
518
+
519
+ // opencode: 空 oldString + 文件不存在 → 创建新文件;文件存在 → 报错
520
+ if (oldString === "") {
521
+ let exists = true;
522
+ try {
523
+ await access(absolutePath, constants.F_OK);
524
+ } catch {
525
+ exists = false;
526
+ }
527
+ throwIfAborted();
528
+ if (exists) {
529
+ throw new Error(
530
+ "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
531
+ );
532
+ }
533
+ // opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
534
+ await mkdir(dirname(absolutePath), { recursive: true });
535
+ throwIfAborted();
536
+ await writeFile(absolutePath, newString, "utf8");
537
+ throwIfAborted();
538
+ const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
539
+ return [
540
+ "Edit applied successfully.",
541
+ { diff: "", patch: "", firstChangedLine: 0 },
542
+ diagnosticText,
543
+ ] as const;
544
+ }
516
545
 
517
- // opencode: 空 oldString + 文件不存在 → 创建新文件;文件存在 → 报错
518
- if (oldString === "") {
519
- let exists = true;
520
546
  try {
521
- await access(absolutePath, constants.F_OK);
522
- } catch {
523
- exists = false;
547
+ await access(absolutePath, constants.R_OK | constants.W_OK);
548
+ } catch (error: unknown) {
549
+ throwIfAborted();
550
+ const msg =
551
+ error instanceof Error && "code" in error && typeof error.code === "string"
552
+ ? `Error code: ${error.code}`
553
+ : String(error);
554
+ throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
524
555
  }
525
556
  throwIfAborted();
526
- if (exists) {
527
- throw new Error(
528
- "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
529
- );
530
- }
531
- // opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
532
- await mkdir(dirname(absolutePath), { recursive: true });
557
+
558
+ const buffer = await readFile(absolutePath);
559
+ const rawContent = buffer.toString("utf8");
533
560
  throwIfAborted();
534
- await writeFile(absolutePath, newString, "utf8");
561
+
562
+ // Strip BOM then normalize line endings to LF.
563
+ // The opencode replacers split on \n and expect only LF.
564
+ const { bom, text: content } = stripBom(rawContent);
565
+ const originalEnding = detectLineEnding(content);
566
+ const normalizedContent = normalizeToLF(content);
567
+
568
+ const newContent = replace(normalizedContent, oldString, newString, replaceAll);
535
569
  throwIfAborted();
536
- return [
537
- "Edit applied successfully.",
538
- { diff: "", patch: "", firstChangedLine: 0 },
539
- ] as const;
540
- }
541
570
 
542
- try {
543
- await access(absolutePath, constants.R_OK | constants.W_OK);
544
- } catch (error: unknown) {
571
+ const finalContent = bom + restoreLineEndings(newContent, originalEnding);
572
+ await writeFile(absolutePath, finalContent, "utf8");
545
573
  throwIfAborted();
546
- const msg =
547
- error instanceof Error && "code" in error && typeof error.code === "string"
548
- ? `Error code: ${error.code}`
549
- : String(error);
550
- throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
551
- }
552
- throwIfAborted();
553
574
 
554
- const buffer = await readFile(absolutePath);
555
- const rawContent = buffer.toString("utf8");
556
- throwIfAborted();
557
-
558
- // Strip BOM then normalize line endings to LF.
559
- // The opencode replacers split on \n and expect only LF.
560
- const { bom, text: content } = stripBom(rawContent);
561
- const originalEnding = detectLineEnding(content);
562
- const normalizedContent = normalizeToLF(content);
563
-
564
- const newContent = replace(normalizedContent, oldString, newString, replaceAll);
565
- throwIfAborted();
566
-
567
- const finalContent = bom + restoreLineEndings(newContent, originalEnding);
568
- await writeFile(absolutePath, finalContent, "utf8");
569
- throwIfAborted();
570
-
571
- const diffResult = generateDiffString(normalizedContent, newContent);
572
- const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
573
- return [
574
- "Edit applied successfully.",
575
- { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
576
- ] as const;
577
- });
575
+ const diffResult = generateDiffString(normalizedContent, newContent);
576
+ const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
577
+ throwIfAborted();
578
+ const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
579
+ return [
580
+ "Edit applied successfully.",
581
+ { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
582
+ diagnosticText,
583
+ ] as const;
584
+ },
585
+ );
578
586
 
579
- throwIfAborted();
580
- // opencode: 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
581
- const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
582
587
  const text = diagnosticText
583
588
  ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
584
589
  : message;
@@ -639,38 +644,39 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
639
644
  if (signal?.aborted) throw new Error("Operation aborted");
640
645
  };
641
646
 
642
- const [message, details] = await withFileMutationQueue(absolutePath, async () => {
643
- throwIfAborted();
647
+ const [message, details, diagnosticText] = await withFileMutationQueue(
648
+ absolutePath,
649
+ async () => {
650
+ throwIfAborted();
644
651
 
645
- // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
646
- // 否则用新内容自带的 BOM
647
- let existing: Buffer | undefined;
648
- try {
649
- const fh = await open(absolutePath, "r");
652
+ // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
653
+ // 否则用新内容自带的 BOM
654
+ let existing: Buffer | undefined;
650
655
  try {
651
- existing = Buffer.alloc(3);
652
- const { bytesRead } = await fh.read(existing, 0, 3, 0);
653
- if (bytesRead < 3) existing = undefined;
654
- } finally {
655
- await fh.close();
656
+ const fh = await open(absolutePath, "r");
657
+ try {
658
+ existing = Buffer.alloc(3);
659
+ const { bytesRead } = await fh.read(existing, 0, 3, 0);
660
+ if (bytesRead < 3) existing = undefined;
661
+ } finally {
662
+ await fh.close();
663
+ }
664
+ } catch {
665
+ // 文件不存在:无旧 BOM
656
666
  }
657
- } catch {
658
- // 文件不存在:无旧 BOM
659
- }
660
- throwIfAborted();
661
- const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
667
+ throwIfAborted();
668
+ const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
662
669
 
663
- await mkdir(dir, { recursive: true });
664
- throwIfAborted();
665
- await writeFile(absolutePath, desiredBom + nextText, "utf8");
666
- throwIfAborted();
670
+ await mkdir(dir, { recursive: true });
671
+ throwIfAborted();
672
+ await writeFile(absolutePath, desiredBom + nextText, "utf8");
673
+ throwIfAborted();
674
+ const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
667
675
 
668
- return ["Wrote file successfully.", undefined] as const;
669
- });
676
+ return ["Wrote file successfully.", undefined, diagnosticText] as const;
677
+ },
678
+ );
670
679
 
671
- throwIfAborted();
672
- // opencode: 写后等待文档诊断,ERROR 级错误追加到输出让模型可见
673
- const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
674
680
  const text = diagnosticText
675
681
  ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
676
682
  : message;