@trim21/personal-pi-extensions 0.0.269 → 0.0.271

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.269",
3
+ "version": "0.0.271",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -23,9 +23,7 @@
23
23
  "format": "prettier --write .",
24
24
  "test": "vitest run",
25
25
  "test:coverage": "vitest run --coverage",
26
- "prepack": "pnpm run copy",
27
- "copy": "cp node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm src/bwrap/tree-sitter-bash.wasm",
28
- "prepare": "husky && pnpm run copy"
26
+ "prepare": "husky"
29
27
  },
30
28
  "peerDependencies": {
31
29
  "@earendil-works/pi-agent-core": ">=0.84.1",
@@ -40,7 +38,6 @@
40
38
  "@types/node": "^24.13.3",
41
39
  "@typescript/native": "npm:typescript@^7.0.2",
42
40
  "@vitest/coverage-v8": "^4.1.10",
43
- "@vscode/tree-sitter-wasm": "^0.3.1",
44
41
  "eslint": "^10.8.1",
45
42
  "eslint-config-prettier": "10.1.8",
46
43
  "eslint-plugin-erasable-syntax-only": "0.4.2",
@@ -86,6 +83,7 @@
86
83
  "node": ">=24"
87
84
  },
88
85
  "dependencies": {
86
+ "@vscode/tree-sitter-wasm": "^0.3.1",
89
87
  "web-tree-sitter": "^0.26.12"
90
88
  }
91
89
  }
@@ -11,8 +11,9 @@
11
11
  * - opencode packages/opencode/src/tool/shell.ts(tree-sitter 命令提取)
12
12
  */
13
13
  import { readFileSync } from "node:fs";
14
+ import { createRequire } from "node:module";
14
15
 
15
- import type { Node } from "web-tree-sitter";
16
+ import { Language, type Node, Parser } from "web-tree-sitter";
16
17
 
17
18
  /** 解析后的单个命令:命令名 + 参数 + 原文 + 嵌套命令(命令替换里的)。 */
