@trim21/personal-pi-extensions 0.1.678 → 0.1.680

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
@@ -31,21 +31,34 @@
31
31
 
32
32
  `codemode` 让模型写一段 JavaScript(作为 async 函数体,`await` 与 `return` 都可用),在
33
33
  进程内的 worker 线程里用 QuickJS wasm VM 执行。VM 里没有 node、文件系统、网络、timer
34
- 或模块加载,脚本唯一的出口是 `tools.<name>(args)`。
35
-
36
- - **嵌套调用经工具总线**:`tools.Read({ file_path })` 最终执行的是 `Read` 工具自己的
37
- `execute`,所以工具的审批照常生效(写工作区外会弹 write-guard,Bash 沙箱外执行会弹
38
- 自己的提权确认)。codemode 不额外加确认层:脚本里连发十次写操作就是十次工具自己的
39
- 审批(需要审批的那些)。
40
- - **可调用集合** = 总线上实际注册的工具减去 `codemode` 自身,执行时再与当前 active
41
- 列表求交,所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` / `--tools`、
42
- 子代理的工具白名单都同样约束脚本。
34
+ 或模块加载,脚本的出口只有 `call(name, args)` 与 `fs.read` / `fs.write` 两个文件原语。
35
+
36
+ - **嵌套调用经工具总线**:`call("Bash", { command })` 最终执行的是 `Bash` 工具自己的
37
+ `execute`,所以工具的审批照常生效(Bash 沙箱外执行会弹自己的提权确认)。codemode
38
+ 不额外加确认层:脚本里连发十次调用就是十次工具自己的审批(需要审批的那些)。
39
+ - **可调用集合** = 总线上实际注册的工具减去排除名单(`codemode` 自身、`spawn-agent`、
40
+ 以及两套文件工具集的 `Read`/`Edit`/`Write` 与 `read`/`edit`/`write`),执行时再与当前
41
+ active 列表求交,所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` /
42
+ `--tools`、子代理的工具白名单都同样约束脚本。
43
+ - **文件读写只有 `fs` 一条路**:`fs.read(path)` 返回文件全文的原始 UTF-8 文本(不加行号、
44
+ 不截断、不设大小上限,只有内容不是合法 UTF-8 时报错),`fs.write(path, content)` 整体写入并自动创建
45
+ 父目录,相对路径相对当前 cwd。`fs.write` 与写类工具共用同一套保护:写前要求「已读且读后
46
+ 未变」(记账与文件工具共享,工具读过的文件脚本可以直接写),工作区外写入走 write-guard
47
+ 的 diff 审批,headless / Windows / `/bwrap-deny-request` 下直接拒绝。文件读写工具本身
48
+ 不在可调用集合里,脚本要改一行内容也走 `fs.read` + `fs.write`(行级替换用 `Edit` 工具
49
+ 直接改,不进脚本)。
43
50
  - **只有脚本输出进上下文**:`text(value)` / `console.log(...)` 与 `return` 值进入工具结果,
44
51
  中间的工具调用与它们的返回内容不会(也不在会话记录里留下工具调用条目)。
45
- - **脚本接口**:`tools` / `ALL_TOOLS` / `text` / `image` / `exit` / `console.*` /
46
- `store.set` / `store.get` / `store.list`(会话内持久的键值表);首行可选
47
- `// @options: {"max_output_tokens": 10000}`。
52
+ - **返回值**:声明了 `structuredSchema` 的工具(如 gh-readonly 的读类工具)把结果放在
53
+ `structuredResult` 里,`call()` 解包成对象给脚本;`{ ok: false, error }` 会 reject 成
54
+ `CallFailedError`(脚本可按 `instanceof CallFailedError` 区分调用失败与自身运行期错误)。
55
+ 没有声明输出结构的工具回退成工具输出的文本。
56
+ - **脚本接口**:`call` / `CallFailedError` / `ALL_TOOLS` / `fs.read` / `fs.write` /
57
+ `text` / `image` / `exit` / `console.*` / `store.set` / `store.get` / `store.list`
58
+ (会话内持久的键值表);首行可选 `// @options: {"max_output_tokens": 10000}`。
48
59
  脚本没有超时:死循环由调用方中止(Esc)结束,等嵌套调用返回(含用户审批弹窗)多久都不算超时。
60
+ - **工具描述**里给出每个可调用工具的 `declare function call(name, args): Promise<T>` 重载,
61
+ 参数与返回类型都取自工具自己的 schema,所以模型在写脚本前就知道返回值形状。
49
62
  - **store** 记在每次成功调用工具结果的 `details.store` 上(与 `src/lib/file-reads.ts` 的
50
63
  已读记账同一套做法),下一次调用从当前分支的 toolResult 重放;输出超过 `max_output_tokens`
