Files
6krrt/deploy/opencode-plugin/router-link.js
adlee-was-taken 5fa869c3df fix: remove non-function export that broke opencode plugin loader
opencode 1.18 treats every export of a plugin module as a plugin
function.  router-link.js exported a Map (), so the
loader rejected the entire module: chat.headers and tool.execute.after
never ran.  The 29 node tests passed because they imported the module
directly, bypassing the loader.

- Move parentCache from a named export to a property on RouterLink
- Add loader-contract test: every module export must be a function
- Update test imports to use RouterLink.parentCache

Evidence: opencode.log at every start since 2026-09-26T08:02Z shows
"Plugin export is not a function" for router-link.js.
2026-09-26 11:26:40 -04:00

225 lines
9.0 KiB
JavaScript

/**
* 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;