Skip to content
检测中

扩展与技能加载机制:Harness 怎么长出新能力

没有扩展机制的 harness 是死的——能力只能改源码。扩展机制让 harness 成可生长底盘:不重编译就长出新工具、新命令、新知识。本篇拆 extension(带代码)与 skill(带知识)两种形态、加载时序、冲突治理、热重载。


目录


引言:harness 不改源码也能长能力

harness 出厂只有内置原语:read/write/edit/bash、固定系统提示、一套会话模型。能力边界被源码焊死。想加「危险命令拦截器」「外部 API 检索」「团队代码规范」都得改 harness 本体——每家用一份 fork,升级 rebase 到吐。

扩展机制打破焊缝:把「能力」从「源码」解耦,变成可独立加载、可分发、可热替换的模块。内核只留 loop、上下文、工具调度,其余外包给扩展与技能。这层抽象沿两轴展开——轴一带不带代码(执行逻辑 vs 注入知识),轴二加载时机(启动全量 vs 运行时按需),交叉落出两种形态:

  • extension:带代码的可执行模块——注册工具、命令、钩子、上下文提供器,启动时加载进进程。
  • skill:带知识的轻量文件——一份 SKILL.md,描述常驻、正文按需读。

6.1 extension vs skill:两种能力形态

定义

extension:跑在 harness 进程内的代码模块,经注册接口把新能力挂进 loop。带逻辑、带状态、带副作用——能拦工具、发 HTTP、开子进程。

skill:一份遵循 Agent Skills 标准SKILL.md,只装知识(步骤、规范、脚本引用),靠「被模型 read 进上下文」生效。

为何要分两种

两种本质不同的需求:「harness 原生不会做」需代码执行(调 API、开进程、改文件)→ extension;「模型会做但不知规矩」需注入知识(规范、流程)→ skill。混进一种都踩坑:extension 装大段文本是杀鸡用牛刀(常驻内存、改字要 reload);skill 描述执行逻辑是空中楼阁(Markdown 拦不了工具、发不了请求,模型不照做能力就消失)。判别:这能力需不需要 harness 主动参与?→extension 还是 skill。

对比表

维度extension(带代码)skill(带知识)
形态TS 模块(index.ts),经 jiti 加载SKILL.md + frontmatter
加载成本启动全量加载进进程;常驻frontmatter 常驻;正文按需 read
生效方式注册工具/命令/钩子,主动参与 loop注入描述→模型按需读正文→照做
失败影响崩溃可能拖垮主进程最坏知识缺失,不致崩
副作用有(拦工具、发请求、改文件)无(纯文本,靠模型执行)
适用场景拦截器、外部集成、自定义工具、UI规范、流程、领域 SOP、操作指南
分发npm 包 / git / 本地文件目录 / 仓库 / 多 harness 共享

一句话:extension 改「能做什么」,skill 改「怎么做」。

怎么做(pi)

~/.pi/agent/
├── extensions/
│   └── danger-gate.ts        # extension:拦 rm -rf
└── skills/
    └── deploy-sop/
        └── SKILL.md          # skill:部署操作规范

pi 启动扫描两目录,分别走 extension 加载与 skill 注入(见 6.4)。

反模式

  • extension 装大段规范文本:启动开销、改字要重载,不如 skill 轻。
  • skill 描述需副作用的能力:「这 skill 会拦 bash」——Markdown 拦不了,必须落成 extension。

6.2 extension 的四类接口

定义

概念接口作用pi 实现
registerTool注册 LLM 可调用的工具pi.registerTool(definition)
registerCommand注册用户可调用的斜杠命令pi.registerCommand(name, { handler })
promptHook在系统提示/对话流上挂钩子pi.on("before_agent_start", ...)
contextProvider向上下文注入信息pi.on("context", ...) / pi.on("resources_discover", ...)

映射要点:registerTool/registerCommand 是显式方法;promptHook/contextProvider概念分类——pi 无同名方法,用事件系统pi.on)实现。before_agent_start 充当 prompt hook(改系统提示、注入消息);context(每轮改消息)与 resources_discover(贡献 skill/prompt/theme 路径)充当 context provider。四类本质是「extension 能挂的四个挂载点」。

为何是这四类

四类恰好覆盖 agent loop 全部干预面,每个咽喉点对应一类:

用户输入 ──> [registerCommand] 斜杠命令拦截
              │(非命令)

         [promptHook] before_agent_start:改系统提示/注入消息


       ┌─ LLM 推理 ──┐
       │  [contextProvider] context:每轮改 messages
       └────工具调用──┘


         [registerTool] 自定义工具执行

