@trim21/personal-pi-extensions 0.1.641 → 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 代码块展示将要发生的变更预览(定位由各写工具注入自己的匹配实现,能定位时显示带行号的真实 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.641",
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,31 +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
- // 审批预览必须与落盘同语义:这里注入本工具自己的精确匹配(空 old_string
352
- // 是创建/填充文件,按整文件写入预览)
353
- change:
354
- params.old_string === ""
355
- ? { kind: "write", newText: params.new_string }
356
- : {
357
- kind: "edit",
358
- oldText: params.old_string,
359
- newText: params.new_string,
360
- apply: (content) => ({
361
- contentOld: content,
362
- contentNew: applyExactEdit(
363
- content,
364
- params.old_string,
365
- params.new_string,
366
- params.replace_all ?? false,
367
- ),
368
- }),
369
- },
370
- policy,
371
- signal,
372
- });
373
348
  const [message, details, diagnostics] = await withFileMutationQueue<
374
349
  [string, FileToolDetails, DiagnosticReport]
375
350
  >(filePath, async () => {
@@ -381,11 +356,12 @@ export function registerFileTools(
381
356
  // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
382
357
  if (oldString === "") {
383
358
  let exists = true;
359
+ let contentOld = "";
384
360
  try {
385
361
  const value = await stat(filePath);
386
362
  if (value.isFile()) {
387
- const content = await readFile(filePath, "utf8");
388
- if (content.trim() !== "") {
363
+ contentOld = await readFile(filePath, "utf8");
364
+ if (contentOld.trim() !== "") {
389
365
  throw new Error("Cannot create new file - file already exists.");
390
366
  }
391
367
  }
@@ -396,6 +372,14 @@ export function registerFileTools(
396
372
  throw error;
397
373
  }
398
374
  }
375
+ // 审批在算完内容之后、产生副作用(建目录 / 写盘)之前:预览就是将要写入的内容
376
+ await guardWriteAccess(ctx, {
377
+ toolName: "Edit",
378
+ absolutePath: filePath,
379
+ mutation: { contentOld, contentNew: newString },
380
+ policy,
381
+ signal,
382
+ });
399
383
  if (!exists) {
400
384
  await mkdir(dirname(filePath), { recursive: true });
401
385
  }
@@ -458,6 +442,13 @@ export function registerFileTools(
458
442
  signal?.throwIfAborted();
459
443
  const original = content.toString("utf8");
460
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
+ });
461
452
  await writeFile(filePath, restored, "utf8");
462
453
  const snapshot = snapshotOf(restored);
463
454
  state.reads.set(key, snapshot);
@@ -532,13 +523,6 @@ export function registerFileTools(
532
523
  async execute(_id, params, signal, _onUpdate, ctx) {
533
524
  signal?.throwIfAborted();
534
525
  const filePath = resolveToolFilePath(params.file_path, ctx.cwd);
535
- await guardWriteAccess(ctx, {
536
- toolName: "Write",
537
- absolutePath: filePath,
538
- change: { kind: "write", newText: params.content },
539
- policy,
540
- signal,
541
- });
542
526
  const [message, details, diagnostics] = await withFileMutationQueue<
543
527
  [string, FileToolDetails, DiagnosticReport]
544
528
  >(filePath, async () => {
@@ -558,6 +542,14 @@ export function registerFileTools(
558
542
  }
559
543
  }
560
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
+ });
561
553
  await mkdir(dirname(filePath), { recursive: true });
562
554
  await writeFile(filePath, params.content, "utf8");
563
555
  const snapshot = snapshotOf(params.content);
@@ -194,18 +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: {
202
- kind: "edit",
203
- oldText: fileEdit.oldText,
204
- newText: fileEdit.newText,
205
- // LSP 编辑已在内存里展开成整文件的 old/new,落盘是覆盖写:预览直接用
206
- // 这一对文本,不必再把整份旧内容交给匹配引擎去找
207
- apply: () => ({ contentOld: fileEdit.oldText, contentNew: fileEdit.newText }),
208
- },
204
+ mutation: { contentOld: fileEdit.oldText, contentNew: fileEdit.newText },
209
205
  policy: options.policy,
210
206
  signal,
211
207
  });
@@ -11,17 +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. An edit carries
16
- * the caller's own matching implementation, so the previewed diff comes from the
17
- * same semantics that will write the file.
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.
18
18
  */
19
19
 
20
- import { readFile } from "node:fs/promises";
21
20
  import { basename, isAbsolute, relative, sep } from "node:path";
22
21
 
23
22
  import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
24
23
 
24
+ import { digestIfExists, snapshotOf } from "./file-reads.js";
25
25
  import { fenceCodeBlock } from "./markdown.js";
26
26
  import type { RequestPolicy } from "./request-policy.js";
27
27
 
@@ -59,80 +59,52 @@ function wrapDiff(patch: string): string {
59
59
  }
60
60
 
61
61
  /**
62
- * 审批预览用的应用函数:把这次改动套到文件内容上,返回 diff 用的前后文本。
63
- * 必须是该工具落盘时使用的同一套匹配实现——否则对话框展示的 diff 与实际写入
64
- * 可能不一致。
62
+ * 待审批的写入:内容由调用方先读盘并算好,批准后按 `contentNew` 落盘。
63
+ *
64
+ * 两份内容都是文件原始内容(保留 BOM 与行尾),不是匹配引擎的中间产物——这样
65
+ * 预览就是磁盘前后的真实对照,批准后的指纹校验也能与磁盘字节逐字节对上。
65
66
  */
66
- export type PendingChangeApply = (fileContent: string) => {
67
+ export interface FileMutation {
68
+ /** 变更前的完整文件内容(文件不存在时为 "")。 */
67
69
  readonly contentOld: string;
70
+ /** 变更后的完整内容——即批准后写进磁盘的字节。 */
68
71
  readonly contentNew: string;
69
- };
70
-
71
- /** 整文件写入:预览是完整的增删 patch,不需要匹配实现。 */
72
- export interface FileWriteChange {
73
- readonly kind: "write";
74
- readonly newText: string;
75
72
  }
76
73
 
77
- /**
78
- * 替换:`apply` 是调用方自己的匹配实现(精确匹配、模糊匹配,或已展开好的整文件
79
- * 编辑)。匹配不上时抛错即可,写保护会退化为参数 diff。
80
- */
81
- export interface FileEditChange {
82
- readonly kind: "edit";
83
- readonly oldText: string;
84
- readonly newText: string;
85
- readonly apply: PendingChangeApply;
86
- }
87
-
88
- /** The pending file change, described by the caller from already-parsed args. */
89
- export type PendingChange = FileWriteChange | FileEditChange;
90
-
91
74
  /** 预览统一按 LF 渲染:CRLF 文件的 diff 每行都带 \r,在对话框里显示成乱码。 */
92
75
  function normalizeToLF(text: string): string {
93
76
  return text.replaceAll("\r\n", "\n");
94
77
  }
95
78
 
96
- /** 参数 diff:调用方的匹配实现报告不出结果时的兜底形式。 */
97
- function parameterDiff(oldText: string, newText: string): string {
98
- const removed = oldText.split("\n").map((line) => `-${line}`);
99
- const added = newText.split("\n").map((line) => `+${line}`);
100
- return [...removed, ...added].join("\n");
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
+ );
101
89
  }
102
90
 
91
+ /** 空内容的指纹:文件不存在与空文件在审批视角下等价。 */
92
+ const EMPTY_DIGEST = snapshotOf("").digest;
93
+
103
94
  /**
104
- * Build a `diff` code block preview of the pending change.
105
- * Returns undefined when the diff cannot be computed.
95
+ * 批准后重新取一次磁盘指纹:用户停留在对话框上的这段时间文件可能被外部改动,
96
+ * 此时按审批所用的旧内容算出的 `contentNew` 会覆盖别人的改动。
106
97
  */
107
- export async function buildDiffPreview(
108
- resolvedPath: string,
109
- change: PendingChange,
110
- ): Promise<string | undefined> {
111
- let fileContent = "";
112
- try {
113
- fileContent = await readFile(resolvedPath, "utf8");
114
- } catch {
115
- // Unreadable or missing file: treat as empty so writes show as full additions.
116
- }
117
-
118
- if (change.kind === "write") {
119
- // Whole-file write: show the full addition/replacement patch.
120
- return wrapDiff(generateUnifiedPatch(basename(resolvedPath), fileContent, change.newText, 2));
98
+ async function requireApprovedContentStillCurrent(opts: WriteGuardOptions): Promise<void> {
99
+ const { mutation } = opts;
100
+ if (mutation === undefined) {
101
+ return;
121
102
  }
122
-
123
- // 定位由调用方的匹配实现负责(与其落盘同语义);匹配不上时退回参数 diff。
124
- try {
125
- const { contentOld, contentNew } = change.apply(fileContent);
126
- return wrapDiff(
127
- generateUnifiedPatch(
128
- basename(resolvedPath),
129
- normalizeToLF(contentOld),
130
- normalizeToLF(contentNew),
131
- 2,
132
- ),
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.",
133
107
  );
134
- } catch {
135
- return wrapDiff(parameterDiff(change.oldText, change.newText));
136
108
  }
137
109
  }
138
110
 
@@ -159,9 +131,10 @@ export interface WriteGuardOptions {
159
131
  /** The resolved absolute target path (caller has already parsed its args). */
160
132
  absolutePath: string;
161
133
  /**
162
- * 待审批的变更内容;缺省时审批对话框不展示 diff 预览,仅按路径审批。
134
+ * 待审批的写入(变更前后的完整内容);缺省时审批对话框不展示 diff 预览,
135
+ * 仅按路径审批(流式落盘的工具用不上它)。
163
136
  */
164
- change?: PendingChange;
137
+ mutation?: FileMutation;
165
138
  /** 调用方所在扩展入口持有的非沙盒请求策略(跨入口一致时绑同一个 pi.events)。 */
166
139
  policy: RequestPolicy;
167
140
  /** 工具调用的中止信号:透传给审批对话框,工具被取消时对话框一起关掉。 */
@@ -204,7 +177,8 @@ export async function guardWriteAccess(
204
177
  }
205
178
 
206
179
  for (;;) {
207
- const diffPreview = opts.change ? await buildDiffPreview(absolutePath, opts.change) : undefined;
180
+ const diffPreview =
181
+ opts.mutation === undefined ? undefined : renderMutationPreview(absolutePath, opts.mutation);
208
182
  const title =
209
183
  `Model requests write access outside workspace:\n\n` +
210
184
  ` Tool: ${opts.toolName}\n` +
@@ -220,6 +194,7 @@ export async function guardWriteAccess(
220
194
  throw new Error(`user deny ${opts.toolName}: cancelled`);
221
195
  }
222
196
  if (choice === "Approve once") {
197
+ await requireApprovedContentStillCurrent(opts);
223
198
  return;
224
199
  }
225
200
  if (choice === "Block") {
@@ -632,27 +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
- // 审批预览必须与落盘同语义:注入本工具自己的模糊匹配引擎(空 oldString
639
- // 是创建新文件,按整文件写入预览)
640
- change:
641
- oldString === ""
642
- ? { kind: "write", newText: newString }
643
- : {
644
- kind: "edit",
645
- oldText: oldString,
646
- newText: newString,
647
- apply: (content) => {
648
- const applied = applyEdit(content, oldString, newString, replaceAll);
649
- return { contentOld: applied.contentOld, contentNew: applied.contentNew };
650
- },
651
- },
652
- policy,
653
- signal,
654
- });
655
-
656
635
  const [message, details, diagnostics] = await withFileMutationQueue(
657
636
  absolutePath,
658
637
  async () => {
@@ -677,6 +656,14 @@ function registerEditTool(
677
656
  );
678
657
  }
679
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
+ });
680
667
  await mkdir(dirname(absolutePath), { recursive: true });
681
668
  signal?.throwIfAborted();
682
669
  await writeFile(absolutePath, newString, "utf8");
@@ -720,6 +707,13 @@ function registerEditTool(
720
707
 
721
708
  const applied = applyEdit(rawContent, oldString, newString, replaceAll);
722
709
  signal?.throwIfAborted();
710
+ await guardWriteAccess(ctx, {
711
+ toolName: "edit",
712
+ absolutePath,
713
+ mutation: { contentOld: rawContent, contentNew: applied.finalContent },
714
+ policy,
715
+ signal,
716
+ });
723
717
  await writeFile(absolutePath, applied.finalContent, "utf8");
724
718
  const snapshot = snapshotOf(applied.finalContent);
725
719
  state.reads.set(key, snapshot);
@@ -810,13 +804,6 @@ function registerWriteTool(
810
804
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
811
805
  const { filePath: rawPath, content } = params;
812
806
  const absolutePath = resolvePath(ctx.cwd, rawPath);
813
- await guardWriteAccess(ctx, {
814
- toolName: "write",
815
- absolutePath,
816
- change: { kind: "write", newText: content },
817
- policy,
818
- signal,
819
- });
820
807
  const dir = dirname(absolutePath);
821
808
 
822
809
  const [message, details, diagnostics] = await withFileMutationQueue(
@@ -832,29 +819,31 @@ function registerWriteTool(
832
819
  }
833
820
 
834
821
  // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
835
- // 否则用新内容自带的 BOM
822
+ // 否则用新内容自带的 BOM。旧内容整读一次,既是 BOM 的来源,也是审批预览
823
+ // 的「变更前内容」。
836
824
  let existing: Buffer | undefined;
837
825
  try {
838
- const fh = await open(absolutePath, "r");
839
- try {
840
- existing = Buffer.alloc(3);
841
- const { bytesRead } = await fh.read(existing, 0, 3, 0);
842
- if (bytesRead < 3) {
843
- existing = undefined;
844
- }
845
- } finally {
846
- await fh.close();
847
- }
826
+ existing = await readFile(absolutePath);
848
827
  } catch {
849
- // 文件不存在:无旧 BOM
828
+ // 文件不存在:无旧文件
850
829
  }
851
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
+ });
852
841
 
853
842
  await mkdir(dir, { recursive: true });
854
843
  signal?.throwIfAborted();
855
- await writeFile(absolutePath, desiredBom + nextText, "utf8");
844
+ await writeFile(absolutePath, finalContent, "utf8");
856
845
  // 写后记账(key 在写入前已算过):紧接着的 edit 不该再要求重新 read
857
- const snapshot = snapshotOf(desiredBom + nextText);
846
+ const snapshot = snapshotOf(finalContent);
858
847
  state.reads.set(key, snapshot);
859
848
  const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
860
849
  signal,