# 06 扩展系统学习记录

## 本节目标

本节是进阶章节。第一遍只想理解 agent 核心主线的人，可以先跳到 07 或回头复习 03/04/05；本节目标是理解 Pi 如何把外部能力装进 agent：skills 如何按需进入上下文，extensions 如何注册工具、命令和生命周期 hook，以及这些能力最后如何接到 `AgentSession` 和 agent loop。

重点问题：

- Skill 和 Extension 的本质区别是什么。
- 为什么 skill 不是 tool。
- `/skill:name` 如何从短命令变成完整 prompt block。
- extension loader 如何发现并执行扩展模块。
- `registerTool()`、`registerCommand()`、`on()` 分别写到哪里。
- `ExtensionRunner` 如何 bind 到 `AgentSession`。
- extension tool 如何进入 `agent.state.tools`。
- extension hook 如何接进工具调用、上下文转换和 provider 请求。
- `resources_discover` 为什么只返回路径，而不是直接返回资源对象。

## 我们怎么学的

1. 先看 `packages/coding-agent/src/core/skills.ts`，理解 skill 的发现、校验和 system prompt 目录注入。
2. 看 `AgentSession._expandSkillCommand()`，理解 `/skill:name` 如何显式展开。
3. 看 `packages/coding-agent/src/core/extensions/loader.ts`，理解 extension 的发现、加载和 `pi` API。
4. 看 `packages/coding-agent/src/core/extensions/runner.ts`，理解 runtime bind、ctx、command、hook 和 stale 检查。
5. 回到 `AgentSession._refreshToolRegistry()`，串起 extension tool 进入 agent tool registry 的路径。
6. 对照 `packages/agent/src/agent-loop.ts`，看 `tool_call`、`tool_result` 等 hook 实际插入的位置。
7. 最后看 `resources_discover` 和 `ResourceLoader.extendResources()`，理解扩展包如何贡献 skills、prompts、themes。

## 总体图

```text
ResourceLoader
  -> 加载 skills / prompts / extensions

Extension loader
  -> 执行 extension factory
  -> 收集 registerTool / registerCommand / on(...)

ExtensionRunner
  -> bind 到 AgentSession
  -> 提供运行时 ctx
  -> 解析 extension commands
  -> emit lifecycle hooks
  -> 暴露 registered tools

AgentSession
  -> _refreshToolRegistry()
  -> setActiveToolsByName()
  -> _rebuildSystemPrompt()
  -> prompt() 里处理 command / input hook / skill-template expansion

Agent loop
  -> transformContext
  -> before_provider_request
  -> beforeToolCall
  -> afterToolCall
```

一句话：

```text
Skill 是“教模型怎么做”；
Extension 是“给 Pi 增加本地能力、工具和生命周期插槽”。
```

Mermaid 源码：

```mermaid
flowchart TD
  R[ResourceLoader] --> L[Extension loader]
  L --> D[register declarations]
  D --> Runner[ExtensionRunner]
  Runner --> S[AgentSession]
  S --> T[tool registry]
  S --> P[prompt/input handling]
  S --> H[hooks]
  T --> A[agent.state.tools]
  H --> Loop[agent loop]
```

## 五种能力的最短定义

```text
skill
= 教模型怎么做

command
= 用户直接触发的本地控制入口

tool
= 模型可以调用的本地动作

hook
= 插入生命周期的拦截点

resource
= 扩展包贡献的静态资源路径
```

这些能力都能来自扩展系统，但边界不同：

- skill/template 改 prompt。
- command 先于 prompt 展开，直接执行本地 handler。
- tool 进入 agent loop，由模型 tool call 触发。
- hook 插入现有生命周期，可以观察、改写或 block。
- resource 回到 `ResourceLoader`，由统一资源管线读取和校验。

## Skill：最轻量的能力注入

Skill 是一份带 frontmatter 的 Markdown 文件。Pi 只需要从它里面提取：

```text
name
description
filePath
baseDir
sourceInfo
disableModelInvocation
```

对应类型：

```ts
interface Skill {
  name: string;
  description: string;
  filePath: string;
  baseDir: string;
  sourceInfo: SourceInfo;
  disableModelInvocation: boolean;
}
```

