10-Plugin 系统与 Skill 发现:扩展边界与上下文注入
简要概括:Plugin用PluginHost隔离API与三重保护,Skill四层发现加缓存,SkillGuidance注入prompt,Registry类型化上下文源。
对应问题 Q10:packages/core/src/plugin.ts和packages/opencode/src/plugin/实现的插件系统,以及core/src/skill.ts、opencode/src/skill/的 Skill 发现机制。涵盖 PluginHost 隔离 API、Plugin.add 三重保护、Plugin.wait 同步、SkillGuidance 注入、SystemContextRegistry 类型化上下文源。 勘察依据:core/src/plugin.ts、core/src/plugin/host.ts、core/src/plugin/internal.ts、core/src/plugin/skill.ts、core/src/effect/keyed-mutex.ts、core/src/system-context/index.ts、core/src/system-context/registry.ts、core/src/system-context/builtins.ts、core/src/instruction-context.ts、core/src/skill.ts、core/src/skill/guidance.ts、core/src/skill/discovery.ts、core/src/session/context-epoch.ts、opencode/src/plugin/index.ts、opencode/src/plugin/loader.ts、opencode/src/plugin/shared.ts、opencode/src/skill/index.ts、opencode/src/skill/discovery.ts、opencode/src/session/system.ts、packages/plugin/src/v2/effect/context.ts、packages/plugin/src/v2/effect/skill.ts。
一、全景:双轨插件架构
OpenCode 存在 V1 插件系统 和 V2 插件系统两套并行架构,与 V1/V2 主循环的迁移路径一致。
bash
┌──────────────────────────────────────┐
│ Plugin 架构全景 │
└──────────────────────────────────────┘
│
┌────────────────────┴────────────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────────┐
│ V1 Plugin (旧) │ │ V2 Plugin (新) │
│ opencode/src/ │ │ core/src/plugin/ │
│ plugin/ │ │ │
└───────┬─────────┘ └──────────┬───────────┘
│ │
┌─────────────┼────────────┐ ┌───────────────┼──────────────┐
▼ ▼ ▼ ▼ ▼ ▼
┌───────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────┐
│ index │ │ loader │ │ shared │ │ host.ts │ │ internal.ts │ │ skill │
│ .ts │ │ .ts │ │ .ts │ │ PluginHost│ │ define()+12 │ │ .ts │
│ │ │ resolve │ │ npm/file│ │ .make() │ │ 内置插件 │ │ Embedded│
│ Hooks │ │ +load │ │ source │ │ │ │ │ │ Source │
│ trigger│ │ external│ │ │ │ │ │ │ │ │
└───────┘ └──────────┘ └─────────┘ └──────────┘ └──────────────┘ └─────────┘
│ │
▼ ▼
┌──────────────┐ ┌─────────────────────────────────────┐
│ 内置 Auth │ │ PluginHost.make(service) │
│ 插件 ×10 │ │ agent / aisdk / catalog / command │
│ (Copilot, │ │ integration / plugin / reference │
│ Codex, │ │ / skill │
│ Azure...) │ └─────────────────────────────────────┘
└──────────────┘ │
▼
┌──────────────────┐
│ plugin.add(id, │
│ effect) │
│ 三重保护: │
│ KeyedMutex │
│ loading Set │
│ Scope.fork │
└──────────────────┘
V1 插件 (opencode/src/plugin/index.ts):基于 Hooks 接口,插件通过 server(input, options) 函数返回一个 Hooks 对象,包含 event、config、dispose 等回调。通过 plugin.trigger(name, input, output) 统一触发。
V2 插件 (core/src/plugin.ts + core/src/plugin/):基于 Effect-TS 的 PluginContext 接口,插件通过 effect(ctx: PluginContext) => Effect<void> 函数注册 Agent / Command / Provider / Skill / Reference 等。通过 PluginHost.make(service) 提供隔离的 API。
二、PluginHost.make:隔离的扩展 API 接口
2.1 PluginContext 契约定义
插件能看到的 API 边界由 PluginContext 接口定义,位于独立的 @opencode-ai/plugin 包中:
ts
// packages/plugin/src/v2/effect/context.ts
export interface PluginContext {
readonly options: PluginOptions
readonly agent: AgentHooks & Reload
readonly aisdk: AISDKHooks
readonly catalog: CatalogHooks & Reload
readonly command: CommandHooks & Reload
readonly integration: IntegrationHooks & Reload
readonly plugin: PluginDomain
readonly reference: ReferenceHooks & Reload
readonly skill: SkillHooks & Reload
}
每个字段是一个 Hooks<T> 模式,提供 reload() 和 transform(callback) 两组能力。transform 允许插件拿到一个 draft 对象,对其做读/写/删操作,然后批量提交。
2.2 PluginHost.make 的实现
ts
// 20:219:opencode/packages/core/src/plugin/host.ts
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const integration = yield* Integration.Service
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
return {
options: {},
agent: {
reload: agents.reload,
transform: (callback) =>
agents.transform((draft) =>
callback({
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(AgentV2.ID.make(id))),
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
remove: (id) => draft.remove(AgentV2.ID.make(id)),
}),
),
},
// ... aisdk / catalog / command / integration / plugin / reference / skill
} satisfies Interface
})
2.3 七个隔离 API 域
PluginHost.make 返回的对象提供七个隔离的 API 域,每个域封装了底层服务,用 DeepMutable 类型擦除和 ID.make() 转换做隔离边界:
| API 域 | 提供的能力 | 底层服务 | Draft 接口 |
|---|---|---|---|
agent |
reload + transform |
AgentV2.Service |
list/get/default/update/remove |
aisdk |
sdk + language 钩子 |
AISDK.Service |
拦截模型创建和语言选择 |
catalog |
reload + transform |
Catalog.Service |
provider 和 model 的 CRUD |
command |
reload + transform |
CommandV2.Service |
命令注册修改 |
integration |
reload + connection + transform |
Integration.Service |
集成方法、OAuth 授权流 |
plugin |
add + remove |
PluginV2.Interface |
递归插件管理 |
reference |
reload + transform |
Reference.Service |
项目引用的增删查 |
skill |
reload + transform |
SkillV2.Service |
Skill 源的添加和列表 |
隔离设计要点:
- 类型边界 :
mutable<T>(value: T) => value as DeepMutable<T>--- 插件拿到的是可变 draft 副本,不直接持有内部不可变引用 - ID 转换 :
AgentV2.ID.make(id)/ProviderV2.ID.make(id)--- 插件传 string ID,内部自动转为 branded ID 类型 plugin.add/remove递归:插件可以注册子插件,子插件共享同一 PluginHost 的隔离 APIintegration域最复杂 :支持 OAuth 授权流,authorize回调返回的 credential 会被自动包装为Credential.OAuth.make(),同时支持refresh令牌刷新
ts
// 193:196:opencode/packages/core/src/plugin/host.ts
plugin: {
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
},
插件通过 ctx.plugin.add({ id, effect }) 注册子插件,子插件的 effect 函数接收相同的 PluginContext,形成递归扩展树。
三、Plugin.add:三重保护与热重载
3.1 三重保护机制
Plugin.add 是插件加载的核心入口,通过三重保护确保并发安全、无循环依赖、生命周期可控:
ts
// 43:83:opencode/packages/core/src/plugin.ts
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) {
// ── 保护 1: loading Set 防循环依赖 ──
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
yield* locks.withLock(id)( // ── 保护 2: KeyedMutex 防并发 ──
Effect.sync(() => {
loading.add(id) // 标记正在加载
failures.delete(id) // 清除旧的失败记录
}).pipe(
Effect.andThen(
State.batch(
Effect.gen(function* () {
// ── 保护 3: Scope.fork 管理生命周期 ──
const existing = active.get(id)
active.delete(id)
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
const child = yield* Scope.fork(scope)
yield* effect(host).pipe(
Scope.provide(child),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
)
yield* events.publish(Event.Added, { id })
active.set(id, child)
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
discard: true,
})
waiters.delete(id)
}),
),
),
Effect.onExit((exit) => {
if (Exit.isSuccess(exit)) return Effect.void
failures.set(id, exit) // 失败时记录 exit
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
discard: true,
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
}),
Effect.ensuring(Effect.sync(() => loading.delete(id))), // 清除 loading 标记
),
)
})
3.2 保护 1:KeyedMutex --- 按 ID 串行化
ts
// 20:42:opencode/packages/core/src/effect/keyed-mutex.ts
export const makeUnsafe = <Key>(): KeyedMutex<Key> => {
const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
const withLock =
(key: Key) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const current = locks.get(key)
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
if (!current) locks.set(key, entry)
entry.users++
return entry.semaphore.withPermit(effect).pipe(
Effect.ensuring(
Effect.sync(() => {
entry.users--
if (entry.users === 0) locks.delete(key) // 引用归零时删除
}),
),
)
})
return { size: Effect.sync(() => locks.size), withLock }
}
KeyedMutex 的关键设计:
- 同 key → 排队 :相同 plugin ID 的
add/remove/wait操作互斥串行执行 - 不同 key → 独立执行:不同 plugin ID 的操作可并发运行
- 引用计数清理 :
users计数 holders + waiters,归零时删除 Map 条目,防止内存泄漏 makeUnsafe:不依赖 Effect Scope,直接创建 --- 适合在 Layer 初始化中使用
3.3 保护 2:loading Set --- 循环依赖检测
ts
// 44:44:opencode/packages/core/src/plugin.ts
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
- 检测时机 :在获取锁之前检查,避免死锁(如果 A 等 B 的锁,B 又开始加载 A)
- 清除时机 :
Effect.ensuring(Effect.sync(() => loading.delete(id)))--- 无论成功还是失败,都清除标记 Effect.die而非Effect.fail:循环依赖是程序错误(defect),不是可恢复的失败
循环依赖场景 :插件 A 的 effect(ctx) 中调用 ctx.plugin.add({ id: "A", effect }) --- 此时 A 还在 loading 中,会立即 die。
3.4 保护 3:Scope.fork --- 生命周期与热重载
ts
// 58:65:opencode/packages/core/src/plugin.ts
const child = yield* Scope.fork(scope)
yield* effect(host).pipe(
Scope.provide(child),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
)
yield* events.publish(Event.Added, { id })
active.set(id, child)
Scope 生命周期管理:
Scope.fork(scope):从父 scope fork 出一个子 scope,子 scope 中的所有Effect.acquireRelease/Effect.addFinalizer资源都会在这个子 scope 关闭时释放Scope.provide(child):把子 scope 提供给插件的effect(host),插件内部获取的所有资源都在这个 scope 中管理Effect.onExit:插件 effect 失败时,关闭子 scope(释放所有资源);成功时不关闭(资源保持活跃)
热重载时的旧 scope 清理:
ts
// 54:56:opencode/packages/core/src/plugin.ts
const existing = active.get(id) // 1. 取出旧 scope
active.delete(id) // 2. 从 active map 中移除
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) // 3. 关闭旧 scope
热重载流程(同一个 plugin ID 再次调用 add):
bash
plugin.add("my-plugin", newEffect)
│
├── [1] loading.has("my-plugin")? → 否(上次已清除)
│
├── [2] locks.withLock("my-plugin"):
│ ├── loading.add("my-plugin")
│ │
│ ├── [3] existing = active.get("my-plugin") ← 取出旧 scope
│ ├── active.delete("my-plugin") ← 从 map 移除
│ ├── Scope.close(existing, Exit.void) ← 关闭旧 scope
│ │ └── 释放旧插件的所有 finalizer:
│ │ ├── 取消事件订阅
│ │ ├── 关闭 HTTP 连接
│ │ ├── 清理定时器
│ │ └── dispose 旧资源
│ │
│ ├── [4] child = Scope.fork(scope) ← fork 新子 scope
│ ├── newEffect(host).pipe(Scope.provide(child))
│ │ └── 新插件注册新资源到 child scope
│ │
│ ├── [5] events.publish(Event.Added, { id: "my-plugin" })
│ ├── active.set("my-plugin", child) ← 存入新 scope
│ │
│ └── [6] loading.delete("my-plugin") ← 清除 loading 标记
│
└── 完成:旧资源已清理,新插件已激活
关键不变量 :Scope.close(existing, Exit.void) 使用 Exit.void(成功退出),确保旧插件的 finalizer 以正常方式清理而非中断方式。Effect.ignore 忽略清理过程中的错误,不阻塞新插件加载。
3.5 Plugin.remove --- 卸载与清理
ts
// 85:98:opencode/packages/core/src/plugin.ts
const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)
yield* locks.withLock(id)(
State.batch(
Effect.gen(function* () {
const current = active.get(id)
active.delete(id)
failures.delete(id)
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
}),
),
)
})
remove 的清理路径与 add 的热重载路径一致:取 scope → 删 map → 关 scope。额外清除 failures 记录,确保后续 wait 不会返回旧的失败。
四、Plugin.wait:依赖同步
4.1 问题场景
某些插件的初始化依赖另一个插件先完成。例如:插件 B 注册了 Agent "reviewer",插件 C 需要等 B 完成后才能修改 "reviewer"。Plugin.wait(id) 提供同步原语。
4.2 实现解析
ts
// 100:126:opencode/packages/core/src/plugin.ts
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
const waiter = yield* Deferred.make<void>()
const pending = yield* locks.withLock(id)(
Effect.sync(() => {
if (active.has(id)) return false // 已加载,无需等待
const failure = failures.get(id)
if (failure) return failure // 之前失败了,返回失败
const current = waiters.get(id) ?? new Set()
current.add(waiter) // 注册等待者
waiters.set(id, current)
return true // 需要等待
}),
)
if (!pending) return // 已加载,直接返回
if (typeof pending !== "boolean") return yield* pending // 之前失败,传播失败
yield* Deferred.await(waiter).pipe( // 阻塞等待
Effect.ensuring(
locks.withLock(id)(
Effect.sync(() => {
const current = waiters.get(id)
current?.delete(waiter) // 清理等待者
if (current?.size === 0) waiters.delete(id) // 空集时删除
}),
),
),
)
})
4.3 四种状态分支
Plugin.wait(id) 在锁内检查三种状态,返回四种行为:
| 状态 | 返回值 | 行为 |
|---|---|---|
active.has(id) |
false |
插件已加载,无需等待,直接返回 |
failures.get(id) 存在 |
Exit.Exit<void, never> |
插件之前失败,传播失败 exit |
| 以上都不满足 | true |
注册 Deferred 到 waiters,阻塞等待 |
加载中(loading.has(id)) |
--- | wait 不会触发循环检测,因为 wait 不检查 loading |
4.4 等待者通知机制
当 add 成功或失败时,通知所有等待者:
ts
// 成功通知(plugin.ts:66-69)
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { discard: true })
waiters.delete(id)
// 失败通知(plugin.ts:73-78)
Effect.onExit((exit) => {
if (Exit.isSuccess(exit)) return Effect.void
failures.set(id, exit)
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
discard: true,
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
})
- 成功 :
Deferred.succeed(waiter, undefined)--- 所有等待者收到成功信号 - 失败 :
Deferred.done(waiter, exit)--- 所有等待者收到与add相同的Exit(失败原因传播) - 清理:通知完成后删除 waiters 集合,防止内存泄漏
4.5 Deferred 清理保障
ts
// 115:125:opencode/packages/core/src/plugin.ts
yield* Deferred.await(waiter).pipe(
Effect.ensuring(
locks.withLock(id)(
Effect.sync(() => {
const current = waiters.get(id)
current?.delete(waiter)
if (current?.size === 0) waiters.delete(id)
}),
),
),
)
即使 wait 被中断(如会话关闭),Effect.ensuring 也会在锁内清理等待者,防止 Deferred 泄漏。
五、内置插件启动链
5.1 PluginInternal.boot --- 12 个内置插件
ts
// 108:123:opencode/packages/core/src/plugin/internal.ts
yield* State.batch(
Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin) // 项目引用配置加载
yield* add(AgentPlugin.Plugin) // Agent 配置加载
yield* add(CommandPlugin.Plugin) // 命令配置加载
yield* add(SkillPlugin.Plugin) // 内置 skill 注入(customize-opencode)
yield* add(ModelsDevPlugin) // models.dev 远程模型目录
yield* add(ConfigAgentPlugin.Plugin) // opencode.json 中的 agent 配置
yield* add(ConfigCommandPlugin.Plugin) // opencode.json 中的 command 配置
yield* add(ConfigSkillPlugin.Plugin) // opencode.json 中的 skill 配置
for (const item of ProviderPlugins) yield* add(item) // 30+ provider 插件
yield* add(ConfigExternalPlugin.Plugin) // 外部 npm/file 插件加载
yield* add(ConfigProviderPlugin.Plugin) // provider 配置覆盖
yield* add(VariantPlugin.Plugin) // 模型变体配置
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
5.2 define 函数与插件上下文注入
ts
// 54:61:opencode/packages/core/src/plugin/internal.ts
export interface Plugin<R = never> {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope>
}
export function define<R>(plugin: Plugin<R>) {
return plugin
}
define 是恒等函数,仅用于类型推导。插件通过 effect(ctx) 获取 PluginContext,ctx 即 PluginHost.make 返回的隔离 API。
add 内部(第 81-106 行)通过 Effect.provideService 为插件 effect 注入全部 14 个底层服务,让插件内部可以直接 yield* Config.Service 而不需要自己管理依赖。
5.3 SkillPlugin --- 内置 Skill 注入示例
ts
// 13:31:opencode/packages/core/src/plugin/skill.ts
export const Plugin = define({
id: "skill",
effect: Effect.fn(function* (ctx) {
yield* ctx.skill.transform((draft) => {
draft.source(
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
name: "customize-opencode",
description: "Use ONLY when the user is editing or creating opencode's own configuration...",
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CustomizeOpencodeContent,
}),
}),
)
})
}),
})
这个插件通过 ctx.skill.transform 向 Skill 系统注入一个 EmbeddedSource,内容是编译时内嵌的 customize-opencode.md。用户磁盘上的同名 skill 可以覆盖它(因为内置 skill 先注册)。
六、Skill 发现机制
6.1 双轨 Skill 系统
与 Plugin 类似,Skill 也分 V1 和 V2 两套:
| 维度 | V1 Skill (opencode/src/skill/) |
V2 Skill (core/src/skill.ts) |
|---|---|---|
| 服务 ID | @opencode/Skill |
@opencode/v2/Skill |
| 发现入口 | discoverSkills() 扫描磁盘 |
load(source) 从 Source 加载 |
| Source 类型 | 直接扫描目录 | EmbeddedSource / DirectorySource / UrlSource |
| 缓存策略 | 无缓存,每次全量扫描 | Map<string, Info[]> 按 Source key 缓存 |
| Skill 来源 | .claude/skills、.agents/skills、config paths、urls |
由插件通过 ctx.skill.transform 注册 |
6.2 V1 Skill 发现 --- 四层扫描
ts
// 173:233:opencode/packages/opencode/src/skill/index.ts
const discoverSkills = Effect.fnUntraced(function* (
config, discovery, fsys, global,
disableExternalSkills, disableClaudeCodeSkills,
directory, worktree,
) {
const state: ScanState = { matches: new Set(), dirs: new Set() }
const externalDirs: string[] = []
// ── 第 1 层:全局外部 Skill 目录 ──
if (!disableExternalSkills) {
if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR) // ".claude"
externalDirs.push(AGENTS_EXTERNAL_DIR) // ".agents"
for (const dir of externalDirs) {
const root = path.join(global.home, dir)
if (!(yield* fsys.isDir(root))) continue
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
// EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
}
}
// ── 第 2 层:项目级外部 Skill 目录(向上查找) ──
const upDir = yield* fsys
.up({ targets: externalDirs, start: directory, stop: worktree })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const root of upDir) {
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
}
// ── 第 3 层:opencode 配置目录 ──
const configDirs = yield* config.directories()
for (const dir of configDirs) {
yield* scan(state, dir, OPENCODE_SKILL_PATTERN)
// OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
}
// ── 第 4 层:自定义路径和远程 URL ──
const cfg = yield* config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
if (!(yield* fsys.isDir(dir))) { yield* Effect.logWarning("skill path not found", { path: dir }); continue }
yield* scan(state, dir, SKILL_PATTERN) // SKILL_PATTERN = "**/SKILL.md"
}
for (const url of cfg.skills?.urls ?? []) {
const pulledDirs = yield* discovery.pull(url)
for (const dir of pulledDirs) {
yield* scan(state, dir, SKILL_PATTERN)
}
}
return { matches: Array.from(state.matches), dirs: Array.from(state.dirs) }
})
四层发现策略:
| 层 | 搜索路径 | Glob 模式 | 特性 |
|---|---|---|---|
| 全局外部 | ~/.claude/, ~/.agents/ |
skills/**/SKILL.md |
dot 文件,可禁用 Claude Code |
| 项目外部 | findUp(.claude/, .agents/) |
skills/**/SKILL.md |
从 cwd 向上到 worktree |
| opencode 配置 | config.directories() |
{skill,skills}/**/SKILL.md |
opencode.json 配置目录 |
| 自定义 | cfg.skills.paths + cfg.skills.urls |
**/SKILL.md |
支持 ~/ 展开,远程 URL 拉取 |
6.3 SKILL.md 解析
ts
// 105:140:opencode/packages/opencode/src/skill/index.ts
const add = Effect.fnUntraced(function* (state, match, events) {
const md = yield* Effect.tryPromise({
try: () => ConfigMarkdown.parse(match), // 解析 frontmatter
catch: (err) => err,
}).pipe(
Effect.catch(function* (err) {
const message = FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}`
const { Session } = yield* Effect.promise(() => import("@/session/session"))
yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
return undefined
}),
)
if (!md) return
if (!isSkillFrontmatter(md.data)) return // 检查 { name: string, description?: string }
if (state.skills[md.data.name]) { // 同名 skill 覆盖(内置先注册)
yield* Effect.logWarning("duplicate skill name", { name: md.data.name, existing: state.skills[md.data.name].location, duplicate: match })
}
state.dirs.add(path.dirname(match))
state.skills[md.data.name] = { name: md.data.name, description: md.data.description, location: match, content: md.content }
})
解析流程 :ConfigMarkdown.parse(filepath) → 提取 frontmatter → 验证 { name: string, description?: string } → 存入 state.skills[name]。同名时后来的覆盖先注册的(内置 customize-opencode 先注册,用户磁盘同名 skill 可覆盖)。
6.4 V2 Skill 加载与缓存
ts
// 73:119:opencode/packages/core/src/skill.ts
const load = Effect.fn("SkillV2.load")(function* (source: Source) {
const skills: Info[] = []
if (source.type === "embedded") return [source.skill] // EmbeddedSource 直接返回
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
for (const directory of directories) {
const files = yield* fs
.glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) continue
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
if (!frontmatter) continue
const name = frontmatter.name !== undefined
? frontmatter.name
: path.dirname(filepath) === directory ? path.basename(filepath, ".md") : undefined
if (!name) continue
skills.push({ name, description: frontmatter.description, slash: frontmatter.slash, location: AbsolutePath.make(filepath), content: markdown.content })
}
}
return skills
})
const cache = new Map<string, Info[]>()
const list = Effect.fn("SkillV2.list")(function* () {
const skills = new Map<string, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source)) // 缓存命中则跳过磁盘扫描
cache.set(key, loaded)
for (const skill of loaded) skills.set(skill.name, skill) // 同名 skill 后者覆盖前者
}
return Array.from(skills.values())
})
V2 的三种 Source 类型:
ts
// 15:25:opencode/packages/core/src/skill.ts
export const DirectorySource = Skill.DirectorySource // { type: "directory", path: string }
export const UrlSource = Skill.UrlSource // { type: "url", url: string }
export const EmbeddedSource = Skill.EmbeddedSource // { type: "embedded", skill: Info }
export const Source = Skill.Source // union of three
缓存设计 :cache: Map<string, Info[]> 按 Source.key(source) 缓存加载结果。Source 的 key 是序列化后的唯一标识。重复 list() 调用不会重新扫描磁盘,除非 reload() 被调用。
6.5 远程 Skill 拉取 --- 原子更新
ts
// 99:208:opencode/packages/core/src/skill/discovery.ts
pull: Effect.fn("SkillDiscovery.pull")(function* (url) {
const base = url.endsWith("/") ? url : `${url}/`
const source = new URL(base)
const index = new URL("index.json", source).href
const data = yield* HttpClientRequest.get(index).pipe( // 1. 拉取 index.json
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((error) => Effect.logError("failed to fetch skill index", { url: index, error }).pipe(Effect.as(undefined))),
)
if (!data) return []
const sourceRoot = path.resolve(global.cache, "skills", Bun.hash(base).toString(16))
return yield* Effect.forEach(data.skills, ({ skill, root, versionFile, files }) =>
Effect.gen(function* () {
const version = skill.version
const current = version === undefined ? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (version === undefined || current === version) {
// 无版本或版本相同:直接下载到目标路径
yield* Effect.forEach(files, (file) => download(file.url, file.destination), { concurrency: fileConcurrency, discard: true })
} else {
// 版本不同:原子更新(staging → backup → swap → cleanup)
const token = crypto.randomUUID()
const staging = `${root}.tmp-${token}` // 临时暂存目录
const backup = `${root}.old-${token}` // 旧版本备份
yield* Effect.gen(function* () {
const downloaded = yield* Effect.forEach(files, (file) => download(file.url, path.resolve(staging, file.file)), { concurrency: fileConcurrency })
if (!downloaded.every(Boolean)) return // 任何一个下载失败则放弃
const exists = (yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) || (yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
if (!exists) return
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
yield* Effect.uninterruptible(Effect.gen(function* () { // 不可中断的原子 swap
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup) // 旧 → backup
yield* fs.rename(staging, root).pipe( // staging → root
Effect.catch((error) => Effect.gen(function* () {
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) // 失败回滚
return yield* Effect.fail(error)
})),
)
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore) // 清理 backup
}))
}).pipe(
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), // 确保清理暂存
)
}
const exists = (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || (yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
return exists ? [AbsolutePath.make(root)] : []
}),
{ concurrency: skillConcurrency },
).pipe(Effect.map((directories) => directories.flat()))
})
原子更新三步法(version 不同时):
- 暂存 :下载到
staging目录,全部成功且 SKILL.md 存在 - swap :
Effect.uninterruptible内rename(root → backup)+rename(staging → root),失败时回滚 - 清理:删除 backup 和 staging
安全检查 (isSafeSegment / isSafeRelativePath):防止路径遍历攻击,确保下载文件不会逃逸出 sourceRoot。
七、SkillGuidance 注入 --- 从 Skill 到 System Prompt
7.1 V1 注入路径 --- sys.skills(agent)
ts
// 98:110:opencode/packages/opencode/src/session/system.ts
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = yield* skill.available(agent) // 按 agent.permission 过滤
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
Skill.fmt(list, { verbose: true }), // 渲染为 <available_skills> XML
].join("\n")
}),
V1 组装顺序 (prompt.ts:1257-1258):
ts
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent), // ← Skill 列表
sys.environment(model), // ← 环境信息
instruction.system(), // ← AGENTS.md/CLAUDE.md/CONTEXT.md
sys.mcp(agent, session.permission), // ← MCP 指令
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : [])]
7.2 V2 注入路径 --- SkillGuidance.load(agent) → SystemContext
ts
// 46:68:opencode/packages/core/src/skill/guidance.ts
load: Effect.fn("SkillGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return SystemContext.empty // 无 agent info → 空
const permitted = SkillV2.available(yield* skills.list(), agent) // 按权限过滤
if (permitted.length === 0 && PermissionV2.evaluate("skill", "*", agent.permissions).effect === "deny")
return SystemContext.empty // 全局禁用 skill → 空
const available = permitted
.flatMap((skill) => skill.description === undefined ? [] : [{ name: skill.name, description: skill.description }])
.toSorted((a, b) => a.name.localeCompare(b.name))
return SystemContext.make({
key: SystemContext.Key.make("core/skill-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)), // 类型化序列化
load: Effect.succeed(available), // 可独立刷新
baseline: render, // 初始渲染
update: (_previous, current) => // 增量更新渲染
["The available skills have changed. This list supersedes the previous available skills list.", render(current)].join("\n"),
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
})
})
render 函数生成 XML 格式的 skill 列表:
ts
// 16:32:opencode/packages/core/src/skill/guidance.ts
const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
...(skills.length === 0
? ["No skills are currently available."]
: [
"<available_skills>",
...skills.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
" </skill>",
]),
"</available_skills>",
]),
].join("\n")
7.3 V2 loadSystemContext --- 三源合并
ts
// 168:171:opencode/packages/core/src/session/runner/llm.ts
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
concurrency: "unbounded",
}).pipe(Effect.map(SystemContext.combine))
三个 SystemContext 源被合并为一个:
| 源 | 内容 | 来源 |
|---|---|---|
systemContext.load() |
环境信息 + 日期 + AGENTS.md 指令 | SystemContextRegistry(builtins + InstructionContext) |
skillGuidance.load(agent) |
可用 skill 列表 | SkillV2.Service + agent.permissions 过滤 |
referenceGuidance.load() |
项目引用 | Reference.Service |
合并后的 SystemContext 通过 SessionContextEpoch.initialize/prepare 持久化到 SQLite,并生成 baseline(初始渲染)和 snapshot(可比较的结构化状态)。
7.4 V1 vs V2 Skill 注入对比
| 维度 | V1 (sys.skills) |
V2 (SkillGuidance.load) |
|---|---|---|
| 返回类型 | `string | undefined` |
| 注入方式 | 直接拼接到 system prompt 数组 | 通过 SystemContext.combine 合并,经 SessionContextEpoch 持久化 |
| 刷新策略 | 每轮循环重新执行 Effect.all |
持久化 snapshot,reconcile 增量更新 |
| 变更检测 | 无(每次全量渲染) | schema 等价性比较,仅变更时注入 update 文本 |
| 权限过滤 | Permission.disabled(["skill"], agent.permission) |
PermissionV2.evaluate("skill", name, agent.permissions) |
| 渲染格式 | verbose(含 location) | 精简(仅 name + description) |
八、SystemContextRegistry --- 类型化上下文源
8.1 核心思想
SystemContextRegistry 把系统上下文从"固定字符串"提升为"可独立刷新、可比较的类型化源"。
8.2 Source<A> --- 类型化源接口
ts
// 32:39:opencode/packages/core/src/system-context/index.ts
export interface Source<A> {
readonly key: Key // 命名空间标识
readonly codec: Schema.Codec<A, Schema.Json, never, never> // 类型安全序列化
readonly load: Effect.Effect<A | Unavailable> // 观察当前值
readonly baseline: (current: A) => string // 初始渲染
readonly update: (previous: A, current: A) => string // 差异渲染
readonly removed?: (previous: A) => string // 移除渲染
}
五个维度:
key: Key--- 命名空间标识,格式为namespace/name(如core/environment),全局唯一codec: Schema.Codec<A>--- 类型安全序列化器,用于将值编码为 JSON(持久化)和从 JSON 解码(恢复比较)load: Effect<A | Unavailable>--- 观察当前值。返回unavailable表示暂时不可用(不同于被移除)baseline: (current: A) => string--- 初始渲染,生成模型可见的基线文本update: (previous, current) => string--- 差异渲染,当值变化时生成更新文本removed?: (previous: A) => string--- 移除渲染,当源不再存在时生成移除通知(可选)
8.3 SystemContext.make --- 类型擦除与打包
ts
// 135:173:opencode/packages/core/src/system-context/index.ts
export function make<A>(source: Source<A>): SystemContext {
const decode = Schema.decodeUnknownOption(source.codec) // 从 JSON 解码
const encode = Schema.encodeSync(source.codec) // 编码为 JSON
const equivalent = Schema.toEquivalence(source.codec) // schema 等价性比较
return context([{
key: source.key,
load: source.load.pipe(
Effect.map((value) => {
if (isUnavailable(value)) return value // 传递 unavailable
const snapshot = (): SourceSnapshot => ({
value: encode(value), // 编码为 JSON 持久化
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
})
return {
baseline: (): Rendered => ({
text: requireText(source.key, "baseline", source.baseline(value)),
snapshot: snapshot(),
}),
compare: (previous): Compared => // 与持久化的 snapshot 比较
Option.match(decode(previous), { // 尝试解码 previous JSON
onNone: (): Compared => ({ _tag: "Incompatible" }), // 解码失败 → 不兼容
onSome: (decoded): Compared =>
equivalent(decoded, value) // schema 等价性比较
? { _tag: "Unchanged" } // 等价 → 无变化
: {
_tag: "Updated", // 不等价 → 已更新
render: () => ({
text: requireText(source.key, "update", source.update(decoded, value)),
snapshot: snapshot(),
}),
},
}),
}
}),
),
}])
}
类型擦除模式:
make<A>(source: Source<A>)接收具体类型A的 source- 内部通过
encode/decode/equivalent把A编码为Schema.Json(通用类型) - 返回的
SystemContext携带PackedSource(不包含A的类型信息) - 不同类型
A的 source 打包后可以统一组合
8.4 combine --- 多源组合
ts
// 176:180:opencode/packages/core/src/system-context/index.ts
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
const sources = values.flatMap((value) => value[ContextTypeId])
assertUniqueKeys(sources) // 拒绝重复 key
return context(sources)
}
combine 把多个 SystemContext 的 PackedSource 数组拼接为一个,合并前检查 key 唯一性。
8.5 三大操作:initialize / reconcile / replace
initialize --- 首次初始化
ts
// 198:215:opencode/packages/core/src/system-context/index.ts
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
return observe(value).pipe(
Effect.flatMap((entries) => {
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable }) // 有源不可用 → 阻塞
return Effect.succeed(initializeObservation(entries))
}),
)
}
- 所有源可用 → 生成
Generation { baseline: string, snapshot: Snapshot } - 任何源返回
unavailable→ 返回InitializationBlocked(不生成不完整的 baseline)
reconcile --- 增量更新
ts
// 218:280:opencode/packages/core/src/system-context/index.ts
export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect<ReconcileResult> {
return observe(value).pipe(
Effect.map((entries): ReconcileResult => {
const result = reconcileObservation(entries, previous)
if (result._tag === "Unchanged" || result._tag === "Updated") return result
return replaceObservation(entries, previous) // 不兼容时降级为 replace
}),
)
}
reconcileObservation 的核心逻辑:
- 逐源比较 :每个源用
compare(previous.value)与持久化 snapshot 比较 - 三种结果 :
Incompatible(schema 不兼容 → 降级 replace)、Unchanged(等价)、Updated(不等价 → 渲染更新文本) - 移除检测 :如果 previous 中有源不在当前 entries 中,且有
removed渲染 → 生成移除文本;否则降级 replace - unavailable 处理:unavailable 源保留旧 snapshot(不更新也不移除)
- 汇总 :有更新文本 →
Updated { text, snapshot };无更新 →Unchanged
replace --- 完整替换
ts
// 283:291:opencode/packages/core/src/system-context/index.ts
export function replace(value: SystemContext, previous: Snapshot): Effect.Effect<ReplacementResult> {
return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous)))
}
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
return { _tag: "ReplacementBlocked" } // 之前已接纳的源现在不可用 → 阻塞
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
}
- 之前已接纳的源现在不可用 →
ReplacementBlocked(保留旧 baseline,不生成不完整的新 baseline) - 否则 →
ReplacementReady(生成全新的 baseline + snapshot)
8.6 Registry --- 注册与加载
ts
// 12:15:opencode/packages/core/src/system-context/registry.ts
export interface Entry {
readonly key: SystemContext.Key
readonly load: Effect.Effect<SystemContext.SystemContext>
}
export interface Interface {
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
ts
// 25:44:opencode/packages/core/src/system-context/registry.ts
return Service.of({
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
yield* Effect.acquireRelease(
Ref.modify(entries, (current) => { // 原子添加
if (current.some((item) => item.key === entry.key)) return [false, current]
return [true, [...current, entry]]
}).pipe(
Effect.flatMap((added) =>
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
),
Effect.as(entry),
),
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)), // scope 关闭时自动移除
)
}),
load: Effect.fn("SystemContextRegistry.load")(function* () {
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) // 按 key 排序
return SystemContext.combine(
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }), // 并发加载所有源
)
}),
})
Registry 关键设计:
Effect.acquireRelease:注册时获取,scope 关闭时自动移除 --- 源的生命周期与注册时的 scope 绑定- 重复 key 检测 :
Effect.die而非Effect.fail--- 重复 key 是程序错误 - 按 key 排序 :
load()时按 key 字母序排序,保证combine的拼接顺序确定性 - 并发加载 :
concurrency: "unbounded"--- 所有注册的源并发加载,互不阻塞
8.7 内置 SystemContext 源
builtins --- 环境与日期
ts
// 12:44:opencode/packages/core/src/system-context/builtins.ts
const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const environment = [...] // Working directory, Workspace root, Platform 等
const context = SystemContext.combine([
SystemContext.make({
key: SystemContext.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(environment),
baseline: (env) => ["Here is some useful information about the environment you are running in:", env].join("\n"),
update: (_prev, env) => ["The environment you are running in is now:", env].join("\n"),
}),
SystemContext.make({
key: SystemContext.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
baseline: (date) => `Today's date: ${date}`,
update: (_prev, date) => `Today's date is now: ${date}`,
}),
])
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
}),
)
InstructionContext --- AGENTS.md 发现
ts
// 22:91:opencode/packages/core/src/instruction-context.ts
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
SystemContext.make({
key: SystemContext.Key.make("core/instructions"),
codec: Schema.toCodecJson(Files),
load: Effect.succeed(value),
baseline: render, // "Instructions from: <path>\n<content>"
update: (_prev, current) => `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
removed: () => "Previously loaded instructions no longer apply.",
})
const observe = Effect.fn("InstructionContext.observe")(function* () {
const start = yield* fs.resolve(location.directory) // 当前工作目录
const stop = yield* fs.resolve(location.project.directory) // 项目根目录
const discovered = new Set(
yield* Effect.forEach(
fs.up({ targets: ["AGENTS.md"], start, stop }), // 向上查找 AGENTS.md
fs.resolve,
),
)
const paths = Array.dedupe([
yield* fs.resolve(join(global.config, "AGENTS.md")), // 全局 AGENTS.md
...discovered, // 项目级 AGENTS.md
])
const files = yield* Effect.forEach(paths, (path) => fs.readFileStringSafe(path)... )
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
return SystemContext.unavailable // 项目级文件读取失败 → unavailable
return files.filter((file): file is File => file !== undefined)
})
yield* registry.register({
key: SystemContext.Key.make("core/instructions"),
load: observe().pipe(Effect.map((files) => ...).pipe(
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))), // 观察错误 → unavailable
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
)),
})
}),
)
AGENTS.md 发现策略:
- 全局 :
~/.config/opencode/AGENTS.md - 项目级 :从
location.directory向上到location.project.directory查找AGENTS.md(不叠加祖先目录) - unavailable 语义 :项目级文件读取失败 → 返回
unavailable(保留旧 snapshot,不降级为空)
8.8 SessionContextEpoch --- 持久化与增量更新
ts
// 40:78:opencode/packages/core/src/session/context-epoch.ts
const prepareOnce = Effect.fnUntraced(function* (db, events, context, sessionID) {
const [value, stored, compaction] = yield* Effect.all(
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
{ concurrency: "unbounded" },
)
if (!stored) { // 首次:initialize
const generation = yield* SystemContext.initialize(value)
const baselineSeq = yield* insert(db, sessionID, generation) // 持久化 baseline + snapshot
return { baseline: generation.baseline, baselineSeq }
}
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot)
.pipe(Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })))
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
const result = replacementSeq
? yield* SystemContext.replace(value, snapshot) // compaction 后:replace
: yield* SystemContext.reconcile(value, snapshot) // 正常:reconcile
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } // 无变化或阻塞 → 旧 baseline
}
if (result._tag === "ReplacementReady") {
const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
yield* replace(db, sessionID, baselineSeq, result.generation) // 持久化新 baseline + snapshot
return { baseline: result.generation.baseline, baselineSeq }
}
// result._tag === "Updated"
yield* events.publish(
SessionEvent.ContextUpdated, // 发布上下文更新事件
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) }, // 事务性提交
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } // baseline 不变,只更新 snapshot
})
三种路径:
| 场景 | 操作 | baseline 变化 | snapshot 变化 |
|---|---|---|---|
| 首次 | initialize |
新建 | 新建 |
| compaction 后 | replace |
替换 | 替换 |
| 正常 reconcile · Unchanged | --- | 不变 | 不变 |
| 正常 reconcile · Updated | 发布 ContextUpdated 事件 | 不变 | 更新 |
| 正常 reconcile · ReplacementReady | replace |
替换 | 替换 |
| 正常 reconcile · ReplacementBlocked | --- | 不变 | 不变 |
关键设计 :Updated 路径中 baseline 不变(模型看到的系统 prompt 基线不变),但 snapshot 更新(下次 reconcile 的比较基准更新)。result.text 作为 ContextUpdated 事件发布,会被注入到对话历史中作为增量更新通知。
九、V1 Plugin 系统 --- Hooks 与 trigger
9.1 V1 插件架构
ts
// 44:56:opencode/packages/opencode/src/plugin/index.ts
export interface Interface {
readonly trigger: <Name extends TriggerName, Input, Output>(
name: Name, input: Input, output: Output,
) => Effect.Effect<Output>
readonly list: () => Effect.Effect<Hooks[]>
readonly init: () => Effect.Effect<void>
}
V1 插件通过 Hooks 接口注册回调,trigger(name, input, output) 按注册顺序调用所有 hook 的对应方法:
ts
// 280:293:opencode/packages/opencode/src/plugin/index.ts
const trigger = Effect.fn("Plugin.trigger")(function* (name, input, output) {
if (!name) return output
const s = yield* InstanceState.get(state)
for (const hook of s.hooks) {
const fn = hook[name] as any
if (!fn) continue
yield* Effect.promise(async () => fn(input, output)) // 串行执行
}
return output
})
9.2 V1 插件加载流程
ts
// 123:238:opencode/packages/opencode/src/plugin/index.ts
const layer = Layer.effect(Service, Effect.gen(function* () {
// ...
const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) {
const hooks: Hooks[] = []
const bridge = yield* EffectBridge.make()
const { Server } = yield* Effect.promise(() => import("../server/server"))
const client = createOpencodeClient({ ... }) // SDK 客户端
const cfg = yield* config.get()
const input: PluginInput = { client, project, worktree, directory, experimental_workspace, serverUrl, $: Bun.$ }
// 1. 内置 Auth 插件(10 个)
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
const init = yield* Effect.tryPromise({ try: () => plugin(input), catch: errorMessage })
.pipe(Effect.tapError((error) => Effect.logError("failed to load internal plugin", { error })), Effect.option)
if (init._tag === "Some") hooks.push(init.value)
}
// 2. 外部插件(npm/file)
const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
if (plugins.length) yield* config.waitForDependencies()
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({ items: plugins, kind: "server", report: { ... } }),
)
for (const load of loaded) {
yield* Effect.tryPromise({ try: () => applyPlugin(load, input, hooks), catch: ... })
.pipe(Effect.tapError((error) => Effect.logError("failed to load plugin", { path: load.spec, error })), Effect.catch(() => Effect.void))
}
// 3. 通知所有插件当前配置
for (const hook of hooks) {
yield* Effect.tryPromise({ try: () => Promise.resolve(hook.config?.(cfg)), catch: errorMessage })
.pipe(Effect.tapError((error) => Effect.logError("plugin config hook failed", { error })), Effect.ignore)
}
// 4. 注册事件监听 + dispose finalizer
const unsubscribe = yield* events.listen((event) => { ... })
yield* Effect.addFinalizer(() => unsubscribe)
yield* Effect.addFinalizer(() => Effect.forEach(hooks, (hook) => Effect.tryPromise({ try: () => Promise.resolve(hook.dispose?.()), catch: errorMessage }).pipe(Effect.ignore)), { discard: true }))
return { hooks }
}),
)
// ...
}))
9.3 PluginLoader --- 解析与加载管线
ts
// 86:133:opencode/packages/opencode/src/plugin/loader.ts
export async function resolve(plan: Plan, kind: PluginKind): Promise<...> {
// Stage 1: install --- 解析插件目标路径
let target = ""
try { target = await resolvePluginTarget(plan.spec) } // npm install 或 file path resolve
catch (error) { return { ok: false, stage: "install", error } }
// Stage 2: entry --- 检查入口点
let base
try { base = await createPluginEntry(plan.spec, target, kind) } // 读取 package.json exports
catch (error) { return { ok: false, stage: "entry", error } }
if (!base.entry) return { ok: false, stage: "missing", value: { ...plan, message: `... does not expose a ${kind} entrypoint` } }
// Stage 3: compatibility --- 版本兼容检查
if (base.source === "npm") {
try { await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) }
catch (error) { return { ok: false, stage: "compatibility", error } }
}
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}
// 136:145:opencode/packages/opencode/src/plugin/loader.ts
export async function load(row: Resolved): Promise<...> {
let mod
try { mod = await import(row.entry) } // Stage 4: dynamic import
catch (error) { return { ok: false, error } }
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
return { ok: true, value: { ...row, mod } }
}
四阶段管线:
| 阶段 | 操作 | 失败处理 |
|---|---|---|
| install | resolvePluginTarget(spec) --- npm 安装或文件路径解析 |
可重试(仅 file 插件,"missing package.json or index file") |
| entry | createPluginEntry(spec, target, kind) --- 读取 package.json exports |
不可重试 |
| compatibility | checkPluginCompatibility(target, version, pkg) --- semver 检查 |
不可重试 |
| load | import(row.entry) --- 动态导入模块 |
不可重试(Bun 缓存失败的动态导入) |
9.4 PluginSource --- npm vs file
ts
// 36:37:opencode/packages/opencode/src/plugin/shared.ts
export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"
// 56:59
export function pluginSource(spec: string): PluginSource {
if (isPathPluginSpec(spec)) return "file" // file://, ./, /path/to
return "npm" // npm package name
}
两种来源的差异:
| 来源 | 解析方式 | 兼容性检查 | 重试策略 |
|---|---|---|---|
file |
resolvePathPluginTarget --- 直接解析路径 |
跳过(视为本地开发代码) | install 阶段失败可重试一次 |
npm |
Npm.add(pkg@version) --- npm 安装 |
semver.satisfies(version, range) |
不可重试 |
十、完整交互流程图
bash
┌─────────────────────────────────────────┐
│ 启动时 Plugin 初始化链 │
└─────────────────────────────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
V1 Plugin Layer V2 Plugin Layer SystemContext
(opencode/src/plugin) (core/src/plugin) Registry
│ │ │
├─ internalPlugins() ├─ PluginInternal.boot() ├─ builtins.register()
│ (10 Auth 插件) │ (12 内置插件) │ (environment + date)
│ │ ┌────────────┐ │
├─ PluginLoader │ │ add(12次) │ ├─ InstructionContext
│ .loadExternal() │ │ │ │ │ .register()
│ ├─ resolve(install) │ │ ├─ loading.has? → die│ (AGENTS.md 发现)
│ ├─ resolve(entry) │ │ ├─ KeyedMutex.lock │
│ ├─ resolve(compat) │ │ ├─ Scope.close(旧) │ ├─ SkillGuidance
│ └─ load(import) │ │ ├─ Scope.fork(新) │ │ (V2 runner 动态加载)
│ │ │ ├─ effect(host) │
├─ applyPlugin(hooks) │ │ ├─ active.set(新) │
│ └─ hooks.push() │ │ └─ waiters.notify()│
│ │ └────────────┘ │
├─ hook.config?(cfg) │ │
├─ events.listen() │ PluginHost.make(service) │
└─ addFinalizer(dispose) │ → PluginContext: │
│ agent/aisdk/catalog/ │
│ command/integration/ │
│ plugin/reference/skill│
└──────────────────────────┘
│
┌─────────────────────────────────────────┘
│ V2 Runner 每轮 SystemContext 加载
└─────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ loadSystemContext(agent) │
│ Effect.all([ │
│ systemContext.load(), ← Registry │
│ skillGuidance.load(agent), │
│ referenceGuidance.load(), │
│ ], concurrency: "unbounded") │
│ → SystemContext.combine() │
└──────────────────┬────────────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
首次运行 compaction后 正常轮次
initialize replace reconcile
│ │ │
├─ Generation ├─ ReplacementReady ├─ Unchanged → 旧 baseline
│ baseline │ 新 baseline ├─ Updated → 旧 baseline + 新 snapshot
│ + snapshot │ + snapshot │ + ContextUpdated 事件
└─ insert DB └─ replace DB ├─ ReplacementReady → 新 baseline
└─ ReplacementBlocked → 旧 baseline
十一、关键设计决策
1. "PluginHost 作为隔离边界而非共享全局"
PluginHost.make 返回的 PluginContext 是插件能看到的唯一 API。插件不直接访问 AgentV2.Service 等内部服务,而是通过 ctx.agent.transform(draft => ...) 拿到可变 draft。DeepMutable 类型擦除和 ID.make() 转换构成了隔离边界。这防止了插件直接修改内部不可变状态,所有修改都通过 State.Transformable 的 batch 提交机制原子化。
2. "Scope.fork 作为插件生命周期单元"
每个活跃插件拥有一个 Scope.Closeable 子 scope。热重载时旧 scope 被关闭(释放所有 finalizer),新 scope 被 fork。这种设计让插件资源管理完全自动化------插件内部使用 Effect.acquireRelease / Effect.addFinalizer 注册的资源,会在插件被 remove 或热重载时自动清理,无需插件作者手动管理。
3. "loading Set 在锁外检查防死锁"
loading.has(id) 检查在 locks.withLock(id) 之前 执行。如果检查在锁内,插件 A 调用 ctx.plugin.add("A", ...) 会死锁(A 持有锁,又要等锁)。在锁外检查,A 会立即 Effect.die 而非死锁。这是"快速失败优于等待"的设计原则。
4. "Plugin.wait 用 Deferred 而非轮询"
wait(id) 创建 Deferred<void> 注册到 waiters map,然后 Deferred.await 阻塞。当 add 完成时,通过 Deferred.succeed / Deferred.done 通知所有等待者。这比轮询 active.has(id) 更高效,且能传播失败 exit。
5. "SystemContext 从固定字符串到类型化源"
传统系统 prompt 是固定字符串拼接。SystemContext 把每个上下文片段建模为 Source<A>,携带 schema codec 实现类型安全的序列化和等价性比较。initialize / reconcile / replace 三操作基于 schema 等价性实现增量更新:只有变化的源生成更新文本,未变化的源保留旧 snapshot。unavailable 与"移除"的区分让临时性观察失败不会破坏已接纳的上下文。
6. "Skill 发现的层级覆盖策略"
V1 的四层发现(全局外部 → 项目外部 → opencode 配置 → 自定义路径)配合"后注册覆盖先注册"策略,让用户能精确控制 skill 来源。内置 customize-opencode skill 先注册,用户磁盘同名 skill 可覆盖它。V2 的 EmbeddedSource / DirectorySource / UrlSource 三种类型让插件能灵活注入 skill,Source.key() 缓存避免重复磁盘扫描。
7. "远程 Skill 的原子更新"
SkillDiscovery.pull 的版本不同路径使用 staging → backup → swap 三步原子更新,在 Effect.uninterruptible 内执行 swap 操作。任何一步失败都会回滚(rename(backup, root)),确保 skill 目录始终处于一致状态。下载到 staging 目录而非直接下载到目标路径,避免半完成状态。