少任一类,extension 就在 loop 某段够不着(缺 registerTool 模型调不到新能力,缺 contextProvider 没法每轮动态注入,以此类推)。

怎么做(pi)

四挂载点各一例:

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

export default function (pi: ExtensionAPI) {
  // ① registerTool:LLM 可调工具
  pi.registerTool({
    name: "fetch_doc",
    label: "Fetch Doc",
    description: "拉取内部文档系统某篇文档",
    promptSnippet: "Fetch internal docs by id",
    parameters: Type.Object({ id: Type.String() }),
    async execute(_id, params, _s, _u, _ctx) {
      const text = await fetch(`https://docs.local/${params.id}`).then(r => r.text());
      return { content: [{ type: "text", text }], details: {} };
    },
  });

  // ② registerCommand:用户斜杠命令
  pi.registerCommand("sweep", {
    description: "扫当前目录 TODO",
    handler: async (_args, ctx) => { ctx.ui.notify("Sweeping...", "info"); /* ... */ },
  });

  // ③ promptHook:before_agent_start 改系统提示
  pi.on("before_agent_start", async (event, _ctx) => {
    return { systemPrompt: event.systemPrompt + "\n\n本仓库强制 4 空格缩进。" };
  });

  // ④ contextProvider:resources_discover 贡献路径
  pi.on("resources_discover", async (_e, _ctx) => ({
    skillPaths: ["/team/shared-skills"],
  }));
}

关键特性:registerTool 运行时也能调用——在 session_start、命令、事件里新工具立即生效pi.getAllTools(),无需 /reload,支撑「动态工具集」。

反模式

  • context hook 里做阻塞 IOcontext 每轮都跑,一次阻塞全 loop 卡死。重活异步化。
  • promptHook 注入超大文本:每轮系统提示膨胀、token 被吃光。静态规范走 skill 按需 read。
  • registerTool 不写 promptSnippet/description:模型不知何时该用,注册了等于没注册。
  • 状态塞模块级变量:reload 拆旧 runtime,模块级状态失效。持久化走 pi.appendEntry

6.3 skill 的两级加载

skill 精妙不在内容,在加载策略——用两级加载把「常驻成本」压到极低。

定义

skill 采用 progressive disclosure(渐进披露):第一级(常驻)启动只提取每个 skill 的 name+description,拼成 <available_skills> XML 注入系统提示;第二级(按需)任务命中时模型用 read 把完整 SKILL.md 正文读进上下文。

启动:扫 skills/ → 提 frontmatter → 注系统提示:
    <available_skills>
      <skill name="deploy-sop" description="生产部署规范..."/>
      <skill name="db-migrate" description="数据库迁移步骤..."/>
    </available_skills>
  → 仅名+描述常驻,正文 0 token

运行(命中):用户"帮我部署" → 模型 read deploy-sop/SKILL.md → 正文进上下文 → 照步骤执行

为何要两级

若所有 skill 正文一次性塞系统提示,三后果:token 爆炸(N 个 × 几百行撑爆窗口);注意力稀释(模型在无关正文里找任务);启动慢(全量读盘+注入)。两级加载用「目录+摘要常驻,正文懒加载」解决——该调才付读全文的代价,把预算支配权交还模型。这是 skill 区别于「静态 AGENTS.md」的关键:后者全量常驻,skill 按需展开。

怎么做(pi)

skill 目录只放一个 SKILL.md,frontmatter 两字段必填:

markdown
---
name: deploy-sop
description: 生产环境部署规范。用于发版、回滚、灰度切换。涉及 k8s 与 CDN。
---

# Deploy SOP

## 前置检查
```bash
./scripts/preflight.sh
```

## 发版步骤
1. 打 tag → 2. 触发 CI → 3. 等镜像就绪 → 4. kubectl apply
字段必填规则
name≤64 字符,小写字母/数字/连字符,无连续连字符
description≤1024 字符,写清「做什么 + 何时用」
disable-model-invocationtrue 时隐藏于系统提示,只能 /skill:name 强制
allowed-tools预批准工具列表(实验性)

description 是命脉:决定模型何时加载该 skill。帮助部署 太虚模型不知何时调;生产环境部署规范,用于发版/回滚/灰度,涉及 k8s 与 CDN 才让「帮我部署」精准命中。写法:列触发场景(匹配任务词)、点技术栈边界(避免同类误调)、别写「怎么做」(正文活)。

强制加载(模型有时不主动 read):

