@trim21/personal-pi-extensions 0.0.225 → 0.0.230

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
@@ -6,17 +6,16 @@
6
6
 
7
7
  ## 扩展概览
8
8
 
9
- | 扩展 | 描述 |
10
- | --------------------------------------------- | ------------------------------------------------------ |
11
- | [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
12
- | [workspace-guard](#workspace-guard) | 限制文件写入在 workspace 内,外部写入需用户审批 |
13
- | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
14
- | [bash-default-timeout](#bash-default-timeout) | bash 工具设置默认超时(180 秒) |
15
- | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn agent 识别图片 |
16
- | [session-name](#session-name) | 首个 user prompt 自动生成会话名,模型命名 + 启发式兜底 |
17
- | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
18
- | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
19
- | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
9
+ | 扩展 | 描述 |
10
+ | ------------------------------- | ------------------------------------------------------- |
11
+ | [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
12
+ | [写保护(内置)](#写保护内置) | 写工具内置:限制文件写入在 workspace 内,外部写入需审批 |
13
+ | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
14
+ | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn agent 识别图片 |
15
+ | [session-name](#session-name) | 首个 user prompt 自动生成会话名,模型命名 + 启发式兜底 |
16
+ | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
17
+ | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
18
+ | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
20
19
 
21
20
  ---
22
21
 
@@ -79,32 +78,24 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
79
78
 
80
79
  ### 使用
81
80
 
82
- ```bash
83
- pi -e ./src/bwrap/index.ts
84
- # 或通过配置文件注册后自动加载
85
- ```
81
+ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/opencode/bash.ts`,Claude Code 风格 `Bash` 位于 `src/claude-code/shell.ts`),随对应扩展一起加载,无需单独安装。bash 工具内置默认超时 120 秒。
86
82
 
87
83
  ---
88
84
 
89
- ## workspace-guard
85
+ ## 写保护(内置)
90
86
 
91
- 阻止 `write` `edit` 工具写入 workspace 外部的路径。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
87
+ 写保护直接内置在各写工具(opencode 风格 `write`/`edit`、Claude Code 风格 `Write`/`Edit`)内部,通过 `src/lib/write-guard.ts` `guardWriteAccess` 实现。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
92
88
 
93
89
  - workspace 内或 `/tmp` 下的路径自动放行
94
- - 外部路径需通过确认对话框由用户审批,对话框内以 ```diff 代码块展示将要发生的变更预览(与 opencode-edit 共享匹配引擎,能定位时显示带行号的真实 patch,否则退化为参数 diff)
95
- - 无需配置
96
-
97
- ### 使用
98
-
99
- ```bash
100
- pi -e ./src/workspace-guard.ts
101
- ```
90
+ - 外部路径需通过确认对话框由用户审批,对话框内以 ```diff 代码块展示将要发生的变更预览(与 opencode-edit 共享匹配引擎,能定位时显示带行号的真实 patch,否则退化为参数 diff
91
+ - headless(无 UI)会话直接拒绝外部写入
92
+ - 无需配置,随各写工具自动生效
102
93
 
103
94
  ---
104
95
 
105
96
  ## opencode-edit
106
97
 
107
- 替换内置 `edit` 工具,使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数直接复制自 opencode,行为与原版完全一致。匹配引擎位于 `src/opencode-edit-engine.ts`,与 workspace-guard 的审批弹窗 diff 预览共享。
98
+ 替换内置 `edit` 工具,使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数直接复制自 opencode,行为与原版完全一致。匹配引擎位于 `src/opencode/edit-engine.ts`,与写保护审批弹窗(`src/lib/write-guard.ts`)的 diff 预览共享。
108
99
 
109
100
  支持的匹配策略:
110
101
 
@@ -128,18 +119,6 @@ pi -e ./src/opencode-edit.ts
128
119
 
129
120
  ---
130
121
 
131
- ## bash-default-timeout
132
-
133
- 为所有 bash 工具调用设置 180 秒默认超时。仅在模型未显式指定 `timeout` 时生效,避免长时间运行的命令无限挂起。
134
-
135
- ### 使用
136
-
137
- ```bash
138
- pi -e ./src/bash-default-timeout.ts
139
- ```
140
-
141
- ---
142
-
143
122
  ## vision-agent
144
123
 
145
124
  视觉代理扩展。主模型不支持视觉(如 DeepSeek)时自动启用 `describe_image` 工具;主模型支持视觉时自动隐藏,图片由 pi 原生透传。
@@ -362,8 +341,7 @@ pi -e ./src/talk/index.ts
362
341
  ### 命令行加载单个扩展
363
342
 
364
343
  ```bash
365
- pi -e ./src/bwrap/index.ts
366
- pi -e ./src/workspace-guard.ts
344
+ pi -e ./src/opencode/index.ts
367
345
  ```
368
346
 
369
347
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.225",
3
+ "version": "0.0.230",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -54,13 +54,10 @@
54
54
  },
55
55
  "pi": {
56
56
  "extensions": [
57
+ "src/opencode/index.ts",
58
+ "src/claude-code/index.ts",
57
59
  "src/vision-agent.ts",
58
60
  "src/session-name.ts",
59
- "src/bwrap/index.ts",
60
- "src/workspace-guard.ts",
61
- "src/claude-code/index.ts",
62
- "src/opencode/index.ts",
63
- "src/bash-default-timeout.ts",
64
61
  "src/gh-readonly.ts",
65
62
  "src/spawn-agent.ts",
66
63
  "src/talk/index.ts"
@@ -7,6 +7,7 @@ import { createBashTool } from "@earendil-works/pi-coding-agent";
7
7
  import { type TObject, Type } from "typebox";
8
8
 
9
9
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
10
+ import { selectWithOptionalInput } from "../lib/ui.js";
10
11
  import {
11
12
  type BwrapMode,
12
13
  createBwrapBashOperations,
@@ -163,26 +164,27 @@ export class BwrapRuntime {
163
164
  if (policy.kind === "deny") throw new Error(policy.reason);
164
165
  const description = `Allow this command to run without sandbox?\n---\n\nReason: ${escapeHtml(reason ?? "(No reason provided by model)")}\n---\n${fenceCodeBlock(command)}`;
165
166
  while (true) {
166
- const choice = await ctx.ui.select(
167
+ const result = await selectWithOptionalInput(
167
168
  description,
168
- ["Approve once", "Block", "Block with reason"],
169
- {
170
- signal: ctx.signal,
171
- },
169
+ [
170
+ { label: "Approve once" },
171
+ { label: "Block" },
172
+ { label: "Block with reason", inputPrompt: "Why was this denied?" },
173
+ ],
174
+ ctx.ui,
175
+ { signal: ctx.signal },
172
176
  );
173
- if (choice === undefined) {
177
+ if (result === undefined) {
174
178
  ctx.abort();
175
179
  throw new Error("User denied the command execution.");
176
180
  }
177
- if (choice === "Approve once") return;
178
- if (choice === "Block") throw new Error("User denied unsandboxed execution.");
179
- const feedback = await ctx.ui.input("Why was this denied?", undefined, {
180
- signal: ctx.signal,
181
- });
182
- if (feedback === undefined) continue;
181
+ if (result.label === "Approve once") return;
182
+ if (result.label === "Block") throw new Error("User denied unsandboxed execution.");
183
+ // "Block with reason":取消输入则重新询问;空输入同样拒绝(不带反馈文本)
184
+ if (result.input === undefined) continue;
183
185
  throw new Error(
184
- feedback
185
- ? `User denied unsandboxed execution: ${feedback}`
186
+ result.input
187
+ ? `User denied unsandboxed execution: ${result.input}`
186
188
  : "User denied unsandboxed execution.",
187
189
  );
188
190
  }
@@ -1,4 +1,4 @@
1
- import { isAbsolute, normalize } from "node:path";
1
+ import { isAbsolute, normalize, resolve } from "node:path";
2
2
 
3
3
  export interface FileSnapshot {
4
4
  digest: string;
@@ -20,6 +20,12 @@ export function requireAbsolutePath(filePath: string, parameter = "file_path"):
20
20
  return normalize(filePath);
21
21
  }
22
22
 
23
+ /** Resolve a search root path against the working directory. */
24
+ export function searchRoot(path: string | undefined, cwd: string): string {
25
+ if (!path) return cwd;
26
+ return isAbsolute(path) ? path : resolve(cwd, path);
27
+ }
28
+
23
29
  export function throwIfAborted(signal: AbortSignal | undefined): void {
24
30
  if (signal?.aborted) throw new Error("Operation aborted");
25
31
  }
@@ -12,6 +12,7 @@ import {
12
12
  } from "@earendil-works/pi-coding-agent";
13
13
  import { Type } from "typebox";
14
14
 
15
+ import { guardWriteAccess } from "../lib/write-guard.js";
15
16
  import {
16
17
  type ClaudeCodeState,
17
18
  type FileSnapshot,
@@ -241,9 +242,18 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
241
242
  },
242
243
  { additionalProperties: false },
243
244
  ),
244
- async execute(_id, params, signal) {
245
+ async execute(_id, params, signal, _onUpdate, ctx) {
245
246
  throwIfAborted(signal);
246
247
  const filePath = requireAbsolutePath(params.file_path);
248
+ await guardWriteAccess(ctx, {
249
+ toolName: "Edit",
250
+ absolutePath: filePath,
251
+ change: {
252
+ oldText: params.old_string,
253
+ newText: params.new_string,
254
+ replaceAll: params.replace_all,
255
+ },
256
+ });
247
257
  return withFileMutationQueue(filePath, async () => {
248
258
  await requireCurrentRead(state, filePath);
249
259
  await access(filePath, constants.R_OK | constants.W_OK);
@@ -287,9 +297,14 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
287
297
  },
288
298
  { additionalProperties: false },
289
299
  ),
290
- async execute(_id, params, signal) {
300
+ async execute(_id, params, signal, _onUpdate, ctx) {
291
301
  throwIfAborted(signal);
292
302
  const filePath = requireAbsolutePath(params.file_path);
303
+ await guardWriteAccess(ctx, {
304
+ toolName: "Write",
305
+ absolutePath: filePath,
306
+ change: { oldText: "", newText: params.content },
307
+ });
293
308
  return withFileMutationQueue(filePath, async () => {
294
309
  let original: string | undefined;
295
310
  try {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Claude Code style `Glob` tool — fast file pattern matching.
3
+ *
4
+ * Split out of the former search.ts so spawn-agent subagents can load it
5
+ * independently (declare `Glob` in the frontmatter to get only this tool).
6
+ */
7
+
8
+ import { glob as fsGlob, stat } from "node:fs/promises";
9
+ import { resolve } from "node:path";
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { Type } from "typebox";
13
+
14
+ import { searchRoot, throwIfAborted } from "./common.js";
15
+
16
+ const GLOB_RESULT_LIMIT = 100;
17
+
18
+ export async function globFiles(
19
+ pattern: string,
20
+ cwd: string,
21
+ signal?: AbortSignal,
22
+ ): Promise<string[]> {
23
+ throwIfAborted(signal);
24
+ const matches: { path: string; mtimeMs: number }[] = [];
25
+ for await (const match of fsGlob(pattern, { cwd, exclude: [".git/**"], withFileTypes: false })) {
26
+ throwIfAborted(signal);
27
+ const absolutePath = resolve(cwd, match);
28
+ try {
29
+ const value = await stat(absolutePath);
30
+ if (value.isFile()) matches.push({ path: absolutePath, mtimeMs: value.mtimeMs });
31
+ } catch {
32
+ // A concurrent filesystem change can remove a match before stat.
33
+ }
34
+ }
35
+ return matches
36
+ .toSorted((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path))
37
+ .slice(0, GLOB_RESULT_LIMIT)
38
+ .map((match) => match.path);
39
+ }
40
+
41
+ export function registerGlobTool(pi: ExtensionAPI): void {
42
+ pi.registerTool({
43
+ name: "Glob",
44
+ label: "Glob",
45
+ description: [
46
+ "Fast file pattern matching tool that works with any codebase size.",
47
+ 'Supports glob patterns such as "**/*.js" and "src/**/*.ts".',
48
+ "Returns matching file paths sorted by modification time.",
49
+ ].join("\n"),
50
+ parameters: Type.Object(
51
+ {
52
+ pattern: Type.String({ description: "The glob pattern to match files against" }),
53
+ path: Type.Optional(
54
+ Type.String({
55
+ description:
56
+ "The directory to search in. If omitted, the current working directory is used.",
57
+ }),
58
+ ),
59
+ },
60
+ { additionalProperties: false },
61
+ ),
62
+ async execute(_id, params, signal, _onUpdate, ctx) {
63
+ const root = searchRoot(params.path, ctx.cwd);
64
+ const matches = await globFiles(params.pattern, root, signal);
65
+ return {
66
+ content: [
67
+ { type: "text", text: matches.length > 0 ? matches.join("\n") : "No files found" },
68
+ ],
69
+ details: { count: matches.length },
70
+ };
71
+ },
72
+ });
73
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Claude Code style `Grep` tool — powerful ripgrep-based search.
3
+ *
4
+ * Split out of the former search.ts so spawn-agent subagents can load it
5
+ * independently (declare `Grep` in the frontmatter to get only this tool).
6
+ */
7
+
8
+ import { StringEnum } from "@earendil-works/pi-ai";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+
12
+ import { searchRoot, throwIfAborted } from "./common.js";
13
+
14
+ const GREP_OUTPUT_MODES = ["content", "files_with_matches", "count"] as const;
15
+
16
+ type GrepOutputMode = (typeof GREP_OUTPUT_MODES)[number];
17
+
18
+ function truncateOutput(output: string, maxCharacters = 30_000): string {
19
+ if (output.length <= maxCharacters) return output;
20
+ return `${output.slice(0, maxCharacters)}\n\n[Output truncated at ${maxCharacters} characters]`;
21
+ }
22
+
23
+ interface GrepParameters {
24
+ pattern: string;
25
+ path?: string;
26
+ glob?: string;
27
+ output_mode?: GrepOutputMode;
28
+ "-B"?: number;
29
+ "-A"?: number;
30
+ "-C"?: number;
31
+ context?: number;
32
+ "-n"?: boolean;
33
+ "-i"?: boolean;
34
+ type?: string;
35
+ head_limit?: number;
36
+ offset?: number;
37
+ multiline?: boolean;
38
+ }
39
+
40
+ export function buildGrepArguments(params: GrepParameters, cwd: string): string[] {
41
+ const mode = params.output_mode ?? "files_with_matches";
42
+ const args = ["--color=never"];
43
+ switch (mode) {
44
+ case "files_with_matches": {
45
+ args.push("--files-with-matches");
46
+ break;
47
+ }
48
+ case "count": {
49
+ args.push("--count-matches");
50
+ break;
51
+ }
52
+ case "content": {
53
+ args.push("--no-heading", "--with-filename");
54
+ if (params["-n"] !== false) args.push("--line-number");
55
+ const before = params["-B"];
56
+ const after = params["-A"];
57
+ const around = params.context ?? params["-C"];
58
+ if (around === undefined) {
59
+ if (before !== undefined) args.push("--before-context", String(before));
60
+ if (after !== undefined) args.push("--after-context", String(after));
61
+ } else args.push("--context", String(around));
62
+
63
+ break;
64
+ }
65
+ // No default
66
+ }
67
+ if (params["-i"] === true) args.push("--ignore-case");
68
+ if (params.glob) args.push("--glob", params.glob);
69
+ if (params.type) args.push("--type", params.type);
70
+ if (params.multiline === true) args.push("--multiline", "--multiline-dotall");
71
+ args.push("--", params.pattern, searchRoot(params.path, cwd));
72
+ return args;
73
+ }
74
+
75
+ export function pageGrepOutput(output: string, offset = 0, headLimit = 0): string {
76
+ const lines = output ? output.replace(/\n$/, "").split("\n") : [];
77
+ if (offset >= lines.length && lines.length > 0) return "No entries at this offset";
78
+ const selected = headLimit > 0 ? lines.slice(offset, offset + headLimit) : lines.slice(offset);
79
+ return truncateOutput(selected.join("\n"));
80
+ }
81
+
82
+ export function registerGrepTool(pi: ExtensionAPI): void {
83
+ pi.registerTool({
84
+ name: "Grep",
85
+ label: "Grep",
86
+ description: [
87
+ "A powerful search tool built on ripgrep.",
88
+ "Supports regular expressions, file globs, file types, multiline matching, context lines, and paginated output.",
89
+ 'output_mode defaults to "files_with_matches"; use "content" for matching lines or "count" for match counts.',
90
+ ].join("\n"),
91
+ parameters: Type.Object(
92
+ {
93
+ pattern: Type.String({ description: "The regular expression pattern to search for" }),
94
+ path: Type.Optional(
95
+ Type.String({
96
+ description: "File or directory to search. Defaults to the current directory.",
97
+ }),
98
+ ),
99
+ glob: Type.Optional(
100
+ Type.String({ description: 'Glob filter such as "*.js" or "*.{ts,tsx}"' }),
101
+ ),
102
+ output_mode: Type.Optional(
103
+ StringEnum(GREP_OUTPUT_MODES, {
104
+ description: "Output mode. Defaults to files_with_matches.",
105
+ }),
106
+ ),
107
+ "-B": Type.Optional(
108
+ Type.Number({ description: "Lines to show before each match in content mode" }),
109
+ ),
110
+ "-A": Type.Optional(
111
+ Type.Number({ description: "Lines to show after each match in content mode" }),
112
+ ),
113
+ "-C": Type.Optional(
114
+ Type.Number({ description: "Lines to show before and after each match" }),
115
+ ),
116
+ context: Type.Optional(
117
+ Type.Number({ description: "Lines to show before and after each match" }),
118
+ ),
119
+ "-n": Type.Optional(
120
+ Type.Boolean({ description: "Show line numbers in content mode; defaults true" }),
121
+ ),
122
+ "-i": Type.Optional(Type.Boolean({ description: "Case-insensitive search" })),
123
+ type: Type.Optional(
124
+ Type.String({ description: "ripgrep file type such as js, py, rust, or go" }),
125
+ ),
126
+ head_limit: Type.Optional(
127
+ Type.Number({ description: "Limit output to the first N entries after offset" }),
128
+ ),
129
+ offset: Type.Optional(Type.Number({ description: "Skip the first N output entries" })),
130
+ multiline: Type.Optional(
131
+ Type.Boolean({ description: "Allow patterns to span multiple lines" }),
132
+ ),
133
+ },
134
+ { additionalProperties: false },
135
+ ),
136
+ async execute(_id, params, signal, _onUpdate, ctx) {
137
+ if (
138
+ params.offset !== undefined &&
139
+ (!Number.isSafeInteger(params.offset) || params.offset < 0)
140
+ ) {
141
+ throw new Error("offset must be a non-negative integer");
142
+ }
143
+ if (
144
+ params.head_limit !== undefined &&
145
+ (!Number.isSafeInteger(params.head_limit) || params.head_limit < 0)
146
+ ) {
147
+ throw new Error("head_limit must be a non-negative integer");
148
+ }
149
+ const result = await pi.exec("rg", buildGrepArguments(params, ctx.cwd), { signal });
150
+ throwIfAborted(signal);
151
+ if (result.code !== 0 && result.code !== 1) {
152
+ throw new Error(result.stderr.trim() || `ripgrep exited with code ${result.code}`);
153
+ }
154
+ if (result.code === 1 || result.stdout === "") {
155
+ return { content: [{ type: "text", text: "No files found" }], details: { matches: 0 } };
156
+ }
157
+ const text = pageGrepOutput(result.stdout, params.offset ?? 0, params.head_limit ?? 0);
158
+ return { content: [{ type: "text", text }], details: undefined };
159
+ },
160
+ });
161
+ }