@trim21/personal-pi-extensions 0.0.229 → 0.0.231

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 内,外部写入需审批 |
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,10 +78,7 @@ 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
 
@@ -123,18 +119,6 @@ pi -e ./src/opencode-edit.ts
123
119
 
124
120
  ---
125
121
 
126
- ## bash-default-timeout
127
-
128
- 为所有 bash 工具调用设置 180 秒默认超时。仅在模型未显式指定 `timeout` 时生效,避免长时间运行的命令无限挂起。
129
-
130
- ### 使用
131
-
132
- ```bash
133
- pi -e ./src/bash-default-timeout.ts
134
- ```
135
-
136
- ---
137
-
138
122
  ## vision-agent
139
123
 
140
124
  视觉代理扩展。主模型不支持视觉(如 DeepSeek)时自动启用 `describe_image` 工具;主模型支持视觉时自动隐藏,图片由 pi 原生透传。
@@ -357,7 +341,7 @@ pi -e ./src/talk/index.ts
357
341
  ### 命令行加载单个扩展
358
342
 
359
343
  ```bash
360
- pi -e ./src/bwrap/index.ts
344
+ pi -e ./src/opencode/index.ts
361
345
  ```
362
346
 
363
347
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.229",
3
+ "version": "0.0.231",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -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
  }
