@trim21/personal-pi-extensions 0.0.241 → 0.0.243

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
@@ -17,6 +17,14 @@
17
17
  | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
18
18
  | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
19
19
 
20
+ > **两套工具风格,按预期只启用其中一套**:本包同时提供 opencode 风格
21
+ > (小写 `read`/`edit`/`write`/`bash`/`todowrite`/`question`)与 Claude Code
22
+ > 风格(大写 `Read`/`Edit`/`Write`/`Bash`/`Grep`/`Glob`/`TodoWrite`/
23
+ > `AskUserQuestion`)两套工具集,二者共享 bwrap 沙箱与写保护实现。两套同时
24
+ > 启用会带来预期外的冗余:同名命令重复注册(如 `/bwrap` 出现 `/bwrap:1`
25
+ > 后缀)、系统提示重复注入。请只启用其中一套:在 `~/.pi/agent/settings.json`
26
+ > 的 `defaultTools` 中只列出一套,或启动时用 `--exclude-tools` 排除另一套。
27
+
20
28
  ---
21
29
 
22
30
  ## bwrap
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.243",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -320,5 +320,3 @@ export class BwrapRuntime {
320
320
  export function createBwrapRuntime(): BwrapRuntime {
321
321
  return new BwrapRuntime();
322
322
  }
323
-
324
- export const bwrapRuntime = createBwrapRuntime();
@@ -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",
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
 
7
- import { bwrapRuntime } from "../bwrap/runtime.js";
7
+ import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
8
8
  import { resolveWorkdir } from "../lib/path.js";
9
9
 
10
10
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -13,8 +13,15 @@ const MAX_TIMEOUT_MS = 600_000;
13
13
  /** Bash tool guidance, kept in markdown so it reads like documentation. */
14
14
  const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
15
15
 
16
- export function registerShellTools(pi: ExtensionAPI): void {
17
- bwrapRuntime.setup(pi);
16
+ /**
17
+ * runtime 由调用方注入:扩展工厂持有一个实例(不依赖模块级全局状态),
18
+ * 测试可注入预置模式的实例。状态随扩展实例生命周期,session 切换重建即重置。
19
+ */
20
+ export function registerShellTools(
21
+ pi: ExtensionAPI,
22
+ runtime: BwrapRuntime = createBwrapRuntime(),
23
+ ): void {
24
+ runtime.setup(pi);
18
25
  pi.registerTool({
19
26
  name: "Bash",
20
27
  promptSnippet: "execute command",
@@ -58,7 +65,7 @@ export function registerShellTools(pi: ExtensionAPI): void {
58
65
  const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
59
66
 
60
67
  try {
61
- return await bwrapRuntime.execute({
68
+ return await runtime.execute({
62
69
  ctx: { ...ctx, cwd },
63
70
  toolCallId: id,
64
71
  command: params.command,
@@ -1,14 +1,17 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
 
4
- import { bwrapRuntime } from "../bwrap/runtime.js";
4
+ import { createBwrapRuntime } from "../bwrap/runtime.js";
5
5
  import { resolveWorkdir } from "../lib/path.js";
6
6
 
7
7
  const DEFAULT_TIMEOUT_MS = 120_000;
8
8
  const MAX_TIMEOUT_MS = 600_000;
9
9
 
10
10
  export default function opencodeBash(pi: ExtensionAPI): void {
11
- bwrapRuntime.setup(pi);
11
+ // 每个扩展实例持有自己的 runtime:不依赖模块级全局状态,状态随扩展
12
+ // 实例生命周期(进程启动 / /reload / session 切换时工厂重建即重置)。
13
+ const runtime = createBwrapRuntime();
14
+ runtime.setup(pi);
12
15
  pi.registerTool({
13
16
  name: "bash",
14
17
  label: "bash",
@@ -52,7 +55,7 @@ export default function opencodeBash(pi: ExtensionAPI): void {
52
55
  const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
53
56
 
54
57
  try {
55
- return await bwrapRuntime.execute({
58
+ return await runtime.execute({
56
59
  ctx: { ...ctx, cwd },
57
60
  toolCallId: id,
58
61
  command: params.command,
@@ -54,9 +54,9 @@ const MAX_PROGRESS_LINES = 5;
54
54
  * built-in tool, the matching opencode extension is loaded via `-e` so the
55
55
  * subagent uses the enhanced implementation instead of the built-in one.
56
56
  *
57
- * The bash override also carries the bwrap sandbox: opencode/bash.ts calls
58
- * bwrapRuntime.setup() and runs commands through bwrapRuntime.execute(), so
59
- * agents that declare the bash tool get sandboxing automatically. Agents
57
+ * The bash override also carries the bwrap sandbox: opencode/bash.ts creates
58
+ * its own bwrap runtime instance and runs commands through runtime.execute(),
59
+ * so agents that declare the bash tool get sandboxing automatically. Agents
60
60
  * without bash need no bwrap setup (there are no commands to sandbox).
61
61
  * (Workspace write protection is embedded in the opencode write/edit tools.)
62
62
  *