AI Research

pi 扩展开发实战手册

2026-06-05 #pi#cli-agent#extension#handbook#tutorial

pi 扩展开发实战手册

摘要

这是一本面向开发者的 cookbook,把 pi(earendil-works/pi)的四层扩展系统(skill / prompt template / extension / package)拆成”先讲机制 → 给最小可运行示例 → 列常见坑 → 推荐进阶”四步式章节,目标是让你看完就能动手写并发布。

本手册覆盖的 pi 版本:@earendil-works/pi-coding-agent 0.74.x – 0.78.x(manifest 字段、生命周期事件、CLI 子命令均按这一带版本)。已有的两份姊妹笔记 pi-deep-dive.md(深度研究 925 行)和 pi-agent-vs-claude-code-codex.md(横向对比)作为背景,本手册不再重复综述内容。

撰写过程同步抓取了官方 docs(packages/coding-agent/docs/*.md)、src/core/extensions/types.ts、loader 源码,以及社区扩展 pi-mcp-adapterpi-subagents 的真实源码,每节”机制”部分尽量贴官方原文,可粘贴可运行的示例则按 docs 与社区代码风格组合而成。

2026-06-05 复核(共 9 处):原稿中标注的不确定点已通过抓取 src/core/extensions/types.tssrc/core/package-manager.tssrc/core/slash-commands.tssrc/core/prompt-templates.tssrc/cli/args.tssrc/package-manager-cli.tspackages/ai/src/api-registry.tsdocs/skills.mddocs/packages.mddocs/extensions.mddocs/containerization.md 进行 verbatim 核实。

第一轮 5 处关键修正tool_call event 没有 replaceWith 字段(只能 block + reason + 直接改 event.input);pi pkg create / pi extension dev 子命令确认不存在getSystemPromptOptions() 是真实 API 但只在 ExtensionCommandContext 上;ToolDefinitionexecutionMode: "sequential" | "parallel" 而不是分开两个字段。详见 §6.3 / §6.4 / §6.6 / §7.3。

第二轮 4 处补充修正:(1) prompt template 替换还支持 ${@:N} ${@:N:L} bash 切片语法,并按顺序替换防递归——详见 §5;(2) slash command 没有显式优先级常量SlashCommandSource 仅有 "extension" | "prompt" | "skill" 三类来源 + 21 个内置命令;真正的 resolve 在调用链推断——详见 §5.5;(3) 自定义 provider 的真实 API 是 registerApiProvider 而非 registerProvider,定义在 packages/ai/src/api-registry.ts,用 Map 实现而非 class;详见 §11.x;(4) context-mode ≠ pi 扩展——它是 mksglu/claude-context-mode(Claude Code 的 MCP 插件,Elastic License 2.0),原稿 §8.3 把它当 pi 扩展拆解是错误前提,已修正。


1. 导言:本手册写给谁

读完你能:

  1. 在 30 分钟内写一个 skill(最简单的层),给 pi 增加一段”按需加载”的工作流。
  2. 写一个 prompt template,把”你常打的那段话”变成一个 /foo 斜杠命令。
  3. 写一个 extension,注册自定义工具、监听 agent 生命周期、画 TUI panel、加快捷键、做进程间 RPC。
  4. 把上述三者打成一个 npm package,发布给别人 pi install npm:your-package 一键装上。
  5. 调试上面这套东西、知道哪里看 log、哪里 hot reload。

不写:模型 / provider 选型,session 文件格式,theme 调色盘,CLI 内部架构(这些去看 pi-deep-dive.md)。

前置假设

  • 你写过一点 TypeScript(不一定精,但能读 import type { ... } from "...")。
  • 你装过 npm 全局包,知道 ~/ 是 home 目录。
  • 你大致知道”agent loop = 模型生成 → 工具调用 → 把结果塞回去再生成”是怎么回事。

2. 预备知识与环境搭建

2.1 安装 pi

1
npm install -g --ignore-scripts @earendil-works/pi-coding-agent

--ignore-scripts 是官方 quickstart 推荐写法,避免依赖在 install 时跑生命周期脚本。装完直接 pi 启动。

2.2 登录 / 配 key

1
2
3
4
5
6
7
# 选项 1:subscription(启动后 /login 选 Claude Pro / ChatGPT Plus / Copilot)
pi
> /login

# 选项 2:API key(启动前注入环境变量)
export ANTHROPIC_API_KEY=sk-ant-...
pi

凭据落到 ~/.pi/agent/auth.json

2.3 目录结构(你只需要记三个位置)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
~/.pi/agent/                  # 全局
├── settings.json # 全局设置
├── keybindings.json # 全局快捷键
├── auth.json # 凭据
├── pi-debug.log # 调试日志(/debug 命令产出)
├── AGENTS.md # 全局 system 上下文
├── extensions/ # 全局扩展(自动发现)
├── skills/ # 全局 skills(自动发现)
├── prompts/ # 全局 prompt templates
├── themes/ # 全局主题
└── npm/ # pi install 装的 npm 包落地处

<project>/.pi/ # 项目级(覆盖全局)
├── settings.json
├── extensions/
├── skills/
├── prompts/
└── themes/

<project>/AGENTS.md # 项目 system 上下文
<project>/CLAUDE.md # 兼容 Claude Code 的项目上下文

优先级:项目级 > 全局;同名 skill / extension 项目级覆盖全局。源码 core/package-manager.ts 中的 dedupePackages() 实现了这条规则——见 §7.7。

2.4 IDE 配置建议

1
2
3
4
5
6
7
8
9
// .vscode/settings.json
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"SKILL.md": "markdown",
"AGENTS.md": "markdown"
}
}

TypeScript 版本:5.4+ 即可(pi 内部用 jiti 加载 .ts,所以不需要你自己 tsc 编译;jiti 直接吃源码)。

2.5 本地 dev loop(最重要的一节)

三种 loop,从快到慢:

场景 命令 何时用
单文件试 pi -e ./my-extension.ts 写第一版、还没决定要不要发布
项目内安装 放进 <project>/.pi/extensions/<name>/ 跟项目绑定的 ext
全局安装 放进 ~/.pi/agent/extensions/<name>/ 自己日常用的 ext

热重载:在 pi 里敲 /reload,会重新发现并加载所有 extensions / skills / prompts,不会丢当前 session

没有 pi extension dev / pi pkg create 这种子命令。pi CLI 截至 v0.78.x 的全部子命令是 install / remove / uninstall / update / list / config(见 src/package-manager-cli.tsPackageCommand 联合类型)。dev loop 就是 pi -e <path>、改文件、/reload

2.6 调试

1
2
3
/debug              # 启用 debug 输出
# 输出落到 ~/.pi/agent/pi-debug.log
# 内容:渲染过的 TUI 行(含 ANSI 码)+ 最近发给模型的消息

console.log / console.error 在 extension 里是有效的,会进 pi 的 log 通道(具体落点取决于运行模式:TUI 模式下被 capture,--mode json / --mode rpc 下被框成事件)。


3. 四层扩展系统速查

文件类型 写起来要 何时用 不能做什么
skill Markdown(SKILL.md 5 分钟 把”复杂任务的步骤、checklist、参考代码”按需投喂给模型 不能跑代码、不能拦事件
prompt template Markdown(*.md 2 分钟 把”你常打的那一大段 prompt”变成 /foo arg1 arg2 不能拦事件、不能注册工具
extension TypeScript 模块 30 分钟~几天 注册新工具、拦截 agent loop、画 TUI、做 RPC 写起来重,不适合纯 prompt 工程
package npm package 1 小时 把上面 1~3 打包发布 它本身只是容器

口诀:能用 skill 就别写 extension;能用 prompt template 就别写 skill;能 hardcode 就别拆 package


4. 写一个 Skill

4.1 机制

skill 走的是 progressive disclosure

  1. pi 启动时扫描所有 skill 位置(~/.pi/agent/skills/<project>/.pi/skills/、packages、settings、CLI flag),只读 SKILL.md 的 frontmatter(name + description)
  2. 把所有 skill 的 name + description 以 XML 格式拼进 system prompt,让模型”看到目录”。
  3. 模型决定要用某个 skill 时,再加载完整的 SKILL.md body 与配套文件。
  4. 用户也可以用 /skill:<name> 显式触发。

这是为了省 token:100 个 skill 摘要 ≈ 10K token,全文加载 ≈ 1M token,差一个数量级。

4.2 文件位置与命名

1
2
3
4
5
6
7
~/.pi/agent/skills/
└── code-review-checklist/
├── SKILL.md # 必须,frontmatter + 主体
├── reference/ # 可选
│ └── owasp-top10.md
└── scripts/ # 可选,skill 引用的辅助脚本
└── check-secrets.sh

frontmatter 字段(来自 docs/skills.md 的 Frontmatter 表,verbatim):

字段 必填 约束
name Max 64 chars. Lowercase a-z, 0-9, hyphens. Pi does not require this to match the parent directory(标准要求一致,pi 故意放宽)。
description Max 1024 chars. What the skill does and when to use it.
license License name or reference to bundled file.
compatibility Max 500 chars. Environment requirements.
metadata Arbitrary key-value mapping.
allowed-tools Space-delimited list of pre-approved tools (experimental)。
disable-model-invocation When true, skill 从 system prompt 隐藏,必须 /skill:name 才能用。

metadata 字段的语义已核实:docs 写明 *”Arbitrary key-value mapping”*,源码(core/skills.tsSkillFrontmatterSkill 接口)把它存为 frontmatter 透传字段,未知字段统一忽略——意思是 pi 自身不读这块,留给 skill 作者 / 三方扩展自由用。如果你的 extension 想消费它,需要自己读 SKILL.md frontmatter。

pi 比 Agent Skills 标准更宽容:skill name 可以与目录名不同(虽然 docs 说 “suboptimal for shared skill directories”),description 缺失才会拒绝加载,其他违规只 warn。

4.3 最小示例:code review checklist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!-- ~/.pi/agent/skills/code-review-checklist/SKILL.md -->
---
name: code-review-checklist
description: |
Use this skill when the user asks for a code review, PR review, or
"look over my changes". Walks through correctness, security, style,
and test coverage in that order, and outputs findings as a numbered
list with severity tags.
license: MIT
---

# Code Review Checklist

When invoked, perform these passes **in order**, stopping at the first
pass that turns up blocking issues.

## Pass 1: Correctness

- [ ] Are off-by-one / boundary cases covered?
- [ ] Any unhandled error / exception path?
- [ ] Concurrency: shared state, race conditions?

## Pass 2: Security

Read `reference/owasp-top10.md` for the canonical list. At minimum:

- [ ] Untrusted input flowing into shell / SQL / template?
- [ ] Hardcoded secrets? Run `scripts/check-secrets.sh` to scan.
- [ ] AuthZ checks in place for new endpoints?

## Pass 3: Style & Tests

- [ ] Naming consistent with surrounding file?
- [ ] New behavior covered by tests?
- [ ] Public API change reflected in docs?

## Output format

Return findings as:

[severity] file:line — short description
why it matters
suggested fix

1
2

severity ∈ {blocker, major, minor, nit}.

运行:

1
2
3
pi --skill ~/.pi/agent/skills/code-review-checklist
> /skill:code-review-checklist
> Review the staged changes.

或者放进 .pi/skills/ 后直接:

1
2
3
pi
> Review my staged changes for security issues
# 模型会从 system prompt 看到 description,自动加载这个 skill

4.4 进阶:与 prompt template 组合

skill 是”参考资料”,prompt template 是”启动咒语”。把 /review template 的 body 写成 “Use the code-review-checklist skill to review $@“,就能把模型的入口点固定下来:

1
2
3
4
5
6
7
<!-- ~/.pi/agent/prompts/review.md -->
---
description: Review changes using the canonical checklist
argument-hint: [path...]
---
Use the `code-review-checklist` skill. Targets: $@
If no targets given, use `git diff --cached`.

调用:/review src/api/auth.ts src/api/users.ts

4.5 常见坑

  1. 命名冲突:同名 skill,pi 只保留先扫到的那个并 warn。项目级先扫,所以项目级 wins。但若两个全局位置(~/.pi/agent/skills/~/.agents/skills/)都有同名 skill,行为依赖发现顺序,最好别撞名。
  2. description 太长:>1024 字符 pi 会 warn,且摘要会被截断进 system prompt,一旦截在关键词中间,模型可能就不调你了。
  3. token 预算:每个 skill 摘要 ~50-200 token,装 200 个 skill 就吃掉一两万 token。考虑用 settings 的 glob filter 关掉不用的。
  4. 加载顺序:skill body 是按需加载的,但摘要在 startup 时就一次扫完。新加 skill 必须 /reload 或重启。
  5. disable-model-invocation 用错地方:如果你只是想让 skill 的描述不出现在 system prompt 里、节省 token,但还希望模型能间接调到——做不到。设了这个标志后必须 /skill:name 显式触发。

4.6 推荐进阶

  • Anthropic 的 agent-skills repo 与 pi 自己 collection 看真实模板。
  • 把”长 reference 文档”放进 skill 子目录而不是 SKILL.md 主体,让模型按需 read 而不是一次塞光。
  • allowed-tools 字段里只暴露 read / grep,做”只读 skill”,避免 agent 写文件。

5. 写一个 Prompt Template

5.1 机制

prompt template 是纯文本宏

  • Markdown 文件,文件名 = 命令名(review.md/review)。
  • 调用时 pi 把 frontmatter 之外的 body 展开成一条 user message 送给模型。
  • 支持参数替换:$1$2、…、$@(全部)、$ARGUMENTS(同 $@)、${@:N}(从第 N 个开始)、${@:N:L}(从第 N 个起 L 个)。
  • 没有任何拦截 / 注册能力,纯展开。

防注入机制(2026-06-05 实测自 core/prompt-templates.ts):substituteArgs()纯字符串顺序替换,没有 shell escape、没有 HTML escape。模块对”参数值里恰好包含 $1 这种 pattern 的字符串”做了顺序处理防递归——一次替换完不会再扫第二遍,所以参数值里的 $2 不会触发对第二个参数的二次替换。但对 prompt 注入完全无防护:如果用户把 "; rm -rf /"$1 传进来,pi 原样塞给模型;写 template 时不要把 $@ 直接喂进 shell 类指令的反引号,要么自己加引号,要么改用 skill 让模型显式 bash 调用,由 bash 工具做参数转义。

参数解析也在 core/prompt-templates.tsparseCommandArgs() 里做:支持 bash-style 单/双引号,按空白分词。所以 /refactor "user account" "too long" 会得到 $1 = "user account"$2 = "too long",而不是 4 个参数。

5.2 加载位置

1
2
~/.pi/agent/prompts/*.md           # 全局,非递归
<project>/.pi/prompts/*.md # 项目,非递归

子目录里的 template 默认不会被发现,要在 settings 或 package manifest 里显式列出。

5.3 何时用 template、何时用 skill

场景
我要把”那段我每天打 5 次的 prompt”变成 /foo template
我要给模型一份 checklist / 参考资料、希望它自己决定何时用 skill
我要传参数(<path><branch> template
我要附带辅助脚本 / reference 文档 skill
我要拦截事件 / 改 tool result / 画 UI extension

5.4 最小示例:/refactor <function-name>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- ~/.pi/agent/prompts/refactor.md -->
---
description: Refactor a function with safety checks
argument-hint: <function-name> [reason]
---
Refactor the function named `$1` in the current project.

Goals:
- Preserve external behavior (tests must still pass).
- Reduce cyclomatic complexity if it's over 10.
- Replace magic numbers with named constants.

Reason for refactoring (free-form, may be empty): ${@:2}

Workflow:
1. `grep` to find the definition.
2. `read` the file to understand context.
3. Read all callers (`grep -n "$1("`).
4. Propose a diff.
5. Apply with `edit`.
6. Run the project's test command.

调用:

1
/refactor parseConfig "too long, hard to test"

替换结果(送给模型的):

1
2
3
4
Refactor the function named `parseConfig` in the current project.
...
Reason for refactoring (free-form, may be empty): too long, hard to test
...

运行命令:

1
2
3
4
pi --prompt-template ~/.pi/agent/prompts/refactor.md
# 或者放进 .pi/prompts/ 后直接
pi
> /refactor parseConfig

5.5 常见坑

  1. 文件名带连字符 / 大写Refactor-Func.md 会变成命令 /Refactor-Func,pi 不会自动 lower-case。建议全 lower-snake-or-kebab。
  2. frontmatter 缺 description:依然能加载,但 / 自动补全里没有提示,别人不知道这命令干嘛——docs/prompt-templates.md 写明 *”if omitted, the first non-empty line serves this purpose”*。
  3. $@ 没人传参:会展开成空字符串,模型可能困惑。给个 if empty, use ... 的 fallback 句子。
  4. 递归发现问题:把 template 塞 prompts/team/code/refactor.md 默认发现不到,要么平铺,要么在 settings.json 配 path。
  5. 跟 skill 重名 / 与内置斜杠命令同名时的优先级2026-06-05 复核):core/slash-commands.ts 只定义 SlashCommandSource = "extension" | "prompt" | "skill" 三类来源 + 21 条内置命令固定清单new/resume/clone/fork/tree/settings/model/scoped-models/login/logout/export/import/share/copy/session/changelog/hotkeys/reload/quit 等),但没有公开的 priority 数值常量——优先级是按”内置 → extension → prompt → skill (/skill: 命名空间)”的注册/查表顺序隐式决定的。/skill: 前缀因为是命名空间不会跟 template 直接撞名;同名 prompt template 与 extension command 同时存在时,后注册者覆盖前者(基于 Map.set 语义)。建议加项目前缀 /myproj-refactor 避免歧义——尤其是当你的项目命令撞上未来 pi 新增的内置命令时(21 条内置清单可能在 v0.79+ 扩张)。

5.6 推荐进阶

  • 把 template 放到 package 里跟 extension 一起发;package 的 pi.prompts 字段可以指 prompt 目录。
  • 用 template 做”对话状态启动器”:body 里写 “你现在是 X 角色,第一句话先问我 Y”。
  • 写一个 meta template /template-new 把生成新 template 的过程也自动化。

6. 写一个 Extension(核心章节)

这是 pi 扩展系统真正强大的地方。skill / template 都是给模型读的文本,extension 是 TypeScript 代码,能拦截 agent loop 的每一步、注册新工具、画 UI、跟外部进程做 RPC。本节会把官方 docs 与 src/core/extensions/types.ts 里的事件 / API 表完整列出,再给两个完整可运行例子。

6.1 Extension 的 3 种形态

形态 文件结构 何时用
单文件 ~/.pi/agent/extensions/foo.ts 写脚本式逻辑
目录 ~/.pi/agent/extensions/foo/index.ts 拆成多文件
带依赖的 npm 包 foo/package.json 里有 "pi": { "extensions": ["./index.ts"] } 要 import 第三方依赖

加载机制(来自 src/core/extensions/loader.ts):

  • jiti 动态 import,所以不需要预编译 TS
  • 入口必须 export default function (pi: ExtensionAPI) { ... }
  • 三个发现位置:项目级 <cwd>/.pi/extensions/、全局 agentDir/extensions/、settings 配的 path。
  • 入口规则(来自 core/package-manager.tsresolveExtensionEntries()):先看 package.json 里的 pi.extensions 字段;找不到就回退到 index.ts / index.js

6.2 Manifest 字段(package.json pi 块)

源码 core/package-manager.tsPiManifest interface(v0.78.x verbatim):

1
2
3
4
5
6
interface PiManifest {
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
}

社区扩展 pi-mcp-adapter 的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "pi-mcp-adapter",
"version": "x.y.z",
"keywords": ["pi-package", "pi", "mcp", "extension"],
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.74.0",
"@earendil-works/pi-tui": "^0.74.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"typebox": "^1.1.24"
},
"pi": {
"extensions": ["./index.ts"],
"video": "https://github.com/.../pi-mcp.mp4"
}
}

pi-subagents 把三层都打包:

1
2
3
4
5
6
7
{
"pi": {
"extensions": ["./src/extension/index.ts"],
"skills": ["./skills"],
"prompts": ["./prompts"]
}
}

注意PiManifest 源码里没有 video / image 字段——这两个是 gallery 元数据,被 npm 读到 pi.video / pi.image 即可,pi 自身的资源加载器忽略它们。

核心 deps 要放 peerDependencies:pi-coding-agent 自己管 @earendil-works/pi-coding-agent / pi-agent-core / pi-ai,extension 不要 bundle,否则版本错位时会出现两份运行时。

6.3 完整生命周期事件表(含返回值精确 TS 类型)

本节所有 return 类型直接抓自 packages/coding-agent/src/core/extensions/types.ts(v0.78.x 时点)。最重要的修正

  • tool_call event 没有 replaceWith 字段!只能 block + reason;要修改工具入参的话,直接 mutate event.input in place(types.ts 注释原文:”event.input is mutable. Mutate it in place to patch tool arguments before execution”)。
  • tool_result event 的返回值是 { content?, details?, isError? },不是 replaceWith
  • input event 用 tagged union { action: "continue" | "transform" | "handled" }
  • user_bash event 是 { operations?, result? },没有 cancel
  • message_end{ message? }(替换整条 message 必须保留原 role)。
事件 触发时机 精确 return 类型(types.ts) 行为
resources_discover 启动扫描 skills / prompts / themes 时 ResourcesDiscoverResult { skillPaths?, promptPaths?, themePaths? } 追加额外资源路径
session_start session 开始(含 fork、resume 后) — (handler 不返回) 只读
session_before_switch 准备切到另一个 session SessionBeforeSwitchResult { cancel?: boolean } 可阻断
session_before_fork 准备 fork SessionBeforeForkResult { cancel?, skipConversationRestore? } 可阻断
session_before_compact 准备压缩对话历史 SessionBeforeCompactResult { cancel?, compaction? } 可阻断 / 可整段替
session_compact compact 实际发生 只读
session_before_tree 进 session tree 视图前 SessionBeforeTreeResult { cancel?, summary?, customInstructions?, replaceInstructions?, label? } 可阻断 / 可改参
session_tree 进入后 只读
session_shutdown session 结束 做清理
context 准备发 LLM 时 ContextEventResult { messages? } 替换 messages 数组
before_provider_request 即将向 provider 发请求 BeforeProviderRequestEventResult = unknown 改请求体
after_provider_response provider 响应回来后 只读
before_agent_start agent loop 即将开始 BeforeAgentStartEventResult { message?, systemPrompt? } 注入 message / 替 system prompt(多扩展会被 chain)
agent_start / agent_end agent loop 起止 只读
turn_start / turn_end turn 起止 只读
message_start / message_update 模型流式 chunk 只读
message_end 一条 message 流完 MessageEndEventResult { message? } 替换整条 message(必须保留 role)
tool_call 执行前模型决定调一个工具 ToolCallEventResult { block?, reason? } 改入参靠 mutate event.input,不靠 return
tool_result 工具执行完毕 ToolResultEventResult { content?, details?, isError? } 改写结果
tool_execution_start/update/end 工具实际执行的 3 个时点 只读
model_select 用户 / 系统选模型 — (无 ResultType;返回值被忽略) 只读 / 监听
thinking_level_select 调 thinking level 同上
user_bash 用户 !cmd 跑 shell UserBashEventResult { operations?, result? } 换执行后端或直接给结果
input 用户提交 message(未送 agent InputEventResult = { action: "continue" } | { action: "transform"; text; images? } | { action: "handled" } 拦 / 改 / 完全接管

要点:

  • 能阻断 + 能改写的事件主要是 tool_call(block)/ input(handled / transform)/ user_bash(result)/ before_agent_start(systemPrompt)。
  • 改 tool 入参必须用 in-place mutation:event.input.command = sanitize(event.input.command)不是 return { input: ... }
  • 改 tool 输出tool_resultcontent / details / isError
  • 上下文型事件(context / before_agent_start / resources_discover)的返回值是追加(多扩展会被 chain)而不是替换。

ToolCallEvent 是按工具名分支的 union(来自 types.ts):

1
2
3
4
5
6
7
8
9
export type ToolCallEvent =
| BashToolCallEvent
| ReadToolCallEvent
| EditToolCallEvent
| WriteToolCallEvent
| GrepToolCallEvent
| FindToolCallEvent
| LsToolCallEvent
| CustomToolCallEvent;

所以你做 event.toolName === "bash" 判断后能拿到带 typed event.input(如 BashToolCallEvent.input.command)。

6.4 ctx:你能拿到什么

来自 types.ts 的 ExtensionContext(v0.78.x,verbatim):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export interface ExtensionContext {
ui: ExtensionUIContext;
mode: ExtensionMode; // "tui" | "rpc" | "json" | "print"
hasUI: boolean; // dialog-capable UI(TUI / RPC 下 true)
cwd: string;
sessionManager: ReadonlySessionManager;
modelRegistry: ModelRegistry;
model: Model<any> | undefined;
isIdle(): boolean;
signal: AbortSignal | undefined;
abort(): void;
hasPendingMessages(): boolean;
shutdown(): void;
getContextUsage(): ContextUsage | undefined;
compact(options?: CompactOptions): void;
getSystemPrompt(): string; // 当前已组装好的 system prompt 文本
}

getSystemPromptOptions() 在哪? 它是真实存在的 API,但只挂在 ExtensionCommandContext 上(命令 handler 拿到的、ExtensionContext 的超集)。verbatim:

1
2
3
4
5
6
7
8
9
10
11
export interface ExtensionCommandContext extends ExtensionContext {
/** Get the current base system-prompt construction options. */
getSystemPromptOptions(): BuildSystemPromptOptions;

/** Wait for the agent to finish streaming */
waitForIdle(): Promise<void>;

/** Start a new session, optionally with initialization. */
newSession(options?: { ... }): Promise<{ cancelled: boolean }>;
// …还有 fork / switchSession / navigateTree / reload
}

