@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 +18 -40
- package/package.json +3 -6
- package/src/bwrap/runtime.ts +16 -14
- package/src/claude-code/common.ts +7 -1
- package/src/claude-code/files.ts +17 -2
- package/src/claude-code/glob.ts +73 -0
- package/src/claude-code/grep.ts +161 -0
- package/src/claude-code/search.ts +16 -212
- package/src/claude-code/session-tools.ts +24 -14
- package/src/claude-code/shell.ts +1 -0
- package/src/lib/ui.ts +54 -0
- package/src/lib/write-guard.ts +153 -0
- package/src/opencode/bash.ts +5 -1
- package/src/opencode/edit-engine.ts +2 -2
- package/src/opencode/edit.ts +8 -1
- package/src/opencode/index.ts +1 -1
- package/src/opencode/question.ts +12 -8
- package/src/opencode/write.ts +6 -0
- package/src/spawn-agent.ts +14 -14
- package/src/bash-default-timeout.ts +0 -10
- package/src/bwrap/index.ts +0 -15
- package/src/workspace-guard.ts +0 -253
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 workspace write
|
|
54
|
-
* guard and the bwrap sandbox. Both are protection layers and must not depend
|
|
55
|
-
* on the agent's declared toolset.
|
|
56
|
-
*/
|
|
57
|
-
const UNCONDITIONAL_EXTENSIONS = ["workspace-guard.ts", "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 =
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
export default function bashDefaultTimeout(pi: ExtensionAPI) {
|
|
4
|
-
pi.on("tool_call", (event) => {
|
|
5
|
-
// opencode 风格 bash 的 timeout 单位是毫秒
|
|
6
|
-
if (isToolCallEventType("bash", event) && event.input.timeout === undefined) {
|
|
7
|
-
event.input.timeout = 180_000;
|
|
8
|
-
}
|
|
9
|
-
});
|
|
10
|
-
}
|
package/src/bwrap/index.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
import { bwrapRuntime } from "./runtime.js";
|
|
4
|
-
|
|
5
|
-
export {
|
|
6
|
-
type BwrapConfig,
|
|
7
|
-
resolveBwrap,
|
|
8
|
-
type ResolvedBwrap,
|
|
9
|
-
resolveHeadlessBwrap,
|
|
10
|
-
} from "./core.js";
|
|
11
|
-
export { type EscalationDecision, resolveEscalation } from "./runtime.js";
|
|
12
|
-
|
|
13
|
-
export default function bwrapExtension(pi: ExtensionAPI): void {
|
|
14
|
-
bwrapRuntime.setup(pi);
|
|
15
|
-
}
|
package/src/workspace-guard.ts
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workspace Guard Extension
|
|
3
|
-
*
|
|
4
|
-
* File-modifying tools (write, edit) are gated:
|
|
5
|
-
* - Paths inside the workspace or /tmp are auto-allowed.
|
|
6
|
-
* - Paths outside require user approval via confirmation dialog.
|
|
7
|
-
* The dialog shows a `diff` code block preview of the pending change.
|
|
8
|
-
*
|
|
9
|
-
* Read tools (read, ls, find, grep) are unrestricted.
|
|
10
|
-
*
|
|
11
|
-
* Usage:
|
|
12
|
-
* pi -e workspace-guard
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { readFile } from "node:fs/promises";
|
|
16
|
-
import { basename, isAbsolute, relative, sep } from "node:path";
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
type ExtensionAPI,
|
|
20
|
-
generateUnifiedPatch,
|
|
21
|
-
type ToolCallEvent,
|
|
22
|
-
} from "@earendil-works/pi-coding-agent";
|
|
23
|
-
|
|
24
|
-
import { resolveHomePath } from "./lib/path.js";
|
|
25
|
-
import { normalizeForEdit, replace } from "./opencode/edit-engine.js";
|
|
26
|
-
|
|
27
|
-
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
28
|
-
const ALWAYS_ALLOW = ["/tmp"];
|
|
29
|
-
|
|
30
|
-
/** Maximum lines of the diff preview shown in the approval dialog. */
|
|
31
|
-
const MAX_PREVIEW_LINES = 100;
|
|
32
|
-
|
|
33
|
-
export function getWriteTarget(input: ToolCallEvent["input"]): string | undefined {
|
|
34
|
-
if (typeof input !== "object" || input === null) return undefined;
|
|
35
|
-
if ("path" in input && typeof input.path === "string") return input.path;
|
|
36
|
-
if ("filePath" in input && typeof input.filePath === "string") return input.filePath;
|
|
37
|
-
return undefined;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isInside(dir: string, filePath: string): boolean {
|
|
41
|
-
const rel = relative(dir, filePath);
|
|
42
|
-
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function isPathAllowed(resolvedPath: string, cwd: string): boolean {
|
|
46
|
-
if (isInside(cwd, resolvedPath)) return true;
|
|
47
|
-
|
|
48
|
-
for (const allowed of ALWAYS_ALLOW) {
|
|
49
|
-
const resolvedAllowed = resolveHomePath(allowed, cwd);
|
|
50
|
-
if (isInside(resolvedAllowed, resolvedPath)) return true;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Type guard for built-in edit entries `{ oldText, newText }`.
|
|
58
|
-
*/
|
|
59
|
-
function isEditPair(value: unknown): value is { oldText: string; newText: string } {
|
|
60
|
-
return (
|
|
61
|
-
typeof value === "object" &&
|
|
62
|
-
value !== null &&
|
|
63
|
-
"oldText" in value &&
|
|
64
|
-
typeof value.oldText === "string" &&
|
|
65
|
-
"newText" in value &&
|
|
66
|
-
typeof value.newText === "string"
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Extract the opencode-style edit pair `{ oldString, newString, replaceAll }`.
|
|
72
|
-
*/
|
|
73
|
-
function getOpencodeEditPair(
|
|
74
|
-
input: ToolCallEvent["input"],
|
|
75
|
-
): { oldString: string; newString: string; replaceAll: boolean } | undefined {
|
|
76
|
-
if (typeof input !== "object" || input === null) return undefined;
|
|
77
|
-
if (!("oldString" in input) || !("newString" in input)) return undefined;
|
|
78
|
-
if (typeof input.oldString !== "string" || typeof input.newString !== "string") return undefined;
|
|
79
|
-
return {
|
|
80
|
-
oldString: input.oldString,
|
|
81
|
-
newString: input.newString,
|
|
82
|
-
replaceAll: "replaceAll" in input && input.replaceAll === true,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Apply the pending write/edit to `content`.
|
|
88
|
-
* Returns undefined when the change cannot be applied (missing fields or oldText not found).
|
|
89
|
-
*/
|
|
90
|
-
function applyChange(
|
|
91
|
-
toolName: string,
|
|
92
|
-
input: ToolCallEvent["input"],
|
|
93
|
-
content: string,
|
|
94
|
-
): string | undefined {
|
|
95
|
-
if (typeof input !== "object" || input === null) return undefined;
|
|
96
|
-
|
|
97
|
-
if (toolName === "write") {
|
|
98
|
-
return "content" in input && typeof input.content === "string" ? input.content : undefined;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Built-in edit: { path, edits: [{ oldText, newText }] }
|
|
102
|
-
if ("edits" in input && Array.isArray(input.edits)) {
|
|
103
|
-
let next = content;
|
|
104
|
-
for (const edit of input.edits) {
|
|
105
|
-
if (!isEditPair(edit)) return undefined;
|
|
106
|
-
if (!next.includes(edit.oldText)) return undefined;
|
|
107
|
-
next = next.replace(edit.oldText, () => edit.newText);
|
|
108
|
-
}
|
|
109
|
-
return next;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Wrap patch text in a `diff` code block, truncating very large diffs. */
|
|
116
|
-
function wrapDiff(patch: string): string {
|
|
117
|
-
const lines = patch.split("\n");
|
|
118
|
-
if (lines.length > MAX_PREVIEW_LINES) {
|
|
119
|
-
const truncated = lines.slice(0, MAX_PREVIEW_LINES).join("\n");
|
|
120
|
-
return `\`\`\`diff\n${truncated}\n… (preview truncated to ${MAX_PREVIEW_LINES} lines)\n\`\`\``;
|
|
121
|
-
}
|
|
122
|
-
return `\`\`\`diff\n${patch}\n\`\`\``;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Build a `diff` code block preview of the pending change.
|
|
127
|
-
* Returns undefined when the diff cannot be computed.
|
|
128
|
-
*/
|
|
129
|
-
export async function buildDiffPreview(
|
|
130
|
-
toolName: string,
|
|
131
|
-
input: ToolCallEvent["input"],
|
|
132
|
-
resolvedPath: string,
|
|
133
|
-
): Promise<string | undefined> {
|
|
134
|
-
let oldContent = "";
|
|
135
|
-
try {
|
|
136
|
-
oldContent = await readFile(resolvedPath, "utf8");
|
|
137
|
-
} catch {
|
|
138
|
-
// Unreadable or missing file: treat as empty so writes show as full additions.
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// opencode-edit: reuse the real matching engine to locate oldString, giving a
|
|
142
|
-
// line-numbered patch when it matches. Fall back to a parameter diff when the
|
|
143
|
-
// edit cannot be applied (oldString not found, ambiguous, or no file).
|
|
144
|
-
const pair = getOpencodeEditPair(input);
|
|
145
|
-
if (pair) {
|
|
146
|
-
try {
|
|
147
|
-
const normalized = normalizeForEdit(oldContent);
|
|
148
|
-
const newContent = replace(normalized, pair.oldString, pair.newString, pair.replaceAll);
|
|
149
|
-
// The full path is shown in the dialog title, so the patch header only
|
|
150
|
-
// carries the file name.
|
|
151
|
-
return wrapDiff(generateUnifiedPatch(basename(resolvedPath), normalized, newContent, 2));
|
|
152
|
-
} catch {
|
|
153
|
-
const removed = pair.oldString.split("\n").map((line) => `-${line}`);
|
|
154
|
-
const added = pair.newString.split("\n").map((line) => `+${line}`);
|
|
155
|
-
return wrapDiff([...removed, ...added].join("\n"));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const newContent = applyChange(toolName, input, oldContent);
|
|
160
|
-
if (newContent === undefined) return undefined;
|
|
161
|
-
|
|
162
|
-
return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, newContent, 2));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export interface WriteGuardDecision {
|
|
166
|
-
block: boolean;
|
|
167
|
-
reason?: string;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Decide how to handle a write outside the workspace.
|
|
172
|
-
*/
|
|
173
|
-
export function decideOutsideWorkspaceWrite(
|
|
174
|
-
rawPath: string,
|
|
175
|
-
opts: { hasUI: boolean },
|
|
176
|
-
): WriteGuardDecision {
|
|
177
|
-
if (!opts.hasUI) {
|
|
178
|
-
return {
|
|
179
|
-
block: true,
|
|
180
|
-
reason: `Path "${rawPath}" is outside workspace. No UI available for approval.`,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
return { block: false };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export default function workspaceGuard(pi: ExtensionAPI) {
|
|
187
|
-
pi.on("before_agent_start", (event, ctx) => {
|
|
188
|
-
const currentCwd = ctx.cwd;
|
|
189
|
-
return {
|
|
190
|
-
systemPrompt:
|
|
191
|
-
event.systemPrompt +
|
|
192
|
-
`\nWorkspace write protection is active. ` +
|
|
193
|
-
`write and edit to paths inside the workspace "${currentCwd}" or /tmp are auto-allowed. ` +
|
|
194
|
-
(ctx.hasUI
|
|
195
|
-
? `Paths outside require user approval before execution.`
|
|
196
|
-
: `Writes outside the workspace are rejected because no UI is available for approval.`),
|
|
197
|
-
};
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
201
|
-
if (!WRITE_TOOLS.has(event.toolName)) return;
|
|
202
|
-
|
|
203
|
-
const rawPath = getWriteTarget(event.input);
|
|
204
|
-
if (!rawPath) return;
|
|
205
|
-
|
|
206
|
-
const resolved = resolveHomePath(rawPath, ctx.cwd);
|
|
207
|
-
|
|
208
|
-
if (isPathAllowed(resolved, ctx.cwd)) return;
|
|
209
|
-
|
|
210
|
-
const decision = decideOutsideWorkspaceWrite(rawPath, {
|
|
211
|
-
hasUI: ctx.hasUI,
|
|
212
|
-
});
|
|
213
|
-
if (decision.block) return decision;
|
|
214
|
-
|
|
215
|
-
let choice: string | undefined;
|
|
216
|
-
while (!choice) {
|
|
217
|
-
const diffPreview = await buildDiffPreview(event.toolName, event.input, resolved);
|
|
218
|
-
|
|
219
|
-
const title =
|
|
220
|
-
`Model requests write access outside workspace:\n\n` +
|
|
221
|
-
` Tool: ${event.toolName}\n` +
|
|
222
|
-
` Path: ${rawPath}\n` +
|
|
223
|
-
` Resolved: ${resolved}\n` +
|
|
224
|
-
(diffPreview ? `\n${diffPreview}\n` : "") +
|
|
225
|
-
`\nAllow?`;
|
|
226
|
-
|
|
227
|
-
choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]);
|
|
228
|
-
|
|
229
|
-
if (choice === undefined) {
|
|
230
|
-
ctx.abort();
|
|
231
|
-
return { block: true, reason: "Write outside workspace cancelled by user." };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (choice === "Block with reason") {
|
|
235
|
-
const feedback = await ctx.ui.input("Why was this write denied?");
|
|
236
|
-
if (feedback === undefined) {
|
|
237
|
-
choice = undefined; // cancelled input, retry select
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
return {
|
|
241
|
-
block: true,
|
|
242
|
-
reason: feedback
|
|
243
|
-
? `Write outside workspace denied: ${feedback}`
|
|
244
|
-
: "Write outside workspace denied by user.",
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (choice !== "Approve once") {
|
|
250
|
-
return { block: true, reason: "Write outside workspace denied by user." };
|
|
251
|
-
}
|
|
252
|
-
});
|
|
253
|
-
}
|