@trim21/personal-pi-extensions 0.0.240 → 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.240",
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": [
package/src/bwrap/core.ts CHANGED
@@ -167,7 +167,13 @@ function findDefaultBwrap(): string {
167
167
  }
168
168
 
169
169
  export function findBwrap(override?: string): string {
170
- return override ?? findDefaultBwrap();
170
+ if (override) {
171
+ if (!existsSync(override)) {
172
+ throw new Error(`bwrap not found at configured path: ${override}`);
173
+ }
174
+ return override;
175
+ }
176
+ return findDefaultBwrap();
171
177
  }
172
178
 
173
179
  export function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): string[] {
@@ -73,6 +73,7 @@ function notifyMode(
73
73
  export class BwrapRuntime {
74
74
  private resolved: ResolvedBwrap | undefined;
75
75
  private sandboxDisabled = false;
76
+ private bwrapUnavailable = false;
76
77
 
77
78
  setup(pi: ExtensionAPI): void {
78
79
  pi.registerFlag("no-bwrap", {
@@ -84,13 +85,18 @@ export class BwrapRuntime {
84
85
  pi.on("session_start", (_event, ctx) => {
85
86
  this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
86
87
  this.resolved = undefined;
88
+ this.bwrapUnavailable = false;
87
89
  const runtime = this.resolve(ctx);
88
90
  if (runtime.bwrapEnabled) {
89
91
  try {
90
92
  findBwrap(runtime.bwrapPath);
91
93
  } catch (error) {
92
- this.sandboxDisabled = true;
94
+ // Fail closed: a missing bwrap binary must not silently degrade to an
95
+ // unsandboxed allow-all session. Commands are refused until the user
96
+ // explicitly opts out via --no-bwrap or the bwrap-allow-all command.
97
+ this.bwrapUnavailable = true;
93
98
  this.resolved = undefined;
99
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("error", "bwrap: unavailable"));
94
100
  ctx.ui.notify(error instanceof Error ? error.message : "bwrap not found", "error");
95
101
  return;
96
102
  }
@@ -110,10 +116,16 @@ export class BwrapRuntime {
110
116
 
111
117
  pi.on("before_agent_start", (event, ctx) => {
112
118
  const runtime = this.resolve(ctx);
113
- const prompt = ctx.hasUI
114
- ? `\n\n## Command Execution\nCurrent bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.\n`
115
- : "\n\n## Command Execution\nThis headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.\n";
116
- return { systemPrompt: event.systemPrompt + prompt };
119
+ const modeText = ctx.hasUI
120
+ ? `Current bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`
121
+ : "This headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.";
122
+ const unavailableText = this.bwrapUnavailable
123
+ ? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
124
+ : "";
125
+ return {
126
+ systemPrompt:
127
+ event.systemPrompt + `\n\n## Command Execution\n${modeText}${unavailableText}\n`,
128
+ };
117
129
  });
118
130
 
119
131
  this.registerCommands(pi);
@@ -128,10 +140,17 @@ export class BwrapRuntime {
128
140
  reset(): void {
129
141
  this.resolved = undefined;
130
142
  this.sandboxDisabled = false;
143
+ this.bwrapUnavailable = false;
131
144
  }
132
145
 
133
146
  async execute(request: BwrapExecutionRequest) {
134
147
  const runtime = this.resolve(request.ctx);
148
+ if (this.bwrapUnavailable && runtime.bwrapEnabled && request.requestFullAccess !== true) {
149
+ throw new Error(
150
+ "bwrap (bubblewrap) not found; refusing to execute commands without sandboxing. " +
151
+ "Install bubblewrap and restart the session, or pass --no-bwrap to disable the sandbox explicitly.",
152
+ );
153
+ }
135
154
  if (request.requestFullAccess === true && runtime.bwrapEnabled) {
136
155
  await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
137
156
  }
@@ -229,6 +248,13 @@ export class BwrapRuntime {
229
248
  handler: (args, ctx) =>
230
249
  this.runCommand(pi, specs.bwrap, args, ctx, (commandCtx) => {
231
250
  const runtime = this.resolve(commandCtx);
251
+ if (this.bwrapUnavailable) {
252
+ commandCtx.ui.notify(
253
+ "bwrap is unavailable: binary not found. Commands are refused unless sandboxing is explicitly disabled.",
254
+ "error",
255
+ );
256
+ return;
257
+ }
232
258
  if (!runtime.bwrapEnabled) {
233
259
  commandCtx.ui.notify(`bwrap disabled (mode: ${runtime.mode})`, "info");
234
260
  return;
@@ -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",
@@ -29,9 +29,10 @@
29
29
  */
30
30
 
31
31
  import { spawn } from "node:child_process";
32
+ import { existsSync } from "node:fs";
32
33
  import { mkdir, readFile, writeFile } from "node:fs/promises";
33
34
  import { homedir } from "node:os";
34
- import { dirname, join, resolve } from "node:path";
35
+ import { delimiter, dirname, join, resolve } from "node:path";
35
36
 
36
37
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
37
38
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
@@ -52,6 +53,22 @@ interface GhResult {
52
53
 
53
54
  // ── helpers ──────────────────────────────────────────────────────────────────
54
55
 
56
+ /**
57
+ * Check whether the `gh` CLI is on the system, scanning PATH like
58
+ * `findDefaultBwrap`. The extension registers no tools when `gh` is missing, so
59
+ * the model never sees GitHub tools that would fail on every call.
60
+ */
61
+ export function isGhAvailable(): boolean {
62
+ const pathEnv = process.env.PATH ?? "";
63
+ for (const directory of pathEnv.split(delimiter)) {
64
+ if (existsSync(join(directory, "gh"))) return true;
65
+ }
66
+ for (const candidate of ["/usr/bin/gh", "/usr/local/bin/gh", "/run/current-system/sw/bin/gh"]) {
67
+ if (existsSync(candidate)) return true;
68
+ }
69
+ return false;
70
+ }
71
+
55
72
  export function runGh(
56
73
  args: string[],
57
74
  ctx: { cwd?: string; signal?: AbortSignal; timeout?: number },
@@ -1082,6 +1099,19 @@ export async function writeLogFile(
1082
1099
  // ── tools ────────────────────────────────────────────────────────────────────
1083
1100
 
1084
1101
  export default function ghReadonlyTools(pi: ExtensionAPI) {
1102
+ // Fail fast: the `gh` CLI is the only backend for these tools. Without it the
1103
+ // extension registers nothing and reports the problem at session start, so
1104
+ // the user gets one clear error instead of a dozen failing tool calls.
1105
+ if (!isGhAvailable()) {
1106
+ pi.on("session_start", (_event, ctx) => {
1107
+ ctx.ui.notify(
1108
+ "gh CLI not found in PATH: GitHub read-only tools are disabled. Install GitHub CLI (https://cli.github.com/) and reload the session.",
1109
+ "error",
1110
+ );
1111
+ });
1112
+ return;
1113
+ }
1114
+
1085
1115
  // ── read-github-issue ──────────────────────────────────────────────────────
1086
1116
  pi.registerTool({
1087
1117
  name: "read-github-issue",