差别:

  • getSystemPrompt() 给你已组装好的最终字符串,事件 handler 与命令 handler 都能调。
  • getSystemPromptOptions() 给你还没组装的结构化选项BuildSystemPromptOptions 来自 core/system-prompt.ts),用来在 before_agent_start 之类的事件里看 / 改 system prompt 的”原料”——但只能在命令 handler 里调。在事件 handler 里要拿原料,看 BeforeAgentStartEvent.systemPromptOptions(事件直接给你了,types.ts 里这条 event 自带 systemPromptOptions: BuildSystemPromptOptions 字段)。

ExtensionCommandContextExtensionContext 多的 methods(命令 handler 专属):

  • getSystemPromptOptions(): BuildSystemPromptOptions
  • waitForIdle(): Promise<void>
  • newSession(options?) / fork(options?) / switchSession(...) / navigateTree(...)
  • reload() —— 重新发现 / 加载所有扩展

6.5 ExtensionAPI 注册接口(pi 参数)

来自 types.ts(v0.78.x verbatim 摘录):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
export interface ExtensionAPI {
// —— 事件订阅 ——
on(event: "resources_discover",
handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on(event: "tool_call",
handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "input",
handler: ExtensionHandler<InputEvent, InputEventResult>): void;
on(event: "context",
handler: ExtensionHandler<ContextEvent, ContextEventResult>): void;
// …其余事件同模式

// —— 注册 ——
registerTool<TParams extends TSchema, TDetails, TState>(
tool: ToolDefinition<TParams, TDetails, TState>,
): void;
registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "sourceInfo">): void;
registerShortcut(shortcut: KeyId, options: { /* ... */ }): void;
registerFlag(name: string, options: { /* ... */ }): void;
getFlag(name: string): boolean | string | undefined;
registerMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void;

// —— 行为 ——
sendMessage<T>(message, options?): void;
sendUserMessage(content, options?): void;
appendEntry<T>(customType: string, data?: T): void;

// —— session 元数据 ——
setSessionName(name: string): void;
getSessionName(): string | undefined;
setLabel(entryId: string, label: string | undefined): void;
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;

// —— tool 状态 ——
getActiveTools(): string[];
getAllTools(): ToolInfo[];
setActiveTools(toolNames: string[]): void;
getCommands(): SlashCommandInfo[];

// —— model / thinking ——
setModel(model: Model<any>): Promise<boolean>;
getThinkingLevel(): ThinkingLevel;
setThinkingLevel(level: ThinkingLevel): void;

// —— provider ——
// 注:这两个方法是 ExtensionContext 包装层。底层调的是
// packages/ai/src/api-registry.ts 的 `registerApiProvider(provider, sourceId?)`
// —— Map 实现,不是 class;详见 §11 的 ProviderConfig 备注。
registerProvider(name: string, config: ProviderConfig): void;
unregisterProvider(name: string): void;

// —— 共享 event bus ——
events: EventBus;
}

