@trim21/personal-pi-extensions 0.1.639 → 0.1.642

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/README.md CHANGED
@@ -137,7 +137,7 @@ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/openco
137
137
  写保护直接内置在各写工具(opencode 风格 `write`/`edit`、Claude Code 风格 `Write`/`Edit`)内部,通过 `src/lib/write-guard.ts` 的 `guardWriteAccess` 实现。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
138
138
 
139
139
  - workspace 内或 `/tmp` 下的路径自动放行
140
- - 外部路径需通过确认对话框由用户审批(**Approve once** / **Block** / **Block with reason**),对话框内以 ```diff 代码块展示将要发生的变更预览(与 opencode-edit 共享匹配引擎,能定位时显示带行号的真实 patch,否则退化为参数 diff)
140
+ - 外部路径需通过确认对话框由用户审批(**Approve once** / **Block** / **Block with reason**),对话框内以 ```diff 代码块展示将要发生的变更;预览由「将要写入的内容」直接算出,批准后写的就是这份内容(批准前若文件被外部改动则拒绝写入)
141
141
  - headless(无 UI)会话直接拒绝外部写入;Windows 上不提供审批路径,工作区外写入一律拒绝
142
142
  - `/bwrap-deny-request` 生效期间外部写入按无理由拒绝处理(`user deny <tool>: blocked`)
143
143
  - 无需配置,随各写工具自动生效
