From a585e6be13b57a66455f8dc1a7fa420825b000c2 Mon Sep 17 00:00:00 2001 From: SREDevOpsAgents Date: Sat, 31 Oct 2026 12:25:45 -0300 Subject: [PATCH] Enable Entire --- .entire/.gitignore | 5 + .entire/settings.json | 9 + .opencode/plugins/entire.ts | 585 ++++++++++++++++++++++++++++++++++++ 3 files changed, 599 insertions(+) create mode 100644 .entire/.gitignore create mode 100644 .entire/settings.json create mode 100644 .opencode/plugins/entire.ts diff --git a/.entire/.gitignore b/.entire/.gitignore new file mode 100644 index 0000000..e66987d --- /dev/null +++ b/.entire/.gitignore @@ -0,0 +1,5 @@ +tmp/ +settings.local.json +metadata/ +logs/ +redactors/local/ diff --git a/.entire/settings.json b/.entire/settings.json new file mode 100644 index 0000000..3a866fd --- /dev/null +++ b/.entire/settings.json @@ -0,0 +1,9 @@ +{ + "enabled": true, + "telemetry": true, + "checkpoints": { + "primary": { + "type": "git-refs" + } + } +} diff --git a/.opencode/plugins/entire.ts b/.opencode/plugins/entire.ts new file mode 100644 index 0000000..4b9e1d6 --- /dev/null +++ b/.opencode/plugins/entire.ts @@ -0,0 +1,585 @@ +// 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 + } + }, + } +}