// 通用 handler 签名:
export type ExtensionHandler<E, R = undefined> =
(event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;

6.6 注册自定义工具

ToolDefinition 接口(v0.78.x verbatim 摘自 types.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export interface ToolDefinition<
TParams extends TSchema = TSchema,
TDetails = unknown,
TState = any,
> {
name: string;
label: string;
description: string;
promptSnippet?: string;
promptGuidelines?: string[];
parameters: TParams;
renderShell?: "default" | "self";
prepareArguments?: (args: unknown) => Static<TParams>;

/**
* Per-tool execution mode override.
* - "sequential": this tool must execute one at a time with other tool calls.
* - "parallel": this tool can execute concurrently with other tool calls.
*
* If omitted, the default execution mode applies.
*/
executionMode?: ToolExecutionMode;

execute(
toolCallId: string,
params: Static<TParams>,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<TDetails>>;

renderCall?: (args, theme, context) => Component;
renderResult?: (result, options, theme, context) => Component;
}

关键修正:并发控制是单一字段 executionMode: "sequential" | "parallel"不是两个分开的 sequential / parallel boolean。原稿里描述错了。

具体用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { Type } from "typebox";

pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "What this tool does",
promptSnippet: "100 char teaser",
parameters: Type.Object({
path: Type.String({ description: "Target file path" }),
dryRun: Type.Optional(Type.Boolean()),
}),
executionMode: "sequential", // 关键 IO 用 sequential;纯查询用 "parallel"

async execute(toolCallId, params, signal, onUpdate, ctx) {
onUpdate?.({ kind: "text", text: "Working..." });
if (signal?.aborted) throw new Error("aborted");
// ... 真实工作 ...
return {
content: [{ type: "text", text: "Done." }],
details: { /* 任意 JSON,给 renderResult 用 */ },
};
},

renderCall: (params, theme, ctx) => /* Component */,
renderResult: (result, options, theme, ctx) => /* Component */,
});

注意:

  • 参数 schema 用 TypeBox,不是 zod。pi 内部把 TypeBox schema 转成 JSON Schema 喂给 LLM。如果你需要 enum,StringEnum from @earendil-works/pi-ai 而不是 Type.Union(Type.Literal(...)),这是 docs 强调的兼容性细节(Google API 会拒绝某些 OneOf)。
  • 输出截断是必须的,docs 写明 50KB 或 2000 行上限——超过会破坏 compaction 逻辑、把 context 撑爆。
  • 工具与原子工具(read/write/edit/bash)共存,名字别冲突;要替换 read/write 行为推荐用 tool_call event 拦截而不是同名 register。
  • ctx 在 execute 里跟事件 handler 拿到的 ExtensionContext 同接口(不是 ExtensionCommandContext)。

6.7 添加 UI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 在 handler 或命令里
ctx.ui.notify("Saved!", "info"); // info | warning | error
const ok = await ctx.ui.confirm("Run prettier?", "This formats all .ts files");
const choice = await ctx.ui.select("Pick one", ["a", "b", "c"]);
const text = await ctx.ui.input("Branch name?", "main");

// 自定义 panel(来自 docs/tui.md + types.ts ExtensionUIContext)
ctx.ui.custom((tui, theme, keybindings, done) => ({
render: (width: number) => [
theme.fg("accent", "Custom Panel"),
theme.fg("muted", "Press q to close"),
],
invalidate: () => { /* 清缓存 */ },
handleInput: (data: string) => {
if (data === "q") done();
},
}), { overlay: true });

ExtensionUIContext 关键 method(types.ts 摘录):select / confirm / input / notify / setStatus / setWorkingMessage / setWidget / setFooter / setHeader / setTitle / custom / pasteToEditor / setEditorText / getEditorText / editor / addAutocompleteProvider / setEditorComponent / theme(只读)/ getAllThemes / setTheme / getToolsExpanded / setToolsExpanded

@earendil-works/pi-tui 里能 import 的内置组件:TextBoxContainerSelectListSettingsListMarkdown、按键工具 matchesKey / Key、宽度工具 visibleWidth / truncateToWidth / wrapTextWithAnsi

守则:自定义渲染必须用 callback 里的 theme,不要直接 import { theme },否则用户切主题你不会跟着变。

6.8 添加 slash commands & 快捷键

1
2
3
4
5
6
7
8
9
10
11
pi.registerCommand("hello", {
description: "Say hi",
handler: async (args, ctx) => {
ctx.ui.notify(`Hi ${args || "world"}!`);
},
});

pi.registerShortcut("ctrl+shift+h", {
description: "Greet",
handler: async (ctx) => { /* ... */ },
});

跟内置命令同名时用户级 keybindings.json 优先级最高,extension 注册的会被 shadow——这是 docs/keybindings.md 说的”用户可改任何 binding”的副作用。

6.9 RPC:扩展跟外部进程通信

pi 的 RPC 主要面向外部客户端控制 pi(headless mode,stdin/stdout 走 JSON),不是 extension 之间互相喊话。但 extension 在 RPC 模式下可以发起 extension_ui_request 让外部 UI 帮你画对话框(参见 docs/rpc.md)。

extension ↔ 外部进程的常见做法:

  1. extension 里 child_process.spawn 起一个本地进程(比如 MCP server、prettier daemon)。
  2. session 结束时在 session_shutdown handler 里 graceful kill。
  3. 跨 extension 共享状态用 pi.appendEntry() 写 session log,下次读回。

pi-mcp-adapter 的真实做法(节选自 index.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pi.on("session_start", async (_event, ctx) => {
const generation = ++lifecycleGeneration;
// 关掉上一次 session 留下的 MCP servers
await Promise.all([
shutdownState(previousState, "session_restart"),
shutdownOAuth(),
]);
// 启动新的
const promise = initializeMcp(pi, ctx);
initPromise = promise;
});

pi.on("session_shutdown", async () => {
await Promise.all([
shutdownState(state, "session_shutdown"),
shutdownOAuth(),
]);
});

lifecycleGeneration 这个 monotonic counter 是经典做法:在 session 反复重启时丢弃”已经过期”的初始化结果,避免 race。

6.10 完整最小示例:auto-format on save

需求:每次 agent 用 writeedit 修改 .ts / .tsx / .js / .jsx / .json / .md 文件后,自动跑 prettier --write,把格式化结果当成”额外的工具结果”显示出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// ~/.pi/agent/extensions/auto-format/index.ts
//
// Listens for write/edit tool results, runs prettier on the touched file,
// and appends a tiny notice. Skips files prettier doesn't support.
//
// Run standalone: pi -e ~/.pi/agent/extensions/auto-format/index.ts
// Or drop it into ~/.pi/agent/extensions/auto-format/ and `pi`.

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as path from "node:path";

const exec = promisify(execFile);

const FORMATTABLE = new Set([
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
".json", ".jsonc", ".md", ".mdx", ".yml", ".yaml",
".css", ".scss", ".html",
]);

export default function autoFormat(pi: ExtensionAPI) {
pi.registerFlag("auto-format-disabled", {
description: "Disable auto-format on save",
type: "boolean",
});

pi.on("tool_result", async (event, ctx) => {
if (pi.getFlag("auto-format-disabled")) return;

// Only react to write / edit
const toolName = event.toolName;
if (toolName !== "write" && toolName !== "edit") return;

// Find the touched path. write/edit conventionally take `file_path`.
const filePath: string | undefined =
(event as any).input?.file_path ?? (event as any).input?.path;
if (!filePath) return;

const ext = path.extname(filePath).toLowerCase();
if (!FORMATTABLE.has(ext)) return;

const abs = path.isAbsolute(filePath)
? filePath
: path.join(ctx.cwd, filePath);

try {
await exec("npx", ["--no-install", "prettier", "--write", abs], {
cwd: ctx.cwd,
signal: ctx.signal,
timeout: 15_000,
});
if (ctx.hasUI) {
ctx.ui.notify(`prettier: formatted ${path.basename(abs)}`, "info");
}
} catch (err) {
// prettier missing or failed — don't blow up the agent loop
if (ctx.hasUI) {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`prettier skipped: ${msg.slice(0, 120)}`, "warning");
}
}

// We don't need to return anything; tool_result returns can mutate
// result content via { content, details, isError }, but here we just observe.
});

pi.registerCommand("autoformat", {
description: "Toggle auto-format on save",
handler: async (_args, ctx) => {
ctx.ui.notify("Toggle via --auto-format-disabled flag for now", "info");
},
});
}

运行:

1
pi -e ~/.pi/agent/extensions/auto-format/index.ts

或者放进 ~/.pi/agent/extensions/auto-format/(把上面文件命名为 index.ts)然后 pi 直接启动。

测试:在 pi 里说 “create a file tmp.ts that prints ‘hi’ badly indented”,等模型 write 完,你会看到右下角 notify 弹”prettier: formatted tmp.ts”,文件被自动格式化。

6.11 进阶示例:简化版 plan-mode

需求:拦截所有写操作(write / edit / bash),先把”接下来要做什么”以 plan 形式打出来,等用户在 UI 里确认才放行。

修正(2026-06-05):原稿这里用了 tool_callreplaceWith 字段——但 ToolCallEventResult 没有这个字段(types.ts 只定义了 block?: booleanreason?: string)。要给模型一个”被拦了为什么”的反馈,靠 reason 字符串。要把”被拦”显示成更复杂的 message,可以在拦截后调 pi.sendMessage() 注入一条 custom message。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// ~/.pi/agent/extensions/plan-gate/index.ts
//
// A toy "plan mode": for every destructive tool call, ask the user
// (via TUI confirm) before letting it through. Skips read-only tools.
//
// Run: pi -e ~/.pi/agent/extensions/plan-gate/index.ts

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

const DESTRUCTIVE = new Set(["write", "edit", "bash"]);
let armed = false;

export default function planGate(pi: ExtensionAPI) {
pi.registerCommand("plan", {
description: "Toggle plan-gate (confirm before write/edit/bash)",
handler: async (_args, ctx) => {
armed = !armed;
ctx.ui.notify(`plan-gate: ${armed ? "ON" : "OFF"}`, "info");
},
});

pi.on("tool_call", async (event, ctx) => {
if (!armed) return;
if (!DESTRUCTIVE.has(event.toolName)) return; // read-only: pass

if (!ctx.hasUI) {
// Non-interactive run: just block.
return {
block: true,
reason: "plan-gate blocked: destructive tool calls require TUI confirmation.",
};
}

const summary = `${event.toolName}(${JSON.stringify(event.input).slice(0, 200)})`;
const ok = await ctx.ui.confirm(
`Approve: ${summary}?`,
"plan-gate is armed; press y to allow this tool call.",
);
if (!ok) {
return {
block: true,
reason: "User declined the action.",
};
}
// Returning nothing lets the call proceed.
});
}

运行:

1
2
3
4
pi -e ~/.pi/agent/extensions/plan-gate/index.ts
> /plan
> refactor src/api/auth.ts to use async/await
# 每次 write/edit/bash 都会弹确认

这就是为什么 pi 把”plan mode”故意没做成内置——50 行 extension 就能自己写一个,且行为完全可控。

顺手提一句:要改 tool 入参而不是拦截,直接 mutate event.input

1
2
3
4
5
pi.on("tool_call", (event) => {
if (event.toolName === "bash") {
event.input.command = event.input.command.replace(/rm -rf \//g, "echo blocked");
}
});

6.12 常见坑

  1. handler 阻塞 agent looptool_call handler 是 await 的,handler 里 sleep 30 秒,agent 就卡 30 秒。长任务请 setImmediate(() => ...),把”通知 / 写日志”挪到后台。
  2. tool_callreplaceWith / cancel没有这俩字段!只能 { block: true, reason: "..." }。要改入参:mutate event.input。要替换”被拦后给模型的反馈”:自己 pi.sendMessage 一条 custom message。
  3. ctx.signal 不监听:用户 ctrl-C,你的工具继续跑、UI 还在转圈。execute 长任务必须周期性 if (signal?.aborted) throw new Error("aborted");
  4. session 切换时残留状态:如 6.9 节展示的 lifecycleGeneration 模式,在 session_start 里要 nuke 之前的状态、并防止”上次的 init 结果回来后污染本次”。
  5. tool_result 改写 size:能减小、不能增大太多,否则触发 token 截断逻辑出怪现象(docs 强调 50KB / 2000 行)。
  6. TS 编译陷阱:jiti 跑你的源码,不会做 strict 类型检查。意思是 as any 和 implicit any 都过;自己 IDE 配 tsc --noEmit 做静态检查,否则 bug 很难发现。
  7. 依赖装错位置@earendil-works/pi-coding-agent 应该在 peerDependencies 而不是 dependencies;放 dependencies 用户装包时会拉一份独立 runtime。
  8. registerProvider 顺序:provider 必须在 session_start 之前注册;registerProvider 调用本身可以放在 factory 同步代码里,但生效要等 ctx 起来。
  9. getSystemPromptOptions() 在事件 handler 里报 undefined:那是因为它只挂在 ExtensionCommandContext 上。事件 handler 里要拿 system prompt 原料,看 BeforeAgentStartEvent.systemPromptOptions

6.13 推荐进阶

  • pi-mcp-adapterindex.ts(约 250 行)学:lifecycle generation、direct tool 注册、proxy tool 模式、CLI flag 注入。
  • pi-subagentssrc/extension/index.ts(约 22 KB)学:怎么把”subagent 任务”做成自定义 tool(一个 tool 名 subagent,靠参数分 single / chain / parallel / list / get / create / update / delete / status / interrupt / resume / doctor 多种动作)、注册三个 message renderer、监听 pi.events.on(SUBAGENT_ASYNC_*)、做 slash bridge。
  • 给你的 extension 加 ctx.appendEntry() 持久化,这样 pi 重启之后 todos / 计数器还在。

7. 写一个 Package

7.1 目录结构

一个 package 同时可以含 extensions / skills / prompt templates / themes:

1
2
3
4
5
6
7
8
9
10
11
12
13
my-pi-pack/
├── package.json
├── README.md
├── extensions/
│ └── auto-format/
│ └── index.ts
├── skills/
│ └── code-review-checklist/
│ └── SKILL.md
├── prompts/
│ └── refactor.md
└── themes/
└── solarized.json

package.json manifest(手写):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"name": "my-pi-pack",
"version": "0.1.0",
"description": "My personal pi pack: auto-format + code review",
"keywords": ["pi-package", "pi"], // 必须有 pi-package 才会被 gallery 收
"license": "MIT",
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.74.0"
},
"dependencies": {
"typebox": "^1.1.24"
},
"pi": {
"extensions": ["./extensions/auto-format/index.ts"],
"skills": ["./skills"], // 见 §7.7:指目录会扫子目录里的 SKILL.md
"prompts": ["./prompts"],
"themes": ["./themes/solarized.json"],
"video": "https://example.com/demo.mp4" // gallery 字段,资源加载器忽略
},
"files": [
"extensions",
"skills",
"prompts",
"themes",
"README.md"
]
}

