@trim21/personal-pi-extensions 0.0.246 → 0.0.249

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.246",
3
+ "version": "0.0.249",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -1,9 +1,10 @@
1
1
  import type {
2
+ AgentToolUpdateCallback,
2
3
  ExtensionAPI,
3
4
  ExtensionCommandContext,
4
5
  ExtensionContext,
5
6
  } from "@earendil-works/pi-coding-agent";
6
- import { createBashTool } from "@earendil-works/pi-coding-agent";
7
+ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
7
8
  import { type TObject, Type } from "typebox";
8
9
 
9
10
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
@@ -39,10 +40,19 @@ export interface BwrapExecutionRequest {
39
40
  requestFullAccess?: boolean;
40
41
  requestFullAccessReason?: string;
41
42
  signal?: AbortSignal;
42
- onUpdate?: Parameters<ReturnType<typeof createBashTool>["execute"]>[3];
43
+ onUpdate?: AgentToolUpdateCallback;
43
44
  ctx: ExtensionContext;
44
45
  }
45
46
 
47
+ /**
48
+ * 底层执行结果:完整退出码 + 完整输出(stdout/stderr 合并,未截断)。
49
+ * 退出码语义(grep exit 1 等)由上层 Bash 工具解释,这里不做成败判定。
50
+ */
51
+ export interface BwrapExecutionResult {
52
+ exitCode: number | null;
53
+ output: string;
54
+ }
55
+
46
56
  function escapeHtml(text: string): string {
47
57
  return text
48
58
  .replaceAll("&", "&amp;")
@@ -57,6 +67,46 @@ function fenceCodeBlock(code: string): string {
57
67
  return `${fence}\n${code}\n${fence}`;
58
68
  }
59
69
 
70
+ /** 进度推送的节流间隔(对齐 pi 内置 bash 工具的 100ms)。 */
71
+ const BASH_UPDATE_THROTTLE_MS = 100;
72
+ /** 进度快照只保留尾部内容,避免大输出每 100ms 全量推给 TUI。 */
73
+ const BASH_UPDATE_TAIL_BYTES = 64 * 1024;
74
+
75
+ /**
76
+ * 合并 stdout/stderr 的流式输出累积器。
77
+ * 内存中保留全部输出供最终结果使用;进度快照只取尾部。
78
+ */
79
+ class BashOutput {
80
+ private chunks: Buffer[] = [];
81
+ private totalBytes = 0;
82
+
83
+ append(data: Buffer): void {
84
+ this.chunks.push(data);
85
+ this.totalBytes += data.length;
86
+ }
87
+
88
+ toString(): string {
89
+ return Buffer.concat(this.chunks, this.totalBytes).toString("utf8");
90
+ }
91
+
92
+ /** 尾部快照(用于流式进度显示)。 */
93
+ tailSnapshot(): string {
94
+ let remaining = BASH_UPDATE_TAIL_BYTES;
95
+ const tail: Buffer[] = [];
96
+ for (let i = this.chunks.length - 1; i >= 0 && remaining > 0; i--) {
97
+ const chunk = this.chunks[i];
98
+ if (chunk.length <= remaining) {
99
+ tail.unshift(chunk);
100
+ remaining -= chunk.length;
101
+ } else {
102
+ tail.unshift(chunk.subarray(chunk.length - remaining));
103
+ remaining = 0;
104
+ }
105
+ }
106
+ return Buffer.concat(tail).toString("utf8");
107
+ }
108
+ }
109
+
60
110
  function notifyMode(
61
111
  ctx: { ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
62
112
  mode: BwrapMode,
@@ -143,7 +193,7 @@ export class BwrapRuntime {
143
193
  this.bwrapUnavailable = false;
144
194
  }
145
195
 
146
- async execute(request: BwrapExecutionRequest) {
196
+ async execute(request: BwrapExecutionRequest): Promise<BwrapExecutionResult> {
147
197
  const runtime = this.resolve(request.ctx);
148
198
  if (this.bwrapUnavailable && runtime.bwrapEnabled && request.requestFullAccess !== true) {
149
199
  throw new Error(
@@ -154,16 +204,70 @@ export class BwrapRuntime {
154
204
  if (request.requestFullAccess === true && runtime.bwrapEnabled) {
155
205
  await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
156
206
  }
157
- const bash =
207
+ const operations =
158
208
  runtime.bwrapEnabled && request.requestFullAccess !== true
159
- ? createBashTool(request.ctx.cwd, { operations: createBwrapBashOperations(runtime) })
160
- : createBashTool(request.ctx.cwd);
161
- return bash.execute(
162
- request.toolCallId,
163
- { command: request.command, timeout: request.timeout },
164
- request.signal,
165
- request.onUpdate,
166
- );
209
+ ? createBwrapBashOperations(runtime)
210
+ : createLocalBashOperations();
211
+ const output = new BashOutput();
212
+ const { onUpdate } = request;
213
+
214
+ // 流式进度:节流推送尾部快照(对齐 pi 内置 bash 的实时输出体验)
215
+ let updateTimer: ReturnType<typeof setTimeout> | undefined;
216
+ let dirty = false;
217
+ let lastUpdateAt = 0;
218
+ const emitUpdate = () => {
219
+ if (!onUpdate || !dirty) return;
220
+ dirty = false;
221
+ lastUpdateAt = Date.now();
222
+ onUpdate({
223
+ content: [{ type: "text", text: output.tailSnapshot() }],
224
+ details: undefined,
225
+ });
226
+ };
227
+ const scheduleUpdate = () => {
228
+ if (!onUpdate) return;
229
+ dirty = true;
230
+ const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
231
+ if (delay <= 0) {
232
+ if (updateTimer) clearTimeout(updateTimer);
233
+ updateTimer = undefined;
234
+ emitUpdate();
235
+ return;
236
+ }
237
+ if (updateTimer) return;
238
+ updateTimer = setTimeout(() => {
239
+ updateTimer = undefined;
240
+ emitUpdate();
241
+ }, delay);
242
+ };
243
+
244
+ try {
245
+ if (onUpdate) onUpdate({ content: [], details: undefined });
246
+ const { exitCode } = await operations.exec(request.command, request.ctx.cwd, {
247
+ onData: (data) => {
248
+ output.append(data);
249
+ scheduleUpdate();
250
+ },
251
+ signal: request.signal,
252
+ timeout: request.timeout,
253
+ });
254
+ return { exitCode, output: output.toString() };
255
+ } catch (error) {
256
+ // 底层统一把超时/中断转成可读文案(对齐 pi 内置 bash 工具)
257
+ if (error instanceof Error && error.message.startsWith("timeout:")) {
258
+ throw new Error(
259
+ `Command timed out after ${error.message.slice("timeout:".length)} seconds`,
260
+ { cause: error },
261
+ );
262
+ }
263
+ if (error instanceof Error && error.message === "aborted") {
264
+ throw new Error("Command aborted", { cause: error });
265
+ }
266
+ throw error;
267
+ } finally {
268
+ if (updateTimer) clearTimeout(updateTimer);
269
+ if (onUpdate && dirty) emitUpdate();
270
+ }
167
271
  }
168
272
 
169
273
  private resolve(ctx: Pick<ExtensionContext, "cwd" | "hasUI">): ResolvedBwrap {
@@ -187,6 +187,7 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
187
187
  label: "Read",
188
188
  description: [
189
189
  "Reads a file from the local filesystem. You can access any file directly using this tool.",
190
+ "Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.",
190
191
  "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.",
191
192
  "Results use cat -n style line numbers starting at 1. Images are returned visually.",
192
193
  "This tool reads files, not directories.",
@@ -294,7 +295,9 @@ export function registerFileTools(pi: ExtensionAPI, state: ClaudeCodeState): voi
294
295
  {
295
296
  file_path: Type.String({ description: "The absolute path to the file to modify" }),
296
297
  old_string: Type.String({ description: "The text to replace" }),
297
- new_string: Type.String({ description: "The text to replace it with" }),
298
+ new_string: Type.String({
299
+ description: "The text to replace it with (must be different from old_string)",
300
+ }),
298
301
  replace_all: Type.Optional(
299
302
  Type.Boolean({ description: "Replace all occurrences of old_string", default: false }),
300
303
  ),
@@ -118,7 +118,7 @@ export function registerGlobTool(pi: ExtensionAPI): void {
118
118
  path: Type.Optional(
119
119
  Type.String({
120
120
  description:
121
- "The directory to search in. If omitted, the current working directory is used.",
121
+ 'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
122
122
  }),
123
123
  ),
124
124
  },
@@ -1,7 +1,17 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { readFileSync } from "node:fs";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
2
5
  import { fileURLToPath } from "node:url";
3
6
 
4
7
  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";
5
15
  import { Type } from "typebox";
6
16
 
7
17
  import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
@@ -10,9 +20,62 @@ import { resolveWorkdir } from "../lib/path.js";
10
20
  const DEFAULT_TIMEOUT_MS = 120_000;
11
21
  const MAX_TIMEOUT_MS = 600_000;
12
22
 
23
+ /** 对齐 Claude Code formatError:错误文本超过该长度时头尾各保留一半。 */
24
+ const MAX_ERROR_CHARS = 10_000;
25
+
13
26
  /** Bash tool guidance, kept in markdown so it reads like documentation. */
14
27
  const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
15
28
 
29
+ /**
30
+ * 对齐 Claude Code 的错误格式:`Exit code N` 在开头,完整输出随后;
31
+ * 超过 10000 字符时头尾各 5000 + 中间截断提示。
32
+ */
33
+ function formatBashError(exitCode: number | null, output: string): string {
34
+ const full = [`Exit code ${exitCode ?? 1}`, output].filter(Boolean).join("\n");
35
+ if (full.length <= MAX_ERROR_CHARS) return full;
36
+ const half = MAX_ERROR_CHARS / 2;
37
+ return (
38
+ full.slice(0, half) +
39
+ `\n\n... [${full.length - MAX_ERROR_CHARS} characters truncated] ...\n\n` +
40
+ full.slice(-half)
41
+ );
42
+ }
43
+
44
+ /**
45
+ * 成功路径:truncateTail 截断 + 全量落盘临时文件,
46
+ * 提示文本 `[Showing lines X-Y of N. Full output: path]`。
47
+ * opencode 套件的 bash 工具复用同一逻辑。
48
+ */
49
+ export async function formatBashSuccess(
50
+ output: string,
51
+ ): Promise<{ content: { type: "text"; text: string }[]; details: BashToolDetails | undefined }> {
52
+ const truncation = truncateTail(output);
53
+ let text = truncation.content || "(no output)";
54
+ let details: BashToolDetails | undefined;
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 };
63
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
64
+ const endLine = truncation.totalLines;
65
+ if (truncation.lastLinePartial) {
66
+ const lastLineSize = formatSize(
67
+ output.length - output.lastIndexOf("\n", output.length - 2) - 1,
68
+ );
69
+ text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${fullOutputPath}]`;
70
+ } else if (truncation.truncatedBy === "lines") {
71
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${fullOutputPath}]`;
72
+ } else {
73
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit). Full output: ${fullOutputPath}]`;
74
+ }
75
+ }
76
+ return { content: [{ type: "text", text }], details };
77
+ }
78
+
16
79
  /**
17
80
  * runtime 由调用方注入:扩展工厂持有一个实例(不依赖模块级全局状态),
18
81
  * 测试可注入预置模式的实例。状态随扩展实例生命周期,session 切换重建即重置。
@@ -64,8 +127,9 @@ export function registerShellTools(
64
127
 
65
128
  const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
66
129
 
130
+ let result: Awaited<ReturnType<BwrapRuntime["execute"]>>;
67
131
  try {
68
- return await runtime.execute({
132
+ result = await runtime.execute({
69
133
  ctx: { ...ctx, cwd },
70
134
  toolCallId: id,
71
135
  command: params.command,
@@ -85,6 +149,15 @@ export function registerShellTools(
85
149
  : error.message;
86
150
  throw new Error(message, { cause: error });
87
151
  }
152
+
153
+ // 对齐 Claude Code:非 0 退出码视为错误(不做 grep/find 等命令语义化特判,
154
+ // 任何非 0 都抛错);成功路径返回纯输出
155
+ if (result.exitCode !== 0 && result.exitCode !== null) {
156
+ throw new Error(formatBashError(result.exitCode, result.output), {
157
+ cause: result,
158
+ });
159
+ }
160
+ return formatBashSuccess(result.output);
88
161
  },
89
162
  });
90
163
  }
@@ -1,7 +1,8 @@
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
+ import { formatBashSuccess } from "../claude-code/shell.js";
5
6
  import { resolveWorkdir } from "../lib/path.js";
6
7
 
7
8
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -54,8 +55,9 @@ export default function opencodeBash(pi: ExtensionAPI): void {
54
55
 
55
56
  const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
56
57
 
58
+ let result: Awaited<ReturnType<BwrapRuntime["execute"]>>;
57
59
  try {
58
- return await runtime.execute({
60
+ result = await runtime.execute({
59
61
  ctx: { ...ctx, cwd },
60
62
  toolCallId: id,
61
63
  command: params.command,
@@ -74,6 +76,15 @@ export default function opencodeBash(pi: ExtensionAPI): void {
74
76
  : error.message;
75
77
  throw new Error(message, { cause: error });
76
78
  }
79
+
80
+ // 任何非 0 退出码都视为失败(不做命令语义化特判)
81
+ if (result.exitCode !== 0 && result.exitCode !== null) {
82
+ const status = `Command exited with code ${result.exitCode}`;
83
+ throw new Error(result.output ? `${result.output}\n\n${status}` : status, {
84
+ cause: result,
85
+ });
86
+ }
87
+ return formatBashSuccess(result.output);
77
88
  },
78
89
  });
79
90
  }