@trim21/personal-pi-extensions 0.0.216 → 0.0.217

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.216",
3
+ "version": "0.0.217",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -22,6 +22,7 @@
22
22
  "lint": "eslint .",
23
23
  "format": "prettier --write .",
24
24
  "test": "vitest run",
25
+ "test:coverage": "vitest run --coverage",
25
26
  "prepare": "husky"
26
27
  },
27
28
  "peerDependencies": {
@@ -35,6 +36,7 @@
35
36
  "@earendil-works/pi-coding-agent": "^0.84.1",
36
37
  "@eslint/js": "10.0.1",
37
38
  "@types/node": "^24.13.3",
39
+ "@vitest/coverage-v8": "^4.1.10",
38
40
  "eslint": "^10.8.1",
39
41
  "eslint-config-prettier": "10.1.8",
40
42
  "eslint-plugin-erasable-syntax-only": "0.4.2",
@@ -56,13 +58,10 @@
56
58
  "src/session-name.ts",
57
59
  "src/bwrap/index.ts",
58
60
  "src/workspace-guard.ts",
59
- "src/opencode-edit.ts",
60
- "src/opencode-read.ts",
61
- "src/opencode-write.ts",
61
+ "src/opencode/index.ts",
62
62
  "src/bash-default-timeout.ts",
63
63
  "src/gh-readonly.ts",
64
64
  "src/spawn-agent.ts",
65
- "src/opencode-todo.ts",
66
65
  "src/question.ts",
67
66
  "src/talk/index.ts"
68
67
  ],
