@trim21/personal-pi-extensions 0.0.249 → 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,10 +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 {
|
|
2
6
|
AgentToolUpdateCallback,
|
|
3
7
|
ExtensionAPI,
|
|
4
8
|
ExtensionCommandContext,
|
|
5
9
|
ExtensionContext,
|
|
6
10
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createLocalBashOperations,
|
|
13
|
+
getAgentDir,
|
|
14
|
+
truncateTail,
|
|
15
|
+
type TruncationResult,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
17
|
import { type TObject, Type } from "typebox";
|
|
9
18
|
|
|
10
19
|
import { type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
@@ -45,12 +54,18 @@ export interface BwrapExecutionRequest {
|
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
/**
|
|
48
|
-
* 底层执行结果:完整退出码 +
|
|
49
|
-
*
|
|
57
|
+
* 底层执行结果:完整退出码 + 截断后的输出文本。
|
|
58
|
+
* 输出在运行时就直接写入 agent-dir/tmp/{uuid}.txt(完整内容),内存不保留全量;
|
|
59
|
+
* `truncation.totalLines/totalBytes` 是精确统计值(非尾部缓冲的)。
|
|
60
|
+
* 退出码语义由上层 Bash 工具解释,这里不做成败判定。
|
|
50
61
|
*/
|
|
51
62
|
export interface BwrapExecutionResult {
|
|
52
63
|
exitCode: number | null;
|
|
64
|
+
/** 截断后的输出(尾部),未截断时为完整输出;空输出为空字符串。 */
|
|
53
65
|
output: string;
|
|
66
|
+
/** 完整输出的文件路径;无输出时不存在。 */
|
|
67
|
+
fullOutputPath?: string;
|
|
68
|
+
truncation: TruncationResult;
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
function escapeHtml(text: string): string {
|
|
@@ -71,30 +86,87 @@ function fenceCodeBlock(code: string): string {
|
|
|
71
86
|
const BASH_UPDATE_THROTTLE_MS = 100;
|
|
72
87
|
/** 进度快照只保留尾部内容,避免大输出每 100ms 全量推给 TUI。 */
|
|
73
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
|
+
}
|
|
74
99
|
|
|
75
100
|
/**
|
|
76
|
-
* 合并 stdout/stderr
|
|
77
|
-
*
|
|
101
|
+
* 合并 stdout/stderr 的流式输出累积器:输出在运行时就直接写入
|
|
102
|
+
* agent-dir/tmp/{uuid}.txt(完整内容),内存只保留尾部缓冲。
|
|
103
|
+
* 大输出不会撑爆内存;最终结果只返回截断后的文本。
|
|
78
104
|
*/
|
|
79
105
|
class BashOutput {
|
|
80
|
-
private
|
|
106
|
+
private stream: WriteStream | undefined;
|
|
107
|
+
private writeError: Error | undefined;
|
|
108
|
+
private tail: Buffer[] = [];
|
|
109
|
+
private tailBytes = 0;
|
|
81
110
|
private totalBytes = 0;
|
|
111
|
+
private totalLines = 0;
|
|
112
|
+
filePath: string | undefined;
|
|
82
113
|
|
|
83
114
|
append(data: Buffer): void {
|
|
84
|
-
this.chunks.push(data);
|
|
85
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
|
+
}
|
|
86
138
|
}
|
|
87
139
|
|
|
88
|
-
|
|
89
|
-
|
|
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 };
|
|
90
162
|
}
|
|
91
163
|
|
|
92
164
|
/** 尾部快照(用于流式进度显示)。 */
|
|
93
165
|
tailSnapshot(): string {
|
|
94
166
|
let remaining = BASH_UPDATE_TAIL_BYTES;
|
|
95
167
|
const tail: Buffer[] = [];
|
|
96
|
-
for (let i = this.
|
|
97
|
-
const chunk = this.
|
|
168
|
+
for (let i = this.tail.length - 1; i >= 0 && remaining > 0; i--) {
|
|
169
|
+
const chunk = this.tail[i];
|
|
98
170
|
if (chunk.length <= remaining) {
|
|
99
171
|
tail.unshift(chunk);
|
|
100
172
|
remaining -= chunk.length;
|
|
@@ -251,7 +323,15 @@ export class BwrapRuntime {
|
|
|
251
323
|
signal: request.signal,
|
|
252
324
|
timeout: request.timeout,
|
|
253
325
|
});
|
|
254
|
-
|
|
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
|
+
};
|
|
255
335
|
} catch (error) {
|
|
256
336
|
// 底层统一把超时/中断转成可读文案(对齐 pi 内置 bash 工具)
|
|
257
337
|
if (error instanceof Error && error.message.startsWith("timeout:")) {
|
|
@@ -267,6 +347,7 @@ export class BwrapRuntime {
|
|
|
267
347
|
} finally {
|
|
268
348
|
if (updateTimer) clearTimeout(updateTimer);
|
|
269
349
|
if (onUpdate && dirty) emitUpdate();
|
|
350
|
+
await output.close();
|
|
270
351
|
}
|
|
271
352
|
}
|
|
272
353
|
|
package/src/claude-code/shell.ts
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import { join } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
5
3
|
import { fileURLToPath } from "node:url";
|
|
6
4
|
|
|
7
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
9
|
-
type BashToolDetails,
|
|
10
|
-
DEFAULT_MAX_BYTES,
|
|
11
|
-
formatSize,
|
|
12
|
-
getAgentDir,
|
|
13
|
-
truncateTail,
|
|
14
|
-
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { type BashToolDetails, formatSize } from "@earendil-works/pi-coding-agent";
|
|
15
7
|
import { Type } from "typebox";
|
|
16
8
|
|
|
17
9
|
import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
|
|
@@ -42,24 +34,17 @@ function formatBashError(exitCode: number | null, output: string): string {
|
|
|
42
34
|
}
|
|
43
35
|
|
|
44
36
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
37
|
+
* 成功路径:消费 runtime 的截断结果(输出已由 runtime 截断并落盘),
|
|
38
|
+
* 截断时追加 `[Showing lines X-Y of N. Full output: path]` 提示。
|
|
47
39
|
* opencode 套件的 bash 工具复用同一逻辑。
|
|
48
40
|
*/
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
let
|
|
55
|
-
if (truncation.truncated) {
|
|
56
|
-
// 完整输出落盘到 agent 数据目录的 tmp 子目录(与 pi 的 agent 状态同处,
|
|
57
|
-
// 模型可读;系统临时目录可能被清理)
|
|
58
|
-
const dir = join(getAgentDir(), "tmp");
|
|
59
|
-
await mkdir(dir, { recursive: true });
|
|
60
|
-
const fullOutputPath = join(dir, `${randomUUID()}.txt`);
|
|
61
|
-
await writeFile(fullOutputPath, output, "utf8");
|
|
62
|
-
details = { truncation, fullOutputPath };
|
|
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) {
|
|
63
48
|
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
64
49
|
const endLine = truncation.totalLines;
|
|
65
50
|
if (truncation.lastLinePartial) {
|
|
@@ -70,10 +55,13 @@ export async function formatBashSuccess(
|
|
|
70
55
|
} else if (truncation.truncatedBy === "lines") {
|
|
71
56
|
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${fullOutputPath}]`;
|
|
72
57
|
} else {
|
|
73
|
-
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes
|
|
58
|
+
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
|
|
74
59
|
}
|
|
75
60
|
}
|
|
76
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text }],
|
|
63
|
+
details: fullOutputPath && truncation.truncated ? { truncation, fullOutputPath } : undefined,
|
|
64
|
+
};
|
|
77
65
|
}
|
|
78
66
|
|
|
79
67
|
/**
|
|
@@ -151,13 +139,16 @@ export function registerShellTools(
|
|
|
151
139
|
}
|
|
152
140
|
|
|
153
141
|
// 对齐 Claude Code:非 0 退出码视为错误(不做 grep/find 等命令语义化特判,
|
|
154
|
-
// 任何非 0
|
|
142
|
+
// 任何非 0 都抛错);错误文本用完整输出(从落盘文件读取,必要时头尾截断)
|
|
155
143
|
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
156
|
-
|
|
144
|
+
const full = result.fullOutputPath
|
|
145
|
+
? await readFile(result.fullOutputPath, "utf8")
|
|
146
|
+
: result.output;
|
|
147
|
+
throw new Error(formatBashError(result.exitCode, full), {
|
|
157
148
|
cause: result,
|
|
158
149
|
});
|
|
159
150
|
}
|
|
160
|
-
return formatBashSuccess(result
|
|
151
|
+
return formatBashSuccess(result);
|
|
161
152
|
},
|
|
162
153
|
});
|
|
163
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
|
@@ -2,16 +2,25 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
4
|
import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
|
|
5
|
-
import { formatBashSuccess } from "../claude-code/shell.js";
|
|
6
5
|
import { resolveWorkdir } from "../lib/path.js";
|
|
7
6
|
|
|
8
7
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
9
8
|
const MAX_TIMEOUT_MS = 600_000;
|
|
10
9
|
|
|
11
|
-
|
|
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 {
|
|
12
22
|
// 每个扩展实例持有自己的 runtime:不依赖模块级全局状态,状态随扩展
|
|
13
23
|
// 实例生命周期(进程启动 / /reload / session 切换时工厂重建即重置)。
|
|
14
|
-
const runtime = createBwrapRuntime();
|
|
15
24
|
runtime.setup(pi);
|
|
16
25
|
pi.registerTool({
|
|
17
26
|
name: "bash",
|
|
@@ -68,23 +77,39 @@ export default function opencodeBash(pi: ExtensionAPI): void {
|
|
|
68
77
|
});
|
|
69
78
|
} catch (error) {
|
|
70
79
|
if (!(error instanceof Error)) throw error;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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;
|
|
78
94
|
}
|
|
79
95
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
});
|
|
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}`;
|
|
86
101
|
}
|
|
87
|
-
return
|
|
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
|
+
};
|
|
88
113
|
},
|
|
89
114
|
});
|
|
90
115
|
}
|