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 POST_MS (a prompt\'s: PROMPT_MS) is kept for the next call or prompt: borg already\n// 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 a stop whose answer carries\n// `\"listen\": true` (borg says no waiter runs), it keeps the session\'s waiter, `borg listen --stream`:\n// it waits at no token cost, holds wakes off while the engineer\'s turn runs and says each wake as a\n// JSON line. The plugin sends a wake in only while the session is idle (from a prompt or tool call to\n// its stop, also a halted one, it is not): opencode 1 with session.promptAsync, opencode 2 with\n// session.prompt({delivery: \"queue\"}); one that fails to go in is kept and sent again (WAKE_TRIES,\n// then at the next stop or halt); a held wake whose messages a hook of the turn showed is dropped. A halted turn (no stop) is sent to borg as `halted`, so the waiter\n// holds 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// 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. $BORG_WAKE_RETRY_MS overrides WAKE_RETRY_MS (tests).\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// A stop with lessons makes one model call (the consequence model). opencode sets no limit of its own;\n// this is the stop\'s BORG_STOP_BUDGET_MS, which leaves room for a live check\'s 15 s wait, the 45 s\n// call and an expert drone.\nconst TIMEOUT_MS = 90_000\nconst PRE_MS = 2_000 // before every tool call: borg answers in a few ms (a stat when nobody steers)\n// How long a tool call\'s result waits for borg\'s context (file appends, no model call).\nconst POST_MS = 2_000\n// How long the engineer\'s message waits for borg\'s prompt context (file appends, no model call); a\n// later answer is kept for the next call or prompt, like a tool call\'s.\nconst PROMPT_MS = 5_000\n// Hard cap on questions sent back into a session per engineer prompt, so nothing can make the\n// plugin prompt the drone in a loop.\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// How much of a shell call\'s output borg gets (its end, where a test run\'s summary is).\nconst OUTPUT_TAIL = 4_000\n// How long the plugin\'s own `borg listen [--off] --session <id>` may take (a few file writes).\nconst LISTEN_MS = 10_000\n// A wake that failed to go in is sent again this much later, at most WAKE_TRIES times in a row; then at\n// the session\'s next stop or halt.\nconst WAKE_RETRY_MS = 5_000\nconst WAKE_TRIES = 5\nconst wakeRetryMs = () => Number(process.env.BORG_WAKE_RETRY_MS) || WAKE_RETRY_MS\n// What `borg listen` prints once it opted the session in.\nconst LISTENS = \"[borg listen] This session listens\"\n// How a hook\'s context shows each message (msg.rs `line`), and how a wake counts them (listen.rs `summary`).\nconst MESSAGE_LINE = /^\\[borg\\] Message from /gm\nconst WAKE_COUNT = /^\\[borg listen\\] (\\d+) messages? /\n\n/** How many messages a wake announces; Infinity for a wording it does not know (never taken as shown). */\nfunction wakeCount(text: string): number {\n const m = WAKE_COUNT.exec(text)\n return m ? Number(m[1]) : Infinity\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 end of its output, after Codex\'s \"Exit code: N\" line when it exited\n * non-zero (opencode\'s shell keeps such a call out of the error state). Undefined without output:\n * borg records no result it cannot see. */\nfunction shellResponse(output: string | undefined, exit: unknown): string | undefined {\n const out = (output ?? \"\").slice(-OUTPUT_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. */\nfunction contextOf(out: any): string | undefined {\n const t = out?.hookSpecificOutput?.additionalContext\n return typeof t === \"string\" && t.trim() ? t : 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, 2).join(\" \")}: timed out`)\n child.kill()\n }, timeoutMs)\n child.on(\"error\", (e) => {\n clearTimeout(timer)\n note(`borg ${args.slice(0, 2).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; resolves to its JSON answer or null. */\nfunction borg(event: string, payload: Record<string, unknown>, timeoutMs = TIMEOUT_MS): 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(TIMEOUT_MS) }\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, 200) || \"(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: string[] // context that came after POST_MS or PROMPT_MS: 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; at: number }[] // the waiter\'s wakes not sent yet, when each came: sent once idle\n retries: number // failed sends of the waiting wakes in a row (<= WAKE_TRIES)\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 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: [], retries: 0 }\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 < 32; 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 /** A hook started at `asked` showed messages (`out`\'s context): wakes held from before then that it\n * showed all of are dropped, so no turn is spent on messages the model already read. */\n const shown = (id: string, asked: number, out: any) => {\n const s = get(id)\n const k = contextOf(out)?.match(MESSAGE_LINE)?.length ?? 0\n if (!k) return\n const held = s.wakes.filter((w) => w.at < asked)\n const n = held.reduce((a, w) => a + wakeCount(w.text), 0)\n if (!held.length || k < n) return\n s.wakes = s.wakes.filter((w) => w.at >= asked)\n note(`${id}: ${held.length} held wake(s) dropped, the turn showed their messages`)\n }\n\n // Queue a borg call behind the session\'s earlier ones. `build` runs in turn, so it sees their effects.\n const send = (id: string, event: string, build: () => Promise<Record<string, unknown>>): 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 const asked = Date.now()\n const out = await borg(event, payload)\n shown(id, asked, out)\n return out\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. */\n const prompt = async (id: string, text: string, marked: boolean): Promise<string | 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), 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<string | 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((text) => {\n if (!text) return\n get(id).late.push(text)\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: string | undefined) => {\n const parts = [...s.late.splice(0), t].filter((x): x is string => !!x)\n return parts.length ? parts.join(\"\\n\\n\") : undefined\n }\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 borg(\"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 text 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`. A shell\n * call\'s `output` and `exit` go along as its tool_response (`shellResponse`). */\n const postTool = async (id: string, callID: string, tool: string, args: any, output?: string, exit?: unknown): Promise<string | 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) : 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 : 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 [context, said].filter((t): t is string => !!t).join(\"\\n\\n\") || undefined\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. */\n const listen = (id: string, which: \"on\" | \"off\", output: string): Promise<string | 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, 2).join(\" \")} -> ${res?.code} ${said.slice(0, 200)}`)\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 ? undefined : said || undefined\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 child = spawn(process.env.BORG_BIN || \"borg\", [\"listen\", \"--stream\", \"--session\", id, \"--dir\", io.directory], { stdio: [\"ignore\", \"pipe\", \"ignore\"] })\n } catch (e) {\n return note(`${id}: starting the waiter failed: ${e}`)\n }\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 s.wakes.push({ text: v.wake, at: Date.now() })\n note(`${id}: a wake${s.busy || s.stopping ? \", held while the turn runs\" : \"\"}`)\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 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. */\n const disarm = (id: string) => {\n const s = get(id)\n const child = s.stream\n s.stream = undefined\n s.wakes = []\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. One\n * that fails to go in is kept and sent again (WAKE_RETRY_MS later, at most WAKE_TRIES times, then at\n * the session\'s next stop or halt): 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 || !s.wakes.length) return\n const sent = s.wakes.splice(0)\n const mark = occupy(s)\n io.ask(id, sent.map((w) => w.text).join(\"\\n\\n\")).then(\n () => {\n s.retries = 0\n note(`woke ${id}: messages wait`)\n },\n (e) => {\n note(`waking ${id} failed: ${e}`)\n release(s, mark)\n if (s.ended) return\n s.wakes.unshift(...sent)\n if (++s.retries <= WAKE_TRIES) setTimeout(() => flush(id), wakeRetryMs()).unref?.()\n else note(`waking ${id}: ${WAKE_TRIES} retries failed, the next goes in at its stop or halt`)\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 s.retries = 0\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 s.retries = 0\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 () => ({})).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, 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,\n synthetic: true,\n })\n note(`${id}: borg\'s prompt context added (${context.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) output.output = `${output.output}\\n\\n${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, delivery: e.delivery, resume: false })\n note(`${id}: borg\'s prompt context added (${context.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, delivery: \"steer\", resume: false, metadata: MARK })\n note(`${e.sessionID}: borg\'s tool context steered in (${t.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";