@@ -0,0 +1,29 @@
1
+ import { isAbsolute, normalize } from "node:path";
2
+
3
+ export interface FileSnapshot {
4
+ digest: string;
5
+ textEditable: boolean;
6
+ }
7
+
8
+ export interface ClaudeCodeState {
9
+ readonly reads: Map<string, FileSnapshot>;
10
+ }
11
+
12
+ export function createClaudeCodeState(): ClaudeCodeState {
13
+ return { reads: new Map() };
14
+ }
15
+
16
+ export function requireAbsolutePath(filePath: string, parameter = "file_path"): string {
17
+ if (!isAbsolute(filePath)) {
18
+ throw new Error(`The ${parameter} parameter must be an absolute path, not a relative path.`);
19
+ }
20
+ return normalize(filePath);
21
+ }
22
+
23
+ export function throwIfAborted(signal: AbortSignal | undefined): void {
24
+ if (signal?.aborted) throw new Error("Operation aborted");
25
+ }
26
+
27
+ export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
28
+ return left.digest === right.digest;
29
+ }
@@ -0,0 +1,320 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
+ import { dirname, extname } from "node:path";
5
+
6
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ generateDiffString,
10
+ generateUnifiedPatch,
11
+ withFileMutationQueue,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { Type } from "typebox";
14
+
15
+ import {
16
+ type ClaudeCodeState,
17
+ type FileSnapshot,
18
+ requireAbsolutePath,
19
+ snapshotsEqual,
20
+ throwIfAborted,
21
+ } from "./common.js";
22
+
23
+ const DEFAULT_READ_LINES = 2000;
24
+ const SAMPLE_BYTES = 4096;
25
+ const IMAGE_MIMES = new Map<string, string>([
26
+ [".gif", "image/gif"],
27
+ [".jpeg", "image/jpeg"],
28
+ [".jpg", "image/jpeg"],
29
+ [".png", "image/png"],
30
+ [".webp", "image/webp"],
31
+ ]);
32
+
33
+ export interface FileToolDetails {
34
+ diff?: string;
35
+ patch?: string;
36
+ firstChangedLine?: number;
37
+ }
38
+
39
+ function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
40
+ return { digest: createHash("sha256").update(content).digest("hex"), textEditable };
41
+ }
42
+
43
+ async function assertReadableFile(filePath: string): Promise<Awaited<ReturnType<typeof stat>>> {
44
+ const value = await stat(filePath);
45
+ if (!value.isFile()) throw new Error(`File not found: ${filePath}`);
46
+ await access(filePath, constants.R_OK);
47
+ return value;
48
+ }
49
+
50
+ function isBinary(sample: Uint8Array): boolean {
51
+ let suspicious = 0;
52
+ for (const byte of sample) {
53
+ if (byte === 0) return true;
54
+ if (byte < 9 || (byte > 13 && byte < 32)) suspicious++;
55
+ }
56
+ return sample.length > 0 && suspicious / sample.length > 0.3;
57
+ }
58
+
59
+ function splitFileLines(content: string): string[] {
60
+ if (content === "") return [];
61
+ const lines = content.split("\n");
62
+ if (content.endsWith("\n")) lines.pop();
63
+ return lines;
64
+ }
65
+
66
+ export function formatReadOutput(
67
+ content: string,
68
+ offset = 1,
69
+ limit = DEFAULT_READ_LINES,
70
+ ): { text: string; complete: boolean; totalLines: number } {
71
+ const lines = splitFileLines(content);
72
+ const totalLines = lines.length;
73
+ if (totalLines === 0) {
74
+ if (offset > 1)
75
+ return {
76
+ text: `<system-reminder>Warning: the file exists but has fewer lines than the provided offset (${offset}). The file has 0 lines.</system-reminder>`,
77
+ complete: false,
78
+ totalLines,
79
+ };
80
+ return {
81
+ text: "<system-reminder>Warning: the file exists but has empty contents.</system-reminder>",
82
+ complete: true,
83
+ totalLines,
84
+ };
85
+ }
86
+ if (offset > totalLines) {
87
+ return {
88
+ text: `<system-reminder>Warning: the file exists but has fewer lines than the provided offset (${offset}). The file has ${totalLines} lines.</system-reminder>`,
89
+ complete: false,
90
+ totalLines,
91
+ };
92
+ }
93
+
94
+ const selected = lines.slice(offset - 1, offset - 1 + limit);
95
+ const numbered = selected
96
+ .map(
97
+ (line, index) =>
98
+ `${String(offset + index).padStart(6)}\t${line.length > 2000 ? line.slice(0, 2000) : line}`,
99
+ )
100
+ .join("\n");
101
+ const complete = offset === 1 && selected.length === totalLines;
102
+ const hasMore = offset - 1 + selected.length < totalLines;
103
+ const notice = hasMore
104
+ ? `\n\n<system-reminder>PARTIAL view: showing lines ${offset}-${offset + selected.length - 1} of ${totalLines}. Use offset and limit to read more.</system-reminder>`
105
+ : "";
106
+ return { text: numbered + notice, complete, totalLines };
107
+ }
108
+
109
+ function countMatches(content: string, needle: string): number {
110
+ if (needle === "") return 0;
111
+ let count = 0;
112
+ let index = 0;
113
+ while ((index = content.indexOf(needle, index)) !== -1) {
114
+ count++;
115
+ index += needle.length;
116
+ }
117
+ return count;
118
+ }
119
+
120
+ export function exactReplace(
121
+ content: string,
122
+ oldString: string,
123
+ newString: string,
124
+ replaceAll = false,
125
+ ): string {
126
+ if (oldString === newString)
127
+ throw new Error("No changes to apply: old_string and new_string are identical.");
128
+ if (oldString === "") throw new Error("old_string must not be empty.");
129
+ const matches = countMatches(content, oldString);
130
+ if (matches === 0) throw new Error("String to replace not found in file.");
131
+ if (!replaceAll && matches > 1) {
132
+ throw new Error(
133
+ `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, provide more context to make old_string unique.`,
134
+ );
135
+ }
136
+ if (replaceAll) return content.split(oldString).join(newString);
137
+ const index = content.indexOf(oldString);
138
+ return content.slice(0, index) + newString + content.slice(index + oldString.length);
139
+ }
140
+
141
+ async function requireCurrentRead(state: ClaudeCodeState, filePath: string): Promise<void> {
142
+ const readSnapshot = state.reads.get(filePath);
143
+ if (!readSnapshot)
144
+ throw new Error(`File has not been read yet. Read it first before writing to it: ${filePath}`);
145
+ if (!readSnapshot.textEditable) {
146
+ throw new Error(`Cannot edit or overwrite a binary file with a text tool: ${filePath}`);
147
+ }
148
+ const currentContent = await readFile(filePath);
149
+ const current = snapshotOf(currentContent);
150
+ if (!snapshotsEqual(readSnapshot, current)) {
151
+ throw new Error(`File has been modified since read. Read it again before writing: ${filePath}`);
152
+ }
153
+ }
154
+
155
+ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): void {
156
+ pi.registerTool({
157
+ name: "Read",
158
+ label: "Read",
159
+ description: [
160
+ "Reads a file from the local filesystem. You can access any file directly using this tool.",
161
+ "The file_path parameter must be an absolute path. By default, it reads up to 2000 lines from the beginning.",
162
+ "Results use cat -n style line numbers starting at 1. Images are returned visually.",
163
+ "This tool reads files, not directories.",
164
+ ].join("\n"),
165
+ parameters: Type.Object(
166
+ {
167
+ file_path: Type.String({ description: "The absolute path to the file to read" }),
168
+ offset: Type.Optional(
169
+ Type.Number({ description: "The line number to start reading from" }),
170
+ ),
171
+ limit: Type.Optional(Type.Number({ description: "The number of lines to read" })),
172
+ pages: Type.Optional(
173
+ Type.String({ description: 'Page range for PDF files (for example "1-5" or "3")' }),
174
+ ),
175
+ },
176
+ { additionalProperties: false },
177
+ ),
178
+ async execute(_id, params, signal) {
179
+ throwIfAborted(signal);
180
+ const filePath = requireAbsolutePath(params.file_path);
181
+ if (
182
+ params.offset !== undefined &&
183
+ (!Number.isSafeInteger(params.offset) || params.offset < 1)
184
+ ) {
185
+ throw new Error("offset must be a positive integer");
186
+ }
187
+ if (params.limit !== undefined && (!Number.isSafeInteger(params.limit) || params.limit < 1)) {
188
+ throw new Error("limit must be a positive integer");
189
+ }
190
+ await assertReadableFile(filePath);
191
+ throwIfAborted(signal);
192
+
193
+ const extension = extname(filePath).toLowerCase();
194
+ if (extension === ".pdf") {
195
+ throw new Error(
196
+ "PDF page rendering is not supported by this pi tool host. Use an external PDF extraction tool before calling Read.",
197
+ );
198
+ }
199
+ const imageMime = IMAGE_MIMES.get(extension);
200
+ if (imageMime) {
201
+ const image = await readFile(filePath);
202
+ const data = image.toString("base64");
203
+ const content: (TextContent | ImageContent)[] = [
204
+ { type: "text", text: `Read image file [${imageMime}]` },
205
+ { type: "image", data, mimeType: imageMime },
206
+ ];
207
+ state.reads.set(filePath, snapshotOf(image, false));
208
+ return { content, details: undefined };
209
+ }
210
+
211
+ const buffer = await readFile(filePath);
212
+ if (isBinary(buffer.subarray(0, SAMPLE_BYTES)))
213
+ throw new Error(`Cannot read binary file: ${filePath}`);
214
+ const formatted = formatReadOutput(
215
+ buffer.toString("utf8"),
216
+ params.offset ?? 1,
217
+ params.limit ?? DEFAULT_READ_LINES,
218
+ );
219
+ state.reads.set(filePath, snapshotOf(buffer));
220
+ return { content: [{ type: "text", text: formatted.text }], details: undefined };
221
+ },
222
+ });
223
+
224
+ pi.registerTool({
225
+ name: "Edit",
226
+ label: "Edit",
227
+ description: [
228
+ "Performs exact string replacements in files.",
229
+ "You must use Read on the file before editing it.",
230
+ "old_string must match exactly and must be unique unless replace_all is true.",
231
+ "This tool does not use regular expressions or fuzzy matching.",
232
+ ].join("\n"),
233
+ parameters: Type.Object(
234
+ {
235
+ file_path: Type.String({ description: "The absolute path to the file to modify" }),
236
+ old_string: Type.String({ description: "The text to replace" }),
237
+ new_string: Type.String({ description: "The text to replace it with" }),
238
+ replace_all: Type.Optional(
239
+ Type.Boolean({ description: "Replace all occurrences of old_string", default: false }),
240
+ ),
241
+ },
242
+ { additionalProperties: false },
243
+ ),
244
+ async execute(_id, params, signal) {
245
+ throwIfAborted(signal);
246
+ const filePath = requireAbsolutePath(params.file_path);
247
+ return withFileMutationQueue(filePath, async () => {
248
+ await requireCurrentRead(state, filePath);
249
+ await access(filePath, constants.R_OK | constants.W_OK);
250
+ const original = await readFile(filePath, "utf8");
251
+ throwIfAborted(signal);
252
+ const updated = exactReplace(
253
+ original,
254
+ params.old_string,
255
+ params.new_string,
256
+ params.replace_all ?? false,
257
+ );
258
+ await writeFile(filePath, updated, "utf8");
259
+ state.reads.set(filePath, snapshotOf(updated));
260
+ const diff = generateDiffString(original, updated);
261
+ return {
262
+ content: [{ type: "text", text: `The file ${filePath} has been updated successfully.` }],
263
+ details: {
264
+ diff: diff.diff,
265
+ patch: generateUnifiedPatch(filePath, original, updated),
266
+ firstChangedLine: diff.firstChangedLine,
267
+ } satisfies FileToolDetails,
268
+ };
269
+ });
270
+ },
271
+ });
272
+
273
+ pi.registerTool({
274
+ name: "Write",
275
+ label: "Write",
276
+ description: [
277
+ "Writes a file to the local filesystem.",
278
+ "This tool overwrites an existing file with the full content provided.",
279
+ "If the file exists, you must use Read first. Prefer Edit for partial changes.",
280
+ ].join("\n"),
281
+ parameters: Type.Object(
282
+ {
283
+ file_path: Type.String({
284
+ description: "The absolute path to the file to write (must be absolute, not relative)",
285
+ }),
286
+ content: Type.String({ description: "The content to write to the file" }),
287
+ },
288
+ { additionalProperties: false },
289
+ ),
290
+ async execute(_id, params, signal) {
291
+ throwIfAborted(signal);
292
+ const filePath = requireAbsolutePath(params.file_path);
293
+ return withFileMutationQueue(filePath, async () => {
294
+ let original: string | undefined;
295
+ try {
296
+ const value = await stat(filePath);
297
+ if (value.isFile()) {
298
+ await requireCurrentRead(state, filePath);
299
+ original = await readFile(filePath, "utf8");
300
+ }
301
+ } catch (error) {
302
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
303
+ }
304
+ throwIfAborted(signal);
305
+ await mkdir(dirname(filePath), { recursive: true });
306
+ await writeFile(filePath, params.content, "utf8");
307
+ state.reads.set(filePath, snapshotOf(params.content));
308
+ const diff = generateDiffString(original ?? "", params.content);
309
+ return {
310
+ content: [{ type: "text", text: `File created successfully at: ${filePath}` }],
311
+ details: {
312
+ diff: diff.diff,
313
+ patch: generateUnifiedPatch(filePath, original ?? "", params.content),
314
+ firstChangedLine: diff.firstChangedLine,
315
+ } satisfies FileToolDetails,
316
+ };
317
+ });
318
+ },
319
+ });
320
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { createClaudeCodeState } from "./common.js";
4
+ import { registerFileTools } from "./files.js";
5
+ import { registerSearchTools } from "./search.js";
6
+ import { registerSessionTools } from "./session-tools.js";
7
+ import { registerShellTools } from "./shell.js";
8
+
9
+ export default function claudeCodeTools(pi: ExtensionAPI): void {
10
+ const state = createClaudeCodeState();
11
+ registerFileTools(pi, state);
12
+ registerSearchTools(pi);
13
+ registerShellTools(pi);
14
+ registerSessionTools(pi);
15
+ }
@@ -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,97 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createBashTool } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+
5
+ const DEFAULT_TIMEOUT_MS = 120_000;
6
+ const MAX_TIMEOUT_MS = 600_000;
7
+
8
+ interface MarkerResult {
9
+ text: string;
10
+ cwd: string | undefined;
11
+ }
12
+
13
+ function stripCwdMarker(text: string, marker: string): MarkerResult {
14
+ const match = new RegExp(String.raw`${marker}([^\n]+)${marker}`).exec(text);
15
+ if (!match) return { text, cwd: undefined };
16
+ return { text: text.replace(match[0], "").trimEnd(), cwd: match[1] };
17
+ }
18
+
19
+ function wrapCommand(command: string, marker: string): string {
20
+ return [
21
+ command,
22
+ "__pi_cc_status=$?",
23
+ "wait",
24
+ String.raw`printf '\n${marker}%s${marker}\n' "$PWD"`,
25
+ "exit $__pi_cc_status",
26
+ ].join("\n");
27
+ }
28
+
29
+ export function registerShellTools(pi: ExtensionAPI): void {
30
+ let persistentCwd: string | undefined;
31
+
32
+ pi.registerTool({
33
+ name: "Bash",
34
+ label: "Bash",
35
+ description: [
36
+ "Executes a given bash command synchronously and returns its output.",
37
+ "The working directory persists between commands, but shell state does not.",
38
+ "timeout is in milliseconds, defaults to 120000, and may not exceed 600000.",
39
+ "Every command runs in the foreground. Background command execution is not supported; shell jobs are waited for before the tool returns.",
40
+ ].join("\n"),
41
+ parameters: Type.Object(
42
+ {
43
+ command: Type.String({ description: "The command to execute" }),
44
+ timeout: Type.Optional(
45
+ Type.Number({ description: "Optional timeout in milliseconds (max 600000)" }),
46
+ ),
47
+ description: Type.Optional(
48
+ Type.String({ description: "Clear, concise description of the command" }),
49
+ ),
50
+ },
51
+ { additionalProperties: false },
52
+ ),
53
+ async execute(id, params, signal, onUpdate, ctx) {
54
+ const timeout = params.timeout ?? DEFAULT_TIMEOUT_MS;
55
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) {
56
+ throw new Error(`timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
57
+ }
58
+
59
+ const marker = `__PI_CC_CWD_${id.replaceAll("-", "_")}_${Date.now()}__`;
60
+ const bash = createBashTool(persistentCwd ?? ctx.cwd);
61
+ try {
62
+ const result = await bash.execute(
63
+ id,
64
+ { command: wrapCommand(params.command, marker), timeout: timeout / 1000 },
65
+ signal,
66
+ onUpdate
67
+ ? (update) => {
68
+ const content = update.content.map((item) => {
69
+ if (item.type !== "text") return item;
70
+ return { ...item, text: stripCwdMarker(item.text, marker).text };
71
+ });
72
+ onUpdate({ ...update, content });
73
+ }
74
+ : undefined,
75
+ );
76
+ const content = result.content.map((item) => {
77
+ if (item.type !== "text") return item;
78
+ const cleaned = stripCwdMarker(item.text, marker);
79
+ if (cleaned.cwd) persistentCwd = cleaned.cwd;
80
+ return { ...item, text: cleaned.text || "(no output)" };
81
+ });
82
+ return { ...result, content };
83
+ } catch (error) {
84
+ if (!(error instanceof Error)) throw error;
85
+ const cleaned = stripCwdMarker(error.message, marker);
86
+ if (cleaned.cwd) persistentCwd = cleaned.cwd;
87
+ const timeoutMatch = /Command timed out after [\d.]+ seconds/.exec(cleaned.text);
88
+ const message = timeoutMatch
89
+ ? cleaned.text.slice(0, timeoutMatch.index) +
90
+ `Command timed out after ${timeout} milliseconds` +
91
+ cleaned.text.slice(timeoutMatch.index + timeoutMatch[0].length)
92
+ : cleaned.text;
93
+ throw new Error(message, { cause: error });
94
+ }
95
+ },
96
+ });
97
+ }
@@ -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-edit-engine.ts
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 "./opencode-edit-engine.js";
38
+ } from "./edit-engine.js";
39
39
 
40
40
  // ── schema ────────────────────────────────────────────────────────────────────
41
41
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * opencode —— 统一注册 opencode 风格工具扩展。
3
+ *
4
+ * 聚合 read / edit / write / todo 四个工具,一次加载全部注册;
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 opencodeRead from "./read.js";
19
+ import opencodeTodo from "./todo.js";
20
+ import opencodeWrite from "./write.js";
21
+
22
+ export { default as opencodeEdit } from "./edit.js";
23
+ export {
24
+ detectLineEnding,
25
+ normalizeForEdit,
26
+ normalizeToLF,
27
+ replace,
28
+ restoreLineEndings,
29
+ stripBom,
30
+ } from "./edit-engine.js";
31
+ export { default as opencodeRead, truncateHead, type TruncationResult } from "./read.js";
32
+ export { default as opencodeTodo } from "./todo.js";
33
+ export { default as opencodeWrite, resolveBom } from "./write.js";
34
+
35
+ export default function opencode(pi: ExtensionAPI) {
36
+ opencodeRead(pi);
37
+ opencodeEdit(pi);
38
+ opencodeWrite(pi);
39
+ opencodeTodo(pi);
40
+ }
@@ -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 "./lib/pendant.js";
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 "./opencode-edit-engine.js";
31
+ import { stripBom } from "./edit-engine.js";
32
32
 
33
33
  /**
34
34
  * opencode: desiredBom = source.bom || next.bom —— 优先保留原文件 BOM,
@@ -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。支持 jsonc(注释/尾逗号),与 pi 文档的 settings.json
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(jsoncToJson(raw));
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;
@@ -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-read.ts",
66
- edit: "opencode-edit.ts",
67
- write: "opencode-write.ts",
65
+ read: "opencode/read.ts",
66
+ edit: "opencode/edit.ts",
67
+ write: "opencode/write.ts",
68
68
  };
69
69
 
70
70
  // ── schema ───────────────────────────────────────────────────────────────────
@@ -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-edit-engine.js";
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"];
package/src/lib/jsonc.ts DELETED
@@ -1,67 +0,0 @@
1
- /**
2
- * Minimal JSONC → JSON conversion for small user-edited config files.
3
- *
4
- * Strips line and block comments and trailing commas outside of string
5
- * literals, so a stray comment in a config file does not silently void the
6
- * whole file (the way a plain `JSON.parse` would).
7
- */
8
-
9
- export function jsoncToJson(raw: string): string {
10
- let out = "";
11
- let inString = false;
12
- let i = 0;
13
- while (i < raw.length) {
14
- const c = raw[i];
15
- if (inString) {
16
- out += c;
17
- if (c === "\\" && i + 1 < raw.length) {
18
- out += raw[i + 1];
19
- i += 2;
20
- continue;
21
- }
22
- if (c === '"') inString = false;
23
- i++;
24
- continue;
25
- }
26
- switch (c) {
27
- case '"': {
28
- inString = true;
29
- out += c;
30
- i++;
31
- continue;
32
- }
33
- case "/": {
34
- if (raw[i + 1] === "/") {
35
- while (i < raw.length && raw[i] !== "\n") i++;
36
- continue;
37
- }
38
- if (raw[i + 1] === "*") {
39
- i += 2;
40
- while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
41
- i += 2;
42
- continue;
43
- }
44
- out += c;
45
- i++;
46
- continue;
47
- }
48
- case ",": {
49
- // Drop a trailing comma before } or ] (outside strings).
50
- let j = i + 1;
51
- while (j < raw.length && /\s/.test(raw[j])) j++;
52
- if (raw[j] === "}" || raw[j] === "]") {
53
- i++;
54
- continue;
55
- }
56
- out += c;
57
- i++;
58
- continue;
59
- }
60
- default: {
61
- out += c;
62
- i++;
63
- }
64
- }
65
- }
66
- return out;
67
- }
File without changes