@trim21/personal-pi-extensions 0.0.243 → 0.0.245

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.243",
3
+ "version": "0.0.245",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -64,7 +64,8 @@
64
64
  "src/talk/index.ts"
65
65
  ],
66
66
  "skills": [
67
- "src/talk/skills"
67
+ "src/talk/skills",
68
+ "src/claude-code/skills"
68
69
  ]
69
70
  },
70
71
  "lint-staged": {
@@ -182,31 +182,37 @@ export class BwrapRuntime {
182
182
  const policy = resolveEscalation({ hasUI: ctx.hasUI });
183
183
  if (policy.kind === "deny") throw new Error(policy.reason);
184
184
  const description = `Allow this command to run without sandbox?\n---\n\nReason: ${escapeHtml(reason ?? "(No reason provided by model)")}\n---\n${fenceCodeBlock(command)}`;
185
- while (true) {
186
- const result = await selectWithOptionalInput(
187
- description,
188
- [
189
- { label: "Approve once" },
190
- { label: "Block" },
191
- { label: "Block with reason", inputPrompt: "Why was this denied?" },
192
- ],
193
- ctx.ui,
194
- { signal: ctx.signal },
195
- );
196
- if (result === undefined) {
197
- ctx.abort();
198
- throw new Error("User denied the command execution.");
199
- }
200
- if (result.label === "Approve once") return;
201
- if (result.label === "Block") throw new Error("User denied unsandboxed execution.");
202
- // "Block with reason":取消输入则重新询问;空输入同样拒绝(不带反馈文本)
203
- if (result.input === undefined) continue;
204
- throw new Error(
205
- result.input
206
- ? `User denied unsandboxed execution: ${result.input}`
207
- : "User denied unsandboxed execution.",
208
- );
185
+
186
+ // 单选 1:允许还是拦截(关闭对话框 = 中断并拒绝)
187
+ const verdict = await selectWithOptionalInput(
188
+ description,
189
+ [{ label: "Approve once" }, { label: "Block" }],
190
+ ctx.ui,
191
+ { signal: ctx.signal },
192
+ );
193
+ if (verdict === undefined) {
194
+ ctx.abort();
195
+ throw new Error("User denied the command execution.");
209
196
  }
197
+ if (verdict.label === "Approve once") return;
198
+
199
+ // 单选 2 + input 组合:直接拦截还是附带理由;选 "Block with reason"
200
+ // 自动弹输入框。关闭对话框/取消输入/空白都按无理由拒绝,不循环重问。
201
+ const style = await selectWithOptionalInput(
202
+ "Block this command?",
203
+ [{ label: "Block" }, { label: "Block with reason", inputPrompt: "Why was this denied?" }],
204
+ ctx.ui,
205
+ { signal: ctx.signal },
206
+ );
207
+ if (style === undefined || style.label === "Block") {
208
+ throw new Error("User denied unsandboxed execution.");
209
+ }
210
+ const feedback = style.input?.trim() ?? "";
211
+ throw new Error(
212
+ feedback
213
+ ? `User denied unsandboxed execution: ${feedback}`
214
+ : "User denied unsandboxed execution.",
215
+ );
210
216
  }
211
217
 
212
218
  private registerCommands(pi: ExtensionAPI): void {
@@ -1,4 +1,16 @@
1
- import { isAbsolute, normalize, resolve } from "node:path";
1
+ import { readdirSync } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import {
4
+ basename,
5
+ dirname,
6
+ extname,
7
+ isAbsolute,
8
+ join,
9
+ normalize,
10
+ relative,
11
+ resolve,
12
+ sep,
13
+ } from "node:path";
2
14
 
3
15
  import { Type } from "typebox";
4
16
  import { Value } from "typebox/value";
@@ -34,6 +46,12 @@ export function searchRoot(path: string | undefined, cwd: string): string {
34
46
  return isAbsolute(path) ? path : resolve(cwd, path);
35
47
  }
36
48
 
49
+ /** 相对 cwd 的路径(超出 cwd 则保留绝对路径),对齐 Claude Code 省 token。 */
50
+ export function toRelativePath(filePath: string, cwd: string): string {
51
+ const relativePath = relative(cwd, filePath);
52
+ return relativePath.startsWith("..") ? filePath : relativePath;
53
+ }
54
+
37
55
  export function throwIfAborted(signal: AbortSignal | undefined): void {
38
56
  if (signal?.aborted) throw new Error("Operation aborted");
39
57
  }
@@ -42,6 +60,68 @@ export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean
42
60
  return left.digest === right.digest;
43
61
  }
44
62
 
63
+ /**
64
+ * 同目录下"同名不同扩展名"的文件(如请求 foo.ts 不存在,目录里有 foo.js),
65
+ * 对齐 Claude Code 的 findSimilarFile。返回文件名(不含路径)。
66
+ */
67
+ export function findSimilarFile(filePath: string): string | undefined {
68
+ try {
69
+ const dir = dirname(filePath);
70
+ const fileBaseName = basename(filePath, extname(filePath));
71
+ const similar = readdirSync(dir).find(
72
+ (name) => basename(name, extname(name)) === fileBaseName && join(dir, name) !== filePath,
73
+ );
74
+ return similar;
75
+ } catch {
76
+ // 目录不存在(ENOENT)属预期,其他错误同样不阻塞建议
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Dropped-repo-folder 检测(对齐 Claude Code 的 suggestPathUnderCwd):模型给出
83
+ * 的绝对路径可能少了仓库目录组件。若请求路径位于 cwd 的父目录下(但不在 cwd
84
+ * 内),把相对父目录的部分拼到 cwd 下,存在则返回完整路径。
85
+ */
86
+ export async function suggestPathUnderCwd(
87
+ requestedPath: string,
88
+ cwd: string,
89
+ ): Promise<string | undefined> {
90
+ const cwdParent = dirname(cwd);
91
+ // realpath 解析请求路径的父目录(如 macOS /tmp → /private/tmp),保证与
92
+ // cwd 的前缀比较一致
93
+ let resolvedPath = requestedPath;
94
+ try {
95
+ const resolvedDir = await realpath(dirname(requestedPath));
96
+ resolvedPath = join(resolvedDir, basename(requestedPath));
97
+ } catch {
98
+ // 父目录不存在,用原路径
99
+ }
100
+ const cwdParentPrefix = cwdParent === sep ? sep : cwdParent + sep;
101
+ if (
102
+ resolvedPath === cwd ||
103
+ !resolvedPath.startsWith(cwdParentPrefix) ||
104
+ resolvedPath.startsWith(cwd + sep)
105
+ ) {
106
+ return undefined;
107
+ }
108
+ const relFromParent = relative(cwdParent, resolvedPath);
109
+ const correctedPath = join(cwd, relFromParent);
110
+ try {
111
+ await stat(correctedPath);
112
+ return correctedPath;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ /** "Did you mean" 建议:优先 cwd 重定位,其次同名不同扩展(对齐 Claude Code)。 */
119
+ export async function didYouMean(filePath: string, cwd: string): Promise<string | undefined> {
120
+ const cwdSuggestion = await suggestPathUnderCwd(filePath, cwd);
121
+ if (cwdSuggestion) return cwdSuggestion;
122
+ return findSimilarFile(filePath);
123
+ }
124
+
45
125
  /**
46
126
  * 从工具结果 details 里恢复文件已读记账(跨进程 resume / reload / fork)。
47
127
  * 数据来自 session 文件,可能缺失或损坏:逐条 TypeBox 校验,非法条目丢弃。
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Claude Code 风格 Edit 的字符串匹配工具(移植自真实 Claude Code 的
3
+ * FileEditTool/utils.ts)。
4
+ *
5
+ * - findActualString:先精确匹配,失败则做引号规范化匹配(文件里的花引号
6
+ * 与模型输出的直引号等价),返回文件中的真实文本;
7
+ * - preserveQuoteStyle:当 old_string 通过引号规范化命中时,把 new_string
8
+ * 里的直引号换成文件使用的花引号风格,保持文件的排版一致。
9
+ */
10
+
11
+ export const LEFT_SINGLE_CURLY_QUOTE = "‘";
12
+ export const RIGHT_SINGLE_CURLY_QUOTE = "’";
13
+ export const LEFT_DOUBLE_CURLY_QUOTE = "“";
14
+ export const RIGHT_DOUBLE_CURLY_QUOTE = "”";
15
+
16
+ /** 把花引号规范化为直引号(匹配与替换两侧都用它做归一)。 */
17
+ export function normalizeQuotes(str: string): string {
18
+ return str
19
+ .replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
20
+ .replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
21
+ .replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
22
+ .replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"');
23
+ }
24
+
25
+ /**
26
+ * 在文件内容中查找与搜索串匹配的实际文本。
27
+ * 先精确匹配;失败则把两侧花引号归一为直引号后重试,返回文件中的原文。
28
+ */
29
+ export function findActualString(fileContent: string, searchString: string): string | null {
30
+ if (fileContent.includes(searchString)) return searchString;
31
+ const normalizedSearch = normalizeQuotes(searchString);
32
+ const normalizedFile = normalizeQuotes(fileContent);
33
+ const searchIndex = normalizedFile.indexOf(normalizedSearch);
34
+ if (searchIndex !== -1) {
35
+ return fileContent.slice(searchIndex, searchIndex + searchString.length);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /** 引号前是空白/行首/开括号/破折号时视为开引号,否则视为闭引号。 */
41
+ function isOpeningContext(chars: string[], index: number): boolean {
42
+ if (index === 0) return true;
43
+ const prev = chars[index - 1];
44
+ return (
45
+ prev === " " ||
46
+ prev === "\t" ||
47
+ prev === "\n" ||
48
+ prev === "\r" ||
49
+ prev === "(" ||
50
+ prev === "[" ||
51
+ prev === "{" ||
52
+ prev === "\u2014" || // em dash
53
+ prev === "\u2013" // en dash
54
+ );
55
+ }
56
+
57
+ function applyCurlyDoubleQuotes(str: string): string {
58
+ const chars = [...str];
59
+ const result: string[] = [];
60
+ for (let i = 0; i < chars.length; i++) {
61
+ if (chars[i] === '"') {
62
+ result.push(isOpeningContext(chars, i) ? LEFT_DOUBLE_CURLY_QUOTE : RIGHT_DOUBLE_CURLY_QUOTE);
63
+ } else {
64
+ result.push(chars[i]);
65
+ }
66
+ }
67
+ return result.join("");
68
+ }
69
+
70
+ function applyCurlySingleQuotes(str: string): string {
71
+ const chars = [...str];
72
+ const result: string[] = [];
73
+ for (let i = 0; i < chars.length; i++) {
74
+ if (chars[i] === "'") {
75
+ // 缩写中的撇号(如 don't)用右花引号,不做开/闭判断
76
+ const prev = chars[i - 1];
77
+ const next = chars[i + 1];
78
+ const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev);
79
+ const nextIsLetter = next !== undefined && /\p{L}/u.test(next);
80
+ if (prevIsLetter && nextIsLetter) {
81
+ result.push(RIGHT_SINGLE_CURLY_QUOTE);
82
+ } else {
83
+ result.push(
84
+ isOpeningContext(chars, i) ? LEFT_SINGLE_CURLY_QUOTE : RIGHT_SINGLE_CURLY_QUOTE,
85
+ );
86
+ }
87
+ } else {
88
+ result.push(chars[i]);
89
+ }
90
+ }
91
+ return result.join("");
92
+ }
93
+
94
+ /**
95
+ * 当 old_string 通过引号规范化命中(oldString !== actualOldString)时,
96
+ * 把 new_string 中的直引号换成文件实际使用的花引号风格。
97
+ */
98
+ export function preserveQuoteStyle(
99
+ oldString: string,
100
+ actualOldString: string,
101
+ newString: string,
102
+ ): string {
103
+ if (oldString === actualOldString) return newString;
104
+ const hasDoubleQuotes =
105
+ actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
106
+ actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE);
107
+ const hasSingleQuotes =
108
+ actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
109
+ actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE);
110
+ if (!hasDoubleQuotes && !hasSingleQuotes) return newString;
111
+
112
+ let result = newString;
113
+ if (hasDoubleQuotes) result = applyCurlyDoubleQuotes(result);
114
+ if (hasSingleQuotes) result = applyCurlySingleQuotes(result);
115
+ return result;
116
+ }
@@ -16,15 +16,31 @@ import { Type } from "typebox";
16
16
  import { guardWriteAccess } from "../lib/write-guard.js";
17
17
  import {
18
18
  type ClaudeCodeState,
19
+ didYouMean,
19
20
  type FileSnapshot,
20
21
  requireAbsolutePath,
21
22
  snapshotsEqual,
22
23
  throwIfAborted,
23
24
  } from "./common.js";
25
+ import { findActualString, preserveQuoteStyle } from "./edit-utils.js";
24
26
 
25
- const DEFAULT_READ_LINES = 2000;
26
27
  const SAMPLE_BYTES = 4096;
27
28
 
29
+ /** Read 全读时的文件大小上限(对齐 Claude Code 的 256KB)。 */
30
+ const MAX_READ_SIZE_BYTES = 0.25 * 1024 * 1024;
31
+ /** Read 输出 token 粗估上限(对齐 Claude Code 的 25K tokens;无 tokenizer,按 4 字符/token 估算)。 */
32
+ const MAX_READ_TOKENS = 25_000;
33
+
34
+ /** Edit 最大文件大小(stat 字节数),防止大文件 OOM(对齐 Claude Code)。 */
35
+ const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024; // 1 GiB
36
+
37
+ function formatFileSize(bytes: number): string {
38
+ if (bytes < 1024) return `${bytes} B`;
39
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
40
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
41
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
42
+ }
43
+
28
44
  /** Tool guidance, kept in markdown so it reads like documentation. */
29
45
  const READ_PROMPT = readFileSync(fileURLToPath(new URL("read.md", import.meta.url)), "utf8").trim();
30
46
  const EDIT_PROMPT = readFileSync(fileURLToPath(new URL("edit.md", import.meta.url)), "utf8").trim();
@@ -71,54 +87,49 @@ function isBinary(sample: Uint8Array): boolean {
71
87
  return sample.length > 0 && suspicious / sample.length > 0.3;
72
88
  }
73
89
 
90
+ /**
91
+ * 对齐 Claude Code 的 readFileInRange + addLineNumbers:
92
+ * - 剥离 UTF-8 BOM;
93
+ * - 非空文件按「每行 + 一个尾随空行」切分(真实 CC 的尾部 fragment 无条件
94
+ * 加入,等价于补一个 \n 再 split),所以 totalLines 总比编辑器显示行数多 1;
95
+ * - 每行去掉尾随 \r(CRLF → LF)。
96
+ */
74
97
  function splitFileLines(content: string): string[] {
75
- if (content === "") return [];
76
- const lines = content.split("\n");
77
- if (content.endsWith("\n")) lines.pop();
78
- return lines;
98
+ const text = content.replace(/^\uFEFF/, "");
99
+ if (text === "") return [];
100
+ return (text.endsWith("\n") ? text : `${text}\n`)
101
+ .split("\n")
102
+ .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
79
103
  }
80
104
 
105
+ /**
106
+ * 格式化读取输出(对齐 Claude Code 的 addLineNumbers):行号无 padding,
107
+ * limit 未指定时读取全部。无 PARTIAL 提示、无单行截断(由 execute 层的
108
+ * 字节/token 上限兜底)。
109
+ */
81
110
  export function formatReadOutput(
82
111
  content: string,
83
112
  offset = 1,
84
- limit = DEFAULT_READ_LINES,
85
- ): { text: string; complete: boolean; totalLines: number } {
113
+ limit?: number,
114
+ ): { text: string; totalLines: number } {
86
115
  const lines = splitFileLines(content);
87
116
  const totalLines = lines.length;
88
117
  if (totalLines === 0) {
89
- if (offset > 1)
90
- return {
91
- text: `<system-reminder>Warning: the file exists but has fewer lines than the provided offset (${offset}). The file has 0 lines.</system-reminder>`,
92
- complete: false,
93
- totalLines,
94
- };
95
118
  return {
96
- text: "<system-reminder>Warning: the file exists but has empty contents.</system-reminder>",
97
- complete: true,
119
+ text: "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>",
98
120
  totalLines,
99
121
  };
100
122
  }
101
123
  if (offset > totalLines) {
102
124
  return {
103
- text: `<system-reminder>Warning: the file exists but has fewer lines than the provided offset (${offset}). The file has ${totalLines} lines.</system-reminder>`,
104
- complete: false,
125
+ text: `<system-reminder>Warning: the file exists but is shorter than the provided offset (${offset}). The file has ${totalLines} lines.</system-reminder>`,
105
126
  totalLines,
106
127
  };
107
128
  }
108
-
109
- const selected = lines.slice(offset - 1, offset - 1 + limit);
110
- const numbered = selected
111
- .map(
112
- (line, index) =>
113
- `${String(offset + index).padStart(6)}\t${line.length > 2000 ? line.slice(0, 2000) : line}`,
114
- )
115
- .join("\n");
116
- const complete = offset === 1 && selected.length === totalLines;
117
- const hasMore = offset - 1 + selected.length < totalLines;
118
- const notice = hasMore
119
- ? `\n\n<system-reminder>PARTIAL view: showing lines ${offset}-${offset + selected.length - 1} of ${totalLines}. Use offset and limit to read more.</system-reminder>`
120
- : "";
121
- return { text: numbered + notice, complete, totalLines };
129
+ const selected =
130
+ limit === undefined ? lines.slice(offset - 1) : lines.slice(offset - 1, offset - 1 + limit);
131
+ const text = selected.map((line, index) => `${offset + index}\t${line}`).join("\n");
132
+ return { text, totalLines };
122
133
  }
123
134
 
124
135
  function countMatches(content: string, needle: string): number {
@@ -155,15 +166,18 @@ export function exactReplace(
155
166
 
156
167
  async function requireCurrentRead(state: ClaudeCodeState, filePath: string): Promise<void> {
157
168
  const readSnapshot = state.reads.get(filePath);
158
- if (!readSnapshot)
159
- throw new Error(`File has not been read yet. Read it first before writing to it: ${filePath}`);
169
+ if (!readSnapshot) {
170
+ throw new Error("File has not been read yet. Read it first before writing to it.");
171
+ }
160
172
  if (!readSnapshot.textEditable) {
161
173
  throw new Error(`Cannot edit or overwrite a binary file with a text tool: ${filePath}`);
162
174
  }
163
175
  const currentContent = await readFile(filePath);
164
176
  const current = snapshotOf(currentContent);
165
177
  if (!snapshotsEqual(readSnapshot, current)) {
166
- throw new Error(`File has been modified since read. Read it again before writing: ${filePath}`);
178
+ throw new Error(
179
+ "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
180
+ );
167
181
  }
168
182
  }
169
183
 
@@ -173,7 +187,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
173
187
  label: "Read",
174
188
  description: [
175
189
  "Reads a file from the local filesystem. You can access any file directly using this tool.",
176
- "The file_path parameter must be an absolute path. By default, it reads up to 2000 lines from the beginning.",
190
+ "The file_path parameter must be an absolute path. By default, it reads the entire file; files over 256 KB or 25K tokens require offset and limit.",
177
191
  "Results use cat -n style line numbers starting at 1. Images are returned visually.",
178
192
  "This tool reads files, not directories.",
179
193
  ].join("\n"),
@@ -192,7 +206,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
192
206
  },
193
207
  { additionalProperties: false },
194
208
  ),
195
- async execute(_id, params, signal) {
209
+ async execute(_id, params, signal, _onUpdate, ctx) {
196
210
  throwIfAborted(signal);
197
211
  const filePath = requireAbsolutePath(params.file_path);
198
212
  if (
@@ -204,7 +218,19 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
204
218
  if (params.limit !== undefined && (!Number.isSafeInteger(params.limit) || params.limit < 1)) {
205
219
  throw new Error("limit must be a positive integer");
206
220
  }
207
- await assertReadableFile(filePath);
221
+ // 文件不存在 → 友好错误 + Did you mean(对齐 Claude Code)
222
+ try {
223
+ await assertReadableFile(filePath);
224
+ } catch (error) {
225
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
226
+ const suggestion = await didYouMean(filePath, ctx.cwd);
227
+ throw new Error(
228
+ `File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
229
+ { cause: error },
230
+ );
231
+ }
232
+ throw error;
233
+ }
208
234
  throwIfAborted(signal);
209
235
 
210
236
  const extension = extname(filePath).toLowerCase();
@@ -229,11 +255,21 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
229
255
  const buffer = await readFile(filePath);
230
256
  if (isBinary(buffer.subarray(0, SAMPLE_BYTES)))
231
257
  throw new Error(`Cannot read binary file: ${filePath}`);
232
- const formatted = formatReadOutput(
233
- buffer.toString("utf8"),
234
- params.offset ?? 1,
235
- params.limit ?? DEFAULT_READ_LINES,
236
- );
258
+ // 全读(limit 未传)时受字节上限约束(对齐 Claude Code)
259
+ if (params.limit === undefined && buffer.length > MAX_READ_SIZE_BYTES) {
260
+ throw new Error(
261
+ `File content (${formatFileSize(buffer.length)}) exceeds maximum allowed size (${formatFileSize(MAX_READ_SIZE_BYTES)}). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.`,
262
+ );
263
+ }
264
+ const text = buffer.toString("utf8");
265
+ const formatted = formatReadOutput(text, params.offset ?? 1, params.limit);
266
+ // 输出 token 粗估上限(无 tokenizer,4 字符/token),对读取范围生效
267
+ const estimatedTokens = Math.ceil(formatted.text.length / 4);
268
+ if (estimatedTokens > MAX_READ_TOKENS) {
269
+ throw new Error(
270
+ `File content (${estimatedTokens} tokens) exceeds maximum allowed tokens (${MAX_READ_TOKENS}). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.`,
271
+ );
272
+ }
237
273
  const snapshot = snapshotOf(buffer);
238
274
  state.reads.set(filePath, snapshot);
239
275
  return {
@@ -278,25 +314,109 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
278
314
  },
279
315
  });
280
316
  return withFileMutationQueue(filePath, async () => {
317
+ const oldString = params.old_string;
318
+ const newString = params.new_string;
319
+ if (oldString === newString) {
320
+ throw new Error("No changes to make: old_string and new_string are exactly the same.");
321
+ }
322
+ // 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
323
+ if (oldString === "") {
324
+ let exists = true;
325
+ try {
326
+ const value = await stat(filePath);
327
+ if (value.isFile()) {
328
+ const content = await readFile(filePath, "utf8");
329
+ if (content.trim() !== "") {
330
+ throw new Error("Cannot create new file - file already exists.");
331
+ }
332
+ }
333
+ } catch (error) {
334
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
335
+ exists = false;
336
+ } else {
337
+ throw error;
338
+ }
339
+ }
340
+ if (!exists) await mkdir(dirname(filePath), { recursive: true });
341
+ await writeFile(filePath, newString, "utf8");
342
+ const snapshot = snapshotOf(newString);
343
+ state.reads.set(filePath, snapshot);
344
+ return {
345
+ content: [
346
+ { type: "text", text: `The file ${filePath} has been updated successfully.` },
347
+ ],
348
+ details: { reads: { [filePath]: snapshot } } satisfies FileToolDetails,
349
+ };
350
+ }
351
+ const replaceAll = params.replace_all ?? false;
352
+ // 防止 OOM 的大文件检查(对齐 Claude Code)
353
+ try {
354
+ const { size } = await stat(filePath);
355
+ if (size > MAX_EDIT_FILE_SIZE) {
356
+ throw new Error(
357
+ `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
358
+ );
359
+ }
360
+ } catch (error) {
361
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
362
+ throw error;
363
+ }
364
+ }
365
+ let original: string;
366
+ try {
367
+ original = await readFile(filePath, "utf8");
368
+ } catch (error) {
369
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
370
+ const suggestion = await didYouMean(filePath, ctx.cwd);
371
+ throw new Error(
372
+ `File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
373
+ { cause: error },
374
+ );
375
+ }
376
+ throw error;
377
+ }
378
+ if (extname(filePath).toLowerCase() === ".ipynb") {
379
+ throw new Error(
380
+ "File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
381
+ );
382
+ }
281
383
  await requireCurrentRead(state, filePath);
282
384
  await access(filePath, constants.R_OK | constants.W_OK);
283
- const original = await readFile(filePath, "utf8");
284
385
  throwIfAborted(signal);
285
- const updated = exactReplace(
286
- original,
287
- params.old_string,
288
- params.new_string,
289
- params.replace_all ?? false,
290
- );
291
- await writeFile(filePath, updated, "utf8");
292
- const snapshot = snapshotOf(updated);
386
+
387
+ // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
388
+ const crlfCount = (original.match(/\r\n/g) ?? []).length;
389
+ const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
390
+ const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
391
+ const normalized = original.replaceAll("\r\n", "\n");
392
+ const actualOldString = findActualString(normalized, oldString) ?? oldString;
393
+ const matches = normalized.split(actualOldString).length - 1;
394
+ if (matches === 0) {
395
+ throw new Error(`String to replace not found in file.\nString: ${oldString}`);
396
+ }
397
+ if (!replaceAll && matches > 1) {
398
+ throw new Error(
399
+ `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, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
400
+ );
401
+ }
402
+ const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
403
+ // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
404
+ const updated = replaceAll
405
+ ? normalized.split(actualOldString).join(actualNewString)
406
+ : normalized.replace(actualOldString, () => actualNewString);
407
+ const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
408
+ await writeFile(filePath, restored, "utf8");
409
+ const snapshot = snapshotOf(restored);
293
410
  state.reads.set(filePath, snapshot);
294
- const diff = generateDiffString(original, updated);
411
+ const diff = generateDiffString(original, restored);
412
+ const text = replaceAll
413
+ ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
414
+ : `The file ${filePath} has been updated successfully.`;
295
415
  return {
296
- content: [{ type: "text", text: `The file ${filePath} has been updated successfully.` }],
416
+ content: [{ type: "text", text }],
297
417
  details: {
298
418
  diff: diff.diff,
299
- patch: generateUnifiedPatch(filePath, original, updated),
419
+ patch: generateUnifiedPatch(filePath, original, restored),
300
420
  firstChangedLine: diff.firstChangedLine,
301
421
  reads: { [filePath]: snapshot },
302
422
  } satisfies FileToolDetails,
@@ -341,7 +461,9 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
341
461
  original = await readFile(filePath, "utf8");
342
462
  }
343
463
  } catch (error) {
344
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
464
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
465
+ throw error;
466
+ }
345
467
  }
346
468
  throwIfAborted(signal);
347
469
  await mkdir(dirname(filePath), { recursive: true });
@@ -349,8 +471,12 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
349
471
  const snapshot = snapshotOf(params.content);
350
472
  state.reads.set(filePath, snapshot);
351
473
  const diff = generateDiffString(original ?? "", params.content);
474
+ const text =
475
+ original === undefined
476
+ ? `File created successfully at: ${filePath}`
477
+ : `The file ${filePath} has been updated successfully.`;
352
478
  return {
353
- content: [{ type: "text", text: `File created successfully at: ${filePath}` }],
479
+ content: [{ type: "text", text }],
354
480
  details: {
355
481
  diff: diff.diff,
356
482
  patch: generateUnifiedPatch(filePath, original ?? "", params.content),
@@ -5,42 +5,100 @@
5
5
  * independently (declare `Glob` in the frontmatter to get only this tool).
6
6
  */
7
7
 
8
+ import { execFile } from "node:child_process";
8
9
  import { readFileSync } from "node:fs";
9
- import { glob as fsGlob, stat } from "node:fs/promises";
10
- import { resolve } from "node:path";
10
+ import { stat } from "node:fs/promises";
11
+ import { basename, dirname, isAbsolute, join, sep } from "node:path";
11
12
  import { fileURLToPath } from "node:url";
13
+ import { promisify } from "node:util";
12
14
 
13
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
16
  import { Type } from "typebox";
15
17
 
16
- import { searchRoot, throwIfAborted } from "./common.js";
18
+ import { searchRoot, suggestPathUnderCwd, throwIfAborted, toRelativePath } from "./common.js";
17
19
 
18
20
  const GLOB_RESULT_LIMIT = 100;
19
21
 
22
+ const execFileAsync = promisify(execFile);
23
+
20
24
  /** Tool guidance, kept in markdown so it reads like documentation. */
21
25
  const GLOB_PROMPT = readFileSync(fileURLToPath(new URL("glob.md", import.meta.url)), "utf8").trim();
22
26
 
27
+ /**
28
+ * 从绝对 glob pattern 中提取搜索根目录和相对 pattern(rg 的 --glob 只接受
29
+ * 相对 pattern)。对齐 Claude Code 的 extractGlobBaseDirectory。
30
+ */
31
+ function extractGlobBaseDirectory(pattern: string): {
32
+ baseDir: string;
33
+ relativePattern: string;
34
+ } {
35
+ const globChars = /[*?[{]/;
36
+ const match = globChars.exec(pattern);
37
+ if (!match || match.index === undefined) {
38
+ // 无 glob 特殊字符:字面路径,目录部分作为 baseDir
39
+ return { baseDir: dirname(pattern), relativePattern: basename(pattern) };
40
+ }
41
+ const staticPrefix = pattern.slice(0, match.index);
42
+ const lastSepIndex = Math.max(staticPrefix.lastIndexOf("/"), staticPrefix.lastIndexOf(sep));
43
+ if (lastSepIndex === -1) return { baseDir: "", relativePattern: pattern };
44
+ let baseDir = staticPrefix.slice(0, lastSepIndex);
45
+ const relativePattern = pattern.slice(lastSepIndex + 1);
46
+ // 根目录 pattern(如 /*.txt):baseDir 为空但应使用 "/"
47
+ if (baseDir === "" && lastSepIndex === 0) baseDir = "/";
48
+ return { baseDir, relativePattern };
49
+ }
50
+
51
+ /**
52
+ * 对齐 Claude Code 的 glob(rg --files):按修改时间升序(最旧在前)排序,
53
+ * --no-ignore/--hidden 不尊重 gitignore、包含隐藏文件。相比真实 CC 额外排除
54
+ * .git(--hidden 会列出 .git 内容,属于噪音)。返回截断标记。
55
+ */
23
56
  export async function globFiles(
24
57
  pattern: string,
25
58
  cwd: string,
26
59
  signal?: AbortSignal,
27
- ): Promise<string[]> {
60
+ ): Promise<{ files: string[]; truncated: boolean }> {
28
61
  throwIfAborted(signal);
29
- const matches: { path: string; mtimeMs: number }[] = [];
30
- for await (const match of fsGlob(pattern, { cwd, exclude: [".git/**"], withFileTypes: false })) {
31
- throwIfAborted(signal);
32
- const absolutePath = resolve(cwd, match);
33
- try {
34
- const value = await stat(absolutePath);
35
- if (value.isFile()) matches.push({ path: absolutePath, mtimeMs: value.mtimeMs });
36
- } catch {
37
- // A concurrent filesystem change can remove a match before stat.
62
+ let searchDir = cwd;
63
+ let searchPattern = pattern;
64
+ if (isAbsolute(pattern)) {
65
+ const { baseDir, relativePattern } = extractGlobBaseDirectory(pattern);
66
+ if (baseDir) {
67
+ searchDir = baseDir;
68
+ searchPattern = relativePattern;
38
69
  }
39
70
  }
40
- return matches
41
- .toSorted((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path))
42
- .slice(0, GLOB_RESULT_LIMIT)
43
- .map((match) => match.path);
71
+ const args = [
72
+ "--files",
73
+ "--glob",
74
+ searchPattern,
75
+ "--sort=modified",
76
+ "--no-ignore",
77
+ "--hidden",
78
+ "--glob",
79
+ "!.git/**",
80
+ ];
81
+ let stdout: string;
82
+ try {
83
+ const result = await execFileAsync("rg", args, {
84
+ cwd: searchDir,
85
+ maxBuffer: 10 * 1024 * 1024,
86
+ ...(signal && { signal }),
87
+ });
88
+ stdout = result.stdout;
89
+ } catch (error) {
90
+ if (signal?.aborted) throwIfAborted(signal);
91
+ throw new Error(`ripgrep failed: ${error instanceof Error ? error.message : String(error)}`, {
92
+ cause: error,
93
+ });
94
+ }
95
+ // rg 输出相对 searchDir 的路径,转成绝对路径
96
+ const lines = stdout ? stdout.replace(/\n$/, "").split("\n") : [];
97
+ const absolutePaths = lines.map((path) => (isAbsolute(path) ? path : join(searchDir, path)));
98
+ return {
99
+ files: absolutePaths.slice(0, GLOB_RESULT_LIMIT),
100
+ truncated: absolutePaths.length > GLOB_RESULT_LIMIT,
101
+ };
44
102
  }
45
103
 
46
104
  export function registerGlobTool(pi: ExtensionAPI): void {
@@ -50,7 +108,7 @@ export function registerGlobTool(pi: ExtensionAPI): void {
50
108
  description: [
51
109
  "Fast file pattern matching tool that works with any codebase size.",
52
110
  'Supports glob patterns such as "**/*.js" and "src/**/*.ts".',
53
- "Returns matching file paths sorted by modification time.",
111
+ "Returns matching file paths sorted by modification time (oldest first).",
54
112
  ].join("\n"),
55
113
  promptSnippet: "Find files by name patterns",
56
114
  promptGuidelines: [`- -\n${GLOB_PROMPT}`],
@@ -68,12 +126,30 @@ export function registerGlobTool(pi: ExtensionAPI): void {
68
126
  ),
69
127
  async execute(_id, params, signal, _onUpdate, ctx) {
70
128
  const root = searchRoot(params.path, ctx.cwd);
71
- const matches = await globFiles(params.pattern, root, signal);
129
+ if (params.path) {
130
+ let stats;
131
+ try {
132
+ stats = await stat(root);
133
+ } catch (error) {
134
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
135
+ const suggestion = await suggestPathUnderCwd(root, ctx.cwd);
136
+ throw new Error(
137
+ `Directory does not exist: ${params.path}. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
138
+ { cause: error },
139
+ );
140
+ }
141
+ throw error;
142
+ }
143
+ if (!stats.isDirectory()) throw new Error(`Path is not a directory: ${params.path}`);
144
+ }
145
+ const { files, truncated } = await globFiles(params.pattern, root, signal);
146
+ const filenames = files.map((filePath) => toRelativePath(filePath, ctx.cwd));
147
+ const lines = truncated
148
+ ? [...filenames, "(Results are truncated. Consider using a more specific path or pattern.)"]
149
+ : filenames;
72
150
  return {
73
- content: [
74
- { type: "text", text: matches.length > 0 ? matches.join("\n") : "No files found" },
75
- ],
76
- details: { count: matches.length },
151
+ content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No files found" }],
152
+ details: { count: filenames.length },
77
153
  };
78
154
  },
79
155
  });
@@ -17,7 +17,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
19
19
 
20
- import { searchRoot, throwIfAborted } from "./common.js";
20
+ import { searchRoot, suggestPathUnderCwd, throwIfAborted, toRelativePath } from "./common.js";
21
21
 
22
22
  const GREP_OUTPUT_MODES = ["content", "files_with_matches", "count"] as const;
23
23
 
@@ -70,7 +70,8 @@ export function buildGrepArguments(params: GrepParameters, cwd: string): string[
70
70
  break;
71
71
  }
72
72
  case "count": {
73
- args.push("--count-matches");
73
+ // -c 统计匹配行数(对齐 Claude Code;--count-matches 是匹配次数)
74
+ args.push("-c");
74
75
  break;
75
76
  }
76
77
  case "content": {
@@ -90,10 +91,26 @@ export function buildGrepArguments(params: GrepParameters, cwd: string): string[
90
91
  // No default
91
92
  }
92
93
  if (params["-i"] === true) args.push("--ignore-case");
93
- if (params.glob) args.push("--glob", params.glob);
94
94
  if (params.type) args.push("--type", params.type);
95
95
  if (params.multiline === true) args.push("--multiline", "--multiline-dotall");
96
- args.push("--", params.pattern, searchRoot(params.path, cwd));
96
+ // glob 按逗号/空格拆分(花括号模式不拆),对齐 Claude Code
97
+ if (params.glob) {
98
+ const globPatterns: string[] = [];
99
+ for (const rawPattern of params.glob.split(/\s+/)) {
100
+ if (rawPattern.includes("{") && rawPattern.includes("}")) {
101
+ globPatterns.push(rawPattern);
102
+ } else {
103
+ globPatterns.push(...rawPattern.split(",").filter(Boolean));
104
+ }
105
+ }
106
+ for (const globPattern of globPatterns) {
107
+ if (globPattern) args.push("--glob", globPattern);
108
+ }
109
+ }
110
+ // 以 - 开头的 pattern 用 -e 显式声明,防止被 rg 当作选项
111
+ if (params.pattern.startsWith("-")) args.push("-e", params.pattern);
112
+ else args.push(params.pattern);
113
+ args.push(searchRoot(params.path, cwd));
97
114
  return args;
98
115
  }
99
116
 
@@ -120,8 +137,9 @@ export async function sortFilesByMtime(output: string): Promise<string> {
120
137
  /**
121
138
  * Append an occurrence/file summary to `filename:count` output (count mode),
122
139
  * mirroring Claude Code's "Found N occurrences across M files" result.
140
+ * `limitInfo`(如 "limit: 250, offset: 5")非空时追加 pagination 说明。
123
141
  */
124
- export function summarizeCountOutput(output: string): string {
142
+ export function summarizeCountOutput(output: string, limitInfo?: string): string {
125
143
  const lines = output.split("\n").filter((line) => line.includes(":"));
126
144
  let occurrences = 0;
127
145
  for (const line of lines) {
@@ -131,14 +149,61 @@ export function summarizeCountOutput(output: string): string {
131
149
  const files = lines.length;
132
150
  const occurrenceLabel = occurrences === 1 ? "occurrence" : "occurrences";
133
151
  const fileLabel = files === 1 ? "file" : "files";
134
- return `${output.trimEnd()}\n\nFound ${occurrences} total ${occurrenceLabel} across ${files} ${fileLabel}.`;
152
+ return `${output.trimEnd()}\n\nFound ${occurrences} total ${occurrenceLabel} across ${files} ${fileLabel}.${limitInfo ? ` with pagination = ${limitInfo}` : ""}`;
135
153
  }
136
154
 
137
- export function pageGrepOutput(output: string, offset = 0, headLimit = 0): string {
155
+ /**
156
+ * 对齐 Claude Code 的 applyHeadLimit:offset 跳过前 N 条;head_limit=0 表示
157
+ * 无限;appliedLimit 仅在真正截断时返回(模型据此知道可以继续分页)。
158
+ */
159
+ export function pageGrepOutput(
160
+ output: string,
161
+ offset = 0,
162
+ headLimit = 0,
163
+ ): { lines: string[]; appliedLimit: number | undefined; appliedOffset: number | undefined } {
138
164
  const lines = output ? output.replace(/\n$/, "").split("\n") : [];
139
- if (offset >= lines.length && lines.length > 0) return "No entries at this offset";
140
- const selected = headLimit > 0 ? lines.slice(offset, offset + headLimit) : lines.slice(offset);
141
- return truncateOutput(selected.join("\n"));
165
+ if (headLimit === 0) {
166
+ return {
167
+ lines: lines.slice(offset),
168
+ appliedLimit: undefined,
169
+ appliedOffset: offset > 0 ? offset : undefined,
170
+ };
171
+ }
172
+ const sliced = lines.slice(offset, offset + headLimit);
173
+ return {
174
+ lines: sliced,
175
+ appliedLimit: lines.length - offset > headLimit ? headLimit : undefined,
176
+ appliedOffset: offset > 0 ? offset : undefined,
177
+ };
178
+ }
179
+
180
+ /** 分页信息文本,仅包含实际发生/提供的部分(对齐 Claude Code)。 */
181
+ function formatLimitInfo(
182
+ appliedLimit: number | undefined,
183
+ appliedOffset: number | undefined,
184
+ ): string {
185
+ const parts: string[] = [];
186
+ if (appliedLimit !== undefined) parts.push(`limit: ${appliedLimit}`);
187
+ if (appliedOffset) parts.push(`offset: ${appliedOffset}`);
188
+ return parts.join(", ");
189
+ }
190
+
191
+ /** content 模式行:路径前缀相对化(`/abs/path:line:content` 取第一个冒号)。 */
192
+ function relativizeContentLine(line: string, cwd: string): string {
193
+ const colonIndex = line.indexOf(":");
194
+ if (colonIndex > 0) {
195
+ return toRelativePath(line.slice(0, colonIndex), cwd) + line.slice(colonIndex);
196
+ }
197
+ return line;
198
+ }
199
+
200
+ /** count 模式行:路径前缀相对化(`/abs/path:count` 取最后一个冒号)。 */
201
+ function relativizeCountLine(line: string, cwd: string): string {
202
+ const colonIndex = line.lastIndexOf(":");
203
+ if (colonIndex > 0) {
204
+ return toRelativePath(line.slice(0, colonIndex), cwd) + line.slice(colonIndex);
205
+ }
206
+ return line;
142
207
  }
143
208
 
144
209
  export function registerGrepTool(pi: ExtensionAPI): void {
@@ -210,29 +275,88 @@ export function registerGrepTool(pi: ExtensionAPI): void {
210
275
  ) {
211
276
  throw new Error("head_limit must be a non-negative integer");
212
277
  }
278
+ if (params.path) {
279
+ const absolutePath = searchRoot(params.path, ctx.cwd);
280
+ try {
281
+ await stat(absolutePath);
282
+ } catch (error) {
283
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
284
+ const suggestion = await suggestPathUnderCwd(absolutePath, ctx.cwd);
285
+ throw new Error(
286
+ `Path does not exist: ${params.path}. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
287
+ { cause: error },
288
+ );
289
+ }
290
+ throw error;
291
+ }
292
+ }
213
293
  const result = await pi.exec("rg", buildGrepArguments(params, ctx.cwd), { signal });
214
294
  throwIfAborted(signal);
215
295
  if (result.code !== 0 && result.code !== 1) {
216
296
  throw new Error(result.stderr.trim() || `ripgrep exited with code ${result.code}`);
217
297
  }
218
- if (result.code === 1 || result.stdout === "") {
219
- return { content: [{ type: "text", text: "No files found" }], details: { matches: 0 } };
220
- }
221
298
  const mode = params.output_mode ?? "files_with_matches";
222
- // head_limit defaults to DEFAULT_HEAD_LIMIT; an explicit 0 means unlimited.
223
- const stdout =
224
- mode === "files_with_matches" ? await sortFilesByMtime(result.stdout) : result.stdout;
225
- const text = pageGrepOutput(
226
- stdout,
227
- params.offset ?? 0,
228
- params.head_limit ?? DEFAULT_HEAD_LIMIT,
229
- );
299
+ const offset = params.offset ?? 0;
300
+ const headLimit = params.head_limit ?? DEFAULT_HEAD_LIMIT;
301
+ // rg 退出码 1 = 无匹配,stdout 为空
302
+ const stdout = result.code === 1 ? "" : result.stdout;
303
+
304
+ if (mode === "files_with_matches") {
305
+ if (stdout === "") {
306
+ return { content: [{ type: "text", text: "No files found" }], details: { matches: 0 } };
307
+ }
308
+ const sorted = await sortFilesByMtime(stdout);
309
+ const { lines, appliedLimit, appliedOffset } = pageGrepOutput(sorted, offset, headLimit);
310
+ const filenames = lines.map((filePath) => toRelativePath(filePath, ctx.cwd));
311
+ const limitInfo = formatLimitInfo(appliedLimit, appliedOffset);
312
+ const text = truncateOutput(
313
+ `Found ${filenames.length} ${filenames.length === 1 ? "file" : "files"}${limitInfo ? ` ${limitInfo}` : ""}\n${filenames.join("\n")}`,
314
+ );
315
+ return { content: [{ type: "text", text }], details: { matches: filenames.length } };
316
+ }
317
+
230
318
  if (mode === "count") {
319
+ if (stdout === "") {
320
+ return {
321
+ content: [
322
+ {
323
+ type: "text",
324
+ text: "No matches found\n\nFound 0 total occurrences across 0 files.",
325
+ },
326
+ ],
327
+ details: undefined,
328
+ };
329
+ }
330
+ const { lines, appliedLimit, appliedOffset } = pageGrepOutput(stdout, offset, headLimit);
331
+ const relativized = lines.map((line) => relativizeCountLine(line, ctx.cwd));
231
332
  return {
232
- content: [{ type: "text", text: summarizeCountOutput(text) }],
333
+ content: [
334
+ {
335
+ type: "text",
336
+ text: truncateOutput(
337
+ summarizeCountOutput(
338
+ relativized.join("\n"),
339
+ formatLimitInfo(appliedLimit, appliedOffset),
340
+ ),
341
+ ),
342
+ },
343
+ ],
233
344
  details: undefined,
234
345
  };
235
346
  }
347
+
348
+ // content mode
349
+ if (stdout === "") {
350
+ return { content: [{ type: "text", text: "No matches found" }], details: undefined };
351
+ }
352
+ const { lines, appliedLimit, appliedOffset } = pageGrepOutput(stdout, offset, headLimit);
353
+ const relativized = lines.map((line) => relativizeContentLine(line, ctx.cwd)).join("\n");
354
+ const limitInfo = formatLimitInfo(appliedLimit, appliedOffset);
355
+ const text = truncateOutput(
356
+ limitInfo
357
+ ? `${relativized}\n\n[Showing results with pagination = ${limitInfo}]`
358
+ : relativized,
359
+ );
236
360
  return { content: [{ type: "text", text }], details: undefined };
237
361
  },
238
362
  });
@@ -5,9 +5,9 @@ Reads a file from the local filesystem. You can access any file directly by usin
5
5
  Usage:
6
6
 
7
7
  - The file_path parameter must be an absolute path, not a relative path
8
- - By default, it reads up to 2000 lines starting from the beginning of the file
8
+ - By default, it reads the entire file; files over 256 KB or 25K tokens require offset and limit to read specific portions
9
9
  - When you already know which part of the file you need, only read that part. This can be important for larger files.
10
- - Results are returned using cat -n format, with line numbers starting at 1
10
+ - Results are returned as `<lineNumber>\t<content>` lines, 1-indexed (Claude Code Read format)
11
11
  - This tool allows you to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually.
12
12
  - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
13
13
  - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
@@ -158,7 +158,8 @@ export function registerSessionTools(pi: ExtensionAPI): void {
158
158
  content: [
159
159
  {
160
160
  type: "text" as const,
161
- 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.",
161
+ // Claude Code TodoWrite 逐字一致(无结尾句号)
162
+ 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",
162
163
  },
163
164
  ],
164
165
  details: { todos },
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: claude-code-tools
3
+ description: Exact behavior of the Claude Code style tools (Read/Edit/Write/Grep/Glob/Bash/TodoWrite/AskUserQuestion) in the @trim21/personal-pi-extensions package: output formats, matching rules, read-before-write requirements, pagination semantics, and conventions. Load whenever you use these tools or are unsure how they behave.
4
+ ---
5
+
6
+ # Claude Code Tools Behavior
7
+
8
+ This package registers two parallel tool suites: opencode style (lowercase `read`/`edit`/`write`/`bash`/`todowrite`/`question`) and Claude Code style (capitalized `Read`/`Edit`/`Write`/`Bash`/`Grep`/`Glob`/`TodoWrite`/`AskUserQuestion`). They share the bwrap sandbox and write-guard. **Only one suite should be enabled** — enabling both duplicates commands (e.g. `/bwrap` vs `/bwrap:1`) and injects the bwrap system-prompt section twice.
9
+
10
+ The capitalized tools below follow Claude Code behavior with a few deliberate deviations. Where behavior differs from stock Claude Code it is called out.
11
+
12
+ ## Read
13
+
14
+ - Output is `<lineNumber>\t<content>` per line, 1-indexed, **no padding** (compact format).
15
+ - Input is normalized: UTF-8 BOM stripped, `\r\n` → `\n` (CRLF stripped), and a trailing empty line is always present — **`totalLines` is one more than the editor line count** for non-empty files.
16
+ - By default reads the **entire file**, capped at 256 KB (bytes) and a rough 25K-token estimate (4 chars/token, no tokenizer). Whole reads over either cap error with `File content (X) exceeds maximum allowed size/tokens (...) — use offset and limit`; providing `limit` bypasses the byte cap and only the selected range counts toward the token cap.
17
+ - `offset`/`limit` are 1-based positive integers. Out-of-range offset returns `Warning: the file exists but is shorter than the provided offset (N). The file has M lines.`; empty files return `Warning: the file exists but the contents are empty.`
18
+ - Missing file → `File does not exist. Note: your current working directory is <cwd>.` plus a `Did you mean ...?` suggestion (same-base different-extension, or a corrected path under cwd).
19
+
20
+ ## Edit / Write
21
+
22
+ - **You must Read a file before editing or overwriting it.** The tool compares a content digest against the last read; after your own Edit/Write the recorded digest is refreshed, so consecutive edits by you are fine. An external change (user edit, linter, another process) triggers `File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.` — re-Read before writing.
23
+ - **Deviation from Claude Code:** staleness is checked by content digest, not mtime.
24
+
25
+ ### Edit specifics
26
+
27
+ - Matching first tries an exact match, then a **quote-normalized match** (curly quotes in the file match straight quotes from the model); the replacement inherits the file's curly-quote style.
28
+ - Matching happens on CRLF-normalized content — `old_string` never needs `\r` — and the file's dominant line ending is restored on write.
29
+ - Empty `old_string` means create-or-fill: nonexistent file → create it; empty file → fill it; non-empty file → `Cannot create new file - file already exists.` Neither create nor fill requires a prior Read.
30
+ - `old_string === new_string` → `No changes to make: old_string and new_string are exactly the same.`
31
+ - Not found → `String to replace not found in file.\nString: <old_string>`
32
+ - Multiple matches without `replace_all` → error listing the match count and asking for more context, with `\nString: <old_string>`.
33
+ - `replace_all: true` success message: `The file X has been updated. All occurrences were successfully replaced.`
34
+ - Missing file → `File does not exist. Note: your current working directory is <cwd>.` plus a `Did you mean ...?` suggestion (same-base different-extension, or a corrected path under cwd).
35
+ - Files over 1 GiB are refused; `.ipynb` files are refused.
36
+
37
+ ### Write specifics
38
+
39
+ - Creating a new file → `File created successfully at: X`; overwriting an existing file → `The file X has been updated successfully.` (the distinction is reported even though both are one tool).
40
+ - New files need no prior Read; overwriting does.
41
+
42
+ ## Grep
43
+
44
+ - Modes: `files_with_matches` (default), `content`, `count`. All output paths are **relative to cwd** (absolute when outside cwd).
45
+ - `files_with_matches`: `Found N files\n<relative paths>` sorted by mtime, newest first. No matches → `No files found`.
46
+ - `content`: `path:line:content` lines. No matches → `No matches found`.
47
+ - `count`: per-file match-line counts (`-c` semantics, not match occurrences) plus `Found N total occurrences across M files.` No matches → `No matches found` + `Found 0 total occurrences across 0 files.`
48
+ - `head_limit` defaults to 250 (0 = unlimited), `offset` skips entries. When truncation or offset actually applied, a pagination note is appended (`limit: N, offset: N`), e.g. `[Showing results with pagination = limit: N, offset: N]`.
49
+ - `glob` accepts comma/space-separated patterns (brace patterns not split). `-i`, `-B/-A/-C`/`context`, `-n` (default true in content mode), `type`, `multiline` map to ripgrep flags. VCS dirs (`.git` etc.) are excluded.
50
+ - A missing `path` → `Path does not exist: ... Note: your current working directory is <cwd>.` with a corrected-path suggestion when applicable.
51
+
52
+ ## Glob
53
+
54
+ - Backed by ripgrep (`--files --sort=modified`): results are **oldest first**, hidden files included, `.git` excluded, up to 100 results.
55
+ - Output paths are relative to cwd. When truncated, a final line `(Results are truncated. Consider using a more specific path or pattern.)` is appended.
56
+ - No matches → `No files found`. `path` must be an existing directory, else `Directory does not exist: ...` / `Path is not a directory: ...`.
57
+
58
+ ## Bash
59
+
60
+ - Commands run through the bwrap sandbox (modes: `allow-all` / `workspace-write` / `allow-net` / `readonly`), switchable via `/bwrap-*` commands. `dangerouslyDisableSandbox: true` requests one-time unsandboxed execution and needs user approval (denied in headless sessions).
61
+ - `timeout` is in milliseconds, default 120000, max 600000. `workdir` overrides the working directory.
62
+ - **Deviation from Claude Code:** no auto-backgrounding on timeout — a timed-out command is killed and the error reports the timeout.
63
+
64
+ ## TodoWrite
65
+
66
+ - Full-list replacement semantics: pass the complete updated list every call; exactly one `in_progress` allowed; statuses `pending` | `in_progress` | `completed`; each item needs `content` and `activeForm`.
67
+ - The list renders as a widget and is persisted in tool `details`; after a restart the widget is restored from the session branch. The list is **not** auto-cleared when all items are completed.
68
+
69
+ ## AskUserQuestion
70
+
71
+ - Blocking: 1–4 questions, 2–4 options each; `multiSelect` for multiple selection. An `Other` free-text option is provided automatically.
72
+ - Returns `User has answered your questions: "q"="a", ... . You can now continue with the user's answers in mind.`