@trim21/personal-pi-extensions 0.1.641 → 0.1.643

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.643",
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);
@@ -282,21 +282,33 @@ export const clientDefaults = {
282
282
  maxOpenDocuments: 32,
283
283
  } as const;
284
284
 
285
- export interface CreateInput {
285
+ /**
286
+ * 客户端可调项的唯一定义:`create()` 的覆盖参数、`lsp.ts` 的 `ResolvedLspConfig`
287
+ * 与 `resolveConfig` 共用同一份字段集合,新增旋钮只需在这里声明一次即可被
288
+ * 配置解析与客户端构造同时要求。超时均为解析后的毫秒数,缺省值见 clientDefaults。
289
+ */
290
+ export interface LspClientOptions {
291
+ /** push 诊断去抖(ms)。 */
292
+ diagnosticsDebounceMs: number;
293
+ /** document 模式诊断等待上限(ms)。 */
294
+ diagnosticsDocumentWaitTimeoutMs: number;
295
+ /** 上一份诊断为空的文档的等待上限(ms);缺省见 clientDefaults。 */
296
+ diagnosticsSilentWaitTimeoutMs: number;
297
+ /** full 模式诊断等待上限(ms)。 */
298
+ diagnosticsFullWaitTimeoutMs: number;
299
+ /** 单次 pull 诊断请求超时(ms)。 */
300
+ diagnosticsRequestTimeoutMs: number;
301
+ /** 服务器 initialize 握手超时(ms)。 */
302
+ initializeTimeoutMs: number;
303
+ /** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
304
+ maxOpenDocuments: number;
305
+ }
306
+
307
+ export interface CreateInput extends Partial<LspClientOptions> {
286
308
  serverID: string;
287
309
  server: LspServerHandle;
288
310
  root: string;
289
311
  directory: string;
290
- /** 可覆盖的超时参数(缺省用 client 默认值,由全局/本地 lsp.json 配置注入)。 */
291
- diagnosticsDebounceMs?: number;
292
- diagnosticsDocumentWaitTimeoutMs?: number;
293
- /** 上一份诊断为空的文档的等待上限;缺省见 clientDefaults。 */
294
- diagnosticsSilentWaitTimeoutMs?: number;
295
- diagnosticsFullWaitTimeoutMs?: number;
296
- diagnosticsRequestTimeoutMs?: number;
297
- initializeTimeoutMs?: number;
298
- /** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
299
- maxOpenDocuments?: number;
300
312
  /** 覆盖 rename 覆盖校验的轮询节奏(缺省见 DEFAULT_RENAME_VERIFICATION_TIMING)。 */
301
313
  renameVerificationTiming?: Partial<RenameVerificationTiming>;
302
314
  }
@@ -37,6 +37,7 @@ import {
37
37
  type Diagnostic,
38
38
  type Info as LspClient,
39
39
  type InspectLocation,
40
+ type LspClientOptions,
40
41
  LspMethodNotSupportedError,
41
42
  RenameNotPossibleError,
42
43
  WATCH_KIND_CHANGE,
@@ -68,7 +69,7 @@ const watchConfigSchema = Type.Object({
68
69
  ignore: Type.Optional(Type.Array(Type.String())),
69
70
  });
70
71
 
71
- /** lsp.json 的配置项(全局与本地同构;只描述用户可写的原始形态,缺省见 configDefaults)。 */
72
+ /** lsp.json 的配置项(全局与本地同构;只描述用户可写的原始形态,缺省见 watchDefaults / clientDefaults)。 */
72
73
  const lspConfigSchema = Type.Object({
73
74
  /** 配置文件版本(当前 1);未知版本会被 typebox 严格校验拒绝。 */
74
75
  version: Type.Optional(Type.Number()),
@@ -78,7 +79,7 @@ const lspConfigSchema = Type.Object({
78
79
  enabled: Type.Optional(Type.Array(Type.String())),
79
80
  /** 从启用集中排除的服务器 id(缺省 = 无)。 */
80
81
  disabled: Type.Optional(Type.Array(Type.String())),
81
- /** 工作区文件监听配置(缺省全部字段见 configDefaults.watch)。 */
82
+ /** 工作区文件监听配置(缺省全部字段见 watchDefaults)。 */
82
83
  watch: Type.Optional(watchConfigSchema),
83
84
  /** 驻留文档上限(LRU 容量),超过时淘汰最久未使用并 didClose。 */
84
85
  maxOpenDocuments: Type.Optional(Type.Number({ minimum: 1 })),
@@ -99,18 +100,12 @@ const lspConfigSchema = Type.Object({
99
100
  /** 配置值(单文件解析结果);超时字段为原始写法(number 或字符串),换算在 resolveConfig。 */
100
101
  export type LspConfig = Static<typeof lspConfigSchema>;
101
102
 
102
- /**
103
- * 解析期缺省(单一来源)。超时/LRU 与 client.create 共用 clientDefaults;
104
- * watch 无 client 对应项,数值在此集中。
105
- */
106
- const configDefaults = {
107
- watch: {
108
- enabled: true,
109
- debounceMs: 300,
110
- flushMs: 1_000,
111
- maxBatch: 500,
112
- },
113
- maxOpenDocuments: clientDefaults.maxOpenDocuments,
103
+ /** 解析期缺省:watch 段(无 client 对应项,数值在此集中);客户端参数缺省见 clientDefaults。 */
104
+ const watchDefaults = {
105
+ enabled: true,
106
+ debounceMs: 300,
107
+ flushMs: 1_000,
108
+ maxBatch: 500,
114
109
  } as const;
115
110
 
116
111
  /** 服务器启动失败后自动重试的冷却(ms);冷却内跳过,之后下次触碰自动重试。 */
@@ -127,8 +122,12 @@ interface EffectiveWatchConfig {
127
122
  ignore: string[];
128
123
  }
129
124
 
130
- /** 全局 + 本地合并并解析后的生效配置:所有字段为确定值,无"未配置"歧义。 */
131
- export interface ResolvedLspConfig {
125
+ /**
126
+ * 全局 + 本地合并并解析后的生效配置:所有字段为确定值,无"未配置"歧义。
127
+ * 客户端参数的整体形状复用 `LspClientOptions`(定义在 client.ts,与
128
+ * `create()` 的覆盖参数同源),因此这里不再逐字段重声明。
129
+ */
130
+ export interface ResolvedLspConfig extends LspClientOptions {
132
131
  /** 合并后的服务器定义(未配置任何服务器时为空表)。 */
133
132
  servers: Record<string, ServerConfig>;
134
133
  /** enabled 白名单(undefined = 全部启用)。 */
@@ -137,15 +136,6 @@ export interface ResolvedLspConfig {
137
136
  disabled: Set<string> | undefined;
138
137
  /** 工作区监听配置(缺省值已应用)。 */
139
138
  watch: EffectiveWatchConfig;
140
- /** 驻留文档 LRU 容量。 */
141
- maxOpenDocuments: number;
142
- /** 以下超时均为换算后的毫秒数(缺省见 configDefaults / clientDefaults)。 */
143
- diagnosticsDebounceMs: number;
144
- diagnosticsDocumentWaitTimeoutMs: number;
145
- diagnosticsSilentWaitTimeoutMs: number;
146
- diagnosticsFullWaitTimeoutMs: number;
147
- diagnosticsRequestTimeoutMs: number;
148
- initializeTimeoutMs: number;
149
139
  }
150
140
 
151
141
  /** "500" → 500、"5s" → 5000、"1m" → 60000;无效字符串返回 NaN(由调用方兜底缺省)。 */
@@ -177,7 +167,7 @@ function toMs(value: number | string | undefined): number | undefined {
177
167
  }
178
168
 
179
169
  /**
180
- * 把合并后的原始配置解析为生效配置:应用 configDefaults 缺省、字符串时长换算、
170
+ * 把合并后的原始配置解析为生效配置:应用 watchDefaults / clientDefaults 缺省、字符串时长换算、
181
171
  * 白名单转 Set;per-server 的 workingDir / rootMarkers 互斥校验在此完成
182
172
  * (配置解析期抛错,与 enabled 白名单校验同层)。
183
173
  */
@@ -195,13 +185,13 @@ export function resolveConfig(raw: LspConfig): ResolvedLspConfig {
195
185
  enabled: raw.enabled === undefined ? undefined : new Set(raw.enabled),
196
186
  disabled: raw.disabled === undefined ? undefined : new Set(raw.disabled),
197
187
  watch: {
198
- enabled: raw.watch?.enabled ?? configDefaults.watch.enabled,
199
- debounceMs: toMs(raw.watch?.debounceMs) ?? configDefaults.watch.debounceMs,
200
- flushMs: configDefaults.watch.flushMs,
201
- maxBatch: raw.watch?.maxBatch ?? configDefaults.watch.maxBatch,
188
+ enabled: raw.watch?.enabled ?? watchDefaults.enabled,
189
+ debounceMs: toMs(raw.watch?.debounceMs) ?? watchDefaults.debounceMs,
190
+ flushMs: watchDefaults.flushMs,
191
+ maxBatch: raw.watch?.maxBatch ?? watchDefaults.maxBatch,
202
192
  ignore: raw.watch?.ignore ?? [],
203
193
  },
204
- maxOpenDocuments: raw.maxOpenDocuments ?? configDefaults.maxOpenDocuments,
194
+ maxOpenDocuments: raw.maxOpenDocuments ?? clientDefaults.maxOpenDocuments,
205
195
  diagnosticsDebounceMs: toMs(raw.diagnosticsDebounceMs) ?? clientDefaults.diagnosticsDebounceMs,
206
196
  diagnosticsDocumentWaitTimeoutMs:
207
197
  toMs(raw.diagnosticsDocumentWaitTimeoutMs) ?? clientDefaults.diagnosticsDocumentWaitTimeoutMs,
@@ -848,19 +838,22 @@ export function createLspService(
848
838
  reportStartupFailure(key, adapter.id, root, "binary not found", notify);
849
839
  return;
850
840
  }
841
+ const overrides: Partial<LspClientOptions> = {};
842
+ if (adapter.diagnosticsWaitMs !== undefined) {
843
+ overrides.diagnosticsDocumentWaitTimeoutMs = adapter.diagnosticsWaitMs;
844
+ }
845
+ if (adapter.startupTimeoutMs !== undefined) {
846
+ overrides.initializeTimeoutMs = adapter.startupTimeoutMs;
847
+ }
851
848
  const client = await create({
852
849
  serverID: adapter.id,
853
850
  server: handle,
854
851
  root,
855
852
  directory: cwd,
856
- diagnosticsDebounceMs: config.diagnosticsDebounceMs,
857
- diagnosticsDocumentWaitTimeoutMs:
858
- adapter.diagnosticsWaitMs ?? config.diagnosticsDocumentWaitTimeoutMs,
859
- diagnosticsSilentWaitTimeoutMs: config.diagnosticsSilentWaitTimeoutMs,
860
- diagnosticsFullWaitTimeoutMs: config.diagnosticsFullWaitTimeoutMs,
861
- diagnosticsRequestTimeoutMs: config.diagnosticsRequestTimeoutMs,
862
- initializeTimeoutMs: adapter.startupTimeoutMs ?? config.initializeTimeoutMs,
863
- maxOpenDocuments: config.maxOpenDocuments,
853
+ // 客户端参数整体取自生效配置(ResolvedLspConfig extends LspClientOptions),
854
+ // 不做逐字段搬运:新增旋钮时漏接线会静默失效,这里靠展开消除该位置。
855
+ ...config,
856
+ ...overrides,
864
857
  });
865
858
  if (state.closing || state.disabled) {
866
859
  await client.shutdown();
@@ -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,