Skill 的发现位置主要有：

```text
全局：~/.pi/agent/skills
项目：.pi/skills
显式路径：settings / CLI 传入的 skillPaths
```

`loadSkillsFromDirInternal()` 的发现规则：

```text
如果目录里有 SKILL.md
  -> 这个目录就是 skill root，不继续递归

否则
  -> 加载 root 下直接的 .md 文件
  -> 递归子目录寻找 SKILL.md
```

`loadSkillFromFile()` 做的事：

```text
readFileSync(filePath)
-> parseFrontmatter()
-> validateName()
-> validateDescription()
-> 生成 Skill 对象
```

Pi 会校验：

```text
name: 小写字母 / 数字 / hyphen，不能太长，不能首尾 hyphen，不能连续 hyphen
description: 必须存在，不能太长
```

缺 description 的 skill 不会加载；其他校验问题通常变成 diagnostics。

## formatSkillsForPrompt()

`formatSkillsForPrompt()` 不会把所有 `SKILL.md` 正文塞进 system prompt。

它只放：

```text
name
description
location
```

形态大概是：

```xml
<available_skills>
  <skill>
    <name>docs-sync</name>
    <description>Keep documentation in sync...</description>
    <location>/.../docs-sync/SKILL.md</location>
  </skill>
</available_skills>
```

我们总结的原因是：

```text
渐进式披露
= 先暴露目录，命中任务时再 read 具体 SKILL.md

省上下文
= skill 可能很多，正文可能很长，全部注入会污染 system prompt
```

这也是为什么 skill 是“按需能力”，不是每次都展开的全量规则。

`disable-model-invocation` 也和这里有关：

```text
disable-model-invocation: true
```

会让这个 skill 不出现在 `formatSkillsForPrompt()` 的可见列表里，只能通过 `/skill:name` 显式调用。

## 为什么 skill 不是 tool

Tool 是模型可调用的执行接口：

```text
name
description
parameters
prepareArguments
executionMode
execute
```

Skill 没有 `execute()`，也没有参数 schema。它不能被模型 tool call。

所以：

```text
tool
= 模型可以调用的动作

skill
= 模型可以读取的指导文档
```

skill 影响的是“模型怎么想、怎么做”，不是直接执行本地动作。

## /skill:name 显式展开

`/skill:name` 出现在 slash command 列表里，但它不是 extension command。

它是 prompt 预处理。

流程：

```text
/skill:docs-sync 更新 README
-> AgentSession.prompt()
-> _expandSkillCommand()
-> readFileSync(skill.filePath)
-> stripFrontmatter()
-> 包成 <skill ...>...</skill>
-> 拼上用户 args
-> 作为普通 user message 进入 agent.prompt()
```

模型最终看到的不是：

```text
/skill:docs-sync 更新 README
```

而是：

```xml
<skill name="docs-sync" location="/.../SKILL.md">
References are relative to /.../docs-sync.

这里是 SKILL.md 正文
</skill>

更新 README
```

## prompt() 里的顺序

`AgentSession.prompt()` 的关键顺序：

```text
raw input
-> extension command 有机会接管
-> input hook 可拦截/改写
-> skill/template 展开
-> agent.prompt()
```

为什么 extension command 要先处理：

```text
extension command 是本地控制入口，必须先拿到原始 slash 命令；
skill/template 只是 prompt 文本展开，应该在确认没人接管后再做。
```

extension command 可能根本不需要模型，例如：

```text
打开 UI
切 session
设置状态
newSession / fork / reload
```

而 `/skill:name` 和 prompt template 的目标是生成更完整的用户消息，最终仍然交给模型。

这也解释了为什么 `steer()` / `followUp()` 里会禁止 extension command：运行中的插队消息只能排进 agent loop，不能直接执行本地 command 控制流。

## Extension loader：发现并执行扩展模块

extension 的发现入口在 `packages/coding-agent/src/core/extensions/loader.ts`。

主线：

```text
discoverAndLoadExtensions()
-> loadExtensions()
-> loadExtension()
-> loadExtensionModule()
-> factory(api)
```

发现位置：