bash
/skill:deploy-sop              # 加载并执行
/skill:db-migrate add-column   # 带参数加载

/skill:name 绕过「模型判该不该读」直接喂正文,调试常用。

反模式

  • 正文太长:SKILL.md 写成万字,一 read 吃半窗口。长内容拆 references/,正文只放主干 + 相对链接。
  • description 写成标题:「Deploy SOP」≠ 何时用,必须写场景。
  • 靠模型主动 read 不验证:模型不总读,关键 skill 在系统提示点名或 /skill: 强制。

6.4 加载时序:扫描→注册→注入

extension 与 skill 加载发生在启动不同阶段,理解时序才能定位「能力没生效」。

定义

启动加载四步:

pi 启动

  ① 扫描(SCAN)
  │   ~/.pi/agent/extensions/ + .pi/extensions/  → extension 列表
  │   ~/.pi/agent/skills/    + .pi/skills/       → skill 列表

  ② 信任裁决(TRUST)   ← 仅 project-local 资源需过 project_trust

  ③ 注册(REGISTER)
  │   extension:jiti 加载 TS → 执行 factory → registerTool/Command/on(...)
  │   skill:提取 frontmatter,存入 available_skills 目录

  ④ 注入(INJECT)
  │   available_skills XML 拼进系统提示;extension 工具进 selectedTools

session_start → resources_discover(extension 可再贡献 skill/prompt 路径)

loop 就绪

为何要分阶段

关键区分:extension 启动时全量加载,skill 正文运行时按需 read。extension 代码须 loop 跑起来前就位——钩子注册晚了前几轮拦不到,工具没注册模型看不见。skill 正文绝不能启动加载——正文常驻等于退化成 AGENTS.md。

project_trust 是安全闸门:项目级 .pi/extensions.agents/skills项目被信任前不加载,只有全局 extension 与 CLI -e extension 参与 trust 决策——没它,克隆仓库就能执行任意代码。

怎么做(pi)

诊断「能力没生效」按四步排查:

症状排查点
命令/工具不出现扫描目录对不对;项目是否 trusted;factory 有没有抛错
skill 在目录里但模型不调description 是否命中场景;是否被 disable-model-invocation 隐藏
工具注册了但 LLM 不调promptSnippet/description 写没写;setActiveTools 有没有禁用
修改后没生效extension 是否需 /reload;skill 正文 read 重读即可

反模式

  • factory 顶层阻塞初始化:factory 返回 Promise 时 pi 会 await,阻塞整个启动。重活挪进 session_start 或后台。
  • 注册命令撞内置:覆盖 /help 之类,用户行为突变(见 6.6)。

6.5 进程内 vs 进程外

extension 还有维度:代码跑哪——主进程内还是外部进程,决定隔离性、性能、故障半径。

定义

形态代码位置典型实现
进程内与 loop 同进程,共享内存/ctxpi 默认:jiti 加载 TS 进主进程
进程外独立进程/服务,经 IPC 桥接MCP server / HTTP 服务 / 子进程工具
进程内 extension:
  ┌──────── 主进程 ─────────┐
  │  loop  ↔  extension.ts  │   零 IPC,共享 ctx/ui/sessionManager
  └─────────────────────────┘

进程外能力源:
  ┌──── 主进程 ────┐        ┌──── 外部进程 ────┐
  │ loop ↔ bridge  │ ─IPC─> │ MCP server / 子进程│
  │  (extension)   │ <────  │ (任意语言/降权)    │
  └────────────────┘        └───────────────────┘

为何要选:权衡四要素

内核是故障半径:这能力的 bug 能不能拖垮整个 harness?拦截器、上下文改写必须进程内(要用主进程 ctx/sessionManager/UI,进程外够不着),代价是 bug 直接打 loop 脸、handler 须包 try/catch。调外部不可信 API、跑用户脚本、跨语言库则该进程外——崩了不拖垮 harness,且可降权、跨语言。

维度进程内进程外
性能零 IPC,最快有序列化/IPC 开销
隔离性差:崩了拖垮主 loop好:外部崩了主 loop 不死
权限= 用户全权可降权(外部进程单独身份)
部署一个文件,jiti 直跑要起服务/管子进程,复杂
跨语言仅 TS/JS任意
状态直接共享主进程内存必须序列化传递

怎么做(pi):选型决策

text
要拦工具调用 / 改系统提示 / 操控 UI / 读 sessionManager?
  → 必须 进程内(这些 API 只在主进程 ctx 上)

要调外部服务 / 跑危险命令 / 用非 JS 库 / 跨 harness 复用?
  → 进程外:extension 在进程内当桥接层,转发到外部进程

