From 12c905f727f463c57be91541969940f1b67f45b6 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Thu, 22 Oct 2026 22:01:04 +0300 Subject: [PATCH 1/5] feat: support OpenCode 2 alongside OpenCode 1 index.js is now the package main and carries both generations: OpenCode 2 calls setup (.opencode/plugins/orchestrate-v2.js), OpenCode 1 calls a lazy server that loads the unchanged V1 entry, so @opencode-ai/plugin is never imported on a V2 host. The V2 entry wires the same pure modules to the V2 API: skills via ctx.skill.transform, bootstrap and role-prompt refresh via the context session hook, [TASK OUTCOME]/[CACHE STATUS] via execute.after on the subagent tool, squad_* tools with JSON Schema inputs, and the rate-limit guard via the retry hook (vetoes the retry instead of aborting) plus the event stream for terminal errors and silent hangs. V2 shapes are converted at the edge in src/v2/adapt.js. Shared by both entries now: tool wording and variant checks (src/squad-tools.js), task notes (src/task-notes.js) and the process-wide lookups (.opencode/plugins/runtime.js). Not available on V2: toasts (no TUI handle for server plugins) and squad_compact's model choice (V2 compaction takes no model). Co-Authored-By: Claude Opus 5.5 --- .opencode/plugins/orchestrate-v2.js | 504 ++++++++++++++++++++++++++++ .opencode/plugins/orchestrate.js | 501 ++++----------------------- .opencode/plugins/runtime.js | 181 ++++++++++ index.js | 15 + knip.json | 2 +- package.json | 3 +- src/squad-tools.js | 142 ++++++++ src/task-notes.js | 129 +++++++ src/v2/adapt.js | 258 ++++++++++++++ src/v2/schemas.js | 53 +++ src/v2/skills.js | 95 ++++++ tests/squad-tools.test.js | 180 ++++++++++ tests/v2-adapt.test.js | 274 +++++++++++++++ 13 files changed, 1897 insertions(+), 440 deletions(-) create mode 100644 .opencode/plugins/orchestrate-v2.js create mode 100644 .opencode/plugins/runtime.js create mode 100644 index.js create mode 100644 src/squad-tools.js create mode 100644 src/task-notes.js create mode 100644 src/v2/adapt.js create mode 100644 src/v2/schemas.js create mode 100644 src/v2/skills.js create mode 100644 tests/squad-tools.test.js create mode 100644 tests/v2-adapt.test.js diff --git a/.opencode/plugins/orchestrate-v2.js b/.opencode/plugins/orchestrate-v2.js new file mode 100644 index 0000000..5e16ae7 --- /dev/null +++ b/.opencode/plugins/orchestrate-v2.js @@ -0,0 +1,504 @@ +/** + * opencode-squad plugin entry for OpenCode 2. + * + * Same responsibilities as orchestrate.js (the OpenCode 1 entry); read its + * header for the why. This file is only the OpenCode 2 wiring: + * + * - skills: registered via ctx.skill.transform (V2 has no config hook); + * - bootstrap + context-budget line: the `context` session hook, gated on + * the hook's own agent id (src/v2/adapt.js: applyOrchestratorTransformV2); + * - role-prompt refresh: the same hook, on the first system part; + * - [TASK OUTCOME] / [CACHE STATUS]: `execute.after` on the `subagent` tool; + * - squad_compact / squad_dump / squad_patch: ctx.tool.transform with JSON + * Schema inputs (src/v2/schemas.js); + * - rate-limit guard: the `retry` session hook vetoes the retry outright + * (V1 had to abort the session from an event), the event stream covers + * terminal errors and silent hangs. + * + * Differences from OpenCode 1, by design of the V2 API: + * - no toasts: a server plugin has no TUI handle in V2; + * - squad_compact cannot choose the summarising model: V2 compaction takes + * no model, so the session's compaction settings apply. + * + * Must not import `@opencode-ai/plugin` or zod (an OpenCode 2 host provides + * neither). Never awaits a domain read during setup: reads happen lazily in + * hooks and tools. + */ + +import { formatCompactReport } from "../../src/compact.js"; +import { buildLimitMap, buildVariantMap, estimateContextTokens } from "../../src/context.js"; +import { formatInventory, hasSquad } from "../../src/inventory.js"; +import { + evaluateRetry, + formatGuardNote, + initGuardState, + isGuardedTerminalError, + isSilentHang, + normalizeGuardConfig, +} from "../../src/rate-limit-guard.js"; +import { validateRoster } from "../../src/roster.js"; +import { + applySquad, + defaultAgentDir, + formatApplyReport, + readSquad, +} from "../../src/squad-apply.js"; +import { + formatVariantLines, + isSubagentCaller, + TOOL_DESCRIPTIONS, + validateVariants, +} from "../../src/squad-tools.js"; +import { subagentUsage, taskNotes } from "../../src/task-notes.js"; +import { isTaskResultEmpty } from "../../src/task-outcome.js"; +import { + appendToResult, + applyOrchestratorTransformV2, + applySystemPartsTransform, + lastAssistantModel, + retryToStatus, + subagentOutcome, + toV1Agents, + toV1Providers, + toV1UsageMessages, +} from "../../src/v2/adapt.js"; +import { SQUAD_COMPACT_INPUT, SQUAD_DUMP_INPUT, SQUAD_PATCH_INPUT } from "../../src/v2/schemas.js"; +import { loadSkills } from "../../src/v2/skills.js"; +import { + buildPerfLookup, + ensureGlobalModelDataFresh, + loadModelData, + loadRolePrompt, + ORCHESTRATOR_AGENT, + SKILLS_DIR, + startBenchmarkRefresh, + turnMemo, +} from "./runtime.js"; + +// How long squad_compact waits for the compaction before reporting anyway. +// Timing out is not a failure: the compaction keeps running server-side. +const COMPACT_TIMEOUT_MS = 180_000; + +// How often the silence backstop ticks (see isSilentHang). +const SILENCE_POLL_INTERVAL_MS = 15_000; + +/** @param {any} res a V2 response that is either the payload or `{ data }` */ +const unwrap = (res) => (res && typeof res === "object" && "data" in res ? res.data : res); + +/** @param {any} error V2 SessionStructuredError */ +const errorText = (error) => + [error?.type, error?.message].filter((v) => typeof v === "string" && v).join(": "); + +export default { + id: "orchestrate", + async setup(ctx) { + ensureGlobalModelDataFresh(); + startBenchmarkRefresh(); + + // Same option slot as V1: { package: "opencode-squad", options: { rate_limit_guard } }. + const guardConfig = normalizeGuardConfig(ctx.options?.rate_limit_guard); + + // --- lazy, process-lifetime lookups (V1 caches the same things) --- + + /** @type {Promise | null> | undefined} */ + let agentsPromise; + const getAgents = () => { + agentsPromise ??= ctx.agent + .list() + .then((r) => toV1Agents(unwrap(r))) + .catch(() => null); + return agentsPromise; + }; + + /** @type {Promise | null> | undefined} */ + let providersPromise; + const getProviders = () => { + providersPromise ??= ctx.model + .list() + .then((r) => toV1Providers(unwrap(r))) + .catch(() => null); + return providersPromise; + }; + const getLimitMap = async () => buildLimitMap((await getProviders()) ?? []); + // null means "could not ask", reported as unknown rather than as "none". + const getVariantMap = async () => { + const providers = await getProviders(); + return providers === null ? null : buildVariantMap(providers); + }; + + let inventory; + let squadDrafted = false; + const getInventory = async () => { + if (inventory !== undefined) return inventory; + const agents = await getAgents(); + squadDrafted = hasSquad(agents ?? []); + inventory = agents + ? formatInventory(agents, buildPerfLookup(), await getLimitMap()) + : "(no subagents available)"; + return inventory; + }; + + const getAgentModel = async (agentName) => { + if (!agentName) return null; + return (await getAgents())?.find((a) => a.name === agentName)?.model ?? null; + }; + + /** @param {string} sessionID */ + const readContext = async (sessionID) => unwrap(await ctx.session.context({ sessionID })) ?? []; + + // --- skills --- + + const skills = loadSkills([SKILLS_DIR]); + await ctx.skill.transform((editor) => { + for (const skill of skills) { + if (!editor.get(skill.id)) editor.add(skill); + } + }); + + // --- per-request transforms --- + + await ctx.session.hook("context", async (event) => { + try { + applySystemPartsTransform(event.system, loadRolePrompt); + } catch { + // The agent keeps the body it was generated with. + } + try { + await applyOrchestratorTransformV2(event, { + orchestratorAgent: ORCHESTRATOR_AGENT, + getInventory, + getLimitMap, + getHasSquad: () => squadDrafted, + getUsageMessages: async () => toV1UsageMessages(await readContext(event.sessionID)), + turnMemo, + }); + } catch { + // Best-effort, like V1: the turn goes out without the bootstrap. + } + }); + + // --- subagent result notes --- + + await ctx.tool.hook("execute.after", async (event) => { + if (event.tool !== "subagent" || event.status !== "completed") return; + const { sessionID: taskId, output } = subagentOutcome(event.result); + if (!taskId) return; + + let usage; + let model = null; + try { + const messages = await readContext(taskId); + usage = subagentUsage(toV1UsageMessages(messages)); + model = lastAssistantModel(messages); + } catch { + usage = { lastHitMs: null, contextTokens: undefined, usageUnknown: true }; + } + + const notes = taskNotes({ + taskId, + providerID: model?.providerID, + modelID: model?.modelID, + usage, + resultEmpty: isTaskResultEmpty(output), + modelData: loadModelData(), + limitMap: await getLimitMap(), + now: Date.now(), + }); + if (notes.length) event.result = appendToResult(event.result, notes.join("\n\n")); + }); + + // --- tools --- + + await ctx.tool.transform((editor) => { + editor.add({ + name: "squad_compact", + description: TOOL_DESCRIPTIONS.squad_compact, + input: SQUAD_COMPACT_INPUT, + execute: async (input, context) => { + if (isSubagentCaller(context.agent)) { + return { + content: + `Refused: ${context.agent} is a subagent, and compacting a session is the ` + + "orchestrator's call. Say in your result that your context is large; sarge " + + "decides what to do about it.", + }; + } + const taskId = String(input?.task_id ?? "").trim(); + if (!taskId) return { content: "squad_compact: task_id is required." }; + + let agentName; + try { + agentName = unwrap(await ctx.session.get({ sessionID: taskId }))?.agent ?? undefined; + } catch { + // Only the report's wording needs the agent. + } + const size = async () => { + try { + return estimateContextTokens(toV1UsageMessages(await readContext(taskId)))?.used; + } catch { + return undefined; + } + }; + const before = await size(); + + let timedOut = false; + try { + await ctx.session.compact({ sessionID: taskId }); + await Promise.race([ + ctx.session.wait({ sessionID: taskId }), + new Promise((resolve) => + setTimeout(() => { + timedOut = true; + resolve(undefined); + }, COMPACT_TIMEOUT_MS).unref?.(), + ), + ]); + } catch (e) { + return { + content: `squad_compact: the compaction call failed — ${e?.message ?? String(e)}`, + }; + } + // The size estimate reads the last completed turn, which a fresh + // compaction does not have yet: report "could not be read" rather + // than a number that predates the compaction. + const after = timedOut + ? undefined + : await size().then((n) => (n === before ? undefined : n)); + + const ignored = input?.model + ? ` The requested model ${input.model} was not used: OpenCode 2 compaction takes no model choice.` + : ""; + return { + content: + formatCompactReport({ + taskId, + agent: agentName, + before, + after, + model: "OpenCode's compaction model", + why: "OpenCode 2 picks it; squad_compact cannot choose", + timedOut, + }) + ignored, + }; + }, + }); + + editor.add({ + name: "squad_dump", + description: TOOL_DESCRIPTIONS.squad_dump, + input: SQUAD_DUMP_INPUT, + execute: async (input) => { + const dir = input?.directory || defaultAgentDir(); + const { roster, conflicts } = readSquad(dir); + const count = + Object.keys(roster.grunts ?? {}).length + Object.keys(roster.drills ?? {}).length; + const note = conflicts.length + ? `\n\nnotes:\n${conflicts.map((c) => ` ${c}`).join("\n")}` + : ""; + const variantLines = formatVariantLines(await getVariantMap(), roster); + return { + content: + `Agent dir: ${dir}\n${JSON.stringify(roster, null, 2)}${note}${variantLines}\n\n` + + "To change the squad, pass this same document back to squad_patch with your " + + "edits applied — keeping every agent you were not asked to touch.", + metadata: { title: `squad: ${count} agent(s)`, dir, agents: count }, + }; + }, + }); + + editor.add({ + name: "squad_patch", + description: TOOL_DESCRIPTIONS.squad_patch, + input: SQUAD_PATCH_INPUT, + execute: async (input, context) => { + if (isSubagentCaller(context.agent)) { + return { + content: + `Refused: ${context.agent} is a subagent, and the squad roster is the ` + + "orchestrator's to change. Report what you think should change and let " + + "sarge decide.", + }; + } + const dir = input?.directory || defaultAgentDir(); + const roster = { grunts: input?.grunts ?? {}, drills: input?.drills ?? {} }; + const errors = validateRoster(roster); + errors.push(...validateVariants(roster, await getVariantMap())); + if (errors.length) { + return { content: `Roster is invalid:\n${errors.map((e) => ` - ${e}`).join("\n")}` }; + } + const result = applySquad({ roster, dir, allowRemove: input?.allow_remove === true }); + if (result.ok) { + // The inventory lists the squad; let the next turn see the change. + inventory = undefined; + agentsPromise = undefined; + } + return { + content: formatApplyReport(result), + metadata: { ok: result.ok, ...result.diff }, + }; + }, + }); + }); + + // --- rate-limit guard --- + + const guardStates = new Map(); // sessionID -> GuardState + const sessionInfo = new Map(); // sessionID -> {parentID, agent} | null + const lastActivity = new Map(); // sessionID -> epoch ms + const pollTimers = new Map(); // sessionID -> interval handle + + const getSessionInfo = async (sessionID) => { + if (sessionInfo.has(sessionID)) return sessionInfo.get(sessionID); + let info = null; + try { + const s = unwrap(await ctx.session.get({ sessionID })); + if (s?.parentID) info = { parentID: s.parentID, agent: s.agent ?? null }; + } catch { + // Treat as "not a subagent" so we never guard by mistake. + } + sessionInfo.set(sessionID, info); + return info; + }; + + const stopSilenceWatch = (sessionID) => { + const timer = pollTimers.get(sessionID); + if (timer) clearInterval(timer); + pollTimers.delete(sessionID); + }; + + // Explain to the orchestrator why its subagent failed and that it should + // reroute. Steered into the parent's running turn, so it lands right after + // the failed subagent call; `resume` starts a turn if the parent is idle. + const sendGuardNote = async (parentID, agentName, result) => { + const model = await getAgentModel(agentName); + const text = formatGuardNote({ + model: model ? `${model.providerID}/${model.modelID}` : (agentName ?? "unknown model"), + provider: model?.providerID ?? "unknown", + reason: result.reason, + waitMs: result.waitMs, + cumulativeMs: result.cumulativeMs, + until: result.until, + silentMs: result.silentMs, + errorText: result.errorText, + }); + try { + await ctx.session.synthetic({ + sessionID: parentID, + text, + description: "squad rate-limit guard", + delivery: "steer", + resume: true, + }); + } catch { + // Worst case the orchestrator sees only the subagent's own failure. + } + }; + + if (guardConfig.enabled) { + await ctx.session.hook("retry", async (event) => { + if (!event.decision?.retry) return; + const info = await getSessionInfo(event.sessionID); + if (!info) return; // top-level session — never guarded + const state = guardStates.get(event.sessionID) ?? initGuardState(); + guardStates.set(event.sessionID, state); + const now = Date.now(); + const result = evaluateRetry( + state, + retryToStatus(event, now), + guardConfig, + event.error?.status, + now, + ); + if (!result.trigger) return; + state.guarded = true; + event.decision = { retry: false }; + stopSilenceWatch(event.sessionID); + void sendGuardNote(info.parentID, info.agent, result); + }); + } + + const startSilenceWatch = (sessionID) => { + if (pollTimers.has(sessionID) || !guardConfig.enabled) return; + const timer = setInterval(async () => { + const state = guardStates.get(sessionID); + if (state?.guarded) return stopSilenceWatch(sessionID); + const last = lastActivity.get(sessionID); + if (last === undefined || !isSilentHang(last, guardConfig, Date.now())) return; + const info = await getSessionInfo(sessionID); + if (!info) return stopSilenceWatch(sessionID); + guardStates.set(sessionID, { ...(state ?? initGuardState()), guarded: true }); + stopSilenceWatch(sessionID); + try { + await ctx.session.interrupt({ sessionID, continue: false }); + } catch { + // No worse off than without the guard. + } + await sendGuardNote(info.parentID, info.agent, { + reason: "silent_hang", + silentMs: Date.now() - last, + }); + }, SILENCE_POLL_INTERVAL_MS); + timer.unref?.(); + pollTimers.set(sessionID, timer); + }; + + const handleTerminalError = async (sessionID, error) => { + if (!guardConfig.enabled) return; + const state = guardStates.get(sessionID); + if (state?.guarded) return; // already handled by the retry hook + const info = await getSessionInfo(sessionID); + if (!info) return; + const text = errorText(error); + if (!isGuardedTerminalError(text, guardConfig, error?.status)) return; + guardStates.set(sessionID, { ...(state ?? initGuardState()), guarded: true }); + await sendGuardNote(info.parentID, info.agent, { reason: "terminal_error", errorText: text }); + }; + + const forget = (sessionID) => { + guardStates.delete(sessionID); + sessionInfo.delete(sessionID); + lastActivity.delete(sessionID); + stopSilenceWatch(sessionID); + }; + + const events = new AbortController(); + if (guardConfig.enabled) { + void (async () => { + for await (const event of ctx.event.subscribe({ signal: events.signal })) { + const data = event?.data; + const sessionID = data?.sessionID; + if (typeof sessionID !== "string") continue; + // Any event naming a watched session is proof of life. + if (pollTimers.has(sessionID)) lastActivity.set(sessionID, Date.now()); + switch (event.type) { + case "session.created": + if (data.parentID) { + sessionInfo.set(sessionID, { parentID: data.parentID, agent: data.agent ?? null }); + lastActivity.set(sessionID, Date.now()); + startSilenceWatch(sessionID); + } + break; + case "session.deleted": + forget(sessionID); + break; + case "session.execution.succeeded": + case "session.execution.interrupted": + stopSilenceWatch(sessionID); + break; + case "session.execution.failed": + stopSilenceWatch(sessionID); + void handleTerminalError(sessionID, data.error); + break; + } + } + })().catch(() => { + // Stream closed on unload. + }); + } + + return () => { + events.abort(); + for (const id of [...pollTimers.keys()]) stopSilenceWatch(id); + }; + }, +}; diff --git a/.opencode/plugins/orchestrate.js b/.opencode/plugins/orchestrate.js index a2fe974..fbe32ca 100644 --- a/.opencode/plugins/orchestrate.js +++ b/.opencode/plugins/orchestrate.js @@ -29,30 +29,12 @@ */ import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { tool } from "@opencode-ai/plugin"; -import { - CHECK_INTERVAL_MS, - cacheDir, - loadSnapshot, - refreshOnce, - toastFor, -} from "../../src/benchmark-cache.js"; -import { formatBench } from "../../src/benchmarks.js"; -import { formatCacheStatus, resolveCacheTtl } from "../../src/cache-status.js"; import { formatCompactReport, pickCompactionModel, splitModelId } from "../../src/compact.js"; -import { - buildLimitMap, - buildVariantMap, - estimateContextTokens, - formatLocalDateTime, -} from "../../src/context.js"; +import { buildLimitMap, buildVariantMap, estimateContextTokens } from "../../src/context.js"; import { formatInventory, hasSquad } from "../../src/inventory.js"; -import { applyOrchestratorTransform, createTurnMemo } from "../../src/message-transform.js"; -import { buildModelData, formatPerf, modelsChanged, readModelData } from "../../src/model-data.js"; -import { applySystemPromptTransform, createPromptLoader } from "../../src/prompt-inject.js"; +import { applyOrchestratorTransform } from "../../src/message-transform.js"; +import { applySystemPromptTransform } from "../../src/prompt-inject.js"; import { evaluateRetry, formatGuardNote, @@ -68,173 +50,25 @@ import { formatApplyReport, readSquad, } from "../../src/squad-apply.js"; -import { formatTaskOutcome, isTaskResultEmpty } from "../../src/task-outcome.js"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PACKAGE_ROOT = path.resolve(__dirname, "../.."); -const SKILLS_DIR = path.join(PACKAGE_ROOT, "skills"); - -// The primary agent that acts as the orchestrator. Injection targets only this -// agent's sessions (verified via message.info.agent in the Task 0 spike). -const ORCHESTRATOR_AGENT = "build"; - -// Holds the rendered injection block for the turn in flight, so every model -// call within one turn sends byte-identical text and the provider's prompt -// cache survives. See createTurnMemo for the measurements behind it. -const _turnMemo = createTurnMemo(); - -// Serves the CURRENT bundled role prompt to every generated grunt/drill agent, -// overriding the copy frozen into its file when the squad was drafted. Cached -// on (size, mtime) so an edit to prompts/.md goes live without a restart -// while costing one stat per request instead of a read. See src/prompt-inject.js. -const _loadRolePrompt = createPromptLoader({ - readFile: (role) => fs.readFileSync(path.join(PACKAGE_ROOT, "prompts", `${role}.md`), "utf8"), - statKey: (role) => { - const st = fs.statSync(path.join(PACKAGE_ROOT, "prompts", `${role}.md`)); - return `${st.size}:${st.mtimeMs}`; - }, -}); - -// Cache the subagent inventory string (and whether a squad has been drafted) -// per process — neither changes at runtime. The bootstrap itself is assembled -// per injection so the live facts (current time, current model) stay fresh. -let _inventoryCache; // undefined = not loaded -let _hasSquadCache; // undefined = not loaded - -// AA benchmark snapshot (models object), read once per process: the fresher of -// the bundled copy and the one the background refresher downloaded. Fixed for -// the process lifetime on purpose — see src/benchmark-cache.js. null if neither -// is usable. -const BENCH_CACHE_DIR = cacheDir(process.env, os.homedir()); -let _benchCache; // undefined = not loaded -let _benchGenerated; // `_meta.generated` of the snapshot in use -let _bundledModelCount = 0; -function loadBenchmarks() { - if (_benchCache !== undefined) return _benchCache; - _benchCache = null; - const { snapshot, bundledCount } = loadSnapshot( - path.join(PACKAGE_ROOT, "src", "benchmarks.json"), - BENCH_CACHE_DIR, - ); - _bundledModelCount = bundledCount; - if (snapshot) { - _benchCache = snapshot.models; - _benchGenerated = snapshot._meta.generated; - } - return _benchCache; -} - -// Background benchmark refresh: first check shortly after startup, then hourly -// (refreshOnce itself enforces the once-a-day / 5-failures throttle). The first -// check is delayed so its toast lands after the TUI is up and a one-shot -// `opencode run` exits before any socket is opened. Fire-and-forget under a -// hard fetch timeout, so opencode never waits on GitHub; timers are unref'd so -// they never keep the process alive. Started once per process. -const BENCH_FIRST_CHECK_MS = 30_000; -let _benchTimer; -function startBenchmarkRefresh(client, directory) { - if (_benchTimer) return; - loadBenchmarks(); // pin the in-use snapshot before the refresher can replace the cache - let busy = false; - let schemaWarned = false; // the "update the plugin" toast, once per process - const tick = async () => { - if (busy) return; - busy = true; - try { - const result = await refreshOnce({ - fetch: globalThis.fetch, - dir: BENCH_CACHE_DIR, - now: Date.now(), - minModels: Math.ceil(_bundledModelCount / 2), - }); - if (result.outcome === "schema-ahead") { - if (schemaWarned) return; - schemaWarned = true; - } - const toast = toastFor(result, _benchGenerated); - if (toast) { - await client.tui?.showToast?.({ - query: { directory }, - body: { ...toast, duration: 10000 }, - }); - } - } catch { - // Best-effort; the next tick tries again. - } finally { - busy = false; - } - }; - _benchTimer = setTimeout(() => { - void tick(); - _benchTimer = setInterval(tick, CHECK_INTERVAL_MS); - _benchTimer.unref?.(); - }, BENCH_FIRST_CHECK_MS); - _benchTimer.unref?.(); -} - -// Hand-editable per-squad-model snapshot, keyed by opencode provider/model id. -// Looked for beside the agent dirs opencode merges: the project's .opencode -// first, then the global config dir. null if neither exists (we then fall back -// to the raw AA benchmarks). Read once per process. -let _modelDataCache; // undefined = not loaded -function loadModelData() { - if (_modelDataCache !== undefined) return _modelDataCache; - _modelDataCache = null; - const candidates = [ - path.join(process.cwd(), ".opencode", "model_data.json"), - path.join(os.homedir(), ".config", "opencode", "model_data.json"), - ]; - const merged = {}; - let found = false; - // Global first, then project on top, so project entries win on overlap. - for (const file of [...candidates].reverse()) { - const md = readModelData(file); - if (md) { - found = true; - Object.assign(merged, md.models); - } - } - if (found) _modelDataCache = merged; - return _modelDataCache; -} - -// Per-model capability tail for the inventory: model_data entry if we have one -// (precomputed indices + hand-written note), else the raw AA benchmark lookup. -function buildPerfLookup() { - const modelData = loadModelData(); - const benchmarks = loadBenchmarks(); - return (modelId) => { - const entry = modelData?.[modelId]; - if (entry) return formatPerf(entry); - return benchmarks ? formatBench(modelId, benchmarks) : null; - }; -} - -// Startup auto-regen of the GLOBAL model_data.json: rescan the squad, refresh -// perf from benchmarks, MERGE (preserving hand-written `info`), and write ONLY -// when the models actually changed — so a new grunt/drill or fresh benchmarks -// land automatically without clobbering edits or churning the file every boot. -// Best-effort: any failure is swallowed so it can never break startup. No-op -// when there is no squad yet (0 models). Only the global agent dir is scanned; -// a project's .opencode/model_data.json stays fully manual (via the script's -// --dir). Runs before the inventory cache is built, so the fresh file is read. -function ensureGlobalModelDataFresh() { - try { - const benchmarks = loadBenchmarks(); - if (!benchmarks) return; - const configDir = path.join(os.homedir(), ".config", "opencode"); - const agentDir = path.join(configDir, "agent"); - const file = path.join(configDir, "model_data.json"); - const existing = readModelData(file) || {}; - const generated = new Date().toISOString().slice(0, 10); - const snapshot = buildModelData(agentDir, benchmarks, existing, generated); - if (Object.keys(snapshot.models).length === 0) return; // no squad -> nothing - if (!modelsChanged(existing, snapshot)) return; // unchanged -> no write - fs.writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`); - } catch { - // Best-effort; the inventory falls back to benchmarks.json regardless. - } -} +import { + ARG_DESCRIPTIONS, + formatVariantLines, + isSubagentCaller, + TOOL_DESCRIPTIONS, + validateVariants, +} from "../../src/squad-tools.js"; +import { subagentUsage, taskNotes } from "../../src/task-notes.js"; +import { isTaskResultEmpty } from "../../src/task-outcome.js"; +import { + buildPerfLookup, + ensureGlobalModelDataFresh, + loadModelData, + loadRolePrompt, + ORCHESTRATOR_AGENT, + SKILLS_DIR, + startBenchmarkRefresh, + turnMemo, +} from "./runtime.js"; // Configured orchestrator model, captured from config (fallback for turn 1, // before any assistant message reveals the actual model). @@ -291,7 +125,9 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // Refresh the global model_data.json once at plugin load (opencode startup), // before anything reads it. Cheap and best-effort; see the function comment. ensureGlobalModelDataFresh(); - startBenchmarkRefresh(client, directory); + startBenchmarkRefresh((toast) => + client.tui?.showToast?.({ query: { directory }, body: { ...toast, duration: 10000 } }), + ); // Rate-limit guard config comes from the plugin-tuple options // (["opencode-squad@...", { rate_limit_guard: {...} }]) — NOT a top-level @@ -393,44 +229,13 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // --- Cache-status hint (see src/cache-status.js) --- - // Two facts about a subagent session, from one messages fetch: - // lastHitMs — the most recent assistant message's request-start time, - // i.e. when this session last actually hit its provider. - // contextTokens — how big its context grew (last completed turn). - // Queried fresh rather than tracked in memory: this only needs to be right - // at the one moment a `task` call just completed, not continuously. + // Facts about a subagent session from one messages fetch (see subagentUsage + // in src/task-notes.js). Queried fresh rather than tracked in memory: this + // only needs to be right at the one moment a `task` call just completed. const getSubagentUsage = async (sessionID) => { try { const res = await client.session.messages({ path: { id: sessionID }, query: { directory } }); - const msgs = res?.data ?? []; - let lastHitMs = null; - let finish; - let outputTokens; - let reasoningTokens; - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i]?.info; - if (info?.role !== "assistant") continue; - if (lastHitMs === null && typeof info?.time?.created === "number") { - lastHitMs = info.time.created; - } - // The finish reason and token split come from the last assistant turn - // that actually COMPLETED — a session can end with a stub assistant - // record (no finish, all-zero tokens) after an abort, and reading the - // outcome off that stub would report "no finish reason" for every - // cancelled task. Deliberately a separate scan from lastHitMs, which - // wants the newest provider contact whether or not it finished. - if (finish === undefined && typeof info?.finish === "string" && info.finish.length > 0) { - finish = info.finish; - outputTokens = info?.tokens?.output; - reasoningTokens = info?.tokens?.reasoning; - } - if (lastHitMs !== null && finish !== undefined) break; - } - // Missing usage must not suppress the cache-status line — it degrades to - // the note without the size clause. - const ctx = estimateContextTokens(msgs); - - return { lastHitMs, contextTokens: ctx?.used, finish, outputTokens, reasoningTokens }; + return subagentUsage(res?.data ?? []); } catch { // Best-effort for the cache hint. `usageUnknown` matters more: without it // the missing finish reason would read downstream as "finished normally", @@ -441,84 +246,30 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { } }; - // Appends a [CACHE STATUS] line to a completed `task` call's result, so the - // orchestrator has what it needs to decide whether passing this task_id - // back in (to reuse the subagent session) still hits a warm provider - // cache, or whether the cache has gone cold and a fresh session is no - // worse. cache_ttl_seconds is a hand-editable model_data.json field (same - // pattern as `info`/`billing` — see src/model-data.js); left unset for + // Appends [TASK OUTCOME] and [CACHE STATUS] to a completed `task` call's + // result, so the orchestrator has what it needs to decide whether passing + // this task_id back in (to reuse the subagent session) still hits a warm + // provider cache. cache_ttl_seconds is a hand-editable model_data.json field + // (same pattern as `info`/`billing` — see src/model-data.js); left unset for // providers with no published TTL, reported honestly as "unknown" rather - // than guessed. + // than guessed. See src/task-notes.js. const handleTaskToolAfter = async (input, output) => { if (input?.tool !== "task") return; const meta = /** @type {any} */ (output)?.metadata; const taskId = meta?.sessionId; - const providerID = meta?.model?.providerID; - const modelID = meta?.model?.modelID; if (!taskId) return; - const { lastHitMs, contextTokens, finish, outputTokens, reasoningTokens, usageUnknown } = - await getSubagentUsage(taskId); - - // [TASK OUTCOME] first, and on its own terms: an empty or truncated result - // is a fact about THIS call the orchestrator has to act on, whereas the - // cache status is advice about a possible next one. It is appended even - // when the model is unknown or the session has no usable timestamp — - // suppressing it there would silently restore the exact blind spot it - // exists to close. See src/task-outcome.js. - const outcome = formatTaskOutcome({ + const notes = taskNotes({ taskId, - providerModelId: providerID && modelID ? `${providerID}/${modelID}` : undefined, - finish, - outputTokens, - reasoningTokens, + providerID: meta?.model?.providerID, + modelID: meta?.model?.modelID, + usage: await getSubagentUsage(taskId), resultEmpty: isTaskResultEmpty(/** @type {any} */ (output)?.output), - usageUnknown, - }); - if (outcome) output.output = `${output.output}\n\n${outcome}`; - - if (!providerID || !modelID) return; - if (lastHitMs === null) return; - - // model_data.json wins when the user has filled it in; otherwise resolve - // from the provider. Without that fallback the hint degraded to "TTL isn't - // published — judge for yourself" for every anthropic model, because the - // hand-edited field was simply never filled in. - const modelData = loadModelData(); - const configuredTtl = modelData?.[`${providerID}/${modelID}`]?.cache_ttl_seconds; - const ttl = - typeof configuredTtl === "number" - ? { seconds: configuredTtl, source: /** @type {const} */ ("published") } - : resolveCacheTtl(providerID); - - const limitMap = await getLimitMap(); - const contextLimit = limitMap?.[`${providerID}/${modelID}`] ?? limitMap?.[modelID]; - - const note = formatCacheStatus({ - taskId, - providerModelId: `${providerID}/${modelID}`, - lastHitMs, - // Absolute wall clock in the same format/zone the bootstrap stamps its - // own "now" with, so the orchestrator can recompute the gap at the next - // delegation instead of trusting a stale relative age. - lastHitAtText: - formatLocalDateTime( - new Date(lastHitMs), - Intl.DateTimeFormat().resolvedOptions().timeZone, - ) ?? undefined, - // The orchestrator's own clock is frozen at the start of its turn (see - // src/message-transform.js), so this stamp is the tighter lower bound on - // "now" when it computes a reuse gap later in the same turn. - nowAtText: - formatLocalDateTime(new Date(), Intl.DateTimeFormat().resolvedOptions().timeZone) ?? - undefined, - ttlSeconds: ttl.seconds, - ttlSource: ttl.source, - contextTokens, - contextLimit, + modelData: loadModelData(), + limitMap: await getLimitMap(), now: Date.now(), }); - output.output = `${output.output}\n\n${note}`; + for (const note of notes) output.output = `${output.output}\n\n${note}`; }; const waitForIdle = (sessionID, timeoutMs = 20_000) => @@ -733,46 +484,16 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // One agent's settings, shared by both role maps. Every field is optional: // `{}` is a perfectly good entry meaning "this model, role defaults". const agentEntry = tool.schema.object({ - variant: tool.schema - .string() - .optional() - .describe( - "Reasoning level. Must be one of THIS model's own `reasoning_options` values in " + - "models.dev (glm-5.3: low|high|max; claude-opus-5: low|medium|high|xhigh|max) — " + - "opencode silently ignores an unrecognized one, so a typo buys silence rather than " + - "an error. Omit for models that publish no reasoning options. Setting it does not " + - "suppress reasoning: it bounds a reasoner otherwise limited only by the output " + - "cap. Per AGENT, so a drill may think harder than the grunt on the same model.", - ), - description: tool.schema - .string() - .optional() - .describe( - "What this agent is for, in one line. The orchestrator reads exactly this in its " + - "subagent inventory when choosing who to dispatch, so make it say WHEN to pick " + - "this model — 'cheap, mechanical edits', 'strong analysis, slow'. Omit to keep the " + - "generic role default, which carries no routing signal at all.", - ), - notes: tool.schema - .string() - .optional() - .describe( - "Extra instructions appended to this agent's prompt only — per-model quirks and " + - "workarounds. Round-trips through squad_dump.", - ), + variant: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["agent.variant"]), + description: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["agent.description"]), + notes: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["agent.notes"]), steps: tool.schema .number() .int() .positive() .optional() - .describe("Max agentic iterations before opencode forces a text-only response."), - disable: tool.schema - .boolean() - .optional() - .describe( - "Park this agent without deleting it: it stops being dispatchable but its settings " + - "survive. Prefer this over deleting an agent you may want back.", - ), + .describe(ARG_DESCRIPTIONS["agent.steps"]), + disable: tool.schema.boolean().optional().describe(ARG_DESCRIPTIONS["agent.disable"]), }); const agentMap = tool.schema.record(tool.schema.string(), agentEntry); @@ -856,37 +577,17 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // assemble by hand. tool: { squad_compact: tool({ - description: - "Compact a subagent session BEFORE reusing it for a new, unrelated task: replaces its " + - "history with a summary, so the next dispatch re-reads a small context instead of the " + - "whole transcript. Use when you are about to send a LONG task down a session whose " + - "context is already large and mostly irrelevant to it — the size is in that session's " + - "last [CACHE STATUS] line. Not free: one full pass of the summarising model over the " + - "history, and the session's provider cache goes cold afterwards, so it pays off only " + - "when several turns follow. If the old history is worthless to the new task, a FRESH " + - "session is cheaper than compacting. opencode's own auto-compaction fires only at the " + - "context limit, which is far later than the moment this is for.", + description: TOOL_DESCRIPTIONS.squad_compact, args: { - task_id: tool.schema - .string() - .describe( - "The subagent session to compact — the task_id from its [CACHE STATUS] line.", - ), - model: tool.schema - .string() - .optional() - .describe( - "`provider/model` that writes the summary. Defaults to the cheapest model the squad " + - "has an agent for (a flat-rate one first), NOT the session's own model — " + - "summarising is mechanical and does not need the expensive model that did the work.", - ), + task_id: tool.schema.string().describe(ARG_DESCRIPTIONS["compact.task_id"]), + model: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["compact.model"]), }, async execute(args, ctx) { // Rewriting another session's history is strictly more destructive // than a roster write, so it takes the same caller guard: a drill is // read-only by contract and must not be able to edit a grunt's // transcript. - if (/^(?:grunt|drill)-/.test(ctx?.agent ?? "")) { + if (isSubagentCaller(ctx?.agent)) { return ( `Refused: ${ctx.agent} is a subagent, and compacting a session is the ` + "orchestrator's call. Say in your result that your context is large; sarge " + @@ -965,19 +666,9 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { }), squad_dump: tool({ - description: - "Read the current squad roster: every generated agent, grouped as `grunts` and " + - "`drills` and keyed by model id, with its per-agent settings. ALWAYS call this " + - "before squad_patch — the roster is a document you edit, not one you retype. " + - "Returns JSON.", + description: TOOL_DESCRIPTIONS.squad_dump, args: { - directory: tool.schema - .string() - .optional() - .describe( - "Agent directory to read. Defaults to the global ~/.config/opencode/agent. " + - "Pass a project's .opencode/agent to scope the squad to one project.", - ), + directory: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["dump.directory"]), }, async execute(args) { const dir = args.directory || defaultAgentDir(); @@ -992,27 +683,7 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // which silently under-reports: claude-haiku-4-5 publishes only a // budget_tokens option, so an effort-values reading says "none" while // opencode in fact accepts high and max. - const variantMap = await getVariantMap(); - const ids = new Set([ - ...Object.keys(roster.grunts ?? {}), - ...Object.keys(roster.drills ?? {}), - ]); - const variantLines = - variantMap === null - ? "\n\nAccepted variants: could not be read from opencode — treat as UNKNOWN and " + - "leave variants alone rather than guessing." - : ids.size === 0 - ? "" - : `\n\nVariants opencode accepts (authoritative — do not guess, and do not derive\nthese from models.dev):\n${[ - ...ids, - ] - .sort() - .map((id) => { - const v = variantMap[id]; - if (v === undefined) return ` ${id}: unknown (model not in this install)`; - return ` ${id}: ${v.length ? v.join(" | ") : "(none — leave variant unset)"}`; - }) - .join("\n")}`; + const variantLines = formatVariantLines(await getVariantMap(), roster); return { title: `squad: ${count} agent(s)`, output: @@ -1025,42 +696,21 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { }), squad_patch: tool({ - description: - "Write the squad roster: one agent per entry under `grunts` / `drills`, keyed by " + - "model id. This is the COMPLETE intended squad, not a delta — an agent absent from " + - "it is DELETED, so start from squad_dump and edit that. Deleting an agent the user " + - "did not ask you to delete is refused, not performed.", + description: TOOL_DESCRIPTIONS.squad_patch, args: { - grunts: agentMap - .optional() - .describe("Executors, keyed by model id. `{}` is a valid entry: role defaults."), - drills: agentMap - .optional() - .describe( - "Reviewers, keyed by model id. A drill's verdict is what the orchestrator acts " + - "on, so give one only to a model that can actually review — a weak reviewer " + - "rubber-stamps the work or invents faults, and both are worse than no review " + - "because they launder a bad change as an approved one. Cheap and small " + - "models belong in `grunts` alone.", - ), + grunts: agentMap.optional().describe(ARG_DESCRIPTIONS["patch.grunts"]), + drills: agentMap.optional().describe(ARG_DESCRIPTIONS["patch.drills"]), allow_remove: tool.schema .boolean() .optional() - .describe( - "Permit deleting agents present in the current squad but absent from this " + - "roster. Pass true ONLY when the user asked for that deletion — never to " + - "get past the refusal.", - ), - directory: tool.schema - .string() - .optional() - .describe("Agent directory to write. Defaults to the global ~/.config/opencode/agent."), + .describe(ARG_DESCRIPTIONS["patch.allow_remove"]), + directory: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["patch.directory"]), }, async execute(args, ctx) { // A grunt or drill rewriting the squad mid-task is never intended, and // the damage outlives the session. Same reasoning as the deletion // guard: make the destructive path require the right caller. - if (/^(?:grunt|drill)-/.test(ctx?.agent ?? "")) { + if (isSubagentCaller(ctx?.agent)) { return { title: "squad_patch refused", output: @@ -1072,32 +722,7 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { const dir = args.directory || defaultAgentDir(); const roster = { grunts: args.grunts ?? {}, drills: args.drills ?? {} }; const errors = validateRoster(roster); - // A variant opencode does not recognize is DROPPED by it without any - // error, so a typo would otherwise land as a silently ineffective - // config. We can ask opencode what it accepts, so refuse instead — - // but only when the lookup worked: our inability to check must not - // block a legitimate edit. - const variantMap = await getVariantMap(); - if (variantMap) { - for (const [key, map] of Object.entries({ - grunts: roster.grunts, - drills: roster.drills, - })) { - for (const [id, entry] of Object.entries(map ?? {})) { - const want = entry?.variant; - if (!want) continue; - const accepted = variantMap[id]; - if (accepted === undefined) continue; // model not in this install - if (!accepted.includes(want)) { - errors.push( - `${key}["${id}"].variant "${want}" is not accepted by opencode for this ` + - `model — it would be ignored silently. Accepted: ` + - `${accepted.length ? accepted.join(" | ") : "(none: leave it unset)"}`, - ); - } - } - } - } + errors.push(...validateVariants(roster, await getVariantMap())); if (errors.length) { return { title: "squad_patch rejected", @@ -1127,7 +752,7 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // starting with the grunt body), and no-ops on anything without our fence — // sarge and hand-authored agents included. "experimental.chat.system.transform": async (_input, output) => { - applySystemPromptTransform(output.system, _loadRolePrompt); + applySystemPromptTransform(output.system, loadRolePrompt); }, "experimental.chat.messages.transform": async (_input, output) => { @@ -1137,7 +762,7 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { getLimitMap, getHasSquad: () => _hasSquadCache, orchestratorModel: _orchestratorModel, - turnMemo: _turnMemo, + turnMemo, }); }, }; diff --git a/.opencode/plugins/runtime.js b/.opencode/plugins/runtime.js new file mode 100644 index 0000000..a73c0d1 --- /dev/null +++ b/.opencode/plugins/runtime.js @@ -0,0 +1,181 @@ +// Process-wide state and file-backed lookups shared by both plugin entries: +// orchestrate.js (OpenCode 1) and orchestrate-v2.js (OpenCode 2). Kept apart +// from either entry because orchestrate.js imports `@opencode-ai/plugin`, which +// an OpenCode 2 host does not provide. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CHECK_INTERVAL_MS, + cacheDir, + loadSnapshot, + refreshOnce, + toastFor, +} from "../../src/benchmark-cache.js"; +import { formatBench } from "../../src/benchmarks.js"; +import { createTurnMemo } from "../../src/message-transform.js"; +import { buildModelData, formatPerf, modelsChanged, readModelData } from "../../src/model-data.js"; +import { createPromptLoader } from "../../src/prompt-inject.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = path.resolve(__dirname, "../.."); +export const SKILLS_DIR = path.join(PACKAGE_ROOT, "skills"); + +// The primary agent that acts as the orchestrator. Injection targets only this +// agent's sessions (verified via message.info.agent in the Task 0 spike). +export const ORCHESTRATOR_AGENT = "build"; + +// Holds the rendered injection block for the turn in flight, so every model +// call within one turn sends byte-identical text and the provider's prompt +// cache survives. See createTurnMemo for the measurements behind it. +export const turnMemo = createTurnMemo(); + +// Serves the CURRENT bundled role prompt to every generated grunt/drill agent, +// overriding the copy frozen into its file when the squad was drafted. Cached +// on (size, mtime) so an edit to prompts/.md goes live without a restart +// while costing one stat per request instead of a read. See src/prompt-inject.js. +export const loadRolePrompt = createPromptLoader({ + readFile: (role) => fs.readFileSync(path.join(PACKAGE_ROOT, "prompts", `${role}.md`), "utf8"), + statKey: (role) => { + const st = fs.statSync(path.join(PACKAGE_ROOT, "prompts", `${role}.md`)); + return `${st.size}:${st.mtimeMs}`; + }, +}); + +// Cache the subagent inventory string (and whether a squad has been drafted) +// per process — neither changes at runtime. The bootstrap itself is assembled +// per injection so the live facts (current time, current model) stay fresh. +let _inventoryCache; // undefined = not loaded +let _hasSquadCache; // undefined = not loaded + +// AA benchmark snapshot (models object), read once per process: the fresher of +// the bundled copy and the one the background refresher downloaded. Fixed for +// the process lifetime on purpose — see src/benchmark-cache.js. null if neither +// is usable. +const BENCH_CACHE_DIR = cacheDir(process.env, os.homedir()); +let _benchCache; // undefined = not loaded +let _benchGenerated; // `_meta.generated` of the snapshot in use +let _bundledModelCount = 0; +function loadBenchmarks() { + if (_benchCache !== undefined) return _benchCache; + _benchCache = null; + const { snapshot, bundledCount } = loadSnapshot( + path.join(PACKAGE_ROOT, "src", "benchmarks.json"), + BENCH_CACHE_DIR, + ); + _bundledModelCount = bundledCount; + if (snapshot) { + _benchCache = snapshot.models; + _benchGenerated = snapshot._meta.generated; + } + return _benchCache; +} + +// Background benchmark refresh: first check shortly after startup, then hourly +// (refreshOnce itself enforces the once-a-day / 5-failures throttle). The first +// check is delayed so its toast lands after the TUI is up and a one-shot +// `opencode run` exits before any socket is opened. Fire-and-forget under a +// hard fetch timeout, so opencode never waits on GitHub; timers are unref'd so +// they never keep the process alive. Started once per process. +const BENCH_FIRST_CHECK_MS = 30_000; +let _benchTimer; +export function startBenchmarkRefresh(notify) { + if (_benchTimer) return; + loadBenchmarks(); // pin the in-use snapshot before the refresher can replace the cache + let busy = false; + let schemaWarned = false; // the "update the plugin" toast, once per process + const tick = async () => { + if (busy) return; + busy = true; + try { + const result = await refreshOnce({ + fetch: globalThis.fetch, + dir: BENCH_CACHE_DIR, + now: Date.now(), + minModels: Math.ceil(_bundledModelCount / 2), + }); + if (result.outcome === "schema-ahead") { + if (schemaWarned) return; + schemaWarned = true; + } + const toast = toastFor(result, _benchGenerated); + if (toast) await notify?.(toast); + } catch { + // Best-effort; the next tick tries again. + } finally { + busy = false; + } + }; + _benchTimer = setTimeout(() => { + void tick(); + _benchTimer = setInterval(tick, CHECK_INTERVAL_MS); + _benchTimer.unref?.(); + }, BENCH_FIRST_CHECK_MS); + _benchTimer.unref?.(); +} + +// Hand-editable per-squad-model snapshot, keyed by opencode provider/model id. +// Looked for beside the agent dirs opencode merges: the project's .opencode +// first, then the global config dir. null if neither exists (we then fall back +// to the raw AA benchmarks). Read once per process. +let _modelDataCache; // undefined = not loaded +export function loadModelData() { + if (_modelDataCache !== undefined) return _modelDataCache; + _modelDataCache = null; + const candidates = [ + path.join(process.cwd(), ".opencode", "model_data.json"), + path.join(os.homedir(), ".config", "opencode", "model_data.json"), + ]; + const merged = {}; + let found = false; + // Global first, then project on top, so project entries win on overlap. + for (const file of [...candidates].reverse()) { + const md = readModelData(file); + if (md) { + found = true; + Object.assign(merged, md.models); + } + } + if (found) _modelDataCache = merged; + return _modelDataCache; +} + +// Per-model capability tail for the inventory: model_data entry if we have one +// (precomputed indices + hand-written note), else the raw AA benchmark lookup. +export function buildPerfLookup() { + const modelData = loadModelData(); + const benchmarks = loadBenchmarks(); + return (modelId) => { + const entry = modelData?.[modelId]; + if (entry) return formatPerf(entry); + return benchmarks ? formatBench(modelId, benchmarks) : null; + }; +} + +// Startup auto-regen of the GLOBAL model_data.json: rescan the squad, refresh +// perf from benchmarks, MERGE (preserving hand-written `info`), and write ONLY +// when the models actually changed — so a new grunt/drill or fresh benchmarks +// land automatically without clobbering edits or churning the file every boot. +// Best-effort: any failure is swallowed so it can never break startup. No-op +// when there is no squad yet (0 models). Only the global agent dir is scanned; +// a project's .opencode/model_data.json stays fully manual (via the script's +// --dir). Runs before the inventory cache is built, so the fresh file is read. +export function ensureGlobalModelDataFresh() { + try { + const benchmarks = loadBenchmarks(); + if (!benchmarks) return; + const configDir = path.join(os.homedir(), ".config", "opencode"); + const agentDir = path.join(configDir, "agent"); + const file = path.join(configDir, "model_data.json"); + const existing = readModelData(file) || {}; + const generated = new Date().toISOString().slice(0, 10); + const snapshot = buildModelData(agentDir, benchmarks, existing, generated); + if (Object.keys(snapshot.models).length === 0) return; // no squad -> nothing + if (!modelsChanged(existing, snapshot)) return; // unchanged -> no write + fs.writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`); + } catch { + // Best-effort; the inventory falls back to benchmarks.json regardless. + } +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..c90ef47 --- /dev/null +++ b/index.js @@ -0,0 +1,15 @@ +// Package entry for both OpenCode generations (OpenCode 1 >= 1.18.29). +// +// OpenCode 2 calls `setup` (.opencode/plugins/orchestrate-v2.js). OpenCode 1 +// calls `server`, which lazy-imports the V1 entry: that module imports +// `@opencode-ai/plugin`, which an OpenCode 2 host does not provide, so it must +// never load there. +import v2 from "./.opencode/plugins/orchestrate-v2.js"; + +export default { + ...v2, + async server(input, options) { + const { OrchestratePlugin } = await import("./.opencode/plugins/orchestrate.js"); + return OrchestratePlugin(input, options); + }, +}; diff --git a/knip.json b/knip.json index c94810e..087239d 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["tests/**/*.test.js", "scripts/*.mjs"], + "entry": [".opencode/plugins/orchestrate.js", "tests/**/*.test.js", "scripts/*.mjs"], "project": ["src/**/*.js", ".opencode/plugins/**/*.js", "scripts/**/*.mjs"] } diff --git a/package.json b/package.json index f73c8ba..9cdf3e3 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "version": "0.1.0", "description": "OpenCode plugin that turns the build agent into a PDCA orchestrator (sarge) over grunt/drill subagents", "type": "module", - "main": ".opencode/plugins/orchestrate.js", + "main": "index.js", "files": [ + "index.js", ".opencode", "src", "prompts", diff --git a/src/squad-tools.js b/src/squad-tools.js new file mode 100644 index 0000000..c58bfa8 --- /dev/null +++ b/src/squad-tools.js @@ -0,0 +1,142 @@ +// Host-independent text and decisions for the squad_* tools. +// +// The OpenCode 1 entry (.opencode/plugins/orchestrate.js) declares the tool +// arguments with zod via `tool.schema`; the OpenCode 2 entry declares them as +// JSON Schema. Both read their wording from here so the two cannot drift, and +// both use the same variant reporting/validation below. + +/** Tool descriptions, keyed by tool name. */ +export const TOOL_DESCRIPTIONS = { + squad_compact: + "Compact a subagent session BEFORE reusing it for a new, unrelated task: replaces its " + + "history with a summary, so the next dispatch re-reads a small context instead of the " + + "whole transcript. Use when you are about to send a LONG task down a session whose " + + "context is already large and mostly irrelevant to it — the size is in that session's " + + "last [CACHE STATUS] line. Not free: one full pass of the summarising model over the " + + "history, and the session's provider cache goes cold afterwards, so it pays off only " + + "when several turns follow. If the old history is worthless to the new task, a FRESH " + + "session is cheaper than compacting. opencode's own auto-compaction fires only at the " + + "context limit, which is far later than the moment this is for.", + squad_dump: + "Read the current squad roster: every generated agent, grouped as `grunts` and " + + "`drills` and keyed by model id, with its per-agent settings. ALWAYS call this " + + "before squad_patch — the roster is a document you edit, not one you retype. " + + "Returns JSON.", + squad_patch: + "Write the squad roster: one agent per entry under `grunts` / `drills`, keyed by " + + "model id. This is the COMPLETE intended squad, not a delta — an agent absent from " + + "it is DELETED, so start from squad_dump and edit that. Deleting an agent the user " + + "did not ask you to delete is refused, not performed.", +}; + +/** Argument descriptions, keyed by argument (agent-entry fields under `agent.*`). */ +export const ARG_DESCRIPTIONS = { + "agent.variant": + "Reasoning level. Must be one of THIS model's own `reasoning_options` values in " + + "models.dev (glm-5.3: low|high|max; claude-opus-5: low|medium|high|xhigh|max) — " + + "opencode silently ignores an unrecognized one, so a typo buys silence rather than " + + "an error. Omit for models that publish no reasoning options. Setting it does not " + + "suppress reasoning: it bounds a reasoner otherwise limited only by the output " + + "cap. Per AGENT, so a drill may think harder than the grunt on the same model.", + "agent.description": + "What this agent is for, in one line. The orchestrator reads exactly this in its " + + "subagent inventory when choosing who to dispatch, so make it say WHEN to pick " + + "this model — 'cheap, mechanical edits', 'strong analysis, slow'. Omit to keep the " + + "generic role default, which carries no routing signal at all.", + "agent.notes": + "Extra instructions appended to this agent's prompt only — per-model quirks and " + + "workarounds. Round-trips through squad_dump.", + "agent.steps": "Max agentic iterations before opencode forces a text-only response.", + "agent.disable": + "Park this agent without deleting it: it stops being dispatchable but its settings " + + "survive. Prefer this over deleting an agent you may want back.", + "compact.task_id": "The subagent session to compact — the task_id from its [CACHE STATUS] line.", + "compact.model": + "`provider/model` that writes the summary. Defaults to the cheapest model the squad " + + "has an agent for (a flat-rate one first), NOT the session's own model — " + + "summarising is mechanical and does not need the expensive model that did the work.", + "dump.directory": + "Agent directory to read. Defaults to the global ~/.config/opencode/agent. " + + "Pass a project's .opencode/agent to scope the squad to one project.", + "patch.grunts": "Executors, keyed by model id. `{}` is a valid entry: role defaults.", + "patch.drills": + "Reviewers, keyed by model id. A drill's verdict is what the orchestrator acts " + + "on, so give one only to a model that can actually review — a weak reviewer " + + "rubber-stamps the work or invents faults, and both are worse than no review " + + "because they launder a bad change as an approved one. Cheap and small " + + "models belong in `grunts` alone.", + "patch.allow_remove": + "Permit deleting agents present in the current squad but absent from this " + + "roster. Pass true ONLY when the user asked for that deletion — never to " + + "get past the refusal.", + "patch.directory": "Agent directory to write. Defaults to the global ~/.config/opencode/agent.", +}; + +/** + * A grunt or drill calling a roster-changing or history-rewriting tool. Those + * are the orchestrator's calls: a drill is read-only by contract, and a grunt + * rewriting the squad mid-task outlives the session that did it. + * + * @param {unknown} agent the calling agent's id + */ +export function isSubagentCaller(agent) { + return /^(?:grunt|drill)-/.test(typeof agent === "string" ? agent : ""); +} + +/** + * The "variants opencode accepts" tail of squad_dump's output. + * + * @param {Record | null} variantMap null when it could not be read + * @param {{grunts?: Record, drills?: Record}} roster + * @returns {string} + */ +export function formatVariantLines(variantMap, roster) { + const ids = new Set([...Object.keys(roster.grunts ?? {}), ...Object.keys(roster.drills ?? {})]); + if (variantMap === null) { + return ( + "\n\nAccepted variants: could not be read from opencode — treat as UNKNOWN and " + + "leave variants alone rather than guessing." + ); + } + if (ids.size === 0) return ""; + const lines = [...ids].sort().map((id) => { + const v = variantMap[id]; + if (v === undefined) return ` ${id}: unknown (model not in this install)`; + return ` ${id}: ${v.length ? v.join(" | ") : "(none — leave variant unset)"}`; + }); + return ( + "\n\nVariants opencode accepts (authoritative — do not guess, and do not derive\n" + + `these from models.dev):\n${lines.join("\n")}` + ); +} + +/** + * Variant errors for squad_patch. A variant opencode does not recognize is + * DROPPED by it without any error, so a typo would otherwise land as a silently + * ineffective config. Only checked when the lookup worked: our inability to + * check must not block a legitimate edit. + * + * @param {{grunts?: Record, drills?: Record}} roster + * @param {Record | null | undefined} variantMap + * @returns {string[]} + */ +export function validateVariants(roster, variantMap) { + if (!variantMap) return []; + const errors = []; + for (const [key, map] of Object.entries({ grunts: roster.grunts, drills: roster.drills })) { + for (const [id, entry] of Object.entries(map ?? {})) { + const want = entry?.variant; + if (!want) continue; + const accepted = variantMap[id]; + if (accepted === undefined) continue; // model not in this install + if (!accepted.includes(want)) { + errors.push( + `${key}["${id}"].variant "${want}" is not accepted by opencode for this ` + + `model — it would be ignored silently. Accepted: ` + + `${accepted.length ? accepted.join(" | ") : "(none: leave it unset)"}`, + ); + } + } + } + return errors; +} diff --git a/src/task-notes.js b/src/task-notes.js new file mode 100644 index 0000000..445c2f4 --- /dev/null +++ b/src/task-notes.js @@ -0,0 +1,129 @@ +// The [TASK OUTCOME] and [CACHE STATUS] notes appended to a finished subagent +// call, as pure functions over already-fetched data. Shared by the OpenCode 1 +// entry (the `task` tool) and the OpenCode 2 entry (the `subagent` tool); each +// entry only fetches the subagent's messages and converts them to V1 `{info}` +// items. + +import { formatCacheStatus, resolveCacheTtl } from "./cache-status.js"; +import { estimateContextTokens, formatLocalDateTime } from "./context.js"; +import { formatTaskOutcome } from "./task-outcome.js"; + +/** + * Facts about a subagent session from its messages: + * lastHitMs — the newest assistant message's request-start time, i.e. + * when this session last actually hit its provider; + * finish/…Tokens — from the newest assistant turn that actually COMPLETED. A + * session can end with a stub assistant record (no finish, + * all-zero tokens) after an abort, and reading the outcome off + * that stub would report "no finish reason" for every + * cancelled task. Deliberately a separate scan from + * lastHitMs, which wants the newest provider contact whether + * or not it finished; + * contextTokens — how big its context grew (last completed turn). Missing + * usage must not suppress the cache-status line — it degrades + * to the note without the size clause. + * + * @param {Array<{info?: any}>} messages V1 `{info}` items + */ +export function subagentUsage(messages) { + let lastHitMs = null; + let finish; + let outputTokens; + let reasoningTokens; + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i]?.info; + if (info?.role !== "assistant") continue; + if (lastHitMs === null && typeof info?.time?.created === "number") { + lastHitMs = info.time.created; + } + if (finish === undefined && typeof info?.finish === "string" && info.finish.length > 0) { + finish = info.finish; + outputTokens = info?.tokens?.output; + reasoningTokens = info?.tokens?.reasoning; + } + if (lastHitMs !== null && finish !== undefined) break; + } + const ctx = estimateContextTokens(messages); + return { lastHitMs, contextTokens: ctx?.used, finish, outputTokens, reasoningTokens }; +} + +/** + * The notes to append, in order: [TASK OUTCOME] first (a fact about THIS call + * the orchestrator has to act on — appended even when the model or the + * timestamp is unknown, since suppressing it there would restore the blind spot + * it exists to close), then [CACHE STATUS] (advice about a possible next call, + * only when the model and a usable timestamp are known). + * + * @param {object} input + * @param {string} input.taskId + * @param {string} [input.providerID] + * @param {string} [input.modelID] + * @param {ReturnType & {usageUnknown?: boolean}} input.usage + * @param {boolean} input.resultEmpty + * @param {Record | null} input.modelData model_data.json models + * @param {Record | undefined} input.limitMap + * @param {number} input.now + * @param {string} [input.timeZone] + * @returns {string[]} + */ +export function taskNotes({ + taskId, + providerID, + modelID, + usage, + resultEmpty, + modelData, + limitMap, + now, + timeZone, +}) { + const notes = []; + const outcome = formatTaskOutcome({ + taskId, + providerModelId: providerID && modelID ? `${providerID}/${modelID}` : undefined, + finish: usage.finish, + outputTokens: usage.outputTokens, + reasoningTokens: usage.reasoningTokens, + resultEmpty, + usageUnknown: usage.usageUnknown, + }); + if (outcome) notes.push(outcome); + + if (!providerID || !modelID || usage.lastHitMs === null || usage.lastHitMs === undefined) { + return notes; + } + + // model_data.json wins when the user has filled it in; otherwise resolve + // from the provider. Without that fallback the hint degraded to "TTL isn't + // published — judge for yourself" for every anthropic model, because the + // hand-edited field was simply never filled in. + const configuredTtl = modelData?.[`${providerID}/${modelID}`]?.cache_ttl_seconds; + const ttl = + typeof configuredTtl === "number" + ? { seconds: configuredTtl, source: /** @type {const} */ ("published") } + : resolveCacheTtl(providerID); + const contextLimit = limitMap?.[`${providerID}/${modelID}`] ?? limitMap?.[modelID]; + const zone = timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + + notes.push( + formatCacheStatus({ + taskId, + providerModelId: `${providerID}/${modelID}`, + lastHitMs: usage.lastHitMs, + // Absolute wall clock in the same format/zone the bootstrap stamps its + // own "now" with, so the orchestrator can recompute the gap at the next + // delegation instead of trusting a stale relative age. + lastHitAtText: formatLocalDateTime(new Date(usage.lastHitMs), zone) ?? undefined, + // The orchestrator's own clock is frozen at the start of its turn (see + // src/message-transform.js), so this stamp is the tighter lower bound on + // "now" when it computes a reuse gap later in the same turn. + nowAtText: formatLocalDateTime(new Date(now), zone) ?? undefined, + ttlSeconds: ttl.seconds, + ttlSource: ttl.source, + contextTokens: usage.contextTokens, + contextLimit, + now, + }), + ); + return notes; +} diff --git a/src/v2/adapt.js b/src/v2/adapt.js new file mode 100644 index 0000000..8d904db --- /dev/null +++ b/src/v2/adapt.js @@ -0,0 +1,258 @@ +// OpenCode 2 payloads -> the shapes the pure squad modules were written for. +// +// Everything in src/ above this folder reads OpenCode 1 shapes: agents named by +// `name` with a `{providerID, modelID}` model, providers with keyed `models`, +// messages as `{info, parts}`, the system prompt as a string array. OpenCode 2 +// changed each of them. Converting here, at the edge, keeps the decision logic +// (and its tests) shared by both entries. Shapes were captured off OpenCode +// 2.1.3 — see tests/v2-adapt.test.js for the fixtures. + +import { BOOTSTRAP_MARKER, buildBootstrap } from "../bootstrap.js"; +import { + DEFAULT_LIMIT, + estimateContextTokens, + formatContextLine, + formatLocalDateTime, +} from "../context.js"; +import { applySystemPromptTransform } from "../prompt-inject.js"; + +/** + * V2 Agent.Info[] -> V1 agent list. V2's `name` is a display name ("Build"); + * the id is what V1 called the name and what everything here matches on + * (`grunt-…`/`drill-…`). + * + * @param {readonly any[]} agents + * @returns {Array<{name: string, mode: string, description?: string, model?: {providerID: string, modelID: string}}>} + */ +export function toV1Agents(agents) { + if (!Array.isArray(agents)) return []; + return agents + .filter((a) => a && typeof a.id === "string") + .map((a) => ({ + name: a.id, + mode: a.mode, + ...(a.description ? { description: a.description } : {}), + ...(a.model?.providerID && a.model?.id + ? { model: { providerID: a.model.providerID, modelID: a.model.id } } + : {}), + })); +} + +/** + * V2 Model.Info[] (ctx.model.list) -> V1 provider list as buildLimitMap and + * buildVariantMap read it: `[{id, models: {[modelID]: {limit, variants}}}]`. + * V2 variants are `[{id, settings}]`; V1's keyed-object form is kept so + * buildVariantMap needs no change. + * + * @param {readonly any[]} models + */ +export function toV1Providers(models) { + /** @type {Map}>} */ + const byProvider = new Map(); + for (const m of Array.isArray(models) ? models : []) { + if (!m?.providerID || !m?.id) continue; + const provider = byProvider.get(m.providerID) ?? { id: m.providerID, models: {} }; + provider.models[m.id] = { + ...(m.limit ? { limit: m.limit } : {}), + variants: Object.fromEntries( + (Array.isArray(m.variants) ? m.variants : []) + .filter((v) => typeof v?.id === "string") + .map((v) => [v.id, v.settings ?? {}]), + ), + }; + byProvider.set(m.providerID, provider); + } + return [...byProvider.values()]; +} + +/** + * V2 SessionMessageInfo[] (ctx.session.context) -> V1 `{info}` items carrying + * what estimateContextTokens and the task-outcome scan read off assistant + * messages: role, time, finish, tokens, model. + * + * @param {readonly any[]} messages + */ +export function toV1UsageMessages(messages) { + if (!Array.isArray(messages)) return []; + return messages + .filter((m) => m?.type === "assistant" || m?.type === "user") + .map((m) => ({ + info: { + role: m.type, + time: m.time, + ...(m.type === "assistant" + ? { + finish: m.finish, + tokens: m.tokens, + modelID: m.model?.id, + providerID: m.model?.providerID, + } + : {}), + }, + })); +} + +/** + * The subagent's model, read off its newest assistant message (V2 keeps it + * nowhere else a plugin can reach). + * + * @param {readonly any[]} messages V2 SessionMessageInfo[] + * @returns {{providerID: string, modelID: string} | null} + */ +export function lastAssistantModel(messages) { + if (!Array.isArray(messages)) return null; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.type === "assistant" && m.model?.providerID && m.model?.id) { + return { providerID: m.model.providerID, modelID: m.model.id }; + } + } + return null; +} + +/** + * Role-prompt refresh on V2's `SystemPart[]`. The agent's own prompt is the + * text of the first part (verified live for a subagent session), so the V1 + * transform runs on the first text part that carries a fence. + * + * @param {Array<{type?: string, text?: string}>} system + * @param {(role: string) => string | null} loadPrompt + */ +export function applySystemPartsTransform(system, loadPrompt) { + if (!Array.isArray(system)) return { applied: false }; + const part = system.find( + (p) => p?.type === "text" && typeof p.text === "string" && p.text.includes("squad:prompt"), + ); + if (!part) return { applied: false }; + const holder = [part.text]; + const result = applySystemPromptTransform(holder, loadPrompt); + if (result.changed) part.text = holder[0]; + return result; +} + +/** + * A copy of a V2 tool result with `text` appended as its own paragraph. + * @param {any} result + * @param {string} text + */ +export function appendToResult(result, text) { + const content = result?.content; + if (Array.isArray(content)) { + return { ...result, content: [...content, { type: "text", text }] }; + } + const base = typeof content === "string" ? content : ""; + return { ...result, content: base ? `${base}\n\n${text}` : text }; +} + +/** + * What the V2 `subagent` tool returned, in the terms the task-outcome and + * cache-status code use: the child session id and the subagent's final text. + * + * @param {any} result + * @returns {{sessionID?: string, output: string}} + */ +export function subagentOutcome(result) { + const sessionID = result?.metadata?.sessionID ?? result?.output?.sessionID; + const text = result?.output?.output; + return { sessionID, output: typeof text === "string" ? text : "" }; +} + +/** + * V2 `retry` hook event -> the V1 `session.status` retry object evaluateRetry + * reads. `next` is the absolute wake-up time OpenCode is proposing. + * + * @param {{attempt: number, error?: {message?: string}, decision: {retry: boolean, delay?: number}}} event + * @param {number} now + */ +export function retryToStatus(event, now) { + const delay = event?.decision?.retry ? Number(event.decision.delay) || 0 : 0; + return { + type: "retry", + attempt: event?.attempt, + message: event?.error?.message ?? "", + next: now + Math.max(0, delay), + }; +} + +/** + * The message this turn's injection lands on: the latest user message with + * text content. V2 hands the agent id to the hook directly and runs it only for + * the agent loop (titles and compactions have hooks of their own), so neither + * V1 heuristic — agent tags on messages, internal-generation prompts — is + * needed here. + * + * @param {Array<{id?: string, role?: string, content?: any}>} messages + */ +export function findV2InjectionTarget(messages) { + if (!Array.isArray(messages)) return null; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.role !== "user" || !Array.isArray(m.content)) continue; + if (m.content.some((c) => c?.type === "text" && typeof c.text === "string")) return m; + } + return null; +} + +/** + * V2 counterpart of applyOrchestratorTransform (src/message-transform.js): the + * same bootstrap + context-budget line, built once per turn and replayed + * byte-identically (see createTurnMemo for why that matters for cost). + * + * @param {{agent: string, model?: {providerID?: string, id?: string}, messages: any[]}} event + * the `context` hook event (mutated in place) + * @param {object} opts + * @param {string} opts.orchestratorAgent + * @param {() => Promise} opts.getInventory + * @param {() => Promise>} opts.getLimitMap + * @param {() => boolean} opts.getHasSquad called after getInventory resolves + * @param {() => Promise} opts.getUsageMessages V1 `{info}` items for the + * session (toV1UsageMessages of ctx.session.context); V2 request messages + * carry no token usage + * @param {ReturnType} [opts.turnMemo] + */ +export async function applyOrchestratorTransformV2(event, opts) { + if (event?.agent !== opts.orchestratorAgent) return; + const target = findV2InjectionTarget(event.messages); + if (!target) return; + if ( + target.content.some( + (c) => c?.type === "text" && typeof c.text === "string" && c.text.includes(BOOTSTRAP_MARKER), + ) + ) { + return; + } + + const turnId = typeof target.id === "string" ? target.id : null; + let block = turnId && opts.turnMemo ? opts.turnMemo.get(turnId) : null; + if (!block) { + const inventory = await opts.getInventory(); + const nowText = formatLocalDateTime( + new Date(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + const modelText = + event.model?.providerID && event.model?.id + ? `${event.model.providerID}/${event.model.id}` + : null; + const bootstrap = buildBootstrap(inventory, { + nowText, + modelText, + hasSquad: opts.getHasSquad(), + }); + + let contextLine = null; + const usage = estimateContextTokens(await opts.getUsageMessages()); + if (usage) { + const limits = await opts.getLimitMap(); + const limit = + limits[`${usage.providerID}/${usage.modelID}`] ?? limits[usage.modelID] ?? DEFAULT_LIMIT; + contextLine = formatContextLine(usage.used, limit); + } + + block = { bootstrap, contextLine }; + if (turnId && opts.turnMemo) opts.turnMemo.set(turnId, block); + } + + target.content.unshift({ type: "text", text: block.bootstrap }); + if (block.contextLine) target.content.push({ type: "text", text: block.contextLine }); +} diff --git a/src/v2/schemas.js b/src/v2/schemas.js new file mode 100644 index 0000000..4c3b62f --- /dev/null +++ b/src/v2/schemas.js @@ -0,0 +1,53 @@ +// JSON Schema inputs for the squad_* tools on OpenCode 2, mirroring the zod +// declarations in .opencode/plugins/orchestrate.js (OpenCode 2 takes plain +// JSON Schema and does not provide zod). Wording comes from src/squad-tools.js. + +import { ARG_DESCRIPTIONS } from "../squad-tools.js"; + +const agentEntry = { + type: "object", + properties: { + variant: { type: "string", description: ARG_DESCRIPTIONS["agent.variant"] }, + description: { type: "string", description: ARG_DESCRIPTIONS["agent.description"] }, + notes: { type: "string", description: ARG_DESCRIPTIONS["agent.notes"] }, + steps: { type: "integer", exclusiveMinimum: 0, description: ARG_DESCRIPTIONS["agent.steps"] }, + disable: { type: "boolean", description: ARG_DESCRIPTIONS["agent.disable"] }, + }, + additionalProperties: false, +}; + +/** @param {string} description */ +const agentMap = (description) => ({ + type: "object", + additionalProperties: agentEntry, + description, +}); + +export const SQUAD_COMPACT_INPUT = { + type: "object", + properties: { + task_id: { type: "string", description: ARG_DESCRIPTIONS["compact.task_id"] }, + model: { type: "string", description: ARG_DESCRIPTIONS["compact.model"] }, + }, + required: ["task_id"], + additionalProperties: false, +}; + +export const SQUAD_DUMP_INPUT = { + type: "object", + properties: { + directory: { type: "string", description: ARG_DESCRIPTIONS["dump.directory"] }, + }, + additionalProperties: false, +}; + +export const SQUAD_PATCH_INPUT = { + type: "object", + properties: { + grunts: agentMap(ARG_DESCRIPTIONS["patch.grunts"]), + drills: agentMap(ARG_DESCRIPTIONS["patch.drills"]), + allow_remove: { type: "boolean", description: ARG_DESCRIPTIONS["patch.allow_remove"] }, + directory: { type: "string", description: ARG_DESCRIPTIONS["patch.directory"] }, + }, + additionalProperties: false, +}; diff --git a/src/v2/skills.js b/src/v2/skills.js new file mode 100644 index 0000000..e3c674d --- /dev/null +++ b/src/v2/skills.js @@ -0,0 +1,95 @@ +/** + * Bundled skills for OpenCode 2 (shared verbatim with opencode-time-logger). + * + * OpenCode 1 let a plugin append a directory to `config.skills.paths`; OpenCode + * 2 has no config hook, so the plugin reads each `//SKILL.md` + * itself and registers it through `ctx.skill.transform`. The registered entry + * mirrors what OpenCode 2 builds for a discovered skill: frontmatter `name` and + * `description`, the absolute file path, and the body without frontmatter as + * `content`. + */ + +import fs from "node:fs"; +import path from "node:path"; + +/** + * Split a SKILL.md into its frontmatter fields and body. Handles the subset of + * YAML that skill frontmatter uses: `key: value`, single- or double-quoted + * values, and `>` / `|` block scalars. Anything fancier is read as plain text. + * + * Pure function — no I/O. + * + * @param {string} text + * @returns {{ fields: Record, body: string }} + */ +export function parseSkillFile(text) { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text); + if (!match) return { fields: {}, body: text }; + + /** @type {Record} */ + const fields = {}; + const lines = match[1].split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]); + if (!kv) continue; + const [, key, raw] = kv; + let value = raw.trim(); + if (value === ">" || value === "|" || value === ">-" || value === "|-") { + const block = []; + while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) { + block.push(lines[++i].trim()); + } + value = block.join(value.startsWith(">") ? " " : "\n"); + } else if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { + try { + value = JSON.parse(value); + } catch { + value = value.slice(1, -1); + } + } else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { + value = value.slice(1, -1).replace(/''/g, "'"); + } + fields[key] = value; + } + return { fields, body: text.slice(match[0].length) }; +} + +/** + * Read every `//SKILL.md` under the given directories. A skill + * without a frontmatter name falls back to its folder name; unreadable or + * missing directories are skipped. + * + * @param {string[]} dirs absolute directories holding one folder per skill + * @returns {Array<{ id: string, name: string, description?: string, path: string, content: string }>} + */ +export function loadSkills(dirs) { + const skills = []; + for (const dir of dirs) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const file = path.join(dir, entry.name, "SKILL.md"); + let text; + try { + text = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + const { fields, body } = parseSkillFile(text); + const name = fields.name || entry.name; + skills.push({ + id: name, + name, + ...(fields.description ? { description: fields.description } : {}), + path: file, + content: body, + }); + } + } + return skills; +} diff --git a/tests/squad-tools.test.js b/tests/squad-tools.test.js new file mode 100644 index 0000000..3ff4f07 --- /dev/null +++ b/tests/squad-tools.test.js @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + ARG_DESCRIPTIONS, + formatVariantLines, + isSubagentCaller, + TOOL_DESCRIPTIONS, + validateVariants, +} from "../src/squad-tools.js"; +import { subagentUsage, taskNotes } from "../src/task-notes.js"; +import { SQUAD_COMPACT_INPUT, SQUAD_DUMP_INPUT, SQUAD_PATCH_INPUT } from "../src/v2/schemas.js"; +import { loadSkills, parseSkillFile } from "../src/v2/skills.js"; + +describe("squad-tools", () => { + test("isSubagentCaller matches grunts and drills only", () => { + expect(isSubagentCaller("grunt-glm")).toBe(true); + expect(isSubagentCaller("drill-opus")).toBe(true); + expect(isSubagentCaller("build")).toBe(false); + expect(isSubagentCaller(undefined)).toBe(false); + }); + + test("formatVariantLines: unknown lookup, empty roster, per-model lines", () => { + const roster = { grunts: { "zai/glm": {} }, drills: { "a/opus": {}, "x/missing": {} } }; + expect(formatVariantLines(null, roster)).toContain("could not be read"); + expect(formatVariantLines({}, { grunts: {}, drills: {} })).toBe(""); + const out = formatVariantLines({ "zai/glm": ["low", "max"], "a/opus": [] }, roster); + expect(out).toContain(" a/opus: (none — leave variant unset)"); + expect(out).toContain(" x/missing: unknown (model not in this install)"); + expect(out).toContain(" zai/glm: low | max"); + expect(out.indexOf("a/opus")).toBeLessThan(out.indexOf("zai/glm")); + }); + + test("validateVariants flags only variants opencode rejects", () => { + const roster = { + grunts: { "zai/glm": { variant: "medium" }, "x/unknown": { variant: "high" } }, + drills: { "a/opus": { variant: "high" }, "a/none": { variant: "low" } }, + }; + const map = { "zai/glm": ["low", "max"], "a/opus": ["high"], "a/none": [] }; + const errors = validateVariants(roster, map); + expect(errors).toHaveLength(2); + expect(errors[0]).toContain('grunts["zai/glm"].variant "medium"'); + expect(errors[1]).toContain("(none: leave it unset)"); + expect(validateVariants(roster, null)).toEqual([]); + }); +}); + +describe("V2 schemas", () => { + test("mirror the V1 arguments and carry the shared wording", () => { + expect(SQUAD_COMPACT_INPUT.required).toEqual(["task_id"]); + expect(Object.keys(SQUAD_PATCH_INPUT.properties).sort()).toEqual([ + "allow_remove", + "directory", + "drills", + "grunts", + ]); + expect(SQUAD_PATCH_INPUT.properties.grunts.additionalProperties.properties.steps.type).toBe( + "integer", + ); + expect(SQUAD_DUMP_INPUT.properties.directory.description).toBe( + ARG_DESCRIPTIONS["dump.directory"], + ); + for (const s of [SQUAD_COMPACT_INPUT, SQUAD_DUMP_INPUT, SQUAD_PATCH_INPUT]) { + expect(s.additionalProperties).toBe(false); + } + expect(Object.keys(TOOL_DESCRIPTIONS).sort()).toEqual([ + "squad_compact", + "squad_dump", + "squad_patch", + ]); + }); +}); + +describe("task notes", () => { + const msgs = [ + { info: { role: "user", time: { created: 1 } } }, + { + info: { + role: "assistant", + time: { created: 100 }, + finish: "length", + tokens: { input: 10, output: 20, reasoning: 5, cache: { read: 0, write: 0 } }, + modelID: "m", + providerID: "p", + }, + }, + { info: { role: "assistant", time: { created: 200 } } }, + ]; + + test("subagentUsage separates newest contact from newest finished turn", () => { + expect(subagentUsage(msgs)).toEqual({ + lastHitMs: 200, + contextTokens: 35, + finish: "length", + outputTokens: 20, + reasoningTokens: 5, + }); + expect(subagentUsage([])).toEqual({ + lastHitMs: null, + contextTokens: undefined, + finish: undefined, + outputTokens: undefined, + reasoningTokens: undefined, + }); + }); + + test("taskNotes: outcome first, cache status only with model and timestamp", () => { + const usage = subagentUsage(msgs); + const base = { + taskId: "t1", + usage, + resultEmpty: false, + modelData: { "p/m": { cache_ttl_seconds: 300 } }, + limitMap: { "p/m": 1000 }, + now: 250, + timeZone: "UTC", + }; + const both = taskNotes({ ...base, providerID: "p", modelID: "m" }); + expect(both).toHaveLength(2); + expect(both[0]).toContain("[TASK OUTCOME] task_id=t1"); + expect(both[1]).toContain("[CACHE STATUS]"); + expect(taskNotes({ ...base })).toHaveLength(1); + const clean = taskNotes({ + ...base, + usage: { ...usage, finish: "stop", lastHitMs: null }, + providerID: "p", + modelID: "m", + }); + expect(clean).toEqual([]); + const providerTtl = taskNotes({ + ...base, + modelData: null, + providerID: "anthropic", + modelID: "m", + }); + expect(providerTtl.at(-1)).toContain("[CACHE STATUS]"); + }); +}); + +describe("V2 skills", () => { + test("parseSkillFile handles quoted, single-quoted and block values", () => { + expect(parseSkillFile('---\nname: a\ndescription: "say \\"hi\\""\n---\nBody').fields).toEqual({ + name: "a", + description: 'say "hi"', + }); + expect(parseSkillFile("---\nname: 'it''s'\n---\n").fields.name).toBe("it's"); + const block = parseSkillFile("---\nname: b\ndescription: |\n one\n two\n---\nBody"); + expect(block.fields.description).toBe("one\ntwo"); + expect(block.body).toBe("Body"); + expect(parseSkillFile("plain").body).toBe("plain"); + }); + + test("loadSkills reads the bundled skills and skips what it cannot read", () => { + const skills = loadSkills([path.resolve(import.meta.dirname, "..", "skills")]); + expect(skills.map((s) => s.id).sort()).toEqual([ + "squad-delegate", + "squad-draft", + "squad-redteam", + "squad-stall", + ]); + for (const s of skills) { + expect(path.isAbsolute(s.path)).toBe(true); + expect(s.content.startsWith("---")).toBe(false); + } + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "squad-skills-")); + fs.mkdirSync(path.join(dir, "bare")); + fs.writeFileSync(path.join(dir, "bare", "SKILL.md"), "no frontmatter"); + fs.mkdirSync(path.join(dir, "empty")); + fs.writeFileSync(path.join(dir, "file.txt"), "x"); + expect(loadSkills([path.join(dir, "missing"), dir])).toEqual([ + { + id: "bare", + name: "bare", + path: path.join(dir, "bare", "SKILL.md"), + content: "no frontmatter", + }, + ]); + }); +}); diff --git a/tests/v2-adapt.test.js b/tests/v2-adapt.test.js new file mode 100644 index 0000000..d838bdf --- /dev/null +++ b/tests/v2-adapt.test.js @@ -0,0 +1,274 @@ +import { describe, expect, test } from "vitest"; +import { BOOTSTRAP_MARKER } from "../src/bootstrap.js"; +import { buildLimitMap, buildVariantMap, estimateContextTokens } from "../src/context.js"; +import { formatInventory, hasSquad } from "../src/inventory.js"; +import { createTurnMemo } from "../src/message-transform.js"; +import { PROMPT_CLOSE, promptOpen } from "../src/prompt-inject.js"; +import { evaluateRetry, initGuardState, normalizeGuardConfig } from "../src/rate-limit-guard.js"; +import { + appendToResult, + applyOrchestratorTransformV2, + applySystemPartsTransform, + findV2InjectionTarget, + lastAssistantModel, + retryToStatus, + subagentOutcome, + toV1Agents, + toV1Providers, + toV1UsageMessages, +} from "../src/v2/adapt.js"; + +// Payload shapes trimmed from OpenCode 2.1.3 captures. +const v2Agents = [ + { id: "build", name: "Build", mode: "primary", hidden: false, permissions: [] }, + { + id: "grunt-glm", + name: "Grunt Glm", + mode: "subagent", + description: "cheap edits", + model: { id: "glm-5.3", providerID: "zai", variant: "low" }, + }, + { id: "general", name: "General", mode: "subagent", description: "general" }, +]; + +const v2Models = [ + { + id: "glm-5.3", + providerID: "zai", + limit: { context: 200000, output: 32000 }, + variants: [ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "max", settings: { reasoningEffort: "max" } }, + ], + }, + { id: "big-pickle", providerID: "opencode", limit: { context: 128000 }, variants: [] }, +]; + +const childContext = [ + { id: "u1", type: "user", time: { created: 1000 }, text: "do it" }, + { + id: "a1", + type: "assistant", + time: { created: 1100, completed: 1200 }, + agent: "grunt-glm", + model: { id: "glm-5.3", providerID: "zai" }, + content: [{ type: "text", text: "done" }], + finish: "stop", + tokens: { input: 900, output: 40, reasoning: 10, cache: { read: 50, write: 0 } }, + }, + { id: "i1", type: "idle", time: { created: 1300 }, outcome: "succeeded" }, +]; + +describe("agents and providers", () => { + test("toV1Agents uses the id as the name and reshapes the model", () => { + const agents = toV1Agents(v2Agents); + expect(agents[1]).toEqual({ + name: "grunt-glm", + mode: "subagent", + description: "cheap edits", + model: { providerID: "zai", modelID: "glm-5.3" }, + }); + expect(agents[0].model).toBeUndefined(); + expect(hasSquad(agents)).toBe(true); + expect(formatInventory(agents)).toContain("`grunt-glm`: cheap edits (model: zai/glm-5.3)"); + expect(toV1Agents(undefined)).toEqual([]); + }); + + test("toV1Providers feeds buildLimitMap and buildVariantMap", () => { + const providers = toV1Providers(v2Models); + expect(buildLimitMap(providers)).toMatchObject({ "zai/glm-5.3": 200000, "glm-5.3": 200000 }); + expect(buildVariantMap(providers)).toEqual({ + "zai/glm-5.3": ["low", "max"], + "opencode/big-pickle": [], + }); + expect(toV1Providers(null)).toEqual([]); + }); +}); + +describe("messages", () => { + test("toV1UsageMessages keeps what the usage scans read", () => { + const items = toV1UsageMessages(childContext); + expect(items).toHaveLength(2); + expect(items[1].info).toMatchObject({ + role: "assistant", + finish: "stop", + modelID: "glm-5.3", + providerID: "zai", + }); + expect(estimateContextTokens(items)).toEqual({ + used: 1000, + modelID: "glm-5.3", + providerID: "zai", + }); + expect(toV1UsageMessages("nope")).toEqual([]); + }); + + test("lastAssistantModel reads the newest assistant message", () => { + expect(lastAssistantModel(childContext)).toEqual({ providerID: "zai", modelID: "glm-5.3" }); + expect(lastAssistantModel([])).toBeNull(); + expect(lastAssistantModel(undefined)).toBeNull(); + }); +}); + +describe("subagent results", () => { + const result = { + output: { sessionID: "ses_child", status: "completed", output: "PONG" }, + content: [{ type: "text", text: '\nPONG\n' }], + metadata: { sessionID: "ses_child", status: "completed" }, + }; + + test("subagentOutcome reads the child session and final text", () => { + expect(subagentOutcome(result)).toEqual({ sessionID: "ses_child", output: "PONG" }); + expect(subagentOutcome({})).toEqual({ sessionID: undefined, output: "" }); + }); + + test("appendToResult adds a text part or a paragraph", () => { + expect(appendToResult(result, "NOTE").content.at(-1)).toEqual({ type: "text", text: "NOTE" }); + expect(result.content).toHaveLength(1); // not mutated + expect(appendToResult({ content: "x" }, "NOTE").content).toBe("x\n\nNOTE"); + expect(appendToResult({}, "NOTE").content).toBe("NOTE"); + }); +}); + +describe("system prompt refresh", () => { + const fenced = `${promptOpen("grunt")}\nOLD\n${PROMPT_CLOSE}\nnotes`; + + test("replaces the fenced body in the first fenced text part", () => { + const system = [ + { type: "text", text: "harness" }, + { type: "text", text: fenced }, + ]; + const r = applySystemPartsTransform(system, () => "NEW BODY"); + expect(r).toMatchObject({ applied: true, role: "grunt", changed: true }); + expect(system[1].text).toContain("NEW BODY"); + expect(system[1].text).not.toContain("OLD"); + expect(system[1].text.endsWith("notes")).toBe(true); + expect(system[0].text).toBe("harness"); + }); + + test("no fence or no loader body leaves the parts alone", () => { + const system = [{ type: "text", text: "plain" }]; + expect(applySystemPartsTransform(system, () => "X")).toEqual({ applied: false }); + const withFence = [{ type: "text", text: fenced }]; + expect(applySystemPartsTransform(withFence, () => null).applied).toBe(false); + expect(withFence[0].text).toBe(fenced); + expect(applySystemPartsTransform(undefined, () => "X")).toEqual({ applied: false }); + }); +}); + +describe("retry hook", () => { + test("retryToStatus turns the proposed delay into an absolute wake-up", () => { + const event = { + attempt: 2, + error: { type: "provider", message: "rate limit, resets in 3h", status: 429 }, + decision: { retry: true, delay: 3 * 3600_000 }, + }; + expect(retryToStatus(event, 1000)).toEqual({ + type: "retry", + attempt: 2, + message: "rate limit, resets in 3h", + next: 1000 + 3 * 3600_000, + }); + const r = evaluateRetry( + initGuardState(), + retryToStatus(event, 1000), + normalizeGuardConfig(undefined), + 429, + 1000, + ); + expect(r).toMatchObject({ trigger: true, reason: "single_wait" }); + }); + + test("a vetoed or missing delay counts as zero wait", () => { + expect(retryToStatus({ attempt: 1, decision: { retry: false } }, 5).next).toBe(5); + expect(retryToStatus({ attempt: 1, decision: { retry: true, delay: -1 } }, 5).next).toBe(5); + }); +}); + +describe("orchestrator injection", () => { + const baseOpts = () => { + const calls = { inventory: 0, usage: 0 }; + return { + calls, + opts: { + orchestratorAgent: "build", + getInventory: async () => { + calls.inventory += 1; + return "- `grunt-glm`: cheap"; + }, + getLimitMap: async () => ({ "zai/glm-5.3": 200000 }), + getHasSquad: () => true, + getUsageMessages: async () => { + calls.usage += 1; + return toV1UsageMessages(childContext); + }, + turnMemo: createTurnMemo(), + }, + }; + }; + const request = () => ({ + agent: "build", + model: { providerID: "anthropic", id: "claude-opus-5-5" }, + messages: [ + { id: "m1", role: "user", content: [{ type: "text", text: "first" }] }, + { id: "m2", role: "assistant", content: [{ type: "text", text: "ok" }] }, + { id: "m3", role: "user", content: [{ type: "text", text: "now this" }] }, + ], + }); + + test("findV2InjectionTarget picks the latest user message with text", () => { + expect(findV2InjectionTarget(request().messages)?.id).toBe("m3"); + expect(findV2InjectionTarget([{ role: "user", content: [{ type: "file" }] }])).toBeNull(); + expect(findV2InjectionTarget(undefined)).toBeNull(); + }); + + test("injects bootstrap first and the context line last on the target", async () => { + const { opts } = baseOpts(); + const event = request(); + await applyOrchestratorTransformV2(event, opts); + const content = event.messages[2].content; + expect(content[0].text).toContain(BOOTSTRAP_MARKER); + expect(content[0].text).toContain("anthropic/claude-opus-5-5"); + expect(content[1].text).toBe("now this"); + expect(content.at(-1).text).toMatch(/context/i); + expect(event.messages[0].content).toHaveLength(1); + }); + + test("replays the memoized block byte-identically within a turn", async () => { + const { opts, calls } = baseOpts(); + const first = request(); + await applyOrchestratorTransformV2(first, opts); + const second = request(); + await applyOrchestratorTransformV2(second, opts); + expect(second.messages[2].content).toEqual(first.messages[2].content); + expect(calls.inventory).toBe(1); + expect(calls.usage).toBe(1); + }); + + test("skips other agents, already-injected targets, and empty requests", async () => { + const { opts, calls } = baseOpts(); + const sub = { ...request(), agent: "grunt-glm" }; + await applyOrchestratorTransformV2(sub, opts); + expect(sub.messages[2].content).toHaveLength(1); + + const injected = request(); + injected.messages[2].content.unshift({ type: "text", text: `${BOOTSTRAP_MARKER} old` }); + await applyOrchestratorTransformV2(injected, opts); + expect(injected.messages[2].content).toHaveLength(2); + + await applyOrchestratorTransformV2({ agent: "build", messages: [] }, opts); + expect(calls.inventory).toBe(0); + }); + + test("no usage yet means no context line; no memo means rebuild", async () => { + const { opts, calls } = baseOpts(); + opts.getUsageMessages = async () => []; + opts.turnMemo = undefined; + const event = request(); + event.model = undefined; + await applyOrchestratorTransformV2(event, opts); + expect(event.messages[2].content).toHaveLength(2); + await applyOrchestratorTransformV2(request(), opts); + expect(calls.inventory).toBe(2); + }); +}); From 4c5e5bcc0188056ee35e1bed391d851b00f3ad2a Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Thu, 22 Oct 2026 22:02:35 +0300 Subject: [PATCH 2/5] fix: keep the V1 inventory cache in orchestrate.js; lint undeclared variables The runtime.js extraction carried the _inventoryCache/_hasSquadCache declarations along, leaving orchestrate.js referencing undeclared variables (ReferenceError on the first V1 turn). Enable biome's noUndeclaredVariables so this class of slip fails the lint. Co-Authored-By: Claude Opus 5.5 --- .opencode/plugins/orchestrate.js | 6 ++++++ .opencode/plugins/runtime.js | 6 ------ biome.json | 5 ++++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.opencode/plugins/orchestrate.js b/.opencode/plugins/orchestrate.js index fbe32ca..0c95001 100644 --- a/.opencode/plugins/orchestrate.js +++ b/.opencode/plugins/orchestrate.js @@ -70,6 +70,12 @@ import { turnMemo, } from "./runtime.js"; +// Cache the subagent inventory string (and whether a squad has been drafted) +// per process — neither changes at runtime. The bootstrap itself is assembled +// per injection so the live facts (current time, current model) stay fresh. +let _inventoryCache; // undefined = not loaded +let _hasSquadCache; // undefined = not loaded + // Configured orchestrator model, captured from config (fallback for turn 1, // before any assistant message reveals the actual model). let _orchestratorModel = null; diff --git a/.opencode/plugins/runtime.js b/.opencode/plugins/runtime.js index a73c0d1..104934e 100644 --- a/.opencode/plugins/runtime.js +++ b/.opencode/plugins/runtime.js @@ -44,12 +44,6 @@ export const loadRolePrompt = createPromptLoader({ }, }); -// Cache the subagent inventory string (and whether a squad has been drafted) -// per process — neither changes at runtime. The bootstrap itself is assembled -// per injection so the live facts (current time, current model) stay fresh. -let _inventoryCache; // undefined = not loaded -let _hasSquadCache; // undefined = not loaded - // AA benchmark snapshot (models object), read once per process: the fresher of // the bundled copy and the one the background refresher downloaded. Fixed for // the process lifetime on purpose — see src/benchmark-cache.js. null if neither diff --git a/biome.json b/biome.json index 8c60054..d116506 100644 --- a/biome.json +++ b/biome.json @@ -18,7 +18,10 @@ "linter": { "enabled": true, "rules": { - "preset": "recommended" + "preset": "recommended", + "correctness": { + "noUndeclaredVariables": "error" + } } }, "javascript": { From b2ddcaa42d9a8efc0925cf5180096834bc32f858 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Thu, 22 Oct 2026 22:12:27 +0300 Subject: [PATCH 3/5] refactor: move V2 entry and shared runtime out of .opencode/plugins; per-project model_data on V2 .opencode/plugins/ is auto-loaded by both hosts when opencode runs in this repo, so only the V1 entry stays there. On V2 the background service's cwd is not the project, so model_data.json is resolved from ctx.location and cached per directory. Co-Authored-By: Claude Opus 5.5 --- .opencode/{plugins => lib}/orchestrate-v2.js | 9 +++++---- .opencode/{plugins => lib}/runtime.js | 21 +++++++++++--------- .opencode/plugins/orchestrate.js | 2 +- index.js | 4 ++-- knip.json | 7 ++++++- 5 files changed, 26 insertions(+), 17 deletions(-) rename .opencode/{plugins => lib}/orchestrate-v2.js (98%) rename .opencode/{plugins => lib}/runtime.js (90%) diff --git a/.opencode/plugins/orchestrate-v2.js b/.opencode/lib/orchestrate-v2.js similarity index 98% rename from .opencode/plugins/orchestrate-v2.js rename to .opencode/lib/orchestrate-v2.js index 5e16ae7..ea6001b 100644 --- a/.opencode/plugins/orchestrate-v2.js +++ b/.opencode/lib/orchestrate-v2.js @@ -1,8 +1,8 @@ /** * opencode-squad plugin entry for OpenCode 2. * - * Same responsibilities as orchestrate.js (the OpenCode 1 entry); read its - * header for the why. This file is only the OpenCode 2 wiring: + * Same responsibilities as ../plugins/orchestrate.js (the OpenCode 1 entry); + * read its header for the why. This file is only the OpenCode 2 wiring: * * - skills: registered via ctx.skill.transform (V2 has no config hook); * - bootstrap + context-budget line: the `context` session hook, gated on @@ -97,6 +97,7 @@ export default { // Same option slot as V1: { package: "opencode-squad", options: { rate_limit_guard } }. const guardConfig = normalizeGuardConfig(ctx.options?.rate_limit_guard); + const directory = ctx.location?.directory; // --- lazy, process-lifetime lookups (V1 caches the same things) --- @@ -133,7 +134,7 @@ export default { const agents = await getAgents(); squadDrafted = hasSquad(agents ?? []); inventory = agents - ? formatInventory(agents, buildPerfLookup(), await getLimitMap()) + ? formatInventory(agents, buildPerfLookup(directory), await getLimitMap()) : "(no subagents available)"; return inventory; }; @@ -200,7 +201,7 @@ export default { modelID: model?.modelID, usage, resultEmpty: isTaskResultEmpty(output), - modelData: loadModelData(), + modelData: loadModelData(directory), limitMap: await getLimitMap(), now: Date.now(), }); diff --git a/.opencode/plugins/runtime.js b/.opencode/lib/runtime.js similarity index 90% rename from .opencode/plugins/runtime.js rename to .opencode/lib/runtime.js index 104934e..59b0148 100644 --- a/.opencode/plugins/runtime.js +++ b/.opencode/lib/runtime.js @@ -114,12 +114,14 @@ export function startBenchmarkRefresh(notify) { // Looked for beside the agent dirs opencode merges: the project's .opencode // first, then the global config dir. null if neither exists (we then fall back // to the raw AA benchmarks). Read once per process. -let _modelDataCache; // undefined = not loaded -export function loadModelData() { - if (_modelDataCache !== undefined) return _modelDataCache; - _modelDataCache = null; +// Keyed by project directory: one OpenCode 2 service process hosts plugin +// instances for several projects, and its cwd is none of them. +const _modelDataCache = new Map(); // directory -> merged models | null +/** @param {string} [directory] project root; defaults to the process cwd (V1) */ +export function loadModelData(directory = process.cwd()) { + if (_modelDataCache.has(directory)) return _modelDataCache.get(directory); const candidates = [ - path.join(process.cwd(), ".opencode", "model_data.json"), + path.join(directory, ".opencode", "model_data.json"), path.join(os.homedir(), ".config", "opencode", "model_data.json"), ]; const merged = {}; @@ -132,14 +134,15 @@ export function loadModelData() { Object.assign(merged, md.models); } } - if (found) _modelDataCache = merged; - return _modelDataCache; + _modelDataCache.set(directory, found ? merged : null); + return _modelDataCache.get(directory); } // Per-model capability tail for the inventory: model_data entry if we have one // (precomputed indices + hand-written note), else the raw AA benchmark lookup. -export function buildPerfLookup() { - const modelData = loadModelData(); +/** @param {string} [directory] see loadModelData */ +export function buildPerfLookup(directory) { + const modelData = loadModelData(directory); const benchmarks = loadBenchmarks(); return (modelId) => { const entry = modelData?.[modelId]; diff --git a/.opencode/plugins/orchestrate.js b/.opencode/plugins/orchestrate.js index 0c95001..2743507 100644 --- a/.opencode/plugins/orchestrate.js +++ b/.opencode/plugins/orchestrate.js @@ -68,7 +68,7 @@ import { SKILLS_DIR, startBenchmarkRefresh, turnMemo, -} from "./runtime.js"; +} from "../lib/runtime.js"; // Cache the subagent inventory string (and whether a squad has been drafted) // per process — neither changes at runtime. The bootstrap itself is assembled diff --git a/index.js b/index.js index c90ef47..2a97190 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,10 @@ // Package entry for both OpenCode generations (OpenCode 1 >= 1.18.29). // -// OpenCode 2 calls `setup` (.opencode/plugins/orchestrate-v2.js). OpenCode 1 +// OpenCode 2 calls `setup` (.opencode/lib/orchestrate-v2.js). OpenCode 1 // calls `server`, which lazy-imports the V1 entry: that module imports // `@opencode-ai/plugin`, which an OpenCode 2 host does not provide, so it must // never load there. -import v2 from "./.opencode/plugins/orchestrate-v2.js"; +import v2 from "./.opencode/lib/orchestrate-v2.js"; export default { ...v2, diff --git a/knip.json b/knip.json index 087239d..d756a53 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,10 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "entry": [".opencode/plugins/orchestrate.js", "tests/**/*.test.js", "scripts/*.mjs"], - "project": ["src/**/*.js", ".opencode/plugins/**/*.js", "scripts/**/*.mjs"] + "project": [ + "src/**/*.js", + ".opencode/plugins/**/*.js", + ".opencode/lib/**/*.js", + "scripts/**/*.mjs" + ] } From 7a511b10f5acdc98e6c3f714a4c8fe36333476f9 Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Thu, 22 Oct 2026 22:27:01 +0300 Subject: [PATCH 4/5] docs: OpenCode 2 install and differences Co-Authored-By: Claude Opus 5.5 --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 25a2eea..1f5aff3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,28 @@ Add one line to `~/.config/opencode/opencode.json`: That is all. On next start, OpenCode registers the skills and bootstrap automatically. There is no bundled grunt/drill agent — run the `squad-draft` skill once to generate the per-model squad the orchestrator delegates to. +### OpenCode 2 + +The same package works on OpenCode 2. Use the plural `plugins` key; plugin options (e.g. `rate_limit_guard`) go in the object form: + +```json +{ + "plugins": [ + { + "package": "opencode-squad@git+https://github.com/GFN-CIS/opencode-squad.git", + "options": { "rate_limit_guard": { "max_wait_seconds": 1800 } } + } + ] +} +``` + +The package entry (`index.js`) carries both generations: OpenCode 2 calls its `setup` (`.opencode/lib/orchestrate-v2.js`), OpenCode 1 (>= 1.18.29) its `server`, which loads the V1 entry `.opencode/plugins/orchestrate.js`. Both run the same `src/` modules. Differences on 2: + +- The delegation tool is `subagent`; the `[TASK OUTCOME]` / `[CACHE STATUS]` notes are appended to its result. +- The rate-limit guard vetoes the retry through OpenCode 2's `retry` hook instead of aborting the subagent, and steers its explanation into the orchestrator's running turn. +- No toasts: an OpenCode 2 server plugin has no TUI handle (the benchmark refresh still runs, silently). +- `squad_compact` cannot choose the summarising model — OpenCode 2 compaction takes none — and says so in its report. + --- ## What you get From 9e907278b22852c49e853df6b419ab52cef6842a Mon Sep 17 00:00:00 2001 From: Alex MKX Date: Fri, 23 Oct 2026 09:03:41 +0300 Subject: [PATCH 5/5] chore: release 0.2.0 (OpenCode 2 support) Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9cdf3e3..1a26bf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-squad", - "version": "0.1.0", + "version": "0.2.0", "description": "OpenCode plugin that turns the build agent into a PDCA orchestrator (sarge) over grunt/drill subagents", "type": "module", "main": "index.js",