/** * Stamps requests to the local LLM router with conversation identity, and * reports test outcomes back to it. * * This replaces `router-outcome.js`, which only reported outcomes. A router * that knows *which conversation* and *which agent* a request belongs to can * attribute quality per-session and distinguish agent-specific behavior from * general model quality. That is the loop this plugin closes on the way in. * * Two hooks: * * chat.headers — for requests routed to the llm-router provider, set * X-Router-Conversation (the session id), * X-Router-Agent (the agent, slugged to the * router's charset), and X-Router-Parent * (the conversation's parent session, when known). * Only the llm-router provider gets these: another * provider would not understand them and they would * just leak identity. * * tool.execute.after — the old router-outcome logic, carried over * unchanged: watch test/build commands, POST * pass/fail to the router's /outcome endpoint so * feedback.py folds it into proficiency. It now also * carries conversation_id so the router can * attribute the outcome to the right session. * * Failure policy: no hook may throw or stall a request more than the 250ms * parent lookup. The router being down must never break the session — a * reporting/stamping failure is not the user's problem. * * Install: * command cp -f deploy/opencode-plugin/router-link.js ~/.config/opencode/plugins/ * rm ~/.config/opencode/plugins/router-outcome.js * * (`command cp` because `cp` is aliased to -i in zsh and silently does * nothing on an existing file; and the old file MUST go — both hook * tool.execute.after, so an outcome would be posted twice and counted twice * by feedback.py.) * * Or per-project, in .opencode/plugins/. */ import { setTimeout as sleep } from "node:timers/promises"; const ROUTER = process.env.LLM_ROUTER_URL || "http://127.0.0.1:8080"; const PROVIDER_ID = "llm-router"; // The deterministic path that the `chat.headers` hook must stay well under. const PARENT_TIMEOUT_MS = 250; const PARENT_CACHE_MAX = 1000; const TIMEOUT_SENTINEL = Symbol("parentOf timeout"); // Commands whose exit status is a real verdict on the work. Deliberately // narrow: a failing `ls` says nothing about model quality, and a false signal // is worse than no signal — it trains the router on noise. const TEST_COMMAND = new RegExp( [ "\\bpytest\\b", "\\bunittest\\b", "\\bnpm\\s+(run\\s+)?test\\b", "\\bpnpm\\s+(run\\s+)?test\\b", "\\byarn\\s+test\\b", "\\bvitest\\b", "\\bjest\\b", "\\bcargo\\s+(test|check|build)\\b", "\\bgo\\s+(test|build|vet)\\b", "\\bmake\\s+(test|check)\\b", "\\bmvn\\s+test\\b", "\\bgradle\\s+test\\b", "\\btsc\\b", "\\bruff\\b", "\\bmypy\\b", "\\beslint\\b", ].join("|"), ); // Failure signatures, for tools that exit 0 while reporting failures. const FAILURE_TEXT = /\b(\d+\s+failed|FAILED|FAIL\b|Traceback \(most recent call last\)|error(s)?:|panic:|AssertionError|✗|✖)/; function looksFailed(output) { const exit = output?.exitCode ?? output?.exit_code; if (typeof exit === "number" && exit !== 0) return true; const text = `${output?.stdout ?? ""}\n${output?.stderr ?? ""}\n${ typeof output?.output === "string" ? output.output : "" }`; // "0 failed" and "no errors" must not trip the failure regex. if (/\b0 failed\b|\bno errors?\b/i.test(text)) return false; return FAILURE_TEXT.test(text); } function commandOf(input) { const args = input?.args ?? input?.arguments ?? {}; return args.command ?? args.cmd ?? args.script ?? ""; } // Cache of successful parent lookups: session id -> parentID (or null for root // sessions when the SDK returns no parentID). We cache successes so repeated // requests in the same session do not keep hitting the SDK. We cache nothing on // timeout or throw -- a transient miss should not be sticky. // Eviction: when the Map reaches PARENT_CACHE_MAX, the oldest-inserted entry // is evicted first (Map preserves insertion order, so the first key is oldest). const parentCache = new Map(); function cacheSet(sessionID, parentID) { if (!parentCache.has(sessionID) && parentCache.size >= PARENT_CACHE_MAX) { const firstKey = parentCache.keys().next().value; parentCache.delete(firstKey); } parentCache.set(sessionID, parentID); } // Resolve a session's parent id, racing the SDK call against a timeout so a // slow session store can never stall request stamping. Returns undefined on // timeout or catch; never throws. Returns null for root sessions (no parent). async function parentOf(client, sessionID) { if (parentCache.has(sessionID)) return parentCache.get(sessionID); try { const result = await Promise.race([ client.session.get({ path: { id: sessionID } }), sleep(PARENT_TIMEOUT_MS).then(() => TIMEOUT_SENTINEL), ]); if (result === TIMEOUT_SENTINEL) return undefined; const parentID = result?.data?.parentID ?? null; cacheSet(sessionID, parentID); return parentID; } catch { return undefined; } } // Whether a request is bound for the local router. opencode identifies the // provider both on the model (`model.providerID`) and on the provider context // (`provider.info.id`); if neither is populated at runtime the provider's // base URL is a usable fallback, compared by ORIGIN: opencode.json sets // options.baseURL to something like http://127.0.0.1:8080/v1, which never // string-equals the bare router URL. A malformed URL on either side (the // provider's, or a bad LLM_ROUTER_URL) fails safe: no headers. function isRouterRequest(input, providerUrl) { if (input?.model?.providerID === PROVIDER_ID) return true; if (input?.provider?.info?.id === PROVIDER_ID) return true; if (!providerUrl) return false; try { return new URL(providerUrl).origin === new URL(ROUTER).origin; } catch { return false; } } // The router validates X-Router-Agent against ^[A-Za-z0-9._:-]{1,64}$ and // treats a failing value as absent, so a live name like "Sisyphus - // ultraworker" would silently lose attribution. Slug to the legal charset: // lowercase; runs of characters outside [a-z0-9._:-] become one '-'; repeated // '-' collapse; leading/trailing '-' trimmed; cut to 64. A name already in // the charset (e.g. "oh-my-claudecode:executor") passes through unchanged. function slugAgent(name) { return String(name) .toLowerCase() .replace(/[^a-z0-9._:-]+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 64); } export const RouterLink = async ({ client, directory }) => { return { "chat.headers": async (input, output) => { const providerUrl = input?.provider?.info?.options?.baseURL ?? input?.provider?.info?.options?.url ?? input?.provider?.info?.api?.url; if (!isRouterRequest(input, providerUrl)) return; output.headers["X-Router-Conversation"] = input.sessionID; const agent = slugAgent(input.agent ?? ""); if (agent) output.headers["X-Router-Agent"] = agent; const parentID = await parentOf(client, input.sessionID); if (parentID) output.headers["X-Router-Parent"] = parentID; }, "tool.execute.after": async (input, output) => { // Only shell-ish tools carry a command whose exit status is a verdict. const command = commandOf(input); if (!command || !TEST_COMMAND.test(command)) return; const ok = !looksFailed(output); const detail = `${command.slice(0, 120)}${ok ? " — passed" : " — failed"}`; try { const res = await fetch(`${ROUTER}/outcome`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ok, detail, source: directory, conversation_id: input.sessionID, }), // The router being down must never break the user's session. signal: AbortSignal.timeout(3000), }); if (res.status === 409) { // Several sessions active and the directory did not match one the // router had seen. Dropping the sample is the correct outcome. console.error( "[router-link] ambiguous session; outcome not recorded", ); } else if (!res.ok && res.status !== 404) { console.error(`[router-link] ${res.status} reporting outcome`); } } catch (err) { // Swallowed on purpose: a reporting failure is not the user's problem. console.error(`[router-link] could not reach ${ROUTER}: ${err.message}`); } }, }; }; // Attach parentCache for test introspection. opencode's plugin loader // rejects any non-function export (see loader-contract test), so the map // lives as a property on the factory function rather than a named export. RouterLink.parentCache = parentCache;