a0576a702a
* opencode v2: harness adapted to opencode-ai 1.15+ SDK-v2 / Effect-ts CLI rewrite
Bumps `opencode-ai` from `1.1.56` → `1.15.1` and ports the harness to the
v2 NDJSON event contract. The legacy `opencode.ts` is kept as reference;
`opencode_v2.ts` is the active runner via `agents/index.ts`.
Why: `1.1.56` doesn't echo Gemini `thought_signature` back through the
MCP tool-call serializer, so direct-Google reviews 400 on the 3rd-ish
tool call. The fix only exists in the `1.14.x`+ line, which also ships
the SDK-v2 / Effect-ts CLI rewrite — taking the rewrite is mandatory.
Also unblocks the Codex ChatGPT-subscription auth path.
Surface area:
- drop `init` / `message` / `result` / `tool_result` event types and
handlers (no longer emitted at v1.14+ per upstream
`cli/cmd/run.ts:588-601`).
- `tool_use` is now a single event covering both `state.status:
"completed"` and `"error"`. duration / subagent-finish bookkeeping
moves from the v1 `tool_result` handler into the consolidated
`tool_use` handler.
- new `reasoning` event handler — gated on `--thinking`, surfaces
Gemini-3 / OpenAI / Anthropic thinking blocks. `--thinking` added to
`baseArgs`.
- drop `pendingTaskDispatches` FIFO + `knownNonTaskCallIDs` set: at
v1.15 the `task` tool callID is stable across the whole
`tool-input-* → tool-call → tool-result/tool-error` chain
(`session/processor.ts:282-330`). exact-match map is sufficient.
- drop `experimental.batch_tool: true` from injected config — declared
but inert at v1.15. re-add once upstream wires it back.
- bin path: `bin/opencode` → `bin/opencode.exe` (postinstall renames
the platform-specific binary into `opencode.exe` for every OS now).
Validated locally:
- `pnpm test` 610/610 ✓
- `pnpm play --raw` end-to-end with Anthropic via OpenRouter ✓
- `pnpm play --raw` with `google/gemini-3.1-pro-preview`: 6 tool calls,
multiple reasoning blocks visible, `set_output` propagates, exit 0 ✓
(this is the headline `thought_signature` fix)
- runtest opencode: smoke ✓, restricted ✓, nobash ✓, token-exfil ✓
- runtest opencode: skill-invoke and mcpmerge fail (model-behavior
drift on the new system prompt; wiring confirmed intact via direct
repro showing both `robinMCP` and `pullfrog` MCP tools exposed).
Tracked for follow-up; does not gate the migration.
Plugin (`opencodePlugin.ts`) and skill discovery paths are unchanged at
v1.15 — verified upstream and reused as-is. Bus subscription via
`bus.subscribeAll()` and the `event` hook still fan out every payload.
* model-smoke: bump opencode bin path to opencode.exe (v1.14+ rename)
The v1.14+ postinstall.mjs renames the platform-specific binary to
`bin/opencode.exe` for every OS (incl. linux/darwin), not just Windows.
Mirrors the fix in action/agents/opencode_v2.ts.
* opencode v2: set PWD env explicitly to fix skill / project-config discovery
Root cause for skill-invoke + mcpmerge harness regressions: opencode-ai 1.15
reads `process.env.PWD` first (with `process.cwd()` as fallback) when
resolving the SDK client's `directory` parameter — see upstream
`cli/cmd/run.ts:282`:
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
We pass `cwd: repoDir` to spawn, but the child inherits the harness's PWD
via `...process.env`. Under `pnpm runtest` (and `pnpm play`) PWD is the
`action/` directory, not the cloned test repo. Result: opencode creates
two instances per session — one at `process.cwd()` (correct) and one at
`PWD` (wrong) — and the agent's session runs in the PWD-derived one,
which can't see the project's `.opencode/skills/` or `.claude/skills/`.
Empirically traced via the full opencode stderr trace under the runtest
harness: `service=skill count=3 init` (no `pullfrog-skill-check`) plus a
second `service=default directory=<harness-pwd> creating instance` line
per run. With `PWD=repoDir` set explicitly, `count=4 init` includes the
test skill, the agent reaches for `skill({"name":"pullfrog-skill-check"})`
exactly as the validator expects, and mcpmerge's `robinMCP_get_test_value`
becomes accessible too.
Validated locally: skill-invoke-opencode ✓, mcpmerge-opencode ✓, smoke ✓,
restricted ✓, nobash ✓, token-exfil ✓ (flaked once on a model-narration
match, passes on retry; unrelated to PWD).
* opencode v2: drop ThinkingTimer; use opencode's reasoning.part.time directly
opencode-ai 1.15 emits `reasoning` parts with `time.start` / `time.end`
on terminal state (`cli/cmd/run.ts:671`), giving us a precise per-block
"thought for X s" duration straight from the runtime. The v1
ThinkingTimer heuristic — measuring wall-clock between markToolResult
and the next markToolCall — was an approximation when no native source
existed; with v2 it's redundant and noisy (it would log alongside the
real reasoning event, and conflated network latency with model thinking).
Removed: `ThinkingTimer` import, `thinkingTimers` Map, `timerFor()`
helper, both `markToolCall` / `markToolResult` call sites in `tool_use`.
The `reasoning` handler now reads `part.time.start/end` directly and
prefixes the visible preview with `(X.Ys)`.
Output before: `» thinking: <preview>` + `» thought for 4.0s` (separate)
Output now: `» thinking (4.0s): <preview>` (one line, sourced)
For models that don't emit reasoning (Sonnet without extended thinking,
GPT-4o, etc.), there's just no thinking line — which matches reality
better than the gap-heuristic, which would fire on any pause >3s
including provider-side latency that wasn't actual model reasoning.
Validated locally: skill-invoke ✓, mcpmerge ✓, smoke ✓, Gemini play
shows `» thinking (4.0s)` and `» thinking (0.8s)` from real durations.
* claude.ts: same PWD fix as opencode v2; entryPost: refresh stale comment
claude-code 2.1.x reads `process.env.PWD` and registers it as a "session"
additional-working-directory when it differs from `process.cwd()` (per the
bundled cli.js: `let H = process.env.PWD; if (H && H !== Y7() && ...)
j.set(H, { path: H, source: "session" })`). Without overriding PWD on the
spawn env, claude inherits the harness's PWD via `...process.env` — under
`pnpm runtest` / `pnpm play` that's `action/`, not the cloned test repo —
and adds the wrong dir to the agent's allowed working set.
Symmetric to the opencode v2 fix in 52337f9. Pre-empts the same class of
"agent's session sees the wrong cwd" failures on the claude side.
Also refresh the stale `action/agents/opencode.ts` reference in
entryPost.ts to point at opencode_v2.ts (the active runner), with the v1
file noted as kept-for-reference.
* opencode: extract shared helpers into opencodeShared.ts; v2 cleanup
Code-quality pass on the v2 work:
1. New `agents/opencodeShared.ts` (144 lines) for genuinely-shared helpers
between v1 and v2:
- `OpenCodeConfig` type
- `geminiHighThinkingOverrides()` (registry-driven Gemini thinking pin)
- `buildReviewerAgentConfig()` (reviewfrog config builder, was in v1
and re-imported by v2 via a back-reference)
- `installOpencodeCli({ binPath })` (parameterized — v1 passes
`bin/opencode`, v2 passes `bin/opencode.exe` via a per-version
`installCli` lambda; matches each pinned version's npm shape)
- `autoSelectModel()` + `getOpenCodeModels()` model-registry fallback
v2 drops the `import { ... } from "./opencode.ts"` back-reference; v1
keeps a one-line `export { geminiHighThinkingOverrides }` re-export
so `opencode.test.ts` keeps working unchanged. Once v1 is retired
(post burn-in) opencodeShared collapses back into v2.
2. `opencode_v2.ts` cleanup:
- drop dead state (`currentStepId`, `stepHistory` were write-only —
their reader was the v1 `tool_result` handler we deleted)
- hoist `state` in `tool_use` handler; replace nested-ternary payload
extraction with a `terminalPayload(state)` helper
- extract `formatPartDuration(time)` for the reasoning-block
"(X.Ys)" suffix
- tighten `OpenCodeBusEnvelopeEvent` type to include `tool` /
`callID` fields directly, drop the `partWithToolFields` cast
- trim docblocks per AGENTS.md "≤ 2-3 lines per code line": reasoning
handler, tool_use handler, bus envelope handler all shortened
- `step_start` becomes an explicit `() => {}` no-op so the dispatcher
doesn't log "unhandled event" for every step
3. `subagentRegistration.test.ts` retargeted at the new file split —
reads opencodeShared.ts for the buildReviewerAgentConfig assertions
and opencode_v2.ts for the orchestrator-model wire-through.
Net: -306 source lines (1339+1130 → 1228+1031+144). Tests + lint + format
+ typecheck all green; skill-invoke-opencode ✓ and smoke ✓ verified
against the refactored v2 runtime.
* opencode v2: address PR review feedback
Three fixes from the inline review threads on #767:
1. Activity-diagnostic ordering bug (Copilot review at L705): the chunk-
level `markActivity()` resets the module-level idle counter, so the
per-event `getIdleMs()` sample inside the dispatch loop was always
~0ms — the "no activity for Xs" diagnostic never fired. Replaced with
a runner-local `lastEventAt` so we measure real event-to-event silence
instead of chunk-arrival latency. Drop the unused `getIdleMs` import.
2. TDZ-defensive hoist (Pullfrog review nit): `agentErrorEvent`,
`lastProviderError`, and `recentStderr` are closed over by the
`handlers` const but were declared after it. No current bug because
handlers only fire inside the awaited `spawn()`, but a future
refactor that triggers a handler synchronously during setup would
surface a TDZ. Hoisted above `handlers`.
3. `step_finish.part.tokens.reasoning` follow-up (Pullfrog review at
L566): leave a `TODO` comment marking the gap until `AgentUsage`
grows a `reasoningTokens` field — separate PR with schema work.
Cost totals stay correct because `part.cost` is summed independently.
Other thread states for the record:
- Copilot L63 (geminiHighThinkingOverrides import from legacy): already
fixed by the opencodeShared.ts extraction in 83a7cab.
- Copilot L672 (ThinkingTimer over-reports on terminal events): already
fixed by dropping ThinkingTimer in a1e536b — we use opencode's own
`reasoning.part.time.{start,end}` for thinking durations now.
- Pullfrog L642 (onToolUse double-fire on subagent dispatch): re-checked
the bus-envelope flow; the plugin filters orchestrator events except
for status=running task dispatches, and bus-envelope returns before
calling handlers.tool_use on those. No double-fire under current code.
Validated: 610/610 unit tests, lint + format + typecheck clean,
skill-invoke-opencode ✓.
* DX: flip pnpm play / pnpm runtest to docker-by-default
Restores the script shape wiki/docker.md has documented since the docker
rewrite (#750). PR #756 inadvertently reverted action/package.json's
gha/play/runtest scripts to host-only and dropped the :local variants;
the wiki kept the new shape, so docs and reality drifted. The OpenCode-v2
migration agent ran `pnpm play --raw …` host-side throughout because the
host entry was the only thing that existed.
scripts (root → action):
- pnpm play → pnpm -C action gha play.ts (docker, default)
- pnpm play:local → pnpm -C action play:local (host)
- pnpm runtest → pnpm -C action gha test/run.ts (docker, default)
- pnpm runtest:local → pnpm -C action runtest:local (host)
- pnpm gha is restored in action/package.json (re-adds `node gha.ts`)
action/package.json deliberately ships only the :local variants — bare
`pnpm -C action play` now errors instead of silently bypassing docker.
This is a tradeoff per the user prompt's "consider whether NAMES should
change" hint: the explicit error is worth the small CI churn.
CI workflows: `.github/workflows/test.yml` and
`action/.github/workflows/test.yml` flipped from `pnpm runtest …` to
`pnpm runtest:local …`. Semantics unchanged — they still execute
`node test/run.ts` directly on the GHA Linux runner; nesting docker on
GHA is unnecessary overhead. Only the script name changed to match the
new package.json.
Webhook tester: the existing root `pnpm play` was actually a webhook
handler smoke harness (root play.ts), unrelated to the action runtime.
Renamed root play.ts → webhook.ts and exposed it as `pnpm webhook` so
`pnpm play` can carry the docker-by-default action shortcut without
collision. README updated.
File headers updated:
- action/play.ts: invocation block now points at `pnpm play` /
`pnpm play:local`
- action/test/run.ts: same
- action/gha.ts: usage block calls out the new shortcut wrappers
AGENTS.md: extended the existing "local sanity checks of action tool
logic" rule with the play / play:local / runtest / runtest:local
selection guidance and the `cd action; pnpm play` footgun note.
wiki/docker.md unchanged — already described the now-real shape.
* test/crossagent: add codex-auth smoke
Pins openai/gpt-5.5 (in opencode's Codex ALLOWED_MODELS) and runs the
full opencode harness against the env-provided CODEX_AUTH_JSON. Verifies:
- installCodexAuth() materializes auth.json under the test HOME
- opencode routes openai requests through ChatGPT subscription auth
(no OPENAI_API_KEY in env, AT path forced via expires: 0)
- the refresh chain advances during the run (refresh_token rotates)
- detectCodexRefresh() would surface the rotation to entryPost.ts
The post-hook write-back fetch isn't reachable from `pnpm runtest`
(it's a separate GHA `post:` step). The integration boundary that
matters end-to-end is "did the on-disk auth.json change in a way
detectCodexRefresh recognizes" — that's exactly what this test asserts.
CI wiring (already committed in a1c1fd4f as part of the DX flip):
- .github/workflows/test.yml: CODEX_AUTH_JSON via secrets in
action-agents env block
- action/.github/workflows/test.yml: same; codex-auth in the
hardcoded test matrix with a claude exclude
The provisioning step on the user's side is `gh secret set
CODEX_AUTH_JSON --repo pullfrog/app < auth.json`.
ci.test.ts: expectedAgentEnvVars now includes provider
`managedCredentials` so the "env vars cover all provider API keys"
invariant stays self-correcting as more managed credentials land.
* docs(codex-auth): make storage requirement unmissable
A previous reviewing agent on this branch came away thinking
`CODEX_AUTH_JSON` could live in GitHub Actions secrets. It can't —
`entryPost.ts` rewrites the rotated refresh token after every run, and GH
Actions secrets are immutable at runtime, so any non-Pullfrog-Postgres
storage breaks the refresh chain on the first rotation (~1h silent
expiry).
- wiki/codex-auth.md: prominent `[!IMPORTANT]` callout above the fold,
with the words "GitHub Actions secrets DO NOT WORK" verbatim and an
enumeration of broken alternatives.
- action/utils/codexHome.ts + action/entryPost.ts: header comments now
loudly contrast Pullfrog secret store vs GH Actions and explain the
writeback constraint.
- AGENTS.md: terse one-bullet rule next to the model-resolution rule so
future agents don't repeat the mistake.
- .github/workflows/test.yml + action/.github/workflows/test.yml: added a
comment marking the existing `secrets.CODEX_AUTH_JSON` injection as a
CI smoke-testing shortcut, not the canonical pattern. CI wiring itself
unchanged per scope.
* auth codex: auto-open device URL, drop --scope flag
- detect `https://auth.openai.com/codex/device...` from codex CLI output
and best-effort launch it in the user's default browser (open / xdg-open
/ cmd start, wslview fallback on linux). gated so we only open once per
flow; failures are swallowed so manual copy-paste still works.
- drop the `--scope` flag entirely. the device-code flow is fundamentally
interactive (browser approval), so a "skip-the-prompt" flag for just one
of the prompts was dead weight. collapses scope selection to "always
prompt on org-owned, always account on user-owned".
* rename gha→docker, flip play/runtest defaults to host
the previous shape conflated "real GitHub Actions" with the local docker
container that mocks it, and made the slow docker path the default for
fast-iteration scripts.
- `action/gha.ts` → `action/docker.ts` (banner, --doctor, --help, image
tag `pullfrog-docker:*`, volume `pullfrog-docker-node-modules-*`,
tmpdir, error messages)
- `pnpm play` / `pnpm runtest` now default to host (fast iteration);
`pnpm play:docker` / `pnpm runtest:docker` run inside the container
- `pnpm gha` → `pnpm docker` (the container runner shortcut)
- `pnpm webhook` → `pnpm play:webhook` (fits the play: namespace; the
bare name implied a webhook server, which hookdeck-cli already is)
- update docs (`wiki/{docker,action-tests,billing,adversarial,browser}.md`,
`README.md`, `AGENTS.md`), CI workflows
(`.github/workflows/test.yml`, `action/.github/workflows/test.yml`),
and code headers (`action/{play,test/run,utils/runFixture}.ts`,
`webhook.ts`, `action/test/coverage.ts`)
`action/commands/gha.ts` keeps its name — it's the real GitHub Actions
entry point for the `pullfrog gha` CLI command (not the docker mock).
* fix(codex): route post-hook writeback through apiFetch + conditional skip
Three threads addressing PR #767 followups.
action/entryPost.ts: replace raw fetch() with apiFetch() so the
PUT /api/runtime/secret call carries the x-vercel-protection-bypass
header/query when targeting a preview deployment. raw fetch silently
401s against the Vercel SSO gate, so every preview-env Codex run was
losing its rotated refresh token. production is unaffected (no SSO).
action/test/crossagent/codexAuth.ts: gate the test on CODEX_AUTH_JSON
via new TestRunnerOptions.skipIf hook. when the secret is absent
(forks, contributors without it), runTestForAgent short-circuits to a
passing-with-skipped ValidationResult before any agent spawn — so the
matrix's fail-fast: true setting doesn't cascade-cancel siblings. CI
on pullfrog/app and dev-local with .env both still run the test for
real. printSingleValidation/printResults now render skipped entries
distinctly.
doc/comment drift:
- docs/codex-auth.mdx, wiki/codex-auth.md: drop stale --scope flag
mention (removed in 10be96db, scope is now always interactively
prompted or implicit).
- wiki/codex-auth.md: tighten Claude-defense wording — materialization
is agent-gated (opencode/opencode_v2 harness), not model-gated;
opencode runs with non-OpenAI models still materialize the file,
it's just not read.
- action/Dockerfile, action/docker-entrypoint.sh: pnpm gha / gha.ts
→ pnpm docker / docker.ts (renamed in a2a63929).
- app/api/runtime/secret/route.ts: refer to the save-time scope prompt
instead of the dropped --scope flag.
* smoke: force ≥2 tool calls; document test-bar in wiki + AGENTS
upgrade crossagent/smoke prompt to call pullfrog_git status before
set_output. this exercises the 2nd model→agent round-trip across every
providers-live flagship, catching bugs like the Gemini thought_signature
echo that single-tool-call tests can't see.
also adds the "bar for adding new LLM-driven tests" section to
wiki/action-tests.md and an extension to the existing AGENTS.md
no-tests rule pointing at it — prefer upgrading existing matrix entries
over adding new ones.
local: pnpm runtest smoke opencode passes against both
anthropic/claude-sonnet-4-6 and google/gemini-pro.
---------
Co-authored-by: Colin McDonnell <colinmcd94@M1chelle.local>
1048 lines
44 KiB
TypeScript
1048 lines
44 KiB
TypeScript
/**
|
|
* OpenCode agent — secure harness around OpenCode CLI (v2 / opencode-ai >=1.14.x).
|
|
*
|
|
* Adapted from `./opencode.ts` for the SDK-v2 / Effect-ts CLI rewrite that
|
|
* landed in the `opencode-ai@1.14.x` line and is current at `1.15.x`. The
|
|
* legacy file is kept as `./opencode.ts` for reference / quick revert; the
|
|
* agent registry (`./index.ts`) imports this module instead.
|
|
*
|
|
* Differences vs the v1 harness:
|
|
* - NDJSON event set is now `tool_use | step_start | step_finish | text |
|
|
* reasoning | error`. `init`, `message`, `result`, and standalone
|
|
* `tool_result` are no longer emitted by `cli/cmd/run.ts emit()`. The
|
|
* v2 `tool_use` event covers both the completion and the error terminal
|
|
* states (read `part.state.status`); the per-call duration tracking that
|
|
* was on the v1 `tool_result` handler now lives on `tool_use`.
|
|
* - `reasoning` events (Gemini thinking blocks etc.) only emit when the
|
|
* CLI is invoked with `--thinking`. Always passed in `baseArgs`.
|
|
* - The `task` tool's callID is now stable across the whole `tool-input-*
|
|
* → tool-call → tool-result/tool-error` chain (v1 had a callID-mismatch
|
|
* quirk that forced a FIFO fallback on the parent). Subagent-finish
|
|
* attribution is exact-match-only — the FIFO scaffolding is gone.
|
|
* - `experimental.batch_tool` is declared-but-inert at v1.15.0 (no read
|
|
* site upstream). Removed from the injected config until upstream wires
|
|
* it back; keeping the flag would just be dead config.
|
|
*
|
|
* Identical to v1:
|
|
* - bash: "deny" via OPENCODE_CONFIG_CONTENT (agent cannot shell out)
|
|
* - OPENCODE_PERMISSION filesystem sandbox — deny all external paths except /tmp
|
|
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
|
|
* - MCP server injected via `mcp.<name> = { type: "remote", url }`
|
|
* - ASKPASS handles git auth separately (token never in subprocess env)
|
|
* - bus-event plugin (`opencodePlugin.ts`) re-emits subagent
|
|
* `message.part.updated` events that the CLI's run-loop filters out by
|
|
* `part.sessionID !== sessionID`. Plugin discovery path
|
|
* (`<XDG_CONFIG_HOME>/opencode/{plugin,plugins}/*.{ts,js}`) and
|
|
* `bus.subscribeAll()` are unchanged at v1.15.0.
|
|
*/
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import * as core from "@actions/core";
|
|
import { pullfrogMcpName } from "../external.ts";
|
|
import { BEDROCK_MODEL_ID_ENV } from "../models.ts";
|
|
import type { ToolState } from "../toolState.ts";
|
|
import { markActivity } from "../utils/activity.ts";
|
|
import { type AgentDiagnostic, formatAgentHangBody } from "../utils/agentHangReport.ts";
|
|
import { formatJsonValue, log } from "../utils/cli.ts";
|
|
import { installCodexAuth } from "../utils/codexHome.ts";
|
|
import { findProviderErrorMatch } from "../utils/providerErrors.ts";
|
|
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
|
import {
|
|
DEFAULT_MAX_RETAINED_BYTES,
|
|
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
|
SpawnTimeoutError,
|
|
spawn,
|
|
TailBuffer,
|
|
} from "../utils/subprocess.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 {
|
|
autoSelectModel,
|
|
buildReviewerAgentConfig,
|
|
geminiHighThinkingOverrides,
|
|
installOpencodeCli,
|
|
type OpenCodeConfig,
|
|
} from "./opencodeShared.ts";
|
|
import {
|
|
buildLearningsReflectionPrompt,
|
|
runPostRunRetryLoop,
|
|
shouldRunReflection,
|
|
} from "./postRun.ts";
|
|
import { REVIEWER_AGENT_NAME } from "./reviewer.ts";
|
|
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts";
|
|
import {
|
|
type AgentResult,
|
|
type AgentRunContext,
|
|
type AgentUsage,
|
|
agent,
|
|
logTokenTable,
|
|
MAX_STDERR_LINES,
|
|
} from "./shared.ts";
|
|
|
|
// v1.14+ npm package: postinstall.mjs renames the platform-specific native
|
|
// binary to `bin/opencode.exe` for every OS (incl. linux/darwin).
|
|
const installCli = () => installOpencodeCli({ binPath: "bin/opencode.exe" });
|
|
|
|
// ── config ─────────────────────────────────────────────────────────────────────
|
|
|
|
// NOTE: OpenCode's per-call `max_tokens` defaults to 32_000. We previously
|
|
// overrode this via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = 5000` in #616
|
|
// to lower OpenRouter's per-call upfront budget reservation — back when the
|
|
// `ROUTER_PER_RUN_LIMIT_USD = 25` per-run key cap meant that reservation was
|
|
// a hard gate that could lock low-balance accounts out of starting a run.
|
|
//
|
|
// That gate is gone (see `app/api/proxy-token/route.ts` ~line 422 — "Per-run
|
|
// key budget … is decoupled from wallet balance"); the router now mints
|
|
// keys with `keyLimitCents = balance + buffer` ($50 / $5 / $0). The override
|
|
// no longer materially helps, and as a hard per-call output truncation it
|
|
// actively hurt: a single `create_pull_request_review` tool_use with many
|
|
// inline comments would truncate mid-stream past 5K output tokens, the JSON
|
|
// was unparseable, and the tool never invoked. We hit this on PR #710's
|
|
// verify-downshift PR. Removed in #710 — using OpenCode's 32K default.
|
|
//
|
|
// If you need to re-cap output for some reason, set
|
|
// `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` in the action env. OpenCode's
|
|
// top-level `limit.output` config field has no read site (silently dropped
|
|
// on merge in session/llm.ts), so the env var is the only working knob.
|
|
|
|
// `geminiHighThinkingOverrides` is shared with v1 — imported above. The merge
|
|
// order in v1.15 (`base ← model.options ← agent.options ← variant`,
|
|
// `session/llm.ts:141`) means our `provider.google.models[id].options` config
|
|
// still flows through unmodified.
|
|
|
|
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
|
const config: OpenCodeConfig = {
|
|
permission: {
|
|
bash: "deny",
|
|
edit: "allow",
|
|
read: "allow",
|
|
webfetch: "allow",
|
|
external_directory: "allow",
|
|
skill: "allow",
|
|
},
|
|
mcp: {
|
|
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
|
},
|
|
agent: (() => {
|
|
const cfg = buildReviewerAgentConfig(model);
|
|
const reviewerModel = (cfg[REVIEWER_AGENT_NAME] as { model?: string })?.model ?? "(inherit)";
|
|
log.info(`» subagent models: reviewfrog=${reviewerModel}`);
|
|
return cfg;
|
|
})(),
|
|
// NB: `experimental.batch_tool: true` was opt-in at v1.4.x but is
|
|
// declared-but-inert at v1.15.0 — the schema accepts it (`config/config.ts`)
|
|
// and the SDK exposes the type, but no runtime call site reads it. removed
|
|
// here to avoid carrying dead config; re-add when upstream wires the batch
|
|
// tool back. see wiki/prompt.md and the v2 plan doc for the audit trail.
|
|
//
|
|
// gemini-3 thinking pinned to high for review depth; gpt and anthropic
|
|
// effort set elsewhere (gpt: upstream default, anthropic: --effort flag in claude.ts).
|
|
provider: { google: { models: geminiHighThinkingOverrides() } },
|
|
};
|
|
|
|
if (model) {
|
|
config.model = model;
|
|
|
|
const slashIndex = model.indexOf("/");
|
|
if (slashIndex > 0) {
|
|
config.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
|
|
}
|
|
}
|
|
|
|
return JSON.stringify(config);
|
|
}
|
|
|
|
// ── NDJSON event types ─────────────────────────────────────────────────────────
|
|
//
|
|
// Mirrors `cli/cmd/run.ts emit()` at opencode-ai v1.15.0. The CLI writes one
|
|
// envelope per call: `{ type, timestamp, sessionID, ...payload }`. Six event
|
|
// types: tool_use, step_start, step_finish, text, reasoning, error.
|
|
// `init`, `message`, `result`, and standalone `tool_result` are NOT emitted
|
|
// at v1.14+ — their handlers in v1 were dead and were dropped here.
|
|
|
|
interface OpenCodeTextEvent {
|
|
type: "text";
|
|
timestamp?: string;
|
|
sessionID?: string;
|
|
part?: { id?: string; type?: string; text?: string; [key: string]: unknown };
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeStepStartEvent {
|
|
type: "step_start";
|
|
timestamp?: string;
|
|
sessionID?: string;
|
|
part?: { id?: string; type?: string; [key: string]: unknown };
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeStepFinishEvent {
|
|
type: "step_finish";
|
|
timestamp?: string;
|
|
sessionID?: string;
|
|
part?: {
|
|
id?: string;
|
|
type?: string;
|
|
reason?: string;
|
|
cost?: number;
|
|
tokens?: {
|
|
input?: number;
|
|
output?: number;
|
|
reasoning?: number;
|
|
cache?: { read?: number; write?: number };
|
|
};
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* tool-part state, mirroring opencode's `ToolState` (anomalyco/opencode
|
|
* `session/message-v2.ts`). error parts carry the reason on `error`,
|
|
* completed parts on `output` — reading the wrong field is what caused
|
|
* the silent `(no error message)` log in #662.
|
|
*
|
|
* Named `ToolPartState` locally (not `ToolState`) so it doesn't shadow the
|
|
* action-wide `ToolState` imported above.
|
|
*/
|
|
type ToolPartState =
|
|
| { status: "pending" | "running"; input?: unknown }
|
|
| { status: "completed"; input?: unknown; output: string }
|
|
| { status: "error"; input?: unknown; error: string };
|
|
|
|
interface OpenCodeToolUseEvent {
|
|
type: "tool_use";
|
|
timestamp?: number;
|
|
sessionID?: string;
|
|
part?: {
|
|
id?: string;
|
|
callID?: string;
|
|
tool?: string;
|
|
state?: ToolPartState;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* Reasoning (thinking) part — only emitted when the CLI is invoked with
|
|
* `--thinking`. Shape mirrors `TextPart`: a finished part has
|
|
* `time?.end !== undefined`. Gemini's `thoughtSignature` round-trip,
|
|
* OpenAI reasoning, and Anthropic extended-thinking all flow through here.
|
|
*/
|
|
interface OpenCodeReasoningEvent {
|
|
type: "reasoning";
|
|
timestamp?: number;
|
|
sessionID?: string;
|
|
part?: {
|
|
id?: string;
|
|
sessionID?: string;
|
|
messageID?: string;
|
|
type?: string;
|
|
text?: string;
|
|
time?: { start?: number; end?: number };
|
|
metadata?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeErrorEvent {
|
|
type: "error";
|
|
timestamp?: string;
|
|
sessionID?: string;
|
|
// opencode emits the error message under `error.data.message`, not at the
|
|
// top level. see anomalyco/opencode packages/opencode/src/cli/cmd/run.ts.
|
|
error?: {
|
|
name?: string;
|
|
data?: { message?: string; [key: string]: unknown };
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* Envelope our injected plugin (`opencodePlugin.ts`) writes to stdout per
|
|
* non-orchestrator `message.part.updated` bus event, so subagent activity
|
|
* surfaces in the parent stream that the CLI run loop would otherwise
|
|
* filter (`cli/cmd/run.ts` checks `part.sessionID === sessionID`).
|
|
* `bus_event.properties.part` matches opencode's `Part` shape so we can
|
|
* route through the same handlers as orchestrator events.
|
|
*/
|
|
interface OpenCodeBusEnvelopeEvent {
|
|
type: "pullfrog_bus_event";
|
|
bus_event?: {
|
|
type?: string;
|
|
properties?: {
|
|
part?: {
|
|
sessionID?: string;
|
|
type?: string;
|
|
tool?: string;
|
|
callID?: string;
|
|
time?: { start?: number; end?: number };
|
|
state?: { status?: string; input?: unknown };
|
|
};
|
|
};
|
|
};
|
|
}
|
|
|
|
type OpenCodeEvent =
|
|
| OpenCodeTextEvent
|
|
| OpenCodeReasoningEvent
|
|
| OpenCodeStepStartEvent
|
|
| OpenCodeStepFinishEvent
|
|
| OpenCodeToolUseEvent
|
|
| OpenCodeErrorEvent
|
|
| OpenCodeBusEnvelopeEvent;
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────────────
|
|
|
|
/** Format `part.time` as a `(X.Ys)` suffix when both endpoints are present. */
|
|
function formatPartDuration(time: { start?: number; end?: number } | undefined): string {
|
|
if (!time || typeof time.start !== "number" || typeof time.end !== "number") return "";
|
|
if (time.end <= time.start) return "";
|
|
return ` (${((time.end - time.start) / 1000).toFixed(1)}s)`;
|
|
}
|
|
|
|
/** Extract the terminal-state payload (output on completed, error on error). */
|
|
function terminalPayload(state: ToolPartState | undefined): string | undefined {
|
|
if (!state) return undefined;
|
|
if (state.status === "completed") return state.output;
|
|
if (state.status === "error") return state.error;
|
|
return undefined;
|
|
}
|
|
|
|
// ── runner ──────────────────────────────────────────────────────────────────────
|
|
|
|
type RunParams = {
|
|
label: string;
|
|
cliPath: string;
|
|
args: string[];
|
|
cwd: string;
|
|
env: Record<string, string | undefined>;
|
|
toolState: ToolState;
|
|
todoTracker?: TodoTracker | undefined;
|
|
onActivityTimeout?: (() => void) | undefined;
|
|
onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
|
|
};
|
|
|
|
async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
|
const startTime = performance.now();
|
|
let eventCount = 0;
|
|
|
|
let finalOutput = "";
|
|
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
// per-step `part.cost` sums across the whole session. sourced from models.dev
|
|
// inside opencode — present for every supported provider (Anthropic, OpenAI,
|
|
// Google, xAI, DeepSeek, Moonshot, OpenRouter sub-providers, etc.).
|
|
let accumulatedCostUsd = 0;
|
|
let tokensLogged = false;
|
|
const toolCallTimings = new Map<string, number>();
|
|
// event-to-event silence detector for the "no activity for Xs" diagnostic.
|
|
// local to this runner so chunk-level `markActivity()` (which feeds the
|
|
// outer spawn activityTimeout) doesn't reset it on every chunk arrival.
|
|
let lastEventAt = performance.now();
|
|
// hoisted above `handlers` so closure capture is initialized before any
|
|
// handler can fire — defensive against future refactors that might invoke
|
|
// a handler synchronously during setup.
|
|
const recentStderr: string[] = [];
|
|
let lastProviderError: string | null = null;
|
|
let agentErrorEvent: OpenCodeErrorEvent | null = null;
|
|
|
|
// per-session labeler. opencode's CLI run loop filters subagent events
|
|
// (`part.sessionID !== sessionID`); our injected plugin re-emits them as
|
|
// `pullfrog_bus_event` envelopes so the labeler sees real subagent data.
|
|
const labeler = new SessionLabeler();
|
|
function eventLabel(event: Record<string, unknown>): string {
|
|
const sid = event.sessionID ?? event.session_id;
|
|
return labeler.labelFor(typeof sid === "string" ? sid : null);
|
|
}
|
|
function withLabel(label: string, message: string): string {
|
|
return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
|
|
}
|
|
|
|
// tracks per-task dispatch metadata so the matching tool_use(completed)
|
|
// can log a labeled "» subagent finished: lens=X duration=Ys" line.
|
|
//
|
|
// v2 simplification: at v1.15, opencode keeps a single stable callID across
|
|
// the whole `tool-input-* → tool-call → tool-result/tool-error` chain
|
|
// (`session/processor.ts:282-330,418,448`). The v1-era hybrid exact+FIFO
|
|
// matcher is gone; we use exact-match only.
|
|
interface TaskDispatch {
|
|
label: string;
|
|
startedAt: number;
|
|
toolUseCallID: string;
|
|
}
|
|
const taskDispatchByCallID = new Map<string, TaskDispatch>();
|
|
|
|
function emitSubagentFinished(dispatch: TaskDispatch, status: string, output: unknown) {
|
|
const subagentDuration = performance.now() - dispatch.startedAt;
|
|
const outputStr = typeof output === "string" ? output : "";
|
|
const outputPreview = outputStr.length > 120 ? `${outputStr.slice(0, 120)}…` : outputStr;
|
|
log.info(
|
|
`» subagent finished: ${dispatch.label} (${(subagentDuration / 1000).toFixed(1)}s, status=${status})` +
|
|
(outputPreview ? ` — ${outputPreview.replace(/\n/g, " ")}` : "")
|
|
);
|
|
taskDispatchByCallID.delete(dispatch.toolUseCallID);
|
|
}
|
|
|
|
function buildUsage(): AgentUsage | undefined {
|
|
const totalInput =
|
|
accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
|
|
return totalInput > 0 || accumulatedTokens.output > 0
|
|
? {
|
|
agent: "pullfrog",
|
|
inputTokens: totalInput,
|
|
outputTokens: accumulatedTokens.output,
|
|
cacheReadTokens: accumulatedTokens.cacheRead || undefined,
|
|
cacheWriteTokens: accumulatedTokens.cacheWrite || undefined,
|
|
costUsd: accumulatedCostUsd > 0 ? accumulatedCostUsd : undefined,
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
const handlers = {
|
|
text: (event: OpenCodeTextEvent) => {
|
|
if (event.part?.text?.trim()) {
|
|
const message = event.part.text.trim();
|
|
const label = eventLabel(event);
|
|
const boxTitle = label === ORCHESTRATOR_LABEL ? params.label : `${params.label} [${label}]`;
|
|
log.box(message, { title: boxTitle });
|
|
// only the orchestrator's final text is the run's "output" — children
|
|
// emit their own text on report-back, which would clobber the parent's
|
|
// final answer if we accepted any text into finalOutput.
|
|
if (label === ORCHESTRATOR_LABEL) {
|
|
finalOutput = message;
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Reasoning blocks (only emitted when `--thinking` is set in baseArgs).
|
|
* `part.time.{start,end}` give us a precise duration from opencode
|
|
* itself. Not folded into `finalOutput` — that's the final answer,
|
|
* not inner monologue.
|
|
*/
|
|
reasoning: (event: OpenCodeReasoningEvent) => {
|
|
const text = event.part?.text?.trim();
|
|
if (!text) return;
|
|
const label = eventLabel(event);
|
|
const durationStr = formatPartDuration(event.part?.time);
|
|
const preview = text.length > 280 ? `${text.slice(0, 280)}…` : text;
|
|
log.info(withLabel(label, `» thinking${durationStr}: ${preview.replace(/\n+/g, " ")}`));
|
|
if (text.length > 280) {
|
|
log.debug(withLabel(label, `» thinking (full): ${text}`));
|
|
}
|
|
},
|
|
// step_start carries no information we surface today (token / cost are
|
|
// reported on step_finish). explicit no-op so the dispatcher doesn't
|
|
// log "unhandled event" for every step.
|
|
step_start: () => {},
|
|
step_finish: (event: OpenCodeStepFinishEvent) => {
|
|
const t = event.part?.tokens;
|
|
if (t) {
|
|
accumulatedTokens.input += t.input || 0;
|
|
accumulatedTokens.output += t.output || 0;
|
|
accumulatedTokens.cacheRead += t.cache?.read || 0;
|
|
accumulatedTokens.cacheWrite += t.cache?.write || 0;
|
|
// TODO: capture `t.reasoning` once `AgentUsage` grows a
|
|
// `reasoningTokens` field. Today these tokens are silently dropped
|
|
// from the token table for reasoning-heavy models (Gemini-3 thinking
|
|
// pinned high, OpenAI o-/gpt-5-codex, Anthropic extended-thinking) —
|
|
// USD totals stay correct because `part.cost` covers them.
|
|
}
|
|
// `part.cost` is a per-step delta, not a running total. verified
|
|
// across Anthropic, OpenAI, Gemini, xAI, DeepSeek, Moonshot,
|
|
// OpenRouter sub-providers. guard NaN/Infinity so a single poison
|
|
// value can't tank the running total for the rest of the session.
|
|
if (typeof event.part?.cost === "number" && Number.isFinite(event.part.cost)) {
|
|
accumulatedCostUsd += event.part.cost;
|
|
}
|
|
},
|
|
/**
|
|
* Tool lifecycle event — at v1.15 a single event covers both completed
|
|
* and error terminal states (read `part.state.status`). Subagent tool
|
|
* parts arrive here via the bus-envelope re-emit too.
|
|
*/
|
|
tool_use: (event: OpenCodeToolUseEvent) => {
|
|
const toolName = event.part?.tool;
|
|
const toolId = event.part?.callID;
|
|
const state = event.part?.state;
|
|
if (!toolName || !toolId) {
|
|
log.info(
|
|
`» tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
|
|
);
|
|
return;
|
|
}
|
|
const status = state?.status;
|
|
const isTerminal = status === "completed" || status === "error";
|
|
const label = eventLabel(event);
|
|
|
|
// seed the labeler's pending-queue on the FIRST observation of a `task`
|
|
// dispatch, before the subagent's first message.part.updated fires.
|
|
// v1.15 keeps callID stable across the lifecycle, so dedupe is by callID.
|
|
if (toolName === "task" && !taskDispatchByCallID.has(toolId)) {
|
|
const taskInput = (state?.input ?? {}) as {
|
|
description?: string;
|
|
subagent_type?: string;
|
|
prompt?: string;
|
|
};
|
|
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
|
taskDispatchByCallID.set(toolId, {
|
|
label: dispatchedLabel,
|
|
startedAt: performance.now(),
|
|
toolUseCallID: toolId,
|
|
});
|
|
log.info(
|
|
`» dispatching subagent: ${dispatchedLabel}` +
|
|
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
|
);
|
|
}
|
|
|
|
params.onToolUse?.({ toolName, input: state?.input });
|
|
|
|
// record start time on first observation; the bus-envelope re-emit can
|
|
// route the same callID through more than once, so guard the set.
|
|
if (!toolCallTimings.has(toolId)) {
|
|
toolCallTimings.set(toolId, performance.now());
|
|
}
|
|
|
|
const inputFormatted = formatJsonValue(state?.input || {});
|
|
const callLine =
|
|
inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
|
|
log.info(withLabel(label, callLine));
|
|
|
|
if (state?.status === "completed") {
|
|
log.debug(withLabel(label, ` output: ${state.output}`));
|
|
}
|
|
if (state?.status === "error") {
|
|
log.info(withLabel(label, `» tool call failed: ${state.error}`));
|
|
}
|
|
|
|
if (isTerminal) {
|
|
const dispatch = toolName === "task" ? taskDispatchByCallID.get(toolId) : undefined;
|
|
if (dispatch) emitSubagentFinished(dispatch, status, terminalPayload(state));
|
|
|
|
const toolStartTime = toolCallTimings.get(toolId);
|
|
if (toolStartTime !== undefined) {
|
|
const toolDuration = performance.now() - toolStartTime;
|
|
toolCallTimings.delete(toolId);
|
|
if (toolDuration > 5000) {
|
|
log.info(
|
|
withLabel(
|
|
label,
|
|
`» tool call took ${(toolDuration / 1000).toFixed(1)}s - may indicate network latency`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (toolName.includes("report_progress") && params.todoTracker) {
|
|
log.debug("» report_progress detected, disabling todo tracking");
|
|
params.todoTracker.cancel();
|
|
}
|
|
|
|
// todowrite input is identical across pending/running/completed; update
|
|
// once on the terminal observation to avoid redundant work.
|
|
if (toolName === "todowrite" && params.todoTracker?.enabled && isTerminal) {
|
|
params.todoTracker.update(state?.input);
|
|
}
|
|
},
|
|
error: (event: OpenCodeErrorEvent) => {
|
|
// opencode emits a `type=error` event when a provider call fails (e.g.
|
|
// 401 Invalid authentication credentials). the underlying CLI still
|
|
// exits 0 because the error was returned cleanly by the LLM SDK, so
|
|
// unless we capture this event the run is reported as success.
|
|
agentErrorEvent = event;
|
|
const errorName = event.error?.name || "unknown";
|
|
const errorMessage = event.error?.data?.message || event.error?.name || JSON.stringify(event);
|
|
log.info(`» ${params.label} error event: ${errorName}: ${errorMessage}`);
|
|
},
|
|
/**
|
|
* Bus envelope (re-emitted by `opencodePlugin.ts`). Synthesizes a
|
|
* CLI-style event for each part type and routes it through the
|
|
* orchestrator's handlers — same labeling / attribution / logging path.
|
|
* Mirrors the dispatch in upstream's `cli/cmd/run.ts` `loop()`.
|
|
*
|
|
* NOT routed: subagent `step-start` / `step-finish`. step_finish carries
|
|
* `tokens` and `cost` that the orchestrator's handler folds into run-wide
|
|
* accumulators — double-counting subagent tokens would inflate usage
|
|
* telemetry. text/tool_use already gate on ORCHESTRATOR_LABEL inside their
|
|
* handlers for the same reason.
|
|
*/
|
|
[PULLFROG_BUS_EVENT_TYPE]: async (event: OpenCodeBusEnvelopeEvent) => {
|
|
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;
|
|
|
|
if (partType === "tool") {
|
|
const status = part.state?.status;
|
|
// Early task-dispatch announce: the CLI's NDJSON `tool_use` event for
|
|
// a `task` call only fires at status=completed (after the subagent
|
|
// finishes), but we need to bind a label to the subagent's sessionID
|
|
// BEFORE its first message.part.updated. only fire on status=running
|
|
// — at "pending", state.input is still {} and the lens label can't be
|
|
// derived. dedupe against the late tool_use handler via callID.
|
|
if (part.tool === "task" && status === "running" && part.callID) {
|
|
if (!taskDispatchByCallID.has(part.callID)) {
|
|
const taskInput = (part.state?.input ?? {}) as {
|
|
description?: string;
|
|
subagent_type?: string;
|
|
prompt?: string;
|
|
};
|
|
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
|
taskDispatchByCallID.set(part.callID, {
|
|
label: dispatchedLabel,
|
|
startedAt: performance.now(),
|
|
toolUseCallID: part.callID,
|
|
});
|
|
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;
|
|
}
|
|
if (partType === "step-start" || partType === "step-finish") return;
|
|
if (partType === "text" && part.time?.end !== undefined) {
|
|
handlers.text({ type: "text", sessionID, part } as OpenCodeTextEvent);
|
|
return;
|
|
}
|
|
if (partType === "reasoning" && part.time?.end !== undefined) {
|
|
handlers.reasoning({ type: "reasoning", sessionID, part } as OpenCodeReasoningEvent);
|
|
}
|
|
},
|
|
};
|
|
|
|
// shared with main.ts via toolState. updated in place as events stream and
|
|
// stderr accumulates so the outer activity-timeout catch sees the same
|
|
// context the harness's own catch path uses to format `result.error`.
|
|
// recentStderr is shared by reference; the scalar fields are mirrored on
|
|
// each update below.
|
|
const diagnostic: AgentDiagnostic = {
|
|
label: params.label,
|
|
recentStderr,
|
|
lastProviderError: undefined,
|
|
eventCount: 0,
|
|
};
|
|
params.toolState.agentDiagnostic = diagnostic;
|
|
|
|
// capped accumulator for the agent's narration. used as a post-run fallback
|
|
// when `finalOutput` (the orchestrator's final assistant message) is empty.
|
|
// unbounded `output += text` previously grew to ~1 GiB on multi-lens Reviews
|
|
// and contributed to the wrapper-level RangeError. retain:"none" on spawn
|
|
// skips the duplicate buffer there; this TailBuffer caps the agent layer.
|
|
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
|
|
let stdoutBuffer = "";
|
|
|
|
try {
|
|
const result = await spawn({
|
|
cmd: params.cliPath,
|
|
args: params.args,
|
|
cwd: params.cwd,
|
|
env: params.env,
|
|
activityTimeout: 300_000,
|
|
onActivityTimeout: params.onActivityTimeout,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
// node_modules/opencode-ai/bin/opencode is a Node shim that spawnSyncs
|
|
// the native opencode-<plat>-<arch> binary with stdio:"inherit". without
|
|
// a process-group kill, SIGKILL hits only the shim, the native binary
|
|
// is reparented to PID 1, holds our stdout pipe open, and `child.close`
|
|
// never fires — producing zombie runs. detached + killGroup nukes the
|
|
// whole tree.
|
|
killGroup: true,
|
|
// we already drain every chunk via onStdout/onStderr (NDJSON parsing
|
|
// + recentStderr ring buffer). retaining a second copy in the spawn
|
|
// wrapper would grow unbounded for multi-lens Reviews and previously
|
|
// crashed the wrapper with RangeError at ~1 GiB. see issue #680.
|
|
retain: "none",
|
|
// 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.append(text);
|
|
markActivity();
|
|
|
|
stdoutBuffer += text;
|
|
const lines = stdoutBuffer.split("\n");
|
|
stdoutBuffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
let event: OpenCodeEvent;
|
|
try {
|
|
event = JSON.parse(trimmed) as OpenCodeEvent;
|
|
} catch {
|
|
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
|
|
continue;
|
|
}
|
|
|
|
eventCount++;
|
|
diagnostic.eventCount = eventCount;
|
|
log.debug(JSON.stringify(event, null, 2));
|
|
|
|
// sample BEFORE the per-event marker — `lastEventAt` is local to
|
|
// this runner so it isn't reset by the chunk-level `markActivity()`
|
|
// above (which exists to feed spawn's outer activityTimeout).
|
|
// measures real event-to-event silence; the previous sampling
|
|
// against the module-level idle counter was always ~0ms because
|
|
// the chunk-level reset happened µs earlier.
|
|
const idleMs = performance.now() - lastEventAt;
|
|
if (idleMs > 10000) {
|
|
const activeToolCalls = toolCallTimings.size;
|
|
const toolCallInfo =
|
|
activeToolCalls > 0
|
|
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
|
|
: ` (${params.label} may be processing internally - LLM calls, planning, etc.)`;
|
|
log.info(
|
|
`» no activity for ${(idleMs / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
|
|
);
|
|
}
|
|
lastEventAt = performance.now();
|
|
|
|
const handler = handlers[event.type as keyof typeof handlers];
|
|
if (!handler) {
|
|
log.info(
|
|
`» ${params.label} event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
|
|
);
|
|
continue;
|
|
}
|
|
try {
|
|
await handler(event as never);
|
|
} catch (err) {
|
|
log.info(
|
|
`» ${params.label} handler for type=${event.type} threw: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
}
|
|
},
|
|
onStderr: (chunk) => {
|
|
const trimmed = chunk.trim();
|
|
if (!trimmed) return;
|
|
|
|
recentStderr.push(trimmed);
|
|
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
|
|
|
|
const match = findProviderErrorMatch(trimmed);
|
|
if (match) {
|
|
lastProviderError = match.label;
|
|
diagnostic.lastProviderError = match.label;
|
|
log.info(`» provider error detected (${match.label}): ${match.excerpt}`);
|
|
} else {
|
|
log.debug(trimmed);
|
|
}
|
|
},
|
|
});
|
|
|
|
if (result.exitCode === 0) {
|
|
await params.todoTracker?.flush();
|
|
} else {
|
|
params.todoTracker?.cancel();
|
|
}
|
|
|
|
// any task dispatches that didn't see a terminal tool_use are surfaced
|
|
// here so the gap is visible rather than silently swallowed. v2 reaches
|
|
// here much less often than v1 (callIDs are stable, so terminal tool_use
|
|
// matches the dispatch exactly), but a subagent reply that arrives via
|
|
// an assistant message rather than the task tool's terminal state can
|
|
// still leave the dispatch open. durations reported are upper bounds.
|
|
if (taskDispatchByCallID.size > 0) {
|
|
for (const dispatch of taskDispatchByCallID.values()) {
|
|
const elapsed = performance.now() - dispatch.startedAt;
|
|
log.info(
|
|
`» subagent finished (inferred at run-end): ${dispatch.label} (≤${(elapsed / 1000).toFixed(1)}s) — no terminal tool_use observed; reply likely arrived via assistant message`
|
|
);
|
|
}
|
|
taskDispatchByCallID.clear();
|
|
}
|
|
|
|
const duration = performance.now() - startTime;
|
|
log.info(
|
|
`» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`
|
|
);
|
|
|
|
if (eventCount === 0) {
|
|
const stderrContext = recentStderr.join("\n");
|
|
const diagnosis = lastProviderError
|
|
? `provider error: ${lastProviderError}`
|
|
: "unknown cause (no stdout events received)";
|
|
log.info(`» ${params.label} produced 0 events (${diagnosis})`);
|
|
if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
|
|
}
|
|
|
|
if (
|
|
!tokensLogged &&
|
|
(accumulatedTokens.input > 0 ||
|
|
accumulatedTokens.output > 0 ||
|
|
accumulatedTokens.cacheRead > 0 ||
|
|
accumulatedTokens.cacheWrite > 0)
|
|
) {
|
|
logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd });
|
|
tokensLogged = true;
|
|
}
|
|
|
|
const usage = buildUsage();
|
|
|
|
if (result.exitCode !== 0) {
|
|
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
|
// result.stdout / result.stderr are empty because we pass retain:"none"
|
|
// to spawn (see issue #680); use the agent's bounded mirrors instead.
|
|
const stdoutSnapshot = output.toString();
|
|
const stderrSnapshot = recentStderr.join("\n");
|
|
const errorMessage =
|
|
stderrSnapshot ||
|
|
stdoutSnapshot ||
|
|
`unknown error - no output from OpenCode CLI${errorContext}`;
|
|
log.error(
|
|
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
|
|
);
|
|
log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
|
|
log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
|
|
return {
|
|
success: false,
|
|
output: finalOutput || stdoutSnapshot,
|
|
error: errorMessage,
|
|
usage,
|
|
};
|
|
}
|
|
|
|
if (eventCount === 0 && lastProviderError) {
|
|
return {
|
|
success: false,
|
|
output: finalOutput || output.toString(),
|
|
error: `provider error: ${lastProviderError}`,
|
|
usage,
|
|
};
|
|
}
|
|
|
|
if (agentErrorEvent) {
|
|
const errorEvent: OpenCodeErrorEvent = agentErrorEvent;
|
|
const errorName = errorEvent.error?.name || "agent error";
|
|
const errorMessage =
|
|
errorEvent.error?.data?.message || errorEvent.error?.name || JSON.stringify(errorEvent);
|
|
return {
|
|
success: false,
|
|
output: finalOutput || output.toString(),
|
|
error: `${errorName}: ${errorMessage}`,
|
|
usage,
|
|
};
|
|
}
|
|
|
|
return { success: true, output: finalOutput || output.toString(), usage };
|
|
} catch (error) {
|
|
params.todoTracker?.cancel();
|
|
const duration = performance.now() - startTime;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const isActivityTimeout =
|
|
error instanceof SpawnTimeoutError && error.code === SPAWN_ACTIVITY_TIMEOUT_CODE;
|
|
|
|
const stderrContext = recentStderr.slice(-10).join("\n");
|
|
const diagnosis = lastProviderError
|
|
? `likely cause: ${lastProviderError}`
|
|
: eventCount === 0
|
|
? "OpenCode produced 0 stdout events - check if the model provider is reachable"
|
|
: `${eventCount} events were processed before the hang`;
|
|
|
|
log.info(
|
|
`» ${params.label} ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`
|
|
);
|
|
log.info(`» diagnosis: ${diagnosis}`);
|
|
if (stderrContext)
|
|
log.info(
|
|
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`
|
|
);
|
|
|
|
const body = formatAgentHangBody({ diagnostic, isHang: isActivityTimeout, errorMessage });
|
|
return {
|
|
success: false,
|
|
output: finalOutput || output.toString(),
|
|
error: body ?? `${errorMessage} [${diagnosis}]`,
|
|
usage: buildUsage(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── agent ───────────────────────────────────────────────────────────────────────
|
|
|
|
export const opencode = agent({
|
|
name: "opencode",
|
|
install: installCli,
|
|
run: async (ctx) => {
|
|
const cliPath = await installCli();
|
|
|
|
const rawModel = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath);
|
|
|
|
// bedrock route: opencode's `amazon-bedrock` provider expects the model
|
|
// string in `amazon-bedrock/<bedrock-id>` form. the bare AWS model ID
|
|
// (what the user puts in `BEDROCK_MODEL_ID`) needs the prefix added.
|
|
// detect via env-var sentinel — same pattern as claude.ts.
|
|
//
|
|
// we deliberately do NOT gate on `!isBedrockAnthropicId(rawModel)` here:
|
|
// Anthropic-on-Bedrock normally routes to claude-code (per `resolveAgent`),
|
|
// but `PULLFROG_AGENT=opencode` is the documented escape hatch for forcing
|
|
// opencode regardless. when that override fires, opencode still needs the
|
|
// `amazon-bedrock/` prefix or the provider lookup fails with
|
|
// "Model not found: <modelId>/.". the Anthropic-vs-other discriminant
|
|
// only belongs in `resolveAgent`.
|
|
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
|
|
const isBedrockRoute =
|
|
rawModel !== undefined && bedrockModelId !== undefined && bedrockModelId === rawModel;
|
|
const model = isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel;
|
|
|
|
const homeEnv = {
|
|
HOME: ctx.tmpdir,
|
|
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
|
|
};
|
|
|
|
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}`,
|
|
skill: "agent-browser",
|
|
env: homeEnv,
|
|
agent: "opencode",
|
|
});
|
|
|
|
installBundledSkills({ home: homeEnv.HOME });
|
|
|
|
// materialize CODEX_AUTH_JSON (Pullfrog-stored Codex subscription
|
|
// credential) into the runner's REAL $HOME/.local/share/opencode/auth.json
|
|
// so OpenCode's CodexAuthPlugin picks it up and routes openai requests
|
|
// through the ChatGPT subscription instead of needing OPENAI_API_KEY.
|
|
// see action/utils/codexHome.ts and wiki/codex-auth.md.
|
|
const codexAuth = installCodexAuth();
|
|
|
|
// base args shared between initial run and continue runs.
|
|
// `--thinking` is required at v1.14+ to surface `reasoning` NDJSON events
|
|
// — the CLI's run loop suppresses reasoning emission unless this flag is
|
|
// set (`cli/cmd/run.ts:241,671`). Without it Gemini-3 / OpenAI-reasoning /
|
|
// Anthropic-extended-thinking blocks would silently disappear from the
|
|
// log even though the model produced them.
|
|
const baseArgs = ["run", "--format", "json", "--print-logs", "--thinking"];
|
|
|
|
// OPENCODE_PERMISSION has absolute highest precedence (merged after managed/MDM configs).
|
|
// external_directory gates ALL native filesystem tools (Read, Write, Edit, Glob, Grep, etc.)
|
|
// for paths outside the project root. last-match-wins: deny everything, then allow /tmp.
|
|
// auth.json sits under real $HOME (outside /tmp/*), so deny-default protects it.
|
|
const permissionOverride = JSON.stringify({
|
|
external_directory: { "*": "deny", "/tmp/*": "allow" },
|
|
});
|
|
|
|
const repoDir = process.cwd();
|
|
|
|
// CRITICAL: opencode-ai >=1.14 reads `process.env.PWD` first when
|
|
// resolving the SDK client's `directory` parameter (see upstream
|
|
// `cli/cmd/run.ts:282` — `Filesystem.resolve(process.env.PWD ?? process.cwd())`).
|
|
// We pass `cwd: repoDir` to spawn but the child inherits the harness's
|
|
// PWD via `...process.env`, which (when running through the test runner /
|
|
// GHA wrapper) is a different directory. Without overriding PWD, opencode
|
|
// creates *two* instances — one at `process.cwd()` (correct) and one at
|
|
// `PWD` (wrong) — and the agent's session runs in the wrong-cwd one,
|
|
// missing the project's `.opencode/skills/` and `.claude/skills/`.
|
|
const env: Record<string, string | undefined> = {
|
|
...process.env,
|
|
...homeEnv,
|
|
PWD: repoDir,
|
|
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
|
|
OPENCODE_PERMISSION: permissionOverride,
|
|
GOOGLE_GENERATIVE_AI_API_KEY:
|
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
|
};
|
|
|
|
if (codexAuth) {
|
|
// point OpenCode at the real-home XDG dir so it reads auth.json from
|
|
// where we wrote it (not the tmpdir-redirected default).
|
|
env.XDG_DATA_HOME = codexAuth.xdgDataHome;
|
|
// remove OPENAI_API_KEY so OpenCode's provider merge unambiguously
|
|
// picks the OAuth path. with both set, the merge order in opencode
|
|
// makes the effective key ambiguous.
|
|
delete env.OPENAI_API_KEY;
|
|
// hand the post-hook everything it needs to detect + persist refresh.
|
|
// post-hook runs in a fresh node process, so we have to ferry apiToken
|
|
// explicitly — env is preserved across main/post but our run-context
|
|
// JWT is computed at runtime and not put in env. see action/entryPost.ts.
|
|
core.saveState(
|
|
"codex_writeback",
|
|
JSON.stringify({
|
|
apiToken: ctx.apiToken,
|
|
authPath: codexAuth.authPath,
|
|
originalRefresh: codexAuth.originalRefresh,
|
|
})
|
|
);
|
|
}
|
|
|
|
log.debug(`» starting Pullfrog (OpenCode): ${cliPath} ${baseArgs.join(" ")}`);
|
|
log.debug(`» working directory: ${repoDir}`);
|
|
|
|
const runParams = {
|
|
label: "Pullfrog",
|
|
cliPath,
|
|
cwd: repoDir,
|
|
env,
|
|
toolState: ctx.toolState,
|
|
todoTracker: ctx.todoTracker,
|
|
onActivityTimeout: ctx.onActivityTimeout,
|
|
onToolUse: ctx.onToolUse,
|
|
};
|
|
|
|
const result = await runOpenCode({
|
|
...runParams,
|
|
args: [...baseArgs, ctx.instructions.full],
|
|
});
|
|
|
|
// post-run retry loop aggregates usage across the initial run + every
|
|
// resume, so the caller sees the whole session — not just the final
|
|
// slice. opencode always accepts `--continue`, so no canResume guard.
|
|
// the reflection prompt fires once after gates go clean, as a dedicated
|
|
// turn that nudges the agent to persist learnings.
|
|
return runPostRunRetryLoop({
|
|
ctx,
|
|
initialResult: result,
|
|
initialUsage: result.usage,
|
|
reflectionPrompt:
|
|
ctx.toolState.learningsFilePath && shouldRunReflection(ctx.toolState.selectedMode)
|
|
? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
|
|
: undefined,
|
|
resume: async (c) =>
|
|
runOpenCode({
|
|
...runParams,
|
|
args: [...baseArgs, "--continue", c.prompt],
|
|
}),
|
|
});
|
|
},
|
|
});
|