# pi-codemode-guard

[English](../README.md) | 简体中文

<p align="center">
  <img src="https://raw.githubusercontent.com/tychenjiajun/pi-codemode-guard/main/assets/preview.png" alt="pi-codemode-guard 在脚本进入 QuickJS 沙箱之前编译 LLM 写的 codemode 脚本" width="880">
</p>

[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
[![npm](https://img.shields.io/npm/v/pi-codemode-guard.svg)](https://www.npmjs.com/package/pi-codemode-guard)
[![pi extension](https://img.shields.io/badge/pi-extension-7c3aed.svg)](https://pi.dev/docs/latest/extensions)
[![pi package](https://img.shields.io/badge/pi-package-7c3aed.svg)](https://pi.dev/docs/latest/packages)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)](../tsconfig.json)
[![Tests](https://img.shields.io/badge/tests-456%20passing-brightgreen.svg)](../package.json)
[![CI](https://github.com/tychenjiajun/pi-codemode-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/tychenjiajun/pi-codemode-guard/actions/workflows/ci.yml)
[![npm downloads](https://img.shields.io/npm/dm/pi-codemode-guard.svg)](https://www.npmjs.com/package/pi-codemode-guard)
[![pnpm](https://img.shields.io/badge/package%20manager-pnpm-f69220.svg)](https://pnpm.io)

**安装：** `pi install npm:pi-codemode-guard` ·
[展示廊](https://pi.dev/packages/pi-codemode-guard) ·
[文档](https://pi.dev/docs/latest/packages)

> **一句话概括：** *一个尽力而为、幂等的编译器，把任意 LLM 写坏的 codemode 工具
> 调用转换成 pi 的 QuickJS 沙箱所期望的确切 JavaScript —— 且完全不 fork codemode
> 的实现。*

**pi-codemode-guard** 是一个开源（MIT 协议）、TypeScript 编写的
[pi](https://github.com/earendil-works/pi) AI 编程智能体扩展。`codemode` 是 pi 的
tool，用于把模型写的一段 JavaScript 脚本放进 QuickJS 沙箱执行、并在其中调用 pi 的
其他工具 —— 而**很多模型写不对这段脚本**。本扩展**在脚本进入沙箱之前修复它**：
补上缺失的 `await`、剥掉 markdown 代码围栏、把 JSON 工具调用程序转换成真正的
JavaScript、规范化 `@options:` / `@exec:` pragma、拆掉多余的 async IIFE，
并翻译 OpenCode、Cloudflare agents、TanStack AI、Vercel AI SDK、DeepSeek Harness PTC
与 OpenAI Codex code mode 方言 —— 让 AI 生成的智能体脚本真正跑起来，而不是静默失败。

模型常见的错误：把脚本包在 markdown 围栏里、用 JSON 工具调用程序代替 JavaScript、
用错字段名（`script` 而不是 `code`）、写成宽松的 `@options:` 行、包一层 async IIFE、
或者忘记 `await` —— 而未 await 的工具调用会被静默地 JSON 序列化成 `{}`。

## 修复前 → 修复后

<p align="center">
  <img src="https://raw.githubusercontent.com/tychenjiajun/pi-codemode-guard/main/assets/demo.gif" alt="模型写了带围栏且缺 await 的脚本；pi-codemode-guard 编译后，沙箱收到 await searchTools(…)" width="880">
</p>

以下都是 `compileCodemodeSource`（每次 `codemode` 调用都会经过的编译器）的真实输出：

**markdown 围栏 + 缺失 `await`** —— `feishu: {}` bug（pi issue
[#10555](https://github.com/earendil-works/pi/issues/10555)）。模型写的是：

````markdown
```js
const hits = searchTools("feishu")
```
````

沙箱收到的是：

```js
const hits = await searchTools("feishu")
```

**JSON 工具调用程序。** 模型写的是：

```json
[{"tool":"read","args":{"path":"package.json"}}]
```

沙箱收到的是：

```js
const _result0 = await tools.read({"path":"package.json"});
return _result0;
```

**另一种方言。** 模型写的是（OpenCode）：

```js
const r = await tools.orders.lookup({ id: 1 });
```

对照 pi 的实时工具目录解析后，沙箱收到的是：

```js
const r = await tools.orders_lookup({ id: 1 });
```

对以上任何一例再编译一次都不会再变化：每个 pass 都是幂等的；无法解析的脚本会
原样返回并附带一条警告 —— 本扩展的 bug 永远不会阻断一次工具调用。

一条命令安装（它会自行注册 pi 的 `codemode` tool）：

```bash
pi install npm:pi-codemode-guard
```

其他安装来源（git、本地 checkout、单次会话 `pi -e`）、安全提示，以及 pi ≥ 1.1.0
的启动警告：见 [安装](#安装)。

## 为什么需要它

来自真实 pi 会话和 pi 问题追踪器的故障：

- **异步辅助函数缺少 `await`。** `const hits = searchTools("feishu")` 打印出
  `feishu: {}`，因为待定的 promise 被序列化了。Pi 修复了*文档描述*
  （`await searchTools(...)`，commit `269121616`，issue #10555）；
  而本扩展修复的是*脚本本身*。
- **JSON 工具调用程序。** 写不出 JavaScript 的模型会输出
  `[{"tool":"read","args":{"path":"package.json"}}]`，pi 只有在它被正确包装时才
  能通过字符串校验，之后仍然无法运行。
- **markdown 围栏。** pi 文档明确写着"原始 JavaScript 源码，不是 JSON，也不是
  markdown 代码围栏"；模型照样忽略。

## 它做了什么

自动安装两个钩子：

1. **参数规范化**（`prepareArguments`）。pi 在任何执行钩子*之前*就校验工具参数，
   因此本扩展会向 codemode 工具定义注入一个垫片。它接受裸字符串、常见字段别名
   （`script`、`source`、`javascript`、`input`、…）、嵌套的源码对象
   （`{ code: { language, content } }`）、单条 JSON 工具调用，以及 JSON 工具调用程序。
2. **脚本编译**（`tool_call` 事件）。通过校验的 `code` 就地由 `compile.ts` 编译。

### 编译 Pass

| Pass | 之前 | 之后 |
|------|------|------|
| `strip-code-fence` | ` ```js\nconst x = 1;\n``` ` | `const x = 1;` |
| `normalize-options-line` | `/* @options: {'maxOutputTokens': 2000} */` | `// @options: {"max_output_tokens": 2000}` |
| `normalize-options-line` | `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}` | `// @options: {"max_output_tokens": 1000}`（并对 `yield_time_ms` 告警）|
| `normalize-options-line` | `// @options: {"timeout_ms": 1000}` 不在第一行 | 移到第一行（重复行会被移除）—— pi ≥ 1.2.0 否则直接失败 |
| `compile-json-program` | `[{"tool":"read","args":{"path":"a"}}]` | `const _result0 = await tools.read({"path":"a"});\nreturn _result0;` |
| `tanstack-typescript` | `const city: string = "London";` | `const city = "London";` |
| `vercel-typescript` | `const c: string = "London";` | `const c = "London";` |
| `ptc-typescript` | `let total: number = 0;` | `let total = 0;` |
| `strip-typescript` | `const n: number = 1;`（没有方言认领它） | `const n = 1;` |
| `unwrap-iife` | `(async () => { … })();` | `…` |
| `translate-statements` | 一次基于语句的翻译，覆盖所有方言构造 | OpenCode/Cloudflare/TanStack/Vercel/PTC/Codex 构造各自独立翻译 |
| `opencode-dialect` | `await tools.orders.lookup({…})` | `await tools.orders_lookup({…})` |
| `cloudflare-dialect` | `await codemode.lookupOrder({…})` | `await tools.lookupOrder({…})` |
| `tanstack-dialect` | `external_getWeather({…})` | `tools.getWeather({…})` |
| `vercel-dialect` | `await tools["web-search"]({ q })` | `await tools.web_search({ q })` |
| `ptc-dialect` | `await tools["my-tool"]({ q })` | `await tools.my_tool({ q })` |
| `codex-dialect` | `yield_control()` / Codex 专有辅助函数 | 保留原样并告警 |
| `bare-tool-calls` | `search({ q })`（已知工具名）| `tools.search({ q })` |
| `await-async-calls` | `const hits = searchTools("x");` | `const hits = await searchTools("x");` |
| `rewrite-tool-identifiers` | `tools["mcp__dev-radius__search"](…)` | `tools.mcp__dev_radius__search(…)` |

每个 pass 都是独立、幂等、尽力而为的。无法解析的脚本会原样返回并附带一条警告，
所以本扩展的 bug 永远不会阻断一次工具调用。JSON 工具调用程序是编译器生成的
JavaScript：它会跳过语句翻译，只在结果上运行 `await-async-calls` 和
`rewrite-tool-identifiers`。

翻译是**基于语句**的：一段脚本可以混用多种方言，每个构造都按其形态修复，而不是
依据整段检测出的单一方言。上表中的各方言 pass id 仅用于归因；一次编译可以同时
列出多个（外加 `translate-statements`）。

## 方言

编译器会检测脚本使用的是哪种 codemode 方言，但翻译不再依赖这个单一答案：
`translateCodemode` 只解析一次源码，然后**按语句形态**翻译每个构造，因此一段脚本
里混用的 OpenCode、Cloudflare、TanStack、Vercel/PTC 与 Codex 构造都会被修复。
检测仍用于选择 TypeScript 剥离 pass、区分 OpenCode 与 PTC 共用的两个构造
（`Object.keys(tools)` 与无法解析的方括号访问），以及填充互操作契约里的
`dialect`。

`detectCodemodeDialect(code, { hadOptionsLine, hadExecLine })` 返回 `pi`、
`opencode`、`cloudflare`、`tanstack`、`vercel`、`ptc`、`codex` 或 `unknown`，
并附带命中的信号；上下文标志恢复了 options pass 移除的 `@options` / `@exec` 信号。
OpenCode 独有信号包括 `$codemode`、`tools.<ns>.<tool>` 路径（Pi 的工具永远是单层的），
以及 `Object.keys(tools)`。Cloudflare 信号包括 `codemode.<tool>` / `codemode.search`
平台调用，以及裸的 `async () => { … }` 程序包装。TanStack 信号是裸的
`external_<tool>` 绑定引用（绝不会是 `tools.external_<tool>` 成员访问）。
Vercel 信号（`vercel:tools.<name>`）是 TypeScript 源码（acorn 无法解析）且引用了
`tools`，同时不带任何 OpenCode/TanStack/Cloudflare 信号 —— TanStack（`external_`）
与 OpenCode（`$codemode` / 嵌套 `tools.<ns>.<tool>`）优先。DeepSeek Harness PTC
信号是 `ptc:ToolCallError` 与 `ptc:import()`，以及仅当 acorn 无法解析
TypeScript 时的 `ptc:Object.keys(tools)`；OpenCode 的结构性信号
（`$codemode` / 嵌套 `tools.<ns>.<tool>`）、TanStack 与 Cloudflare 优先，但 PTC
自身信号优先于 OpenCode 也使用的通用 `Object.keys(tools)` 形态；Codex 信号是
`@exec` pragma 与 Codex 专有辅助函数（`yield_control`、`notify`、`generatedImage`、
`audio`），因为 Codex 脚本也会用到 `ALL_TOOLS`/`models`，它们优先于 Pi 自身的辅助
函数信号；Vercel 仍是通用的 TypeScript 回退，因此不带任何这些信号的 PTC 程序仍会
正确地按 `vercel` 编译。

当方言为 `opencode` 时，`compileOpencodeDialect` 会在 await pass 之前运行：

| OpenCode (`@opencode-ai/codemode`) | Pi codemode |
|---|---|
| `tools.orders.lookup({…})` | `tools.orders_lookup({…})` —— 对照实时工具目录解析 |
| `tools.context7["resolve-library-id"]({…})` | `tools.context7_resolve_library_id({…})` |
| `tools.mcp.dev.radius.search({…})` | `tools.mcp__dev_radius__search({…})`（模糊名称匹配） |
| `await tools.$codemode.search({ query, namespace, limit, offset })` | 一个 `await searchTools(...)` 垫片，返回 `{ items: [{ path, description, signature }], remaining, next }` |
| `Object.keys(tools)` | `ALL_TOOLS.map((t) => t.name)` |
| `Object.keys(tools.<ns>)` | 同一份 `ALL_TOOLS` 名称列表，按命名空间前缀过滤（普通与 `mcp__` 两种拼写） |
| `for...in tools.<ns>` | 不改写 —— 发出警告并指向 `ALL_TOOLS.map((t) => t.name)` |
| `return value`、`console.log`、`Promise.all`、顶层 `await` | 本身就是合法 Pi —— 原样保留 |

工具路径解析需要 Pi 的实时目录，因此扩展会把
`pi.getAllTools().map((t) => t.name)` 传入编译器。先尝试精确分隔符
（`.`、`__`、`_`、`/`、`-`，每种还会再试 MCP 的 `mcp__` 前缀），再做归一化后的模糊匹配
（`mcp.dev.radius.search` ↔ `mcp__dev_radius__search`）。目录中匹配不到的路径会被
确定性地展平，并记录到 `warnings` 中。

`console.log` 映射为 Pi 的 `<console_output>` 块；OpenCode 的 `{ ok, value}` 信封被
丢弃（Pi 直接展示 `return` 的值）。这两者在 OpenCode → Pi 方向上都是兼容的，
因此无需改写。

当方言为 `cloudflare` 时，会翻译
[`@cloudflare/codemode`](https://github.com/cloudflare/agents/tree/main/packages/codemode)
程序。`unwrap-iife` 先移除 `async () => { … }` 包装，然后
`compileCloudflareDialect` 改写命名空间：

| Cloudflare agents | Pi codemode |
|---|---|
| `codemode.lookupOrder({…})` | `tools.lookupOrder({…})` —— 去掉默认命名空间 |
| `state.readFile("/path")` | `tools.state_readFile("/path")` —— 具名 provider，仅在实时目录命中时改写 |
| `await codemode.search("query")` | 一个 `await searchTools(...)` 垫片，返回 Cloudflare 的 `{ results: [{ path, connector, method, description, kind }], total, truncated }` |
| `await codemode.describe(path)` | 一个 `await describeTool(...)` 垫片，返回 `{ path, description, types }` |
| `codemode.run(name)` / `codemode.step(name, fn)` | Pi 无对应能力 —— 原样保留并给出警告 |
| `Math`/`JSON`/`console`/`Promise`/`Object`/`searchTools` 等 | 永远不会被当作 provider 命名空间 |

Cloudflare 的工具命名规则（`sanitizeToolName`）与 Pi 的 `toCodemodeIdentifier`
不同：它剥离非法字符而不是替换、给数字开头加 `_`（`3d-render` → `_3d_render`）、
给保留字加 `_` 后缀（`delete` → `delete_`）。因此目录同时以两种拼写建索引，
`codemode.delete_()` 能映射回 `tools.delete()`，`codemode._3d_render()` 映射到
`tools._d_render()`。脚本自身绑定的名字（`const state = { … }`）永远不会被改动；
无法解析的 provider 同样保持原样 —— 只有当检测出的方言是 `cloudflare` 时才告警
（`` could not resolve Cloudflare provider `state.readFile` … ``），其他方言一律
保持沉默，除非最后一段是 Pi 的实时工具，此时给出提示
（`` `foo.read` 不是 Pi 工具路径；Pi 有工具 `read` —— 请调用 `tools.read(...)` ``，
见 `translate.ts`），因此 `performance.now()` 与 `foo.bar()` 完全不会告警。
位置参数的 provider 调用（`state.readFile("/path")`）原样保留，因为编译器无从得知 Pi 的参数名。
两个已知限制：只使用具名 provider 的语句式程序（没有 `codemode.*` 调用、没有
async-arrow 包装）会被检测为 `unknown`（检测不看目录），于是互操作 `dialect`
不够准确 —— 但 provider 改写仍会运行：`translate.ts` 会把 Cloudflare 规则应用到
每条语句上（前提是目录能确认；无法解析的 provider 仅在方言为 `cloudflare` 时
才告警）；`export default async () => {}` 也会原样保留 —— `unwrap-iife` 只解包单独的
表达式语句。

当方言为 `tanstack` 时，会翻译 [`@tanstack/ai-code-mode`](https://github.com/TanStack/ai/tree/main/packages/ai-code-mode)
程序。源码是 TypeScript，因此 `tanstack-typescript` 先运行（`unwrap-iife` 在其后运行，
以防模型把代码包了一层），随后 `compileTanstackDialect` 改写绑定：

| TanStack AI code mode | Pi codemode |
|---|---|
| `{ typescriptCode: "…" }` | `{ code: "…" }`（经 `typescriptCode` 参数别名） |
| `external_getWeather({…})` | `tools.getWeather({…})` —— 对照实时工具目录解析 |
| 带 `my-tool` 工具的 `external_my_tool({…})` | `tools.my_tool({…})`（Pi 的 `toCodemodeIdentifier` 规则） |
| `return value`、`console.log`、`Promise.all`、顶层 `await` | 本身就是合法 Pi —— 原样保留 |

绑定前缀被剥离后，其余部分通过 Pi 的目录解析，方式与 OpenCode 命名空间路径完全相同；
无法解析的绑定会被确定性地展平（与 OpenCode 一致，并给出警告），至少保证它能解析通过。

当方言为 `vercel` 时，会翻译 [`@ai-sdk/code-mode`](https://github.com/vercel/ai/tree/main/packages/code-mode)
程序。信封字段是 `{ js: "…" }` 而非 Pi 的 `{ code }`（`js` 已被接受为参数别名），
程序作为 async 函数体运行（顶层 `await`/`return` 有效）。源码是 TypeScript，
因此 `vercel-typescript` 在 `unwrap-iife` 之前运行，随后 `compileVercelDialect`
改写方括号访问：

| Vercel AI code mode | Pi codemode |
|---|---|
| `{ js: "…" }` | `{ code: "…" }`（经 `js` 参数别名） |
| `await tools["web-search"]({ q })` | `await tools.web_search({ q })` —— 对照实时工具目录解析 |
| `const c: string = "London";` | `const c = "London";` —— 通过 sucrase 剥离 TypeScript |
| `tools.getWeather({ location })` | `tools.getWeather({ location })`（原样保留） |

在 Vercel 中，不是合法 JS 标识符的原始工具名会保留方括号访问；
`compileVercelDialect` 对照实时目录把它们映射为 Pi 标识符，无法解析的名字会被
确定性展平（并给出警告）—— 与 OpenCode 相同的回退策略。纯 JavaScript 的 Vercel
程序可以被解析，因此 `vercel-dialect` 不会运行：其中的特殊方括号访问仍由
`rewrite-tool-identifiers` 对照目录解析，但不会给出无法解析的警告。该方言没有
`searchTools` / `ALL_TOOLS` / `codemode.*` 辅助能力；工具发现内嵌在工具描述中，
因此不需要任何垫片。

当方言为 `ptc` 时，会翻译
[`@deepseek-ai/dsh-ptc-runtime-node`](https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/ptc-runtime/ptc-runtime-node)
程序。DeepSeek 的 PTC 模式工具是 `run_code({ description, code })` —— 参数垫片只读
`code`（或其别名字段），因此多余的 `description` 被忽略，留下 Pi 的 `{ code }` ——
而 `code` 是一个
async TypeScript 函数体（仅限可擦除的 TypeScript），因此顶层 `await`/`return`
有效。源码是 TypeScript，因此 `ptc-typescript` 在 `unwrap-iife` 之前运行，
随后 `compilePtcDialect` 改写调用：

| DeepSeek PTC | Pi codemode |
|---|---|
| `{ code, description }` | `{ code }`（忽略 `description`） |
| `await tools["web-search"]({ q })` | `await tools.web_search({ q })` —— 对照实时工具目录解析 |
| `Object.keys(tools)` | `ALL_TOOLS.map((t) => t.name)` |
| `const c: string = "London";` | `const c = "London";` —— 通过 sucrase 剥离 TypeScript |
| `ToolCallError` | 无对应能力 —— 会发出警告 |
| `await import("node:fs")` | Pi 中不可用 —— 会发出警告 |

宿主函数暴露在一个全局 `tools` 对象上，函数名是任意字符串（模型写
`await tools.name(args)`，怪异名字用方括号访问 `tools["my-tool"](args)`）；
`compilePtcDialect` 对照实时目录把它们映射为 Pi 的 `tools.<identifier>`。
`ToolCallError`（PTC 失败工具调用的拒绝值，带 `.toolName`）与
`await import(...)`（PTC 访问 Node API 的方式）在 Pi 中没有对应能力 ——
Pi 失败的工具调用以普通 `Error` 拒绝，QuickJS 沙箱没有 `import`、`fetch`
或 Node API —— 因此两者都会发出警告。那两项是 PTC 特有的；沙箱缺失的其他全局 ——
定时器（`setInterval`/`clearInterval`）、
`Intl`、`structuredClone`、`TextEncoder`/`TextDecoder`、`URL`/`URLSearchParams`、
`crypto`、`fetch`、`process`、`require`、`Buffer`，以及 `Atomics`、`WebAssembly`、
`AbortController`、`Headers`、`Request`、`Response`、`localStorage`、`window`、
`document` 这类宿主全局 —— 也会告警，走的是 `UNSUPPORTED_GLOBALS`（`dialect/signals.ts`）
中共享的 `runtime` 行：调用原样保留，并给出 Pi 侧的替代方案。缺失的全局
**只在没有任何解析结果时才告警**：实时目录确认为真实工具的引用 ——
有名为 `fetch` 的工具时的 `fetch({q})`、有工具 `crypto_read` 时的
`crypto.read` —— 会改为改写并保持沉默。`console.log(...)` 与 `return` 是
PTC 的输出通道，与 Pi 兼容。

### OpenAI Codex code mode（`codex-rs/code-mode-runtime`）

[OpenAI Codex](https://github.com/openai/codex/tree/main/codex-rs/code-mode-runtime) 的
`exec` 工具在新 V8 isolate 中运行原始 JavaScript。它的程序 API 与 Pi 非常接近，
因为其名字规则 `normalize_code_mode_identifier` 与 Pi 的 `toCodemodeIdentifier`
完全一致：

| Codex | Pi codemode |
|---|---|
| `exec({ code })` | `codemode({ code })` |
| `await tools.mcp__ologs__get_profile(…)` | 不变（标识符规则相同）|
| `text` / `image` / `store` / `load` / `exit` / `ALL_TOOLS` | 不变 |
| `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}` | `// @options: {"max_output_tokens": 1000}`；`yield_time_ms` 被丢弃并告警 |
| `audio` / `generatedImage` / `notify` / `yield_control` / `setTimeout` / `clearTimeout` | Pi 无对应能力 —— 告警，调用保留原样 |

`yield_time_ms` 是 Codex 的提前让出提示，不是 Pi 的 `timeout_ms`，因此丢弃而非映射。
`generatedImage` 的告警会指向 Pi 的 `image(block)`。

### 它刻意不动的东西

- **非 async 函数**内部缺失的 `await`（插入它会变成语法错误）。
- `Promise.all` / `allSettled` / `race` / `any` 的成员，以及用
  `.then` / `.catch` / `.finally` 链起来的 promise。
- 在参数钩子之前就已没通过 pi 校验的信封结构。

### 已知限制

- 只使用具名 provider 的语句式 Cloudflare 程序（没有 `codemode.*` 调用、没有
  async-arrow 包装）仍会被检测为 `unknown`，所以互操作 `dialect` 不够准确 ——
  而且由于检测方言不是 `cloudflare`，这类程序**完全不会**得到 provider 诊断，
  除非最后一段恰好是实时目录中的工具（`` `foo.read` 不是 Pi 工具路径 `` 提示）。
  provider 改写仍会运行：`translate.ts` 会把 Cloudflare 规则应用到每条语句上，
  前提是目录能确认。
- 工具句柄的属性访问（`tools.read.length`）只有在实时目录确认了首段时才会被
  保留，而检测不看目录 —— 因此这类源码在契约里仍会报告 `dialect: "opencode"`。
  链的其余部分从不校验：无法解析的 `tools.<tool>.<property>`（`tools.read.zzz`）
  会原样保留 —— 不改写、不告警 —— 随后在 pi 的 helpful tools 代理中运行时报错。
- 不可擦除的 TypeScript：sucrase 会把 `enum`/`namespace` 编译成可运行的
  JavaScript，而 DeepSeek 只接受可擦除 TypeScript 的 PTC 参考实现会拒绝它们。
- 标识符冲突（`web-search` / `web_search`）：解析是确定性的（以写下的原始名字
  为准）且不发警告，但两者都映射到同一个 `tools.web_search`。
- 动态方括号访问（`tools[expr]`，非常量属性）永远不会被改写，且静默无提示。
- `ALL_TOOLS` 的元素是 `{ name, description }` 对象；把它们当字符串用的模型代码
  （例如 `ALL_TOOLS.filter(n => /x/.test(n))`）不会被规范化（真实会话中观察到）。

## 安装

`pi-codemode-guard` 是一个 [Pi 包](https://pi.dev/docs/latest/packages)：它带有
`pi.extensions` 清单和 `pi-package` 关键字，用 `pi` CLI 安装后会自动注册 `codemode` 工具。
它已发布到 npm：[`pi-codemode-guard`](https://www.npmjs.com/package/pi-codemode-guard)。

```bash
# 从 npm 安装（推荐）
pi install npm:pi-codemode-guard

# 从 git 安装
pi install git:github.com/tychenjiajun/pi-codemode-guard

# 从本地检出安装
pi install ./pi-codemode-guard

# 仅本次会话试用，不写入设置
pi -e npm:pi-codemode-guard
```

`pi list` 列出已配置的包；`pi remove <source>` 卸载；`pi update --extensions`
同步已安装的包；`pi config` 启用或禁用单个资源（包括 pi 的内置扩展）。
个人级安装写入 `~/.pi/agent/settings.json` —— 加 `--local`（或 `-l`）则改为把项目级
声明写入 `.pi/settings.json`，仅在项目信任通过后才会加载。

> 扩展和包都**运行在 pi 进程内，并拥有你的操作系统权限**，可以读取提示词、工具调用和会话历史。
> 安装第三方源码前请先审阅 —— 本包也一样。

它既适用于 CLI 内置的 codemode 扩展（会被它替换），
也适用于自行添加 `createCodemodeExtension()` 的 SDK 会话。

> **pi ≥ 1.1.0 的启动警告。** 由于本扩展注册了名为 `codemode` 的工具，pi 会跳过
> 其可替换的内置 `codemode` 扩展并打印 ``Extension … registers tool `codemode`,
> so built-in extension `codemode` was not loaded``。本扩展正是那个替代者 ——
> `codemode` 仍然可用 —— 若要消除该提示，请禁用内置副本：在
> `~/.pi/agent/settings.json` 中加入 `"extensions": ["-builtin:codemode"]`
> （或在 `pi config` 的 **Built-in** 中取消勾选 `codemode`）。如果之后卸载了本扩展，
> 请重新启用内置扩展，以免 `codemode` 消失。

## 如何保持兼容

内置的 `codemode` 工具由一个被 pi 标记为**可替换**的扩展注册（CLI 中为
`builtin: true, replaceable: true`），重新实现它会丢失 `models`、
`store()` 持久化和 `codemode.mode`。本扩展的做法是让规范的
`createCodemodeExtension()` 工厂通过一个小型 `pi` 代理运行，由其 `registerTool`
追加参数垫片和编译回执。代理展开规范定义，因此工具仍然保持
`parameters === codemodeSchema`、`reachesTools`、`prepareLoadout`、
`defaultActive: false` 及其余全部原始选项——pi 1.2.0 依靠 `reachesTools`
（而非 1.2.0 中已移除的旧 `isCodemodeTool` 辅助函数）判断替换的 `codemode`
能否触达 MCP 工具。

## 基于 pi 构建

本扩展就是一个普通的 [pi 扩展](https://pi.dev/docs/latest/extensions)：一个 TypeScript
模块，默认导出的工厂函数接收 `ExtensionAPI`，并为当前运行时注册能力。它只用了文档中的两个
集成点：

- `pi.registerTool()` —— 通过代理重新注册规范的 `codemode` 工具，附加 `prepareArguments`
  和编译回执。
- `pi.on("tool_call")` / `pi.on("tool_result")` —— 就地把校验通过的 `code` 编译好，再把
  `details.piCodemodeGuard` 写到结果上。

它同样遵循[包契约](https://pi.dev/docs/latest/packages)：

| 包契约 | 本包的遵守方式 |
|---|---|
| 清单 | `"pi": { "extensions": ["./src/index.ts"], "image": "https://raw.githubusercontent.com/tychenjiajun/pi-codemode-guard/main/assets/preview.png" }` |
| 展示廊资格 | 带 `pi-package` 关键字，`pi.image` 提供展示廊预览图 |
| 宿主包 | `@earendil-works/pi-coding-agent` 与 `@earendil-works/pi-tui` 以 `"*"` 范围声明在 `peerDependencies`，从不打包进产物 |
| 运行时依赖 | `acorn`、`sucrase` 是真正的 `dependencies`，随包安装 |
| 无需构建 | pi 通过 jiti 直接加载 TypeScript 入口，因此发布产物即源码 |
| 生命周期 | 工厂只做注册 —— 不启动进程、socket、监听器或定时器，不带会话加载也安全；编译过程除一个有界的待处理调用表外不保留状态 |

## 互操作契约

编译后的调用会发布 `details.piCodemodeGuard`（见 `contract.ts`）：

```ts
interface PiCodemodeGuardDetails {
  version: 1;
  originalCode: string; // pi 校验过的源码
  compiledCode: string; // 沙箱收到的源码
  passes: string[];     // 例如 ["opencode-dialect(2)", "await-async-calls(2)"]
  parsed: boolean;
  dialect: "pi" | "opencode" | "cloudflare" | "tanstack" | "vercel" | "ptc" | "codex" | "unknown";
  warnings: string[];
}
```

用 `readPiCodemodeGuardDetails(details)` 读取。该结构带版本号且只做增量扩展；
消费方对不认识的版本必须回退到内联内容。

## 常见问题

### pi-codemode-guard 是什么？

`pi-codemode-guard` 是一个 [Pi 包](https://pi.dev/packages/pi-codemode-guard)：
`codemode` 是 pi 的工具，让模型写的一段 JavaScript 脚本在 QuickJS 沙箱里调用 pi 的
其他工具，而本扩展把模型写坏的 `codemode` 脚本编译成沙箱所期望的确切 JavaScript。
每个 pass 都尽力而为且幂等：无法解析的脚本会原样返回并附带警告，因此 guard 自身的
bug 永远不会阻塞工具调用。

### 它支持 OpenCode / Codex code mode 方言吗？

支持 —— 它能检测并翻译 OpenCode、OpenAI Codex、Cloudflare agents、TanStack AI、
Vercel AI SDK 与 DeepSeek Harness PTC code mode 方言，把工具路径与命名空间按 pi 的
实时工具目录解析到 pi 自己的方言。每个方言都是独立、幂等的编译 pass，无法解析的
输入会原样保留。

### 它和 fork codemode 有什么区别？

它不 fork 任何代码：通过一个小代理重新注册规范的 `createCodemodeExtension()`，
保留原始 schema、`models`、`store()` 持久化和 `codemode.mode`，因此与内置工具
共存。它确实替换了内置注册 —— 若不想再收到替换警告，请禁用内置副本
（`"extensions": ["-builtin:codemode"]`）。

### 为什么我的 codemode 调用返回空的 `{}`？

因为脚本里有未 await 的 promise（通常是 `searchTools(...)` 或 `models.*` 调用）。
待定的 promise 被 JSON 序列化成 `{}`，工具于是静默地什么也没返回。
`await-async-calls` pass 会自动补上缺失的 `await`。

## 开发

```bash
pnpm install
pnpm test        # 456 个单元测试
pnpm typecheck
pi -e .          # 把本检出作为扩展加载，仅本次会话
```

代码结构和设计说明见 [`AGENTS.md`](../AGENTS.md) 与 [`CONTEXT.md`](../CONTEXT.md)。
pi 官方文档：[扩展](https://pi.dev/docs/latest/extensions) ·
[Pi 包](https://pi.dev/docs/latest/packages)。

## 许可证

MIT
