30 · Extension API
Stage Hello Pi · v30-extension
第二十九章,我们把 src/ 从平铺重构成了「有边界的 Core」:Model / Runtime / Context / Tool / Event / Session 六件套住进 src/core/,其余能力留在外面,core/index.ts 是唯一公共出口。边界有了,demo 也证明了「只用 Core 也能跑」。
但收工的时候,作为一个勤于思考的老同学,心里还是会有点疑问的:
边界是画出来了,可它是一堵「只进不出」的墙。 这堵墙挡住了「坏东西」,可也挡住了「好东西」——下一章说「Skill 要长在外面」,它长在哪?怎么挂上去?Core 之外有没有一个「插座」,让新能力能插进来?
这一章,我们就给这堵墙开一个口子——Extension API:一个 name + setup(ctx) 的极简接口,加上装它的插座(ExtensionRegistry)。这是 Pi 思想的第二块拼图:Minimal Core + Extension First。接下来进入正题。
一、上一版存在什么问题?
一般来讲,墙立起来了,要是没有门,那墙就只能看、不能用。ch29 立了原则,但也留下三个具体问题:
- 「长在 Core 之外」只是一句口号:ch29 说 Skill、权限、TUI 以后都要长在 Core 外面——但外面没有「挂点」。工具还好说,直接
registry.register(...);可提示词、Skill、Hook 这些不是 Tool 的东西,往哪挂? - 新能力必须改
cli/index.ts:加一个工具,改一行createAgent;加一个提示词,改一行SYSTEM_PROMPT;加一个 Hook,改一行AgentRuntime——每加一个能力都要动产品壳,这正是 ch29 想消灭的「加能力动骨架」; - 「外圈」只是一个位置,不是一个机制:
tools/、workspace/、session/、providers/平铺在 Core 之外,彼此没有契约——我们有的是「一堆目录」,缺的是「一种统一的能力形态」。
一句话:ch29 立起了「核心边界」,但还没打开「能力入口」。 Core 外面没有插座,一切能力依然只能「写死」在代码里。说白了就是个只进不出的死胡同 😂
二、本篇解决什么问题?
问题已经清晰,既然是外圈只有位置没有机制,那怎么让「能力外置」真的落地呢?接下来看下这一章的具体实现措施,一共就四件事:
- 定义扩展的统一形态:
Extension = name + setup(ctx)——任何能力(工具、Hook、提示词、Skill……)都以这个形态存在; - 打开插座:新增
ExtensionRegistry,负责校验、安装、登记扩展——「装扩展」和「改代码」从此是两件事; - 立起「扩展身份」:
ExtensionContext给每个扩展一个名字、一个说话的地方(log)——扩展既知道自己是谁,又能被 harness 观察到; - 把插座装进产品:CLI 增加
hello --extensions打印已安装扩展清单,并立起hello-coding这个「占位扩展」——它此刻只有身份,工具和 prompt 将在 ch31 / ch33 迁入。
核心心智模型:
Core 是墙,Extension 是墙上的门,
ExtensionRegistry是门框。 门框负责「门怎么安、安几扇、门牌号不能重」;门本身(扩展)只回答两个问题:你叫什么名字,装好后你要做什么。
这一章把线串一下:上一版「外圈只是位置、没有挂点、加能力必改壳」这些遗留问题 → 这一章用「Extension 形态 + ExtensionRegistry 插座」解决 → 接下来看一个扩展怎么从定义到安装、再到被 harness 点名道姓。
三、先看最终效果
先跑 demo(无需 API Key,下面是标准的使用姿势):
$ node --import tsx examples/stage-4/30-extension-api/demo.mts输出结果如下:
=== 30 · Extension API:让能力在 Core 之外生长 ===
=== 1. 编写扩展(defineExtension) ===
已定义 2 个扩展:hello-hello、status(每个都只有 name + setup)
=== 2. 安装到 ExtensionRegistry ===
[ext:hello-hello] 你好,我是 hello-hello,已挂载到 harness
[ext:status] status:扩展名「status」安装成功,setup 已执行一次
install() 完成:2 个扩展进入 active,setup 各执行恰好一次
=== 3. 扩展清单(manifest) ===
hello-hello@0.1.0 (active) — 打招呼:安装时向 harness 报到
status@0.2.0 (active) — 报告一次安装信息
=== 4. 边界校验:install() 的三道闸门 ===
install(status) 重复安装 → 拒绝:扩展 status 已注册
install({ name: "" }) 空名 → 拒绝:扩展名不能为空
install(helloHello) 重复安装 → 拒绝:扩展 hello-hello 已注册
清单仍然只有 2 个扩展——被拒绝的 install 不留下任何痕迹。注意两个信息(重点关注这两点):
- setup 恰好执行一次:
[ext:hello-hello]、[ext:status]各出现一次——安装即执行、且只执行一次; - 安装是受控的:重名、空名、重复安装全部被
install()拒绝,且被拒绝的安装不留下任何痕迹。
再看产品壳——CLI 现在能回答「这个 harness 装了哪些扩展」(实测结果如下):
$ hello --extensionsWorkspace: ...
已安装扩展(manifest):
hello-coding@0.3.0 (active) — Coding Agent 本体:工具四件套(read/write/edit/bash)+ 方法论 prompt。本章只立身份,工具与 prompt 分别于 ch31 / ch33 迁入。这就是这一章的兑现:「能力可以在 Core 之外生长」第一次有了字面的、可运行的形式。 扩展长什么样、怎么装、装了什么,全部可观察。然后就可以愉快的接着玩了。
四、架构变化
这一章的架构变化:新增一个「扩展层」,它住在 Core 之外、且只依赖 Core。
src/
core/ ← Core:六件套(ch29 收紧)
extensions/ ← 新增:扩展层
extension.ts ← 扩展契约:Extension / ExtensionContext / defineExtension
registry.ts ← 插座:ExtensionRegistry(校验 · 安装 · 登记)
index.ts ← 扩展层的「名片」,镜像 core/index.ts
providers/ ← Model 的具体实现(ch29)
tools/ ← 具体工具(尚未迁入扩展,ch31)
workspace/ session/ cli/ ← 其余外圈依赖方向是关键(请注意这一条,是这一章的含金量所在):
extensions ──→ core(只 import core/errors,用 RuntimeError 报错)
core ──↗ 不看 extensions 一眼Core 对扩展一无所知——这就是边界的含金量:Core 不 import 扩展,扩展反过来 import Core。将来删掉整个 extensions/ 目录,Core 一行都不用改(最基本的,手动加强语气,删外圈不动骨架)。
归属表:
| 新文件 | 归属 | 一句话理由 |
|---|---|---|
extensions/extension.ts | 外圈 | 扩展的「形态」:name + setup(ctx)——能力外置的载体 |
extensions/registry.ts | 外圈 | 装扩展的插座:校验、安装、登记——它是机制,不是核心抽象 |
extensions/index.ts | 外圈 | 扩展层的公共出口,沿用 core/index.ts 的「名片」习惯 |
为什么
ExtensionRegistry不放进src/core/?因为「扩展」这个名字本身就是「核心之外」的意思。 把它放进 Core,等于在 Core 里开了一扇通往自己的门——自相矛盾。放在外圈,它还顺手证明了「外圈能力可以 import Core,而 Core 依然『外圈未知』」。骚操作谈不上,但这步棋走得很稳。
五、核心抽象
这一章的核心抽象只有一个:Extension 契约。它由三块拼成(接下来逐一拆开看)。
1. 扩展的形态:Extension
interface Extension {
name: string; // 门牌号:全局唯一,安装时校验
version?: string; // 版本:先声明,后验证(ch38 Package 再收紧)
description?: string; // 一句话说明:进 manifest,给人看
setup(ctx: ExtensionContext): void; // 安装时恰好执行一次
}名字是身份,setup 是行为。 一个扩展是什么、能干什么,看这两样就够。
2. 扩展的视野:ExtensionContext
interface ExtensionContext {
readonly name: string; // 我是谁(安装时注入)
log(message: string): void; // 我说话的地方(转发到 harness 的观察出口)
}ch30 的 ctx 只有两个成员——身份 + 观察口。这不是抠门,是刻意的节奏:
ExtensionContext每章长大一点。 ch30 只给身份和 log;ch31 给它塞进ctx.tools(注册 Tool);ch32 给它塞进 Hook 挂点;ch33 再给它 prompt。每章只加一个能力,读者跟得住,Core 也守得住。
3. 装的插座:ExtensionRegistry
class ExtensionRegistry {
install(extension: Extension): void; // 三道闸门:非空 · 唯一 · setup 恰好一次
get(name: string): Extension | undefined;
list(): InstalledExtension[]; // manifest:name / version / description / status
}三道闸门是「受控可变更」的最小版本——先写进 install(),将来 ch37 的 Permission Gate、ch66 的 Harness Mutation 都是它的亲戚。
六、实现代码
先交代一句(不赘述大体结构):这一章「新代码」只有 3 个小文件 + 几行 CLI 接线,逻辑全部是「校验 + 登记」,没有任何魔法。
src/extensions/extension.ts(完整)
下面给出完整实现:
export interface ExtensionContext {
readonly name: string;
log(message: string): void;
}
export interface Extension {
name: string;
version?: string;
description?: string;
setup(ctx: ExtensionContext): void;
}
export function defineExtension(extension: Extension): Extension {
return extension;
}defineExtension 是纯「类型友好」的糖:它什么都不做,只是让作者在写扩展时有完整的类型推导(ch31 的 export default defineExtension({...}) 就是靠它)。先教学后抽象——等扩展变成独立包(ch38),它才需要真正干活。
src/extensions/registry.ts(完整)
import { RuntimeError } from "../core/errors/errors";
import type { Extension } from "./extension";
export interface InstalledExtension {
name: string;
version?: string;
description?: string;
status: "active";
}
export interface ExtensionRegistryOptions {
log?: (name: string, message: string) => void;
}
export class ExtensionRegistry {
private readonly extensions = new Map<string, Extension>();
private readonly log: (name: string, message: string) => void;
constructor(options: ExtensionRegistryOptions = {}) {
this.log = options.log ?? ((name, message) => console.log(`[ext:${name}] ${message}`));
}
install(extension: Extension): void {
const name = extension.name;
if (typeof name !== "string" || name.trim() === "") {
throw new RuntimeError("扩展名不能为空");
}
if (this.extensions.has(name)) {
throw new RuntimeError(`扩展 ${name} 已注册`);
}
extension.setup({ name, log: (message) => this.log(name, message) });
this.extensions.set(name, extension);
}
get(name: string): Extension | undefined {
return this.extensions.get(name);
}
list(): InstalledExtension[] {
return [...this.extensions.values()].map((extension) => ({
name: extension.name,
version: extension.version,
description: extension.description,
status: "active",
}));
}
}三个细节值得点名(重点关注这三点):
- 错误用
RuntimeError:扩展装错了,是 harness 组装期的错误,不是模型错误、也不是工具错误——kind: "runtime"语义最贴; log可注入:默认打到 console,CLI 里可以传 no-op 让扩展安静安装——观察出口可配置,而不是写死;- 顺序敏感:先校验、再 setup、最后登记。如果 setup 抛异常,扩展不会进入
extensionsMap——半装的扩展不存在。
CLI 接线(src/cli/index.ts)
createAgent 里多建一个 ExtensionRegistry,装一个占位扩展 hello-coding:
const helloCoding = defineExtension({
name: "hello-coding",
version: "0.3.0",
description: "Coding Agent 本体:工具四件套(read/write/edit/bash)+ 方法论 prompt。本章只立身份,工具与 prompt 分别于 ch31 / ch33 迁入。",
setup() {},
});然后加一个 --extensions 分支,打印 manifest:
if (args.extensions) {
console.log("已安装扩展(manifest):");
for (const ext of extensions.list()) {
console.log(` ${ext.name}@${ext.version ?? "-"} (${ext.status}) — ${ext.description ?? ""}`);
}
return;
}为什么 hello-coding 的 setup 是空的? 因为 ch30 的 ctx 还只有身份和 log——工具注册(ch31)和 prompt(ch33)就是从这个「空壳」长出来的。先立门框,再安门。这就是最实在的接入姿势:不一步到位,但每步都站得住。
七、运行 Demo
跑法一:扩展生命周期演示(本章主角,无需 API Key):
$ node --import tsx examples/stage-4/30-extension-api/demo.mts即第三节那四段输出:编写 → 安装 → manifest → 边界校验。
跑法二:看产品壳装了哪些扩展:
$ node --import tsx src/cli/index.ts --extensions跑法三:老姿势全保留——Coding Agent 一条都不破(需要 .env):
$ pnpm dev -- "帮我修复这个项目"
$ pnpm dev -- --chat扩展层是「只加不改」:工具注册、会话、resume 的代码一行没动,hello 还是那个 hello。
复测验证的小伙伴,重点观察以下关键证据(最基本的,手动加强语气,这几条建议逐条核一遍):
| 观察点 | 期望 | 实测 |
|---|---|---|
| setup 恰好一次 | 每个扩展的 log 只出现一次 | [ext:hello-hello] / [ext:status] 各一次 ✓ |
| manifest | 2 个 active 扩展,带版本与描述 | hello-hello@0.1.0 / status@0.2.0 ✓ |
| 重名拒绝 | 抛出 RuntimeError,清单不变 | 扩展 status 已注册 ✓ |
| 空名拒绝 | 抛出 RuntimeError | 扩展名不能为空 ✓ |
| 老 CLI 不破 | pnpm typecheck 零报错 | ✓ |
| 产品可见 | --extensions 打印清单 | hello-coding@0.3.0 (active) ✓ |
八、新架构解决了什么?
- 「能力外置」从口号变成机制:任何能力都以
Extension形态存在、通过ExtensionRegistry安装——不再需要为每个能力改cli/index.ts; - Core 的边界得到正反馈:
extensions/import Core、Core 不认识 extensions——边界不是画出来自我安慰的,是能被依赖方向证明的; - 受控安装落地:三道闸门(非空 / 唯一 / setup 一次)是最小的「受控变更」——乱装的扩展根本进不来;
- 可观察性提前到位:
ctx.log让扩展的安装过程可见,--extensions让已装清单可见——扩展不是黑盒,是可以点名道姓的; - 产品有了「能力清单」:
hello --extensions是 manifest 的第一个消费者——将来 Skill、Prompt、Theme 都能登记成扩展,一份清单看全家。
九、它又引入了什么问题?
接下来再泼盆冷水,看看这个插座还留了哪些坑:
ctx太空,setup 无事可做:ch30 的扩展只能报个到、喊个话——真正的价值(注册工具、挂 Hook)要等 ctx 长大,这就是 ch31 / ch32 的由来;setup是同步的:装一个扩展要等它await点什么(比如读文件、建索引)就办不到——异步安装要么等 ch32 的 Hook 机制、要么等 ch38 的独立包;- 扩展没有卸载 / 禁用:装进去就是
active,没有disable/uninstall——生命周期只有一半; - 错误只用一个
RuntimeError:装错、装重、装挂全用一个 kind——报错够用,但不够分诊,等扩展能做更多事,也许需要更细的错误语义; - 加载还是「写死在代码里」:
createAgent里install(helloCoding)是硬编码——扩展还不能从磁盘发现、加载,那是 ch35 Skill Loader / ch38 Package 的活。
十、下一章
插座有了,门框立好了——但空壳扩展除了报到,什么都干不了。小伙伴自然会问:扩展到底能往 harness 里塞什么?
下一章,我们让 ctx 长大第一个能力:ctx.tools——用扩展注册 Tool。我们会把 ch30 留的 hello-coding 空壳真正填起来,让 read / write / edit / bash 从「写死在 createAgent」变成「由扩展登记」——能力外置的第一块真骨头,Pi 思想的第三块拼图。
Minimal Core + Extension First:这一章开了门,下一章把工具搬出门外。
上面这些就是 Extension API 的基本使用姿势了,有啥用、怎么接着玩 ctx.tools,留在下一篇逐一展开。
尽信书则不如,以上内容纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现 bug 或者有更好的建议,欢迎批评指正,不吝感激 🙃
欢迎点赞、关注公众号「一灰灰Blog」 我们下章见
微信公众号: 一灰灰Blog