Skip to content

Plugin config() hook mutations to skills.paths invisible to skill discovery #20940

Closed as not planned

Description

@sjawhar

Description

Plugins that register skill directories via their config() hook are invisible to skill discovery. For example, the superpowers plugin adds its skills directory to config.skills.paths inside config(), but Skill.all() never finds them.

Expected Behavior

Skills registered by plugins via config.skills.paths mutation in the config() hook should appear in Skill.all() and be available to agents.

Actual Behavior

Skills registered by plugin config() hooks are not discovered. Only skills from static config (opencode.json) or standard directories (~/.config/opencode/skills/) are found.

Verified: setting skills.paths directly in opencode.json works (65 skills). Plugin config() hook mutation does not (51 skills).

Root Cause

Each service's makeRuntime creates a separate InstanceState scope backed by ScopedCache. When Plugin calls config.get() and a plugin's config() hook mutates cfg.skills.paths, that mutation only affects Plugin's scoped copy. When Skill independently calls config.get(), it gets a separate copy — the mutation is invisible.

Same class of bug as #20026 (plugin providers disappearing after instance dispose).

Steps to reproduce

  1. Install a plugin that registers skills via config() hook (e.g. superpowers@git+https://github.com/obra/superpowers.git#v5.0.7)
  2. Start OpenCode
  3. List available skills — plugin-registered skills are missing

Plugins

superpowers@git+https://github.com/obra/superpowers.git#v5.0.7

OpenCode version

1.3.13

Operating System

Linux (Ubuntu)

Terminal

N/A

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Apr 3, 2026
  2. luanweslley77 commented on Apr 12, 2026

    @luanweslley77
    Contributor

    @sjawhar

    It’s working on version 1.4.3.

    Recently, I’ve been building a plugin that uses the config.skills.paths.push(dir) method:
    opencode-security-analyze

    Out of curiosity, I also tested it with Superpowers, and it works there as well:
    superpowers

    Could you provide more details on how to properly reproduce this setup, or confirm whether this behavior can be considered stable in version 1.4.3?

    Thanks in advance!

  3. sjawhar commented on Apr 12, 2026

    @sjawhar
    Author

    The thing that was happening was an interaction between opencode and oh-my-openagent. The fault was on oh-my-openagent's side, I believe, but the way to fix it seemed to need to expose this functionality.

  4. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  5. herjarsa commented on May 21, 2026

    @herjarsa

    Still affecting v1.15.5 — full analysis with root cause traced

    I am a user affected by this bug. I traced the full root cause through the source code — here is the complete picture to help get this fixed.

    Symptoms

    All superpowers skills (brainstorming, dispatching-parallel-agents, systematic-debugging, test-driven-development, subagent-driven-development, etc.) are invisible to the skill tool even though the superpowers plugin correctly pushes its skills directory to config.skills.paths via the config() hook.

    Affected versions

    • Confirmed on v1.15.5 (current)
    • Also reproduced by multiple users on the latest builds

    Root cause traced

    I traced the full flow through the codebase:

    1. Plugin service (packages/opencode/src/plugin/index.ts, lines 136-244):

    const cfg = yield* config.get()  // Gets the config object
    // ...loads plugins, collects hooks...
    for (const hook of hooks) {
      yield* Effect.tryPromise({
        try: () => Promise.resolve((hook as any).config?.(cfg)),  // Plugin mutates cfg
      })
    }

    The superpowers plugin mutates cfg.skills.paths inside hook.config(cfg).

    2. Skill service (packages/opencode/src/skill/index.ts, lines 200-210):

    const cfg = yield* config.get()  // Gets ITS OWN config object
    for (const item of cfg.skills?.paths ?? []) {

    The skill service calls config.get() independently during discoverSkills().

    3. Config service (packages/opencode/src/config/config.ts, lines 762-764):

    const get = Effect.fn("Config.get")(function* () {
      return yield* InstanceState.use(state, (s) => s.config)
    })

    Config.get() returns the config from InstanceState, which is wrapped in Effect ScopedCache (packages/opencode/src/effect/instance-state.ts).

    4. The key insight: The Config service IS a singleton (single Layer.effect), and InstanceState.make creates a cache keyed by directory. So config.get() returns the same cached object reference.

    So why doesnt the mutation stick?

    The subtlety is in initialization ordering. The Plugin service and Skill service are separate Layers. Each initializes independently via their own InstanceState.make(). The Plugin service calls config.get(), runs hooks (mutating the config object), but then the Skill service has already cached its discovery result via its own InstanceState.make() BEFORE the plugin hooks run.

    Looking at the Layer graph:

    • Plugin.layer depends on Config.defaultLayer
    • Skill.defaultLayer depends on Config.defaultLayer
    • There is no ordering dependency between Plugin and Skill

    So the race is:

    1. Config service loads → stores config in InstanceState (the real config, without superpowers paths)
    2. Skill service initializes → calls discoverSkills() → calls config.get() → gets config (no superpowers paths) → caches the discovery result in its own InstanceState
    3. Plugin service initializes → calls config.get() → gets config → runs hook.config(cfg) → superpowers mutates cfg.skills.paths
    4. Too late — Skill already cached its discovery result, never re-reads config

    Fix approach

    The fix needs to ensure that when plugins mutate the config via config() hooks, the Skill service picks up those changes. Three possible approaches:

    A (Recommended): Ordering dependency — Make Skill service initialization wait for Plugin hooks to finish. Add a dependency or explicit initialization step:

    // In skill/index.ts layer:
    // Run plugin hooks first, THEN discover skills
    yield* Plugin.Service.init()  // blocks until plugins are loaded and hooks run
    yield* discoverSkills(...)

    B (Cleaner): Plugin hooks return mutations — Instead of mutating cfg in-place, expose a Config.applyHooks() method that the Plugin service calls, and then the config service applies those mutations to its InstanceState before any consumer reads it.

    C (Simplest fix): Two-phase discovery — Have the Skill service invalidate and re-discover after plugins finish:

    yield* Plugin.Service.init()
    yield* InstanceState.invalidate(discovered)
    // Re-run discovery

    Request

    Can we get this prioritized? The superpowers plugin ecosystem is essentially broken without this — all third-party skill directories registered via plugins are invisible to the skill tool. Happy to help test or review a fix.

  6. herjarsa commented on May 21, 2026

    @herjarsa

    Fix submitted in PR #28647.

    The fix: Skill discovery now calls plugin.init() before reading config.skills.paths, ensuring plugin config() hooks (e.g. superpowers) have already mutated the config before discoverSkills() reads it. This is a 9-line change to packages/opencode/src/skill/index.ts.

    Closes this issue along with #28646 and #21282 when merged.

  7. github-actions commented on Jul 21, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions