@trim21/personal-pi-extensions 0.0.247 → 0.0.251
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
package/src/bwrap/runtime.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
1
5
|
import type {
|
|
6
|
+
AgentToolUpdateCallback,
|
|
2
7
|
ExtensionAPI,
|
|
3
8
|
ExtensionCommandContext,
|
|
4
9
|
ExtensionContext,
|
|
5
10
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createLocalBashOperations,
|
|
13
|
+
getAgentDir,
|
|
14
|
+
truncateTail,
|
|
15
|
+
type TruncationResult,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
17
|
import { type TObject, Type } from "typebox";
|
|
8
18
|
|
|
9
19
|
import { type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
@@ -39,10 +49,25 @@ export interface BwrapExecutionRequest {
|
|
|
39
49
|
requestFullAccess?: boolean;
|
|
40
50
|
requestFullAccessReason?: string;
|
|
41
51
|
signal?: AbortSignal;
|
|
42
|
-
onUpdate?:
|
|
52
|
+
onUpdate?: AgentToolUpdateCallback;
|
|
43
53
|
ctx: ExtensionContext;
|
|
44
54
|
}
|
|
45
55
|
|
|
56
|
+
/**
|
|
57
|
+
* 底层执行结果:完整退出码 + 截断后的输出文本。
|
|
58
|
+
* 输出在运行时就直接写入 agent-dir/tmp/{uuid}.txt(完整内容),内存不保留全量;
|
|
59
|
+
* `truncation.totalLines/totalBytes` 是精确统计值(非尾部缓冲的)。
|
|
60
|
+
* 退出码语义由上层 Bash 工具解释,这里不做成败判定。
|
|
61
|
+
*/
|
|
62
|
+
export interface BwrapExecutionResult {
|
|
63
|
+
exitCode: number | null;
|
|
64
|
+
/** 截断后的输出(尾部),未截断时为完整输出;空输出为空字符串。 */
|
|
65
|
+
output: string;
|
|
66
|
+
/** 完整输出的文件路径;无输出时不存在。 */
|
|
67
|
+
fullOutputPath?: string;
|
|
68
|
+
truncation: TruncationResult;
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
function escapeHtml(text: string): string {
|
|
47
72
|
return text
|
|
48
73
|
.replaceAll("&", "&")
|
|
@@ -57,6 +82,103 @@ function fenceCodeBlock(code: string): string {
|
|
|
57
82
|
return `${fence}\n${code}\n${fence}`;
|
|
58
83
|
}
|
|
59
84
|
|
|
85
|
+
/** 进度推送的节流间隔(对齐 pi 内置 bash 工具的 100ms)。 */
|
|
86
|
+
const BASH_UPDATE_THROTTLE_MS = 100;
|
|
87
|
+
/** 进度快照只保留尾部内容,避免大输出每 100ms 全量推给 TUI。 */
|
|
88
|
+
const BASH_UPDATE_TAIL_BYTES = 64 * 1024;
|
|
89
|
+
/** 内存尾部缓冲上限:必须大于 truncateTail 的默认上限(50KB / 2000 行)。 */
|
|
90
|
+
const BASH_TAIL_LIMIT_BYTES = 1024 * 1024;
|
|
91
|
+
|
|
92
|
+
function countNewlines(data: Buffer): number {
|
|
93
|
+
let count = 0;
|
|
94
|
+
for (const byte of data) {
|
|
95
|
+
if (byte === 0x0a) count++;
|
|
96
|
+
}
|
|
97
|
+
return count;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 合并 stdout/stderr 的流式输出累积器:输出在运行时就直接写入
|
|
102
|
+
* agent-dir/tmp/{uuid}.txt(完整内容),内存只保留尾部缓冲。
|
|
103
|
+
* 大输出不会撑爆内存;最终结果只返回截断后的文本。
|
|
104
|
+
*/
|
|
105
|
+
class BashOutput {
|
|
106
|
+
private stream: WriteStream | undefined;
|
|
107
|
+
private writeError: Error | undefined;
|
|
108
|
+
private tail: Buffer[] = [];
|
|
109
|
+
private tailBytes = 0;
|
|
110
|
+
private totalBytes = 0;
|
|
111
|
+
private totalLines = 0;
|
|
112
|
+
filePath: string | undefined;
|
|
113
|
+
|
|
114
|
+
append(data: Buffer): void {
|
|
115
|
+
this.totalBytes += data.length;
|
|
116
|
+
this.totalLines += countNewlines(data);
|
|
117
|
+
if (!this.stream) {
|
|
118
|
+
const dir = join(getAgentDir(), "tmp");
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
this.filePath = join(dir, `${randomUUID()}.txt`);
|
|
121
|
+
this.stream = createWriteStream(this.filePath, { flags: "w" });
|
|
122
|
+
this.stream.on("error", (error) => {
|
|
123
|
+
this.writeError = error;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
this.stream.write(data);
|
|
127
|
+
this.tail.push(data);
|
|
128
|
+
this.tailBytes += data.length;
|
|
129
|
+
while (this.tailBytes > BASH_TAIL_LIMIT_BYTES && this.tail.length > 1) {
|
|
130
|
+
this.tailBytes -= this.tail[0].length;
|
|
131
|
+
this.tail.shift();
|
|
132
|
+
}
|
|
133
|
+
if (this.tailBytes > BASH_TAIL_LIMIT_BYTES && this.tail.length === 1) {
|
|
134
|
+
// 单个 chunk 超过上限:截掉头部,只保留尾部
|
|
135
|
+
this.tail[0] = this.tail[0].subarray(this.tailBytes - BASH_TAIL_LIMIT_BYTES);
|
|
136
|
+
this.tailBytes = BASH_TAIL_LIMIT_BYTES;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
close(): Promise<void> {
|
|
141
|
+
if (!this.stream) return Promise.resolve();
|
|
142
|
+
const stream = this.stream;
|
|
143
|
+
this.stream = undefined;
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
stream.end(() => {
|
|
146
|
+
if (this.writeError) {
|
|
147
|
+
// 落盘失败(如 readonly 沙箱):降级为纯内存模式,命令仍正常返回
|
|
148
|
+
this.filePath = undefined;
|
|
149
|
+
}
|
|
150
|
+
resolve();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 尾部文本(截断结果的候选,未截断时即完整输出)。 */
|
|
156
|
+
tailText(): string {
|
|
157
|
+
return Buffer.concat(this.tail, this.tailBytes).toString("utf8");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get stats(): { totalBytes: number; totalLines: number } {
|
|
161
|
+
return { totalBytes: this.totalBytes, totalLines: this.totalLines };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 尾部快照(用于流式进度显示)。 */
|
|
165
|
+
tailSnapshot(): string {
|
|
166
|
+
let remaining = BASH_UPDATE_TAIL_BYTES;
|
|
167
|
+
const tail: Buffer[] = [];
|
|
168
|
+
for (let i = this.tail.length - 1; i >= 0 && remaining > 0; i--) {
|
|
169
|
+
const chunk = this.tail[i];
|
|
170
|
+
if (chunk.length <= remaining) {
|
|
171
|
+
tail.unshift(chunk);
|
|
172
|
+
remaining -= chunk.length;
|
|
173
|
+
} else {
|
|
174
|
+
tail.unshift(chunk.subarray(chunk.length - remaining));
|
|
175
|
+
remaining = 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Buffer.concat(tail).toString("utf8");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
60
182
|
function notifyMode(
|
|
61
183
|
ctx: { ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
|
|
62
184
|
mode: BwrapMode,
|
|
@@ -143,7 +265,7 @@ export class BwrapRuntime {
|
|
|
143
265
|
this.bwrapUnavailable = false;
|
|
144
266
|
}
|
|
145
267
|
|
|
146
|
-
async execute(request: BwrapExecutionRequest) {
|
|
268
|
+
async execute(request: BwrapExecutionRequest): Promise<BwrapExecutionResult> {
|
|
147
269
|
const runtime = this.resolve(request.ctx);
|
|
148
270
|
if (this.bwrapUnavailable && runtime.bwrapEnabled && request.requestFullAccess !== true) {
|
|
149
271
|
throw new Error(
|
|
@@ -154,16 +276,79 @@ export class BwrapRuntime {
|
|
|
154
276
|
if (request.requestFullAccess === true && runtime.bwrapEnabled) {
|
|
155
277
|
await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
|
|
156
278
|
}
|
|
157
|
-
const
|
|
279
|
+
const operations =
|
|
158
280
|
runtime.bwrapEnabled && request.requestFullAccess !== true
|
|
159
|
-
?
|
|
160
|
-
:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
281
|
+
? createBwrapBashOperations(runtime)
|
|
282
|
+
: createLocalBashOperations();
|
|
283
|
+
const output = new BashOutput();
|
|
284
|
+
const { onUpdate } = request;
|
|
285
|
+
|
|
286
|
+
// 流式进度:节流推送尾部快照(对齐 pi 内置 bash 的实时输出体验)
|
|
287
|
+
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
288
|
+
let dirty = false;
|
|
289
|
+
let lastUpdateAt = 0;
|
|
290
|
+
const emitUpdate = () => {
|
|
291
|
+
if (!onUpdate || !dirty) return;
|
|
292
|
+
dirty = false;
|
|
293
|
+
lastUpdateAt = Date.now();
|
|
294
|
+
onUpdate({
|
|
295
|
+
content: [{ type: "text", text: output.tailSnapshot() }],
|
|
296
|
+
details: undefined,
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
const scheduleUpdate = () => {
|
|
300
|
+
if (!onUpdate) return;
|
|
301
|
+
dirty = true;
|
|
302
|
+
const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
|
|
303
|
+
if (delay <= 0) {
|
|
304
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
305
|
+
updateTimer = undefined;
|
|
306
|
+
emitUpdate();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (updateTimer) return;
|
|
310
|
+
updateTimer = setTimeout(() => {
|
|
311
|
+
updateTimer = undefined;
|
|
312
|
+
emitUpdate();
|
|
313
|
+
}, delay);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
if (onUpdate) onUpdate({ content: [], details: undefined });
|
|
318
|
+
const { exitCode } = await operations.exec(request.command, request.ctx.cwd, {
|
|
319
|
+
onData: (data) => {
|
|
320
|
+
output.append(data);
|
|
321
|
+
scheduleUpdate();
|
|
322
|
+
},
|
|
323
|
+
signal: request.signal,
|
|
324
|
+
timeout: request.timeout,
|
|
325
|
+
});
|
|
326
|
+
await output.close();
|
|
327
|
+
const truncation = truncateTail(output.tailText());
|
|
328
|
+
return {
|
|
329
|
+
exitCode,
|
|
330
|
+
output: truncation.content,
|
|
331
|
+
...(output.filePath && { fullOutputPath: output.filePath }),
|
|
332
|
+
// 用精确统计值覆盖尾部缓冲的估算(提示文本的行数/字节数要准确)
|
|
333
|
+
truncation: { ...truncation, ...output.stats },
|
|
334
|
+
};
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// 底层统一把超时/中断转成可读文案(对齐 pi 内置 bash 工具)
|
|
337
|
+
if (error instanceof Error && error.message.startsWith("timeout:")) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Command timed out after ${error.message.slice("timeout:".length)} seconds`,
|
|
340
|
+
{ cause: error },
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (error instanceof Error && error.message === "aborted") {
|
|
344
|
+
throw new Error("Command aborted", { cause: error });
|
|
345
|
+
}
|
|
346
|
+
throw error;
|
|
347
|
+
} finally {
|
|
348
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
349
|
+
if (onUpdate && dirty) emitUpdate();
|
|
350
|
+
await output.close();
|
|
351
|
+
}
|
|
167
352
|
}
|
|
168
353
|
|
|
169
354
|
private resolve(ctx: Pick<ExtensionContext, "cwd" | "hasUI">): ResolvedBwrap {
|
package/src/claude-code/shell.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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 } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { type BashToolDetails, formatSize } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
import { Type } from "typebox";
|
|
6
8
|
|
|
7
9
|
import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
|
|
@@ -10,9 +12,58 @@ import { resolveWorkdir } from "../lib/path.js";
|
|
|
10
12
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
11
13
|
const MAX_TIMEOUT_MS = 600_000;
|
|
12
14
|
|
|
15
|
+
/** 对齐 Claude Code formatError:错误文本超过该长度时头尾各保留一半。 */
|
|
16
|
+
const MAX_ERROR_CHARS = 10_000;
|
|
17
|
+
|
|
13
18
|
/** Bash tool guidance, kept in markdown so it reads like documentation. */
|
|
14
19
|
const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
|
|
15
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 对齐 Claude Code 的错误格式:`Exit code N` 在开头,完整输出随后;
|
|
23
|
+
* 超过 10000 字符时头尾各 5000 + 中间截断提示。
|
|
24
|
+
*/
|
|
25
|
+
function formatBashError(exitCode: number | null, output: string): string {
|
|
26
|
+
const full = [`Exit code ${exitCode ?? 1}`, output].filter(Boolean).join("\n");
|
|
27
|
+
if (full.length <= MAX_ERROR_CHARS) return full;
|
|
28
|
+
const half = MAX_ERROR_CHARS / 2;
|
|
29
|
+
return (
|
|
30
|
+
full.slice(0, half) +
|
|
31
|
+
`\n\n... [${full.length - MAX_ERROR_CHARS} characters truncated] ...\n\n` +
|
|
32
|
+
full.slice(-half)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 成功路径:消费 runtime 的截断结果(输出已由 runtime 截断并落盘),
|
|
38
|
+
* 截断时追加 `[Showing lines X-Y of N. Full output: path]` 提示。
|
|
39
|
+
* opencode 套件的 bash 工具复用同一逻辑。
|
|
40
|
+
*/
|
|
41
|
+
export function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execute"]>>): {
|
|
42
|
+
content: { type: "text"; text: string }[];
|
|
43
|
+
details: BashToolDetails | undefined;
|
|
44
|
+
} {
|
|
45
|
+
const { output, truncation, fullOutputPath } = result;
|
|
46
|
+
let text = output || "(no output)";
|
|
47
|
+
if (fullOutputPath && truncation.truncated) {
|
|
48
|
+
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
49
|
+
const endLine = truncation.totalLines;
|
|
50
|
+
if (truncation.lastLinePartial) {
|
|
51
|
+
const lastLineSize = formatSize(
|
|
52
|
+
output.length - output.lastIndexOf("\n", output.length - 2) - 1,
|
|
53
|
+
);
|
|
54
|
+
text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${fullOutputPath}]`;
|
|
55
|
+
} else if (truncation.truncatedBy === "lines") {
|
|
56
|
+
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${fullOutputPath}]`;
|
|
57
|
+
} else {
|
|
58
|
+
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text }],
|
|
63
|
+
details: fullOutputPath && truncation.truncated ? { truncation, fullOutputPath } : undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
16
67
|
/**
|
|
17
68
|
* runtime 由调用方注入:扩展工厂持有一个实例(不依赖模块级全局状态),
|
|
18
69
|
* 测试可注入预置模式的实例。状态随扩展实例生命周期,session 切换重建即重置。
|
|
@@ -64,8 +115,9 @@ export function registerShellTools(
|
|
|
64
115
|
|
|
65
116
|
const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
|
|
66
117
|
|
|
118
|
+
let result: Awaited<ReturnType<BwrapRuntime["execute"]>>;
|
|
67
119
|
try {
|
|
68
|
-
|
|
120
|
+
result = await runtime.execute({
|
|
69
121
|
ctx: { ...ctx, cwd },
|
|
70
122
|
toolCallId: id,
|
|
71
123
|
command: params.command,
|
|
@@ -85,6 +137,18 @@ export function registerShellTools(
|
|
|
85
137
|
: error.message;
|
|
86
138
|
throw new Error(message, { cause: error });
|
|
87
139
|
}
|
|
140
|
+
|
|
141
|
+
// 对齐 Claude Code:非 0 退出码视为错误(不做 grep/find 等命令语义化特判,
|
|
142
|
+
// 任何非 0 都抛错);错误文本用完整输出(从落盘文件读取,必要时头尾截断)
|
|
143
|
+
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
144
|
+
const full = result.fullOutputPath
|
|
145
|
+
? await readFile(result.fullOutputPath, "utf8")
|
|
146
|
+
: result.output;
|
|
147
|
+
throw new Error(formatBashError(result.exitCode, full), {
|
|
148
|
+
cause: result,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return formatBashSuccess(result);
|
|
88
152
|
},
|
|
89
153
|
});
|
|
90
154
|
}
|
|
@@ -59,7 +59,10 @@ The capitalized tools below follow Claude Code behavior with a few deliberate de
|
|
|
59
59
|
|
|
60
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
61
|
- `timeout` is in milliseconds, default 120000, max 600000. `workdir` overrides the working directory.
|
|
62
|
-
- **Deviation from Claude Code:** no
|
|
62
|
+
- **Non-zero exit code is a tool failure**: the error text starts with `Exit code N` followed by the full output (head/tail-truncated at 10000 chars if larger). **Deviation from Claude Code:** no command-semantics special cases — `grep` with no matches (exit 1), `diff` differences, `test` false, etc. all fail like any other non-zero exit.
|
|
63
|
+
- Output is streamed to a file under `agent-dir/tmp/<uuid>.txt` during execution; the tool result only contains the truncated tail (2000 lines / 50 KB). On truncation a note is appended: `[Showing lines X-Y of N. Full output: <path>]` — read that file for the complete output. In a read-only sandbox where the write fails, the result degrades to the in-memory tail only.
|
|
64
|
+
- **Deviation from Claude Code:** no auto-backgrounding on timeout — a timed-out command is killed and the error reports `Command timed out after N milliseconds`.
|
|
65
|
+
- The lowercase opencode-style `bash` tool differs: it **never throws** on non-zero exit — it returns the output plus a `Command exited with code N.` status text block; a timeout returns `Command exceeded timeout of N ms. Retry with a larger timeout...` instead of failing.
|
|
63
66
|
|
|
64
67
|
## TodoWrite
|
|
65
68
|
|
package/src/opencode/bash.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
-
import { createBwrapRuntime } from "../bwrap/runtime.js";
|
|
4
|
+
import { type BwrapRuntime, 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
|
+
/** 对齐上游 opencode 的截断提示文案(tools/BashTool/bash.ts)。 */
|
|
11
|
+
const CAPTURE_TRUNCATED_NOTICE = "[output capture truncated at the in-memory safety limit]";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 对齐上游 opencode(packages/core/src/tool/bash.ts):
|
|
15
|
+
* 命令失败(非 0 退出码)与超时都不抛错,输出与状态文本一起返回,
|
|
16
|
+
* 由模型根据 `Command exited with code N.` 自行判断。
|
|
17
|
+
*/
|
|
18
|
+
export default function opencodeBash(
|
|
19
|
+
pi: ExtensionAPI,
|
|
20
|
+
runtime: BwrapRuntime = createBwrapRuntime(),
|
|
21
|
+
): void {
|
|
11
22
|
// 每个扩展实例持有自己的 runtime:不依赖模块级全局状态,状态随扩展
|
|
12
23
|
// 实例生命周期(进程启动 / /reload / session 切换时工厂重建即重置)。
|
|
13
|
-
const runtime = createBwrapRuntime();
|
|
14
24
|
runtime.setup(pi);
|
|
15
25
|
pi.registerTool({
|
|
16
26
|
name: "bash",
|
|
@@ -54,8 +64,9 @@ export default function opencodeBash(pi: ExtensionAPI): void {
|
|
|
54
64
|
|
|
55
65
|
const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
|
|
56
66
|
|
|
67
|
+
let result: Awaited<ReturnType<BwrapRuntime["execute"]>>;
|
|
57
68
|
try {
|
|
58
|
-
|
|
69
|
+
result = await runtime.execute({
|
|
59
70
|
ctx: { ...ctx, cwd },
|
|
60
71
|
toolCallId: id,
|
|
61
72
|
command: params.command,
|
|
@@ -66,14 +77,39 @@ export default function opencodeBash(pi: ExtensionAPI): void {
|
|
|
66
77
|
});
|
|
67
78
|
} catch (error) {
|
|
68
79
|
if (!(error instanceof Error)) throw error;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
// 对齐上游 opencode:超时不抛错,返回提示文本(丢弃部分输出)
|
|
81
|
+
if (/Command timed out after [\d.]+ seconds/.test(error.message)) {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text" as const,
|
|
86
|
+
text: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
|
87
|
+
},
|
|
88
|
+
{ type: "text" as const, text: "Command timed out before completion." },
|
|
89
|
+
],
|
|
90
|
+
details: { timeout: true },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 命令失败(非 0 退出码)不抛错:输出与状态文本一起返回
|
|
97
|
+
let text = result.output || "(no output)";
|
|
98
|
+
if (result.truncation.truncated) {
|
|
99
|
+
text += `\n\n${CAPTURE_TRUNCATED_NOTICE}`;
|
|
100
|
+
if (result.fullOutputPath) text += `\nFull output: ${result.fullOutputPath}`;
|
|
76
101
|
}
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{ type: "text" as const, text },
|
|
105
|
+
{ type: "text" as const, text: `Command exited with code ${result.exitCode}.` },
|
|
106
|
+
],
|
|
107
|
+
details: {
|
|
108
|
+
exitCode: result.exitCode,
|
|
109
|
+
truncated: result.truncation.truncated,
|
|
110
|
+
...(result.fullOutputPath && { fullOutputPath: result.fullOutputPath }),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
77
113
|
},
|
|
78
114
|
});
|
|
79
115
|
}
|