第 7 章 · 03 sdk/go 与插件开发实战 本节摘要:本节是第 7 章的收尾,也是实战篇——讲第三方如何用 (约 5.1K 行)开发 Extension Protocol v1 sidecar。SDK 包揽了协议的脏活:NDJSON over stdio 线协议、JSON-RPC 2.0 帧、握手屏障(initialize 完成 before 其他回调)、shutdown 序列、内容引用重水合;开发者只需实现 接口(必须)和可选的 InterceptorFunc / Provider / UI 回调(通过 )。
本节摘要:本节是第 7 章的收尾,也是实战篇——讲第三方如何用
sdk/go(约 5.1K 行)开发 Extension Protocol v1 sidecar。SDK 包揽了协议的脏活:NDJSON over stdio 线协议、JSON-RPC 2.0 帧、握手屏障(initialize 完成 before 其他回调)、shutdown 序列、内容引用重水合;开发者只需实现Handler接口(必须)和可选的 InterceptorFunc / Provider / UI 回调(通过Options)。本节读两个示例:examples/starterextension(最小 sidecar,只拦截input.receive,验证 manifest → sidecar → intercept 全链路)和examples/fullsidecar(完整参考,演示 input 重写、tool 拦截、system_prompt 策略替换、extension-hosted Provider、结构化 UI、干净 shutdown 全部六种贡献)。然后讲 Plugin Manifest v1 实战格式、.exe后缀的跨平台技巧、插件分发安装(reasonix plugin install --link/--replace/--yes、~/.reasonix/plugins/<name>/、plugin-packages.json)、plugin 包生命周期管理(启动/健康检查/关闭),以及internal/plugin与internal/extension的协作。读完本节,你能动手写自己的 Reasonix 扩展。
内容来源:原项目源码
sdk/go/(sdk.go、types_generated.go、wire.go、examples/starterextension/、examples/fullsidecar/)、sdk/go/README.md、docs/PLUGIN_PACKAGES.md、docs/EXTENSION_PROTOCOL.md,精读并套用体系化模板。
⚠️ 注意:本节的代码片段来自真实示例,可以照抄起步。但记住上一节的铁律:Extension 是 full trust(沙箱外、未过滤环境、能绕权限)。所以开发时用
--link(本地开发模式),发布前务必审查 manifest 的intercepts/replaces/capabilities,并理解"安装即授权、--link持续信任变化内容"。
阅读完本节,你应当能够:
input.receive 重写以 starter: 开头的输入。runtime block,理解 .exe 后缀的跨平台用意。reasonix plugin install --link/--dry-run/--replace/--yes 完成本地开发安装与远程仓库安装。~/.reasonix/plugins/<name>/(内容)+ ~/.reasonix/plugin-packages.json(状态)。internal/plugin 与 internal/extension 如何协作(都由 boot 装配,MCP 走 plugin 客户端,Extension 走 extension host)。internal/extension(host 侧,上一节)是 Reasonix 用来驱动 sidecar 的;sdk/go(sidecar 侧,本节)是第三方用来编写 sidecar 的。sdk.go 包注释把分工讲得很清楚:
// Package extension is the Go SDK for Reasonix extension sidecars speaking // Extension Protocol v1 over stdio. An extension is a separate process: the // Reasonix host launches it, sends extension/initialize first, drives // intercepts, events, provider streams, and UI calls, and finally asks it to // stop with extension/shutdown. // // The transport is strict JSON-RPC 2.0 framed as NDJSON ... The SDK owns the // wire, the handshake barrier, and the shutdown sequence; the extension // implements Handler and, optionally, interceptors, a Provider, and UI // callbacks via Options. After Initialize completes, the SDK may invoke up to // 32 callbacks concurrently; extensions must synchronize any mutable state // shared by those callbacks.
SDK 拥有(替你管)的三件脏活:
Initialize 完成 before 其他任何回调——SDK 保证不会在你的 Initialize 还没返回时就给你派 intercept/event。开发者只需实现:
Handler 接口(必须):只有一个方法 Initialize(ctx, InitializeParams) (*InitializeResult, error),返回 sidecar 的声明(订阅的事件、替换的 slot、provider、UI action)。Options):Interceptors(map[event]InterceptorFunc)、Provider(流式 Provider 实现)、UI(UIHandler:Action/Submit)、Observer(只读事件观察)、Shutdown(关闭钩子)、Logger。一个关键并发约束:Initialize 完成后,SDK 最多并发调 32 个回调。注释:"extensions must synchronize any mutable state shared by those callbacks."(extension 必须同步那些被多个回调共享的可变状态。)所以 fullsidecar 的示例里,session 上下文用 atomic.Pointer[extension.SessionContext] 存(原子指针,无锁并发安全),而不是普通字段。
sdk/go/sdk.go 的 Serve 是 sidecar 的唯一入口:
func Serve(ctx context.Context, h Handler, opts Options) error
一个 sidecar 的 main 函数模式固定:构造 Handler、配 Options、调 Serve、看错误退出。Options 结构体的关键字段(从 fullsidecar 用法归纳):
type Options struct { Name, Version string Interceptors map[string]InterceptorFunc // 事件名 → 拦截函数 Observer func(...) // 只读事件观察 Provider Provider // 流式 Provider 实现 UI UIHandler // Action/Submit 回调 Shutdown func(context.Context) // 关闭钩子 Logger *log.Logger }
五个辅助函数构造 InterceptResult(对应上一节五种决策):
func Continue() *InterceptResult // 放行 func Block(reason string) *InterceptResult // 阻断,带理由 func Replace(payload any) (*InterceptResult, error) // 替换 payload // Allow / Deny 用于 permission.decision
这些辅助函数让你在 InterceptorFunc 里一行返回决策,不用手搓 InterceptResult 结构。比如 starterextension 里:
return extension.Continue(), nil // 不匹配前缀,放行 return extension.Replace(map[string]string{ // 匹配,替换 "text": strings.TrimPrefix(input.Text, inputPrefix) + " [rewritten by starter-extension]", })
examples/starterextension 是"最小的可安装 Reasonix code extension"。它的 main.go 注释直接说明意图:
// Command starterextension is the smallest installable Reasonix code // extension. It rewrites inputs beginning with "starter: " so developers can // verify the complete manifest -> sidecar -> intercept path before adding more // capabilities.
翻译:它是最小的可安装 Reasonix code extension。它重写以 starter: 开头的输入,目的是让开发者验证完整的 manifest → sidecar → intercept 路径,然后再加更多能力。
完整 main.go(精简)就这些:
const inputPrefix = "starter: " type starter struct{} func (starter) Initialize(context.Context, extension.InitializeParams) (*extension.InitializeResult, error) { return &extension.InitializeResult{ Subscriptions: []string{"input.receive"}, }, nil } func interceptInput(_ context.Context, _ string, payload json.RawMessage) (*extension.InterceptResult, error) { var input struct { Text string `json:"text"` } if err := json.Unmarshal(payload, &input); err != nil || !strings.HasPrefix(input.Text, inputPrefix) { return extension.Continue(), nil } return extension.Replace(map[string]string{ "text": strings.TrimPrefix(input.Text, inputPrefix) + " [rewritten by starter-extension]", }) } func main() { err := extension.Serve(context.Background(), starter{}, extension.Options{ Name: "starter-extension", Version: "0.1.0", Interceptors: map[string]extension.InterceptorFunc{ "input.receive": interceptInput, }, }) if err != nil { os.Exit(1) } }
它对应的 manifest(reasonix-plugin.json):
{ "apiVersion": "reasonix.io/plugin/v1", "name": "starter-extension", "version": "0.1.0", "description": "Minimal Reasonix Extension Protocol v1 sidecar", "contributes": {}, "runtime": { "command": "${REASONIX_PLUGIN_ROOT}/bin/starter-extension.exe", "args": [], "env": {}, "required": false, "priority": 0, "intercepts": ["input.receive"], "replaces": [], "capabilities": ["interceptors"] } }
四个要点。
第一,Initialize 只订阅 input.receive。Subscriptions 必须是 manifest intercepts 的子集——这里 manifest 也是 ["input.receive"],完全一致。如果 Initialize 声明了 manifest 没有的,握手 capability_not_declared 失败。
第二,interceptInput 的逻辑:把 payload 反序列化取 text,如果以 starter: 开头,Replace 成"去掉前缀 + [rewritten by starter-extension]";否则 Continue 放行。这就是"重写用户输入"的最小演示。
第三,main 里 Serve 配 Options:Name/Version/Interceptors map(事件名 → 函数)。Serve 返回 nil 表示 host 优雅 shutdown;返回 error 退出码 1。
第四,manifest 的 .exe 后缀。command 用 ${REASONIX_PLUGIN_ROOT}/bin/starter-extension.exe——注意带 .exe。README 解释:"The .exe suffix is intentional: using one fixed runtime path keeps the manifest identical on every platform. Unix executes the binary normally, and Windows requires the executable suffix." 用一个固定的带 .exe 路径,让 manifest 在所有平台都一样:Unix 照常执行这个二进制(后缀无关),Windows 需要这个后缀。这是个很实用的跨平台技巧——避免维护三份 manifest。
starterextension 的 README 还给了完整的 build + install 命令(macOS/Linux 和 PowerShell 两版),核心是:
go build -o bin/starter-extension.exe . reasonix plugin install "$plugin_root" --dry-run # 先预览 reasonix plugin install "$plugin_root" --link --replace --yes # 链接安装
README 提醒:"Review the FULL TRUST block in the dry-run output before installing. The linked package trusts future changes in this directory and runs outside the Reasonix sandbox."(安装前审查 dry-run 输出里的 FULL TRUST 块;链接的包信任此目录的未来变化,且跑在沙箱外。)
examples/fullsidecar 是"参考 Reasonix extension sidecar":一个小程序演示每种 Extension Protocol v1 贡献类型。它的 main.go 头注释列了完整的行为地图:
// Behavior map: // input "/fs <text>" → input.receive replaces the input with // "<text> [rewritten by fullsidecar]" // tool "dangerous_exec" → tool.before blocks it with a policy reason // tool "read" → tool.before rewrites the arguments (sandbox) // system_prompt.build → the strategy slot owner wraps the prompt // session.start → publishes a status line and a card // action "demo" → asks a form prompt, greets via notification // provider plugin/<id>/fake/echo → streams a fixed completion: two text // chunks, one tool call, usage, done
六种贡献对应六种能力:(1)input 重写、(2)tool 拦截(block + rewrite 两种)、(3)system_prompt 策略替换、(4)session.start 事件观察 + UI 发布、(5)UI action 提问、(6)extension-hosted 流式 Provider。
它的 Initialize 声明比 starter 丰富得多:
func (p *plugin) Initialize(_ context.Context, params extension.InitializeParams) (*extension.InitializeResult, error) { session := params.Session p.session.Store(&session) p.log.Printf("initialized for session %s (workspace %s)", session.SessionID, session.WorkspaceRoot) return &extension.InitializeResult{ Subscriptions: []string{"input.receive", "tool.before", "system_prompt.build", "session.start"}, Replaces: []string{"system_prompt"}, Providers: []extension.ProviderDescriptor{fakeDescriptor(p.id)}, UIActions: []extension.UIActionDecl{{ActionID: "demo", Label: "Run the fullsidecar demo"}}, }, nil }
注意四个细节。
第一,session 用 atomic 存。p.session.Store(&session)——因为 SDK 最多 32 个并发回调,session 上下文要被多个回调读,用 atomic.Pointer 保证无锁并发安全。这是上一节"必须同步共享可变状态"的实战体现。
第二,声明四个订阅 + 一个 replace + 一个 provider + 一个 UI action。这些必须都是 manifest 声明的子集(fullsidecar 的 manifest 会声明更全的 intercepts/replaces/capabilities)。
第三,Provider 用 plugin/<id> 命名空间。注释:"provider refs must live in the plugin// namespace"。环境变量 REASONIX_PLUGIN_NAME 由 host 启动时设置;独立运行时默认 fullsidecar。Provider 模型以 plugin/<id>/fake/echo 出现。
第四,main 里 Serve 配齐所有 Options:
err := extension.Serve(context.Background(), p, extension.Options{ Name: id, Version: "1.0.0", Interceptors: map[string]extension.InterceptorFunc{ "input.receive": func(...) { return p.interceptInput(...) }, "tool.before": func(...) { return p.interceptTool(...) }, "system_prompt.build": func(...) { return p.interceptSystemPrompt(...) }, }, Observer: p.observe, Provider: provider, UI: extension.UIHandler{Action: p.action, Submit: p.submit}, Shutdown: func(context.Context) { logger.Print("shutdown requested; exiting") }, Logger: logger, })
六个 Options 字段全用上了。fullsidecar 是第三方写 extension 时的"复制起点"——你想用哪种能力,就去 fullsidecar 里看对应那段怎么写的。
fullsidecar 还内置了两个测试钩子(给 host↔SDK 一致性测试套件用,正常情况惰性):FULLSIDECAR_CRASH_ON_INPUT(匹配文本时 exit(3) 不应答)和 FULLSIDECAR_STALL_ON_INPUT(匹配时挂住直到 intercept context 结束)。这些是用来验证 host 对 crash 和 stall 的处理,开发自己的 extension 时不用管。
第二节讲过 Manifest v1 的格式,这里从实战角度补充。
一个带 runtime 的完整 manifest(综合 starter 和 full 思路):
{ "apiVersion": "reasonix.io/plugin/v1", "name": "my-extension", "version": "1.0.0", "description": "My custom Reasonix extension", "contributes": { "skills": ["skills"], "commands": ["commands"], "mcpServers": { "helper": { "command": "bin/helper" } } }, "runtime": { "command": "${REASONIX_PLUGIN_ROOT}/bin/my-extension.exe", "args": [], "env": { "MY_EXTENSION_MODE": "production" }, "required": true, "priority": 0, "intercepts": ["input.receive", "tool.before", "permission.decision"], "replaces": ["system_prompt"], "capabilities": ["interceptors", "strategies", "providers", "ui"] } }
实战要点。
第一,.exe 后缀跨平台。如前所述,用固定 .exe 路径,manifest 全平台一致。Unix 不在乎后缀,Windows 必须有。go build -o bin/my-extension.exe . 在所有平台都能产出这个名字。
第二,${REASONIX_PLUGIN_ROOT} 展开。command 里的这个变量在进程启动前由 host 展开,所以 command 永远是相对插件根的绝对路径,不依赖目标 shell 的环境变量语法。host 还展开 $NAME、%NAME% 形式。
第三,exec form only。command 是可执行文件,不经 shell 解释;args 是参数列表。没有 shell 意味着没有 shell 注入,也没有 shell 的通配/管道/重定向——需要这些就在 extension 内部自己处理。
第四,required 与 priority。required: true 表示这个 extension 必需——它崩溃/超时,操作直接失败(不静默降级);required: false(如 starter)则它的失败只警告跳过。priority(-1000..1000,默认 0)决定拦截顺序——多个 extension 拦截同一事件时,按 priority 升序串行。
第五,contributes 与 runtime 可共存。一个插件可以既贡献静态资源(skills/commands/mcpServers)又有 runtime sidecar。它们不冲突:静态资源由 plugin 流程安装时复制,runtime sidecar 由 extension host 启动。
第六,v1 严格解析。任何未知字段(根或 contributes/runtime 下)都是错误,点名字段路径。所以拼错字段名(比如把 intercepts 写成 intercept)会立刻失败,不会静默失效。这是 v1 相对 legacy manifest 的关键改进。
docs/PLUGIN_PACKAGES.md 详述了分发安装。两种来源:
本地目录(开发用 --link):
reasonix plugin install /path/to/plugin --link --replace --yes
--link 链接本地目录而非复制进 Reasonix 存储。开发时改完代码重新 build,新会话或 /reload 就生效。代价:移动/删除该目录会破坏链接插件;且 --link 持续信任变化内容(因为是 full trust,你 link 的就是你的代码)。
Git 仓库(发布用):
reasonix plugin install git:github.com/owner/superpowers --dry-run reasonix plugin install git:github.com/owner/superpowers --yes reasonix plugin install https://github.com/owner/repo/tree/main/path/to/plugin --yes
支持 GitHub 仓库、分支/子目录 URL。--dry-run 预览安装计划不写文件;--yes 才真写。--replace 允许替换同名插件;--name <name> 覆盖 manifest 的 name。
存储布局:
~/.reasonix/plugin-packages.json # 已安装插件状态(名字/源/根/启用) ~/.reasonix/plugins/<name>/ # 已安装插件内容
plugin-packages.json 记录"哪些插件安装了、启用没、根在哪";plugins/<name>/ 是实际内容(复制的或符号链接的)。只有通过 plugin 流程安装(记进 plugin-packages.json)的插件能启动 runtime sidecar——项目配置永远不能声明 runtime(上一节的安全模型)。
管理命令:
reasonix plugin list # 列已安装 reasonix plugin show <name> # 看一个插件的元数据/根/源/能力数 reasonix plugin doctor <name> # 检查 manifest 和 skill 根可读 reasonix plugin disable <name> # 禁用(不卸载) reasonix plugin enable <name> # 启用 reasonix plugin remove <name> --yes # 卸载(uninstall 是别名)
doctor 命令检查 manifest 和 skill 根是否可读,报告警告或诊断——开发时遇到"装上了但不工作",先跑 doctor。show 打印具体能力清单(skills 的 /<plugin>:<skill> 调用、commands、hooks 的生命周期事件/matcher/命令、mcpServers 的名字/传输/启动目标)。
会话内使用:/plugins 列已安装包,/plugins show <name> 看导出的 skills/hooks/MCP servers/用法。Skills 以 /<plugin>:<skill> 调用,commands 以 /<plugin>:<command>,hooks 自动在其生命周期事件触发,MCP servers 加入正常工具流。
internal/plugin(MCP 客户端)与 internal/extension(Extension host)由 boot 在装配时一起拉起(第 6 章 01 节讲过 boot 装配)。两者协作模型:
两者的生命周期都由 boot/Controller 管理:
closeWaitBudget = 5s / gracefulCloseWaitBudget = 750ms 控制超时。热加载:安装/启用/禁用/更新插件后,当前会话不会立即反映——需要开新会话或 /reload(空闲时)。这是因为插件变动会改变 tool schema(影响 cache-stable 前缀,第 6 章),且 Extension sidecar 的启动是有界的(30 秒预算,最多 4 个并行),不适合在 turn 中途做。所以"装新插件 → 开新会话"是推荐流程。
把本节实战要点浓缩成一个 checklist,帮你动手写第一个 Reasonix 扩展。
cmd/reasonix-plugin-example)。需要拦截事件/贡献 Provider/提供 UI?写 Extension sidecar(参考 starter/fullsidecar)。apiVersion: reasonix.io/plugin/v1、name/version、contributes(如有静态资源)、runtime(command 用 .exe 后缀、intercepts/replaces/capabilities 如实声明)。go build -o bin/<name>.exe . → reasonix plugin install "$root" --dry-run(审查 FULL TRUST 块)→ --link --replace --yes。reasonix plugin doctor <name> 检查;开新会话或 /reload 测试。Serve(ctx, Handler, Options) error;Options 含 Name/Version/Interceptors/Observer/Provider/UI/Shutdown/Logger;辅助函数 Continue/Block/Replace/Allow/Deny 构造决策。input.receive,拦截函数把 starter: 开头的输入 Replace 加后缀;manifest 用 .exe 后缀跨平台;--link 开发安装。apiVersion 选 v1;.exe 后缀让 manifest 全平台一致;${REASONIX_PLUGIN_ROOT} 展开;exec form only(无 shell 注入);required/priority 控制必需性和顺序;contributes 与 runtime 可共存;v1 严格解析(unknown field 错)。--link(开发,持续信任变化)、Git 仓库 git:github.com/...(发布);--dry-run 预览、--yes 写、--replace 替换、--name 覆盖;存储 ~/.reasonix/plugin-packages.json(状态)+ ~/.reasonix/plugins/<name>/(内容);只有 plugin flow 能启 runtime。/plugins、/plugins show;skills 以 /<plugin>:<skill>、commands 以 /<plugin>:<command> 调用。/reload(因插件变动影响 tool schema/前缀)。.exe/子集声明)→ 实现 Handler+Options(共享状态同步)→ build+dry-run+link → doctor+新会话验证 → 发布前审查 full trust。至此第 7 章结束,Reasonix 的两层扩展体系讲完。后续章节将进入三前端(Controller 如何被终端 TUI/HTTP-SSE/Wails 桌面共享驱动)以及其他主题。回顾全书到此:第 5 章讲 agent 内核(Session/harness loop/permission/checkpoint),第 6 章(高潮)讲 prefix-cache 友好的上下文维护(Cache-first 契约/ride the turn tail/compact-snip-prune),第 7 章讲两层扩展(MCP/Extension Protocol v1)。这三章合起来,就是 Reasonix 作为 AI coding agent 的"运行时内核 + 性能灵魂 + 扩展生态"。