@trim21/personal-pi-extensions 0.0.270 → 0.0.272

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.270",
3
+ "version": "0.0.272",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -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);
@@ -34,10 +34,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
34
34
  import { homedir } from "node:os";
35
35
  import { delimiter, dirname, join, resolve } from "node:path";
36
36
 
37
- import { type ExtensionAPI, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
37
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
38
  import { Type } from "typebox";
39
39
  import { Value } from "typebox/value";
40
40
 
41
+ import { createSeqState } from "./lib/seq-state.js";
42
+
41
43
  interface GhResult {
42
44
  stdout: string;
43
45
  stderr: string;
@@ -380,8 +382,9 @@ export function stepsDetail(
380
382
  }));
381
383
  }
382
384
 
383
- /** In-flight dedup map to avoid concurrent fetches of the same log. */
384
- const inflightLogs = new Map<string, Promise<string>>();
385
+ // 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
386
+ // 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
387
+ const seq = createSeqState();
385
388
 
386
389
  async function getJobLog(
387
390
  runId: string,
@@ -393,13 +396,10 @@ async function getJobLog(
393
396
  ): Promise<string> {
394
397
  const cacheDir = join(homedir(), ".cache", "pi", "ci-logs", runId);
395
398
  const cacheFile = join(cacheDir, `${jobId}.log`);
396
- const key = `${runId}:${jobId}`;
397
-
398
- // Check in-flight dedup map
399
- const inflight = inflightLogs.get(key);
400
- if (inflight) return inflight;
401
399
 
402
- const fetchAndCache = async (): Promise<string> => {
400
+ // 同一 runId:jobId 的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
401
+ // 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
402
+ return seq.execute(`${runId}:${jobId}`, async () => {
403
403
  // Check file cache
404
404
  try {
405
405
  return await readFile(cacheFile, "utf8");
@@ -424,20 +424,10 @@ async function getJobLog(
424
424
 
425
425
  // Write to cache
426
426
  await mkdir(cacheDir, { recursive: true });
427
- await withFileMutationQueue(cacheFile, async () => {
428
- await writeFile(cacheFile, log);
429
- });
427
+ await writeFile(cacheFile, log);
430
428
 
431
429
  return log;
432
- };
433
-
434
- const promise = fetchAndCache();
435
- inflightLogs.set(key, promise);
436
- try {
437
- return await promise;
438
- } finally {
439
- inflightLogs.delete(key);
440
- }
430
+ });
441
431
  }
442
432
 
443
433
  async function resolveRepo(
@@ -1061,9 +1051,7 @@ export async function writeLogFile(
1061
1051
 
1062
1052
  const target = resolve(cwd ?? process.cwd(), outputFile);
1063
1053
  await mkdir(dirname(target), { recursive: true });
1064
- await withFileMutationQueue(target, async () => {
1065
- await writeFile(target, content);
1066
- });
1054
+ await writeFile(target, content);
1067
1055
 
1068
1056
  const lines = content.split("\n").length;
1069
1057
  const bytes = Buffer.byteLength(content, "utf8");
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 按 key 串行化并发任务:同一 key 的调用排队执行,前一个 settle(无论成败)
3
+ * 后才执行下一个。key 由调用方自定义(如资源 id),每个 `createSeqState()`
4
+ * 返回独立的闭包状态,不同扩展之间不串扰。
5
+ */
6
+
7
+ export interface SeqState {
8
+ execute<T>(key: string, fn: () => Promise<T>): Promise<T>;
9
+ }
10
+
11
+ export function createSeqState(): SeqState {
12
+ const tails = new Map<string, Promise<unknown>>();
13
+
14
+ return {
15
+ execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
16
+ const previous = tails.get(key) ?? Promise.resolve();
17
+
18
+ // 等前一个任务 settle 后执行本任务。previous 恒为 resolve(tail 吞掉了错误),
19
+ // 失败不会阻塞后续任务。
20
+ const run = previous.then(() => fn());
21
+
22
+ // 本任务的完成占位:永远 resolve,作为下一个任务的等待点。
23
+ // eslint-disable-next-line unicorn/no-useless-undefined -- 显式返回 undefined:吞掉前序错误并归一化为成功占位
24
+ const tail = run.catch(() => undefined);
25
+
26
+ tails.set(key, tail);
27
+ void tail.finally(() => {
28
+ if (tails.get(key) === tail) tails.delete(key);
29
+ });
30
+
31
+ return run;
32
+ },
33
+ };
34
+ }