10-Plugin 系统与 Skill 发现:扩展边界与上下文注入

10-Plugin 系统与 Skill 发现:扩展边界与上下文注入

简要概括:Plugin用PluginHost隔离API与三重保护,Skill四层发现加缓存,SkillGuidance注入prompt,Registry类型化上下文源。
对应问题 Q10:packages/core/src/plugin.tspackages/opencode/src/plugin/ 实现的插件系统,以及 core/src/skill.tsopencode/src/skill/ 的 Skill 发现机制。涵盖 PluginHost 隔离 API、Plugin.add 三重保护、Plugin.wait 同步、SkillGuidance 注入、SystemContextRegistry 类型化上下文源。 勘察依据:core/src/plugin.tscore/src/plugin/host.tscore/src/plugin/internal.tscore/src/plugin/skill.tscore/src/effect/keyed-mutex.tscore/src/system-context/index.tscore/src/system-context/registry.tscore/src/system-context/builtins.tscore/src/instruction-context.tscore/src/skill.tscore/src/skill/guidance.tscore/src/skill/discovery.tscore/src/session/context-epoch.tsopencode/src/plugin/index.tsopencode/src/plugin/loader.tsopencode/src/plugin/shared.tsopencode/src/skill/index.tsopencode/src/skill/discovery.tsopencode/src/session/system.tspackages/plugin/src/v2/effect/context.tspackages/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 对象,包含 eventconfigdispose 等回调。通过 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 源的添加和列表

隔离设计要点

  1. 类型边界mutable<T>(value: T) => value as DeepMutable<T> --- 插件拿到的是可变 draft 副本,不直接持有内部不可变引用
  2. ID 转换AgentV2.ID.make(id) / ProviderV2.ID.make(id) --- 插件传 string ID,内部自动转为 branded ID 类型
  3. plugin.add/remove 递归:插件可以注册子插件,子插件共享同一 PluginHost 的隔离 API
  4. integration 域最复杂 :支持 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 不同时):

  1. 暂存 :下载到 staging 目录,全部成功且 SKILL.md 存在
  2. swapEffect.uninterruptiblerename(root → backup) + rename(staging → root),失败时回滚
  3. 清理:删除 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                            // 移除渲染
}

五个维度

  1. key: Key --- 命名空间标识,格式为 namespace/name(如 core/environment),全局唯一
  2. codec: Schema.Codec<A> --- 类型安全序列化器,用于将值编码为 JSON(持久化)和从 JSON 解码(恢复比较)
  3. load: Effect<A | Unavailable> --- 观察当前值。返回 unavailable 表示暂时不可用(不同于被移除)
  4. baseline: (current: A) => string --- 初始渲染,生成模型可见的基线文本
  5. update: (previous, current) => string --- 差异渲染,当值变化时生成更新文本
  6. 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/equivalentA 编码为 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 把多个 SystemContextPackedSource 数组拼接为一个,合并前检查 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 的核心逻辑:

  1. 逐源比较 :每个源用 compare(previous.value) 与持久化 snapshot 比较
  2. 三种结果Incompatible(schema 不兼容 → 降级 replace)、Unchanged(等价)、Updated(不等价 → 渲染更新文本)
  3. 移除检测 :如果 previous 中有源不在当前 entries 中,且有 removed 渲染 → 生成移除文本;否则降级 replace
  4. unavailable 处理:unavailable 源保留旧 snapshot(不更新也不移除)
  5. 汇总 :有更新文本 → 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 发现策略

  1. 全局~/.config/opencode/AGENTS.md
  2. 项目级 :从 location.directory 向上到 location.project.directory 查找 AGENTS.md不叠加祖先目录
  3. 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 目录而非直接下载到目标路径,避免半完成状态。

相关推荐
奔跑中的小象2 小时前
统信UOS + 天数AI卡部署SGLang服务手册
人工智能·uos·sglang·天数智芯
DevSecOps选型指南2 小时前
中国版Mythos,为何是悬镜安全灵脉CodeAI?
人工智能·安全
Shockang2 小时前
LangGraph 状态机实战
人工智能
刹那芳华19922 小时前
循环神经网络的从零开始实现(RNN)
人工智能·rnn·深度学习
阿童木写作2 小时前
跨境图片翻译工具多合一,批量图片视频字幕翻译加智能抠图
人工智能·python·音视频·语音识别
科技之门2 小时前
AI3D从建模到贴图、绑骨和动画的完整流程怎么做?V2Fun完整工作流指南
人工智能·3d·贴图
宇宙第一小趴菜2 小时前
二、机器学习的应用领域和发展史
人工智能·机器学习
前端开发江鸟2 小时前
我写过 MCP Server,却一直以为 MCP 只有 Tool
人工智能
天国梦3 小时前
自习室智能化升级避坑指南:天学网AI智习室方案实测与选型建议
大数据·人工智能
阿里云云原生3 小时前
可用性从 99.9% 跃升至 99.995%:畅捷通如何用 AI 重塑运维底座?
运维·网络·人工智能