@@ -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,232 @@
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
+ * Parameter semantics and default behavior are aligned with Claude Code's
8
+ * GrepTool (ripgrep-based): hidden files searched, VCS directories excluded,
9
+ * lines capped at 500 columns, and an implicit head_limit of 250 entries.
10
+ */
11
+
12
+ import { stat } from "node:fs/promises";
13
+
14
+ import { StringEnum } from "@earendil-works/pi-ai";
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+
18
+ import { searchRoot, throwIfAborted } from "./common.js";
19
+
20
+ const GREP_OUTPUT_MODES = ["content", "files_with_matches", "count"] as const;
21
+
22
+ type GrepOutputMode = (typeof GREP_OUTPUT_MODES)[number];
23
+
24
+ /** Version control directories excluded from searches (noise in results). */
25
+ const VCS_DIRECTORIES_TO_EXCLUDE = [".git", ".svn", ".hg", ".bzr", ".jj", ".sl"] as const;
26
+
27
+ /**
28
+ * Default cap on results when head_limit is unspecified. Prevents broad
29
+ * patterns from flooding the context; pass head_limit=0 explicitly for
30
+ * unlimited results. Mirrors Claude Code's default of 250.
31
+ */
32
+ const DEFAULT_HEAD_LIMIT = 250;
33
+
34
+ function truncateOutput(output: string, maxCharacters = 30_000): string {
35
+ if (output.length <= maxCharacters) return output;
36
+ return `${output.slice(0, maxCharacters)}\n\n[Output truncated at ${maxCharacters} characters]`;
37
+ }
38
+
39
+ interface GrepParameters {
40
+ pattern: string;
41
+ path?: string;
42
+ glob?: string;
43
+ output_mode?: GrepOutputMode;
44
+ "-B"?: number;
45
+ "-A"?: number;
46
+ "-C"?: number;
47
+ context?: number;
48
+ "-n"?: boolean;
49
+ "-i"?: boolean;
50
+ type?: string;
51
+ head_limit?: number;
52
+ offset?: number;
53
+ multiline?: boolean;
54
+ }
55
+
56
+ export function buildGrepArguments(params: GrepParameters, cwd: string): string[] {
57
+ const mode = params.output_mode ?? "files_with_matches";
58
+ const args = ["--color=never", "--hidden", "--max-columns", "500"];
59
+ for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
60
+ args.push("--glob", `!${dir}`);
61
+ }
62
+ switch (mode) {
63
+ case "files_with_matches": {
64
+ args.push("--files-with-matches");
65
+ break;
66
+ }
67
+ case "count": {
68
+ args.push("--count-matches");
69
+ break;
70
+ }
71
+ case "content": {
72
+ args.push("--no-heading", "--with-filename");
73
+ const showLineNumbers = params["-n"] === true || params["-n"] === undefined;
74
+ if (showLineNumbers) args.push("--line-number");
75
+ const before = params["-B"];
76
+ const after = params["-A"];
77
+ const around = params.context ?? params["-C"];
78
+ if (around === undefined) {
79
+ if (before !== undefined) args.push("--before-context", String(before));
80
+ if (after !== undefined) args.push("--after-context", String(after));
81
+ } else args.push("--context", String(around));
82
+
83
+ break;
84
+ }
85
+ // No default
86
+ }
87
+ if (params["-i"] === true) args.push("--ignore-case");
88
+ if (params.glob) args.push("--glob", params.glob);
89
+ if (params.type) args.push("--type", params.type);
90
+ if (params.multiline === true) args.push("--multiline", "--multiline-dotall");
91
+ args.push("--", params.pattern, searchRoot(params.path, cwd));
92
+ return args;
93
+ }
94
+
95
+ /**
96
+ * Sort a newline-separated list of file paths by modification time, most
97
+ * recent first, with file name as a tiebreaker. Files that can no longer be
98
+ * stat-ed sort last (mtime 0). Used for files_with_matches output, matching
99
+ * Claude Code's behaviour.
100
+ */
101
+ export async function sortFilesByMtime(output: string): Promise<string> {
102
+ const paths = output.replace(/\n$/, "").split("\n").filter(Boolean);
103
+ if (paths.length <= 1) return output;
104
+ const stats = await Promise.allSettled(paths.map((path) => stat(path)));
105
+ const sorted = paths
106
+ .map((path, index) => ({
107
+ path,
108
+ mtimeMs: stats[index]?.status === "fulfilled" ? stats[index].value.mtimeMs : 0,
109
+ }))
110
+ .toSorted((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path))
111
+ .map((entry) => entry.path);
112
+ return sorted.join("\n");
113
+ }
114
+
115
+ /**
116
+ * Append an occurrence/file summary to `filename:count` output (count mode),
117
+ * mirroring Claude Code's "Found N occurrences across M files" result.
118
+ */
119
+ export function summarizeCountOutput(output: string): string {
120
+ const lines = output.split("\n").filter((line) => line.includes(":"));
121
+ let occurrences = 0;
122
+ for (const line of lines) {
123
+ const colon = line.lastIndexOf(":");
124
+ occurrences += Number(line.slice(colon + 1)) || 0;
125
+ }
126
+ const files = lines.length;
127
+ const occurrenceLabel = occurrences === 1 ? "occurrence" : "occurrences";
128
+ const fileLabel = files === 1 ? "file" : "files";
129
+ return `${output.trimEnd()}\n\nFound ${occurrences} total ${occurrenceLabel} across ${files} ${fileLabel}.`;
130
+ }
131
+
132
+ export function pageGrepOutput(output: string, offset = 0, headLimit = 0): string {
133
+ const lines = output ? output.replace(/\n$/, "").split("\n") : [];
134
+ if (offset >= lines.length && lines.length > 0) return "No entries at this offset";
135
+ const selected = headLimit > 0 ? lines.slice(offset, offset + headLimit) : lines.slice(offset);
136
+ return truncateOutput(selected.join("\n"));
137
+ }
138
+
139
+ export function registerGrepTool(pi: ExtensionAPI): void {
140
+ pi.registerTool({
141
+ name: "Grep",
142
+ label: "Grep",
143
+ description: [
144
+ "A powerful search tool built on ripgrep.",
145
+ "Supports regular expressions, file globs, file types, multiline matching, context lines, and paginated output.",
146
+ 'output_mode defaults to "files_with_matches"; use "content" for matching lines or "count" for match counts.',
147
+ ].join("\n"),
148
+ parameters: Type.Object(
149
+ {
150
+ pattern: Type.String({ description: "The regular expression pattern to search for" }),
151
+ path: Type.Optional(
152
+ Type.String({
153
+ description: "File or directory to search. Defaults to the current directory.",
154
+ }),
155
+ ),
156
+ glob: Type.Optional(
157
+ Type.String({ description: 'Glob filter such as "*.js" or "*.{ts,tsx}"' }),
158
+ ),
159
+ output_mode: Type.Optional(
160
+ StringEnum(GREP_OUTPUT_MODES, {
161
+ description: "Output mode. Defaults to files_with_matches.",
162
+ }),
163
+ ),
164
+ "-B": Type.Optional(
165
+ Type.Number({ description: "Lines to show before each match in content mode" }),
166
+ ),
167
+ "-A": Type.Optional(
168
+ Type.Number({ description: "Lines to show after each match in content mode" }),
169
+ ),
170
+ "-C": Type.Optional(
171
+ Type.Number({ description: "Lines to show before and after each match" }),
172
+ ),
173
+ context: Type.Optional(
174
+ Type.Number({ description: "Lines to show before and after each match" }),
175
+ ),
176
+ "-n": Type.Optional(
177
+ Type.Boolean({ description: "Show line numbers in content mode; defaults true" }),
178
+ ),
179
+ "-i": Type.Optional(Type.Boolean({ description: "Case-insensitive search" })),
180
+ type: Type.Optional(
181
+ Type.String({ description: "ripgrep file type such as js, py, rust, or go" }),
182
+ ),
183
+ head_limit: Type.Optional(
184
+ Type.Number({ description: "Limit output to the first N entries after offset" }),
185
+ ),
186
+ offset: Type.Optional(Type.Number({ description: "Skip the first N output entries" })),
187
+ multiline: Type.Optional(
188
+ Type.Boolean({ description: "Allow patterns to span multiple lines" }),
189
+ ),
190
+ },
191
+ { additionalProperties: false },
192
+ ),
193
+ async execute(_id, params, signal, _onUpdate, ctx) {
194
+ if (
195
+ params.offset !== undefined &&
196
+ (!Number.isSafeInteger(params.offset) || params.offset < 0)
197
+ ) {
198
+ throw new Error("offset must be a non-negative integer");
199
+ }
200
+ if (
201
+ params.head_limit !== undefined &&
202
+ (!Number.isSafeInteger(params.head_limit) || params.head_limit < 0)
203
+ ) {
204
+ throw new Error("head_limit must be a non-negative integer");
205
+ }
206
+ const result = await pi.exec("rg", buildGrepArguments(params, ctx.cwd), { signal });
207
+ throwIfAborted(signal);
208
+ if (result.code !== 0 && result.code !== 1) {
209
+ throw new Error(result.stderr.trim() || `ripgrep exited with code ${result.code}`);
210
+ }
211
+ if (result.code === 1 || result.stdout === "") {
212
+ return { content: [{ type: "text", text: "No files found" }], details: { matches: 0 } };
213
+ }
214
+ const mode = params.output_mode ?? "files_with_matches";
215
+ // head_limit defaults to DEFAULT_HEAD_LIMIT; an explicit 0 means unlimited.
216
+ const stdout =
217
+ mode === "files_with_matches" ? await sortFilesByMtime(result.stdout) : result.stdout;
218
+ const text = pageGrepOutput(
219
+ stdout,
220
+ params.offset ?? 0,
221
+ params.head_limit ?? DEFAULT_HEAD_LIMIT,
222
+ );
223
+ if (mode === "count") {
224
+ return {
225
+ content: [{ type: "text", text: summarizeCountOutput(text) }],
226
+ details: undefined,
227
+ };
228
+ }
229
+ return { content: [{ type: "text", text }], details: undefined };
230
+ },
231
+ });
232
+ }
@@ -1,218 +1,22 @@
1
- import { glob as fsGlob, stat } from "node:fs/promises";
2
- import { isAbsolute, resolve } from "node:path";
1
+ /**
2
+ * Claude Code style search tools — `Glob` and `Grep`.
3
+ *
4
+ * Aggregation entry that registers both tools; each tool also lives in its own
5
+ * file (glob.ts / grep.ts) so spawn-agent subagents can load them
6
+ * independently via the `Glob` / `Grep` frontmatter tool names.
7
+ */
3
8
 
