@trim21/personal-pi-extensions 0.1.679 → 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,24 +31,31 @@
31
31
 
32
32
  `codemode` 让模型写一段 JavaScript(作为 async 函数体,`await` 与 `return` 都可用),在
33
33
  进程内的 worker 线程里用 QuickJS wasm VM 执行。VM 里没有 node、文件系统、网络、timer
34
- 或模块加载,脚本唯一的出口是 `call(name, args)`。
35
-
36
- - **嵌套调用经工具总线**:`call("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
52
  - **返回值**:声明了 `structuredSchema` 的工具(如 gh-readonly 的读类工具)把结果放在
46
53
  `structuredResult` 里,`call()` 解包成对象给脚本;`{ ok: false, error }` 会 reject 成
47
- `CallFailedError`(脚本可按 `instanceof CallFailedError` 区分工具失败与自身运行期错误)。
54
+ `CallFailedError`(脚本可按 `instanceof CallFailedError` 区分调用失败与自身运行期错误)。
48
55
  没有声明输出结构的工具回退成工具输出的文本。
49
- - **脚本接口**:`call` / `CallFailedError` / `ALL_TOOLS` / `text` / `image` / `exit` /
50
- `console.*` / `store.set` / `store.get` / `store.list`(会话内持久的键值表);首行可选
51
- `// @options: {"max_output_tokens": 10000}`。
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}`。
52
59
  脚本没有超时:死循环由调用方中止(Esc)结束,等嵌套调用返回(含用户审批弹窗)多久都不算超时。
53
60
  - **工具描述**里给出每个可调用工具的 `declare function call(name, args): Promise<T>` 重载,
54
61
  参数与返回类型都取自工具自己的 schema,所以模型在写脚本前就知道返回值形状。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.679",
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 时自建一份(入口共享的那份由入口创建)。
@@ -102,13 +102,20 @@ export function toScriptTools(tools: readonly ToolLike[]): ScriptTool[] {
102
102
  }));
103
103
  }
104
104
 
105
- /** 渲染脚本侧的声明:每个工具一条 `call` 重载,外加 `CallFailedError` 与全局辅助函数。 */
105
+ /** 渲染脚本侧的声明:每个工具一条 `call` 重载,外加 `fs` 原语、`CallFailedError` 与全局辅助函数。 */
106
106
  export function renderDeclarations(tools: readonly ToolLike[]): string {
107
107
  return [
108
108
  ...tools.map((tool) => renderOverload(tool)),
109
109
  // 动态名字的兜底重载,必须放最后
110
110
  "declare function call(name: string, args?: unknown): Promise<unknown>;",
111
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"),
112
119
  'declare class CallFailedError extends Error { readonly name: "CallFailedError"; }',
113
120
  "declare function text(value: unknown): void;",
114
121
  "declare function image(value: unknown): 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,16 +1,16 @@
1
1
  /**
2
2
  * codemode 脚本侧的 prelude:在 QuickJS VM 里先于脚本求值,构建脚本能看到的全部
3
- * 能力(`call` / `CallFailedError` / `ALL_TOOLS` / `text` / `image` / `exit` /
3
+ * 能力(`call` / `CallFailedError` / `ALL_TOOLS` / `fs` / `text` / `image` / `exit` /
4
4
  * `console` / `store`),并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
5
5
  *
6
6
  * 值与参数过桥时都是 JSON 文本,本侧负责 parse/stringify;异步调用用一个 pending
7
- * 表把 id 映射到 promise,由宿主在结果到达时 settle。每次嵌套调用失败都由宿主以
8
- * `ok: false` 回报,本侧统一 reject 成 `CallFailedError`(脚本可以按 instanceof 区分
9
- * 「工具失败」与自己的运行期错误)。
7
+ * 表把 id 映射到 promise,由宿主在结果到达时 settle。每次调用失败(工具或 fs 原语)
8
+ * 都由宿主以 `ok: false` 回报,本侧统一 reject 成 `CallFailedError`(脚本可以按
9
+ * instanceof 区分「调用失败」与自己的运行期错误)。
10
10
  *
11
11
  * 求值结果是一个函数 `(bridge, toolsJson, storeJson) => { settle, run, stalled }`。
12
12
  * `bridge(kind, a, b, c)`:
13
- * - `"call"`(id, name, argsJson)
13
+ * - `"call"`(id, name, argsJson):工具,以及名字为 `fs.read` / `fs.write` 的 fs 原语
14
14
  * - `"output"`("text", text)/("image", data, mimeType)
15
15
  * - `"done"`(ok, valueJsonOrErrorJson, writesJson)
16
16
  */
@@ -118,6 +118,16 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
118
118
  }
119
119
  Object.freeze(call);
120
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
+
121
131
  // key -> JSON 文本;容量按 key 与 JSON 的字符数计
122
132
  const stored = new Map();
123
133
  const writes = new Map();
@@ -260,6 +270,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
260
270
  Object.defineProperty(globalThis, "call", { value: call, enumerable: true });
261
271
  Object.defineProperty(globalThis, "CallFailedError", { value: CallFailedError, enumerable: true });
262
272
  Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
273
+ Object.defineProperty(globalThis, "fs", { value: fs, enumerable: true });
263
274
  Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
264
275
  Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
265
276
  Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
@@ -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([
@@ -1,11 +1,12 @@
1
1
  /**
2
- * codemode 工具:模型写一段 JavaScript,脚本在 QuickJS VM(worker 线程)里执行,脚本唯一
3
- * 的能力是调用 `call(name, args)`——每个嵌套调用都由主线程经本仓库的工具总线执行,因此工具实现
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,27 +19,52 @@ 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 {
@@ -126,6 +152,11 @@ function readStore(ctx: ExtensionContext): Record<string, unknown> {
126
152
  return Object.fromEntries(store);
127
153
  }
128
154
 
155
+ /** 本次脚本有没有通过 fs 读写过文件(决定 details 里要不要带 reads 记账)。 */
156
+ function hasReads(reads: Record<string, FileSnapshot>): boolean {
157
+ return Object.keys(reads).length > 0;
158
+ }
159
+
129
160
  function errorText(error: ScriptError): string {
130
161
  const head = error.name ? `${error.name}: ${error.message}` : error.message;
131
162
  return `${head}${error.stack && error.stack !== head ? `\n${error.stack}` : ""}`;
@@ -224,8 +255,9 @@ function formatCallSummary(calls: readonly ScriptCall[]): string {
224
255
 
225
256
  export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
226
257
  return {
227
- async register(bus) {
258
+ async register(bus, deps) {
228
259
  const sandbox: CodemodeSandbox = await createCodemodeSandbox();
260
+ const fs = createCodemodeFs(deps);
229
261
  const tools = collectTools(bus, allowedToolNames(pi));
230
262
  const callable = new Set(tools.map((tool) => tool.name));
231
263
  const scriptTools = toScriptTools(tools);
@@ -269,6 +301,9 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
269
301
  };
270
302
  }
271
303
 
304
+ // 脚本的 fs 原语读到的文件也进同一份记账,随结果持久化(与 details.store 同一处)
305
+ const recordedReads: Record<string, FileSnapshot> = {};
306
+
272
307
  const outcome = await sandbox.run({
273
308
  code,
274
309
  tools: scriptTools,
@@ -295,6 +330,20 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
295
330
  });
296
331
  },
297
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
+ }
298
347
  if (!callable.has(name)) {
299
348
  return { ok: false, error: `Tool "${name}" is not available in codemode.` };
300
349
  }
@@ -336,6 +385,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
336
385
  calls: outcome.calls,
337
386
  // store 的写入随工具结果持久化,下一次调用从这里重放恢复
338
387
  ...(hasWrites && { store: writes }),
388
+ ...(hasReads(recordedReads) && { reads: recordedReads }),
339
389
  pendant: scriptPendant(code, `${outcome.calls.length} tool call(s)`),
340
390
  ...(truncated.fullOutputPath && { fullOutputPath: truncated.fullOutputPath }),
341
391
  },
@@ -356,6 +406,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
356
406
  details: {
357
407
  calls: outcome.calls,
358
408
  error: outcome.error.kind,
409
+ ...(hasReads(recordedReads) && { reads: recordedReads }),
359
410
  pendant: scriptPendant(code, `failed (${outcome.error.kind})`),
360
411
  },
361
412
  };
@@ -101,6 +101,16 @@ var PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson) {
101
101
  }
102
102
  Object.freeze(call);
103
103
 
104
+ // 脚本的文件原语:与工具走同一条桥,但它们是内建能力而不是工具——不进 ALL_TOOLS,
105
+ // 也不出现在工具描述的工具重载里(声明单独渲染),写审批与已读记账由宿主负责。
106
+ // 脚本侧是 Node 风格的位置参数,过桥仍是一个可校验的对象。
107
+ const readFile = caller("fs.read");
108
+ const writeFile = caller("fs.write");
109
+ const fs = Object.freeze({
110
+ read: (path) => readFile({ path }),
111
+ write: (path, content) => writeFile({ path, content }),
112
+ });
113
+
104
114
  // key -> JSON 文本;容量按 key 与 JSON 的字符数计
105
115
  const stored = new Map();
106
116
  const writes = new Map();
@@ -243,6 +253,7 @@ var PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson) {
243
253
  Object.defineProperty(globalThis, "call", { value: call, enumerable: true });
244
254
  Object.defineProperty(globalThis, "CallFailedError", { value: CallFailedError, enumerable: true });
245
255
  Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
256
+ Object.defineProperty(globalThis, "fs", { value: fs, enumerable: true });
246
257
  Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
247
258
  Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
248
259
  Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
@@ -309,6 +320,7 @@ var PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson) {
309
320
  // src/codemode/protocol.ts
310
321
  import { Type } from "typebox";
311
322
  import { Value } from "typebox/value";
323
+ var MEMORY_LIMIT_BYTES = 2 * 1024 ** 3;
312
324
  var outputItemSchema = Type.Union([
313
325
  Type.Object({ type: Type.Literal("text"), text: Type.String() }),
314
326
  Type.Object({ type: Type.Literal("image"), data: Type.String(), mimeType: Type.String() })
@@ -387,7 +399,6 @@ function decodeHostMessage(value) {
387
399
  }
388
400
 
389
401
  // src/codemode/worker.ts
390
- var MEMORY_LIMIT_BYTES = 512 * 1024 * 1024;
391
402
  function discardOutput(memory) {
392
403
  return {
393
404
  fd_write(_fd, iovsPtr, iovsLen, nwrittenPtr) {
@@ -17,15 +17,13 @@ import { PRELUDE_SOURCE } from "./prelude.js";
17
17
  import {
18
18
  decodeHostMessage,
19
19
  type HostMessage,
20
+ MEMORY_LIMIT_BYTES,
20
21
  type ScriptError,
21
22
  type StoreWrites,
22
23
  type WorkerBootstrap,
23
24
  type WorkerMessage,
24
25
  } from "./protocol.js";
25
26
 
26
- /** QuickJS VM 的堆上限:超量分配在脚本里变成 InternalError,而不是拖垮宿主。 */
27
- const MEMORY_LIMIT_BYTES = 512 * 1024 * 1024;
28
-
29
27
  /**
30
28
  * QuickJS 把引擎诊断写到 fd 1 / 2,那会直接进 pi 的 TUI;按写入长度回报并丢弃内容,
31
29
  * 避免 libc 重试。
package/src/index.ts CHANGED
@@ -120,8 +120,11 @@ export default function personalExtensions(pi: ExtensionAPI): void {
120
120
  for (const { name, module } of modules) {
121
121
  runModule(name, () => module.register(bus));
122
122
  }
123
- // codemode 最后注册:它把总线上已有的工具写进自己的描述,并在注册时编译 wasm
124
- await runModuleAsync("codemode", () => codemode.register(bus));
123
+ // codemode 最后注册:它把总线上已有的工具写进自己的描述,并在注册时编译 wasm。
124
+ // 脚本的 fs 原语与文件工具共用请求策略与已读记账,所以这里把工具集那两份注入过去。
125
+ await runModuleAsync("codemode", () =>
126
+ codemode.register(bus, { policy: services.policy, reads: fileToolset.reads }),
127
+ );
125
128
 
126
129
  for (const warning of warnings) {
127
130
  ctx.ui.notify(warning, "warning");
@@ -407,8 +407,14 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
407
407
 
408
408
  // ── read-before-edit 记账 ────────────────────────────────────────────────────
409
409
 
410
- /** 会更新 reads 记账(src/lib/file-reads.ts)并随 details 持久化快照的工具名。 */
411
- const READS_TOOL_NAMES = new Set(["read", "edit", "write", "lsp-rename"]);
410
+ /**
411
+ * 会更新 reads 记账(src/lib/file-reads.ts)并随 details 持久化快照的工具名。
412
+ *
413
+ * 含 `codemode`:脚本的 `fs.read` / `fs.write`(src/codemode/fs.ts)与本工具集共用
414
+ * 同一个 ReadsState,脚本记下的已读落在 codemode 自己的工具结果里,重放分支时要一起
415
+ * 收回来(见 `restoreReads`)。
416
+ */
417
+ const READS_TOOL_NAMES = new Set(["read", "edit", "write", "lsp-rename", "codemode"]);
412
418
 
413
419
  function registerReadTool(bus: ToolBus, getService: () => LspService, state: ReadsState): void {
414
420
  bus.register({
@@ -897,6 +903,8 @@ export interface OpencodeFileToolset {
897
903
  register(bus: ToolBus): void;
898
904
  onLspEnabled(bus: ToolBus, service: LspService): void;
899
905
  restoreReads(ctx: ExtensionContext): void;
906
+ /** 与工具共用的已读记账:codemode 的 fs 原语拿它做 stale 保护(两边互通)。 */
907
+ readonly reads: ReadsState;
900
908
  }
901
909
 
902
910
  /**
@@ -939,6 +947,8 @@ export function createOpencodeFileTools(
939
947
  restoreReads(ctx) {
940
948
  restoreReads(state, ctx.sessionManager, READS_TOOL_NAMES);
941
949
  },
950
+
951
+ reads: state,
942
952
  };
943
953
 
944
954
  const manager =