如果你不写 pi 字段,pi 会自动发现 extensions/skills/prompts/themes/ 这些约定目录(来自 core/package-manager.tscollectPackageResources() fallback 分支)。所以小包可以直接省略 manifest。

7.2 4 种分发方式(直接抓自 docs/packages.md)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1) npm 注册表
pi install npm:@foo/bar@1.0.0
pi install npm:my-pi-pack # 不指定版本走 latest

# 2) git
pi install git:github.com/user/repo@v1
pi install https://github.com/user/repo
pi install ssh://git@github.com/user/repo
pi install git:git@github.com:user/repo@v1.0.0

# 3) 本地路径
pi install /absolute/path/to/package
pi install ./relative/path/to/package

# 4) 内置(pi 自带)
# 不需要装,extension 通过 settings.json 启用即可

-l flag 表示项目级安装(落 <project>/.pi/npm/),不加就是全局(落 ~/.pi/agent/npm/)。源码 core/package-manager.tsgetManagedNpmInstallPath()

1
2
3
4
5
6
7
8
9
private getManagedNpmInstallPath(source: NpmSource, scope: SourceScope): string {
if (scope === "temporary") {
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
return join(this.agentDir, "npm", "node_modules", source.name);
}

7.3 管理命令(v0.78.x verbatim 抓自 src/package-manager-cli.ts

1
export type PackageCommand = "install" | "remove" | "update" | "list";

加上 alias uninstall 和独立的 config 命令,CLI 子命令的完整列表就是这些(args.ts 的 help 文本同步):

1
2
3
4
5
6
7
8
9
10
pi install <source> [-l]      # 装
pi remove <source> [-l] # 卸
pi uninstall <source> [-l] # remove 的 alias
pi list # 列已装
pi update # 升 pi 自己 + 所有 package
pi update --extensions # 只升 package
pi update --self # 只升 pi
pi update --self --force # 强制重装 pi
pi update <source> # 升单个
pi config # 交互式开/关 package 资源

经源码核实,pi 当前 (v0.78.x) 没有以下子命令

  • pi pkg create —— 不存在。npm init -y 起项目,按 §7.1 写 package.jsonpi 字段即可。
  • pi pkg publish —— 不存在。**用 npm publish**。
  • pi extension dev —— 不存在。**dev loop 是 pi -e <path> + /reload**(见 §2.5)。
  • pi pkg <anything> —— 整个 pkg 子命令族不存在。

这是用户 brief 里的笔误或外部资料过时;以本节为准。

7.4 临时加载(不安装)

1
2
3
4
5
6
pi -e npm:@foo/bar                    # 单次启动加载 npm 包里的 extension
pi -e git:github.com/user/repo
pi -e ./local/extension.ts
pi --skill ./my-skill # 加载 skill
pi --prompt-template ./tpl.md # 加载 template
pi --theme ./theme.json # 加载 theme

适合 demo / CI / 临时调试。

7.5 版本管理 & 兼容性

  • pi-coding-agent 主版本 0.x 阶段频繁 break:events 的 ctx shape、tool 注册字段、内置组件 API 都可能改名。把 peerDependencies 写成 >=0.74.0 这种宽松约束,CI 跑测试,挂了就出新 minor。
  • pi 安装 npm 包时 dependencies 会被装到 package 自己的 node_modulesbundledDependencies 里列出的会一起 publish 进 tarball(适合 fork 出来的子模块)。peerDependencies 不会被装——意思是 pi 自己作为 peer 提供。
  • pi-package 这个 keyword 是 gallery 收录的硬性条件(也是 docs 推荐的)。

7.6 最小示例:把第 6 节的 auto-format 发成 npm package

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 1) 起项目
mkdir my-auto-format && cd my-auto-format
npm init -y
mkdir -p extensions/auto-format

# 2) 把第 6.10 节的 index.ts 拷进 extensions/auto-format/

# 3) 改 package.json
cat > package.json <<'EOF'
{
"name": "pi-auto-format",
"version": "0.1.0",
"description": "Auto-run prettier after pi writes/edits files",
"keywords": ["pi-package", "pi", "prettier", "extension"],
"license": "MIT",
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.74.0"
},
"pi": {
"extensions": ["./extensions/auto-format/index.ts"]
},
"files": ["extensions", "README.md"]
}
EOF