4
- import { StringEnum } from "@earendil-works/pi-ai";
5
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "typebox";
7
10
 
8
- import { throwIfAborted } from "./common.js";
9
-
10
- const GLOB_RESULT_LIMIT = 100;
11
- const GREP_OUTPUT_MODES = ["content", "files_with_matches", "count"] as const;
12
-
13
- type GrepOutputMode = (typeof GREP_OUTPUT_MODES)[number];
14
-
15
- function searchRoot(path: string | undefined, cwd: string): string {
16
- if (!path) return cwd;
17
- return isAbsolute(path) ? path : resolve(cwd, path);
18
- }
19
-
20
- function truncateOutput(output: string, maxCharacters = 30_000): string {
21
- if (output.length <= maxCharacters) return output;
22
- return `${output.slice(0, maxCharacters)}\n\n[Output truncated at ${maxCharacters} characters]`;
23
- }
24
-
25
- export async function globFiles(
26
- pattern: string,
27
- cwd: string,
28
- signal?: AbortSignal,
29
- ): Promise<string[]> {
30
- throwIfAborted(signal);
31
- const matches: { path: string; mtimeMs: number }[] = [];
32
- for await (const match of fsGlob(pattern, { cwd, exclude: [".git/**"], withFileTypes: false })) {
33
- throwIfAborted(signal);
34
- const absolutePath = resolve(cwd, match);
35
- try {
36
- const value = await stat(absolutePath);
37
- if (value.isFile()) matches.push({ path: absolutePath, mtimeMs: value.mtimeMs });
38
- } catch {
39
- // A concurrent filesystem change can remove a match before stat.
40
- }
41
- }
42
- return matches
43
- .toSorted((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path))
44
- .slice(0, GLOB_RESULT_LIMIT)
45
- .map((match) => match.path);
46
- }
47
-
48
- interface GrepParameters {
49
- pattern: string;
50
- path?: string;
51
- glob?: string;
52
- output_mode?: GrepOutputMode;
53
- "-B"?: number;
54
- "-A"?: number;
55
- "-C"?: number;
56
- context?: number;
57
- "-n"?: boolean;
58
- "-i"?: boolean;
59
- type?: string;
60
- head_limit?: number;
61
- offset?: number;
62
- multiline?: boolean;
63
- }
64
-
65
- export function buildGrepArguments(params: GrepParameters, cwd: string): string[] {
66
- const mode = params.output_mode ?? "files_with_matches";
67
- const args = ["--color=never"];
68
- switch (mode) {
69
- case "files_with_matches": {
70
- args.push("--files-with-matches");
71
- break;
72
- }
73
- case "count": {
74
- args.push("--count-matches");
75
- break;
76
- }
77
- case "content": {
78
- args.push("--no-heading", "--with-filename");
79
- if (params["-n"] !== false) args.push("--line-number");
80
- const before = params["-B"];
81
- const after = params["-A"];
82
- const around = params.context ?? params["-C"];
83
- if (around === undefined) {
84
- if (before !== undefined) args.push("--before-context", String(before));
85
- if (after !== undefined) args.push("--after-context", String(after));
86
- } else args.push("--context", String(around));
87
-
88
- break;
89
- }
90
- // No default
91
- }
92
- if (params["-i"] === true) args.push("--ignore-case");
93
- if (params.glob) args.push("--glob", params.glob);
94
- if (params.type) args.push("--type", params.type);
95
- if (params.multiline === true) args.push("--multiline", "--multiline-dotall");
96
- args.push("--", params.pattern, searchRoot(params.path, cwd));
97
- return args;
98
- }
99
-
100
- export function pageGrepOutput(output: string, offset = 0, headLimit = 0): string {
101
- const lines = output ? output.replace(/\n$/, "").split("\n") : [];
102
- if (offset >= lines.length && lines.length > 0) return "No entries at this offset";
103
- const selected = headLimit > 0 ? lines.slice(offset, offset + headLimit) : lines.slice(offset);
104
- return truncateOutput(selected.join("\n"));
105
- }
11
+ import { registerGlobTool } from "./glob.js";
12
+ import { registerGrepTool } from "./grep.js";
106
13
 