18
19
  export interface BashCommand {
@@ -43,22 +44,22 @@ interface BashParser {
43
44
  }
44
45
 
45
46
  /**
46
- * 延迟初始化 tree-sitter bash parser(wasm 加载开销大,只做一次)。
47
- * Node 环境:主 wasm web-tree-sitter 按自身路径自动加载,
48
- * bash grammar 的 wasm 从同目录文件读入。
49
- *
50
- * tree-sitter-bash.wasm 不提交进 git:`pnpm install` 后的 prepare
51
- * 和发布前的 prepack 都会执行 `pnpm run copy`,从 devDependency
52
- * @vscode/tree-sitter-wasm(MIT)复制到本目录。
47
+ * 延迟初始化 tree-sitter bash parser:web-tree-sitter 是静态 import,
48
+ * wasm 加载与 parser 构造只在首次调用时发生(Parser.init() 开销大)。
49
+ * bash grammar 的 wasm 在模块加载时从 runtime 依赖
50
+ * @vscode/tree-sitter-wasm(MIT)解析并读取一次。
53
51
  */
52
+ const require = createRequire(import.meta.url);
53
+ const BASH_WASM = readFileSync(
54
+ require.resolve("@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm"),
55
+ );
56
+
54
57
  function createParserLoader(): () => Promise<BashParser> {
55
58
  let parserPromise: Promise<BashParser> | undefined;
56
59
  return function loadParser(): Promise<BashParser> {
57
60
  parserPromise ??= (async () => {
58
- const { Language, Parser } = await import("web-tree-sitter");
59
61
  await Parser.init();
60
- const bashWasm = readFileSync(new URL("tree-sitter-bash.wasm", import.meta.url));
61
- const language = await Language.load(bashWasm);
62
+ const language = await Language.load(BASH_WASM);
62
63
  const parser = new Parser();
63
64
  parser.setLanguage(language);
64
65
  return { parse: (source: string) => parser.parse(source) };
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { constants, readFileSync } from "node:fs";
3
- import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { access, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
4
  import { dirname, extname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
@@ -164,17 +164,44 @@ export function exactReplace(
164
164
  return content.slice(0, index) + newString + content.slice(index + oldString.length);
165
165
  }
166
166
 
167
- async function requireCurrentRead(state: ClaudeCodeState, filePath: string): Promise<void> {
168
- const readSnapshot = state.reads.get(filePath);
167
+ /**
168
+ * reads 记账 key:解析 symlink 后的真实路径,与 withFileMutationQueue 的队列
169
+ * key 对齐。文件尚不存在(Write 新建 / Edit 空 old_string 创建)时 realpath
170
+ * 抛 ENOENT,回退到已规范化路径。
171
+ */
172
+ async function readStateKey(filePath: string): Promise<string> {
173
+ try {
174
+ return await realpath(filePath);
175
+ } catch (error) {
176
+ if (
177
+ error instanceof Error &&
178
+ "code" in error &&
179
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
180
+ ) {
181
+ return filePath;
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * 校验「已读且未变」。key 与 currentContent 由调用方提供:调用方每次工具调用
189
+ * 只 realpath / readFile 一次,避免重复 IO。
190
+ */
191
+ function requireCurrentRead(
192
+ state: ClaudeCodeState,
193
+ key: string,
194
+ filePath: string,
195
+ currentContent: Uint8Array,
196
+ ): void {
197
+ const readSnapshot = state.reads.get(key);
169
198
  if (!readSnapshot) {
170
199
  throw new Error("File has not been read yet. Read it first before writing to it.");
171
200
  }
172
201
  if (!readSnapshot.textEditable) {
173
202
  throw new Error(`Cannot edit or overwrite a binary file with a text tool: ${filePath}`);
174
203
  }
175
- const currentContent = await readFile(filePath);
176
- const current = snapshotOf(currentContent);
177
- if (!snapshotsEqual(readSnapshot, current)) {
204
+ if (!snapshotsEqual(readSnapshot, snapshotOf(currentContent))) {
178
205
  throw new Error(
179
206
  "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
180
207
  );
@@ -249,8 +276,9 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
249
276
  { type: "image", data, mimeType: imageMime },
250
277
  ];
251
278
  const snapshot = snapshotOf(image, false);
252
- state.reads.set(filePath, snapshot);
253
- return { content, details: { reads: { [filePath]: snapshot } } };
279
+ const key = await readStateKey(filePath);
280
+ state.reads.set(key, snapshot);
281
+ return { content, details: { reads: { [key]: snapshot } } };
254
282
  }
255
283
 
256
284
  const buffer = await readFile(filePath);
@@ -272,10 +300,11 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
272
300
  );
273
301
  }
274
302
  const snapshot = snapshotOf(buffer);
275
- state.reads.set(filePath, snapshot);
303
+ const key = await readStateKey(filePath);
304
+ state.reads.set(key, snapshot);
276
305
  return {
277
306
  content: [{ type: "text", text: formatted.text }],
278
- details: { reads: { [filePath]: snapshot } },
307
+ details: { reads: { [key]: snapshot } },
279
308
  };
280
309
  },
281
310
  });
@@ -343,12 +372,13 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
343
372
  if (!exists) await mkdir(dirname(filePath), { recursive: true });
344
373
  await writeFile(filePath, newString, "utf8");
345
374
  const snapshot = snapshotOf(newString);
346
- state.reads.set(filePath, snapshot);
375
+ const key = await readStateKey(filePath);
376
+ state.reads.set(key, snapshot);
347
377
  return {
348
378
  content: [
349
379
  { type: "text", text: `The file ${filePath} has been updated successfully.` },
350
380
  ],
351
- details: { reads: { [filePath]: snapshot } } satisfies FileToolDetails,
381
+ details: { reads: { [key]: snapshot } } satisfies FileToolDetails,
352
382
  };
353
383
  }
354
384
  const replaceAll = params.replace_all ?? false;
@@ -365,9 +395,9 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
365
395
  throw error;
366
396
  }
367
397
  }
368
- let original: string;
398
+ let content: Buffer;
369
399
  try {
370
- original = await readFile(filePath, "utf8");
400
+ content = await readFile(filePath);
371
401
  } catch (error) {
372
402
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
373
403
  const suggestion = await didYouMean(filePath, ctx.cwd);
@@ -383,9 +413,11 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
383
413
  "File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
384
414
  );
385
415
  }
386
- await requireCurrentRead(state, filePath);
416
+ const key = await readStateKey(filePath);
417
+ requireCurrentRead(state, key, filePath, content);
387
418
  await access(filePath, constants.R_OK | constants.W_OK);
388
419
  throwIfAborted(signal);
420
+ const original = content.toString("utf8");
389
421
 
390
422
  // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
391
423
  const crlfCount = (original.match(/\r\n/g) ?? []).length;
@@ -410,7 +442,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
410
442
  const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
411
443
  await writeFile(filePath, restored, "utf8");
412
444
  const snapshot = snapshotOf(restored);
413
- state.reads.set(filePath, snapshot);
445
+ state.reads.set(key, snapshot);
414
446
  const diff = generateDiffString(original, restored);
415
447
  const text = replaceAll
416
448
  ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
@@ -421,7 +453,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
421
453
  diff: diff.diff,
422
454
  patch: generateUnifiedPatch(filePath, original, restored),
423
455
  firstChangedLine: diff.firstChangedLine,
424
- reads: { [filePath]: snapshot },
456
+ reads: { [key]: snapshot },
425
457
  } satisfies FileToolDetails,
426
458
  };
427
459
  });
