@trim21/personal-pi-extensions 0.0.241 → 0.0.242

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.241",
3
+ "version": "0.0.242",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -1,10 +1,18 @@
1
1
  import { isAbsolute, normalize, resolve } from "node:path";
2
2
 
3
+ import { Type } from "typebox";
4
+ import { Value } from "typebox/value";
5
+
3
6
  export interface FileSnapshot {
4
7
  digest: string;
5
8
  textEditable: boolean;
6
9
  }
7
10
 
11
+ const fileSnapshotSchema = Type.Object({
12
+ digest: Type.String(),
13
+ textEditable: Type.Boolean(),
14
+ });
15
+
8
16
  export interface ClaudeCodeState {
9
17
  readonly reads: Map<string, FileSnapshot>;
10
18
  }
@@ -33,3 +41,17 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
33
41
  export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
34
42
  return left.digest === right.digest;
35
43
  }
44
+
45
+ /**
46
+ * 从工具结果 details 里恢复文件已读记账(跨进程 resume / reload / fork)。
47
+ * 数据来自 session 文件,可能缺失或损坏:逐条 TypeBox 校验,非法条目丢弃。
48
+ * 只接受 plain object,数组、null 等异常形态直接返回空 map。
49
+ */
50
+ export function deserializeReads(data: unknown): Map<string, FileSnapshot> {
51
+ const reads = new Map<string, FileSnapshot>();
52
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return reads;
53
+ for (const [filePath, snapshot] of Object.entries(data)) {
54
+ if (Value.Check(fileSnapshotSchema, snapshot)) reads.set(filePath, snapshot);
55
+ }
56
+ return reads;
57
+ }
@@ -44,6 +44,11 @@ export interface FileToolDetails {
44
44
  diff?: string;
45
45
  patch?: string;
46
46
  firstChangedLine?: number;
47
+ /**
48
+ * 本轮受影响文件的已读快照(path → snapshot)。随工具结果持久化到
49
+ * session 文件,resume 后由 session_start 重建 reads state。
50
+ */
51
+ reads?: Record<string, FileSnapshot>;
47
52
  }
48
53
 
49
54
  function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
@@ -216,8 +221,9 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
216
221
  { type: "text", text: `Read image file [${imageMime}]` },
217
222
  { type: "image", data, mimeType: imageMime },
218
223
  ];
219
- state.reads.set(filePath, snapshotOf(image, false));
220
- return { content, details: undefined };
224
+ const snapshot = snapshotOf(image, false);
225
+ state.reads.set(filePath, snapshot);
226
+ return { content, details: { reads: { [filePath]: snapshot } } };
221
227
  }
222
228
 
223
229
  const buffer = await readFile(filePath);
@@ -228,8 +234,12 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
228
234
  params.offset ?? 1,
229
235
  params.limit ?? DEFAULT_READ_LINES,
230
236
  );
231
- state.reads.set(filePath, snapshotOf(buffer));
232
- return { content: [{ type: "text", text: formatted.text }], details: undefined };
237
+ const snapshot = snapshotOf(buffer);
238
+ state.reads.set(filePath, snapshot);
239
+ return {
240
+ content: [{ type: "text", text: formatted.text }],
241
+ details: { reads: { [filePath]: snapshot } },
242
+ };
233
243
  },
234
244
  });
235
245
 
@@ -279,7 +289,8 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
279
289
  params.replace_all ?? false,
280
290
  );
281
291
  await writeFile(filePath, updated, "utf8");
282
- state.reads.set(filePath, snapshotOf(updated));
292
+ const snapshot = snapshotOf(updated);
293
+ state.reads.set(filePath, snapshot);
283
294
  const diff = generateDiffString(original, updated);
284
295
  return {
285
296
  content: [{ type: "text", text: `The file ${filePath} has been updated successfully.` }],
@@ -287,6 +298,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
287
298
  diff: diff.diff,
288
299
  patch: generateUnifiedPatch(filePath, original, updated),
289
300
  firstChangedLine: diff.firstChangedLine,
301
+ reads: { [filePath]: snapshot },
290
302
  } satisfies FileToolDetails,
