@trim21/personal-pi-extensions 0.0.242 → 0.0.244
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +3 -2
- package/src/bwrap/runtime.ts +0 -2
- package/src/claude-code/common.ts +81 -1
- package/src/claude-code/edit-utils.ts +116 -0
- package/src/claude-code/files.ts +182 -56
- package/src/claude-code/glob.ts +99 -23
- package/src/claude-code/grep.ts +146 -22
- package/src/claude-code/read.md +2 -2
- package/src/claude-code/session-tools.ts +2 -1
- package/src/claude-code/shell.ts +11 -4
- package/src/claude-code/skills/claude-code-tools/SKILL.md +72 -0
- package/src/opencode/bash.ts +6 -3
- package/src/spawn-agent.ts +3 -3
package/README.md
CHANGED
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
| [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
|
|
18
18
|
| [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
|
|
19
19
|
|
|
20
|
+
> **两套工具风格,按预期只启用其中一套**:本包同时提供 opencode 风格
|
|
21
|
+
> (小写 `read`/`edit`/`write`/`bash`/`todowrite`/`question`)与 Claude Code
|
|
22
|
+
> 风格(大写 `Read`/`Edit`/`Write`/`Bash`/`Grep`/`Glob`/`TodoWrite`/
|
|
23
|
+
> `AskUserQuestion`)两套工具集,二者共享 bwrap 沙箱与写保护实现。两套同时
|
|
24
|
+
> 启用会带来预期外的冗余:同名命令重复注册(如 `/bwrap` 出现 `/bwrap:1`
|
|
25
|
+
> 后缀)、系统提示重复注入。请只启用其中一套:在 `~/.pi/agent/settings.json`
|
|
26
|
+
> 的 `defaultTools` 中只列出一套,或启动时用 `--exclude-tools` 排除另一套。
|
|
27
|
+
|
|
20
28
|
---
|
|
21
29
|
|
|
22
30
|
## bwrap
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.244",
|
|
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": {
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import {
|
|
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
|
+
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
85
|
-
): { text: string;
|
|
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
|
|
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
|
|
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
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
const
|
|
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,
|
|
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
|
|
416
|
+
content: [{ type: "text", text }],
|
|
297
417
|
details: {
|
|
298
418
|
diff: diff.diff,
|
|
299
|
-
patch: generateUnifiedPatch(filePath, original,
|
|
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
|
|
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
|
|
479
|
+
content: [{ type: "text", text }],
|
|
354
480
|
details: {
|
|
355
481
|
diff: diff.diff,
|
|
356
482
|
patch: generateUnifiedPatch(filePath, original ?? "", params.content),
|
package/src/claude-code/glob.ts
CHANGED
|
@@ -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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/claude-code/grep.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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: [
|
|
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
|
});
|
package/src/claude-code/read.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 },
|
package/src/claude-code/shell.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
|
|
8
8
|
import { resolveWorkdir } from "../lib/path.js";
|
|
9
9
|
|
|
10
10
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -13,8 +13,15 @@ const MAX_TIMEOUT_MS = 600_000;
|
|
|
13
13
|
/** Bash tool guidance, kept in markdown so it reads like documentation. */
|
|
14
14
|
const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* runtime 由调用方注入:扩展工厂持有一个实例(不依赖模块级全局状态),
|
|
18
|
+
* 测试可注入预置模式的实例。状态随扩展实例生命周期,session 切换重建即重置。
|
|
19
|
+
*/
|
|
20
|
+
export function registerShellTools(
|
|
21
|
+
pi: ExtensionAPI,
|
|
22
|
+
runtime: BwrapRuntime = createBwrapRuntime(),
|
|
23
|
+
): void {
|
|
24
|
+
runtime.setup(pi);
|
|
18
25
|
pi.registerTool({
|
|
19
26
|
name: "Bash",
|
|
20
27
|
promptSnippet: "execute command",
|
|
@@ -58,7 +65,7 @@ export function registerShellTools(pi: ExtensionAPI): void {
|
|
|
58
65
|
const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
|
|
59
66
|
|
|
60
67
|
try {
|
|
61
|
-
return await
|
|
68
|
+
return await runtime.execute({
|
|
62
69
|
ctx: { ...ctx, cwd },
|
|
63
70
|
toolCallId: id,
|
|
64
71
|
command: params.command,
|
|
@@ -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.`
|
package/src/opencode/bash.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { createBwrapRuntime } from "../bwrap/runtime.js";
|
|
5
5
|
import { resolveWorkdir } from "../lib/path.js";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
8
8
|
const MAX_TIMEOUT_MS = 600_000;
|
|
9
9
|
|
|
10
10
|
export default function opencodeBash(pi: ExtensionAPI): void {
|
|
11
|
-
|
|
11
|
+
// 每个扩展实例持有自己的 runtime:不依赖模块级全局状态,状态随扩展
|
|
12
|
+
// 实例生命周期(进程启动 / /reload / session 切换时工厂重建即重置)。
|
|
13
|
+
const runtime = createBwrapRuntime();
|
|
14
|
+
runtime.setup(pi);
|
|
12
15
|
pi.registerTool({
|
|
13
16
|
name: "bash",
|
|
14
17
|
label: "bash",
|
|
@@ -52,7 +55,7 @@ export default function opencodeBash(pi: ExtensionAPI): void {
|
|
|
52
55
|
const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
|
|
53
56
|
|
|
54
57
|
try {
|
|
55
|
-
return await
|
|
58
|
+
return await runtime.execute({
|
|
56
59
|
ctx: { ...ctx, cwd },
|
|
57
60
|
toolCallId: id,
|
|
58
61
|
command: params.command,
|
package/src/spawn-agent.ts
CHANGED
|
@@ -54,9 +54,9 @@ const MAX_PROGRESS_LINES = 5;
|
|
|
54
54
|
* built-in tool, the matching opencode extension is loaded via `-e` so the
|
|
55
55
|
* subagent uses the enhanced implementation instead of the built-in one.
|
|
56
56
|
*
|
|
57
|
-
* The bash override also carries the bwrap sandbox: opencode/bash.ts
|
|
58
|
-
*
|
|
59
|
-
* agents that declare the bash tool get sandboxing automatically. Agents
|
|
57
|
+
* The bash override also carries the bwrap sandbox: opencode/bash.ts creates
|
|
58
|
+
* its own bwrap runtime instance and runs commands through runtime.execute(),
|
|
59
|
+
* so agents that declare the bash tool get sandboxing automatically. Agents
|
|
60
60
|
* without bash need no bwrap setup (there are no commands to sandbox).
|
|
61
61
|
* (Workspace write protection is embedded in the opencode write/edit tools.)
|
|
62
62
|
*
|