@@ -457,11 +489,14 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
457
489
  });
458
490
  return withFileMutationQueue(filePath, async () => {
459
491
  let original: string | undefined;
492
+ let key: string | undefined;
460
493
  try {
461
494
  const value = await stat(filePath);
462
495
  if (value.isFile()) {
463
- await requireCurrentRead(state, filePath);
464
- original = await readFile(filePath, "utf8");
496
+ const content = await readFile(filePath);
497
+ key = await readStateKey(filePath);
498
+ requireCurrentRead(state, key, filePath, content);
499
+ original = content.toString("utf8");
465
500
  }
466
501
  } catch (error) {
467
502
  if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
@@ -472,7 +507,9 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
472
507
  await mkdir(dirname(filePath), { recursive: true });
473
508
  await writeFile(filePath, params.content, "utf8");
474
509
  const snapshot = snapshotOf(params.content);
475
- state.reads.set(filePath, snapshot);
510
+ // 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
511
+ const resolvedKey = key ?? (await readStateKey(filePath));
512
+ state.reads.set(resolvedKey, snapshot);
476
513
  const diff = generateDiffString(original ?? "", params.content);
477
514
  const text =
478
515
  original === undefined
@@ -484,7 +521,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
484
521
  diff: diff.diff,
485
522
  patch: generateUnifiedPatch(filePath, original ?? "", params.content),
486
523
  firstChangedLine: diff.firstChangedLine,
487
- reads: { [filePath]: snapshot },
524
+ reads: { [resolvedKey]: snapshot },
488
525
  } satisfies FileToolDetails,
489
526
  };
490
527
  });
@@ -1,6 +1,6 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import { createClaudeCodeState, deserializeReads } from "./common.js";
3
+ import { type ClaudeCodeState, createClaudeCodeState, deserializeReads } from "./common.js";
4
4
  import { registerFileTools } from "./files.js";
5
5
  import { registerSearchTools } from "./search.js";
6
6
  import { registerSessionTools } from "./session-tools.js";
@@ -9,6 +9,26 @@ import { registerShellTools } from "./shell.js";
9
9
  /** 会更新 reads state 并随 details 持久化快照的工具名。 */
10
10
  const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write"]);
11
11
 
12
+ /**
13
+ * 从当前分支的历史工具结果重建已读记账。先清空再重放,保证 state 只反映
14
+ * 当前分支:rewind / fork / resume 后,被抛弃分支上的 Read 不再残留。
15
+ */
16
+ function restoreReads(
17
+ state: ClaudeCodeState,
18
+ sessionManager: ExtensionContext["sessionManager"],
19
+ ): void {
20
+ state.reads.clear();
21
+ for (const entry of sessionManager.getBranch()) {
22
+ if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
23
+ if (!FILE_TOOL_NAMES.has(entry.message.toolName)) continue;
24
+ const details = entry.message.details as { reads?: unknown } | undefined;
25
+ if (!details?.reads) continue;
26
+ for (const [filePath, snapshot] of deserializeReads(details.reads)) {
27
+ state.reads.set(filePath, snapshot);
28
+ }
29
+ }
30
+ }
31
+
12
32
  export default function claudeCodeTools(pi: ExtensionAPI): void {
13
33
  const state = createClaudeCodeState();
14
34
 
@@ -17,15 +37,14 @@ export default function claudeCodeTools(pi: ExtensionAPI): void {
17
37
  // 若文件在此期间被外部修改,Edit/Write 时的指纹对比仍会要求重新 Read,
18
38
  // 防呆语义不因重建而弱化。
19
39
  pi.on("session_start", (_event, ctx) => {
20
- for (const entry of ctx.sessionManager.getBranch()) {
21
- if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
22
- if (!FILE_TOOL_NAMES.has(entry.message.toolName)) continue;
23
- const details = entry.message.details as { reads?: unknown } | undefined;
24
- if (!details?.reads) continue;
25
- for (const [filePath, snapshot] of deserializeReads(details.reads)) {
26
- state.reads.set(filePath, snapshot);
27
- }
28
- }
40
+ restoreReads(state, ctx.sessionManager);
41
+ });
42
+
43
+ // rewind / 树内跳转走 navigateTree branch(),只发 session_tree 不发
44
+ // session_start,扩展实例也不重建。这里同样重放当前分支,丢弃被抛弃分支
45
+ // 的记账,避免 state 与当前分支脱节。
46
+ pi.on("session_tree", (_event, ctx) => {
47
+ restoreReads(state, ctx.sessionManager);
29
48
  });
30
49
 
31
50
  registerFileTools(pi, state);
Binary file