@trim21/personal-pi-extensions 0.1.575 → 0.1.576

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
@@ -138,7 +138,9 @@ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/openco
138
138
 
139
139
  所有匹配策略按顺序尝试,第一个匹配成功即返回。同时自动处理 BOM、CRLF/LF 行尾转换和文件写入队列。`filePath` 接受绝对路径或相对工作目录的路径。
140
140
 
141
- edit 要求目标文件已被 `read` 读过且内容未变(内容指纹比对,与 Claude Code 风格 Edit 同一套语义):没读过报 `File has not been read yet. Read it first before editing it.`,读后文件被外部改动报 `File has been modified since read...`,两种情况都要重新 `read`。`read`/`edit`/`write`/`lsp-rename` 都会刷新记账并随工具结果持久化,session 恢复 / fork / rewind 后依然有效;`write` 本身不要求先读,但写后会刷新记账,紧随其后的 `edit` 不必重新读。
141
+ edit 要求目标文件已被 `read` 读过且内容未变(内容指纹比对,与 Claude Code 风格 Edit 同一套语义):没读过报 `File has not been read yet. Read it first before writing to it.`,读后文件被外部改动报 `File has been modified since read...`,两种情况都要重新 `read`。
142
+
143
+ `write` 不要求先读——没读过的文件可以直接写;但如果该文件已被读过、之后又被外部改动(或删除),`write` 会与 `edit` 一样要求重新 `read`,不让读取记录过期的内容被盲覆盖。`read`/`edit`/`write`/`lsp-rename` 都会刷新记账并随工具结果持久化,session 恢复 / fork / rewind 后依然有效;写完也会刷新,紧随其后的 `edit` 不必重新读。
142
144
 
143
145
  ### 使用
144
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.575",
3
+ "version": "0.1.576",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -45,6 +45,25 @@ export async function fileDigest(filePath: string): Promise<string> {
45
45
  return hash.digest("hex");
46
46
  }
47
47
 
48
+ /** ENOENT / ENOTDIR:路径不存在。 */
49
+ function isMissingPath(error: unknown): boolean {
50
+ return (
51
+ error instanceof Error &&
52
+ "code" in error &&
53
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
54
+ );
55
+ }
56
+
57
+ /** 磁盘上当前内容的指纹;文件已不存在时返回 undefined(视为「与读取时不同」)。 */
58
+ export async function digestIfExists(filePath: string): Promise<string | undefined> {
59
+ try {
60
+ return await fileDigest(filePath);
61
+ } catch (error) {
62
+ if (isMissingPath(error)) return undefined;
63
+ throw error;
64
+ }
65
+ }
66
+
48
67
  /**
49
68
  * 记账 key:解析 symlink 后的真实路径。文件尚不存在(Write 新建 / Edit 空
50
69
  * old_string 创建)时 realpath 抛 ENOENT,回退到调用方给出的路径。
@@ -53,13 +72,7 @@ export async function readStateKey(filePath: string): Promise<string> {
53
72
  try {
54
73
  return await realpath(filePath);
55
74
  } catch (error) {
56
- if (
57
- error instanceof Error &&
58
- "code" in error &&
59
- (error.code === "ENOENT" || error.code === "ENOTDIR")
60
- ) {
61
- return filePath;
62
- }
75
+ if (isMissingPath(error)) return filePath;
63
76
  throw error;
64
77
  }
65
78
  }
@@ -89,6 +102,27 @@ export function requireCurrentRead(
89
102
  }
90
103
  }
91
104
 
105
+ /**
106
+ * 过期校验:该文件已有读取记录时,要求磁盘上的当前指纹与记录一致,否则拒绝
107
+ * 写入;从未读过(没有记录)时直接放行。
108
+ *
109
+ * opencode 的 write 用这个:没读过的文件允许直接写,读过之后再被外部改动就必须
110
+ * 重新 read。`currentDigest` 为 undefined(文件已不存在)同样算过期。
111
+ */
112
+ export function requireUnchangedRead(
113
+ state: ReadsState,
114
+ key: string,
115
+ currentDigest: string | undefined,
116
+ ): void {
117
+ const readSnapshot = state.reads.get(key);
118
+ if (!readSnapshot) return;
119
+ if (currentDigest === undefined || readSnapshot.digest !== currentDigest) {
120
+ throw new Error(
121
+ "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
122
+ );
123
+ }
124
+ }
125
+
92
126
  /**
93
127
  * 从工具结果 details 恢复已读记账(跨进程 resume / reload / fork)。数据来自
94
128
  * session 文件,可能缺失或损坏:逐条 TypeBox 校验,非法条目丢弃。只接受
@@ -11,8 +11,9 @@
11
11
  * 不接 PDF、不接 <system-reminder>;图片 magic 检测保留。
12
12
  * - edit:匹配引擎 + 把 old/new 转到文件换行后再替换;写后等待文档诊断。
13
13
  * edit 要求文件已被 read 读过且内容未变(read 记账见下)。
14
- * - write:BOM 保留(source.bom || next.bom);写后同 edit 的诊断输出。write 本身
15
- * 不要求先 read,但会刷新记账,保证紧接着的 edit 不必重新 read。
14
+ * - write:BOM 保留(source.bom || next.bom);写后同 edit 的诊断输出。write 不
15
+ * 要求先 read(未读过的文件可直接写),但若已有读取记录而磁盘内容已变,则要求
16
+ * 重新 read;写完刷新记账,保证紧接着的 edit 不必重新 read。
16
17
  *
17
18
  * read 记账:read / edit / write / lsp-rename 各自把受影响文件的内容指纹写进
18
19
  * details.reads(随 session 持久化),session_start / session_tree 时从当前分支
@@ -37,11 +38,13 @@ import { Type } from "typebox";
37
38
 
38
39
  import {
39
40
  createReadsState,
41
+ digestIfExists,
40
42
  fileDigest,
41
43
  type FileSnapshot,
42
44
  type ReadsState,
43
45
  readStateKey,
44
46
  requireCurrentRead,
47
+ requireUnchangedRead,
45
48
  restoreReads,
46
49
  snapshotOf,
47
50
  } from "../lib/file-reads.js";
@@ -790,6 +793,13 @@ function registerWriteTool(
790
793
  async () => {
791
794
  signal?.throwIfAborted();
792
795
 
796
+ const key = await readStateKey(absolutePath);
797
+ // write 不要求先读过;但若已有读取记录,磁盘内容必须仍是读取时的样子,
798
+ // 否则先重新 read(外部改动过的文件不让盲写覆盖)
799
+ if (state.reads.has(key)) {
800
+ requireUnchangedRead(state, key, await digestIfExists(absolutePath));
801
+ }
802
+
793
803
  // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
794
804
  // 否则用新内容自带的 BOM
795
805
  let existing: Buffer | undefined;
@@ -810,8 +820,7 @@ function registerWriteTool(
810
820
  await mkdir(dir, { recursive: true });
811
821
  signal?.throwIfAborted();
812
822
  await writeFile(absolutePath, desiredBom + nextText, "utf8");
813
- // write 不要求先 read,但写后记账:紧接着的 edit 不该再要求重新 read
814
- const key = await readStateKey(absolutePath);
823
+ // 写后记账(key 在写入前已算过):紧接着的 edit 不该再要求重新 read
815
824
  const snapshot = snapshotOf(desiredBom + nextText);
816
825
  state.reads.set(key, snapshot);
817
826
  const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {