/** * Source for the opencode plugin we drop into the per-run tmpdir at * `/opencode/plugin/pullfrog-events.ts`. The harness already * redirects `XDG_CONFIG_HOME` to `ctx.tmpdir/.config` (see `opencode.ts` * `homeEnv`), so opencode's auto-discovery scans the tmpdir, never the user's * working tree. opencode's `Global.Path.config` resolves to * `path.join(xdgConfig, "opencode")` and the config layer auto-discovers * plugins from every directory in its scan list — including * `Global.Path.config` — by globbing `{plugin,plugins}/*.{ts,js}` via * `ConfigPlugin.load(dir)`. * * We MUST NOT write into the user's repo working tree. The repo is a checkout * the agent operates on; only the agent's own tools (gated by * `OPENCODE_PERMISSION`) may modify it. The whole reason we redirect HOME and * XDG_CONFIG_HOME is so harness-side files (config, plugins, scratch state) * land in the tmpdir. * * Why this plugin exists: opencode's `task` tool runs subagents in-process and * the CLI's `cli/cmd/run.ts` event loop filters `part.sessionID !== sessionID`, * so subagent-internal `message.part.updated` events are silently discarded * before reaching our parent NDJSON stream. plugins, by contrast, receive * EVERY bus event via `bus.subscribeAll()` regardless of session. * * The plugin re-emits every relevant bus event onto opencode's stdout as a * single JSON line wrapped in a sentinel envelope. our `runOpenCode` parser * recognises the envelope, unpacks it, and routes the inner part through the * existing handlers with a per-session label from `SessionLabeler` so each * subagent's tool calls / text appear inline alongside the orchestrator's. * * Dumb plugin / smart parent split: the plugin emits every part for every * session. the parent dedupes against the orchestrator's own session id (which * it already knows from the `init` event). this keeps the plugin trivial and * keeps the per-session attribution logic on the parent side where the * SessionLabeler already lives. * * Event-name prefixing: the wrapped event-type sentinel is * `pullfrog_bus_event` — picked to be unmistakably ours so a future opencode * release that introduces a coincidentally-named event type won't collide. */ export const PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event" as const; export const PULLFROG_OPENCODE_PLUGIN_FILENAME = "pullfrog-events.ts" as const; /** * Source written verbatim to `/opencode/plugin/pullfrog-events.ts`. * * - Structural typing only (no runtime import of `@opencode-ai/plugin`): * opencode installs that dep into the directory containing the plugin * alongside discovery, but a) the dep isn't required for the structural * shape we use, and b) keeping zero imports avoids any module-resolution * coupling to opencode's plugin-loader internals across versions. * - default export is the plugin factory (opencode's plugin loader accepts * default exports as the server entrypoint). * - we only forward `message.part.updated`. that's where the user-visible * subagent activity (tool calls, text, step transitions) lives. add more * event types here if the parent needs them. * - JSON.stringify+single write keeps the line atomic up to PIPE_BUF (4KB on * Linux). longer parts may interleave with concurrent stdout writers; the * parser tolerates non-JSON lines (logs them at debug) so a torn line is a * missed event, not a crash. */ export const PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run. // surfaces opencode subagent activity that the CLI's run-loop discards. see // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside // the user's working tree. const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)}; // the first sessionID we see on a message.part.updated event is the // orchestrator — opencode's run command creates exactly one top-level session // before any subagent is dispatched, and the user-prompt text part fires // before the first task tool_use. we lock that sessionID in here and use it // to filter: the orchestrator's events are already streamed by the CLI's // run-loop, so we only forward (a) all subagent events, and (b) the // orchestrator's task tool dispatches at status="running". the CLI only // emits task tool_use at status=completed (after the subagent finishes), so // without the early announce the parent's labeler binds subagent sessions // before recordTaskDispatch fires and the lens label is lost. let orchestratorSessionID: string | undefined; function isOrchestratorTaskDispatch(part: { type?: string; tool?: string; state?: { status?: string }; }): boolean { if (part.type !== "tool") return false; if (part.tool !== "task") return false; // only forward at status="running" (not "pending"). at pending the // state.input is still {} — the orchestrator has emitted the part shell // but the LLM hasn't filled in description/subagent_type/prompt yet. by // running, input is populated and recordTaskDispatch can derive the lens // label correctly. return part.state?.status === "running"; } export default async function pullfrogEventsPlugin() { return { event: async (input: { event: { type: string; properties?: { part?: { sessionID?: string; type?: string; tool?: string; state?: { status?: string }; }; }; }; }) => { const event = input.event; if (!event || typeof event !== "object") return; if (event.type !== "message.part.updated") return; const part = event.properties?.part; const sessionID = part?.sessionID; if (typeof sessionID !== "string" || sessionID.length === 0) return; if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID; if (sessionID === orchestratorSessionID) { // skip orchestrator events EXCEPT early task dispatches. if (!part || !isOrchestratorTaskDispatch(part)) return; } try { const line = JSON.stringify({ type: PULLFROG_BUS_EVENT_TYPE, bus_event: event, }); process.stdout.write(line + "\\n"); } catch { // a circular reference or BigInt etc. would throw; swallow rather // than letting a single bad event take down the plugin. } }, }; } `;