pub const ADAPTER: &str = "// borg adapter for opencode, one module for both plugin APIs: opencode 1.x (`server`, written\n// against 1.18.32, verified on 1.19.0) and opencode 2.x (`setup`, written against 2.0.23, verified on\n// 2.0.29).\n//\n// Translates opencode plugin hooks and events into borg\'s one canonical, Claude-shaped hook event\n// and pipes it to `borg hook opencode <event>`:\n//\n// opencode 1.x opencode 2.x borg\n// chat.message (user text) session hook \"prompt\" prompt\n// tool.execute.before tool hook \"execute.before\" pre_tool (throws borg\'s reason: a\n// paused drone\'s tool call is refused)\n// tool.execute.after tool hook \"execute.after\" post_tool\n// tool part in state \"error\" \"execute.after\" with an error; post_tool_failure (incl. a rejected\n// session.tool.failed \"aborted\" permission, with or without feedback)\n// session.idle session.execution.succeeded stop\n// session.deleted, dispose session.deleted, setup\'s cleanup end\n//\n// At stop, a {\"decision\":\"block\",\"reason\"} answer is sent back into the session as a user message,\n// at most once per engineer prompt (the next stop carries stop_hook_active: true, like Claude\'s Stop\n// hook). No stop when the turn was halted (Esc/abort, a provider error, a permission rejected\n// without feedback): Claude\'s Stop hook skips interrupts too. opencode 1 tells a halted turn from\n// the last assistant message; opencode 2 ends it with session.execution.failed/interrupted instead.\n//\n// At prompt, borg\'s additionalContext (e.g. a queen question) reaches the drone but not the\n// engineer\'s TUI: opencode 1 adds it to the message as a synthetic text part, opencode 2 as a\n// synthetic session input without a description, just before the prompt.\n//\n// After a tool call, borg\'s additionalContext (live mode: a check\'s questions, graded lessons) reaches\n// the model before its next step; both after-hooks are awaited. opencode 1 appends it to the tool\'s\n// output (stored with the call: the TUI hides it for built-in tools, exports show it); opencode 2 sends\n// it as a steer synthetic input and leaves the tool result alone (the TUI shows that). opencode 1 runs\n// no after-hook for a failed call, so text waiting then comes with the next successful call or prompt.\n// A shell call\'s post_tool carries the end of its output and a non-zero exit as tool_response, Codex\'s\n// way: borg tells a test run\'s pass from its failure by it.\n// An answer later than the tool call\'s budget (post_ms; a prompt\'s: prompt_ms) is kept for the next call\n// or prompt: borg already counts it delivered.\n//\n// A session that listens (`borg listen`, docs/commands.md) is woken by the plugin. opencode gives its\n// shell no session id, so the plugin binds the session itself: it sets BORG_LISTEN_BINDER in its own\n// process (every command its model runs has it, and borg\'s own run there binds no session: one pid can\n// name several), and seeing `borg listen [--off]` in a session\'s own tool.execute.after it runs\n// `borg listen [--off] --session <id>`, adding borg\'s answer to what the model reads when the shell\'s own\n// run did not say it (within the tool call\'s post_ms; a later one waits in `late`). From then on, or from\n// a stop whose answer carries `\"listen\": true` (borg says no waiter runs), it keeps the session\'s waiter,\n// `borg listen --stream`: it waits at no token cost, holds wakes off while the engineer\'s turn runs and\n// says each wake as a JSON line naming its messages. The plugin sends a wake in only while the session\n// is idle (from a prompt or tool call to its stop, also a halted one, it is not): opencode 1 with\n// session.promptAsync, opencode 2 with session.prompt({delivery: \"queue\"}), and answers the waiter on\n// its stdin: `{\"sent\": ids}`, or `{\"failed\": ids}` when opencode refused it, and the waiter wakes the\n// session again for them after its backoff. A held wake is dropped once the turn\'s hooks, one or several,\n// handed the model every message it names, and one that comes after they did as it comes (borg\'s answer\n// names them, `\"shown_ids\"`; an older borg\'s names none, so the wake goes in): when that context reached\n// the model, not when borg answered, so an answer that came late (it waits in `late`) drops nothing\n// until a call carries it. A halted turn (no stop) is sent to borg as `halted`, so the waiter holds\n// nothing off any more. Its `{\"end\"}` line ends it until the next `\"listen\": true`; `--off`, the\n// session\'s end and the plugin\'s unload stop it.\n//\n// How long each call waits for borg, and how much of a shell\'s output borg gets, are borg\'s own\n// (`budgets`, harness/opencode.rs `BUDGETS`): the plugin asks for them until an answer carried them, and\n// uses FALLBACK, the same values, until then.\n//\n// Subagent (child) sessions are ignored, like Claude\'s hooks ignore subagents, with one exception:\n// a child\'s tool call still asks pre_tool, as its root session, so a paused drone cannot carry on\n// through a subagent.\n// Fails open: if borg is missing, slow or broken, opencode carries on untouched.\n// borg is found via $BORG_BIN, else `borg` on PATH. $BORG_OPENCODE_LOG names a file that gets\n// what the adapter did and every error it swallowed.\n//\n// The module has a single default export: opencode 1 calls every exported function as a plugin.\n\nimport { spawn } from \"node:child_process\"\nimport { appendFileSync } from \"node:fs\"\nimport type { Plugin, PluginModule } from \"@opencode-ai/plugin\"\n\n/** What each call waits for borg\'s answer (ms), and how much of a shell call\'s output borg gets. */\ntype Budgets = { prompt_ms: number; pre_ms: number; post_ms: number; stop_ms: number; end_ms: number; tail_chars: number }\n\n// borg\'s budgets (harness/opencode.rs `BUDGETS`, a test keeps them equal) until its first answer carries\n// them. opencode limits no plugin hook, so each is a guard: the engineer\'s message waits 5 s and a tool\n// result 2 s for borg\'s context (file appends; a later answer is kept for the next call or prompt), a\n// tool call 2 s before it runs (a stat when nobody steers). The stop, the one call with a model call,\n// and every call queued behind it get borg\'s stop timeout, which is also its BORG_STOP_BUDGET_MS: room\n// for a live check\'s wait, the model call and an expert drone; the session\'s end, queued too, the same\n// as its own end_ms. The output\'s end holds a test run\'s summary.\nconst FALLBACK: Budgets = { prompt_ms: 5_000, pre_ms: 2_000, post_ms: 2_000, stop_ms: 90_000, end_ms: 90_000, tail_chars: 4_000 }\n\n/** The budgets borg\'s answer carries (`\"budgets\"`), over `current`: each a positive number, the rest kept;\n * undefined when it carries none (an older borg, or none asked). */\nfunction budgetsOf(out: any, current: Budgets): Budgets | undefined {\n const b = out?.budgets\n if (!b || typeof b !== \"object\") return undefined\n const next = { ...current }\n for (const k of Object.keys(FALLBACK) as (keyof Budgets)[]) if (Number.isFinite(b[k]) && b[k] > 0) next[k] = Number(b[k])\n return next\n}\n// Guard: a hard cap on questions sent back into a session per engineer prompt, so nothing can make\n// the plugin prompt the drone in a loop; the engineer\'s next prompt allows the next round.\nconst MAX_ROUNDS = 1\n// A wait\'s timeout, told apart from an answer without context.\nconst LATE = Symbol(\"late\")\n// Marks the text part we send back, so its chat.message echo is recognised without comparing text.\nconst MARK = { borg: true }\n// opencode\'s PermissionRejectedError: the engineer said no without feedback, which halts the turn.\nconst REJECTED = \"The user rejected permission to use this specific tool call.\"\n// opencode 1\'s rejection with the engineer\'s words after it.\nconst FEEDBACK = /rejected permission to use this specific tool call with the following feedback:\\s*([\\s\\S]*)/i\n// opencode 2\'s call the engineer declined without feedback (error type \"aborted\").\nconst DECLINED = \"The user declined this tool call\"\n// opencode 2\'s `opencode run` rejects every permission ask by itself, as a Permission.CorrectedError\n// with this \"feedback\": nobody\'s correction.\nconst AUTO_REJECTED = \"This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action.\"\n// Claude\'s plain rejection, which borg takes for a wordless denial: the engineer\'s next prompt is the reason.\nconst DENIED = \"The user doesn\'t want to proceed with this tool use.\"\n// Guard: how long the plugin\'s own `borg listen [--off] --session <id>` may take (a few file writes).\nconst LISTEN_MS = 10_000\n// What `borg listen` prints once it opted the session in.\nconst LISTENS = \"[borg listen] This session listens\"\n// Guard: how many parents `root` follows up from a subagent session, so a cycle of parent ids, or a\n// chain deeper than any opencode builds, cannot spin a hook.\nconst MAX_DEPTH = 32\n// Display: how much of borg\'s answer one line of $BORG_OPENCODE_LOG keeps.\nconst LOG_CHARS = 200\n// Display: how many of a borg run\'s arguments such a line names (the command and its first, `listen --off`).\nconst LOG_ARGS = 2\n// Guard: the most message ids a turn\'s `handed` keeps, the newest, so a turn that never ends (a lost\n// stop event) in a busy collective cannot grow it without end. Far more than one turn hands over: one\n// hook shows at most ~90 (borg\'s 10,000-character context budget for opencode, ~110 characters or more\n// a message, msg.rs `line`), and a turn\'s hooks show only what arrived meanwhile. An id let go only\n// lets a wake that names it go in at the stop; it never drops one.\nconst HANDED = 1_024\n\n/** borg\'s context for the model and the ids of the messages it shows: an answer\'s `additionalContext`\n * and `\"shown_ids\"` (none from a borg that names none; never a count, since one event may show a newer\n * message and leave a held wake\'s), or several such joined. */\ntype Context = { text: string; ids: string[] }\n\n/** The ids of the messages borg\'s answer `out` showed the model (its `\"shown_ids\"`). */\nfunction shownIds(out: any): string[] {\n const ids = out?.shown_ids\n return Array.isArray(ids) ? ids.filter((i: unknown): i is string => typeof i === \"string\") : []\n}\n\n/** Contexts as one, in order: their texts, and every message they show. */\nfunction joined(parts: (Context | undefined)[]): Context | undefined {\n const all = parts.filter((c): c is Context => !!c)\n return all.length ? { text: all.map((c) => c.text).join(\"\\n\\n\"), ids: all.flatMap((c) => c.ids) } : undefined\n}\n\n/** Tells the waiter `child` whether the wake for `ids` went in (`sent`) or opencode refused it\n * (`failed`): one JSON line on its stdin. Fails open: a waiter gone meanwhile hears nothing. */\nfunction answerWake(child: ReturnType<typeof spawn> | undefined, how: \"sent\" | \"failed\", ids: string[]) {\n try {\n if (ids.length && child?.stdin?.writable) child.stdin.write(`${JSON.stringify({ [how]: ids })}\\n`)\n } catch {}\n}\n\n// opencode tool ids -> the Claude names borg matches on (borg reads Bash command, Grep/Glob pattern,\n// else file_path/path from tool_input). Unknown tools keep their id.\nconst TOOL_NAMES: Record<string, string> = {\n bash: \"Bash\", read: \"Read\", write: \"Write\", edit: \"Edit\", multiedit: \"MultiEdit\", apply_patch: \"Edit\",\n grep: \"Grep\", glob: \"Glob\", list: \"LS\", webfetch: \"WebFetch\", websearch: \"WebSearch\", task: \"Task\",\n todowrite: \"TodoWrite\",\n // opencode 2 ids (its read/write/edit/grep/glob keep their names)\n shell: \"Bash\", patch: \"Edit\", subagent: \"Task\",\n}\n\n// Tools whose `path` (opencode 2) is the file they touch, borg\'s file_path.\nconst FILE_TOOLS = new Set([\"read\", \"write\", \"edit\"])\n\nfunction toolInput(tool: string, args: any): Record<string, unknown> {\n const input: Record<string, unknown> = { ...(args ?? {}) }\n if (typeof input.filePath === \"string\") input.file_path = input.filePath\n else if (FILE_TOOLS.has(tool) && typeof input.path === \"string\") input.file_path = input.path\n // An edit\'s new text, as Claude\'s Edit names it (borg keeps it with the change, .edits).\n if (typeof input.newString === \"string\") input.new_string = input.newString\n // apply_patch (opencode 1, GPT models) and patch (opencode 2) name their files only inside the patch text.\n if ((tool === \"apply_patch\" || tool === \"patch\") && typeof input.patchText === \"string\") {\n const files = [...input.patchText.matchAll(/^\\*\\*\\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1].trim())\n if (files.length) input.file_path = files.join(\" \")\n }\n return input\n}\n\n/** borg\'s wording for a permission rejected with feedback (opencode 1\'s \"...with the following\n * feedback: X\", opencode 2\'s Permission.CorrectedError). */\nconst corrected = (feedback: string) =>\n `The user doesn\'t want to proceed with this tool use. The tool use was rejected. To tell you how to proceed, the user said:\\n${feedback}`\n\n// borg\'s rejection capture speaks Claude\'s wording, so opencode\'s is re-phrased: a rejection with\n// feedback carries the engineer\'s words; one without (opencode 1\'s REJECTED, opencode 2\'s DECLINED)\n// becomes Claude\'s plain rejection, a wordless denial. Every other error goes as it is.\nfunction canonicalError(error: string): string {\n const m = FEEDBACK.exec(error)\n if (m) return corrected(m[1])\n const bare = error.trim()\n return bare === REJECTED || bare === DECLINED ? DENIED : error\n}\n\n/** A shell call\'s tool_response for borg, which tells a test run\'s pass from its failure by it\n * (hook.rs `capture_test`): the last `tail` characters of its output, after Codex\'s \"Exit code: N\" line\n * when it exited non-zero (opencode\'s shell keeps such a call out of the error state). Undefined\n * without output: borg records no result it cannot see. */\nfunction shellResponse(output: string | undefined, exit: unknown, tail: number): string | undefined {\n const out = (output ?? \"\").slice(-tail)\n if (!out.trim()) return undefined\n return typeof exit === \"number\" && exit !== 0 ? `Exit code: ${exit}\\nOutput:\\n${out}` : out\n}\n\n/** The text of an opencode 2 tool result: its `output`, or its content\'s text blocks. */\nfunction resultText(result: any): string {\n if (typeof result === \"string\") return result\n if (typeof result?.output === \"string\") return result.output\n const content = Array.isArray(result?.content) ? result.content : []\n return content.map((b: any) => (b?.type === \"text\" ? String(b.text ?? \"\") : \"\")).filter((t: string) => t).join(\"\\n\")\n}\n\n/** The engineer- or drone-visible text of message parts (no synthetic or ignored parts). */\nfunction visibleText(parts: any[] | undefined): string {\n return (parts ?? [])\n .filter((p: any) => p?.type === \"text\" && !p.synthetic && !p.ignored)\n .map((p: any) => p.text)\n .join(\"\\n\")\n}\n\n/** Why the turn that ended with assistant message `msg` was halted rather than finished, or \"\". */\nfunction halted(msg: any): string {\n const error = msg?.info?.error\n if (error) return String(error.name ?? \"error\") // MessageAbortedError (Esc, abort), APIError, ...\n const rejected = (msg?.parts ?? []).some(\n (p: any) => p?.type === \"tool\" && p.state?.status === \"error\" && String(p.state.error ?? \"\").trim() === REJECTED,\n )\n return rejected ? \"permission rejected\" : \"\"\n}\n\n/** borg\'s additionalContext in an answer, if it has any text, with the messages it shows. */\nfunction contextOf(out: any): Context | undefined {\n const t = out?.hookSpecificOutput?.additionalContext\n return typeof t === \"string\" && t.trim() ? { text: t, ids: shownIds(out) } : undefined\n}\n\nfunction note(msg: string) {\n const file = process.env.BORG_OPENCODE_LOG\n if (!file) return\n try {\n appendFileSync(file, `${new Date().toISOString()} ${msg}\\n`)\n } catch {}\n}\n\n/** What a shell command does to the listening of the session it runs in: `on` for `borg listen`, `off`\n * for `borg listen --off`. Undefined for anything else: a waiter (`--wait`, `--stream`, `--queue`) or\n * another, named session\'s (`--session`). */\nfunction listenCommand(command: string): \"on\" | \"off\" | undefined {\n for (const part of command.split(/&&|\\|\\||[;|\\n]/)) {\n const m = /(?:^|\\s)(?:\\S*\\/)?borg\\s+listen(?:\\s+(.*))?$/.exec(part.trim())\n if (!m) continue\n const args = (m[1] ?? \"\").trim().split(/\\s+/).filter(Boolean)\n if (args.some((a) => [\"--wait\", \"--stream\", \"--queue\", \"--session\"].includes(a) || a.startsWith(\"--session=\"))) return undefined\n return args.includes(\"--off\") ? \"off\" : \"on\"\n }\n return undefined\n}\n\n/** Runs `borg <args>` (no stdin); resolves to its stdout and exit code, or null (fails open). */\nfunction borgRun(args: string[], timeoutMs: number): Promise<{ out: string; code: number | null } | null> {\n return new Promise((resolve) => {\n let out = \"\"\n let child: ReturnType<typeof spawn>\n try {\n child = spawn(process.env.BORG_BIN || \"borg\", args, { stdio: [\"ignore\", \"pipe\", \"ignore\"] })\n } catch (e) {\n note(`spawn borg ${args.join(\" \")} failed: ${e}`)\n return resolve(null)\n }\n const timer = setTimeout(() => {\n note(`borg ${args.slice(0, LOG_ARGS).join(\" \")}: timed out`)\n child.kill()\n }, timeoutMs)\n child.on(\"error\", (e) => {\n clearTimeout(timer)\n note(`borg ${args.slice(0, LOG_ARGS).join(\" \")}: ${e}`)\n resolve(null)\n })\n child.stdout!.on(\"data\", (d) => (out += d))\n child.on(\"close\", (code) => {\n clearTimeout(timer)\n resolve({ out, code })\n })\n })\n}\n\n/** Runs `borg hook opencode <event>` with `payload` on stdin, killed after `timeoutMs`; resolves to its\n * JSON answer or null. `stopMs` is the stop\'s budget borg keeps inside. */\nfunction borg(event: string, payload: Record<string, unknown>, timeoutMs: number, stopMs: number): Promise<any> {\n return new Promise((resolve) => {\n let out = \"\"\n let done = false\n const finish = (v: any) => {\n if (done) return\n done = true\n clearTimeout(timer)\n resolve(v)\n }\n let child: ReturnType<typeof spawn>\n try {\n // BORG_STOP_BUDGET_MS: at a stop borg keeps its wait for a live check, its model call and its expert\n // drone inside this timeout, so its answer arrives.\n const env = { ...process.env, BORG_STOP_BUDGET_MS: String(stopMs) }\n child = spawn(process.env.BORG_BIN || \"borg\", [\"hook\", \"opencode\", event], { stdio: [\"pipe\", \"pipe\", \"ignore\"], env })\n } catch (e) {\n note(`spawn borg failed: ${e}`)\n return resolve(null)\n }\n const timer = setTimeout(() => {\n note(`borg ${event}: timed out`)\n child.kill()\n finish(null)\n }, timeoutMs)\n child.on(\"error\", (e) => {\n note(`borg ${event}: ${e}`)\n finish(null)\n })\n child.stdout!.on(\"data\", (d) => (out += d))\n child.on(\"close\", () => {\n note(`borg ${event} -> ${out.trim().slice(0, LOG_CHARS) || \"(nothing)\"}`)\n try {\n finish(out.trim() ? JSON.parse(out) : null)\n } catch {\n finish(null)\n }\n })\n child.stdin!.on(\"error\", () => {})\n child.stdin!.end(JSON.stringify(payload))\n })\n}\n\ntype Session = {\n chain: Promise<unknown> // borg calls of one session run in order\n child: boolean // a subagent session: ignored (but for pre_tool, asked as its root session)\n parent?: string // a child\'s parent session id\n continued: boolean // we answered the last idle with borg\'s questions\n gen: number // counts engineer prompts: a stop answer older than the latest prompt is dropped\n rounds: number // questions sent back since the engineer\'s last prompt (<= MAX_ROUNDS)\n echoes: number // messages we sent whose prompt echo has not come back yet\n stopping: boolean\n used: boolean // borg has heard of this session\n ended: boolean\n args: Map<string, { tool: string; args: any }> // callID -> call, for failures reported without input\n failed: Set<string> // callIDs already reported as failures\n worded: boolean // opencode 1: a call was rejected with the engineer\'s words since their last prompt\n late: Context[] // context that came after post_ms or prompt_ms (or failed to go in): delivered with the next call or prompt\n stream?: ReturnType<typeof spawn> // its waiter, `borg listen --stream`, while the session listens\n busy: boolean // a turn runs: from a prompt or tool call to its stop (also a halted one)\n owner: number // bumped by each mark of busy: a failed send frees only the mark it made (`occupy`)\n wakes: { text: string; ids: string[] }[] // the waiter\'s wakes not sent yet, with their messages\' ids: sent once idle\n handed: Set<string> // ids of the messages the turn\'s contexts handed the model (`shown`), the newest HANDED: emptied once idle\n}\n\n/** What the session state machine needs from one opencode version. */\ntype Io = {\n directory: string\n /** The drone\'s last assistant message: its last visible text, and why its turn was halted rather\n * than finished (\"\" when it finished). */\n lastAssistant: (id: string) => Promise<{ text: string; halted: string }>\n /** Sends borg\'s questions into the session as a user message that carries MARK. */\n ask: (id: string, text: string) => Promise<void>\n}\n\n/** The per-session state machine both opencode versions drive: they differ only in how their hooks\n * and events arrive and in `io`. */\nfunction drones(io: Io) {\n const sessions = new Map<string, Session>()\n // borg\'s budgets: FALLBACK until an answer carried them, asked for until then.\n let budgets: Budgets = { ...FALLBACK }\n let learned = false\n /** `borg hook opencode <event>`, killed after `budgets[within]`; an answer\'s budgets are taken. */\n const call = async (event: string, payload: Record<string, unknown>, within: keyof Budgets) => {\n const out = await borg(event, learned ? payload : { ...payload, budgets: true }, budgets[within], budgets.stop_ms)\n const b = budgetsOf(out, budgets)\n if (b) [budgets, learned] = [b, true]\n return out\n }\n const get = (id: string): Session => {\n let s = sessions.get(id)\n if (!s) {\n s = { chain: Promise.resolve(), child: false, continued: false, gen: 0, rounds: 0, echoes: 0, stopping: false, used: false, ended: false, args: new Map(), failed: new Set(), worded: false, late: [], busy: false, owner: 0, wakes: [], handed: new Set() }\n sessions.set(id, s)\n }\n return s\n }\n\n // The top-level session a (possibly nested) subagent session belongs to.\n const root = (id: string): string => {\n for (let i = 0; i < MAX_DEPTH; i++) {\n const parent = sessions.get(id)?.parent\n if (!parent || parent === id) break\n id = parent\n }\n return id\n }\n\n const history = async (id: string): Promise<{ text: string; halted: string }> => {\n try {\n return await io.lastAssistant(id)\n } catch (e) {\n note(`reading ${id}\'s messages failed: ${e}`)\n return { text: \"\", halted: \"\" }\n }\n }\n const lastText = async (id: string) => (await history(id)).text\n\n /** Marks the session busy; the mark\'s number, for `release`. */\n const occupy = (s: Session) => {\n s.busy = true\n return ++s.owner\n }\n /** A send that failed frees the session, unless a prompt or tool call marked it busy since. */\n const release = (s: Session, mark: number) => {\n if (s.owner === mark) s.busy = false\n }\n\n /** Each held wake whose messages the turn\'s contexts handed the model all of (`handed`, however many\n * hooks borg split them over) is dropped, so no turn is spent on messages the model already read; one\n * with a message they left, or that names none (an older waiter\'s), keeps waiting. */\n const drop = (id: string, s: Session) => {\n const held = s.wakes.length\n s.wakes = s.wakes.filter((w) => !w.ids.length || !w.ids.every((i) => s.handed.has(i)))\n if (s.wakes.length < held) note(`${id}: ${held - s.wakes.length} held wake(s) dropped, the turn showed their messages`)\n }\n\n /** borg\'s context `c` reached the model: its messages join what the turn handed over (the newest\n * HANDED), and the held wakes they now cover are dropped. */\n const shown = (id: string, c: Context | undefined) => {\n if (!c?.ids.length) return\n const s = get(id)\n for (const i of c.ids) {\n // Moved to the newest, so the guard lets the oldest go.\n s.handed.delete(i)\n s.handed.add(i)\n }\n for (const i of s.handed) {\n if (s.handed.size <= HANDED) break\n s.handed.delete(i)\n }\n drop(id, s)\n }\n\n // Queue a borg call behind the session\'s earlier ones. `build` runs in turn, so it sees their effects.\n // Queued behind a stop, any call may wait as long as one (`within`); the end waits its own budget.\n const send = (id: string, event: string, build: () => Promise<Record<string, unknown>>, within: keyof Budgets = \"stop_ms\"): Promise<any> => {\n const s = get(id)\n s.used = true\n // pid: opencode\'s own (the plugin runs in its process), so `borg top` sees a vanished opencode.\n const run = s.chain.then(async () => {\n const payload = { session_id: id, cwd: io.directory, pid: process.pid, ...(await build()) }\n return call(event, payload, within)\n })\n s.chain = run.catch(() => null)\n return run.catch(() => null)\n }\n\n /** A subagent session, from its creation event. */\n const child = (id: string, parent: string) => {\n const c = get(id)\n c.child = true\n c.parent = parent\n }\n\n /** A user message: the engineer\'s prompt, or the echo of our questions (`marked`). Resolves to\n * borg\'s context for the prompt (e.g. a queen question), if it came within prompt_ms, after context\n * that came late before; the caller tells `shown` once it went in. */\n const prompt = async (id: string, text: string, marked: boolean): Promise<Context | undefined> => {\n const s = get(id)\n if (s.child) return\n // Our own stop answer coming back: not the engineer speaking.\n // Any prompt starts a turn: no wake goes in until its stop.\n occupy(s)\n if (marked) {\n s.echoes = Math.max(0, s.echoes - 1)\n return\n }\n if (!text.trim()) return\n if (s.echoes > 0) {\n // We sent questions and their echo has not come back, yet this one is unmarked. Take it for\n // the echo anyway: an echo mistaken for the engineer would reset the cycle and could loop.\n s.echoes--\n note(`${id}: unmarked message while our questions were pending, taken as their echo`)\n return\n }\n // The engineer spoke: a fresh idle cycle.\n s.gen++\n s.continued = false\n s.rounds = 0\n s.worded = false\n // Take the proposal now: by the time the queue reaches this prompt, the drone may be\n // streaming its reply to it.\n const prior = lastText(id)\n // Behind a stop still thinking (a model call), the message does not wait for borg\'s context, so\n // borg is told to hand none over (it would count as delivered, e.g. a queen question, unseen).\n const busy = s.stopping\n const answer = send(id, \"prompt\", async () => ({ prompt: text, last_assistant_message: await prior, ...(busy ? { context: false } : {}) }))\n if (busy) return\n return withLate(s, await within(id, answer.then(contextOf), budgets.prompt_ms, \"prompt\"))\n }\n\n /** borg\'s context if it comes within `ms`; a later answer waits in `late` for the next call or\n * prompt (borg already counts it delivered). */\n const within = async (id: string, answer: Promise<Context | undefined>, ms: number, what: string) => {\n let timer: ReturnType<typeof setTimeout> | undefined\n const late = new Promise<typeof LATE>((r) => (timer = setTimeout(() => r(LATE), ms)))\n const t = await Promise.race([answer, late])\n clearTimeout(timer)\n if (t !== LATE) return t\n void answer.then((c) => {\n if (!c) return\n get(id).late.push(c)\n note(`${id}: borg\'s ${what} context came late, kept for the next call or prompt`)\n })\n }\n\n /** Context that came late before, then `t`. */\n const withLate = (s: Session, t: Context | undefined) => joined([...s.late.splice(0), t])\n\n /** Before a tool call: throws borg\'s reason when the drone is paused\n * (`borg pause`). Not queued behind the session\'s other calls, and fail open after pre_ms.\n * A subagent\'s call is asked as its root session: the pause holds for the drone\'s subagents. */\n const preTool = async (id: string, callID: string, tool: string, args: any) => {\n get(id).args.set(callID, { tool, args })\n occupy(get(root(id)))\n const out = await call(\"pre_tool\", {\n session_id: root(id), cwd: io.directory, pid: process.pid,\n tool_name: TOOL_NAMES[tool] ?? tool, tool_input: toolInput(tool, args),\n }, \"pre_ms\")\n const deny = out?.hookSpecificOutput\n if (deny?.permissionDecision === \"deny\" && typeof deny.permissionDecisionReason === \"string\") {\n throw new Error(deny.permissionDecisionReason)\n }\n }\n\n /** A finished tool call. Resolves to the context for the model\'s next step: context that came late\n * before, then borg\'s answer, if it came within post_ms; a later answer waits in `late`. The caller\n * tells `shown` once it went in. A shell call\'s `output` and `exit` go along as its tool_response\n * (`shellResponse`). */\n const postTool = async (id: string, callID: string, tool: string, args: any, output?: string, exit?: unknown): Promise<Context | undefined> => {\n const s = get(id)\n s.args.delete(callID)\n if (s.child) return\n const name = TOOL_NAMES[tool] ?? tool\n const response = name === \"Bash\" ? shellResponse(output, exit, budgets.tail_chars) : undefined\n const answer = send(id, \"post_tool\", async () => ({ tool_name: name, tool_input: toolInput(tool, args), ...(response ? { tool_response: response } : {}) })).then(contextOf)\n // Behind a stop still thinking (a model call) the answer can only come late: the call does not wait.\n const until = Date.now() + (s.stopping ? 0 : budgets.post_ms)\n const context = withLate(s, await within(id, answer, until - Date.now(), \"tool\"))\n const which = name === \"Bash\" && typeof args?.command === \"string\" ? listenCommand(args.command) : undefined\n if (!which) return context\n // The bind waits behind the post_tool call: the tool result waits for both no longer than post_ms.\n const said = await within(id, listen(id, which, output ?? \"\"), Math.max(0, until - Date.now()), \"listen\")\n return joined([context, said])\n }\n\n /** The session\'s model ran `borg listen [--off]`: the plugin opts this session in or out (its shell\n * has no session id) behind its other borg calls, and starts or stops its waiter once that ran. borg\'s\n * answer, for the model, when the shell\'s own run (`output`) did not say it (it shows no message). */\n const listen = (id: string, which: \"on\" | \"off\", output: string): Promise<Context | undefined> => {\n const s = get(id)\n const args = [\"listen\", ...(which === \"off\" ? [\"--off\"] : []), \"--session\", id, \"--dir\", io.directory]\n const run = s.chain.then(async () => {\n const res = await borgRun(args, LISTEN_MS)\n const said = (res?.out ?? \"\").trim()\n note(`${id}: borg ${args.slice(0, LOG_ARGS).join(\" \")} -> ${res?.code} ${said.slice(0, LOG_CHARS)}`)\n if (which === \"off\") disarm(id)\n else if (res?.code === 0) arm(id)\n const told = which === \"on\" ? output.includes(LISTENS) : output.includes(\"[borg listen] this session\")\n return told || !said ? undefined : { text: said, ids: [] }\n })\n s.chain = run.catch(() => null)\n return run.catch(() => undefined)\n }\n\n /** The session\'s waiter, unless one runs: each wake goes in once the session is idle (`flush`). */\n const arm = (id: string) => {\n const s = get(id)\n if (s.child || s.ended || s.stream) return\n let child: ReturnType<typeof spawn>\n try {\n // Its stdin takes the plugin\'s answers to its wakes (`answerWake`).\n child = spawn(process.env.BORG_BIN || \"borg\", [\"listen\", \"--stream\", \"--session\", id, \"--dir\", io.directory], { stdio: [\"pipe\", \"pipe\", \"ignore\"] })\n } catch (e) {\n return note(`${id}: starting the waiter failed: ${e}`)\n }\n child.stdin!.on(\"error\", () => {})\n s.stream = child\n let rest = \"\"\n child.stdout!.on(\"data\", (d) => {\n rest += d\n let nl: number\n while ((nl = rest.indexOf(\"\\n\")) >= 0) {\n const line = rest.slice(0, nl)\n rest = rest.slice(nl + 1)\n try {\n const v = JSON.parse(line)\n if (typeof v?.wake === \"string\" && v.wake.trim()) {\n // An older waiter names neither: never taken as shown, nothing to answer.\n const ids: string[] = Array.isArray(v.ids) ? v.ids.filter((i: unknown) => typeof i === \"string\") : []\n s.wakes.push({ text: v.wake, ids })\n note(`${id}: a wake${s.busy || s.stopping ? \", held while the turn runs\" : \"\"}`)\n // The waiter may have taken its messages before a hook\'s claim of them landed, and that\n // hook\'s context reached the model before this line: such a wake is dropped as it comes.\n drop(id, s)\n flush(id)\n } else if (typeof v?.note === \"string\" || typeof v?.end === \"string\") note(`${id}: waiter: ${v.note ?? `ends: ${v.end}`}`)\n } catch {}\n }\n })\n const end = (code: number | null) => {\n if (s.stream === child) s.stream = undefined\n // Its stdin\'s end too, which would otherwise keep opencode\'s event loop holding a dead pipe.\n child.stdin?.destroy()\n note(`${id}: the waiter ended (${code})`)\n }\n child.on(\"error\", () => end(null))\n child.on(\"close\", end)\n note(`${id}: the waiter runs`)\n }\n\n /** The session listens no more (`--off`, its end, the plugin\'s unload): its waiter is stopped, and its\n * held wakes and what the turn handed over go. */\n const disarm = (id: string) => {\n const s = get(id)\n const child = s.stream\n s.stream = undefined\n s.wakes = []\n s.handed.clear()\n try {\n if (child && child.exitCode === null && child.signalCode === null) child.kill(\"SIGTERM\")\n } catch {}\n }\n\n /** Sends the session\'s waiting wakes, as one marked message, while it is idle and no stop runs, and\n * answers its waiter whether they went in. One that fails to go in is the waiter\'s to send again\n * (after its backoff): the messages stay unread until a wake goes in. */\n const flush = (id: string) => {\n const s = get(id)\n if (s.busy || s.stopping || s.ended) return\n // Idle: the turn is over. Its messages drop no later wake: borg claimed them shown before it\n // answered, so the waiter names none of them again.\n s.handed.clear()\n if (!s.wakes.length) return\n const sent = s.wakes.splice(0)\n const ids = sent.flatMap((w) => w.ids)\n const mark = occupy(s)\n io.ask(id, sent.map((w) => w.text).join(\"\\n\\n\")).then(\n () => {\n answerWake(s.stream, \"sent\", ids)\n note(`woke ${id}: messages wait`)\n },\n (e) => {\n note(`waking ${id} failed: ${e}; its waiter wakes it again`)\n release(s, mark)\n answerWake(s.stream, \"failed\", ids)\n },\n )\n }\n\n /** The session\'s turn was halted with no stop (opencode 2\'s session.execution.failed or\n * .interrupted; opencode 1\'s halted turn still reaches `stop`, which says so): it is idle, and its\n * waiting wakes go in, so an Esc never leaves a listening session that nothing wakes. */\n const idle = (id: string) => {\n const s = get(id)\n if (s.child) return\n s.busy = false\n halt(id)\n flush(id)\n }\n\n /** A listening session\'s turn was halted with no stop: borg ends the engineer\'s turn on its side too\n * (`borg hook opencode halted`), so its waiter holds nothing off. Nothing for any other session. */\n const halt = (id: string) => {\n const s = get(id)\n if (!s.stream || s.ended) return\n send(id, \"halted\", async () => ({}))\n }\n\n /** A failed tool call; `args` undefined takes the input seen before the call. */\n const failure = (id: string, callID: string, tool: string | undefined, args: any, error: string) => {\n const s = get(id)\n const call = s.args.get(callID)\n s.args.delete(callID)\n if (s.child || s.failed.has(callID)) return\n s.failed.add(callID)\n // When the engineer rejects a call with words, opencode 1 rejects every other pending call with\n // the plain REJECTED: that cascade is nobody\'s denial (borg assimilate drops it too).\n if (FEEDBACK.test(error)) s.worded = true\n else if (s.worded && error.trim() === REJECTED) return\n const name = tool ?? call?.tool ?? \"\"\n send(id, \"post_tool_failure\", async () => ({\n tool_name: TOOL_NAMES[name] ?? name,\n tool_input: toolInput(name, args ?? call?.args),\n error: canonicalError(error),\n last_assistant_message: await lastText(id),\n }))\n }\n\n /** The drone\'s turn ended: borg\'s conscience may send questions back, at most MAX_ROUNDS per prompt. */\n const stop = async (id: string) => {\n const s = get(id)\n if (s.child || s.stopping) return\n s.stopping = true\n s.busy = false\n let sent = 0 // the busy mark of borg\'s questions sent back, once they are\n try {\n const gen = s.gen\n const { text, halted } = await history(id)\n if (halted) {\n // The engineer (or the provider) stopped the drone: not a turn ending on its own.\n note(`${id} halted (${halted}): no stop`)\n halt(id)\n return\n }\n const active = s.continued\n s.continued = false\n const out = await send(id, \"stop\", async () => ({ last_assistant_message: text, stop_hook_active: active }))\n // The session listens and no waiter runs.\n if (out?.listen === true) arm(id)\n if (out?.decision !== \"block\" || typeof out.reason !== \"string\") return\n if (active) return\n // The engineer spoke while borg thought: the questions are about a turn already superseded.\n if (s.gen !== gen) return note(`${id}: engineer spoke during stop, questions dropped`)\n if (s.rounds >= MAX_ROUNDS) return note(`${id}: ${s.rounds} question round(s) this prompt, no more`)\n s.rounds++\n s.continued = true\n s.echoes++\n sent = occupy(s)\n await io.ask(id, out.reason)\n note(`sent borg\'s questions into ${id}`)\n } catch (e) {\n note(`sending borg\'s questions into ${id} failed: ${e}`)\n if (sent) {\n s.continued = false\n s.echoes = Math.max(0, s.echoes - 1)\n release(s, sent)\n }\n } finally {\n s.stopping = false\n // Wakes that came during the turn go in now that it is idle.\n flush(id)\n }\n }\n\n const end = (id: string) => {\n const s = get(id)\n if (s.child || !s.used || s.ended) return\n s.ended = true\n // borg\'s end forgets the opt-in; the waiter is stopped here at once.\n return send(id, \"end\", async () => ({}), \"end_ms\").then((out) => {\n disarm(id)\n return out\n })\n }\n\n /** opencode is shutting down (e.g. `opencode run` finished): end every session borg has heard of. */\n const endAll = async () => {\n await Promise.all([...sessions.keys()].map(end))\n }\n\n return { get, child, prompt, preTool, postTool, shown, failure, stop, idle, end, endAll }\n}\n\n// ---- opencode 1.x: a `server` function that returns hooks; bus events through `event`.\n\nconst server: Plugin = async ({ client, directory }) => {\n process.env.BORG_LISTEN_BINDER = \"opencode\"\n const d = drones({\n directory,\n // The last assistant message ({info, parts}) tells a halted turn; its last visible text is borg\'s\n // \"proposal\" for captured corrections and the plan the conscience reads at stop.\n lastAssistant: async (id) => {\n const res: any = await client.session.messages({ path: { id } })\n const msgs: any[] = res?.data ?? []\n let last: any\n for (let i = msgs.length - 1; i >= 0; i--) {\n if (msgs[i]?.info?.role !== \"assistant\") continue\n last ??= msgs[i]\n const text = visibleText(msgs[i].parts).trim()\n if (text) return { text, halted: halted(last) }\n }\n return { text: \"\", halted: halted(last) }\n },\n ask: async (id, text) => {\n const body = { parts: [{ type: \"text\" as const, text, metadata: MARK }] }\n const session: any = client.session\n const res = await (session.promptAsync ?? session.prompt).call(session, { path: { id }, body })\n if (res?.error) throw new Error(JSON.stringify(res.error))\n },\n })\n\n return {\n \"chat.message\": async (input, output) => {\n const parts: any[] = output.parts ?? []\n const id = input.sessionID\n const context = await d.prompt(id, visibleText(parts), parts.some((p) => p?.metadata?.borg === true))\n if (!context) return\n // opencode gave the parts their ids already; ours sorts right after the last one.\n const last = parts[parts.length - 1]\n parts.push({\n ...(last?.id ? { id: `${last.id}0` } : {}),\n sessionID: last?.sessionID ?? id,\n messageID: last?.messageID ?? (input as any).messageID,\n type: \"text\",\n text: context.text,\n synthetic: true,\n })\n d.shown(id, context)\n note(`${id}: borg\'s prompt context added (${context.text.length} chars)`)\n },\n\n \"tool.execute.before\": async (input, output) => {\n await d.preTool(input.sessionID, input.callID, input.tool, output.args)\n },\n\n // Awaited before the next step; the output is what the model reads (and what opencode stores).\n \"tool.execute.after\": async (input, output) => {\n const t = await d.postTool(input.sessionID, input.callID, input.tool, input.args, output.output, output.metadata?.exit)\n if (!t) return\n output.output = `${output.output}\\n\\n${t.text}`\n d.shown(input.sessionID, t)\n },\n\n event: async ({ event }) => {\n const e: any = event\n const p = e?.properties ?? {}\n switch (e?.type) {\n case \"session.created\":\n case \"session.updated\":\n if (p.info?.id && p.info.parentID) d.child(p.info.id, p.info.parentID)\n break\n case \"message.part.updated\": {\n // A failed tool call (opencode does not run tool.execute.after for it).\n const part = p.part\n if (part?.type === \"tool\" && part.state?.status === \"error\")\n d.failure(part.sessionID, part.callID, part.tool, part.state.input, String(part.state.error ?? \"\"))\n break\n }\n case \"session.next.tool.failed\":\n // The same failure on opencode\'s newer session event stream.\n if (p.sessionID && p.callID) d.failure(p.sessionID, p.callID, undefined, undefined, String(p.error?.message ?? \"\"))\n break\n case \"session.idle\":\n // Also after a halted turn: stop tells it from the last assistant message (and sends `halted`).\n if (p.sessionID) await d.stop(p.sessionID)\n break\n case \"session.deleted\":\n if (p.info?.id) await d.end(p.info.id)\n break\n }\n },\n\n dispose: d.endAll,\n }\n}\n\n// ---- opencode 2.x: `setup(ctx)` registers hooks and reads the event stream.\n\nasync function setup(ctx: any) {\n process.env.BORG_LISTEN_BINDER = \"opencode\"\n const d = drones({\n directory: ctx.location?.directory ?? process.cwd(),\n // A stop only follows session.execution.succeeded, so the turn finished: nothing to tell apart.\n lastAssistant: async (id) => {\n const res: any = await ctx.session.context({ sessionID: id })\n const msgs: any[] = Array.isArray(res) ? res : (res?.data ?? [])\n for (let i = msgs.length - 1; i >= 0; i--) {\n if (msgs[i]?.type !== \"assistant\") continue\n const text = visibleText(msgs[i].content).trim()\n if (text) return { text, halted: \"\" }\n }\n return { text: \"\", halted: \"\" }\n },\n // \"queue\": should the engineer have typed meanwhile, the questions wait for that turn to end.\n ask: async (id, text) => {\n await ctx.session.prompt({ sessionID: id, text, metadata: MARK, delivery: \"queue\" })\n },\n })\n\n // Every prompt into a session: the engineer\'s, a subagent\'s task, or the echo of our questions.\n // The hook is awaited before the prompt is stored. Text added to the prompt would show in the\n // engineer\'s message, so borg\'s context goes in as a synthetic input without a description: the\n // drone reads it, the TUI hides it, and it lands just before the prompt it belongs to.\n await ctx.session.hook(\"prompt\", async (e: any) => {\n const id = e.sessionID\n const context = await d.prompt(id, String(e.prompt?.text ?? \"\"), e.metadata?.borg === true)\n if (!context) return\n try {\n await ctx.session.synthetic({ sessionID: id, text: context.text, delivery: e.delivery, resume: false })\n d.shown(id, context)\n note(`${id}: borg\'s prompt context added (${context.text.length} chars)`)\n } catch (err) {\n // borg already counts it shown (a message\'s seen claim): the next call or prompt carries it.\n d.get(id).late.unshift(context)\n note(`adding borg\'s prompt context to ${id} failed: ${err}`)\n }\n })\n\n await ctx.tool.hook(\"execute.before\", async (e: any) => {\n await d.preTool(e.sessionID, e.id, e.tool, e.input)\n })\n\n await ctx.tool.hook(\"execute.after\", async (e: any) => {\n if (e.status !== \"error\") {\n // Awaited before the next step. A steer, not the tool result: the TUI shows the result.\n const t = await d.postTool(e.sessionID, e.id, e.tool, e.input, resultText(e.result), e.result?.metadata?.exit ?? e.metadata?.exit)\n if (!t) return\n try {\n await ctx.session.synthetic({ sessionID: e.sessionID, text: t.text, delivery: \"steer\", resume: false, metadata: MARK })\n d.shown(e.sessionID, t)\n note(`${e.sessionID}: borg\'s tool context steered in (${t.text.length} chars)`)\n } catch (err) {\n // borg already counts it delivered: the next call or prompt carries it.\n d.get(e.sessionID).late.unshift(t)\n note(`steering borg\'s tool context into ${e.sessionID} failed: ${err}`)\n }\n return\n }\n const cause = e.error?.error\n // `opencode run`\'s own auto-reject arrives as the same error: no feedback, an ordinary failure.\n const feedback =\n cause?._tag === \"Permission.CorrectedError\" && typeof cause.feedback === \"string\" && cause.feedback.trim() !== AUTO_REJECTED ? cause.feedback : undefined\n d.failure(e.sessionID, e.id, e.tool, e.input, feedback !== undefined ? corrected(feedback) : String(e.error?.message ?? \"\"))\n })\n\n const events = new AbortController()\n void (async () => {\n try {\n for await (const event of ctx.event.subscribe({ signal: events.signal })) {\n const e: any = event\n const p = e?.data ?? {}\n switch (e?.type) {\n case \"session.created\":\n if (p.sessionID && p.parentID) d.child(p.sessionID, p.parentID)\n break\n case \"session.tool.failed\":\n // A call the engineer declined without feedback: no execute.after, the turn is interrupted.\n // Its DECLINED reaches borg as Claude\'s plain rejection (canonicalError).\n if (p.sessionID && p.id && p.error?.type === \"aborted\") d.failure(p.sessionID, p.id, undefined, undefined, String(p.error.message ?? \"\"))\n break\n // Each busy period ends with exactly one of these; only a finished turn gets a stop\n // (Esc, a provider error or a declined call halt it: Claude\'s Stop hook skips interrupts too).\n case \"session.execution.succeeded\":\n if (p.sessionID) await d.stop(p.sessionID)\n break\n case \"session.execution.failed\":\n case \"session.execution.interrupted\":\n if (p.sessionID) {\n note(`${p.sessionID} halted (${e.type.slice(\"session.execution.\".length)} ${p.reason ?? p.error?.type ?? \"\"}): no stop`)\n d.idle(p.sessionID)\n }\n break\n case \"session.deleted\":\n if (p.sessionID) await d.end(p.sessionID)\n break\n }\n }\n } catch (e) {\n if (!events.signal.aborted) note(`event stream failed: ${e}`)\n }\n })()\n\n // The plugin is unloaded (reload, location closed, shutdown): end every session borg has heard of.\n return async () => {\n events.abort()\n await d.endAll()\n }\n}\n\n// One module for both: opencode 2 reads `id` and `setup`, opencode 1 (>= 1.18.29) `id` and `server`.\nexport default { id: \"borg\", setup, server } satisfies PluginModule & { setup: typeof setup }\n";