107
14
  export function registerSearchTools(pi: ExtensionAPI): void {
108
- pi.registerTool({
109
- name: "Glob",
110
- label: "Glob",
111
- description: [
112
- "Fast file pattern matching tool that works with any codebase size.",
113
- 'Supports glob patterns such as "**/*.js" and "src/**/*.ts".',
114
- "Returns matching file paths sorted by modification time.",
115
- ].join("\n"),
116
- parameters: Type.Object(
117
- {
118
- pattern: Type.String({ description: "The glob pattern to match files against" }),
119
- path: Type.Optional(
120
- Type.String({
121
- description:
122
- "The directory to search in. If omitted, the current working directory is used.",
123
- }),
124
- ),
125
- },
126
- { additionalProperties: false },
127
- ),
128
- async execute(_id, params, signal, _onUpdate, ctx) {
129
- const root = searchRoot(params.path, ctx.cwd);
130
- const matches = await globFiles(params.pattern, root, signal);
131
- return {
132
- content: [
133
- { type: "text", text: matches.length > 0 ? matches.join("\n") : "No files found" },
134
- ],
135
- details: { count: matches.length },
136
- };
137
- },
138
- });
139
-
140
- pi.registerTool({
141
- name: "Grep",
142
- label: "Grep",
143
- description: [
144
- "A powerful search tool built on ripgrep.",
145
- "Supports regular expressions, file globs, file types, multiline matching, context lines, and paginated output.",
146
- 'output_mode defaults to "files_with_matches"; use "content" for matching lines or "count" for match counts.',
147
- ].join("\n"),
148
- parameters: Type.Object(
149
- {
150
- pattern: Type.String({ description: "The regular expression pattern to search for" }),
151
- path: Type.Optional(
152
- Type.String({
153
- description: "File or directory to search. Defaults to the current directory.",
154
- }),
155
- ),
156
- glob: Type.Optional(
157
- Type.String({ description: 'Glob filter such as "*.js" or "*.{ts,tsx}"' }),
158
- ),
159
- output_mode: Type.Optional(
160
- StringEnum(GREP_OUTPUT_MODES, {
161
- description: "Output mode. Defaults to files_with_matches.",
162
- }),
163
- ),
164
- "-B": Type.Optional(
165
- Type.Number({ description: "Lines to show before each match in content mode" }),
166
- ),
167
- "-A": Type.Optional(
168
- Type.Number({ description: "Lines to show after each match in content mode" }),
169
- ),
170
- "-C": Type.Optional(
171
- Type.Number({ description: "Lines to show before and after each match" }),
172
- ),
173
- context: Type.Optional(
174
- Type.Number({ description: "Lines to show before and after each match" }),
175
- ),
176
- "-n": Type.Optional(
177
- Type.Boolean({ description: "Show line numbers in content mode; defaults true" }),
178
- ),
179
- "-i": Type.Optional(Type.Boolean({ description: "Case-insensitive search" })),
180
- type: Type.Optional(
181
- Type.String({ description: "ripgrep file type such as js, py, rust, or go" }),
182
- ),
183
- head_limit: Type.Optional(
184
- Type.Number({ description: "Limit output to the first N entries after offset" }),
185
- ),
186
- offset: Type.Optional(Type.Number({ description: "Skip the first N output entries" })),
187
- multiline: Type.Optional(
188
- Type.Boolean({ description: "Allow patterns to span multiple lines" }),
189
- ),
190
- },
191
- { additionalProperties: false },
192
- ),
193
- async execute(_id, params, signal, _onUpdate, ctx) {
194
- if (
195
- params.offset !== undefined &&
196
- (!Number.isSafeInteger(params.offset) || params.offset < 0)
197
- ) {
198
- throw new Error("offset must be a non-negative integer");
199
- }
200
- if (
201
- params.head_limit !== undefined &&
202
- (!Number.isSafeInteger(params.head_limit) || params.head_limit < 0)
203
- ) {
204
- throw new Error("head_limit must be a non-negative integer");
205
- }
206
- const result = await pi.exec("rg", buildGrepArguments(params, ctx.cwd), { signal });
207
- throwIfAborted(signal);
208
- if (result.code !== 0 && result.code !== 1) {
209
- throw new Error(result.stderr.trim() || `ripgrep exited with code ${result.code}`);
210
- }
211
- if (result.code === 1 || result.stdout === "") {
212
- return { content: [{ type: "text", text: "No files found" }], details: { matches: 0 } };
213
- }
214
- const text = pageGrepOutput(result.stdout, params.offset ?? 0, params.head_limit ?? 0);
215
- return { content: [{ type: "text", text }], details: undefined };
216
- },
217
- });
15
+ registerGlobTool(pi);
16
+ registerGrepTool(pi);
218
17
  }