```text
项目扩展：.pi/extensions/
全局扩展：~/.pi/agent/extensions/
显式配置：configuredPaths
```

目录支持：

```text
extensions/foo.ts
extensions/foo.js
extensions/foo/index.ts
extensions/foo/index.js
extensions/foo/package.json 里的 pi.extensions
```

Pi 用 `jiti` 加载 TS/JS：

```text
jiti.import(path, { default: true })
```

extension 默认导出必须是 factory function：

```ts
export default async function(pi) {
  pi.registerTool(...)
  pi.registerCommand(...)
  pi.on("message_end", ...)
}
```

加载时：

```text
const extension = createExtension(...)
const api = createExtensionAPI(extension, runtime, cwd, eventBus)
await factory(api)
```

## ExtensionAPI：注册 API 和动作 API

`createExtensionAPI()` 里有两类 API。

第一类是注册 API，加载时安全：

```text
pi.on()
pi.registerTool()
pi.registerCommand()
pi.registerShortcut()
pi.registerFlag()
pi.registerMessageRenderer()
```

这些只是写入当前 `Extension` 对象：

```text
extension.handlers
extension.tools
extension.commands
extension.shortcuts
extension.flags
extension.messageRenderers
```

第二类是动作 API，依赖运行时：

```text
pi.sendMessage()
pi.sendUserMessage()
pi.appendEntry()
pi.setSessionName()
pi.setActiveTools()
pi.setModel()
pi.exec()
```

load 阶段不能直接触发 agent 行为。

我们总结为：

```text
load 阶段只允许注册能力，不能触发 agent 行为。

registerTool()
= 登记 metadata

sendMessage()
= 运行时动作，必须等 runner bind 完并进入事件/命令上下文后才能调用
```

所以 `createExtensionRuntime()` 先给动作 API 放 throwing stubs：

```text
Extension runtime not initialized.
```

等 `AgentSession` 调 `runner.bindCore(...)` 后，stub 才会被替换成真实动作。

特殊情况是 `registerProvider()`：

```text
load 阶段先进 pendingProviderRegistrations
bindCore() 后 flush 到 modelRegistry
```

因为 provider 注册也是声明性质，但需要 registry 准备好。

## ExtensionRunner：运行时胶水

Loader 负责收集扩展声明，Runner 负责把声明变成运行时资源。

`ExtensionRunner` 主要做四件事：

```text
bindCore()
  -> 把 runtime stub 换成真实 AgentSession 动作

getAllRegisteredTools() / getRegisteredCommands()
  -> 汇总 extension 注册的资源

createContext() / createCommandContext()
  -> 给事件、工具、命令提供运行时 ctx

emitXxx()
  -> 按事件类型派发 extension hooks
```

`AgentSession._bindExtensionCore()` 会把真实动作接进去：

```text
sendMessage        -> AgentSession.sendCustomMessage()
sendUserMessage    -> AgentSession.sendUserMessage()
appendEntry        -> sessionManager.appendCustomEntry()
setActiveTools     -> setActiveToolsByName()
refreshTools       -> _refreshToolRegistry()
setModel           -> setModel()
compact            -> compact()
```

## 为什么 ctx 用 getter + stale 检查

`createContext()` 返回的 ctx 不是静态快照。

它使用 getter：

```text
ctx.model
ctx.signal
ctx.sessionManager
ctx.ui
```

每次访问时都从 runner 当前状态取值，并先 `assertActive()`。

我们总结的原因：

```text
extension ctx 不是“静态配置”，而是运行时入口。

getter
= 每次使用取最新 runtime 状态

stale 检查
= reload / fork / switch 后，旧 ctx 继续用会直接报错
```

如果创建普通对象快照，会有两个风险：

```text
值不新：
fork / switchSession / reload 后，ctx 还指向旧 session、旧 signal、旧 UI

拦不住旧 ctx：
旧 ctx 继续 appendEntry / setActiveTools / sendMessage，可能写到错误 session 或污染新运行
```

所以 Pi 做了两层保护：

```text
getter
= 保持读取最新状态

assertActive / invalidate
= 防止旧 ctx 误操作
```

## extension tool 如何进入 agent.state.tools

extension tool 的完整链路：