pi 的 extension 文件本身是进程内 TS 模块(jiti 加载)。「进程外能力」两种接入:一是进程内 extension 用 child_process/fetch 桥接外部服务/子进程,主 loop 受保护;二是 MCP/RPC 工具源,能力以独立 server 暴露,harness 当 client 接入,多 harness 共用、语言无关。

何时上进程外:能力不可信、需降权、要跨语言、或崩了不能拖垮主 loop。 进程内是默认,进程外是「故障半径不能覆盖 harness 本身」时才上——IPC 与部署复杂度是实打实成本。

反模式

  • 不可信库直接 import 进进程内:库崩了拖垮 loop。不可信逻辑进程外化。
  • 进程内不做错误隔离:handler 抛未捕获异常息火整个会话。外部调用包 try/catch。
  • 进程外不做超时:外部进程挂住,工具调用永远等。设超时 + 心跳。
  • 能进程内的硬上进程外:简单工具也起 MCP server,部署与延迟都不值。

6.6 冲突治理

能力越多冲突越多。两 extension 注册同名工具、两 skill 重名、命令撞内置——治理不好就是「能力互相打架」。

定义 + 为何必须

冲突类型触发不治理后果
工具名冲突两 extension 注册同名工具后者覆盖前者 / 调用歧义
命令冲突/cmd 撞内置或撞别家行为突变,用户困惑
skill 重名两 skill 同 name加载歧义,描述打架

根因:扁平命名空间下名字是唯一协调点,多源加载(全局+项目+包)几乎必然撞名——这是机制使然。治理不是「避免撞」,而是给名字加结构,让冲突从「偶发 bug」变「可预测的覆盖规则」。

怎么做(pi + 通用治理)

