Repository navigation
Description
Summary
opencode auto-discovers skills from six default locations including ~/.claude/skills/ and ~/.agents/skills/. When the same skill basename is reachable through more than one root (a common Claude Code setup, where ~/.claude/skills/<name> is symlinked to ~/.agents/skills/<name>), opencode's discovery randomly assigns one path or the other per session in the emitted <skill><location>...</location></skill> entries. This injects per-session volatility into the system prompt at random offsets, defeating prefix-cache reuse on every upstream backend.
Environment
- opencode dev branch (also reproduced on 1.15.11 release)
- Skills available in both
~/.claude/skills/(mostly symlinks →~/.agents/skills/) and~/.agents/skills/ - 20 skills are reachable through both roots in our setup
Steps to reproduce
- Have
~/.agents/skills/foo/SKILL.mdand~/.claude/skills/foo -> ../../.agents/skills/foo(symlink, or independent copy) - Open two fresh opencode sessions
- Capture the outgoing system prompt in each session (we did this via a LiteLLM
async_pre_call_hookwriting the full system payload to disk) - Diff the two captures
Observed
Two fresh sessions in the same cwd, with identical config, produced byte-different <skill><location>...</location></skill> entries:
@@ -443,7 +443,7 @@
<skill>
<name>linear-reporting</name>
- <location>file:///Users/.../.agents/skills/linear-reporting/SKILL.md</location>
+ <location>file:///Users/.../.claude/skills/linear-reporting/SKILL.md</location>
</skill>
@@ -453,7 +453,7 @@
<skill>
<name>officecli</name>
- <location>file:///Users/.../.agents/skills/officecli/SKILL.md</location>
+ <location>file:///Users/.../.claude/skills/officecli/SKILL.md</location>
</skill>
@@ -495,7 +495,7 @@
<skill>
<name>pr-verify</name>
- <location>file:///Users/.../.claude/skills/pr-verify/SKILL.md</location>
+ <location>file:///Users/.../.agents/skills/pr-verify/SKILL.md</location>
</skill>Note that the resolved root flips both directions across skills: linear-reporting goes .agents → .claude between sessions, while pr-verify goes .claude → .agents. So this isn't "preferred root" inconsistency — it's purely non-deterministic per-skill resolution.
Why it matters
Each one of these <location> flips changes ~30 characters of the system prompt at a deep offset. Every upstream prefix cache (Anthropic cache_control, OpenAI auto-cache, vLLM APC, oMLX paged SSD) keys on byte/block-level prefix match. Even with the prompt structurally identical, the location-string flips break cache hit rate.
Measured impact in our setup (Qwen3-Coder-Next-80B-A3B via oMLX on M3 Ultra):
- With this non-determinism: cache.read=0 or ~4096 tokens of 30,000 (~13%) per fresh session, ~30 s TTFT every session
- After workaround (see below): cache.read=28,672 / 30,000 (95.6%), 3–5 s TTFT on subsequent fresh sessions
Workaround
Set OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 in the launching environment. This drops ~/.claude/skills/ from external discovery entirely, leaving ~/.agents/skills/ as the only external root and eliminating the duplicate-resolution path. Tested and confirmed effective.
This workaround is not documented. We discovered it by reading packages/opencode/src/skill/index.ts (the disableClaudeCodeSkills flag) and packages/opencode/src/effect/runtime-flags.ts (the env var wire-up).
Proposed fix
In packages/opencode/src/skill/index.ts, deduplicate skills by basename at discovery time. When the same skill name appears in multiple roots, pick a canonical location (e.g., prefer .agents/skills/<name> over .claude/skills/<name>, or sort roots alphabetically and take the first match). Then sort the final set deterministically before emitting <skill> blocks.
This would:
- Eliminate the per-session non-determinism
- Restore prefix-cache hit rate to the structural maximum
- Make the
OPENCODE_DISABLE_CLAUDE_CODE_SKILLSworkaround unnecessary for this scenario
Relationship to #18215 (NOT a duplicate)
A bot flagged #18215 ("Non-deterministic agent/skill ordering in tool descriptions breaks prompt caching", CLOSED 2026-03-19) as a possible duplicate. It is not — the two issues describe orthogonal axes of non-determinism, and #18215's fix does not fix this one.
| Property | #18215 (closed) | This issue (#29950) |
|---|---|---|
| Non-determinism axis | List order — which slot does skill X occupy? | Entry content — what URL appears in skill X's <location> tag? |
| Root cause | accessibleAgents / Skill.available() returned in unsorted insertion order |
Same skill basename reachable through .claude/skills/ and .agents/skills/; discovery picks one root randomly per session |
| Fix surface | packages/opencode/src/tool/task.ts:39, packages/opencode/src/tool/skill.ts:10 (.sort(localeCompare)) |
packages/opencode/src/skill/index.ts (deduplicate by basename at discovery, canonicalize root selection) |
| Status | CLOSED — fix in dev | OPEN — reproduces on dev and 1.15.11 |
The reproducer in this issue's body was captured against current dev — which already includes #18215's sort fix. The byte diff sits inside the <location> tag of each <skill> entry, not in the ordering of entries. Sorting by name pins linear-reporting at index N every session, but the URL inside that entry still flips .agents ↔ .claude, so byte-level prefix caches still miss.
In short: #18215 made the order of skill entries deterministic. This issue is about making the content of each entry deterministic. Both are needed for a fully stable system prefix.
Related
- Companion PR: fix(session): move env block to tail of system prompt for cache stability #29949 — fix(session): move env block to tail of system prompt for cache stability
- Anthropic prompt caching misses when dynamic user.system is merged into the static prefix #20110, System environment prompt causes cache invalidation #5224 (system-prompt cache invalidation issues this is adjacent to)
Activity
github-actions commentedon May 30, 2026
This issue might be a duplicate of existing issues. Please check:
If you've already verified that #18215's fix did not address your specific case, feel free to note that here to help maintainers triage.
rikkarth commentedon May 30, 2026
For the maintainer triaging this — the bot flagged #18215 as a possible duplicate. It isn't:
<skill>/<agent>entries in tool descriptions. Fixed by.sort(localeCompare)intool/task.tsandtool/skill.ts.<skill><location>entry when the same basename is reachable through multiple discovery roots. Different file, different code path, different non-determinism axis.The reproducer in the body was captured against current dev (which already has #18215's sort applied). The byte diff is inside each
<location>tag, not in the ordering of<skill>blocks. I added an explicit "Relationship to #18215" section in the body to spell this out side-by-side.lexlian commentedon Jun 1, 2026
I fixed the issue with PR #30184.
Root Cause
discoverSkillsinpackages/opencode/src/skill/index.tsscans skills from five phases in fixed sequential order:~/.claude/skills/~/.agents/skills/When the same skill basename exists in multiple roots,
Glob.scanreturns both absolute paths. Both were added to aSet<string>. JavaScript Set iteration order is insertion-order within a process but not stable across sessions — the same two paths could iterate in either order on restart. Sincestate.skillsis keyed by skill name (not path), whichever path was processed last would overwrite the first viastate.skills[md.data.name] = {...}. This caused<skill><location>to flip between.claudeand.agentsper session, breaking byte-level prompt cache reuse.Fix
DEDUPE_MODE Design
Introduced a
DEDUPE_MODEconst object with three modes:KeepFirst(default): When a new basename is already in theseenMap, skip inserting the new path. First-discovered path wins deterministically.KeepLast: When a new basename is already in the Map, delete the old path fromstate.matches, then insert the new one. Latest-discovered path wins.KeepBoth: No deduplication — both paths remain instate.matches. Set iteration order determines winner (preserves pre-fix behavior).With
KeepFirst(default), priority across all 5 phases follows scan order:~/.claude/skills/~/.agents/skills/.opencode/skills/via configDirs.claude/skills/via upDirs (project).agents/skills/via upDirs (project)Major Code Changes
packages/opencode/src/skill/index.ts—scanfunction:dedupeModeparameter (default:DEDUPE_MODE.KeepFirst)seen: Map<string, string>toScanState(basename → path)packages/opencode/src/skill/index.ts—discoverSkillsfunction:statenow initialized withseen: new Map()alongsidematchesanddirsscan()calls passdedupeModeparameterpackages/opencode/test/skill/skill.test.ts— new test block:KeepFirst: deduplicates same basename from .claude and .agents, .claude winsKeepFirst: available() returns stable list across callsKeepFirst: skills with unique basenames are all retainedfeat(agents): share pi-skills across every coding agent
github-actions commentedon Aug 1, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.
rneilson-cerby commentedon Aug 4, 2026
Please reopen, issue still relevant
feat(agents): share pi-skills across every coding agent
feat(agents): share pi-skills across every coding agent
feat(agents): share pi-skills across every coding agent
feat(agents): share pi-skills across every coding agent