opencode: surface subagent events via injected plugin (#634)

* 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
<XDG_CONFIG_HOME>/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 "» <tool>(...)" with no
   error indication. fix: extend handlers.tool_use itself to log
   "» tool call failed: <msg>" 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.
This commit is contained in:
Colin McDonnell
2026-05-08 22:46:43 +00:00
committed by pullfrog[bot]
parent d6de1c369a
commit 3d393c36a3
4 changed files with 331 additions and 110 deletions
+192 -47
View File
@@ -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<AgentResult> {
// 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: <lens>`) 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, unknown>): string {
const sid = event.sessionID ?? event.session_id;
@@ -368,16 +401,6 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
// mismatched callID" signal).
const knownNonTaskCallIDs = new Set<string>();
// 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<AgentResult> {
// 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<AgentResult> {
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 `» <tool>(...)` 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<AgentResult> {
}
}
},
[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<AgentResult> {
// 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 `<Global.Path.config>/{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}`,
+139
View File
@@ -0,0 +1,139 @@
/**
* Source for the opencode plugin we drop into the per-run tmpdir at
* `<XDG_CONFIG_HOME>/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 `<XDG_CONFIG_HOME>/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.
}
},
};
}
`;
-43
View File
@@ -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
-20
View File
@@ -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<SpawnResult> {
`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`