冲突pi 行为治理对策
skill 重名warn,保留第一个找到的加载顺序决定;团队用命名空间前缀(team-
工具/命令重名按加载顺序注册,后到者覆盖强制 scope 前缀team_fetch 而非 fetch
撞内置命令自定义优先不覆盖高频内置(/help//reload),改名

工程层硬规矩:

  1. 命名空间:自定义工具/命令带 scope 前缀(org_team-),从源头避撞——唯一能预防而非事后发现冲突的手段。
  2. 加载顺序显式化:global → project → CLI -e/--skill,后者覆盖前者。诊断冲突先查加载链。
  3. 工具启用集pi.setActiveTools() 动态开关,冲突时按场景裁剪启用集。
  4. skill 目录隔离:团队 skill 与个人 skill 分目录,name 自带归属前缀。
  5. 冲突可观测:启动打印已加载能力清单与覆盖关系,warn 别淹没在日志。

核心思想:把冲突从隐式 bug 变成显式规则,任一环节缺失就变难复现的神秘行为。

typescript
// 命名空间前缀:从源头防撞
pi.registerTool({ name: "acme_fetch_doc", /* ... */ });
pi.registerCommand("acme-sweep", { /* ... */ });

反模式

  • 裸名注册registerTool({ name: "search" }) 必撞——几十个扩展都想要 search
  • 依赖加载顺序解决冲突:顺序是隐式的,换机器/换用户就变。靠命名空间,不靠顺序。
  • skill 重名只 warn 不修:warn 在日志一闪而过,实际是「想要的 skill 没加载」的隐形 bug。

6.7 热重载与版本管理

开发扩展/skill 最痛是「改一行要重启」。热重载决定迭代速度,版本管理决定可分发性。

定义

  • 热重载:不重启进程,让改动(extension 代码 / skill 正文 / 模板)立即生效。
  • 版本管理:扩展/技能作为可分发单元,版本化、依赖化、跨项目复用。

怎么做(pi)

放置决定能否热重载:extension 放自动发现目录(~/.pi/agent/extensions/.pi/extensions/)才支持 /reloadpi -e ./path.ts 只适合一次性测试,不支持热重载。

bash
/reload   # 重载 extensions + skills + prompts + themes

时序(理解它才能 debug):

/reload
  → session_shutdown(旧 runtime 拆除)
  → 重新扫描资源
  → session_start { reason: "reload" }
  → resources_discover { reason: "reload" }
  → 新版 extension/skill 就位

/reload当前命令处理函数仍在旧 call frame 里——reload 后那行代码用旧版本。可靠写法把 reload 当命令终点:await ctx.reload(); return;

registerTool 即时生效:动态注册工具不需 reload,调用即进 getAllTools()。区分:改扩展源码是「文件级」变动要 reload;代码里 pi.registerTool() 是「运行时」注册即时生效,两者混淆是调试常见坑。

版本与分发:扩展作为 npm 包或 git 仓库分发,settings.json 声明:

json
{
  "packages": ["npm:@acme/[email protected]", "git:github.com/acme/skills@v3"],
  "extensions": ["./local/extension.ts"],
  "skills": ["../shared-skills"]
}

skill 因遵循 Agent Skills 标准,可跨 harness 复用——同一份 ~/.agents/skills/ 给 pi、Claude Code、Codex 共用,零迁移。

何时 reload vs 何时重启

改动生效方式
extension TS 代码/reload(在自动发现目录)
skill 的 SKILL.md 正文模型重新 read 即新值,无需 reload
skill 的 frontmatter/reload(要重扫目录、重注 XML)
extension 的 npm 依赖npm install 后重启(reload 不重装依赖)
进程外服务代码重启该服务,harness 侧无需动

反模式

  • pi -e 做长期开发:不支持 reload,每次重启,迭代慢。挪进自动发现目录。
  • reload 后复用旧闭包状态:reload 拆了旧 runtime,闭包连接/句柄已废,重建。
  • skill 正文改了不验证:模型可能 read 过旧版(同 session 缓存)——开新 session 或 /skill: 重读。

6.8 反模式

加载机制典型翻车,逐条给对策:

反模式症状对策
skill 堆积装几十个 skill,启动慢、描述干扰、命中率反降常驻只有目录(轻);无用 skill 移出目录或 disable-model-invocation
extension 侧效应未声明extension 偷偷发请求/改文件,用户与模型都不知情工具 description 写明副作用;危险操作走 tool_call 拦截确认
能力碎片化无目录装一堆 extension/skill,没人知道有哪些、何时用维护能力清单;description 统一写场景;团队建命名空间
reload 依赖未重装改依赖没重启,旧依赖仍生效改依赖必重启进程

最该警惕skill 堆积 + 能力碎片化:装得越多命中率反降——能力不是越多越好,是「能被正确选中」才算有效。没有目录治理、命名混乱的扩展库,比裸 harness 还糟:给了能力幻觉,却让模型无从调用。


迁移清单

把 pi 的扩展/技能加载机制映射到其他 harness:

机制piClaude CodeCursorAider通用 harness
extension(带代码)TS 模块 + jiti,~/.pi/agent/extensions/hooks / extension / plugin.cursor/rules + MCP无原生可执行模块 + 注册接口
skill(带知识)SKILL.md + Agent Skills 标准skills(同标准)/ .claude/skillsrules(知识形态)无原生Markdown + frontmatter
registerToolpi.registerTool()工具定义 / MCP 工具MCP 工具内置工具集工具注册 API
registerCommandpi.registerCommand()slash commandslash command/command命令注册 API
promptHookpi.on("before_agent_start")system prompt / hook.cursorrulessystem prompt提示钩子事件
contextProviderpi.on("context") / resources_discoverMCP resourcecontext indexrepo map上下文注入接口
skill 两级加载frontmatter 常驻 + 按需 read同标准 progressive disclosurerules 全量/按需摘要常驻 + 正文懒加载
热重载/reload(自动发现目录)重启 / 部分热更重启重启文件监听 + 重注册
冲突治理skill 重名 warn 保留首个;工具按序覆盖命名空间命名空间scope 前缀 + 加载顺序
分发npm/git 包 + settings.jsonskills 仓库rules 仓库无原生包管理 + 配置声明

迁移要点:能力越弱的 harness(Aider、老版 Cursor),越要把「知识」外置成 .md 规范、「代码能力」外置成 MCP 工具或脚本——用 harness 不认的格式装能力等于没装。Agent Skills 标准是跨 harness 复用 skill 的最大杠杆:同一份技能目录多家用,零迁移。


下一步

本文是 Harness Engineering 第 6 篇 / 共 10 篇。前一篇:子代理调度容器(subagent = 进程、调度四象限、acceptance 契约、worktree 隔离)——子代理本身也是一种「可加载的能力源」,读完更能理解能力如何被编排。

extension 与 skill 解决了「能力从哪来、怎么挂进来」,但还有一种能力形态跨在两者之间:跨会话的记忆。它既非一次性注入的知识(skill),也非带副作用的代码(extension),而是 harness 对自身历史的持久化读写——下一篇拆解它。

下一篇:7. 记忆与持久化状态 ——记忆层级、跨会话状态、记忆污染治理。第 7 篇 / 共 10 篇。


参考资料

Released under the ISC License.