// Make the team's GitHub Copilot agents usable from opencode. // // The rest of the team drives GitHub Copilot, which reads custom agents from // `.github/agents/.agent.md` (markdown body = system prompt, YAML // frontmatter = metadata). opencode has its own agent format and does NOT read // those files. This plugin bridges the gap: on startup it scans the repo's // `.github/agents` directory, translates each `*.agent.md` into an opencode // agent, and injects them into the merged config via the `config` hook — so // `@mention`/Tab in opencode surface the same agents the team maintains, with // zero opencode-specific files added to the shared repo. // // This file is the source of truth for the github-agents plugin. It is bundled // into the opencode-chamber wrapper (see ../../flake.nix) and loaded via // OPENCODE_CONFIG_CONTENT's `plugin` array, so `nix run .` ships it // automatically. Tweak the CONFIG constants below and rebuild to change // behavior. // // Mapping (.agent.md -> opencode agent): // frontmatter `name` -> agent name (slugified; falls back to filename) // frontmatter `description` -> description // frontmatter `temperature` -> temperature (if numeric) // frontmatter `model` -> model (only when APPLY_MODEL; see below) // markdown body -> prompt (system prompt) // everything else (tools …) -> intentionally ignored (see notes below) // // Notes / deliberate limitations: // * Tools are NOT translated. Copilot's `tools:` allow-list uses tool names // that don't map 1:1 to opencode's permission model, and guessing wrong // would either wrongly lock down or wrongly open up an agent. Injected // agents therefore inherit opencode's default permissions for their mode. // Restrict them with an explicit `agent..permission` block in your // own opencode config if needed — explicit opencode config always wins // (see the "respect explicit config" check below). // * Models are NOT applied by default. Copilot model names (e.g. "gpt-4o") // are not opencode model ids, so by default injected agents inherit your // global opencode model. Flip APPLY_MODEL to true to map them via // MODEL_PROVIDER / MODEL_MAP. import { existsSync, readFileSync, readdirSync, statSync } from "node:fs" import { basename, dirname, join, parse } from "node:path" // ---------------------------------------------------------------------------- // CONFIG — edit and rebuild to change behavior. // ---------------------------------------------------------------------------- // Where Copilot keeps custom agents, relative to the repo root, and the // filename suffix that identifies them. const AGENTS_SUBDIR = join(".github", "agents") const FILE_SUFFIX = ".agent.md" // opencode mode for injected agents: "primary" (Tab), "subagent" (@mention / // Task only), or "all" (both). "all" is the most flexible default. const DEFAULT_MODE = "all" // Model handling. When APPLY_MODEL is true, a frontmatter `model:` is mapped to // an opencode `${MODEL_PROVIDER}/` model. opencode's `github-copilot` // provider exposes the same models Copilot offers, and its ids are just the // model's lowercased, hyphenated label, so most Copilot values map by simple // normalization: // // "Claude Sonnet 4.5" / "claude-sonnet-4.5" -> github-copilot/claude-sonnet-4.5 // "GPT-5.4" -> github-copilot/gpt-5.4 // "Gemini 2.5 Pro" -> github-copilot/gemini-2.5-pro // // We resolve as: explicit MODEL_ALIASES -> normalize -> validate against the // provider's live model list (fetched via the SDK, never stale) -> set // `${MODEL_PROVIDER}/`. A value already containing "/" is treated as a full // opencode model id and used verbatim. If a value can't be resolved, the agent // inherits your global opencode model and a warning is logged listing what was // available, so you can add an alias below. // // Set APPLY_MODEL to false to ignore frontmatter models entirely (every agent // then inherits your global opencode model). const APPLY_MODEL = true const MODEL_PROVIDER = "github-copilot" // Explicit overrides for names that don't normalize cleanly to a valid id. // Key = the raw frontmatter value (or its lowercase form); value = either a // bare id (gets the MODEL_PROVIDER prefix) or a full "provider/id". const MODEL_ALIASES = { // "gpt-4o": "gpt-5.4", // "claude sonnet 4": "claude-sonnet-4.5", } // opencode's built-in agent names. We never overwrite these from a .agent.md. const RESERVED = new Set([ "build", "plan", "general", "explore", "scout", "compaction", "title", "summary", ]) // ---------------------------------------------------------------------------- // Frontmatter parsing — a deliberately small YAML subset. // // Copilot agent frontmatter is shallow: scalar fields plus a `tools` list. We // support `key: scalar`, inline arrays (`key: [a, b]`), and block arrays // (`key:` then ` - item` lines), quoted/bare strings, booleans, and numbers. // That covers the real files without pulling in a YAML dependency (this plugin // runs from a read-only Nix store path, so it can't bun-install one). // ---------------------------------------------------------------------------- function parseScalar(raw) { const v = raw.trim() if (v === "") return "" if ( (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ) { return v.slice(1, -1) } if (v === "true") return true if (v === "false") return false if (v === "null" || v === "~") return null if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v) return v } function parseInlineArray(raw) { const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "") if (inner.trim() === "") return [] const parts = [] let cur = "" let quote = null for (const ch of inner) { if (quote) { if (ch === quote) quote = null else cur += ch continue } if (ch === '"' || ch === "'") { quote = ch continue } if (ch === ",") { parts.push(cur) cur = "" continue } cur += ch } parts.push(cur) return parts.map((p) => parseScalar(p)) } function parseYaml(src) { const data = {} const lines = src.split(/\r?\n/) let i = 0 while (i < lines.length) { const line = lines[i] if (!line.trim() || line.trim().startsWith("#")) { i++ continue } const indent = /^(\s*)/.exec(line)[1].length const kv = /^\s*([A-Za-z0-9_-]+):\s*(.*)$/.exec(line) if (!kv) { i++ continue } const key = kv[1] const rest = kv[2] if (rest === "") { // Possible block array: collect deeper-indented `- item` lines. const items = [] let j = i + 1 while (j < lines.length) { const l = lines[j] if (!l.trim()) { j++ continue } const childIndent = /^(\s*)/.exec(l)[1].length const item = /^\s*-\s+(.*)$/.exec(l) if (item && childIndent > indent) { items.push(parseScalar(item[1])) j++ } else break } data[key] = items i = j continue } if (rest.startsWith("[")) { data[key] = parseInlineArray(rest) i++ continue } data[key] = parseScalar(rest) i++ } return data } function parseFrontmatter(text) { const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text) if (!m) return { data: {}, body: text } return { data: parseYaml(m[1]), body: m[2] } } // ---------------------------------------------------------------------------- // Translation helpers. // ---------------------------------------------------------------------------- function slugify(s) { return String(s) .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") } // Lowercase, collapse runs of whitespace/underscores to a single hyphen, and // collapse repeated hyphens — turning a Copilot label into an opencode id. function normalizeModelName(s) { return String(s) .trim() .toLowerCase() .replace(/[\s_]+/g, "-") .replace(/-+/g, "-") } // Resolve a frontmatter `model:` value to an opencode model id, or null if it // can't be mapped. `validIds` is the Set of bare model ids the provider // actually offers (from the SDK), or null when that list is unavailable — in // which case we fall back to a best-effort normalized id without validation. function resolveModel(raw, validIds) { const value = String(raw).trim() if (value === "") return null // Already a full "provider/id" — trust it verbatim. if (value.includes("/")) return value // Explicit alias wins (matched against the raw value or its lowercase form). const alias = MODEL_ALIASES[value] ?? MODEL_ALIASES[value.toLowerCase()] if (alias) return alias.includes("/") ? alias : `${MODEL_PROVIDER}/${alias}` const id = normalizeModelName(value) if (!id) return null if (validIds) { // We know the real list: only accept a confirmed id. return validIds.has(id) ? `${MODEL_PROVIDER}/${id}` : null } // No list to check against; best effort. return `${MODEL_PROVIDER}/${id}` } // Fetch the set of bare model ids a provider offers, via the SDK. Returns null // on any error/timeout so model resolution degrades to best-effort instead of // blocking startup (the copilot list is fetched over the network and may be // slow or unavailable). async function fetchValidModelIds(client, providerId) { try { const res = await Promise.race([ client.config.providers(), new Promise((resolve) => setTimeout(() => resolve(null), 5000)), ]) if (!res) return null const data = res.data ?? res const provider = (data.providers ?? []).find((p) => p.id === providerId) if (!provider) return null const ids = Object.keys(provider.models ?? {}) return ids.length ? new Set(ids) : null } catch { return null } } function toAgent(data, body, fallbackName) { // Name comes from the FILENAME (planner.agent.md -> "planner"), not the // frontmatter `name:` — Copilot files tend to use a short filename and a // long descriptive `name:`, and the filename is the nicer @mention handle. const name = slugify(fallbackName) if (!name) return null const agent = { description: (data.description && String(data.description)) || (data.name && `GitHub Copilot agent "${data.name}"`) || `GitHub Copilot agent "${fallbackName}"`, mode: DEFAULT_MODE, prompt: body.trim(), } if (typeof data.temperature === "number") agent.temperature = data.temperature return { name, agent } } // Find the nearest `.github/agents` directory: prefer the git worktree root, // then walk up from the session's working directory. function findAgentsDir(directory, worktree) { const candidates = [] if (worktree) candidates.push(join(worktree, AGENTS_SUBDIR)) if (directory) { let dir = directory const root = parse(dir).root while (true) { candidates.push(join(dir, AGENTS_SUBDIR)) if (dir === root) break dir = dirname(dir) } } for (const c of candidates) { try { if (statSync(c).isDirectory()) return c } catch { // not here; keep looking } } return null } // ---------------------------------------------------------------------------- // Plugin. // ---------------------------------------------------------------------------- export const GithubAgentsPlugin = async ({ client, directory, worktree }) => { const log = (level, message, extra) => client?.app ?.log({ body: { service: "github-agents", level, message, extra } }) .catch(() => {}) return { // Runs once on init with the fully-merged config. We add agents only when // the name isn't already defined, so any explicit opencode `agent.` // config (or a built-in) always takes precedence. config: async (cfg) => { const dir = findAgentsDir(directory, worktree) if (!dir || !existsSync(dir)) return let files try { files = readdirSync(dir).filter((f) => f.toLowerCase().endsWith(FILE_SUFFIX), ) } catch (e) { log("warn", `could not read ${dir}`, { error: String(e) }) return } if (files.length === 0) return // Resolve the provider's live model list once (only when we'll use it). const validIds = APPLY_MODEL ? await fetchValidModelIds(client, MODEL_PROVIDER) : null cfg.agent = cfg.agent || {} const added = [] const skipped = [] for (const file of files.sort()) { let text try { text = readFileSync(join(dir, file), "utf8") } catch { continue } const { data, body } = parseFrontmatter(text) const built = toAgent(data, body, basename(file, FILE_SUFFIX)) if (!built) continue if (RESERVED.has(built.name)) { skipped.push(`${built.name} (reserved)`) continue } if (cfg.agent[built.name]) { skipped.push(`${built.name} (overridden by opencode config)`) continue } if (APPLY_MODEL && data.model) { const model = resolveModel(data.model, validIds) if (model) { built.agent.model = model } else { log( "warn", `could not map model "${data.model}" for agent "${built.name}"; inheriting default model`, { available: validIds ? [...validIds] : "unknown" }, ) } } cfg.agent[built.name] = built.agent added.push(built.name) } if (added.length) { log("info", `loaded ${added.length} GitHub agent(s)`, { dir, agents: added, skipped, }) } else if (skipped.length) { log("info", "no GitHub agents loaded", { dir, skipped }) } }, } }