291
303
  };
292
304
  });
@@ -334,7 +346,8 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
334
346
  throwIfAborted(signal);
335
347
  await mkdir(dirname(filePath), { recursive: true });
336
348
  await writeFile(filePath, params.content, "utf8");
337
- state.reads.set(filePath, snapshotOf(params.content));
349
+ const snapshot = snapshotOf(params.content);
350
+ state.reads.set(filePath, snapshot);
338
351
  const diff = generateDiffString(original ?? "", params.content);
339
352
  return {
340
353
  content: [{ type: "text", text: `File created successfully at: ${filePath}` }],
@@ -342,6 +355,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
342
355
  diff: diff.diff,
343
356
  patch: generateUnifiedPatch(filePath, original ?? "", params.content),
344
357
  firstChangedLine: diff.firstChangedLine,
358
+ reads: { [filePath]: snapshot },
345
359
  } satisfies FileToolDetails,
346
360
  };
347
361
  });
@@ -1,13 +1,33 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import { createClaudeCodeState } from "./common.js";
3
+ import { 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";
7
7
  import { registerShellTools } from "./shell.js";
8
8
 
9
+ /** 会更新 reads state 并随 details 持久化快照的工具名。 */
10
+ const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write"]);
11
+
9
12
  export default function claudeCodeTools(pi: ExtensionAPI): void {
10
13
  const state = createClaudeCodeState();
14
+
15
+ // 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的
16
+ // 已读记账随之丢失。这里从当前分支的历史工具结果里恢复:digest 是当时的值,
17
+ // 若文件在此期间被外部修改,Edit/Write 时的指纹对比仍会要求重新 Read,
18
+ // 防呆语义不因重建而弱化。
19
+ 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
+ }
29
+ });
30
+
11
31
  registerFileTools(pi, state);
12
32
  registerSearchTools(pi);
13
33
  registerShellTools(pi);
@@ -1,6 +1,7 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
+ import { Value } from "typebox/value";
4
5
 
5
6
  import { selectWithOptionalInput } from "../lib/ui.js";
6
7
 
@@ -102,23 +103,34 @@ async function askMultiple(
102
103
  }
103
104
 
104
105
  export function registerSessionTools(pi: ExtensionAPI): void {
106
+ const todoItemSchema = Type.Object(
107
+ {
108
+ content: Type.String({ minLength: 1 }),
109
+ status: StringEnum(TODO_STATUSES),
110
+ activeForm: Type.String({ minLength: 1 }),
111
+ },
112
+ { additionalProperties: false },
113
+ );
105
114
  const todoSchema = Type.Object(
106
115
  {
107
- todos: Type.Array(
108
- Type.Object(
109
- {
110
- content: Type.String({ minLength: 1 }),
111
- status: StringEnum(TODO_STATUSES),
112
- activeForm: Type.String({ minLength: 1 }),
113
- },
114
- { additionalProperties: false },
115
- ),
116
- { description: "The updated todo list" },
117
- ),
116
+ todos: Type.Array(todoItemSchema, { description: "The updated todo list" }),
118
117
  },
119
118
  { additionalProperties: false },
120
119
  );
121
120
 
121
+ // TodoWrite 的列表随工具结果 details 持久化(跟随会话分支),但 widget 是
122
+ // 纯 TUI 状态,进程重启后丢失。session 恢复时从当前分支取最后一个 TodoWrite
123
+ // 的列表重新渲染(完整列表替换语义,后出现的覆盖前面的)。
124
+ pi.on("session_start", (_event, ctx) => {
125
+ for (const entry of ctx.sessionManager.getBranch()) {
126
+ if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
127
+ if (entry.message.toolName !== "TodoWrite") continue;
128
+ if (Value.Check(todoSchema, entry.message.details)) {
129
+ ctx.ui.setWidget("claude-code-todos", formatTodos(entry.message.details.todos));
130
+ }
131
+ }
132
+ });
133
+
122
134
  pi.registerTool({
123
135
  name: "TodoWrite",
124
136
  label: "Todo Write",