```text
extension factory
-> pi.registerTool(toolDefinition)
-> extension.tools.set(name, { definition, sourceInfo })
-> runner.getAllRegisteredTools()
-> AgentSession._refreshToolRegistry()
-> wrapRegisteredTools()
-> _toolRegistry
-> setActiveToolsByName()
-> agent.state.tools
```

extension 注册的仍然是 `ToolDefinition`，不是 `AgentTool`。

`wrapper.ts` 做适配：

```text
wrapToolDefinition(registeredTool.definition, () => runner.createContext())
```

所以 extension tool 执行时拿到的是运行时 ctx，不是 load 阶段的 api。

`_refreshToolRegistry()` 会统一处理：

```text
builtin tools
extension registered tools
SDK custom tools
allowed/excluded tool filter
sourceInfo
promptSnippet
promptGuidelines
ToolDefinition -> AgentTool wrap
active tool names
system prompt rebuild
```

工具启用/禁用不只是改：

```text
agent.state.tools
```

还要重建：

```text
agent.state.systemPrompt
```

因为 tool 的 `promptSnippet` 和 `promptGuidelines` 也会影响模型行为。

我们对 `refreshTools()` 的理解：

```text
工具不是单个数组状态。

Pi 维护：
- definition registry
- runtime wrapped registry
- active tools
- system prompt snippets/guidelines
- source metadata

registerTool() 只登记定义；
_refreshToolRegistry() 统一重算工具注册表、active tools、metadata 和 system prompt。
```

直接 push 到 `agent.state.tools` 会绕过一致性逻辑。

## extension hook 如何接进 agent loop

hook 接入有两条主线。

### 工具调用 hook

`AgentSession._installAgentToolHooks()`：

```text
this.agent.beforeToolCall = runner.emitToolCall(...)
this.agent.afterToolCall = runner.emitToolResult(...)
```

agent loop 中的顺序：

```text
prepareToolCallArguments()
validateToolArguments()
beforeToolCall()
tool.execute()
afterToolCall()
createToolResultMessage()
回填 context
```

所以：

```text
tool_call hook
= 看到校验过的 args，可以 block

tool_result hook
= 看到执行后的结果，可以改 content/details/isError/terminate
```

为什么 `tool_call` hook 放在参数校验后：

```text
hook 通常要做权限、策略、审计、拦截；
这些判断需要可信的结构化参数。
```

如果模型刚开始流式吐 tool call 时就触发，参数可能还没完整。

如果 tool call 完成但还没校验就触发，hook 需要自己处理类型错误、缺字段和非法结构，职责会混乱。

### 模型请求 hook

SDK 创建 Agent 时会接入：

```text
transformContext -> runner.emitContext(messages)
onPayload        -> runner.emitBeforeProviderRequest(payload)
```

agent loop 调模型前：

```text
transformContext(messages)
-> convertToLlm(messages)
-> build Context
-> streamFn(model, llmContext, options)
-> provider onPayload
```

边界：

```text
context hook
= 改 AgentMessage[]，发生在转 LLM message 前

before_provider_request hook
= 改 provider payload，发生在 provider 适配之后、真正请求前
```

`context` 更偏 Pi 内部语义，可以改 user/assistant/toolResult 消息。

`before_provider_request` 更偏具体 provider API payload，已经是 OpenAI/Anthropic/Google 各自的请求形态。

## 哪些 hook 能改东西

常见 hook 可以分成几类：

```text
观察类
= 只看事件，不改变主流程

改写类
= 返回新内容，改变消息、上下文或 payload

拦截类
= block / cancel 某个动作
```

我们重点看过：

```text
message_end
= 可以返回同 role message，改最终持久化/上下文消息

tool_call
= 可以 block 工具调用

tool_result
= 可以改 content/details/isError/terminate

context
= 可以改发给模型的 AgentMessage[]

before_provider_request
= 可以改 provider payload

before_agent_start
= 可以追加 message 或改 systemPrompt

session_before_*
= 可以 cancel
```

## resources_discover：扩展资源索引

`resources_discover` 不是注册工具或命令。

它让 extension 在启动或 reload 后返回额外资源路径：