@@ -253,7 +253,7 @@ opencode 风格工具集,随 `src/opencode/index.ts` 一次加载:`read` / `
253
253
 
254
254
  ### edit
255
255
 
256
- `edit` 使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数复制自 opencode,匹配引擎位于 `src/opencode/edit-engine.ts`,与写保护审批弹窗(`src/lib/write-guard.ts`)的 diff 预览共享。唯一的有意差异是去掉了原版的转义规范化(EscapeNormalizedReplacer):它会把源码里合法的 `\n`、`\t` 等转义序列当成模型多转义的产物做启发式反转义,可能改坏内容。
256
+ `edit` 使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数复制自 opencode,匹配引擎位于 `src/opencode/edit-engine.ts`。唯一的有意差异是去掉了原版的转义规范化(EscapeNormalizedReplacer):它会把源码里合法的 `\n`、`\t` 等转义序列当成模型多转义的产物做启发式反转义,可能改坏内容。
257
257
 
258
258
  支持的匹配策略:
259
259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.639",
3
+ "version": "0.1.642",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -345,17 +345,6 @@ export function registerFileTools(
345
345
  async execute(_id, params, signal, _onUpdate, ctx) {
346
346
  signal?.throwIfAborted();
347
347
  const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
348
- await guardWriteAccess(ctx, {
349
- toolName: "Edit",
350
- absolutePath: filePath,
351
- change: {
352
- oldText: params.old_string,
353
- newText: params.new_string,
354
- replaceAll: params.replace_all,
355
- },
356
- policy,
357
- signal,
358
- });
359
348
  const [message, details, diagnostics] = await withFileMutationQueue<
360
349
  [string, FileToolDetails, DiagnosticReport]
361
350
  >(filePath, async () => {
@@ -367,11 +356,12 @@ export function registerFileTools(
367
356
  // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
368
357
  if (oldString === "") {
369
358
  let exists = true;
359
+ let contentOld = "";
370
360
  try {
371
361
  const value = await stat(filePath);
372
362
  if (value.isFile()) {
373
- const content = await readFile(filePath, "utf8");
374
- if (content.trim() !== "") {
363
+ contentOld = await readFile(filePath, "utf8");
364
+ if (contentOld.trim() !== "") {
375
365
  throw new Error("Cannot create new file - file already exists.");
376
366
  }
377
367
  }
@@ -382,6 +372,14 @@ export function registerFileTools(
382
372
  throw error;
383
373
  }
384
374
  }
375
+ // 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
376
+ await guardWriteAccess(ctx, {
377
+ toolName: "Edit",
378
+ absolutePath: filePath,
379
+ mutation: { contentOld, contentNew: newString },
380
+ policy,
381
+ signal,
382
+ });
385
383
  if (!exists) {
386
384
  await mkdir(dirname(filePath), { recursive: true });
387
385
  }
@@ -444,6 +442,13 @@ export function registerFileTools(
444
442
  signal?.throwIfAborted();
445
443
  const original = content.toString("utf8");
446
444
  const restored = applyExactEdit(original, oldString, newString, replaceAll);
445
+ await guardWriteAccess(ctx, {
446
+ toolName: "Edit",
447
+ absolutePath: filePath,
448
+ mutation: { contentOld: original, contentNew: restored },
449
+ policy,
450
+ signal,
451
+ });
447
452
  await writeFile(filePath, restored, "utf8");
448
453
  const snapshot = snapshotOf(restored);
449
454
  state.reads.set(key, snapshot);
@@ -518,13 +523,6 @@ export function registerFileTools(
518
523
  async execute(_id, params, signal, _onUpdate, ctx) {
519
524
  signal?.throwIfAborted();
520
525
  const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
521
- await guardWriteAccess(ctx, {
522
- toolName: "Write",
523
- absolutePath: filePath,
524
- change: { oldText: "", newText: params.content },
525
- policy,
526
- signal,
527
- });
528
526
  const [message, details, diagnostics] = await withFileMutationQueue<
529
527
  [string, FileToolDetails, DiagnosticReport]
530
528
  >(filePath, async () => {
@@ -544,6 +542,14 @@ export function registerFileTools(
544
542
  }
545
543
  }
546
544
  signal?.throwIfAborted();
545
+ // 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
546
+ await guardWriteAccess(ctx, {
547
+ toolName: "Write",
548
+ absolutePath: filePath,
549
+ mutation: { contentOld: original ?? "", contentNew: params.content },
550
+ policy,
551
+ signal,
552
+ });
547
553
  await mkdir(dirname(filePath), { recursive: true });
548
554
  await writeFile(filePath, params.content, "utf8");
549
555
  const snapshot = snapshotOf(params.content);
@@ -194,11 +194,14 @@ export function registerLspRenameTool(
194
194
  if (applied.length === 0) {
195
195
  throw new Error("LSP rename returned no edits");
196
196
  }
197
+ // 先逐个文件审批、再逐个写盘:用户在第二个文件上拒绝时不该留下半个 rename。
198
+ // 变更前后内容已由 expandWorkspaceEdit 在内存里算好,审批框展示的就是要写入的
199
+ // 内容;对话框停留期间的外部改动由写保护的指纹校验兜住。
197
200
  for (const fileEdit of applied) {
198
201
  await guardWriteAccess(ctx, {
199
202
  toolName: "lsp-rename",
200
203
  absolutePath: fileEdit.path,
201
- change: { oldText: fileEdit.oldText, newText: fileEdit.newText },
204
+ mutation: { contentOld: fileEdit.oldText, contentNew: fileEdit.newText },
202
205
  policy: options.policy,
203
206
  signal,
204
207
  });
@@ -11,16 +11,17 @@
11
11
  * - `/bwrap-deny-request` (non-sandbox request policy) refuses outside writes
12
12
  * outright, with the same error as the user picking "Block".
13
13
  *
14
- * Callers have already parsed their tool arguments, so the guard only takes the
15
- * resolved pieces: the raw target path and the pending change (oldText/newText).
14
+ * Callers have already parsed their tool arguments and computed the result, so the
15
+ * guard takes the resolved pieces: the raw target path and the mutation (the file's
16
+ * content before and after). The preview is rendered from those two strings, so the
17
+ * approved diff is the change that gets written.
16
18
  */
17
19
 
18
- import { readFile } from "node:fs/promises";
19
20
  import { basename, isAbsolute, relative, sep } from "node:path";
20
21
 
21
22
  import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
22
23
 
23
- import { applyEdit, normalizeToLF } from "../opencode/edit-engine.js";
24
+ import { digestIfExists, snapshotOf } from "./file-reads.js";
24
25
  import { fenceCodeBlock } from "./markdown.js";
25
26
  import type { RequestPolicy } from "./request-policy.js";
26
27
 
@@ -57,53 +58,53 @@ function wrapDiff(patch: string): string {
57
58
  return fenceCodeBlock(body, "diff");
58
59
  }
59
60
 
60
- /** The pending file change, described by the caller from already-parsed args. */
61
- export interface PendingChange {
62
- /** Text to replace; empty for whole-file writes. */
63
- oldText: string;
64
- /** Replacement text. */
65
- newText: string;
66
- /** Replace all occurrences of oldText (edits only). */
67
- replaceAll?: boolean;
61
+ /**
62
+ * 待审批的写入:内容由调用方先读盘并算好,批准后按 `contentNew` 落盘。
63
+ *
64
+ * 两份内容都是文件原始内容(保留 BOM 与行尾),不是匹配引擎的中间产物——这样
65
+ * 预览就是磁盘前后的真实对照,批准后的指纹校验也能与磁盘字节逐字节对上。
66
+ */
67
+ export interface FileMutation {
68
+ /** 变更前的完整文件内容(文件不存在时为 "")。 */
69
+ readonly contentOld: string;
70
+ /** 变更后的完整内容——即批准后写进磁盘的字节。 */
71
+ readonly contentNew: string;
72
+ }
73
+
74
+ /** 预览统一按 LF 渲染:CRLF 文件的 diff 每行都带 \r,在对话框里显示成乱码。 */
75
+ function normalizeToLF(text: string): string {
76
+ return text.replaceAll("\r\n", "\n");
77
+ }
78
+
79
+ /** 待审批写入的 diff 代码块:由将落盘的内容直接算出,不做第二次匹配。 */
80
+ export function renderMutationPreview(resolvedPath: string, mutation: FileMutation): string {
81
+ return wrapDiff(
82
+ generateUnifiedPatch(
83
+ basename(resolvedPath),
84
+ normalizeToLF(mutation.contentOld),
85
+ normalizeToLF(mutation.contentNew),
86
+ 2,
87
+ ),
88
+ );
68
89
  }
69
90
 
91
+ /** 空内容的指纹:文件不存在与空文件在审批视角下等价。 */
92
+ const EMPTY_DIGEST = snapshotOf("").digest;
93
+
70
94
  /**
71
- * Build a `diff` code block preview of the pending change.
72
- * Returns undefined when the diff cannot be computed.
95
+ * 批准后重新取一次磁盘指纹:用户停留在对话框上的这段时间文件可能被外部改动,
96
+ * 此时按审批所用的旧内容算出的 `contentNew` 会覆盖别人的改动。
73
97
  */
74
- export async function buildDiffPreview(
75
- resolvedPath: string,
76
- change: PendingChange,
77
- ): Promise<string | undefined> {
78
- let oldContent = "";
79
- try {
80
- oldContent = await readFile(resolvedPath, "utf8");
81
- } catch {
82
- // Unreadable or missing file: treat as empty so writes show as full additions.
83
- }
84
-
85
- if (change.oldText === "") {
86
- // Whole-file write: show the full addition/replacement patch.
87
- return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, change.newText, 2));
98
+ async function requireApprovedContentStillCurrent(opts: WriteGuardOptions): Promise<void> {
99
+ const { mutation } = opts;
100
+ if (mutation === undefined) {
101
+ return;
88
102
  }
89
-
90
- // Edit: reuse the real matching engine to locate oldText, giving a
91
- // line-numbered patch when it matches. Fall back to a parameter diff when the
92
- // edit cannot be applied (oldText not found, ambiguous, or no file).
93
- try {
94
- const applied = applyEdit(oldContent, change.oldText, change.newText, change.replaceAll);
95
- return wrapDiff(
96
- generateUnifiedPatch(
97
- basename(resolvedPath),
98
- normalizeToLF(applied.contentOld),
99
- normalizeToLF(applied.contentNew),
100
- 2,
101
- ),
103
+ const current = (await digestIfExists(opts.absolutePath)) ?? EMPTY_DIGEST;
104
+ if (current !== snapshotOf(mutation.contentOld).digest) {
105
+ throw new Error(
106
+ "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
102
107
  );
103
- } catch {
104
- const removed = change.oldText.split("\n").map((line) => `-${line}`);
105
- const added = change.newText.split("\n").map((line) => `+${line}`);
106
- return wrapDiff([...removed, ...added].join("\n"));
107
108
  }
108
109
  }
109
110
 
@@ -130,9 +131,10 @@ export interface WriteGuardOptions {
130
131
  /** The resolved absolute target path (caller has already parsed its args). */
131
132
  absolutePath: string;
132
133
  /**
133
- * 待审批的变更内容;缺省时审批对话框不展示 diff 预览,仅按路径审批。
134
+ * 待审批的写入(变更前后的完整内容);缺省时审批对话框不展示 diff 预览,
135
+ * 仅按路径审批(流式落盘的工具用不上它)。
134
136
  */
135
- change?: PendingChange;
137
+ mutation?: FileMutation;
136
138
  /** 调用方所在扩展入口持有的非沙盒请求策略(跨入口一致时绑同一个 pi.events)。 */
137
139
  policy: RequestPolicy;
138
140
  /** 工具调用的中止信号:透传给审批对话框,工具被取消时对话框一起关掉。 */
@@ -175,7 +177,8 @@ export async function guardWriteAccess(
175
177
  }
176
178
 
177
179
  for (;;) {
178
- const diffPreview = opts.change ? await buildDiffPreview(absolutePath, opts.change) : undefined;
180
+ const diffPreview =
181
+ opts.mutation === undefined ? undefined : renderMutationPreview(absolutePath, opts.mutation);
179
182
  const title =
180
183
  `Model requests write access outside workspace:\n\n` +
181
184
  ` Tool: ${opts.toolName}\n` +
@@ -191,6 +194,7 @@ export async function guardWriteAccess(
191
194
  throw new Error(`user deny ${opts.toolName}: cancelled`);
192
195
  }
193
196
  if (choice === "Approve once") {
197
+ await requireApprovedContentStillCurrent(opts);
194
198
  return;
195
199
  }
196
200
  if (choice === "Block") {
@@ -632,14 +632,6 @@ function registerEditTool(
632
632
 
633
633
  const absolutePath = isAbsolute(filePath) ? filePath : resolvePath(ctx.cwd, filePath);
634
634
 
635
- await guardWriteAccess(ctx, {
636
- toolName: "edit",
637
- absolutePath,
638
- change: { oldText: oldString, newText: newString, replaceAll },
639
- policy,
640
- signal,
641
- });
642
-
643
635
  const [message, details, diagnostics] = await withFileMutationQueue(
644
636
  absolutePath,
645
637
  async () => {
@@ -664,6 +656,14 @@ function registerEditTool(
664
656
  );
665
657
  }
666
658
  // opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
659
+ // 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
660
+ await guardWriteAccess(ctx, {
661
+ toolName: "edit",
662
+ absolutePath,
663
+ mutation: { contentOld: "", contentNew: newString },
664
+ policy,
665
+ signal,
666
+ });
667
667
  await mkdir(dirname(absolutePath), { recursive: true });
668
668
  signal?.throwIfAborted();
669
669
  await writeFile(absolutePath, newString, "utf8");
@@ -707,6 +707,13 @@ function registerEditTool(
707
707
 
708
708
  const applied = applyEdit(rawContent, oldString, newString, replaceAll);
709
709
  signal?.throwIfAborted();
710
+ await guardWriteAccess(ctx, {
711
+ toolName: "edit",
712
+ absolutePath,
713
+ mutation: { contentOld: rawContent, contentNew: applied.finalContent },
714
+ policy,
715
+ signal,
716
+ });
710
717
  await writeFile(absolutePath, applied.finalContent, "utf8");
711
718
  const snapshot = snapshotOf(applied.finalContent);
712
719
  state.reads.set(key, snapshot);
@@ -797,13 +804,6 @@ function registerWriteTool(
797
804
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
798
805
  const { filePath: rawPath, content } = params;
799
806
  const absolutePath = resolvePath(ctx.cwd, rawPath);
800
- await guardWriteAccess(ctx, {
801
- toolName: "write",
802
- absolutePath,
803
- change: { oldText: "", newText: content },
804
- policy,
805
- signal,
806
- });
807
807
  const dir = dirname(absolutePath);
808
808
 
809
809
  const [message, details, diagnostics] = await withFileMutationQueue(
@@ -819,29 +819,31 @@ function registerWriteTool(
819
819
  }
820
820
 
821
821
  // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
822
- // 否则用新内容自带的 BOM
822
+ // 否则用新内容自带的 BOM。旧内容整读一次,既是 BOM 的来源,也是审批预览
823
+ // 的「变更前内容」。
823
824
  let existing: Buffer | undefined;
824
825
  try {
825
- const fh = await open(absolutePath, "r");
826
- try {
827
- existing = Buffer.alloc(3);
828
- const { bytesRead } = await fh.read(existing, 0, 3, 0);
829
- if (bytesRead < 3) {
830
- existing = undefined;
831
- }
832
- } finally {
833
- await fh.close();
834
- }
826
+ existing = await readFile(absolutePath);
835
827
  } catch {
836
- // 文件不存在:无旧 BOM
828
+ // 文件不存在:无旧文件
837
829
  }
838
830
  const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
831
+ const finalContent = desiredBom + nextText;
832
+
833
+ // 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
834
+ await guardWriteAccess(ctx, {
835
+ toolName: "write",
836
+ absolutePath,
837
+ mutation: { contentOld: existing?.toString("utf8") ?? "", contentNew: finalContent },
838
+ policy,
839
+ signal,
840
+ });
839
841
 
840
842
  await mkdir(dir, { recursive: true });
841
843
  signal?.throwIfAborted();
842
- await writeFile(absolutePath, desiredBom + nextText, "utf8");
844
+ await writeFile(absolutePath, finalContent, "utf8");
843
845
  // 写后记账(key 在写入前已算过):紧接着的 edit 不该再要求重新 read
844
- const snapshot = snapshotOf(desiredBom + nextText);
846
+ const snapshot = snapshotOf(finalContent);
845
847
  state.reads.set(key, snapshot);
846
848
  const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
847
849
  signal,