# 4) 本地测一下
pi -e .
# 或者 pi install ./
# 在 pi 里让模型 write 一个文件,看 prettier 有没有跑

# 5) 发布
npm login
npm publish --access public

# 6) 别人怎么装
pi install npm:pi-auto-format

7.7 加载顺序与覆盖规则(核心源码精确版)

本节所有结论直接来自 core/package-manager.tsdedupePackages() / resourcePrecedenceRank() / collectPackageResources() / applyPatterns()

Package 维度(同源覆盖)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// core/package-manager.ts dedupePackages()
private dedupePackages(packages) {
const seen = new Map();
for (const entry of packages) {
const identity = this.getPackageIdentity(sourceStr, entry.scope);
const existing = seen.get(identity);
if (!existing) {
seen.set(identity, entry);
} else if (entry.scope === "project" && existing.scope === "user") {
// Project wins over user
seen.set(identity, entry);
}
// 同 scope 同 identity:保留先扫到的
}
return Array.from(seen.values());
}

identity 的算法

1
2
3
4
5
6
7
8
private getPackageIdentity(source, scope?) {
// npm: 包名(不含 version)
if (parsed.type === "npm") return `npm:${parsed.name}`;
// git: host/path(SSH 与 HTTPS 视为同一个)
if (parsed.type === "git") return `git:${parsed.host}/${parsed.path}`;
// local: 解析后的绝对路径
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
}

