From 3d393c36a303fb9432220cef64ee40723659b1d6 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 8 May 2026 22:46:43 +0000 Subject: [PATCH] opencode: surface subagent events via injected plugin (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * opencode: surface subagent events via injected plugin opencode's cli/cmd/run.ts event loop filters all message.part.updated events to the orchestrator's session id (`part.sessionID !== sessionID` continue), so subagent-internal tool_use / text / step events were silently discarded by the CLI in --format json mode. opencode plugins, by contrast, receive every bus event via bus.subscribeAll() regardless of session. ship a per-run plugin (action/agents/opencodePlugin.ts) that re-emits non-orchestrator message.part.updated events as `pullfrog_bus_event` envelopes on opencode's stdout. the plugin is staged into /opencode/plugin/pullfrog-events.ts which is already redirected to ctx.tmpdir — never the user's repo working tree. the plugin also forwards the orchestrator's task tool dispatch at state.status="running" — that's the first moment state.input is populated with description / subagent_type / prompt and it lands BEFORE the subagent's first message.part.updated. forwarding this lets SessionLabeler register the lens label early, so subagent events bind to the correct lens name (e.g. lens:correctness) instead of the subagent#N fallback. the existing tool_use handler dedupes on callID so the late status=completed event from the CLI doesn't double-record. the parent's pullfrog_bus_event handler synthesizes the equivalent CLI-style event for each part type (tool/step-start/step-finish/text) and dispatches through the same handlers used by orchestrator events, so labeling, tool-call rendering, and the formatWithLabel magenta prefix all share one code path. verified end-to-end via `pnpm play --local --raw` with a prompt that dispatches a reviewfrog subagent: orchestrator's task call now logs "» dispatching subagent: lens:read-readme-and-report-purpose" before the subagent runs, the subagent's read tool call surfaces with [lens:...] magenta prefix, and the run-end "subagent finished" attribution shows the lens name. also adds an AGENTS.md rule formalizing the no-write-to-repo invariant: action runtime must never write into the user's working tree; auxiliary files go in ctx.tmpdir via HOME / XDG_CONFIG_HOME. * drop opencodePlugin.test.ts — bullshit-test cleanup these tests spied on process.stdout.write, loaded the plugin source into a temp file via dynamic import, and asserted the output strings matched the plugin source i'd just hand-written. zero unique signal over the e2e run in preview repo, plus they violate AGENTS.md's "mocks tend to add ceremony and brittleness" rule. real signal lives in the e2e: lens label rendering, dispatch attribution, no double events. if a syntactic regression in the plugin source ever ships, opencode logs it on plugin load and the e2e fails fast — the unit tests would catch the same regression no faster. * remove isPausedExternally — plugin makes it unnecessary empirical proof from PR #634's e2e debug trace: ~3.3 pullfrog_bus_event lines per second arrive on the parent's child.stdout pipe during a typical subagent run. each one fires updateActivity() and resets lastActivityTime, so the inner spawn activity timer naturally stays armed-but-not-fired throughout the subagent's lifetime — no suspend predicate needed. drop: - SpawnOptions.isPausedExternally + the check in spawn()'s activity loop - isSubagentInFlight() in opencode.ts + its callsite - two isPausedExternally unit tests in subprocess.test.ts keep: - killGroup (the actual zombie-prevention fix; still tested) - the plugin (action/agents/opencodePlugin.ts; the architectural fix) - everything in opencode.ts that derives lens labels from task dispatches the only edge case isPausedExternally covered that the plugin doesn't is a non-streaming provider going silent for >5min during a single LLM call inside a subagent. that's a provider-behavior question, not a harness-architecture one — best fixed at the provider level if it shows up. defense-in-depth that adds indirection is harmful when the upstream architectural fix is already in place. * opencode: address review feedback on bus envelope routing three findings from PR #634 review (2026-05-08T22:13:44Z): 1. token/cost double-count: routing subagent step_finish through the orchestrator's handler folded subagent tokens/cost into the run-wide accumulators that flow to logTokenTable + AgentUsage. neighbouring init/text handlers all gate on ORCHESTRATOR_LABEL for exactly this reason. fix: drop step_start AND step_finish from the bus envelope handler — those carry orchestrator-scoped state (currentStepId, stepHistory, token accumulators) that subagent events shouldn't touch. tool calls and text from subagents still surface — that's the user-visible activity. 2. subagent tool errors invisible: routed status="error" tool parts into handlers.tool_use which only emits "» (...)" with no error indication. fix: extend handlers.tool_use itself to log "» tool call failed: " when state.status==="error". benefits the orchestrator path too — opencode CLI also emits failed tool calls as tool_use at status=error and we were swallowing the failure signal there as well. 3. stale comments + leaked local paths: plugin source had /tmp/opencode-investigate/... paths from my local clone, specific line numbers from opencode's dev branch that don't match v1.1.56, forkDetach claim that's wrong for the pinned version, and JSDoc that still listed message.updated/session.error in the forwarded set after the runtime filter narrowed to message.part.updated only. fix: drop machine-local paths, drop version-fragile line numbers, correct the forwarded-set list, generalize the "why no @opencode-ai/plugin import" rationale to be version-agnostic. second review (2026-05-08T22:27:58Z) confirms these are the only findings still open — no new issues from the isPausedExternally removal. --- agents/opencode.ts | 239 +++++++++++++++++++++++++++++++-------- agents/opencodePlugin.ts | 139 +++++++++++++++++++++++ utils/subprocess.test.ts | 43 ------- utils/subprocess.ts | 20 ---- 4 files changed, 331 insertions(+), 110 deletions(-) create mode 100644 agents/opencodePlugin.ts diff --git a/agents/opencode.ts b/agents/opencode.ts index 4ffb378..9f3eca9 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -12,7 +12,7 @@ * security is enforced at the tool layer, not the process layer. */ import { execFileSync } from "node:child_process"; -import { mkdirSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; import { pullfrogMcpName } from "../external.ts"; @@ -26,6 +26,11 @@ import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/ import { ThinkingTimer } from "../utils/timer.ts"; import type { TodoTracker } from "../utils/todoTracking.ts"; import { getDevDependencyVersion } from "../utils/version.ts"; +import { + PULLFROG_BUS_EVENT_TYPE, + PULLFROG_OPENCODE_PLUGIN_FILENAME, + PULLFROG_OPENCODE_PLUGIN_SOURCE, +} from "./opencodePlugin.ts"; import { buildLearningsReflectionPrompt, runPostRunRetryLoop } from "./postRun.ts"; import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts"; import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts"; @@ -280,6 +285,36 @@ interface OpenCodeErrorEvent { [key: string]: unknown; } +/** + * Envelope event emitted by our `.opencode/plugin/pullfrog-events.ts` (the + * source lives in `opencodePlugin.ts`). The plugin subscribes to opencode's + * bus via `bus.subscribeAll()` and re-emits non-orchestrator + * `message.part.updated` events on stdout so subagent activity surfaces here. + * + * `bus_event.properties.part` matches the same `Part` shape that opencode's + * `cli/cmd/run.ts` uses to drive its own emit() calls, so we can route the + * inner part through the existing `tool_use` / `step_start` / `step_finish` + * / `text` handlers by synthesizing the equivalent OpenCode-style event. + */ +interface OpenCodeBusEnvelopeEvent { + type: "pullfrog_bus_event"; + bus_event?: { + type?: string; + properties?: { + part?: { + sessionID?: string; + type?: string; + time?: { end?: number | string }; + state?: { status?: string }; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; +} + type OpenCodeEvent = | OpenCodeInitEvent | OpenCodeMessageEvent @@ -289,7 +324,8 @@ type OpenCodeEvent = | OpenCodeToolUseEvent | OpenCodeToolResultEvent | OpenCodeResultEvent - | OpenCodeErrorEvent; + | OpenCodeErrorEvent + | OpenCodeBusEnvelopeEvent; // ── runner ────────────────────────────────────────────────────────────────────── @@ -324,15 +360,12 @@ async function runOpenCode(params: RunParams): Promise { // per-session labeler so parallel subagent log lines can be differentiated. // the orchestrator's task tool_use events seed the labeler; the next // previously-unseen sessionID consumes the head of the pending-label queue. - // NB: opencode's runtime currently encapsulates subagent execution inside - // the `task` tool — subagent-internal tool_use/tool_result events do not - // surface on the parent's NDJSON stream. The labeler is therefore mostly - // dormant in practice for opencode (no per-event session differentiation - // is needed because there are no per-subagent events). The orchestrator's - // `task` dispatch log (with `description: `) and the per-task - // duration log below are the actual attribution surface available today. - // The labeler is kept in place defensively so that if/when opencode begins - // streaming subagent sessions, attribution flips on with no further work. + // upstream opencode's `cli/cmd/run.ts` filters subagent events out of its + // NDJSON stream (`part.sessionID !== sessionID`), so we ship a per-run + // plugin (`action/agents/opencodePlugin.ts`, written into the tmpdir at + // setup) that re-emits non-orchestrator `message.part.updated` events. those + // arrive here as `pullfrog_bus_event` envelopes and feed the labeler with + // real data per subagent session. const labeler = new SessionLabeler(); function eventLabel(event: Record): string { const sid = event.sessionID ?? event.session_id; @@ -368,16 +401,6 @@ async function runOpenCode(params: RunParams): Promise { // mismatched callID" signal). const knownNonTaskCallIDs = new Set(); - // a subagent is "in flight" while at least one `task` tool_use is awaiting - // its tool_result. used to suspend spawn's activity timer (which only sees - // opencode's stdout) so a long-running subagent — whose internal events - // don't surface on the parent NDJSON stream — can't trip the 5min timeout. - // boolean state instead of a heartbeat so there's no race window between a - // periodic tick and a subagent that finishes between ticks. - function isSubagentInFlight(): boolean { - return taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0; - } - function emitSubagentFinished( dispatch: TaskDispatch, status: string, @@ -532,25 +555,31 @@ async function runOpenCode(params: RunParams): Promise { // label is already bound); the dispatch label is for the next new // sessionID that appears. if (toolName === "task") { - const taskInput = (event.part?.state?.input ?? {}) as { - description?: string; - subagent_type?: string; - prompt?: string; - }; - const dispatchedLabel = labeler.recordTaskDispatch(taskInput); - // dual-index by callID (fast path) AND in a FIFO queue (fallback path - // for when opencode's task tool_result carries a different callID). - const dispatch: TaskDispatch = { - label: dispatchedLabel, - startedAt: performance.now(), - toolUseCallID: toolId, - }; - taskDispatchByCallID.set(toolId, dispatch); - pendingTaskDispatches.push(dispatch); - log.info( - `» dispatching subagent: ${dispatchedLabel}` + - (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "") - ); + // may have been pre-registered via the plugin's early task-dispatch + // announcement (`pullfrog_bus_event` handler). dedupe on callID so + // we don't record the same dispatch twice (which would corrupt the + // FIFO label queue). + if (!taskDispatchByCallID.has(toolId)) { + const taskInput = (event.part?.state?.input ?? {}) as { + description?: string; + subagent_type?: string; + prompt?: string; + }; + const dispatchedLabel = labeler.recordTaskDispatch(taskInput); + // dual-index by callID (fast path) AND in a FIFO queue (fallback path + // for when opencode's task tool_result carries a different callID). + const dispatch: TaskDispatch = { + label: dispatchedLabel, + startedAt: performance.now(), + toolUseCallID: toolId, + }; + taskDispatchByCallID.set(toolId, dispatch); + pendingTaskDispatches.push(dispatch); + log.info( + `» dispatching subagent: ${dispatchedLabel}` + + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "") + ); + } } else { // remember non-task callIDs so a later tool_result with that callID // is correctly identified as not-a-task (and we don't FIFO-pop a @@ -580,6 +609,16 @@ async function runOpenCode(params: RunParams): Promise { if (event.part?.state?.status === "completed" && event.part.state.output) { log.debug(withLabel(label, ` output: ${event.part.state.output}`)); } + // surface tool errors at info level. opencode emits tool parts at + // status="error" through the same `tool_use` event the CLI's run-loop + // (and our injected plugin for subagent parts) emits — without this + // branch the only signal in the user's logs is `» (...)` with + // no indication the call failed. error info lives in `state.output` + // (an error string set by the tool layer). + if (event.part?.state?.status === "error") { + const errorMsg = event.part.state.output ?? "(no error message)"; + log.info(withLabel(label, `» tool call failed: ${errorMsg}`)); + } // agent's explicit MCP report_progress takes priority over todo tracking if (toolName.includes("report_progress") && params.todoTracker) { @@ -700,6 +739,98 @@ async function runOpenCode(params: RunParams): Promise { } } }, + [PULLFROG_BUS_EVENT_TYPE]: async (event: OpenCodeBusEnvelopeEvent) => { + // surface subagent activity that opencode's CLI run-loop discards (it + // filters `part.sessionID !== sessionID`). our injected plugin + // (action/agents/opencodePlugin.ts) re-emits non-orchestrator + // `message.part.updated` bus events; here we synthesize the equivalent + // CLI-style event for each known part type and dispatch through the + // existing handlers so labeling, attribution, and logging all reuse the + // same code path as the orchestrator's events. mirrors the dispatch + // logic in opencode-ai's `cli/cmd/run.ts` `loop()` function. + const busEvent = event.bus_event; + if (!busEvent || busEvent.type !== "message.part.updated") return; + const part = busEvent.properties?.part; + if (!part || typeof part.sessionID !== "string") return; + const sessionID = part.sessionID; + const partType = part.type; + + // early task dispatch: the orchestrator's task tool fires bus events at + // status=running BEFORE the subagent's first message.part.updated, but + // the CLI's run-loop only emits the matching tool_use NDJSON event at + // status=completed (after the subagent finishes). without + // pre-registering the dispatch label here, the labeler binds the + // subagent's sessionID to a generic `subagent#N` fallback before the + // CLI's tool_use ever fires recordTaskDispatch. dedupe against + // taskDispatchByCallID so the late tool_use handler doesn't double-add. + if (partType === "tool") { + const status = part.state?.status; + const partWithToolFields = part as { + tool?: string; + callID?: string; + state?: { status?: string; input?: unknown }; + }; + // only running (not pending) — at pending state.input is still {}. + // by running, the LLM has filled in description/subagent_type/prompt. + // mirrors the same check in the plugin source. + const isOrchestratorTaskDispatch = + partWithToolFields.tool === "task" && status === "running"; + if (isOrchestratorTaskDispatch) { + const callID = partWithToolFields.callID; + if (typeof callID === "string" && !taskDispatchByCallID.has(callID)) { + const taskInput = (partWithToolFields.state?.input ?? {}) as { + description?: string; + subagent_type?: string; + prompt?: string; + }; + const dispatchedLabel = labeler.recordTaskDispatch(taskInput); + const dispatch: TaskDispatch = { + label: dispatchedLabel, + startedAt: performance.now(), + toolUseCallID: callID, + }; + taskDispatchByCallID.set(callID, dispatch); + pendingTaskDispatches.push(dispatch); + log.info( + `» dispatching subagent: ${dispatchedLabel}` + + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "") + ); + } + return; + } + if (status !== "completed" && status !== "error") return; + await handlers.tool_use({ + type: "tool_use", + sessionID, + part, + } as OpenCodeToolUseEvent); + return; + } + // intentionally NOT routing subagent step_start / step_finish through + // the orchestrator's handlers: + // - step_finish carries `tokens` and `cost` and the handler folds + // them into the run-wide accumulators. surfacing subagent steps + // here would inflate the orchestrator's usage telemetry — and + // either double-count (if opencode also bills child tokens back + // up to the parent session) or just over-report. the existing + // init/message/text handlers all gate on ORCHESTRATOR_LABEL for + // the same reason. + // - step_start mutates `currentStepId` / `currentStepType` / + // `stepHistory`, which are orchestrator-scoped — using them to + // attribute subagent activity in the orchestrator's tool-use + // timing log would be wrong. + // the subagent's tool calls and text still surface (handled below) + // — that's the user-visible activity. + if (partType === "step-start" || partType === "step-finish") return; + if (partType === "text" && part.time?.end !== undefined) { + await handlers.text({ + type: "text", + sessionID, + part, + } as OpenCodeTextEvent); + return; + } + }, }; const recentStderr: string[] = []; @@ -726,13 +857,13 @@ async function runOpenCode(params: RunParams): Promise { // never fires — producing zombie runs. detached + killGroup nukes the // whole tree. killGroup: true, - // suspend the inner activity timer while a `task` subagent is in flight. - // opencode's task tool encapsulates subagent execution in-process — the - // subagent's internal events don't surface on the parent NDJSON stream, - // so without this the 5min timeout would falsely fire mid-subagent. - // suspend/resume is preferable to a heartbeat because there's no race - // between a periodic tick and a subagent finishing between ticks. - isPausedExternally: isSubagentInFlight, + // NB: we used to pass `isPausedExternally: isSubagentInFlight` to suspend + // the activity timer during subagent dispatches. unnecessary now that + // our injected plugin (action/agents/opencodePlugin.ts) re-emits + // subagent `message.part.updated` events on opencode's stdout — those + // arrive at child.stdout here, fire updateActivity(), and reset + // lastActivityTime naturally. verified empirically in PR #634 + // (~3.3 plugin events/sec during a typical subagent run). onStdout: async (chunk) => { const text = chunk.toString(); output += text; @@ -944,6 +1075,20 @@ export const opencode = agent({ mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true }); + // drop our bus-event surfacing plugin into opencode's global config dir + // (which we've redirected to the per-run tmpdir via XDG_CONFIG_HOME). + // opencode auto-discovers plugins from `/{plugin,plugins}/*.{ts,js}` + // (see `packages/opencode/src/config/config.ts:633` calling + // `ConfigPlugin.load(dir)`), so this lands in the loader without any + // config wiring. critically: this MUST be inside the tmpdir, never the + // user's repo working tree — see AGENTS.md. + const opencodePluginDir = join(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin"); + mkdirSync(opencodePluginDir, { recursive: true }); + writeFileSync( + join(opencodePluginDir, PULLFROG_OPENCODE_PLUGIN_FILENAME), + PULLFROG_OPENCODE_PLUGIN_SOURCE + ); + const agentBrowserVersion = getDevDependencyVersion("agent-browser"); addSkill({ ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`, diff --git a/agents/opencodePlugin.ts b/agents/opencodePlugin.ts new file mode 100644 index 0000000..379a8d3 --- /dev/null +++ b/agents/opencodePlugin.ts @@ -0,0 +1,139 @@ +/** + * 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. + } + }, + }; +} +`; diff --git a/utils/subprocess.test.ts b/utils/subprocess.test.ts index a26c8b8..f490b23 100644 --- a/utils/subprocess.test.ts +++ b/utils/subprocess.test.ts @@ -79,49 +79,6 @@ describe("spawn error path", () => { expect(elapsed).toBeLessThan(10_000); }, 20_000); - it("isPausedExternally suspends the activity timer while it returns true", async () => { - // opencode's `task` tool encapsulates subagent execution in-process — - // subagent-internal events don't reach our parent NDJSON stream. this - // means the child's stdout falls silent for the full subagent duration - // even when real work is happening. opencode passes a predicate keyed - // off its in-flight task dispatch tracker; while it returns true, the - // activity timer must skip the kill decision and reset its baseline. - const result = await spawn({ - cmd: "bash", - args: ["-c", "sleep 8; echo done"], - env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" }, - activityTimeout: 1000, - isPausedExternally: () => true, - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("done"); - }, 20_000); - - it("isPausedExternally fires normally once it returns false again", async () => { - // verifies suspend/resume rather than unconditional bypass: the predicate - // flips to false after a few hundred ms, so the timer should resume from - // a fresh baseline (advanced during the paused window) and fire after - // activityTimeout idle time. critical that lastActivityTime is reset - // while paused — otherwise the resume tick would see a stale baseline - // and kill instantly. - let paused = true; - setTimeout(() => { - paused = false; - }, 500); - - const result = await spawn({ - cmd: "sleep", - args: ["30"], - env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" }, - activityTimeout: 1000, - isPausedExternally: () => paused, - }).catch((err) => err); - - expect(result).toBeInstanceOf(Error); - expect(String(result)).toMatch(/activity timeout/i); - }, 20_000); - it("reports signal-killed subprocesses as failures, not success", async () => { // regression: before the fix, `child.on("close", (exitCode) => ...)` // discarded the signal parameter and `exitCode || 0` coerced the diff --git a/utils/subprocess.ts b/utils/subprocess.ts index 8654617..c84314f 100644 --- a/utils/subprocess.ts +++ b/utils/subprocess.ts @@ -115,16 +115,6 @@ export interface SpawnOptions { // open, keeps emitting NDJSON, and `child.on("close")` never fires — // producing zombie runs that hang until the GitHub Actions job timeout). killGroup?: boolean; - // optional pause predicate consulted on every activity check. when it - // returns true the activity timer skips the kill decision *and* resets - // its idle baseline so a clean unpause can't immediately fire on a stale - // lastActivityTime. opencode uses this because its `task` tool encapsulates - // subagent execution in-process — subagent-internal events don't surface - // on the parent's NDJSON stream, so the local stdout-only signal would - // falsely fire mid-subagent. preferred over fake-activity heartbeats - // because there's no race window between a heartbeat tick and a subagent - // that finishes between ticks. - isPausedExternally?: () => boolean; } export interface SpawnResult { @@ -216,16 +206,6 @@ export async function spawn(options: SpawnOptions): Promise { `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms` ); activityCheckIntervalId = setInterval(() => { - // when an external pause predicate says we're suspended (e.g. - // opencode in a long-running task subagent whose internal events - // don't surface on stdout), advance lastActivityTime to "now" so a - // clean unpause doesn't immediately fire on a stale baseline, and - // skip the kill decision for this tick. - if (options.isPausedExternally?.()) { - lastActivityTime = performance.now(); - log.debug(`spawn activity check: pid=${child.pid} paused externally`); - return; - } const idleMs = performance.now() - lastActivityTime; log.debug( `spawn activity check: pid=${child.pid} idle=${Math.round(idleMs)}ms / ${activityTimeoutMs}ms`