// Entire CLI plugin for OpenCode // Auto-generated by `entire enable --agent opencode` // Do not edit manually — changes will be overwritten on next install. // // Uses node:child_process so hooks work under both Bun (OpenCode CLI/TUI) // and Node (OpenCode Desktop's Electron sidecar). See #2014. import { spawn, spawnSync } from "node:child_process" import type { Plugin } from "@opencode-ai/plugin" export const EntirePlugin: Plugin = async ({ directory }) => { // Track seen user messages to fire turn-start only once per message const seenUserMessages = new Set() // Track current session ID for message events (which don't include sessionID) let currentSessionID: string | null = null // Track the model used by the most recent assistant message let currentModel: string | null = null // In-memory store for message metadata (role, tokens, etc.) const messageStore = new Map() // One-time model-context injection captured from the turn-start hook's stdout, // applied on the next LLM call via experimental.chat.system.transform. let pendingInjection: string | null = null // Child (subagent) sessions. OpenCode's task tool runs each subagent as a // real session, so without this set a child would register as the user's // session: its write would take the checkpoint and the parent would log // "no files modified". Learned from session.* events' `parentID`, and from the child // ID surfaced in the parent's task part / tool.execute.after — the latter // two also cover a child session resumed via `task_id` from an earlier // process, whose own session.created predates this plugin instance. These // sets live for the process and are cleared only on server.instance.disposed. const childSessions = new Set() // child session ID -> the top-level (user's) session it descends from. With // subagent_depth > 1 a child can itself call the task tool; that // grandchild's task is recorded on the top-level session, the only one // Entire tracks, keyed by its own callID like any other task. const rootOf = new Map() // child session ID -> the session that launched it, so aborting a session // can end the background calls it launched. const parentOf = new Map() // The per-call maps and sets below are keyed by callKey: the calling // session plus the hook key. A callID is unique only within one session // (some providers number calls per conversation), so a child's own task // call can share one with its parent's running call. // Call keys already announced via subagent-start (the running part update // repeats), so a late running update cannot announce a task again after its // stop. Cleared for a key when a new call starts under it (a reused callID). const announcedTasks = new Set() // Call key -> Date.now() at tool.execute.before, sent as started_at. A // child resumed via `task_id` holds every earlier call's messages too; this // is what lets the CLI keep only the ones this call produced. The hook key // is the part's callID, except for a command subtask (`subtask: true`), // whose before/after hooks carry the task part's own id instead. const taskStartedAt = new Map() // Announced tasks not yet stopped: call key -> its subagent-stop payload. // Every stop path takes the entry out, so a task stops at most once. const liveTasks = new Map>() // Call key of a task part id -> call key of its callID, to map a command // subtask's hook key to the callID its subagent-start used. const partCallID = new Map() // Call keys whose stop fired before their running part was announced. The // late announcement is then skipped rather than opening a task that would // never stop. const stoppedBeforeStart = new Set() // Call keys of tasks stopped from their error part, so a // tool.execute.after that still arrives does not stop them again. const stoppedOnError = new Set() // Background children (OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS, or a // foreground task promoted to the background): child ID -> subagent-stop // payloads held back until the child's own session goes idle. A queue: a // resume via `task_id` that joins a running child is queued behind the // current run and runs as its own busy -> idle cycle, so each idle ends the // oldest held call. const backgroundTasks = new Map[]>() // Sessions whose last session.status was not idle. An idle only ends a // held call when it follows a busy period: an errored run reports idle // twice. const busySessions = new Set() // Children whose held stops all end at their next idle: an abort reached // them mid-run. Cancelling a job does not cancel a joined run already // executing, so the child's own idle, not the abort, is when it ends. const drainAtIdle = new Set() /** * Build the shell command for a hook invocation. * Uses sh -c so the command is guarded by a `command -v` probe: when the * entire binary is not on PATH the hook exits 0 rather than failing the * surrounding OpenCode operation. */ function hookCmd(hookName: string): string[] { return ["sh", "-c", `if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks opencode ${hookName}`] } /** * Pipe JSON payload to an entire hooks command (async). * Errors are logged but never thrown — plugin failures must not crash OpenCode. */ async function callHook(hookName: string, payload: Record) { try { const json = JSON.stringify(payload) const [cmd, ...args] = hookCmd(hookName) await new Promise((resolve) => { const proc = spawn(cmd, args, { cwd: directory, stdio: ["pipe", "ignore", "ignore"], }) proc.on("error", () => resolve()) proc.on("close", () => resolve()) proc.stdin?.end(json + "\n") }) } catch { // Silently ignore — plugin failures must not crash OpenCode } } /** * Synchronous variant for hooks that must complete before subsequent agent work * or process exit. `turn-start` must finish initializing session state before a * fast mid-turn commit can hit git hooks, and `turn-end` / `session-end` must * finish before `opencode run` tears down its event loop. */ function callHookSync(hookName: string, payload: Record) { try { const json = JSON.stringify(payload) const [cmd, ...args] = hookCmd(hookName) spawnSync(cmd, args, { cwd: directory, input: json + "\n", stdio: ["pipe", "ignore", "ignore"], }) } catch { // Silently ignore — plugin failures must not crash OpenCode } } // parseInjectedContext scans a hook's stdout for Entire's injection envelope // ({"inject_context":"..."}) and returns the text to inject, or null. function parseInjectedContext(stdout: string): string | null { if (!stdout) return null for (const line of stdout.split("\n")) { const trimmed = line.trim() if (!trimmed.startsWith("{")) continue try { const parsed = JSON.parse(trimmed) as { inject_context?: unknown } if (typeof parsed.inject_context === "string" && parsed.inject_context.length > 0) { return parsed.inject_context } } catch { // not our envelope — ignore } } return null } // fireTurnStart fires turn-start synchronously (state must be ready before // mid-turn commits) and stashes any model-context injection emitted on stdout // for experimental.chat.system.transform to apply. Entire emits the injection // at most once per session, so pendingInjection is set on the first turn only. function fireTurnStart(payload: Record) { try { const json = JSON.stringify(payload) const [cmd, ...args] = hookCmd("turn-start") const proc = spawnSync(cmd, args, { cwd: directory, input: json + "\n", encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], }) const out = typeof proc.stdout === "string" ? proc.stdout : "" const injected = parseInjectedContext(out) if (injected) pendingInjection = injected } catch { // Silently ignore — plugin failures must not crash OpenCode } } // trackChild records a child and the top-level session it descends from. function trackChild(childID: string, parentID: string) { childSessions.add(childID) if (!rootOf.has(childID)) rootOf.set(childID, rootOf.get(parentID) ?? parentID) if (!parentOf.has(childID)) parentOf.set(childID, parentID) } // callKey scopes a hook key (a callID or a task part id) to the session // that made the call. function callKey(sessionID: string, hookKey: string): string { return sessionID + "\0" + hookKey } // topLevelSession maps a session to the top-level session it belongs to. function topLevelSession(sessionID: string): string { return rootOf.get(sessionID) ?? sessionID } // announceTask fires subagent-start the first time a task part is running // with its child bound (state.metadata.sessionId). tool.execute.before fires // earlier but has no child ID yet, and session.created for the child can // interleave with a sibling's, so neither is a safe join. The running update // repeats, hence once per call key. Called for task parts in child sessions // too, so a nested subagent is announced on the top-level session. function announceTask(part: any) { if (!(part?.type === "tool" && part.tool === "task" && part.callID && part.state?.status === "running" && part.state?.metadata?.sessionId)) return const sessionID = part.sessionID ?? currentSessionID if (!sessionID) return const key = callKey(sessionID, part.callID) const partKey = callKey(sessionID, part.id) if (announcedTasks.has(key)) return announcedTasks.add(key) trackChild(part.state.metadata.sessionId, sessionID) // Without the before hook (the plugin loaded mid-call), the part's own // start, OpenCode's clock before the child's prompt, still bounds it. const startedAt = taskStartedAt.get(key) ?? taskStartedAt.get(partKey) ?? part.state?.time?.start ?? 0 if (stoppedBeforeStart.delete(key) || stoppedBeforeStart.delete(partKey)) { forgetTaskKeys(part, sessionID) return } const payload = { session_id: topLevelSession(sessionID), tool_use_id: part.callID, subagent_id: part.state.metadata.sessionId, subagent_type: part.state?.input?.subagent_type ?? "", task_description: part.state?.input?.description ?? "", started_at: startedAt, } liveTasks.set(key, payload) if (part.id) partCallID.set(partKey, key) callHookSync("subagent-start", payload) } // announcedKey maps a call key to the call key the task was announced under. function announcedKey(key: string): string { return partCallID.get(key) ?? key } // takeLiveTask removes and returns the announced, not yet stopped task a // call key belongs to. function takeLiveTask(key: string): Record | undefined { const announced = announcedKey(key) partCallID.delete(key) const payload = liveTasks.get(announced) liveTasks.delete(announced) return payload } function forgetTaskKeys(part: any, sessionID: string) { taskStartedAt.delete(callKey(sessionID, part.callID)) if (part.id) taskStartedAt.delete(callKey(sessionID, part.id)) } // stopFailedTask fires subagent-stop for an announced task whose part // ended in error without tool.execute.after: OpenCode skips that hook when // the call is aborted (Esc, `opencode run` teardown) or its execute throws. // A task that failed before its child was bound was never announced, so // nothing fires for it. function stopFailedTask(part: any) { if (!(part?.type === "tool" && part.tool === "task" && part.callID && part.state?.status === "error")) return const sessionID = part.sessionID ?? currentSessionID ?? "" const key = callKey(sessionID, part.callID) const partKey = callKey(sessionID, part.id) const payload = takeLiveTask(key) forgetTaskKeys(part, sessionID) stoppedBeforeStart.delete(key) if (part.id) { partCallID.delete(partKey) stoppedBeforeStart.delete(partKey) } if (!payload) return stoppedOnError.add(key) if (part.id) stoppedOnError.add(partKey) callHookSync("subagent-stop", payload) } // finishBackgroundTask fires the oldest held subagent-stop for a background // child whose run went idle: its work finished, failed, or was aborted // (`opencode run` exiting with the child still running). Each case ends the // call; a later call queued on the same child ends at its own run's idle. function finishBackgroundTask(childID: string) { const payloads = backgroundTasks.get(childID) const payload = payloads?.shift() if (!payload) return if (payloads.length === 0) backgroundTasks.delete(childID) callHookSync("subagent-stop", payload) } // drainBackgroundTasks fires every held subagent-stop for a child whose // background job is over. A job that fails or is cancelled drops the runs // still queued on it, so their calls get no idle of their own; left held, // the next resume of the child would end them at its idle instead. function drainBackgroundTasks(childID: string) { const payloads = backgroundTasks.get(childID) if (!payloads) return backgroundTasks.delete(childID) for (const payload of payloads) callHookSync("subagent-stop", payload) } // A background job's end reaches the session that launched it as a // synthetic text part: . const backgroundResult = /^/ // backgroundSessionAborted ends the held calls of an aborted session and of // the children it launched (Esc cancels its background jobs and reports no // result): at once for an idle child, at its next idle for a busy one. function backgroundSessionAborted(sessionID: string) { const targets = [sessionID] for (const [childID, parentID] of parentOf) if (parentID === sessionID) targets.push(childID) for (const target of targets) { if (!backgroundTasks.has(target)) continue if (busySessions.has(target)) drainAtIdle.add(target) else drainBackgroundTasks(target) } } function resetSessionTracking(sessionID: string) { if (currentSessionID === sessionID) { return false } seenUserMessages.clear() messageStore.clear() currentModel = null currentSessionID = sessionID // Drop any turn-start injection captured for the prior session so it // can't leak into the new session's system prompt. pendingInjection = null return true } return { // Apply the one-time Entire context injection captured at turn-start by // appending it to the system prompt for this LLM call. "experimental.chat.system.transform": async (input: { sessionID?: string }, output: { system: string[] }) => { if (input?.sessionID && childSessions.has(input.sessionID)) return if (pendingInjection && Array.isArray(output.system)) { output.system.push(pendingInjection) pendingInjection = null } }, // Subagent call start. No child ID exists yet, so this only records the // time; subagent-start fires from the running task part below. "tool.execute.before": async (input) => { try { if (input.tool !== "task") return const key = callKey(input.sessionID, input.callID) // A new call under this key: some providers number calls per // conversation, so an earlier call's markers must not dedupe or // swallow this one's start and stop. announcedTasks.delete(key) stoppedBeforeStart.delete(key) stoppedOnError.delete(key) taskStartedAt.set(key, Date.now()) } catch { // Silently ignore — plugin failures must not crash OpenCode } }, // Subagent completion. tool.execute.after for the task tool fires once, with // the child session ID in output.metadata: at true completion for a // foreground task, immediately for a background one (metadata.background), // whose stop is held until the child goes idle. Synchronous: `opencode run` // exits on the parent's idle right after this, and an async hook would be // killed before it finished. "tool.execute.after": async (input, output) => { try { if (input.tool !== "task") return const key = callKey(input.sessionID, input.callID) const announced = takeLiveTask(key) if (stoppedOnError.delete(key) && !announced) { // Already stopped: its part ended in error first. taskStartedAt.delete(key) return } // A failed command subtask fires this hook with no output; its // announced child still ends here. const childID = output?.metadata?.sessionId ?? announced?.subagent_id if (!childID) { taskStartedAt.delete(key) return } trackChild(childID, input.sessionID) const startedAt = taskStartedAt.get(key) ?? 0 taskStartedAt.delete(key) if (!announced) stoppedBeforeStart.add(key) // A child's own task call (subagent_depth > 1) is recorded on the // top-level session: the child has no Entire session of its own. const payload = announced ?? { session_id: topLevelSession(input.sessionID), tool_use_id: input.callID, subagent_id: childID, subagent_type: input.args?.subagent_type ?? "", task_description: input.args?.description ?? "", started_at: startedAt, } if (output?.metadata?.background === true) { backgroundTasks.set(childID, [...(backgroundTasks.get(childID) ?? []), payload]) return } callHookSync("subagent-stop", payload) } catch { // Silently ignore — plugin failures must not crash OpenCode } }, event: async ({ event }) => { try { const props = (event as any).properties const info = props?.info if (event.type.startsWith("session.") && info?.parentID && info?.id) trackChild(info.id, info.parentID) if (event.type === "message.part.updated") { announceTask(props?.part) stopFailedTask(props?.part) const part = props?.part // Only a part in a tracked session: synthetic text also carries // attachment contents, which could spell the same envelope. const sid = part?.sessionID const tracked = sid === currentSessionID || childSessions.has(sid) || [...parentOf.values()].includes(sid) if (tracked && part?.type === "text" && part.synthetic === true && typeof part.text === "string") { const result = backgroundResult.exec(part.text) if (result) drainBackgroundTasks(result[1]) } } // An aborted message, reported on session.error, or on the message // itself when the run was interrupted outside the processor. if (event.type === "session.error" && props?.error?.name === "MessageAbortedError" && props?.sessionID) { backgroundSessionAborted(props.sessionID) } if (event.type === "message.updated" && info?.error?.name === "MessageAbortedError" && info?.sessionID) { backgroundSessionAborted(info.sessionID) } if (event.type === "session.status" && props?.sessionID) { if (props?.status?.type !== "idle") busySessions.add(props.sessionID) else if (busySessions.delete(props.sessionID)) { if (drainAtIdle.delete(props.sessionID)) drainBackgroundTasks(props.sessionID) else finishBackgroundTask(props.sessionID) } } const eventSessionID: string | undefined = props?.sessionID ?? info?.sessionID ?? info?.id ?? props?.part?.sessionID if (eventSessionID && childSessions.has(eventSessionID)) return switch (event.type) { case "session.created": { const session = info if (!session?.id) break // Reset per-session tracking state when switching sessions. if (resetSessionTracking(session.id)) { await callHook("session-start", { session_id: session.id, }) } break } case "message.updated": { const msg = info if (!msg) break if (msg.sessionID && resetSessionTracking(msg.sessionID)) { callHookSync("session-start", { session_id: msg.sessionID, }) } // Store message metadata (role, time, tokens, etc.) messageStore.set(msg.id, msg) // Track model from assistant messages if (msg.role === "assistant" && msg.modelID) { currentModel = msg.modelID } // Fallback: some opencode run flows commit before any message.part.updated // event is delivered for the user's prompt. Start the turn from the // user message itself so git hooks see an ACTIVE session in time. if (msg.role === "user" && !seenUserMessages.has(msg.id)) { seenUserMessages.add(msg.id) const sessionID = msg.sessionID ?? currentSessionID if (sessionID) { fireTurnStart({ session_id: sessionID, prompt: "", model: currentModel ?? "", }) } } break } case "message.part.updated": { const part = props?.part if (!part?.messageID) break // Fire turn-start on the first text part of a new user message const msg = messageStore.get(part.messageID) if (msg?.role === "user" && part.type === "text" && !seenUserMessages.has(msg.id)) { seenUserMessages.add(msg.id) const sessionID = msg.sessionID ?? currentSessionID if (sessionID) { // A synthetic part is OpenCode writing, not the user: a // background task's result arrives this way. The parent does // take a turn on it, so the turn still starts, with no prompt. fireTurnStart({ session_id: sessionID, prompt: part.synthetic === true ? "" : (part.text ?? ""), model: currentModel ?? "", }) } } break } case "session.status": { // session.status fires in both TUI and non-interactive (run) mode. // session.idle is deprecated and not reliably emitted in run mode. if (props?.status?.type !== "idle") break const sessionID = props?.sessionID ?? currentSessionID if (!sessionID) break // Use sync variant: `opencode run` exits on the same idle event, // so an async hook would be killed before completing. callHookSync("turn-end", { session_id: sessionID, model: currentModel ?? "", }) break } case "session.compacted": { const sessionID = props?.sessionID if (!sessionID) break await callHook("compaction", { session_id: sessionID, }) break } case "session.deleted": { const session = info if (!session?.id) break seenUserMessages.clear() messageStore.clear() currentSessionID = null pendingInjection = null // childSessions/announcedTasks are NOT cleared here: a child's own // deletion never reaches this case (the guard above returns first), // so this only ever fires for an unrelated top-level session, and // clearing here would drop a live child back to top-level mid-task. // Use sync variant: session-end may fire during shutdown. callHookSync("session-end", { session_id: session.id, }) break } case "server.instance.disposed": { // Fires when OpenCode shuts down (TUI close or `opencode run` exit). // session.deleted only fires on explicit user deletion, not on quit, // so this is the only reliable way to end sessions on exit. if (!currentSessionID) break const sessionID = currentSessionID seenUserMessages.clear() messageStore.clear() currentSessionID = null pendingInjection = null childSessions.clear() rootOf.clear() parentOf.clear() announcedTasks.clear() taskStartedAt.clear() liveTasks.clear() partCallID.clear() stoppedBeforeStart.clear() stoppedOnError.clear() // A background child still running here is ended by the Go side's // SessionEnd sweep, which completes every live task record. backgroundTasks.clear() busySessions.clear() drainAtIdle.clear() // Use sync variant: this is the last event before process exit. callHookSync("session-end", { session_id: sessionID, }) break } } } catch { // Silently ignore — plugin failures must not crash OpenCode } }, } }