Skip to content

Skills silently vanish on re-parse: gray-matter cache is poisoned when strict-YAML frontmatter parse throws #42350

Description

@craftsamo

Summary

Project skills (and potentially agents/commands parsed via ConfigMarkdown.parse) silently vanish whenever the same markdown content is parsed more than once in a single server process and its frontmatter is not strict-YAML-valid (e.g. an unquoted description: containing : ).

Root cause: gray-matter 4.0.3 registers its module-level cache entry before parsing. When strict YAML parsing throws, the never-parsed entry (data: {}) stays in matter.cache keyed by the file content. opencode's sanitize() fallback rescues the first parse per unique content per process — but every later matter(content) call hits the poisoned cache entry, returns data: {} without throwing (so the fallback never runs), and Skill.add() drops the file silently at the isSkillFrontmatter guard.

This looks like the actual root cause behind #41751 ("exactly N skills silently dropped in server mode") and plausibly #41213.

Minimal repro (gray-matter level)

import matter from "gray-matter"

const s = `---\nname: x\ndescription: Use this. Trigger keywords: "e2e", "test"\n---\nbody`

try { matter(s) } catch (e) { /* YAMLException — opencode falls back to sanitize() here */ }
console.log(matter(s).data) // => {}   <- poisoned cache: no throw, no data, skill silently dropped

packages/core/src/config/markdown.ts:

export function parse(content: string) {
  try {
    return matter(content)          // 1st call: throws AND poisons matter.cache[content]
  } catch {
    return matter(sanitize(content)) // rescues the 1st call only
  }
}

gray-matter 4.0.3, index.js:

let file = toFile(input);
const cached = matter.cache[file.content];
if (!options) {
  if (cached) { ...return shallow copy... }
  matter.cache[file.content] = file;   // <- cached BEFORE parseMatter() runs
}
return parseMatter(file, options);     // <- throws on strict-YAML errors, cache entry stays empty

Server-level repro (v1.18.16, also reproduced on 1.18.18 via bunx [email protected])

  1. A project with skills whose description: contains an unquoted : (very common — e.g. ... Trigger keywords: "foo", "bar").
  2. opencode serve --port 4097 (with or without OPENCODE_EXPERIMENTAL_WORKSPACES).
  3. curl -H "x-opencode-directory: <project>" :4097/skill → all skills present (first discovery: throw → sanitize fallback works).
  4. curl -X POST -H "x-opencode-directory: <project>" :4097/instance/dispose, then GET /skill again → all affected project skills gone (count drops from e.g. 66 to 42), reproducible 15/15 times. No error or warning in logs — init count=… simply shrinks.

Because the cache is keyed by content, the poisoning also crosses instances and directories:

  • Mirror layouts (.claude/skills / .agents/skills symlinked to .opencode/skills): the 3 scans see identical content; only the first-parsed path survives — this is also why no duplicate skill name warnings appear for project mirrors.
  • Sibling forks: once repo A's add-e2e-test/SKILL.md was parsed, repo B's byte-identical copy loads as data:{} — opening a second project in the same server silently loses its skills. We measured fresh instances loading 0/12, 3/15, 6/20, 1/13 of their skills depending on how much identical content earlier instances had already parsed (the survivors were exactly the files with unique content).
  • CLI/TUI single-shot runs are mostly unaffected (one discovery per process), matching the "works in CLI, breaks in server mode" observation in v1.18.16 server/web mode: exactly 2 project skills silently dropped when project is a git repo (works in CLI, works without .git) #41751.

Suggested fix

Bypass gray-matter's cache in ConfigMarkdown.parse — any truthy options object disables it:

export function parse(content: string) {
  try {
    return matter(content, {})           // no cache read/write -> no poisoning
  } catch {
    return matter(sanitize(content), {})
  }
}

(Or delete matter.cache[content] before the fallback.) Skill discovery re-parses at most a few hundred small files per instance creation, so losing the cache is negligible. Additionally, Skill.add()'s isSkillFrontmatter failure path could log a warning instead of silently returning — that would have made this diagnosable from logs.

Workaround for affected users

Make skill frontmatter strict-YAML valid, e.g. convert unquoted colon-containing descriptions to block scalars (description: |-). After that, the first parse never throws, nothing is poisoned, and server-mode discovery is stable across /instance/dispose (verified: 8/8 full loads on the same previously-affected server).

Environment

  • opencode v1.18.16 (homebrew) and v1.18.18 (bunx opencode-ai), macOS arm64, Bun 1.3.14
  • gray-matter 4.0.3 (as pinned by packages/core)

Activity

enterdot commented on Sep 19, 2026

@enterdot

I can confirm this, also hit it on V2 (2.0.8). Also, it's not only skills that vanish, subagents silently fall back to mode: primary and hidden: false after the first reload, because every field in the agent schema is optional and the empty frontmatter just decodes to defaults. Quoting the affected frontmatter values works around it.

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