@trim21/personal-pi-extensions 0.1.681 → 0.1.683
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 +13 -9
- package/package.json +1 -1
- package/src/claude-code/shell.ts +167 -96
- package/src/codemode/tool.ts +8 -1
- package/src/opencode/bash.ts +158 -105
package/README.md
CHANGED
|
@@ -36,10 +36,12 @@
|
|
|
36
36
|
- **嵌套调用经工具总线**:`call("Bash", { command })` 最终执行的是 `Bash` 工具自己的
|
|
37
37
|
`execute`,所以工具的审批照常生效(Bash 沙箱外执行会弹自己的提权确认)。codemode
|
|
38
38
|
不额外加确认层:脚本里连发十次调用就是十次工具自己的审批(需要审批的那些)。
|
|
39
|
-
- **可调用集合** =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
- **可调用集合** = 总线上实际注册的工具减去排除名单,执行时再与当前 active 列表求交,
|
|
40
|
+
所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` / `--tools`、子代理的工具
|
|
41
|
+
白名单都同样约束脚本。排除名单:`codemode` 自身与 `spawn-agent`;两套文件工具集的
|
|
42
|
+
读写工具(`Read`/`Edit`/`Write` 与 `read`/`edit`/`write`);两套工具集的搜索工具
|
|
43
|
+
(`Grep`/`Glob` 与 `grep`/`glob`)——脚本搜文件用 `call("Bash", { command: "rg …" })`,
|
|
44
|
+
走同一个沙箱、拿得到退出码,还能拼管道,而那两个工具是给模型看结果的。
|
|
43
45
|
- **文件读写只有 `fs` 一条路**:`fs.read(path)` 返回文件全文的原始 UTF-8 文本(不加行号、
|
|
44
46
|
不截断、不设大小上限,只有内容不是合法 UTF-8 时报错),`fs.write(path, content)` 整体写入并自动创建
|
|
45
47
|
父目录,相对路径相对当前 cwd。`fs.write` 与写类工具共用同一套保护:写前要求「已读且读后
|
|
@@ -49,10 +51,12 @@
|
|
|
49
51
|
直接改,不进脚本)。
|
|
50
52
|
- **只有脚本输出进上下文**:`text(value)` / `console.log(...)` 与 `return` 值进入工具结果,
|
|
51
53
|
中间的工具调用与它们的返回内容不会(也不在会话记录里留下工具调用条目)。
|
|
52
|
-
- **返回值**:声明了 `structuredSchema`
|
|
53
|
-
|
|
54
|
-
`
|
|
55
|
-
|
|
54
|
+
- **返回值**:声明了 `structuredSchema` 的工具把结果放在 `structuredResult` 里,`call()`
|
|
55
|
+
解包成对象给脚本——gh-readonly 的读类工具给解析后的 JSON,`Bash` / `bash` 给
|
|
56
|
+
`{ exitCode, output }`(`exitCode` 为 `null` 表示命令被超时/中止杀掉;命令非零退出是正常
|
|
57
|
+
结果,直接读 `exitCode` 分支即可)。`{ ok: false, error }` 会 reject 成 `CallFailedError`
|
|
58
|
+
(脚本可按 `instanceof CallFailedError` 区分调用失败与自身运行期错误)。没有声明输出
|
|
59
|
+
结构的工具回退成工具输出的文本。
|
|
56
60
|
- **脚本接口**:`call` / `CallFailedError` / `ALL_TOOLS` / `fs.read` / `fs.write` /
|
|
57
61
|
`text` / `image` / `exit` / `console.*` / `store.set` / `store.get` / `store.list`
|
|
58
62
|
(会话内持久的键值表);首行可选 `// @options: {"max_output_tokens": 10000}`。
|
|
@@ -425,7 +429,7 @@ Claude Code 风格工具集:`Read` / `Edit` / `Write` / `Grep` / `Glob` / `Bas
|
|
|
425
429
|
|
|
426
430
|
- **`Read` / `Edit` / `Write`**:与 opencode 风格共享 read-before-write 记账与文案(`File has not been read yet...` / `File has been modified since read...`),差异是三者都要求先 `Read`(`Edit` 用空 `old_string` 创建新文件、`Write` 创建新文件例外)。`Read` 支持 offset / limit 分页与 PDF `pages`
|
|
427
431
|
- **`Grep` / `Glob`**:ripgrep 实现,行为跟随 Claude Code(`Glob` 带 `--no-ignore` / `--hidden` / `--sort=modified`,与 opencode 风格 `glob` 的差异是各自跟随上游)
|
|
428
|
-
- **`Bash`**:与 opencode 风格 `bash` 共享 bwrap 运行时与 `dangerouslyDisableSandbox` 提权,默认超时 120
|
|
432
|
+
- **`Bash`**:与 opencode 风格 `bash` 共享 bwrap 运行时与 `dangerouslyDisableSandbox` 提权,默认超时 120 秒,只支持同步执行。结构化结果给 `{ exitCode, output }`:`output` 是命令的**完整**输出(文本该截断还截断,载荷里是全文),`exitCode` 为 `null` 表示被超时/中止杀掉;命令非零退出不是失败,脚本直接读 `exitCode` 分支
|
|
429
433
|
- **`TodoWrite`**:任务列表工具(`merge` 语义),与 opencode 风格 `todowrite` 的完整替换语义不同,不要混用
|
|
430
434
|
- **`AskUserQuestion`**:阻塞式向用户提问
|
|
431
435
|
|
package/package.json
CHANGED
package/src/claude-code/shell.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
sandboxHintBlock,
|
|
18
18
|
} from "../bwrap/runtime.js";
|
|
19
19
|
import { resolveWorkdir } from "../lib/path.js";
|
|
20
|
-
import type
|
|
20
|
+
import { defineStructuredTool, type ToolBus } from "../lib/tool-bus.js";
|
|
21
21
|
|
|
22
22
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
23
23
|
const MAX_TIMEOUT_MS = 7_200_000;
|
|
@@ -66,6 +66,39 @@ function appendTruncationNotice(
|
|
|
66
66
|
return `${text}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* `Bash` 的结构化结果:只有退出码与完整输出——命令非零退出不是失败,所以载荷里没有成败
|
|
71
|
+
* 标志,脚本直接读 `exitCode` 分支;超时/中止时 `exitCode` 为 `null`(原因在模型侧文本里)。
|
|
72
|
+
*/
|
|
73
|
+
const bashStructuredSchema = Type.Object({
|
|
74
|
+
exitCode: Type.Union([Type.Number(), Type.Null()], {
|
|
75
|
+
description: "Exit code of the command; null when it was killed (timeout or abort)",
|
|
76
|
+
}),
|
|
77
|
+
output: Type.String({
|
|
78
|
+
description:
|
|
79
|
+
"Complete output of the command (stdout and stderr merged). Never truncated, never mixed with tool-added notices.",
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 载荷里的输出:文本被截断时读回落盘的完整输出(读不到就退回那份截断文本),
|
|
85
|
+
* 因此脚本拿到的永远是命令真正输出的内容。
|
|
86
|
+
*/
|
|
87
|
+
async function fullOutput(
|
|
88
|
+
output: string,
|
|
89
|
+
truncation: TruncationResult,
|
|
90
|
+
spillPath: string | undefined,
|
|
91
|
+
): Promise<string> {
|
|
92
|
+
if (spillPath === undefined || !truncation.truncated) {
|
|
93
|
+
return output;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return await readFile(spillPath, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return output;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
/**
|
|
70
103
|
* 成功路径:消费 runtime 的截断结果(输出已由 runtime 截断并落盘),
|
|
71
104
|
* 截断时追加 `[Showing lines X-Y of N. Full output: path]` 提示。
|
|
@@ -88,116 +121,154 @@ function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execute"]>>)
|
|
|
88
121
|
* 测试可注入预置模式的实例。状态随扩展实例生命周期,session 切换重建即重置。
|
|
89
122
|
*/
|
|
90
123
|
export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: BwrapRuntime): void {
|
|
91
|
-
bus.register(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Type.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
Type.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
Type.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Type.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
bus.register(
|
|
125
|
+
defineStructuredTool({
|
|
126
|
+
name: "Bash",
|
|
127
|
+
promptSnippet: "execute command",
|
|
128
|
+
promptGuidelines: [BASH_PROMPT],
|
|
129
|
+
label: "Bash",
|
|
130
|
+
description: [
|
|
131
|
+
"Executes a given bash command synchronously and returns its output.",
|
|
132
|
+
"timeout is in milliseconds, defaults to 120000, and may not exceed 7200000.",
|
|
133
|
+
"Every command runs in the foreground. Background command execution is not supported; shell jobs are waited for before the tool returns.",
|
|
134
|
+
].join("\n"),
|
|
135
|
+
parameters: Type.Object(
|
|
136
|
+
{
|
|
137
|
+
command: Type.String({ description: "The command to execute" }),
|
|
138
|
+
timeout: Type.Optional(
|
|
139
|
+
Type.Number({ description: "Optional timeout in milliseconds (max 7200000)" }),
|
|
140
|
+
),
|
|
141
|
+
description: Type.Optional(
|
|
142
|
+
Type.String({ description: "Clear, concise description of the command" }),
|
|
143
|
+
),
|
|
144
|
+
workdir: Type.Optional(
|
|
145
|
+
Type.String({
|
|
146
|
+
description:
|
|
147
|
+
"Working directory to execute the command in. Defaults to the current directory; relative paths resolve from there.",
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
dangerouslyDisableSandbox: Type.Optional(
|
|
151
|
+
Type.Boolean({
|
|
152
|
+
description:
|
|
153
|
+
"Request one-time unsandboxed execution. The user must approve this request.",
|
|
154
|
+
}),
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
{ additionalProperties: false },
|
|
158
|
+
),
|
|
159
|
+
async execute(id, params, signal, onUpdate, ctx) {
|
|
160
|
+
const timeout = params.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
161
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) {
|
|
162
|
+
throw new Error(`timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
|
|
163
|
+
}
|
|
130
164
|
|
|
131
|
-
|
|
165
|
+
const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
|
|
132
166
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
let result: Awaited<ReturnType<BwrapRuntime["execute"]>>;
|
|
168
|
+
try {
|
|
169
|
+
result = await runtime.execute({
|
|
170
|
+
ctx,
|
|
171
|
+
cwd,
|
|
172
|
+
toolCallId: id,
|
|
173
|
+
command: params.command,
|
|
174
|
+
timeout: timeout / 1000,
|
|
175
|
+
requestFullAccess: params.dangerouslyDisableSandbox,
|
|
176
|
+
description: params.description,
|
|
177
|
+
signal,
|
|
178
|
+
onUpdate,
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!(error instanceof Error)) {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (error instanceof BashInterruptedError) {
|
|
185
|
+
// 输出在前(必要时带截断提示),状态文本在最后
|
|
186
|
+
const text = appendTruncationNotice(
|
|
187
|
+
error.partial.output || "",
|
|
188
|
+
error.partial.truncation,
|
|
189
|
+
error.partial.fullOutputPath,
|
|
190
|
+
);
|
|
191
|
+
if (error.kind === "aborted") {
|
|
192
|
+
// 用户取消:直接返回已捕获的输出,不抛错;时长只算命令真正运行的时间,
|
|
193
|
+
// 不含审批弹窗等 UI 交互
|
|
194
|
+
const status = `Command aborted by user after ${formatElapsedSeconds(error.elapsedMs)}`;
|
|
195
|
+
return {
|
|
196
|
+
content: [
|
|
197
|
+
{ type: "text" as const, text: text ? `${text}\n\n${status}` : status },
|
|
198
|
+
...sandboxHintBlock(error.sandboxReminder),
|
|
199
|
+
],
|
|
200
|
+
details: undefined,
|
|
201
|
+
structuredResult: {
|
|
202
|
+
ok: true as const,
|
|
203
|
+
value: {
|
|
204
|
+
exitCode: null,
|
|
205
|
+
output: await fullOutput(
|
|
206
|
+
error.partial.output,
|
|
207
|
+
error.partial.truncation,
|
|
208
|
+
error.partial.fullOutputPath,
|
|
209
|
+
),
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const full = text
|
|
215
|
+
? `${text}\n\nCommand timed out after ${timeout} milliseconds`
|
|
216
|
+
: `Command timed out after ${timeout} milliseconds`;
|
|
161
217
|
return {
|
|
162
218
|
content: [
|
|
163
|
-
{ type: "text"
|
|
219
|
+
{ type: "text" as const, text: full },
|
|
220
|
+
...sandboxHintBlock(error.sandboxHint),
|
|
164
221
|
...sandboxHintBlock(error.sandboxReminder),
|
|
165
222
|
],
|
|
166
223
|
details: undefined,
|
|
224
|
+
structuredResult: {
|
|
225
|
+
ok: true as const,
|
|
226
|
+
value: {
|
|
227
|
+
exitCode: null,
|
|
228
|
+
output: await fullOutput(
|
|
229
|
+
error.partial.output,
|
|
230
|
+
error.partial.truncation,
|
|
231
|
+
error.partial.fullOutputPath,
|
|
232
|
+
),
|
|
233
|
+
},
|
|
234
|
+
},
|
|
167
235
|
};
|
|
168
236
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 对齐 Claude Code:非 0 退出码都算失败(不做 grep/find 等命令语义化特判)。
|
|
241
|
+
// 失败是命令的正常结果而不是异常:与成功一样 return,文本用完整输出
|
|
242
|
+
// (从落盘文件读取,必要时头尾截断),沙箱状态另行附一块
|
|
243
|
+
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
244
|
+
const full = result.fullOutputPath
|
|
245
|
+
? await readFile(result.fullOutputPath, "utf8")
|
|
246
|
+
: result.output;
|
|
172
247
|
return {
|
|
173
248
|
content: [
|
|
174
|
-
{ type: "text", text: full },
|
|
175
|
-
...sandboxHintBlock(
|
|
176
|
-
...sandboxHintBlock(
|
|
249
|
+
{ type: "text" as const, text: formatBashError(result.exitCode, full) },
|
|
250
|
+
...sandboxHintBlock(result.sandboxHint),
|
|
251
|
+
...sandboxHintBlock(result.sandboxReminder),
|
|
177
252
|
],
|
|
178
253
|
details: undefined,
|
|
254
|
+
structuredResult: {
|
|
255
|
+
ok: true as const,
|
|
256
|
+
value: { exitCode: result.exitCode, output: full },
|
|
257
|
+
},
|
|
179
258
|
};
|
|
180
259
|
}
|
|
181
|
-
throw error;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// 对齐 Claude Code:非 0 退出码都算失败(不做 grep/find 等命令语义化特判)。
|
|
185
|
-
// 失败是命令的正常结果而不是异常:与成功一样 return,文本用完整输出
|
|
186
|
-
// (从落盘文件读取,必要时头尾截断),沙箱状态另行附一块
|
|
187
|
-
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
188
|
-
const full = result.fullOutputPath
|
|
189
|
-
? await readFile(result.fullOutputPath, "utf8")
|
|
190
|
-
: result.output;
|
|
191
260
|
return {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
261
|
+
...formatBashSuccess(result),
|
|
262
|
+
structuredResult: {
|
|
263
|
+
ok: true as const,
|
|
264
|
+
value: {
|
|
265
|
+
exitCode: result.exitCode,
|
|
266
|
+
output: await fullOutput(result.output, result.truncation, result.fullOutputPath),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
198
269
|
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
},
|
|
202
|
-
|
|
270
|
+
},
|
|
271
|
+
structuredSchema: bashStructuredSchema,
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
203
274
|
}
|
package/src/codemode/tool.ts
CHANGED
|
@@ -36,7 +36,10 @@ export const CODEMODE_TOOL_NAME = "codemode";
|
|
|
36
36
|
* - codemode 自身(防递归);
|
|
37
37
|
* - spawn-agent:它启动一个新的隔离会话,成本与运行时长都不适合放进脚本编排;
|
|
38
38
|
* - 两套文件工具集的读写工具:脚本用 `fs.read` / `fs.write`(原文、不截断、按路径整体
|
|
39
|
-
* 写入),不重复给一套为 LLM
|
|
39
|
+
* 写入),不重复给一套为 LLM 上下文设计的行号/锚点语义;
|
|
40
|
+
* - 两套工具集的搜索工具:脚本用 `call("Bash", { command })` 跑 `rg` / `grep` 更顺手——
|
|
41
|
+
* 退出码可用、能拼管道,而这两个工具是给模型看结果的(相对路径、分组渲染、分页尾巴),
|
|
42
|
+
* 声明块(尤其 Grep 的参数表与三选一输出)也白占 codemode 的描述篇幅。
|
|
40
43
|
*/
|
|
41
44
|
const EXCLUDED_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
42
45
|
CODEMODE_TOOL_NAME,
|
|
@@ -47,6 +50,10 @@ const EXCLUDED_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
|
47
50
|
"read",
|
|
48
51
|
"edit",
|
|
49
52
|
"write",
|
|
53
|
+
"Grep",
|
|
54
|
+
"Glob",
|
|
55
|
+
"grep",
|
|
56
|
+
"glob",
|
|
50
57
|
]);
|
|
51
58
|
|
|
52
59
|
/** 估计 token 用的字符数(与 pi 一致)。 */
|
package/src/opencode/bash.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
|
|
4
5
|
import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
} from "../bwrap/runtime.js";
|
|
14
15
|
import { resolveWorkdir } from "../lib/path.js";
|
|
15
16
|
import { createRequestPolicy } from "../lib/request-policy.js";
|
|
16
|
-
import type
|
|
17
|
+
import { defineStructuredTool, type ToolBus } from "../lib/tool-bus.js";
|
|
17
18
|
import { registerToolsOnSessionStart } from "../lib/tool-registration.js";
|
|
18
19
|
|
|
19
20
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -25,6 +26,39 @@ const CAPTURE_TRUNCATED_NOTICE = "[output capture truncated at the in-memory saf
|
|
|
25
26
|
/** Bash tool guidance, kept in markdown so it reads like documentation. */
|
|
26
27
|
const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* `bash` 的结构化结果:只有退出码与完整输出——命令非零退出不是失败,所以载荷里没有成败
|
|
31
|
+
* 标志,脚本直接读 `exitCode` 分支;超时/中止时 `exitCode` 为 `null`(原因在模型侧文本里)。
|
|
32
|
+
*/
|
|
33
|
+
const bashStructuredSchema = Type.Object({
|
|
34
|
+
exitCode: Type.Union([Type.Number(), Type.Null()], {
|
|
35
|
+
description: "Exit code of the command; null when it was killed (timeout or abort)",
|
|
36
|
+
}),
|
|
37
|
+
output: Type.String({
|
|
38
|
+
description:
|
|
39
|
+
"Complete output of the command (stdout and stderr merged). Never truncated, never mixed with tool-added notices.",
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 载荷里的输出:文本被截断时读回落盘的完整输出(读不到就退回那份截断文本),
|
|
45
|
+
* 因此脚本拿到的永远是命令真正输出的内容。
|
|
46
|
+
*/
|
|
47
|
+
async function fullOutput(
|
|
48
|
+
output: string,
|
|
49
|
+
truncation: TruncationResult,
|
|
50
|
+
spillPath: string | undefined,
|
|
51
|
+
): Promise<string> {
|
|
52
|
+
if (spillPath === undefined || !truncation.truncated) {
|
|
53
|
+
return output;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return await readFile(spillPath, "utf8");
|
|
57
|
+
} catch {
|
|
58
|
+
return output;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
/** opencode 风格截断提示(成功、超时、中断路径共用)。 */
|
|
29
63
|
function appendTruncationNotice(
|
|
30
64
|
text: string,
|
|
@@ -54,113 +88,132 @@ export default function opencodeBash(
|
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
export function registerBashTool(bus: ToolBus, pi: ExtensionAPI, runtime: BwrapRuntime): void {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
91
|
+
bus.register(
|
|
92
|
+
defineStructuredTool({
|
|
93
|
+
name: "bash",
|
|
94
|
+
label: "bash",
|
|
95
|
+
description: [
|
|
96
|
+
"Executes a given bash command synchronously and returns its output.",
|
|
97
|
+
"The default working directory is the current directory; use workdir to run elsewhere.",
|
|
98
|
+
"timeout is in milliseconds, defaults to 120000, and may not exceed 7200000.",
|
|
99
|
+
"Every command runs in the foreground. Background command execution is not supported; shell jobs are waited for before the tool returns.",
|
|
100
|
+
].join("\n"),
|
|
101
|
+
promptSnippet: "execute bash command",
|
|
102
|
+
promptGuidelines: [BASH_PROMPT],
|
|
103
|
+
parameters: Type.Object(
|
|
104
|
+
{
|
|
105
|
+
command: Type.String({ description: "The command to execute" }),
|
|
106
|
+
description: Type.Optional(
|
|
107
|
+
Type.String({ description: "Clear, concise description of the command" }),
|
|
108
|
+
),
|
|
109
|
+
workdir: Type.Optional(
|
|
110
|
+
Type.String({
|
|
111
|
+
description:
|
|
112
|
+
"Working directory to execute the command in. Defaults to the current directory; relative paths resolve from there. prefer use this argument over `cd ...`",
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
timeout: Type.Optional(
|
|
116
|
+
Type.Number({
|
|
117
|
+
description: "Optional timeout in milliseconds (max 7200000)",
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
dangerouslyDisableSandbox: Type.Optional(
|
|
121
|
+
Type.Boolean({
|
|
122
|
+
description:
|
|
123
|
+
"Request one-time unsandboxed execution. The user must approve this request.",
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
},
|
|
127
|
+
{ additionalProperties: false },
|
|
128
|
+
),
|
|
129
|
+
async execute(id, params, signal, onUpdate, ctx) {
|
|
130
|
+
const timeout = params.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
131
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) {
|
|
132
|
+
throw new Error(`timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
|
|
133
|
+
}
|
|
101
134
|
|
|
102
|
-
|
|
135
|
+
const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
|
|
103
136
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
137
|
+
let result: Awaited<ReturnType<BwrapRuntime["execute"]>>;
|
|
138
|
+
try {
|
|
139
|
+
result = await runtime.execute({
|
|
140
|
+
ctx,
|
|
141
|
+
cwd,
|
|
142
|
+
toolCallId: id,
|
|
143
|
+
command: params.command,
|
|
144
|
+
timeout: timeout / 1000,
|
|
145
|
+
requestFullAccess: params.dangerouslyDisableSandbox,
|
|
146
|
+
description: params.description,
|
|
147
|
+
signal,
|
|
148
|
+
onUpdate,
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (!(error instanceof Error)) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
if (error instanceof BashInterruptedError) {
|
|
155
|
+
const text = appendTruncationNotice(
|
|
156
|
+
error.partial.output || "",
|
|
157
|
+
error.partial.truncation,
|
|
158
|
+
error.partial.fullOutputPath,
|
|
159
|
+
);
|
|
160
|
+
const status =
|
|
161
|
+
error.kind === "timeout"
|
|
162
|
+
? `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
|
163
|
+
: `Command aborted by user after ${formatElapsedSeconds(error.elapsedMs)}`;
|
|
164
|
+
const full = text ? `${text}\n\n${status}` : status;
|
|
165
|
+
// 对齐上游 opencode:超时与中断都不抛错,输出与状态文本一起返回;
|
|
166
|
+
// 超时可能是沙箱的网络限制导致的,附加沙箱状态(用户中断与沙箱无关)
|
|
167
|
+
return {
|
|
168
|
+
content: [
|
|
169
|
+
{ type: "text" as const, text: full },
|
|
170
|
+
...sandboxHintBlock(error.kind === "timeout" ? error.sandboxHint : undefined),
|
|
171
|
+
...sandboxHintBlock(error.sandboxReminder),
|
|
172
|
+
],
|
|
173
|
+
details: error.kind === "timeout" ? { timeout: true } : {},
|
|
174
|
+
structuredResult: {
|
|
175
|
+
ok: true as const,
|
|
176
|
+
value: {
|
|
177
|
+
exitCode: null,
|
|
178
|
+
output: await fullOutput(
|
|
179
|
+
error.partial.output,
|
|
180
|
+
error.partial.truncation,
|
|
181
|
+
error.partial.fullOutputPath,
|
|
182
|
+
),
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
119
187
|
throw error;
|
|
120
188
|
}
|
|
121
|
-
if (error instanceof BashInterruptedError) {
|
|
122
|
-
const text = appendTruncationNotice(
|
|
123
|
-
error.partial.output || "",
|
|
124
|
-
error.partial.truncation,
|
|
125
|
-
error.partial.fullOutputPath,
|
|
126
|
-
);
|
|
127
|
-
const status =
|
|
128
|
-
error.kind === "timeout"
|
|
129
|
-
? `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
|
130
|
-
: `Command aborted by user after ${formatElapsedSeconds(error.elapsedMs)}`;
|
|
131
|
-
const full = text ? `${text}\n\n${status}` : status;
|
|
132
|
-
// 对齐上游 opencode:超时与中断都不抛错,输出与状态文本一起返回;
|
|
133
|
-
// 超时可能是沙箱的网络限制导致的,附加沙箱状态(用户中断与沙箱无关)
|
|
134
|
-
return {
|
|
135
|
-
content: [
|
|
136
|
-
{ type: "text" as const, text: full },
|
|
137
|
-
...sandboxHintBlock(error.kind === "timeout" ? error.sandboxHint : undefined),
|
|
138
|
-
...sandboxHintBlock(error.sandboxReminder),
|
|
139
|
-
],
|
|
140
|
-
details: error.kind === "timeout" ? { timeout: true } : {},
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
throw error;
|
|
144
|
-
}
|
|
145
189
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
190
|
+
// 命令失败(非 0 退出码)不抛错:输出与状态文本一起返回
|
|
191
|
+
let text = result.output || "(no output)";
|
|
192
|
+
text = appendTruncationNotice(text, result.truncation, result.fullOutputPath);
|
|
193
|
+
const failed = result.exitCode !== 0 && result.exitCode !== null;
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{ type: "text" as const, text },
|
|
197
|
+
{ type: "text" as const, text: `Command exited with code ${result.exitCode}.` },
|
|
198
|
+
// 失败可能是被沙箱的写边界或网络限制挡住的,附一块沙箱状态
|
|
199
|
+
...sandboxHintBlock(failed ? result.sandboxHint : undefined),
|
|
200
|
+
...sandboxHintBlock(result.sandboxReminder),
|
|
201
|
+
],
|
|
202
|
+
details: {
|
|
203
|
+
exitCode: result.exitCode,
|
|
204
|
+
truncated: result.truncation.truncated,
|
|
205
|
+
...(result.fullOutputPath && { fullOutputPath: result.fullOutputPath }),
|
|
206
|
+
},
|
|
207
|
+
structuredResult: {
|
|
208
|
+
ok: true as const,
|
|
209
|
+
value: {
|
|
210
|
+
exitCode: result.exitCode,
|
|
211
|
+
output: await fullOutput(result.output, result.truncation, result.fullOutputPath),
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
structuredSchema: bashStructuredSchema,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
166
219
|
}
|