18
+
19
+ // Re-exported for tests and other modules that import pure functions from
20
+ // "./search.js"; the implementations live in the split files above.
21
+ export { globFiles } from "./glob.js";
22
+ export { buildGrepArguments, pageGrepOutput } from "./grep.js";
@@ -2,6 +2,8 @@ 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
4
 
5
+ import { selectWithOptionalInput } from "../lib/ui.js";
6
+
5
7
  const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
6
8
  const OTHER_OPTION = "Other";
7
9
  const DONE_OPTION = "Done";
@@ -45,15 +47,17 @@ async function askSingle(
45
47
  signal: AbortSignal | undefined,
46
48
  ): Promise<string> {
47
49
  const title = `${question.header}: ${question.question}`;
48
- const selected = await ctx.ui.select(
50
+ const result = await selectWithOptionalInput(
49
51
  title,
50
- [...question.options.map((option) => option.label), OTHER_OPTION],
52
+ [
53
+ ...question.options.map((option) => ({ label: option.label })),
54
+ { label: OTHER_OPTION, inputPrompt: "Type your answer" },
55
+ ],
56
+ ctx.ui,
51
57
  { signal },
52
58
  );
53
- if (selected === undefined) return "Unanswered";
54
- if (selected !== OTHER_OPTION) return selected;
55
- const answer = await ctx.ui.input(title, "Type your answer", { signal });
56
- return answer?.trim() || "Unanswered";
59
+ if (result === undefined) return "Unanswered";
60
+ return result.prompted ? result.input || "Unanswered" : result.label;
57
61
  }
58
62
 
59
63
  async function askMultiple(
@@ -65,16 +69,22 @@ async function askMultiple(
65
69
  const remaining = new Set(question.options.map((option) => option.label));
66
70
  const selected: string[] = [];
67
71
  while (remaining.size > 0) {
68
- const choice = await ctx.ui.select(title, [...remaining, OTHER_OPTION, DONE_OPTION], {
69
- signal,
70
- });
71
- if (choice === undefined || choice === DONE_OPTION) break;
72
- if (choice === OTHER_OPTION) {
73
- const answer = await ctx.ui.input(title, "Type your answer", { signal });
74
- if (answer?.trim()) selected.push(answer.trim());
72
+ const result = await selectWithOptionalInput(
73
+ title,
74
+ [
75
+ ...[...remaining].map((label) => ({ label })),
76
+ { label: OTHER_OPTION, inputPrompt: "Type your answer" },
77
+ { label: DONE_OPTION },
78
+ ],
79
+ ctx.ui,
80
+ { signal },
81
+ );
82
+ if (result === undefined || result.label === DONE_OPTION) break;
83
+ if (result.prompted) {
84
+ if (result.input) selected.push(result.input);
75
85
  break;
76
86
  }
77
- if (remaining.delete(choice)) selected.push(choice);
87
+ if (remaining.delete(result.label)) selected.push(result.label);
78
88
  }
79
89
  return selected.length > 0 ? selected.join(", ") : "Unanswered";
80
90
  }
package/src/lib/ui.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared interactive UI helpers used by multiple extensions.
3
+ */
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+
7
+ export interface SelectAction {
8
+ label: string;
9
+ /**
10
+ * Set to turn this entry into a free-text input: picking it opens an input
11
+ * dialog instead of returning immediately. The typed text (trimmed) is
12
+ * reported via `SelectActionResult.input`; `undefined` means the input
13
+ * dialog was cancelled, `""` that an empty value was submitted.
14
+ */
15
+ inputPrompt?: string;
16
+ }
17
+
18
+ export interface SelectActionResult {
19
+ label: string;
20
+ /** Whether the picked action carries an inputPrompt (false for plain actions) */
21
+ prompted: boolean;
22
+ /** Meaningful when prompted: undefined=cancelled input, ""=blank submission, otherwise the trimmed text */
23
+ input?: string;
24
+ }
25
+
26
+ /**
27
+ * Combined select + optional input dialog.
28
+ *
29
+ * Shows a selection list built from `actions`. If the user picks an action
30
+ * with `inputPrompt`, an input dialog opens for free-text entry. Returns
31
+ * `undefined` when the selection list is dismissed.
32
+ */
33
+ export async function selectWithOptionalInput(
34
+ title: string,
35
+ actions: readonly SelectAction[],
36
+ ui: ExtensionContext["ui"],
37
+ opts: { signal?: AbortSignal } = {},
38
+ ): Promise<SelectActionResult | undefined> {
39
+ const { signal } = opts;
40
+ const choice = await ui.select(
41
+ title,
42
+ actions.map((action) => action.label),
43
+ { signal },
44
+ );
45
+ if (choice === undefined) return undefined;
46
+ const action = actions.find((candidate) => candidate.label === choice);
47
+ if (action?.inputPrompt === undefined) return { label: choice, prompted: false };
48
+ const answer = await ui.input(title, action.inputPrompt, { signal });
49
+ return {
50
+ label: choice,
51
+ prompted: true,
52
+ input: answer === undefined ? undefined : answer.trim(),
53
+ };
54
+ }
@@ -23,6 +23,8 @@
23
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
 
26
+ import { selectWithOptionalInput } from "../lib/ui.js";
27
+
26
28
  // ── constants ────────────────────────────────────────────────────────────────
27
29
 
28
30
  export const TOOL_NAME = "question";
@@ -117,14 +119,16 @@ function dialogTitle(q: Question): string {
117
119
 
118
120
  async function askSingle(q: Question, ctx: ExtensionContext): Promise<Answer> {
119
121
  const title = dialogTitle(q);
120
- const options = [...q.options.map((o) => o.label), CUSTOM_LABEL];
121
- const choice = await ctx.ui.select(title, options);
122
- if (choice === undefined) return [];
123
- if (choice === CUSTOM_LABEL) {
124
- const typed = await ctx.ui.input(title, "Type your answer…");
125
- return typed?.trim() ? [typed.trim()] : [];
126
- }
127
- return [choice];
122
+ const result = await selectWithOptionalInput(
123
+ title,
124
+ [
125
+ ...q.options.map((o) => ({ label: o.label })),
126
+ { label: CUSTOM_LABEL, inputPrompt: "Type your answer…" },
127
+ ],
128
+ ctx.ui,
129
+ );
130
+ if (result === undefined) return [];
131
+ return result.prompted ? (result.input ? [result.input] : []) : [result.label];
128
132
  }
129
133
 
130
134
  async function askMultiple(q: Question, ctx: ExtensionContext): Promise<Answer> {
@@ -49,22 +49,28 @@ const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
49
49
  /** Progress log keeps only the most recent lines (rolling window). */
50
50
  const MAX_PROGRESS_LINES = 5;
51
51
 
52
- /**
53
- * Extensions loaded unconditionally into every subagent: the bwrap sandbox is a
54
- * protection layer and must not depend on the agent's declared toolset.
55
- * (Workspace write protection is embedded in the opencode write/edit tools.)
56
- */
57
- const UNCONDITIONAL_EXTENSIONS = ["bwrap/index.ts"] as const;
58
-
59
52
  /**
60
53
  * Tool → extension override map: when a subagent's frontmatter enables a
61
54
  * built-in tool, the matching opencode extension is loaded via `-e` so the
62
55
  * subagent uses the enhanced implementation instead of the built-in one.
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
60
+ * without bash need no bwrap setup (there are no commands to sandbox).
61
+ * (Workspace write protection is embedded in the opencode write/edit tools.)
62
+ *
63
+ * Claude Code style tools (capitalized names, e.g. `Grep`/`Glob`) map to
64
+ * their split claude-code files, so a subagent can enable exactly the search
65
+ * tools it declares — e.g. `Grep` without `Glob`.
63
66
  */
64
67
  const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
65
68
  read: "opencode/read.ts",
66
69
  edit: "opencode/edit.ts",
67
70
  write: "opencode/write.ts",
71
+ bash: "opencode/bash.ts",
72
+ Grep: "claude-code/grep.ts",
73
+ Glob: "claude-code/glob.ts",
68
74
  };
69
75
 
70
76
  // ── schema ───────────────────────────────────────────────────────────────────
@@ -185,15 +191,9 @@ export function buildSubagentArgs(
185
191
  // dialog responses over stdin. --no-session keeps the child ephemeral.
186
192
  // --no-extensions disables
187
193
  // extension discovery; only the extensions explicitly loaded below (the
188
- // unconditional guards plus per-tool overrides) run inside the subagent.
194
+ // per-tool overrides) run inside the subagent.
189
195
  const args: string[] = ["--mode", "rpc", "--no-session", "--no-extensions"];
190
196
 
191
- // Protection layers that must be present in every subagent regardless of
192
- // its declared toolset: the workspace write guard and the bwrap sandbox.
193
- for (const ext of UNCONDITIONAL_EXTENSIONS) {
194
- args.push("-e", extensionPath(ext));
195
- }
196
-
197
197
  // Thinking level rides on the model shorthand ("model:level"); it cannot be
198
198
  // set without a model, so a level without a model is ignored.
199
199
  const model =