@trim21/personal-pi-extensions 0.0.216 → 0.0.222
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 +4 -2
- package/package.json +4 -6
- package/src/bwrap/core.ts +279 -0
- package/src/bwrap/index.ts +44 -829
- package/src/bwrap/runtime.ts +296 -0
- package/src/claude-code/common.ts +29 -0
- package/src/claude-code/files.ts +320 -0
- package/src/claude-code/index.ts +15 -0
- package/src/claude-code/search.ts +218 -0
- package/src/claude-code/session-tools.ts +201 -0
- package/src/claude-code/shell.ts +107 -0
- package/src/{opencode-edit.ts → opencode/edit.ts} +2 -2
- package/src/opencode/index.ts +43 -0
- package/src/{opencode-todo.ts → opencode/todo.ts} +1 -1
- package/src/{opencode-write.ts → opencode/write.ts} +1 -1
- package/src/session-name.ts +2 -5
- package/src/spawn-agent.ts +3 -3
- package/src/workspace-guard.ts +1 -1
- package/src/lib/jsonc.ts +0 -67
- /package/src/{opencode-edit-engine.ts → opencode/edit-engine.ts} +0 -0
- /package/src/{question.ts → opencode/question.ts} +0 -0
- /package/src/{opencode-read.ts → opencode/read.ts} +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { glob as fsGlob, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
|
|
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
|
+
}
|
|
106
|
+
|
|
107
|
+
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
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
6
|
+
const OTHER_OPTION = "Other";
|
|
7
|
+
const DONE_OPTION = "Done";
|
|
8
|
+
|
|
9
|
+
type TodoStatus = (typeof TODO_STATUSES)[number];
|
|
10
|
+
|
|
11
|
+
export interface ClaudeCodeTodo {
|
|
12
|
+
content: string;
|
|
13
|
+
status: TodoStatus;
|
|
14
|
+
activeForm: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface QuestionOption {
|
|
18
|
+
label: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface QuestionInput {
|
|
23
|
+
question: string;
|
|
24
|
+
header: string;
|
|
25
|
+
options: QuestionOption[];
|
|
26
|
+
multiSelect: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatTodos(todos: readonly ClaudeCodeTodo[]): string[] | undefined {
|
|
30
|
+
if (todos.length === 0) return undefined;
|
|
31
|
+
const markers: Record<TodoStatus, string> = {
|
|
32
|
+
pending: " ",
|
|
33
|
+
in_progress: ">",
|
|
34
|
+
completed: "x",
|
|
35
|
+
};
|
|
36
|
+
return todos.map((todo) => {
|
|
37
|
+
const text = todo.status === "in_progress" ? todo.activeForm : todo.content;
|
|
38
|
+
return `- [${markers[todo.status]}] ${text}`;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function askSingle(
|
|
43
|
+
question: QuestionInput,
|
|
44
|
+
ctx: ExtensionContext,
|
|
45
|
+
signal: AbortSignal | undefined,
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
const title = `${question.header}: ${question.question}`;
|
|
48
|
+
const selected = await ctx.ui.select(
|
|
49
|
+
title,
|
|
50
|
+
[...question.options.map((option) => option.label), OTHER_OPTION],
|
|
51
|
+
{ signal },
|
|
52
|
+
);
|
|
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";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function askMultiple(
|
|
60
|
+
question: QuestionInput,
|
|
61
|
+
ctx: ExtensionContext,
|
|
62
|
+
signal: AbortSignal | undefined,
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
const title = `${question.header}: ${question.question}`;
|
|
65
|
+
const remaining = new Set(question.options.map((option) => option.label));
|
|
66
|
+
const selected: string[] = [];
|
|
67
|
+
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());
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (remaining.delete(choice)) selected.push(choice);
|
|
78
|
+
}
|
|
79
|
+
return selected.length > 0 ? selected.join(", ") : "Unanswered";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerSessionTools(pi: ExtensionAPI): void {
|
|
83
|
+
const todoSchema = Type.Object(
|
|
84
|
+
{
|
|
85
|
+
todos: Type.Array(
|
|
86
|
+
Type.Object(
|
|
87
|
+
{
|
|
88
|
+
content: Type.String({ minLength: 1 }),
|
|
89
|
+
status: StringEnum(TODO_STATUSES),
|
|
90
|
+
activeForm: Type.String({ minLength: 1 }),
|
|
91
|
+
},
|
|
92
|
+
{ additionalProperties: false },
|
|
93
|
+
),
|
|
94
|
+
{ description: "The updated todo list" },
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
{ additionalProperties: false },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
pi.registerTool({
|
|
101
|
+
name: "TodoWrite",
|
|
102
|
+
label: "Todo Write",
|
|
103
|
+
description: [
|
|
104
|
+
"Use this tool to create and manage a structured task list for the current coding session.",
|
|
105
|
+
"Pass the complete updated todo list on every call.",
|
|
106
|
+
"Keep exactly one task in_progress while work remains and mark tasks completed immediately after finishing them.",
|
|
107
|
+
"Each task needs an imperative content form and a present-continuous activeForm.",
|
|
108
|
+
].join("\n"),
|
|
109
|
+
parameters: todoSchema,
|
|
110
|
+
execute(_id, params, _signal, _onUpdate, ctx) {
|
|
111
|
+
const todos = params.todos.map((todo) => ({
|
|
112
|
+
content: todo.content.trim(),
|
|
113
|
+
status: todo.status,
|
|
114
|
+
activeForm: todo.activeForm.trim(),
|
|
115
|
+
}));
|
|
116
|
+
if (todos.some((todo) => todo.content === "" || todo.activeForm === "")) {
|
|
117
|
+
throw new Error("Todo content and activeForm must not be blank.");
|
|
118
|
+
}
|
|
119
|
+
const inProgress = todos.filter((todo) => todo.status === "in_progress");
|
|
120
|
+
if (inProgress.length > 1) throw new Error("Only one todo may be in_progress at a time.");
|
|
121
|
+
ctx.ui.setWidget("claude-code-todos", formatTodos(todos));
|
|
122
|
+
return Promise.resolve({
|
|
123
|
+
content: [
|
|
124
|
+
{
|
|
125
|
+
type: "text" as const,
|
|
126
|
+
text: "Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable.",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
details: { todos },
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const optionSchema = Type.Object(
|
|
135
|
+
{
|
|
136
|
+
label: Type.String({ description: "Concise display text for the option" }),
|
|
137
|
+
description: Type.String({ description: "Explanation of the option" }),
|
|
138
|
+
},
|
|
139
|
+
{ additionalProperties: false },
|
|
140
|
+
);
|
|
141
|
+
const questionSchema = Type.Object(
|
|
142
|
+
{
|
|
143
|
+
question: Type.String({ description: "The complete question to ask" }),
|
|
144
|
+
header: Type.String({ description: "Very short label displayed with the question" }),
|
|
145
|
+
options: Type.Array(optionSchema, {
|
|
146
|
+
minItems: 2,
|
|
147
|
+
maxItems: 4,
|
|
148
|
+
description: "The available choices; do not include an Other option",
|
|
149
|
+
}),
|
|
150
|
+
multiSelect: Type.Boolean({
|
|
151
|
+
default: false,
|
|
152
|
+
description: "Allow the user to select multiple options",
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
{ additionalProperties: false },
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
pi.registerTool({
|
|
159
|
+
name: "AskUserQuestion",
|
|
160
|
+
label: "Ask User Question",
|
|
161
|
+
description: [
|
|
162
|
+
"Ask the user questions during execution to gather preferences, clarify requirements, or choose an implementation direction.",
|
|
163
|
+
"Users can always provide their own answer through the automatically supplied Other option.",
|
|
164
|
+
"Use multiSelect for questions where multiple choices may apply.",
|
|
165
|
+
'If you recommend an option, put it first and append "(Recommended)" to its label.',
|
|
166
|
+
].join("\n"),
|
|
167
|
+
parameters: Type.Object(
|
|
168
|
+
{
|
|
169
|
+
questions: Type.Array(questionSchema, {
|
|
170
|
+
minItems: 1,
|
|
171
|
+
maxItems: 4,
|
|
172
|
+
description: "Questions to ask the user",
|
|
173
|
+
}),
|
|
174
|
+
},
|
|
175
|
+
{ additionalProperties: false },
|
|
176
|
+
),
|
|
177
|
+
executionMode: "sequential",
|
|
178
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
179
|
+
if (!ctx.hasUI) throw new Error("Cannot ask questions: interactive UI is not available");
|
|
180
|
+
const answers: Record<string, string> = {};
|
|
181
|
+
for (const question of params.questions) {
|
|
182
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
183
|
+
answers[question.question] = question.multiSelect
|
|
184
|
+
? await askMultiple(question, ctx, signal)
|
|
185
|
+
: await askSingle(question, ctx, signal);
|
|
186
|
+
}
|
|
187
|
+
const formatted = Object.entries(answers)
|
|
188
|
+
.map(([question, answer]) => `"${question}"="${answer}"`)
|
|
189
|
+
.join(", ");
|
|
190
|
+
return {
|
|
191
|
+
content: [
|
|
192
|
+
{
|
|
193
|
+
type: "text",
|
|
194
|
+
text: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
details: { questions: params.questions, answers },
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import { bwrapRuntime } from "../bwrap/runtime.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
7
|
+
const MAX_TIMEOUT_MS = 600_000;
|
|
8
|
+
|
|
9
|
+
interface MarkerResult {
|
|
10
|
+
text: string;
|
|
11
|
+
cwd: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function stripCwdMarker(text: string, marker: string): MarkerResult {
|
|
15
|
+
const match = new RegExp(String.raw`${marker}([^\n]+)${marker}`).exec(text);
|
|
16
|
+
if (!match) return { text, cwd: undefined };
|
|
17
|
+
return { text: text.replace(match[0], "").trimEnd(), cwd: match[1] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wrapCommand(command: string, marker: string): string {
|
|
21
|
+
return [
|
|
22
|
+
command,
|
|
23
|
+
"__pi_cc_status=$?",
|
|
24
|
+
"wait",
|
|
25
|
+
String.raw`printf '\n${marker}%s${marker}\n' "$PWD"`,
|
|
26
|
+
"exit $__pi_cc_status",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerShellTools(pi: ExtensionAPI): void {
|
|
31
|
+
let persistentCwd: string | undefined;
|
|
32
|
+
|
|
33
|
+
pi.registerTool({
|
|
34
|
+
name: "Bash",
|
|
35
|
+
label: "Bash",
|
|
36
|
+
description: [
|
|
37
|
+
"Executes a given bash command synchronously and returns its output.",
|
|
38
|
+
"The working directory persists between commands, but shell state does not.",
|
|
39
|
+
"timeout is in milliseconds, defaults to 120000, and may not exceed 600000.",
|
|
40
|
+
"Every command runs in the foreground. Background command execution is not supported; shell jobs are waited for before the tool returns.",
|
|
41
|
+
].join("\n"),
|
|
42
|
+
parameters: Type.Object(
|
|
43
|
+
{
|
|
44
|
+
command: Type.String({ description: "The command to execute" }),
|
|
45
|
+
timeout: Type.Optional(
|
|
46
|
+
Type.Number({ description: "Optional timeout in milliseconds (max 600000)" }),
|
|
47
|
+
),
|
|
48
|
+
description: Type.Optional(
|
|
49
|
+
Type.String({ description: "Clear, concise description of the command" }),
|
|
50
|
+
),
|
|
51
|
+
dangerouslyDisableSandbox: Type.Optional(
|
|
52
|
+
Type.Boolean({
|
|
53
|
+
description:
|
|
54
|
+
"Request one-time unsandboxed execution. The user must approve this request.",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
{ additionalProperties: false },
|
|
59
|
+
),
|
|
60
|
+
async execute(id, params, signal, onUpdate, ctx) {
|
|
61
|
+
const timeout = params.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
62
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) {
|
|
63
|
+
throw new Error(`timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const marker = `__PI_CC_CWD_${id.replaceAll("-", "_")}_${Date.now()}__`;
|
|
67
|
+
try {
|
|
68
|
+
const result = await bwrapRuntime.execute({
|
|
69
|
+
ctx: { ...ctx, cwd: persistentCwd ?? ctx.cwd },
|
|
70
|
+
toolCallId: id,
|
|
71
|
+
command: wrapCommand(params.command, marker),
|
|
72
|
+
timeout: timeout / 1000,
|
|
73
|
+
requestFullAccess: params.dangerouslyDisableSandbox,
|
|
74
|
+
requestFullAccessReason: params.description,
|
|
75
|
+
signal,
|
|
76
|
+
onUpdate: onUpdate
|
|
77
|
+
? (update) => {
|
|
78
|
+
const content = update.content.map((item) => {
|
|
79
|
+
if (item.type !== "text") return item;
|
|
80
|
+
return { ...item, text: stripCwdMarker(item.text, marker).text };
|
|
81
|
+
});
|
|
82
|
+
onUpdate({ ...update, content });
|
|
83
|
+
}
|
|
84
|
+
: undefined,
|
|
85
|
+
});
|
|
86
|
+
const content = result.content.map((item) => {
|
|
87
|
+
if (item.type !== "text") return item;
|
|
88
|
+
const cleaned = stripCwdMarker(item.text, marker);
|
|
89
|
+
if (cleaned.cwd) persistentCwd = cleaned.cwd;
|
|
90
|
+
return { ...item, text: cleaned.text || "(no output)" };
|
|
91
|
+
});
|
|
92
|
+
return { ...result, content };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!(error instanceof Error)) throw error;
|
|
95
|
+
const cleaned = stripCwdMarker(error.message, marker);
|
|
96
|
+
if (cleaned.cwd) persistentCwd = cleaned.cwd;
|
|
97
|
+
const timeoutMatch = /Command timed out after [\d.]+ seconds/.exec(cleaned.text);
|
|
98
|
+
const message = timeoutMatch
|
|
99
|
+
? cleaned.text.slice(0, timeoutMatch.index) +
|
|
100
|
+
`Command timed out after ${timeout} milliseconds` +
|
|
101
|
+
cleaned.text.slice(timeoutMatch.index + timeoutMatch[0].length)
|
|
102
|
+
: cleaned.text;
|
|
103
|
+
throw new Error(message, { cause: error });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Known gaps (intentionally not implemented): LSP diagnostics in the result,
|
|
11
11
|
* formatter run.
|
|
12
12
|
*
|
|
13
|
-
* The matching engine (replacers + replace()) lives in opencode
|
|
13
|
+
* The matching engine (replacers + replace()) lives in opencode/edit-engine.ts
|
|
14
14
|
* and is also used by workspace-guard for the diff preview.
|
|
15
15
|
*
|
|
16
16
|
* Usage:
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
replace,
|
|
36
36
|
restoreLineEndings,
|
|
37
37
|
stripBom,
|
|
38
|
-
} from "./
|
|
38
|
+
} from "./edit-engine.js";
|
|
39
39
|
|
|
40
40
|
// ── schema ────────────────────────────────────────────────────────────────────
|
|
41
41
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode —— 统一注册 opencode 风格工具扩展。
|
|
3
|
+
*
|
|
4
|
+
* 聚合 read / edit / write / todo / question 五个工具,一次加载全部注册;
|
|
5
|
+
* 各工具的公开 API(匹配引擎、纯函数等)也从这里重新导出,方便
|
|
6
|
+
* 测试与其他模块(如 workspace-guard)引用。
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* pi -e ./opencode/index.ts
|
|
10
|
+
*
|
|
11
|
+
* spawn-agent 的子代理按声明工具单独加载 `opencode/{read,edit,write}.ts`,
|
|
12
|
+
* 避免把未声明的工具注入子代理工具集。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import opencodeEdit from "./edit.js";
|
|
18
|
+
import opencodeQuestion from "./question.js";
|
|
19
|
+
import opencodeRead from "./read.js";
|
|
20
|
+
import opencodeTodo from "./todo.js";
|
|
21
|
+
import opencodeWrite from "./write.js";
|
|
22
|
+
|
|
23
|
+
export { default as opencodeEdit } from "./edit.js";
|
|
24
|
+
export {
|
|
25
|
+
detectLineEnding,
|
|
26
|
+
normalizeForEdit,
|
|
27
|
+
normalizeToLF,
|
|
28
|
+
replace,
|
|
29
|
+
restoreLineEndings,
|
|
30
|
+
stripBom,
|
|
31
|
+
} from "./edit-engine.js";
|
|
32
|
+
export { default as opencodeQuestion } from "./question.js";
|
|
33
|
+
export { default as opencodeRead, truncateHead, type TruncationResult } from "./read.js";
|
|
34
|
+
export { default as opencodeTodo } from "./todo.js";
|
|
35
|
+
export { default as opencodeWrite, resolveBom } from "./write.js";
|
|
36
|
+
|
|
37
|
+
export default function opencode(pi: ExtensionAPI) {
|
|
38
|
+
opencodeRead(pi);
|
|
39
|
+
opencodeEdit(pi);
|
|
40
|
+
opencodeWrite(pi);
|
|
41
|
+
opencodeTodo(pi);
|
|
42
|
+
opencodeQuestion(pi);
|
|
43
|
+
}
|
|
@@ -26,7 +26,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
26
26
|
import { type ExtensionAPI, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { Type } from "typebox";
|
|
28
28
|
|
|
29
|
-
import { type ToolPendant } from "
|
|
29
|
+
import { type ToolPendant } from "../lib/pendant.js";
|
|
30
30
|
|
|
31
31
|
// ── constants ────────────────────────────────────────────────────────────────
|
|
32
32
|
|
|
@@ -28,7 +28,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
28
28
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
29
29
|
import { Type } from "typebox";
|
|
30
30
|
|
|
31
|
-
import { stripBom } from "./
|
|
31
|
+
import { stripBom } from "./edit-engine.js";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* opencode: desiredBom = source.bom || next.bom —— 优先保留原文件 BOM,
|
package/src/session-name.ts
CHANGED
|
@@ -33,8 +33,6 @@ import {
|
|
|
33
33
|
} from "@earendil-works/pi-ai";
|
|
34
34
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
35
35
|
|
|
36
|
-
import { jsoncToJson } from "./lib/jsonc.js";
|
|
37
|
-
|
|
38
36
|
// ── constants ────────────────────────────────────────────────────────────────
|
|
39
37
|
|
|
40
38
|
/** ~/.pi/agent/settings.json:sessionName 配置所在文件 */
|
|
@@ -98,8 +96,7 @@ export interface UserMessageLike {
|
|
|
98
96
|
/**
|
|
99
97
|
* 读取 ~/.pi/agent/settings.json 的 sessionName 配置。
|
|
100
98
|
* provider 缺省时回退到 defaultProvider;文件缺失 / JSON 损坏 / 无 sessionName
|
|
101
|
-
* 时返回 undefined
|
|
102
|
-
* 示例一致。
|
|
99
|
+
* 时返回 undefined。
|
|
103
100
|
*/
|
|
104
101
|
export function loadSessionNameConfig(settingsPath = SETTINGS_PATH): SessionNameConfig | undefined {
|
|
105
102
|
let raw: string;
|
|
@@ -109,7 +106,7 @@ export function loadSessionNameConfig(settingsPath = SETTINGS_PATH): SessionName
|
|
|
109
106
|
return undefined;
|
|
110
107
|
}
|
|
111
108
|
try {
|
|
112
|
-
const parsed: unknown = JSON.parse(
|
|
109
|
+
const parsed: unknown = JSON.parse(raw);
|
|
113
110
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
114
111
|
const settings = parsed as Record<string, unknown>;
|
|
115
112
|
const sn = settings.sessionName;
|
package/src/spawn-agent.ts
CHANGED
|
@@ -62,9 +62,9 @@ const UNCONDITIONAL_EXTENSIONS = ["workspace-guard.ts", "bwrap/index.ts"] as con
|
|
|
62
62
|
* subagent uses the enhanced implementation instead of the built-in one.
|
|
63
63
|
*/
|
|
64
64
|
const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
|
|
65
|
-
read: "opencode
|
|
66
|
-
edit: "opencode
|
|
67
|
-
write: "opencode
|
|
65
|
+
read: "opencode/read.ts",
|
|
66
|
+
edit: "opencode/edit.ts",
|
|
67
|
+
write: "opencode/write.ts",
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
// ── schema ───────────────────────────────────────────────────────────────────
|
package/src/workspace-guard.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "@earendil-works/pi-coding-agent";
|
|
23
23
|
|
|
24
24
|
import { resolveHomePath } from "./lib/path.js";
|
|
25
|
-
import { normalizeForEdit, replace } from "./opencode
|
|
25
|
+
import { normalizeForEdit, replace } from "./opencode/edit-engine.js";
|
|
26
26
|
|
|
27
27
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
28
28
|
const ALWAYS_ALLOW = ["/tmp"];
|