@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 +3 -5
- package/src/bwrap/approval-rules.ts +12 -11
- package/src/claude-code/files.ts +58 -21
- package/src/claude-code/index.ts +30 -11
- package/src/bwrap/tree-sitter-bash.wasm +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
"
|
|
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
|
|
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
|
|
47
|
-
*
|
|
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
|
|
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) };
|
package/src/claude-code/files.ts
CHANGED
|
@@ -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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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: { [
|
|
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
|
-
|
|
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: { [
|
|
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
|
|
398
|
+
let content: Buffer;
|
|
369
399
|
try {
|
|
370
|
-
|
|
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
|
|
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(
|
|
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: { [
|
|
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
|
|
464
|
-
|
|
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
|
-
|
|
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: { [
|
|
524
|
+
reads: { [resolvedKey]: snapshot },
|
|
488
525
|
} satisfies FileToolDetails,
|
|
489
526
|
};
|
|
490
527
|
});
|
package/src/claude-code/index.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|