Resource 维度(同名资源覆盖)

1
2
3
4
5
6
// resourcePrecedenceRank(),越小越优先
// 0 project + settings entry (source: "local", scope: "project")
// 1 project + auto-discovered (source: "auto", scope: "project")
// 2 user + settings entry (source: "local", scope: "user")
// 3 user + auto-discovered (source: "auto", scope: "user")
// 4 package resource (origin: "package")

也就是说:

  • 项目 settings 显式列出 > 项目 auto > 全局 settings 显式列出 > 全局 auto > package
  • 同 rank 同名时先扫到的 wins(loader.ts / skills.ts 里 “first wins” 的实现一致)。

Package filteringpi config / settings 里的 packages 数组对象形式):

1
2
3
4
5
6
7
8
9
10
11
12
13
// settings.json
{
"packages": [
{
"source": "npm:my-pi-pack",
"extensions": ["./extensions/auto-format/index.ts"], // 仅这一个 ext 启用
"skills": ["!archive/*"], // 排除 archive/ 下
"prompts": [], // 全关
"themes": ["+special.json"] // 强制开
// 省略某 key → 该类型走默认(全开)
}
]
}

applyPatterns() 的精确规则(源码注释 verbatim):

Pattern types:

  • Plain patterns: include matching paths
  • !pattern: exclude matching paths
  • +path: force-include exact path (overrides exclusions)
  • -path: force-exclude exact path (overrides force-includes)

pi.skills manifest 指什么(来自 core/package-manager.tscollectResourceFiles()collectSkillEntries(dir, "pi")):

  • 指目录"./skills""./skills/foo"):递归扫描,遇到 SKILL.md 就停止下钻(把那个目录当作 skill root),同时根目录下的 .md 文件也直接当作单文件 skill 加载。
  • 直接指 SKILL.md 文件"./skills/foo/SKILL.md"):把那一个 skill 加进来。
  • 指中间目录但目录里没有 SKILL.md、只有 .md 文件:根目录的 .md 文件被当成 skill;若该目录仅是”装着多个子 skill 目录”的容器,行为正确——逐个递归找 SKILL.md。

简单说:指目录最稳,无论目录里是直接放 SKILL.md 还是嵌套了多个子 skill 目录都行。


8. 真实案例拆解

8.1 pi-mcp-adapter(月下载 99.2K)

职责:把任意 MCP server 接入 pi。

关键设计

  1. proxy tool 模式:默认只注册一个 mcp 工具(~200 token),通过参数(tool / connect / describe / search)把上百个 MCP tool 隐藏在后面。
  2. direct tool 模式:用户在 .mcp.json 里把高频 tool 标记为 direct,启动时把这些注册成一等公民工具(每个 ~200 token)。MCP_DIRECT_TOOLS=__none__ 完全禁用、缺省自动选。
  3. lazy connect:MCP server 不在启动时全连,模型调到时才 spawn(节省内存、更快启动)。
  4. lifecycleGeneration:抗 session 反复重启的 race。
  5. OAuth flow:跟 MCP server 走 OAuth 时,需要 extension 自己起 callback 端口;在 session_startinitializeOAuth()session_shutdownshutdownOAuth()

注册的工具与命令