51
64
  时头尾截断并把全文落到 `$TMPDIR/pi-codemode-*.txt`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.678",
3
+ "version": "0.1.680",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -595,8 +595,14 @@ export function registerFileTools(
595
595
  // 它们由 manager 的 onEnabled 回调注册,不在这里注册。
596
596
  }
597
597
 
598
- /** 会更新 reads state 并随 details 持久化快照的工具名。 */
599
- const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write", "lsp-rename"]);
598
+ /**
599
+ * 会更新 reads state 并随 details 持久化快照的工具名。
600
+ *
601
+ * 含 `codemode`:脚本的 `fs.read` / `fs.write`(src/codemode/fs.ts)与本工具集共用
602
+ * 同一个 ReadsState,脚本记下的已读落在 codemode 自己的工具结果里,重放分支时要一起
603
+ * 收回来(见 `restoreReads`)。
604
+ */
605
+ const FILE_TOOL_NAMES = new Set(["Read", "Edit", "Write", "lsp-rename", "codemode"]);
600
606
 
601
607
  export interface ClaudeCodeFileToolOptions extends LspServiceOptions {
602
608
  /** 与 bash runtime 共享的非沙盒请求策略;独立入口不传,自建一份。 */
@@ -619,6 +625,8 @@ export interface FileToolset {
619
625
  register(bus: ToolBus): void;
620
626
  onLspEnabled(bus: ToolBus, service: LspService): void;
621
627
  restoreReads(ctx: ExtensionContext): void;
628
+ /** 与工具共用的已读记账:codemode 的 fs 原语拿它做 stale 保护(两边互通)。 */
629
+ readonly reads: ReadsState;
622
630
  }
623
631
 
624
632
  /**
@@ -668,6 +676,8 @@ export function createClaudeCodeFileTools(
668
676
  restoreReads(ctx) {
669
677
  restoreReads(state, ctx.sessionManager, FILE_TOOL_NAMES);
670
678
  },
679
+
680
+ reads: state,
671
681
  };
672
682
 
673
683
  // 独立入口没有注入 manager 时自建一份(入口共享的那份由入口创建)。
@@ -1,9 +1,9 @@
1
1
  /**
2
- * 把工具的参数 schema 渲染成脚本侧的 TypeScript 声明:写进 codemode 的工具描述里,
3
- * 模型据此知道脚本里能写 `await tools.Read({ file_path: "..." })`。
2
+ * 把工具声明渲染成 codemode 脚本侧的 TypeScript:写进 codemode 的工具描述里,模型据此
3
+ * 知道脚本里能写 `await call("Read", { file_path: "..." })`,以及每个工具返回什么。
4
4
  *
5
5
  * 只处理本仓库工具实际用到的形状(object / array / string / number / boolean /
6
- * union / enum),其余退化成 `unknown`——宁可少给类型,也不要编出错类型。
6
+ * const / union / enum),其余退化成 `unknown`——宁可少给类型,也不要编出错类型。
7
7
  */
8
8
 
9
9
  import type { ScriptTool } from "./protocol.js";
@@ -12,6 +12,7 @@ interface ToolLike {
12
12
  name: string;
13
13
  description?: string;
14
14
  parameters?: unknown;
15
+ structuredSchema?: unknown;
15
16
  }
16
17
 
17
18
  type JsonSchema = Record<string, unknown>;
@@ -24,7 +25,7 @@ function literal(value: unknown): string {
24
25
  return typeof value === "string" ? JSON.stringify(value) : String(value);
25
26
  }
26
27
 
27
- /** 工具名作为属性访问:非法标识符用引号形式。 */
28
+ /** 对象成员名:非法标识符用引号形式。 */
28
29
  function propertyName(name: string): string {
29
30
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
30
31
  }
@@ -33,6 +34,10 @@ function renderType(schema: unknown, indent: string): string {
33
34
  if (!isSchema(schema)) {
34
35
  return "unknown";
35
36
  }
37
+ // TypeBox 的 Type.Literal / StringEnum 产出 const,而不是 enum
38
+ if ("const" in schema) {
39
+ return literal(schema.const);
40
+ }
36
41
  if (Array.isArray(schema.enum) && schema.enum.length > 0) {
37
42
  return schema.enum.map((value) => literal(value)).join(" | ");
38
43
  }
@@ -76,24 +81,42 @@ function renderType(schema: unknown, indent: string): string {
76
81
  }
77
82
  }
78
83
 
79
- /** 单个工具在脚本里的签名(`tools` 的一个成员)。 */
80
- function renderToolMember(tool: ToolLike): string {
84
+ /**
85
+ * 单个工具在脚本里的调用重载:`declare function call(name: "X", args: T): Promise<R>;`。
86
+ * 返回类型取自工具声明的输出结构,未声明就是文本(`string`)。
87
+ */
88
+ function renderOverload(tool: ToolLike): string {
81
89
  const summary = tool.description?.split("\n", 1)[0]?.trim();
82
- const doc = summary ? `/** ${summary} */\n ` : "";
83
- return `${doc}${propertyName(tool.name)}(args: ${renderType(tool.parameters, " ")}): Promise<unknown>;`;
90
+ const doc = summary ? `/** ${summary} */\n` : "";
91
+ const output =
92
+ tool.structuredSchema === undefined ? "string" : renderType(tool.structuredSchema, "");
93
+ return `${doc}declare function call(name: ${JSON.stringify(tool.name)}, args: ${renderType(tool.parameters, "")}): Promise<${output}>;`;
84
94
  }
85
95
 
86
96
  /** 给脚本用的工具清单(名字 + 说明)。 */
87
97
  export function toScriptTools(tools: readonly ToolLike[]): ScriptTool[] {
88
- return tools.map((tool) => ({ name: tool.name, description: tool.description }));
98
+ return tools.map((tool) => ({
99
+ name: tool.name,
100
+ description: tool.description,
101
+ structuredSchema: tool.structuredSchema,
102
+ }));
89
103
  }
90
104
 
91
- /** 渲染脚本侧的声明:`declare const tools: {...}` 与全局辅助函数。 */
105
+ /** 渲染脚本侧的声明:每个工具一条 `call` 重载,外加 `fs` 原语、`CallFailedError` 与全局辅助函数。 */
92
106
  export function renderDeclarations(tools: readonly ToolLike[]): string {
93
- const members = tools.map((tool) => ` ${renderToolMember(tool)}`).join("\n");
94
107
  return [
95
- `declare const tools: {\n${members}\n};`,
108
+ ...tools.map((tool) => renderOverload(tool)),
109
+ // 动态名字的兜底重载,必须放最后
110
+ "declare function call(name: string, args?: unknown): Promise<unknown>;",
96
111
  "declare const ALL_TOOLS: Array<{ name: string; description?: string }>;",
112
+ // 文件原语:直接读写文件。它们不是工具,所以不在上面的 call 重载里。
113
+ [
114
+ "declare const fs: {",
115
+ " read(path: string): Promise<string>;",
116
+ " write(path: string, content: string): Promise<void>;",
117
+ "};",
118
+ ].join("\n"),
119
+ 'declare class CallFailedError extends Error { readonly name: "CallFailedError"; }',
97
120
  "declare function text(value: unknown): void;",
98
121
  "declare function image(value: unknown): void;",
99
122
  "declare function exit(): void;",
@@ -0,0 +1,138 @@
1
+ /**
2
+ * codemode 的文件原语(`fs.read` / `fs.write`):宿主用 `node:fs/promises` 直接读写,
3
+ * 不走工具总线——它们不是工具,不出现在工具列表里,也不参与工具开关与 active 工具求交。
4
+ *
5
+ * 两条保护与文件工具完全一致,而且共用同一份状态:
6
+ * - stale 保护:写入前要求目标文件「已读且读后未变」(`file-reads.ts` 的
7
+ * `requireCurrentRead`),文件不存在时允许直接创建;读与写都记账,因此工具读过的
8
+ * 文件脚本可以直接写,反之亦然。
9
+ * - 审批:写入经 write-guard(工作区内与 `/tmp` 放行,区外弹 diff 审批,headless /
10
+ * Windows / 请求策略下直接拒绝)。
11
+ */
12
+
13
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
14
+ import { dirname, resolve } from "node:path";
15
+
16
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import { Type } from "typebox";
18
+
19
+ import {
20
+ type FileSnapshot,
21
+ type ReadsState,
22
+ recordRead,
23
+ requireCurrentRead,
24
+ snapshotOf,
25
+ } from "../lib/file-reads.js";
26
+ import { parseWithSchema } from "../lib/parse-with-schema.js";
27
+ import type { RequestPolicy } from "../lib/request-policy.js";
28
+ import { guardWriteAccess } from "../lib/write-guard.js";
29
+
30
+ const readArgsSchema = Type.Object({ path: Type.String() });
31
+ const writeArgsSchema = Type.Object({ path: Type.String(), content: Type.String() });
32
+
33
+ const FS_READ = "fs.read";
34
+ const FS_WRITE = "fs.write";
35
+
36
+ export interface FsCallContext {
37
+ ctx: ExtensionContext;
38
+ signal?: AbortSignal;
39
+ }
40
+
41
+ /** 一次 fs 原语的结果:`value` 回给脚本,`reads` 是本次新增的已读记账(进 details.reads)。 */
42
+ export interface FsCallResult {
43
+ value?: unknown;
44
+ reads?: Record<string, FileSnapshot>;
45
+ }
46
+
47
+ export interface CodemodeFs {
48
+ /** 该名字是否归 fs 原语(`fs.read` / `fs.write`)。 */
49
+ handles(name: string): boolean;
50
+ /** 执行一次 fs 原语;失败抛错,由调用方转成脚本侧的错误。 */
51
+ execute(name: string, args: unknown, call: FsCallContext): Promise<FsCallResult>;
52
+ }
53
+
54
+ export interface CodemodeFsOptions {
55
+ /** 与写类工具共享的请求策略:`/bwrap-deny-request` 生效时工作区外写入直接拒绝。 */
56
+ policy: RequestPolicy;
57
+ /** 与文件工具共享的已读记账。 */
58
+ reads: ReadsState;
59
+ }
60
+
61
+ /**
62
+ * 解码 UTF-8:`ignoreBOM: true` 表示不特殊处理 BOM(即保留它),这样内容与磁盘字节
63
+ * 一一对应——记账指纹与审批预览都是按原始字节比对的,丢掉 BOM 会让两者对不上。
64
+ */
65
+ function decodeUtf8(buffer: Uint8Array, path: string): string {
66
+ try {
67
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
68
+ } catch {
69
+ throw new Error(
70
+ `Cannot read "${path}" as UTF-8 text: the file is not valid UTF-8 (binary files are not supported).`,
71
+ );
72
+ }
73
+ }
74
+
75
+ /** 读文件;不存在时返回 undefined(新建文件免已读),其余错误照常抛出。 */
76
+ async function readIfExists(path: string, signal?: AbortSignal): Promise<Buffer | undefined> {
77
+ try {
78
+ return await readFile(path, { signal });
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
81
+ return undefined;
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ export function createCodemodeFs(options: CodemodeFsOptions): CodemodeFs {
88
+ const { policy, reads } = options;
89
+
90
+ async function read(args: unknown, call: FsCallContext): Promise<FsCallResult> {
91
+ const { path } = parseWithSchema(readArgsSchema, args);
92
+ const absolutePath = resolve(call.ctx.cwd, path);
93
+ // 不设大小上限:内容不进模型上下文,放不下时(VM 堆不够)会以错误回到脚本里
94
+ const buffer = await readFile(absolutePath, { signal: call.signal });
95
+ const text = decodeUtf8(buffer, absolutePath);
96
+ // 指纹按磁盘字节算:与文件工具共用记账,两侧的 digest 必须能互相对上
97
+ const recorded = await recordRead(reads, absolutePath, snapshotOf(buffer));
98
+ return { value: text, reads: recorded };
99
+ }
100
+
101
+ async function write(args: unknown, call: FsCallContext): Promise<FsCallResult> {
102
+ const { path, content } = parseWithSchema(writeArgsSchema, args);
103
+ const absolutePath = resolve(call.ctx.cwd, path);
104
+ const existing = await readIfExists(absolutePath, call.signal);
105
+ if (existing !== undefined) {
106
+ await requireCurrentRead(reads, absolutePath, existing);
107
+ }
108
+ await guardWriteAccess(call.ctx, {
109
+ toolName: FS_WRITE,
110
+ absolutePath,
111
+ mutation: {
112
+ contentOld: existing === undefined ? "" : decodeUtf8(existing, absolutePath),
113
+ contentNew: content,
114
+ },
115
+ policy,
116
+ signal: call.signal,
117
+ });
118
+ await mkdir(dirname(absolutePath), { recursive: true });
119
+ await writeFile(absolutePath, content, { encoding: "utf8", signal: call.signal });
120
+ const recorded = await recordRead(reads, absolutePath, snapshotOf(content));
121
+ return { reads: recorded };
122
+ }
123
+
124
+ return {
125
+ handles(name) {
126
+ return name === FS_READ || name === FS_WRITE;
127
+ },
128
+ async execute(name, args, call) {
129
+ if (name === FS_READ) {
130
+ return await read(args, call);
131
+ }
132
+ if (name === FS_WRITE) {
133
+ return await write(args, call);
134
+ }
135
+ throw new Error(`Unknown fs operation "${name}".`);
136
+ },
137
+ };
138
+ }
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * codemode 脚本侧的 prelude:在 QuickJS VM 里先于脚本求值,构建脚本能看到的全部
3
- * 能力(`tools` / `ALL_TOOLS` / `text` / `image` / `exit` / `console` / `store`),
4
- * 并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
3
+ * 能力(`call` / `CallFailedError` / `ALL_TOOLS` / `fs` / `text` / `image` / `exit` /
4
+ * `console` / `store`),并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
5
5
  *
6
6
  * 值与参数过桥时都是 JSON 文本,本侧负责 parse/stringify;异步调用用一个 pending
7
- * 表把 id 映射到 promise,由宿主在结果到达时 settle。
7
+ * 表把 id 映射到 promise,由宿主在结果到达时 settle。每次调用失败(工具或 fs 原语)
8
+ * 都由宿主以 `ok: false` 回报,本侧统一 reject 成 `CallFailedError`(脚本可以按
9
+ * instanceof 区分「调用失败」与自己的运行期错误)。
8
10
  *
9
11
  * 求值结果是一个函数 `(bridge, toolsJson, storeJson) => { settle, run, stalled }`。
10
12
  * `bridge(kind, a, b, c)`:
11
- * - `"call"`(id, name, argsJson)
13
+ * - `"call"`(id, name, argsJson):工具,以及名字为 `fs.read` / `fs.write` 的 fs 原语
12
14
  * - `"output"`("text", text)/("image", data, mimeType)
13
15
  * - `"done"`(ok, valueJsonOrErrorJson, writesJson)
14
16
  */
@@ -28,6 +30,13 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
28
30
  const promiseThen = Promise.prototype.then;
29
31
  const ErrorCtor = Error;
30
32
  const TypeErrorCtor = TypeError;
33
+ // 嵌套调用失败统一用它 reject:脚本能按 instanceof 区分「工具失败」与自身运行期错误
34
+ class CallFailedError extends ErrorCtor {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = "CallFailedError";
38
+ }
39
+ }
31
40
  const pending = new Map();
32
41
  let nextId = 1;
33
42
  let finished = false;
@@ -89,20 +98,36 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
89
98
  });
90
99
  }
91
100
 
92
- const tools = Object.create(null);
101
+ const callers = new Map();
93
102
  const allTools = [];
94
- for (const { name, jsName, description } of parse(toolsJson)) {
95
- const fn = caller(name);
96
- // 两个名字归一化成同一个标识符时,第一个赢
97
- if (!(jsName in tools)) {
98
- tools[jsName] = fn;
99
- allTools.push(Object.freeze({ name: jsName, description }));
100
- }
101
- if (!(name in tools)) tools[name] = fn;
103
+ for (const { name, description } of parse(toolsJson)) {
104
+ if (callers.has(name)) continue;
105
+ callers.set(name, caller(name));
106
+ allTools.push(Object.freeze({ name, description }));
102
107
  }
103
- Object.freeze(tools);
104
108
  Object.freeze(allTools);
105
109
 
110
+ function call(name, args) {
111
+ const fn = callers.get(name);
112
+ if (fn === undefined) {
113
+ return Promise.reject(
114
+ new CallFailedError('Tool "' + String(name) + '" is not available in codemode.'),
115
+ );
116
+ }
117
+ return fn(args);
118
+ }
119
+ Object.freeze(call);
120
+
121
+ // 脚本的文件原语:与工具走同一条桥,但它们是内建能力而不是工具——不进 ALL_TOOLS,
122
+ // 也不出现在工具描述的工具重载里(声明单独渲染),写审批与已读记账由宿主负责。
123
+ // 脚本侧是 Node 风格的位置参数,过桥仍是一个可校验的对象。
124
+ const readFile = caller("fs.read");
125
+ const writeFile = caller("fs.write");
126
+ const fs = Object.freeze({
127
+ read: (path) => readFile({ path }),
128
+ write: (path, content) => writeFile({ path, content }),
129
+ });
130
+
106
131
  // key -> JSON 文本;容量按 key 与 JSON 的字符数计
107
132
  const stored = new Map();
108
133
  const writes = new Map();
@@ -242,8 +267,10 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
242
267
  }
243
268
  Object.freeze(console);
244
269
 
245
- Object.defineProperty(globalThis, "tools", { value: tools, enumerable: true });
270
+ Object.defineProperty(globalThis, "call", { value: call, enumerable: true });
271
+ Object.defineProperty(globalThis, "CallFailedError", { value: CallFailedError, enumerable: true });
246
272
  Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
273
+ Object.defineProperty(globalThis, "fs", { value: fs, enumerable: true });
247
274
  Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
248
275
  Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
249
276
  Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
@@ -255,7 +282,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
255
282
  if (!entry) return;
256
283
  pending.delete(id);
257
284
  if (!ok) {
258
- entry.reject(new ErrorCtor(payload));
285
+ entry.reject(new CallFailedError(payload));
259
286
  return;
260
287
  }
261
288
  let value;
@@ -270,7 +297,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
270
297
  run(fn) {
271
298
  let promise;
272
299
  try {
273
- promise = fn(tools, console);
300
+ promise = fn();
274
301
  } catch (error) {
275
302
  done(false, describeError(error));
276
303
  return;
@@ -13,6 +13,18 @@
13
13
  import { type TSchema, Type } from "typebox";
14
14
  import { Value } from "typebox/value";
15
15
 
16
+ // ── 资源上限 ─────────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * QuickJS VM 的堆上限。它不是预留(创建 VM 只占几 MiB,按脚本实际分配增长),而是
20
+ * 「超量分配变成脚本里可捕获的 InternalError,而不是拖垮宿主」的那条线。
21
+ *
22
+ * 文件读取没有单独的上限:读进来的内容不进模型上下文,所以不需要按上下文预算裁剪;
23
+ * 真正放不下时(VM 堆不够、或超出宿主字符串/缓冲上限)会以错误回到脚本里,让它自己
24
+ * 决定怎么办。
25
+ */
26
+ export const MEMORY_LIMIT_BYTES = 2 * 1024 ** 3;
27
+
16
28
  // ── 消息 schema ──────────────────────────────────────────────────────────────
17
29
 
18
30
  const outputItemSchema = Type.Union([
@@ -23,6 +35,8 @@ const outputItemSchema = Type.Union([
23
35
  const toolDeclSchema = Type.Object({
24
36
  name: Type.String(),
25
37
  description: Type.Optional(Type.String()),
38
+ /** 工具的 structuredSchema(TypeBox schema):脚本侧据此渲染 call() 的返回类型。 */
39
+ structuredSchema: Type.Optional(Type.Unknown()),
26
40
  });
27
41
 
28
42
  const startSchema = Type.Object({
@@ -54,7 +68,7 @@ const callSchema = Type.Object({
54
68
  args: Type.Unknown(),
55
69
  });
56
70
 
57
- const outputSchema = Type.Object({
71
+ const outputFrameSchema = Type.Object({
58
72
  t: Type.Literal("output"),
59
73
  items: Type.Array(outputItemSchema),
60
74
  });
@@ -87,7 +101,7 @@ const doneSchema = Type.Union([
87
101
  ]);
88
102
 
89
103
  const hostMessageSchema = Type.Union([startSchema, resultSchema]);
90
- const workerMessageSchema = Type.Union([callSchema, outputSchema, doneSchema]);
104
+ const workerMessageSchema = Type.Union([callSchema, outputFrameSchema, doneSchema]);
91
105
 
92
106
  /** worker 启动数据:注册时编译好的 wasm 模块(结构化克隆可以带它跨线程)。 */
93
107
  export interface WorkerBootstrap {
@@ -117,6 +131,7 @@ export interface StoreWrites {
117
131
  export interface ScriptTool {
118
132
  name: string;
119
133
  description?: string;
134
+ structuredSchema?: unknown;
120
135
  }
121
136
 
122
137
  export type HostMessage =
@@ -1,11 +1,12 @@
1
1
  /**
2
- * codemode 工具:模型写一段 JavaScript,脚本在 QuickJS VM(worker 线程)里执行,脚本唯一
3
- * 的能力是调用 `tools.*`——每个嵌套调用都由主线程经本仓库的工具总线执行,因此工具实现
4
- * 内部的审批(工作区外写入、Bash 沙箱提权等)照常生效;codemode 不再加自己的确认层。
2
+ * codemode 工具:模型写一段 JavaScript,脚本在 QuickJS VM(worker 线程)里执行,脚本的
3
+ * 能力有两条:`call(name, args)` 调用工具(每个嵌套调用都由主线程经本仓库的工具总线执行,
4
+ * 因此工具实现内部的审批——工作区外写入、Bash 沙箱提权等——照常生效;codemode 不再加
5
+ * 自己的确认层),以及 `fs.read` / `fs.write` 两个文件原语(同样由主线程执行,与文件工具
6
+ * 共用写审批与已读记账,见 fs.ts)。
5
7
  *
6
- * 可调用集合:总线上实际注册的工具减去 codemode 自身与 spawn-agent,执行时再与 active
8
+ * 可调用集合:总线上实际注册的工具减去 `EXCLUDED_TOOL_NAMES`,执行时再与 active
7
9
  * 列表求交——pi 自己的 `defaultTools` / `--tools` / 子代理白名单的排除因此同样生效。
8
- * spawn-agent 被排除是因为它启动一个新的隔离会话、成本与运行时长都不适合放进脚本编排。
9
10
  *
10
11
  * wasm 在注册这个工具时编译一次(`createCodemodeSandbox`),worker 复用编译结果。
11
12
  */
@@ -18,33 +19,59 @@ import { join } from "node:path";
18
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
20
  import { Type } from "typebox";
20
21
 
22
+ import type { FileSnapshot, ReadsState } from "../lib/file-reads.js";
21
23
  import type { ToolPendant } from "../lib/pendant.js";
24
+ import type { RequestPolicy } from "../lib/request-policy.js";
22
25
  import { type ToolBus, toolResultText } from "../lib/tool-bus.js";
23
26
  import { renderDeclarations, toScriptTools } from "./declarations.js";
27
+ import { createCodemodeFs } from "./fs.js";
24
28
  import type { CodemodeOutputItem, ScriptError, StoreWrites } from "./protocol.js";
25
29
  import { type CodemodeSandbox, createCodemodeSandbox, type ScriptCall } from "./sandbox.js";
26
30
  import { CODEMODE_SOURCE_GRAMMAR, DEFAULT_OUTPUT_TOKENS, parseCodemodeSource } from "./source.js";
27
31
 
28
32
  export const CODEMODE_TOOL_NAME = "codemode";
29
33
 
30
- /** 不暴露给脚本的工具:codemode 自身(防递归)与 spawn-agent(见文件头注释)。 */
31
- const EXCLUDED_TOOL_NAMES: ReadonlySet<string> = new Set([CODEMODE_TOOL_NAME, "spawn-agent"]);
34
+ /**
35
+ * 不暴露给脚本的工具:
36
+ * - codemode 自身(防递归);
37
+ * - spawn-agent:它启动一个新的隔离会话,成本与运行时长都不适合放进脚本编排;
38
+ * - 两套文件工具集的读写工具:脚本用 `fs.read` / `fs.write`(原文、不截断、按路径整体
39
+ * 写入),不重复给一套为 LLM 上下文设计的行号/锚点语义。
40
+ */
41
+ const EXCLUDED_TOOL_NAMES: ReadonlySet<string> = new Set([
42
+ CODEMODE_TOOL_NAME,
43
+ "spawn-agent",
44
+ "Read",
45
+ "Edit",
46
+ "Write",
47
+ "read",
48
+ "edit",
49
+ "write",
50
+ ]);
32
51
 
33
52
  /** 估计 token 用的字符数(与 pi 一致)。 */
34
53
  const CHARS_PER_TOKEN = 4;
35
54
 
55
+ export interface CodemodeToolDeps {
56
+ /** 与写类工具共享的非沙盒请求策略(写审批要用)。 */
57
+ policy: RequestPolicy;
58
+ /** 与文件工具共享的已读记账:脚本的 fs 原语据此做 stale 保护(两边互通)。 */
59
+ reads: ReadsState;
60
+ }
61
+
36
62
  export interface CodemodeTools {
37
63
  /**
38
64
  * 注册 codemode 工具。注册时编译 quickjs.wasm(编译失败则注册失败,由入口记成
39
65
  * 警告),之后每次执行复用同一份编译结果。
40
66
  */
41
- register(bus: ToolBus): Promise<void>;
67
+ register(bus: ToolBus, deps: CodemodeToolDeps): Promise<void>;
42
68
  }
43
69
 
44
70
  interface CallableTool {
45
71
  name: string;
46
72
  description?: string;
47
73
  parameters?: unknown;
74
+ structuredSchema?: unknown;
48
75
  }
49
76
 
50
77
  /** 只在 pi 有 active 工具概念时才求交(子代理、`--tools` 等场景)。 */
@@ -66,6 +93,7 @@ function collectTools(bus: ToolBus, allowed: Set<string> | undefined): CallableT
66
93
  name: definition.name,
67
94
  description: definition.description,
68
95
  parameters: definition.parameters,
96
+ structuredSchema: definition.structuredSchema,
69
97
  }));
70
98
  }
71
99
 
@@ -73,8 +101,8 @@ function buildDescription(tools: readonly CallableTool[]): string {
73
101
  return [
74
102
  "Run JavaScript code that orchestrates tool calls in a QuickJS sandbox.",
75
103
  "- The code is the body of an async function: top-level `await` and `return` both work.",
76
- "- Call tools with `await tools.<name>(args)`. Arguments and results make a JSON round trip,",
77
- " and a failing tool rejects with an `Error` you can catch.",
104
+ "- Call tools with `await call(name, args)`. Arguments and results make a JSON round trip,",
105
+ " and a failing tool rejects with a `CallFailedError` you can catch.",
78
106
  "- Only what the script passes to `text(value)` / `console.log(...)` and its `return` value enter",
79
107
  " this conversation; nested calls and their results stay out of it.",
80
108
  "- `store.set(key, value)`, `store.get(key)` and `store.list()` are a small key/value store that",
@@ -124,6 +152,11 @@ function readStore(ctx: ExtensionContext): Record<string, unknown> {
124
152
  return Object.fromEntries(store);
125
153
  }
126
154
 
155
+ /** 本次脚本有没有通过 fs 读写过文件(决定 details 里要不要带 reads 记账)。 */
156
+ function hasReads(reads: Record<string, FileSnapshot>): boolean {
157
+ return Object.keys(reads).length > 0;
158
+ }
159
+
127
160
  function errorText(error: ScriptError): string {
128
161
  const head = error.name ? `${error.name}: ${error.message}` : error.message;
129
162
  return `${head}${error.stack && error.stack !== head ? `\n${error.stack}` : ""}`;
@@ -222,8 +255,9 @@ function formatCallSummary(calls: readonly ScriptCall[]): string {
222
255
 
223
256
  export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
224
257
  return {
225
- async register(bus) {
258
+ async register(bus, deps) {
226
259
  const sandbox: CodemodeSandbox = await createCodemodeSandbox();
260
+ const fs = createCodemodeFs(deps);
227
261
  const tools = collectTools(bus, allowedToolNames(pi));
228
262
  const callable = new Set(tools.map((tool) => tool.name));
229
263
  const scriptTools = toScriptTools(tools);
@@ -267,6 +301,9 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
267
301
  };
268
302
  }
269
303
 
304
+ // 脚本的 fs 原语读到的文件也进同一份记账,随结果持久化(与 details.store 同一处)
305
+ const recordedReads: Record<string, FileSnapshot> = {};
306
+
270
307
  const outcome = await sandbox.run({
271
308
  code,
272
309
  tools: scriptTools,
@@ -293,10 +330,29 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
293
330
  });
294
331
  },
295
332
  onCall: async ({ name, args }) => {
333
+ if (fs.handles(name)) {
334
+ try {
335
+ const result = await fs.execute(name, args, { ctx, signal });
336
+ if (result.reads !== undefined) {
337
+ Object.assign(recordedReads, result.reads);
338
+ }
339
+ return { ok: true, value: result.value };
340
+ } catch (error) {
341
+ return {
342
+ ok: false,
343
+ error: error instanceof Error ? error.message : String(error),
344
+ };
345
+ }
346
+ }
296
347
  if (!callable.has(name)) {
297
348
  return { ok: false, error: `Tool "${name}" is not available in codemode.` };
298
349
  }
299
350
  const result = await bus.executeTool(name, args, { ctx, signal });
351
+ if (result.structuredResult) {
352
+ return result.structuredResult.ok
353
+ ? { ok: true, value: result.structuredResult.value }
354
+ : { ok: false, error: result.structuredResult.error };
355
+ }
300
356
  const text = toolResultText(result);
301
357
  if (result.isError) {
302
358
  return { ok: false, error: text };
@@ -329,6 +385,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
329
385
  calls: outcome.calls,
330
386
  // store 的写入随工具结果持久化,下一次调用从这里重放恢复
331
387
  ...(hasWrites && { store: writes }),
388
+ ...(hasReads(recordedReads) && { reads: recordedReads }),
332
389
  pendant: scriptPendant(code, `${outcome.calls.length} tool call(s)`),
333
390
  ...(truncated.fullOutputPath && { fullOutputPath: truncated.fullOutputPath }),
334
391
  },
@@ -349,6 +406,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
349
406
  details: {
350
407
  calls: outcome.calls,
351
408
  error: outcome.error.kind,
409
+ ...(hasReads(recordedReads) && { reads: recordedReads }),
352
410
  pendant: scriptPendant(code, `failed (${outcome.error.kind})`),
353
411
  },
354
412
  };