3d393c36a3
* 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.
140 lines
6.4 KiB
TypeScript
140 lines
6.4 KiB
TypeScript
/**
|
|
* 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.
|
|
}
|
|
},
|
|
};
|
|
}
|
|
`;
|