@trim21/personal-pi-extensions 0.1.680 → 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 CHANGED
@@ -36,10 +36,12 @@
36
36
  - **嵌套调用经工具总线**:`call("Bash", { command })` 最终执行的是 `Bash` 工具自己的
37
37
  `execute`,所以工具的审批照常生效(Bash 沙箱外执行会弹自己的提权确认)。codemode
38
38
  不额外加确认层:脚本里连发十次调用就是十次工具自己的审批(需要审批的那些)。
39
- - **可调用集合** = 总线上实际注册的工具减去排除名单(`codemode` 自身、`spawn-agent`、
40
- 以及两套文件工具集的 `Read`/`Edit`/`Write` 与 `read`/`edit`/`write`),执行时再与当前
41
- active 列表求交,所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` /
42
- `--tools`、子代理的工具白名单都同样约束脚本。
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` 的工具(如 gh-readonly 的读类工具)把结果放在
53
- `structuredResult` 里,`call()` 解包成对象给脚本;`{ ok: false, error }` 会 reject 成
54
- `CallFailedError`(脚本可按 `instanceof CallFailedError` 区分调用失败与自身运行期错误)。
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}`。
@@ -64,7 +68,10 @@
64
68
  时头尾截断并把全文落到 `$TMPDIR/pi-codemode-*.txt`。
65
69
  - **构建**:worker 入口是 esbuild 产物 `src/codemode/worker.js`(随仓库提交,
66
70
  `pnpm run build:codemode-worker` 重新生成,pre-commit 会自动跑);`quickjs-wasi` 的
67
- wasm 在注册工具时编译一次,之后每次执行复用。
71
+ wasm 在注册工具时编译一次,之后每次执行复用。产物必须**自包含**:它由 `new Worker(url)`
72
+ 作为普通 Node 模块加载,不像主线程那样经 jiti 从 pi 的 `node_modules` 解析裸包名,所以
73
+ 除了 `quickjs-wasi`(我们自己的 dependency)之外的依赖都要打包进去——否则用户装好包后
74
+ worker 会在启动时报 `Cannot find package`。
68
75
 
69
76
  ## 工具启用配置
70
77
 
@@ -422,7 +429,7 @@ Claude Code 风格工具集:`Read` / `Edit` / `Write` / `Grep` / `Glob` / `Bas
422
429
 
423
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`
424
431
  - **`Grep` / `Glob`**:ripgrep 实现,行为跟随 Claude Code(`Glob` 带 `--no-ignore` / `--hidden` / `--sort=modified`,与 opencode 风格 `glob` 的差异是各自跟随上游)
425
- - **`Bash`**:与 opencode 风格 `bash` 共享 bwrap 运行时与 `dangerouslyDisableSandbox` 提权,默认超时 120 秒,只支持同步执行
432
+ - **`Bash`**:与 opencode 风格 `bash` 共享 bwrap 运行时与 `dangerouslyDisableSandbox` 提权,默认超时 120 秒,只支持同步执行。结构化结果给 `{ exitCode, output }`:`output` 是命令的**完整**输出(文本该截断还截断,载荷里是全文),`exitCode` 为 `null` 表示被超时/中止杀掉;命令非零退出不是失败,脚本直接读 `exitCode` 分支
426
433
  - **`TodoWrite`**:任务列表工具(`merge` 语义),与 opencode 风格 `todowrite` 的完整替换语义不同,不要混用
427
434
  - **`AskUserQuestion`**:阻塞式向用户提问
428
435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.680",
3
+ "version": "0.1.683",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -28,7 +28,7 @@
28
28
  "aft-search": "jiti bin/aft-search.ts",
29
29
  "build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
30
30
  "prepare": "husky",
31
- "build:codemode-worker": "esbuild src/codemode/worker.ts --bundle --packages=external --format=esm --target=node24 --outfile=src/codemode/worker.js"
31
+ "build:codemode-worker": "esbuild src/codemode/worker.ts --bundle --platform=node --external:quickjs-wasi --format=esm --target=node24 --outfile=src/codemode/worker.js"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-agent-core": ">=0.84.1",
@@ -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 { ToolBus } from "../lib/tool-bus.js";
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
- name: "Bash",
93
- promptSnippet: "execute command",
94
- promptGuidelines: [BASH_PROMPT],
95
- label: "Bash",
96
- description: [
97
- "Executes a given bash command synchronously and returns its output.",
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
- parameters: Type.Object(
102
- {
103
- command: Type.String({ description: "The command to execute" }),
104
- timeout: Type.Optional(
105
- Type.Number({ description: "Optional timeout in milliseconds (max 7200000)" }),
106
- ),
107
- description: Type.Optional(
108
- Type.String({ description: "Clear, concise description of the command" }),
109
- ),
110
- workdir: Type.Optional(
111
- Type.String({
112
- description:
113
- "Working directory to execute the command in. Defaults to the current directory; relative paths resolve from there.",
114
- }),
115
- ),
116
- dangerouslyDisableSandbox: Type.Optional(
117
- Type.Boolean({
118
- description:
119
- "Request one-time unsandboxed execution. The user must approve this request.",
120
- }),
121
- ),
122
- },
123
- { additionalProperties: false },
124
- ),
125
- async execute(id, params, signal, onUpdate, ctx) {
126
- const timeout = params.timeout ?? DEFAULT_TIMEOUT_MS;
127
- if (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) {
128
- throw new Error(`timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
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
- const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
165
+ const cwd = params.workdir ? await resolveWorkdir(params.workdir, ctx.cwd) : ctx.cwd;
132
166
 
133
- let result: Awaited<ReturnType<BwrapRuntime["execute"]>>;
134
- try {
135
- result = await runtime.execute({
136
- ctx,
137
- cwd,
138
- toolCallId: id,
139
- command: params.command,
140
- timeout: timeout / 1000,
141
- requestFullAccess: params.dangerouslyDisableSandbox,
142
- description: params.description,
143
- signal,
144
- onUpdate,
145
- });
146
- } catch (error) {
147
- if (!(error instanceof Error)) {
148
- throw error;
149
- }
150
- if (error instanceof BashInterruptedError) {
151
- // 输出在前(必要时带截断提示),状态文本在最后
152
- const text = appendTruncationNotice(
153
- error.partial.output || "",
154
- error.partial.truncation,
155
- error.partial.fullOutputPath,
156
- );
157
- if (error.kind === "aborted") {
158
- // 用户取消:直接返回已捕获的输出,不抛错;时长只算命令真正运行的时间,
159
- // 不含审批弹窗等 UI 交互
160
- const status = `Command aborted by user after ${formatElapsedSeconds(error.elapsedMs)}`;
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", text: text ? `${text}\n\n${status}` : status },
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
- const full = text
170
- ? `${text}\n\nCommand timed out after ${timeout} milliseconds`
171
- : `Command timed out after ${timeout} milliseconds`;
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(error.sandboxHint),
176
- ...sandboxHintBlock(error.sandboxReminder),
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
- content: [
193
- { type: "text", text: formatBashError(result.exitCode, full) },
194
- ...sandboxHintBlock(result.sandboxHint),
195
- ...sandboxHintBlock(result.sandboxReminder),
196
- ],
197
- details: undefined,
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
- return formatBashSuccess(result);
201
- },
202
- });
270
+ },
271
+ structuredSchema: bashStructuredSchema,
272
+ }),
273
+ );
203
274
  }
@@ -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 一致)。 */