/** * Claude Code custom-agent definition injection (devlog 260712 070). * * The Agent tool's `model` argument is a hard 4-alias enum (2.1.207 binary), but an * agent DEFINITION's frontmatter `model:` is a free string ("Model alias this agent * uses. If omitted, inherits the parent's model"). So we sync the featured * subagent roster (config.subagentModels, <=5) plus the main model (when not * already covered) into ~/.claude/agents/ocx-*.md — one dispatchable * `subagent_type` per routed model, loaded at the next session start. * * Ownership contract: this module only creates/overwrites/deletes files matching * `ocx-*.md` inside the agents dir. User-authored agents are never touched. */ import { lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { OcxConfig } from "../types"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; import { getOrCreateDirectiveSigningKey } from "./directive-key"; import { signDirective } from "./directive-sign"; import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias"; import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, withOneMillionMarker } from "./context-windows"; import { claudeConfigDir } from "./gateway-cache"; import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config"; import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound"; import { AnthropicRequestError } from "./inbound-records"; import { knownModelIdsForProvider } from "../router"; import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec"; export interface ClaudeAgentDef { file: string; name: string; model: string; description: string; effort?: NonNullable["subagentEffort"]; blockedSkills: readonly string[]; } const OWNED_PREFIX = "ocx-"; /** Ownership proof (audit 071 #2): a file without this marker is NEVER touched. */ const GENERATED_MARKER = "generated-by: opencodex"; function sanitizeName(value: string): string { const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return cleaned.length > 0 ? cleaned : "model"; } /** * The user's default model as saved by the /model picker (settings.json `model`). * `model: "inherit"` in agent frontmatter is DISPROVEN on 2.1.207 (live: a * no-model ocx-self dispatch fell back to claude-fable-5 — devlog 072), so the * self-clone pins this value instead, refreshed at every launch-time sync. */ function pickerDefaultModel(configDir: string): string | null { try { const parsed = JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8")) as Record; return typeof parsed.model === "string" && parsed.model.trim() !== "" ? parsed.model.trim() : null; } catch { return null; } } /** Roster entry -> alias + display parts. Entries are bare native slugs or "provider/id". * Codex-facing encoded ids (`provider/vendor-model`) decode to the native slash id first * so the alias joins the raw-native context-window map (context-windows.ts). */ /** * Generated subagent defs cannot rely on the parent's auto-context compaction * pairing, so their [1m] marker follows the AUTHORITATIVE window only: mark when * the effective window (exact selector, then the canonical [1m] form, then bare) * is genuinely >= 1M; strip an inherited unsafe marker back to the bare selector; * with no window information, keep the selector as it was. Genuine routed [1m] * ids are preserved through the canonical-exact lookup. (#854) */ function withSubagentContextMarker(selector: string, windows: Record): string { const bare = stripOneMillionMarker(selector); const wasMarked = selector !== bare; const canonicalExact = wasMarked ? `${bare}[1m]` : selector; const authoritativeWindow = windows[selector] ?? windows[canonicalExact] ?? windows[bare]; if (typeof authoritativeWindow === "number" && authoritativeWindow > 0) { return shouldMarkOneMillion(authoritativeWindow, AUTO_CONTEXT_OFF) ? (withOneMillionMarker(selector, windows) ?? selector) : bare; } return wasMarked ? selector : bare; } function entryParts(entry: string, config: OcxConfig): { alias: string; id: string; provider: string } { const slash = entry.indexOf("/"); if (slash > 0) { const provider = entry.slice(0, slash); const prov = hasOwnProvider(config.providers, provider) ? config.providers[provider] : undefined; const id = prov ? decodeRoutedModelIdOrThrow(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov, config)) : entry.slice(slash + 1); return { alias: claudeCodeAlias(provider, id), id, provider }; } return { alias: claudeCodeNativeAlias(entry), id: entry, provider: "native" }; } export function buildClaudeAgentDefs(config: OcxConfig, windows: Record, configDir = claudeConfigDir()): ClaudeAgentDef[] { const blockedSkills = effectiveBlockedSkillNames(config.claudeCode); const blockedSkillsFor = (model: string): readonly string[] => { const unmarked = stripOneMillionMarker(model); try { const nativePassthrough = config.claudeCode?.nativePassthrough !== false && !unmarked.includes("/") && /^(claude|anthropic)(?:-|$)/i.test(unmarked) && resolveInboundModel(unmarked, config.claudeCode) === unmarked; return nativePassthrough ? [] : blockedSkills; } catch (error) { // A stale Desktop selector must not break the roster or acquire native exemptions. if (error instanceof AnthropicRequestError) return blockedSkills; throw error; } }; const defs: ClaudeAgentDef[] = []; const usedNames = new Set(); const coveredModels = new Set(); const push = (name: string, alias: string, description: string) => { // Generated defs mark [1m] on the authoritative window only — never the // main-session auto-context predicate (a 372K route marked [1m] would be // accounted at 1M with no compaction safety net in the subagent). const model = withSubagentContextMarker(alias, windows); const bare = alias.toLowerCase(); if (coveredModels.has(bare)) return; coveredModels.add(bare); let unique = name; for (let i = 2; usedNames.has(unique); i++) unique = `${name}-${i}`; usedNames.add(unique); defs.push({ file: `${OWNED_PREFIX}${unique}.md`, name: `${OWNED_PREFIX}${unique}`, model, description, effort: config.claudeCode?.subagentEffort, blockedSkills: blockedSkillsFor(model), }); }; // Default roster applies only when the field is UNSET — an explicit [] is // respected (audit 071 #6: an upgraded config must not lose the default five). const roster = config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels; for (const entry of roster.slice(0, 5)) { if (typeof entry !== "string" || entry.trim() === "") continue; const { alias, id, provider } = entryParts(entry.trim(), config); push(sanitizeName(id), alias, `Delegate work to ${id} (${provider}) via opencodex routing. General-purpose worker/explorer on that model. ${NO_MODEL_ARG}`); } // Self-clone slot: pin the picker-saved default (settings.json), falling back to // config.claudeCode.model. `inherit` is NOT honored by 2.1.207 (live-disproven, // devlog 072); a session started with a divergent --model stays divergent until // the next launch sync — documented limit. No resolvable default -> no self def. const selfModel = pickerDefaultModel(configDir) ?? (config.claudeCode?.model?.trim() || null); if (selfModel) { const marked = withSubagentContextMarker(selfModel, windows); defs.push({ file: `${OWNED_PREFIX}self.md`, name: `${OWNED_PREFIX}self`, model: marked, description: `Self-clone: delegate to your default main model (${marked}), synced from the /model picker at launch. ${NO_MODEL_ARG}`, effort: config.claudeCode?.subagentEffort, blockedSkills: blockedSkillsFor(marked), }); } return defs; } export function getActiveRosterDirectives( config: OcxConfig, windows: Record = {}, configDir?: string, ): Map { if (config.claudeCode?.injectAgents === false || config.claudeCode?.enabled === false) { return new Map(); } const defs = buildClaudeAgentDefs(config, windows, configDir); const roster = new Map(); for (const def of defs) { const raw = def.model.trim().toLowerCase(); const stripped = stripOneMillionMarker(raw).trim().toLowerCase(); roster.set(raw, { effort: def.effort }); roster.set(stripped, { effort: def.effort }); } return roster; } export function isAllowedLegacyDirective( route: string, effort: string | null | undefined, config: OcxConfig, configDir?: string, ): boolean { try { if (!route || typeof route !== "string") return false; const rawRoute = route.trim().toLowerCase(); const strippedRoute = stripOneMillionMarker(route.trim()).toLowerCase(); const roster = getActiveRosterDirectives(config, {}, configDir); const match = roster.get(rawRoute) ?? roster.get(strippedRoute); if (!match) return false; if (effort) { const expectedEffort = match.effort ?? config.claudeCode?.subagentEffort; if (effort !== expectedEffort) return false; } return true; } catch { // Roster resolution must never crash the request path: an unreadable or // undecodable selector in config fails closed like any untrusted override. return false; } } function skillNameLiteral(name: string): string { return JSON.stringify(name) .replaceAll("`", "\\u0060") .replaceAll("<", "\\u003c") .replaceAll(">", "\\u003e"); } export function renderAgentDef(def: ClaudeAgentDef, key: string): string { const blockedSkillGuard = def.blockedSkills.length === 0 ? [] : [ "", `Do not invoke blocked Claude Code skills: ${def.blockedSkills.map(skillNameLiteral).join(", ")}.`, "Their document bundles are intentionally omitted for routed models; continue without loading them.", ]; // YAML frontmatter: model ids carry dots/brackets — always double-quote scalars. return [ "---", `name: ${JSON.stringify(def.name)}`, `description: ${JSON.stringify(def.description)}`, `model: ${JSON.stringify(def.model)}`, ...(def.effort ? [`effort: ${JSON.stringify(def.effort)}`] : []), "---", "", ``, // Proxy routing directive (devlog 072): 2.1.207 does not honor custom gateway // ids in agent frontmatter (falls back to sonnet — live-proven), but the agent // BODY rides the subagent's system prompt verbatim. The proxy detects this // directive and overrides the request model before routing/passthrough. ``, ...(def.effort ? [``] : []), ``, "", `You are a delegated worker running on \`${def.model}\` through the local opencodex proxy.`, `IDENTITY: your ACTUAL underlying model is \`${def.model}\` — the opencodex proxy routes this`, "session there regardless of what model name the Claude Code harness displays or claims.", "If asked which model you are, answer with the id above; do not guess a Claude model name.", ...blockedSkillGuard, "", "Complete the dispatched task directly and report results concisely. This file is", "auto-generated by opencodex (`ocx claude`) from the featured subagent roster —", "manual edits will be overwritten; remove the model from the roster to drop it.", "", ].join("\n"); } /** True only for a REGULAR file we generated (marker present; symlinks never owned). */ function isOwnedFile(path: string): boolean { try { const st = lstatSync(path); if (!st.isFile()) return false; // symlink or dir: never touch (audit 071 #2) return readFileSync(path, "utf8").includes(GENERATED_MARKER); } catch { return false; } } /** * Sync owned agent files: write/overwrite current defs, prune stale ocx-*.md, * never touch anything else. Ownership requires the generated marker; writes are * atomic (tmp + rename). Best-effort — returns null on any failure. */ export function syncClaudeAgentDefs( defs: readonly ClaudeAgentDef[], configDir = claudeConfigDir(), ocxConfigDir?: string, key?: string, ): string[] | null { try { const signingKey = key ?? getOrCreateDirectiveSigningKey(ocxConfigDir); const dir = join(configDir, "agents"); if (defs.length === 0) { try { lstatSync(dir); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } } else { mkdirSync(dir, { recursive: true }); } const keep = new Set(defs.map(d => d.file)); for (const existing of readdirSync(dir)) { if (!existing.startsWith(OWNED_PREFIX) || !existing.endsWith(".md")) continue; if (!keep.has(existing) && isOwnedFile(join(dir, existing))) { try { unlinkSync(join(dir, existing)); } catch { /* best-effort prune */ } } } const written: string[] = []; for (const def of defs) { const target = join(dir, def.file); // A pre-existing ocx-* file WITHOUT our marker is user property: skip the def. try { lstatSync(target); if (!isOwnedFile(target)) continue; } catch { /* does not exist: ours to create */ } const tmp = `${target}.tmp-${process.pid}`; writeFileSync(tmp, renderAgentDef(def, signingKey), { encoding: "utf8", mode: 0o644 }); renameAtomicFile(tmp, target, undefined, "claude-agents"); written.push(def.file); } return written; } catch { return null; } } /** Launch-time hook: gate + build + sync in one call (used by ocx claude and systemEnv). */ export function injectClaudeAgentDefs( config: OcxConfig, windows: Record, configDir?: string, ocxConfigDir?: string, key?: string, ): string[] | null { if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) { // Disabled: prune verified-owned files so stale definitions stop loading // in future sessions (audit 071 #3). return syncClaudeAgentDefs([], configDir, ocxConfigDir, key); } return syncClaudeAgentDefs(buildClaudeAgentDefs(config, windows, configDir), configDir, ocxConfigDir, key); } /** * Dispatcher directive appended to every ocx-* description. The ocx-route body * directive makes the Agent tool's `model` argument INERT (the proxy overrides * the request model before routing — live-proven), so instead of asking the * dispatcher to omit it (which caused schema-anxiety loops), we hand it a fixed * placeholder: any value works; "haiku" is canonical because a haiku-labeled call * is visibly a placeholder in the Claude Code UI, while "sonnet" was * indistinguishable from a genuine Sonnet call (issue #252). */ const NO_MODEL_ARG = "NOTE: this agent's real model is pinned by the opencodex proxy — the `model` argument is ignored. Pass model: \"haiku\" as a placeholder (or omit it); routing is unaffected either way.";