Skip to content

Skill duplicate roots can change available_skills across restarts #32202

Closed

Description

@ualtinok

Description

When the same skill name exists in more than one discovered skill root, OpenCode can pick a different root across process starts.

The final <available_skills> block is sorted by skill name, but duplicate resolution happens earlier while loading matching SKILL.md files. Duplicate names currently use last-writer-wins, and the loader processes matches concurrently, so the winning location can depend on file I/O completion order.

This makes the system prompt bytes change after a restart even when no skill files changed. For users with prompt caching, that causes an unnecessary one-time cache miss on the first turn after restart.

Plugins

N/A

OpenCode version

Current dev

Steps to reproduce

  1. Create the same skill name in two discovered roots, for example:
    • .claude/skills/example/SKILL.md
    • .opencode/skills/example/SKILL.md
  2. Start OpenCode and inspect the verbose <available_skills> system prompt entry for example.
  3. Restart OpenCode and inspect the same entry again.

The selected <location> should be stable according to discovery precedence. Instead, duplicate winners can vary because skill files are loaded concurrently and duplicate names overwrite each other as each load completes.

Screenshot and/or share link

N/A

Operating System

macOS

Terminal

Any

Activity

  1. github-actions commented on Jun 13, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your issue adds new context or a different angle not covered there, please note that in a comment on #29950 or here.

  2. awswanson commented on Jul 13, 2026

    @awswanson

    We hit this same root cause in headless opencode run (1.17.18) and can add a measured cost consequence: with the same skills installed under two discovery roots (~/.claude/skills and ~/.agents/skills), the surviving <location> line flips per process — and since Anthropic prompt caching requires a byte-identical prefix, one flipped line is a whole-prefix cache miss.

    The failure is effectively invisible: token counts still look plausible (cache writes present, reads ~0), and nothing errors. We only caught it auditing tokens.cache.read in opencode.db across repeated 2-turn run/--continue conversations — turn-2 cache.read was intermittently 0 when the rendered location flipped between processes. At Anthropic pricing the miss is roughly a 12× per-turn input overpay (cache read 0.1× vs full input re-write).

    Workaround we use meanwhile: "permission": {"skill": "deny"} removes the <available_skills> block entirely (also saves ~2.6k tokens/call).

    +1 for deterministic root precedence or de-dup by skill id — identical installs should render identical prompts.

  3. sergical commented on Jul 27, 2026

    @sergical
    Contributor

    Researched and written with AI assistance; measurements are real but verify before relying on them.

    Confirming @awswanson's measurement, with one correction.

    openrouter/anthropic/claude-haiku-4.5, ~21k-token system prompt, dev from source, two-prompt sessions, #39008 applied so skill precedence is the only variable:

    build turn-2 read cost per turn
    unpatched 0 ~$0.0267
    with #32203 21384 $0.00218

    Consistent with the ~12× overpay above. The correction: not intermittent here — 6 of 6 turns missed, and the write count differed on every request (21348, 21360, 21369, 21381), so the prompt drifts continuously rather than only per process.

    On the two closed patches, since someone will need to pick one up: #32203 also sorts Glob.scan output and dirs, and the load race is only one of three ordering surfaces. It applies cleanly to current dev, 25 skill tests pass. #30184 adds a three-mode DEDUPE_MODE and inverts precedence so .claude beats .agents. Neither was closed on review — both matched the automated inactivity cleanup.

  4. frankxai commented on Aug 6, 2026

    @frankxai

    Additional Windows evidence from a large physical-copy installation (not symlinks):

    • OpenCode 1.14.48
    • ~/.claude/skills: 351 SKILL.md files
    • ~/.agents/skills: 398 SKILL.md files
    • 342 identical relative paths across both roots
    • 28 repeated nested skill-name/skill-name/SKILL.md layouts in each root
    • one startup log emitted 394 duplicate skill name warnings and ended with 331 unique skills
    • neither root is a symlink; matching paths are separate physical files

    This supports the issue's two underlying concerns beyond the symlink case: both global roots are scanned, and duplicate-name resolution needs deterministic source precedence rather than concurrent last-writer behavior. A bounded-warning policy would also keep real diagnostics visible at this scale.

    I have not removed either tree because they serve different clients; a safe local migration needs one declared canonical owner plus client adapters. Upstream realpath/content dedup and deterministic root precedence would avoid forcing users to break another client's native discovery.

  5. y10reo commented on Aug 11, 2026

    @y10reo

    Hi, I checked the current dev branch. add() still overwrites state.skills[name], and loadSkills() still parses matches with unbounded concurrency, so the winning source can depend on completion order.

    I reviewed #32203 and #30184 as well. The cleanup bot closed both after inactivity; maintainers did not reject either patch. I found no open PR for #32202.

    I can revive the smaller #32203 approach against current dev: sort scan results and directories, resolve duplicates in discovery order, preserve the current source precedence, and add Windows coverage for .claude, .agents, and .opencode. I would leave any new precedence policy for a separate discussion.

    Is someone working on this, or may I send that PR?

  6. eshe-huli commented on Aug 31, 2026

    @eshe-huli

    Related: #46327 — same multi-root collision, but the user-visible failure on 1.18.25 Desktop is a WARN flood (once per workspace) when ~/.claude/skills/<name> is a symlink to ~/.agents/skills/<name> (same inode, still two locations). Nested remotion-best-practices/**/SKILL.md collides with top-level copies as well.

  7. cmsflash commented on Sep 1, 2026

    @cmsflash

    Independent reproduction on 1.18.15 (released build, macOS 26.6.2 arm64), with a datapoint the thread does not have yet: this reproduces even when the two roots are the same directory.

    I consolidated my hubs down to one, expecting that to fix it:

    ~/.agents/skills            REAL (13 skills)
    ~/.claude/skills         -> ~/.agents/skills   (root-level symlink)
    ~/.config/opencode/skills   removed
    

    There is now exactly one real skills directory on disk. It still fails, because discovery globs .claude and .agents independently and the glob follows the symlinked root, so both phases still yield distinct absolute path strings for the same file.

    Minimal fixture — two roots, one target, isolated HOME:

    /tmp/globtest/home/.agents/skills -> /tmp/globtest/real
    /tmp/globtest/home/.claude/skills -> /tmp/globtest/home/.agents/skills
    
    WARN duplicate skill name name=one existing=.../.agents/skills/one/SKILL.md duplicate=.../.claude/skills/one/SKILL.md
    WARN duplicate skill name name=two existing=.../.claude/skills/two/SKILL.md duplicate=.../.agents/skills/two/SKILL.md
    

    Note one and two resolve from opposite roots within a single run.

    Confirming @sergical's correction over "per process": on my real install (13 skills, ~28.3k-char system prompt, anthropic/claude-haiku-4-5 via a LiteLLM gateway, captured on the wire by a proxy), 6 consecutive opencode run invocations gave:

    distinct system-prompt hashes: 6 / 6
    cache hits:                    0 / 6      (write=65490 every run, read=0 every run)
    

    sysLen was identical (28351) in all six, so this is not content drift — only the <location> strings move. The .claude-vs-.agents split wandered per run: claude=3,4,2,2,6,4 of 20 location lines. Deterministic ordering alone would fix it; byte-length stability means the prompt is otherwise stable.

    Two practical notes for anyone landing a fix:

    • skills.paths cannot be used as a workaround. It is additive (skills?.paths ?? [] is iterated after the built-in roots), so it cannot suppress a hardcoded root. The roots are constants: var bA=".claude", xA=".agents", GA="skills/**/SKILL.md".
    • The only configuration I found that yields 0 duplicates is ~/.claude/skills not existing — which is not viable for anyone also running Claude Code, since it has no configurable skills location.

    Dedupe by resolved realpath (rather than path string) would additionally cover the symlinked-root case above, which basename dedupe alone handles only incidentally.

    Both #32203 and #30184 appear to have been closed by the inactivity bot rather than on review.


    Posted by an AI agent on behalf of @cmsflash, who ran into this on his own machine. The investigation and all measurements above were performed by the agent against a real 1.18.15 install and are reproducible.

  8. afsozer commented on Sep 6, 2026

    @afsozer

    Independent reproduction on 1.18.20 (release build, macOS 26 arm64), with two datapoints the thread may find useful: a measurement of what the flip costs on a local prefix-cached backend, and a workaround that made the prompt byte-stable.

    Setup: ~20 skills present as physical copies in both ~/.claude/skills and ~/.agents/skills (Claude Code and Codex each read only their own root, so both copies have to exist). Also the superpowers plugin, whose package ships brainstorming and systematic-debugging, which also exist in both roots.

    Method: pointed the provider baseURL at a tiny local HTTP server that dumps the request body, ran opencode run --agent <custom> "selam" three times per configuration, diffed the captured system prompts.

    configuration 3 consecutive starts byte-identical?
    default (both roots + plugin) no – every <location> flips between .claude and .agents
    ~/.agents/skills symlinked to ~/.claude/skills no – same flips, path text differs
    only the 2 plugin-duplicated skills removed from ~/.agents no – remaining 18 still flip; the 2 now flip between ~/.claude and the plugin package
    OPENCODE_DISABLE_EXTERNAL_SKILLS=1 + ~/.config/opencode/skills = symlinks to ~/.claude/skills/* no – the 2 plugin-duplicated skills still flip between the single root and the package
    same, but the 2 plugin-provided skills excluded from the single root yes

    So the race is not limited to the ~/.claude vs ~/.agents pair; a plugin package that ships a skill of the same name races with a single configured root too.

    Cost, measured on a local backend with a persistent prefix cache (oMLX, 2048-token blocks): the first divergent <location> lands around token ~6.1–8k of a 13.4k-token prompt, so a fresh session reuses only the first 3 blocks (tool definitions) and re-prefills the remaining ~7k tokens every start. On a 27B model on an M5 laptop that is ~30 s of extra prefill per new session that would otherwise be a cache hit. In-session turns are unaffected. For cloud providers this is the "one cache miss per restart" already described above, but for slow local backends it is a noticeable wall-clock penalty.

    Workaround for anyone needing the exact bytes stable today: OPENCODE_DISABLE_EXTERNAL_SKILLS=1, a single ~/.config/opencode/skills root built from per-skill symlinks, and leaving out any skill that a plugin already provides. Deterministic precedence in add() (or sorting the scan results before dedup, as #32203 proposed) would make this unnecessary.

  9. neriousy commented on Oct 1, 2026

    @neriousy
    Member

    In OpenCode v2, the winner among skills with the same name is deterministic. Sources load in a fixed order (built-in, then .claude/skills, then .agents/skills, then configured skill paths), later sources win, and files are read in sorted order. <available_skills> no longer changes between restarts.

    Fixed in #41622; the precedence is documented in #41844.

    Closing as completed. If something doesn't work on v2, please open a new issue.

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