```ts
{
  skillPaths?: string[];
  promptPaths?: string[];
  themePaths?: string[];
}
```

触发流程：

```text
session_start 后 / reload 后
-> runner.emitResourcesDiscover(cwd, reason)
-> 收集 skillPaths / promptPaths / themePaths
-> resourceLoader.extendResources(...)
-> updateSkillsFromPaths / updatePromptsFromPaths / updateThemesFromPaths
-> _rebuildSystemPrompt()
```

它适合一个 extension 包里附带：

```text
skills/
prompts/
themes/
```

extension 负责告诉 Pi：

```text
我这里还有这些资源，请一起加载。
```

为什么返回路径，而不是直接返回 `Skill` 或 `Prompt` 对象：

```text
resources_discover 只负责“告诉 Pi 去哪里找资源”；
资源怎么读、怎么校验、怎么去重、怎么进 system prompt，由 ResourceLoader 统一处理。
```

如果让 extension 直接返回对象，就会绕过：

```text
SKILL.md frontmatter 校验
name / description 规则
路径解析和 baseDir
sourceInfo 标注
冲突诊断
ignore 规则
prompt/template 解析
system prompt 重建
```

所以 `resources_discover` 是“扩展资源索引”，不是“资源对象注入”。

## 06 能力对照表

| 能力 | 本质 | 入口 | 最后进入哪里 |
| --- | --- | --- | --- |
| `skill` | 指导文档 | `SKILL.md` / `/skill:name` | system prompt 目录，或 user message 的 `<skill>` block |
| prompt template | prompt 文本模板 | `/template-name` | user message |
| extension command | 本地控制命令 | `pi.registerCommand()` | 立即执行 command handler |
| extension tool | 模型可调用工具 | `pi.registerTool()` | `_toolRegistry -> agent.state.tools` |
| extension hook | 生命周期拦截/观察 | `pi.on(event, handler)` | agent/session/provider 流程中的事件点 |
| resource / resources_discover | 扩展资源索引 | `pi.on("resources_discover")` | ResourceLoader 继续加载 skills/prompts/themes |

核心区别：

```text
skill/template
= 改 prompt 文本

command
= 本地立即执行，不一定进模型

tool
= 给模型调用，进入 agent loop

hook
= 插入现有生命周期，可能观察，也可能改写/block

resources_discover
= 让扩展包贡献更多静态资源
```

## 常见误解

- skill 不是 tool。skill 没有参数 schema 和 `execute()`，它只能影响模型如何理解任务。
- command 不是 prompt template。command 是本地控制入口，会先于 skill/template 展开被处理。
- `registerTool()` 不等于直接改 `agent.state.tools`。中间还要经过 `_refreshToolRegistry()`、wrap、过滤、active tools、system prompt rebuild。
- hook 不是只能观察。部分 hook 可以改写 message/context/provider payload，也可以 block 工具或 cancel session 行为。
- `resources_discover` 不是资源对象注入。它只返回路径，资源读取、校验、去重和 sourceInfo 仍由 `ResourceLoader` 负责。

## 自测题

- 为什么 skill 不是 tool？
- extension command 为什么先于 prompt 展开？
- extension tool 如何进入 `agent.state.tools`？
- hook 能改哪些东西，哪些只是观察？
- 为什么资源发现必须回到 `ResourceLoader`？

## 本节完成度

本节已经完成：

- 理解 skill 的渐进式披露和省上下文设计。
- 理解 `/skill:name` 是 prompt 展开，不是代码执行。
- 理解 extension loader 如何发现和执行 TypeScript/JavaScript 扩展。
- 理解 registration API 和 runtime action API 的区别。
- 理解 `ExtensionRunner` 的 bind、ctx、stale 检查和 event emit。
- 理解 extension tool 如何进入 `_toolRegistry` 和 `agent.state.tools`。
- 理解 tool hooks、context hook、provider payload hook 的边界。
- 理解 `resources_discover` 如何把扩展包资源接回 ResourceLoader。

下一节建议进入 SDK/RPC：

```text
06 看 Pi 如何扩展自己的能力；
07 看 Pi 如何把同一套 agent 能力暴露给其他进程或应用。
```