名字 类型 干嘛
mcp tool(proxy) 让 LLM 调任意 MCP tool
<prefix>__<tool> tool(direct) 高频 MCP tool 直接暴露
/mcp command 状态面板(reconnect / tools / setup / logout / status
/mcp-auth command 触发 OAuth

怎么读它的源码

  • 入口 index.ts 是粘合层。
  • 业务逻辑在 init.ts(启动)、commands.ts(slash 命令)、proxy-modes.ts(proxy tool 的几种模式)、direct-tools.ts(直接 tool 注册)、tool-result-renderer.ts(自定义渲染)。
  • 配置:config.ts.mcp.json(兼容 Claude Desktop / Cursor 的格式)。

对你的启发:写”adapter 类”扩展(连外部协议进 pi)的标准模板就是它。

8.2 pi-subagents(月下载 103.2K)

职责:在 pi 里实现 sub-agent,能 sequential / parallel / background 跑多个内嵌 agent。

关键设计(从 src/extension/index.ts 实抓):

  1. 单工具多动作:只注册一个 subagent 工具,靠参数 schema 分支({ agent, task? } / { chain: [...] } / { tasks: [...] } 三种主模式 + list / get / create / update / delete / status / interrupt / resume / doctor 这套管理动作)。
  2. 三个 message renderer:分别处理 SLASH_RESULT_TYPEsubagent-notifySUBAGENT_CONTROL_MESSAGE_TYPE,定制 TUI 显示。
  3. 基于 EventBus 的 async 框架pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT) / SUBAGENT_ASYNC_COMPLETE_EVENT / SUBAGENT_CONTROL_EVENT,让 background subagent 能跨 turn 通知主 session。
  4. session bridgeregisterSlashSubagentBridgeregisterPromptTemplateDelegationBridgeregisterFanoutChildSubagentExtension——把 slash command 和 prompt template 接进 subagent 体系。
  5. 包结构(package.json):
1
2
3
4
5
"pi": {
"extensions": ["./src/extension/index.ts"],
"skills": ["./skills"],
"prompts": ["./prompts"]
}

三层都用:

  • extension 注册 subagent 工具与 /subagent / /subagent-status 等命令。
  • skills 提供”如何使用 subagent 框架”的 progressive disclosure 文档。
  • prompts 提供 /scout/plan 这类启动咒语。

对比 Claude Code 内置 subagent

维度 Claude Code pi-subagents
实现位置 内核 第三方 extension
可定制性 受限于官方暴露的 hook 全部 TS 源码
chain 表达力 简单 parallel / collect / chain 三种
隔离粒度 子任务 子 session(含独立 context window)
持久化 跟主 session 绑 markdown / json artifact

这正体现 pi 的哲学:核心不内置,社区能写得比内置更好——因为你看得到全部代码。

8.3 context-mode(月下载 121.5K)— ⚠️ 不是 pi extension

2026-06-05 复核更正:原稿把 context-mode 当 pi 社区扩展拆解是错误前提。npm registry 显示该包的 repository 字段指向 mksglu/claude-context-mode,作者 Mert Koseoğlu (mksglu)Elastic License 2.0(不是 MIT),最新版 1.0.162。它实际是Claude Code / Gemini CLI / Cursor / Codex CLI 等多平台的 MCP 插件,安装方式是:

1
2
3
4
5
6
7
8
# Claude Code
/plugin marketplace add mksglu/context-mode
/plugin install context-mode@context-mode
/context-mode:ctx-doctor

# Gemini CLI / 其他平台
npm install -g context-mode
# 在 ~/.gemini/settings.json 或对应平台 config 里配 MCP server + 4 hooks

官方明确支持的平台清单(README 摘录):

  • Full hooks(routing enforcement + session continuity):Claude Code、Gemini CLI、VS Code Copilot、JetBrains Copilot、OpenCodeOpenClaw、Codex CLI、OMP
  • Partial / no hooks(约 60% compliance):Cursor、Antigravity、Zed、Kiro
  • 未提及 pi——所以”pi 月下载 121.5K 的最热扩展”这一说法源自 npm 全局下载量,不能等同于 pi extension 圈的真实份额

功能(README 原文):解决”context window crisis”——把 raw data 隔离到 sandbox 跑代码、用 SQLite + FTS5 + BM25 做长程检索、强制模型生成分析脚本而非直接看数据。核心工具:

Tool 功能 Context 节省
ctx_execute 在 12 种语言的 sandbox 跑代码,只回 stdout 56 KB → 299 B
ctx_batch_execute 多命令 + 多查询合一调 986 KB → 62 KB
ctx_index / ctx_search FTS5 + BM25 检索 60 KB → 40 B
ctx_fetch_and_index 抓 URL 切片建索引(24h 缓存) 60 KB → 40 B
ctx_execute_file sandbox 内处理大文件 45 KB → 155 B

对 pi 用户的启示

  1. 想要类似能力,可以通过 pi-mcp-adaptercontext-mode 接进 pi——pi-mcp-adapter 本身就是 MCP 桥,理论上 context-mode 这个 MCP server 能挂上去(pi 文档与 README 都未明确测试,需自验)。
  2. context-mode 的”代码代替数据”范式仍然有借鉴价值:写真正的 pi extension 时,用 sandboxed bash + 临时文件路径回传(pi 的 bash 工具默认行为)已经能实现 70% 等效效果,无需引入新工具。
  3. License 差异:Elastic License 2.0 禁止”作为托管服务转售”,企业内部使用 OK;写到 pi-package 生态时不要复制 context-mode 源码——只能通过 MCP 调用。

9. 从 Claude Code / Codex 迁移

9.1 概念映射

