@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 +12 -28
- package/package.json +1 -1
- package/src/bwrap/runtime.ts +16 -14
- package/src/claude-code/common.ts +7 -1
- package/src/claude-code/glob.ts +73 -0
- package/src/claude-code/grep.ts +232 -0
- package/src/claude-code/search.ts +16 -212
- package/src/claude-code/session-tools.ts +24 -14
- package/src/lib/ui.ts +54 -0
- package/src/opencode/question.ts +12 -8
- package/src/spawn-agent.ts +14 -14
package/README.md
CHANGED
|
@@ -6,17 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
## 扩展概览
|
|
8
8
|
|
|
9
|
-
| 扩展
|
|
10
|
-
|
|
|
11
|
-
| [bwrap](#bwrap)
|
|
12
|
-
| [写保护(内置)](#写保护内置)
|
|
13
|
-
| [opencode-edit](#opencode-edit)
|
|
14
|
-
| [
|
|
15
|
-
| [
|
|
16
|
-
| [
|
|
17
|
-
| [
|
|
18
|
-
| [
|
|
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
|
-
|
|
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/
|
|
344
|
+
pi -e ./src/opencode/index.ts
|
|
361
345
|
```
|
|
362
346
|
|
|
363
347
|
---
|
package/package.json
CHANGED
package/src/bwrap/runtime.ts
CHANGED
|
@@ -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
|
|
167
|
+
const result = await selectWithOptionalInput(
|
|
167
168
|
description,
|
|
168
|
-
[
|
|
169
|
-
|
|
170
|
-
|
|
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 (
|
|
177
|
+
if (result === undefined) {
|
|
174
178
|
ctx.abort();
|
|
175
179
|
throw new Error("User denied the command execution.");
|
|
176
180
|
}
|
|
177
|
-
if (
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
? `User denied unsandboxed execution: ${
|
|
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
|
-
|
|
2
|
-
|
|
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 {
|
|
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
|
|
109
|
-
|
|
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
|
|
50
|
+
const result = await selectWithOptionalInput(
|
|
49
51
|
title,
|
|
50
|
-
[
|
|
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 (
|
|
54
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/opencode/question.ts
CHANGED
|
@@ -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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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> {
|
package/src/spawn-agent.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 =
|