Claude Code Codex pi
Skills (无原生) skills(同协议)
MCP servers MCP servers pi-mcp-adapter(社区)
Hooks(PreToolUse / PostToolUse) (Codex 内置较少) extension events(tool_call / tool_result
Subagents(内置) (无原生) pi-subagents(社区)
/commands --prompt flag prompt templates
Plan mode(内置) (无) DIY,参考 6.11
Permissions sandbox(OpenShell) DIY + pi-OpenShell / gondolin

9.2 Skills:直接挪过来

Claude Code 的 skill 协议跟 pi 的几乎完全一致(pi 实现的就是 Agent Skills 标准),把 ~/.claude/skills/foo 复制到 ~/.pi/agent/skills/foo 多半能用。差异:

  • pi 允许 name 跟目录名不同。
  • pi 的 frontmatter 多了 compatibility / metadata / disable-model-invocation,少了 Claude 特有字段(如果有)。
  • allowed-tools 列表里写的工具名要对应 pi 的工具名(bash / read / write / edit),不是 Claude Code 的 Bash / Read(首字母大小写不同)。

docs/skills.md 给出现成的”借用其他 harness 的 skills”配置:

1
2
3
4
5
6
{
"skills": [
"~/.claude/skills",
"~/.codex/skills"
]
}

9.3 MCP:装个 adapter

1
pi install npm:pi-mcp-adapter

把 Claude Desktop / Cursor 的 mcp.json 直接放 <project>/.mcp.json~/.config/mcp/mcp.json,pi 自动读。OAuth flow 走 /mcp-auth

9.4 Hooks → events

Claude Code Hook pi event
PreToolUse tool_call(return { block: true, reason } 等于拒绝;mutate event.input 等于改入参)
PostToolUse tool_result(return { content?, details?, isError? } 等于 transform)
UserPromptSubmit input(return { action: "transform", text, images? } 改写;{ action: "handled" } 完全接管)
Stop agent_end
SubagentStop (pi 里没 subagent 内置;用 pi-subagents 自己监听)

写法上 pi 是 TS 里的函数,Claude Code 是 JSON 配命令;pi 灵活得多但要写代码。

9.5 Codex sandbox → pi(来自 docs/containerization.md 抓取)

docs/containerization.md 列了三种容器化模式,verbatim 引用:

Pattern What is isolated Best for Notes
OpenShell Whole pi process in a policy-controlled sandbox Local or remote managed sandbox Requires an OpenShell gateway
Gondolin extension Built-in tools and ! commands Local micro-VM isolation while keeping auth on host See examples/extensions/gondolin/
Plain Docker Whole pi process in a local container Simple local isolation Provider API keys enter the container

OpenShell(NVIDIA OpenShell)

进 sandbox 内整体跑 pi:

1
2
3
openshell gateway add <gateway-url> --name <name>
openshell gateway select <name>
openshell sandbox create --name pi-sandbox --from pi -- pi

如果 gateway 是远端的,项目文件不会被 bind-mount,要么 sandbox 里 clone repo,要么用 openshell sandbox upload / download。OpenShell 还能做”原始 API key 不进 sandbox、由 gateway 注入”的 inference routing(沙箱里调 https://inference.local)。

Gondolin(earendil-works 自家本地 micro-VM)

1
2
3
4
5
6
cp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin
cd ~/.pi/agent/extensions/gondolin
npm install --ignore-scripts

cd /path/to/project
pi -e ~/.pi/agent/extensions/gondolin

行为:把 host cwd 挂到 VM 的 /workspaceoverride 内置 read / write / edit / bash / grep / find / ls 七个工具,并把 ! 命令也路由进 VM。文件改动通过 /workspace 写回 host。需要 Node ≥ 23.6.0 和 QEMU。

Plain Docker(最简单)

1
2
3
4
5
6
7
FROM node:24-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
WORKDIR /workspace
ENTRYPOINT ["pi"]
1
2
3
4
5
6
docker build -t pi-sandbox -f Dockerfile.pi .
docker run --rm -it \
-e ANTHROPIC_API_KEY \
-v "$PWD:/workspace" \
-v pi-agent-home:/root/.pi/agent \
pi-sandbox

关键差别:OpenShell / Plain Docker 是整 pi 进容器,凭据在容器内;Gondolin 是 pi 在 host、工具调用进 VM,auth 留在 host。要纯审计沙箱选 OpenShell,要本地隔离选 Gondolin,要 demo 选 Docker。


10. 调试与发布

10.1 本地 dev loop

1
2
3
# 写代码 → 跑 pi -e ./your-ext.ts
# 改代码 → 在 pi 里 /reload
# 看 log → /debug 然后 tail ~/.pi/agent/pi-debug.log

console.log / console.error 可用。在 TUI 模式下 stdout 被 capture,进 log 文件而不是终端。

10.2 单元测试

pi 没有官方 extension test harness(截至 v0.78.x 我看到的 docs / repo)。建议方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// tests/auto-format.test.ts
import { describe, it, expect, vi } from "vitest";
import autoFormat from "../extensions/auto-format/index.ts";

function makeFakePi() {
const handlers: Record<string, Function> = {};
return {
api: {
on: (name: string, fn: Function) => { handlers[name] = fn; },
registerTool: vi.fn(),
registerCommand: vi.fn(),
registerFlag: vi.fn(),
getFlag: vi.fn().mockReturnValue(false),
} as any,
fire: (name: string, event: any, ctx: any) =>
handlers[name]?.(event, ctx),
};
}

describe("auto-format", () => {
it("triggers prettier on .ts write", async () => {
const { api, fire } = makeFakePi();
autoFormat(api);
const ctx = {
cwd: process.cwd(),
hasUI: false,
signal: new AbortController().signal,
ui: { notify: vi.fn() },
};
await fire("tool_result",
{ toolName: "write", input: { file_path: "x.ts" } },
ctx);
// assert prettier got called — wire up a child_process spy
});
});

集成测试:跑 pi --mode json -p "...",把 JSON 事件流抓下来,断言事件序列。这条路 pi-subagents 项目里有 test:integration script,可以参考它的实现。

10.3 发布

1
2
3
4
5
6
# 普通 npm 流程
npm version patch # bump
npm publish --access public

# pi.dev gallery 自动从 npm 同步带 pi-package keyword 的包
# 不需要手动提交

发布之前 checklist:

  • package.jsonpi-package keyword
  • peerDependencies 写明 pi 兼容版本范围
  • files 字段包含 extensions / skills / prompts / themes / README
  • README 里有「装 / 配 / 用 / 卸」四段
  • 至少跑过一次 pi -e . 不报错
  • 至少有一个 demo gif 或 mp4

11. 未来与限制

仍在 unstable / 短期可能变的

  • ctx.mode 的取值集合(tui / rpc / json / print)—— SDK mode 是否会进来还不确定。
  • TypeBox 是 pi 选的,但 0.x 内随时可能换 schema 库(Claude Code / OpenAI 都各有偏好)。
  • Provider registration API:ExtensionContext.registerProvider(name, config) 是包装层;底层是 packages/ai/src/api-registry.tsregisterApiProvider(provider, sourceId?)Map 实现,非 class,不存在 ApiRegistry)。ApiProvider<TApi, TOptions> 接口字段是 { api, stream, streamSimple }。**ProviderConfig 这个名字在 pi-ai/src/types.ts 中并不存在——extension docs 用 ProviderConfig 是把 ApiProvider 包了一层(具体包装在 core/extensions/wrapper.ts,本次未深入)。社区扩展自定义 OpenAI 兼容 endpoint 时,目前最可靠路径仍是pi-ai 内置的 openai-completions provider 加 baseURL 配置**,而不是手写 ApiProvider。
  • disable-model-invocation 这个 skill 字段的执行细节。
  • ToolCallEventResult 的字段集(block + reason)非常窄;社区有需求要 transformInput 这种正式字段(目前靠 mutate event.input)。

已知限制

  • extension 之间没有标准化的 shared state 总线,要么 appendEntry、要么自己开 socket。
  • tool_call 不能在执行后改 result(要 tool_result 才能改)。
  • skill 的”按需加载”是模型自己决定,不是确定性触发——遇到模型偷懒不调,只能改 description 或显式 /skill:name
  • prompt template 的参数替换没有自动 escape,命令注入风险靠开发者自己防。
  • pi 的 RPC mode 是 stdio JSON,没有 WebSocket(不适合跨主机集成;要远程就上 gondolin)。

12. 不确定性(2026-06-05 两轮复核后留下的)

12.1 第一轮 5 处复核(已闭合)

5 个原始不确定点的核实结果:

# 原疑问 结论
1 ctx.getSystemPromptOptions() 真实存在,但只挂在 ExtensionCommandContext(命令 handler 拿到的超集)。事件 handler 拿不到,要看 BeforeAgentStartEvent.systemPromptOptions
2 pi pkg create / pi extension dev 确认不存在。CLI 子命令完整集合是 install / remove / uninstall / update / list / config(来自 src/package-manager-cli.tsPackageCommand 联合类型 + args.ts 的 help 文本)。
3 tool_callreplaceWith schema 不存在ToolCallEventResult 仅有 { block?, reason? }。改入参是 mutate event.input in place。改 tool 结果要在 tool_result 事件里返回 { content?, details?, isError? }
4 skill metadata 字段 ✅ docs 写明 *”Arbitrary key-value mapping”*;pi 自身只透传不读,给三方 extension 作元数据用。
5 全局 + 项目同名 package、pi config 部分关闭时的覆盖规则 ✅ 见 §7.7:dedupePackages 项目 wins、resourcePrecedenceRank 给出 5 级精确顺序、applyPatterns 处理 plain / ! / + / - 四类 filter。

12.2 第二轮 4 处补充复核(已闭合)

# 原疑问 结论
6 slash command / prompt template / skill 同名优先级常量 ⚠️ 没有显式 priority 常量core/slash-commands.ts 只定义 SlashCommandSource = "extension" | "prompt" | "skill" + 21 个内置命令固定清单(new/resume/clone/fork/tree/settings/model/scoped-models/login/logout/export/import/share/copy/session/changelog/hotkeys/reload/quit 等)。优先级靠”内置 → extension → prompt → skill /skill:“的注册顺序隐式决定,同源后注册者覆盖前者Map.set 语义)。建议永远加项目前缀避免歧义。
7 prompt template 参数替换的 sanitization 边界 ⚠️ 完全无防护core/prompt-templates.tssubstituteArgs() 是纯字符串顺序替换,只防递归不防注入parseCommandArgs() 支持 bash-style 单/双引号,按空白分词。命令注入风险全部转嫁给 template 作者。
8 registerProvider 的 ProviderConfig schema ⚠️ **API 真名是 registerApiProvider**(packages/ai/src/api-registry.ts,Map 实现)。ApiProvider 接口是 { api, stream, streamSimple }ProviderConfig 名字仅出现在 ExtensionContext.registerProvider 的包装层,未在 pi-ai/src/types.ts 中独立定义;自定义 OpenAI 兼容 endpoint 推荐走内置 openai-completions + baseURL 配置。详见 §6.4 注释与 §11。
9 context-mode 源码地址与 pi 关系 不是 pi extension!npm 包 context-moderepositorymksglu/claude-context-mode,Elastic License 2.0,是 Claude Code / Gemini CLI / Cursor / Codex CLI 的多平台 MCP 插件,README 明确未列 pi。原稿 §8.3 已重写。

12.3 仍未闭合的派生疑点

  1. ApiProvider 包装层 wrapper.tsExtensionContext.registerProvider(name, config)nameProviderConfig 转成 ApiProvider 的具体路径未抓 verbatim 源码。
  2. /skill:name 命名空间冲突:当一个 package 与一个 extension 同时注册同名 skill,/skill:name 解析到哪个,源码无明确测试。
  3. slash command 21 条内置清单的稳定性core/slash-commands.ts 内置数组在 v0.79+ 是否会扩张/重命名(用户在 prompt template / extension 里撞上同名时会被静默覆盖)。
  4. registerApiProvidersourceId 参数:用于 unregisterApiProviders(sourceId) 时按源批量清理,但 extension 调用 ctx.registerProvider 时这个 sourceId 是否自动塞为 extension name,未抓代码确认。

13. 来源

直接拉取的源码 / docs(raw URL,时点 main 分支 v0.78.x 附近,2026-06-05 抓取):

姊妹笔记(同 repo):

  • research/notes/pi-deep-dive.md
  • research/notes/pi-agent-vs-claude-code-codex.md
评论
分享