From a0576a702a0709e2c164819876249ae202cb6e97 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 20 May 2026 04:05:16 +0000 Subject: [PATCH] opencode v2: harness adapted to opencode-ai 1.15+ SDK-v2 / Effect-ts CLI rewrite (#767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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= 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: ` + `» thought for 4.0s` (separate) Output now: `» thinking (4.0s): ` (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 --- .github/workflows/test.yml | 10 + Dockerfile | 10 +- agents/claude.ts | 13 +- agents/index.ts | 5 +- agents/opencode.ts | 143 +--- agents/opencodeShared.ts | 144 ++++ agents/opencode_v2.ts | 1047 ++++++++++++++++++++++++ agents/subagentRegistration.test.ts | 15 +- commands/auth.ts | 95 ++- docker-entrypoint.sh | 4 +- gha.ts => docker.ts | 86 +- entryPost.ts | 25 +- package.json | 3 +- play.ts | 20 +- pnpm-lock.yaml | 109 +-- test/ci.test.ts | 7 +- test/coverage.ts | 4 +- test/crossagent/codexAuth.ts | 110 +++ test/crossagent/mcpmerge.ts | 2 +- test/crossagent/nobash.ts | 2 +- test/crossagent/restricted.ts | 2 +- test/crossagent/skillInvokeOpencode.ts | 6 +- test/crossagent/smoke.ts | 13 +- test/crossagent/tokenExfil.ts | 2 +- test/model-smoke.ts | 4 +- test/providers.ts | 1 + test/run.ts | 44 +- test/utils.ts | 27 +- utils/codexHome.ts | 13 +- utils/runFixture.ts | 4 +- 30 files changed, 1658 insertions(+), 312 deletions(-) create mode 100644 agents/opencodeShared.ts create mode 100644 agents/opencode_v2.ts rename gha.ts => docker.ts (83%) create mode 100644 test/crossagent/codexAuth.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90da6c5..eeb2c25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,7 @@ jobs: agent: [claude, opencode] test: [ + codex-auth, mcpmerge, nobash, restricted, @@ -41,6 +42,8 @@ jobs: exclude: - agent: claude test: skill-invoke-opencode + - agent: claude + test: codex-auth - agent: opencode test: skill-invoke-claude env: @@ -59,6 +62,13 @@ jobs: AWS_REGION: us-east-1 BEDROCK_MODEL_ID: us.anthropic.claude-opus-4-6-v1 PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }} + # CI smoke-testing shortcut only — production stores this in Pullfrog's + # per-org secret store (Postgres), set via `pullfrog auth codex`. GH + # Actions secrets are immutable at runtime so the post-hook can't write + # back the rotated refresh token; CI accepts the staleness and we + # manually re-provision when smoke tests start failing. Do not copy this + # pattern into user-facing workflows. See wiki/codex-auth.md. + CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 diff --git a/Dockerfile b/Dockerfile index 07f6c8a..007232f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ # pullfrog GHA-like test container. # -# baked once at image build time, used by `pnpm gha`. all runtime cost -# (apt-get, useradd, sudoers wiring) is paid here so each `gha` invocation +# baked once at image build time, used by `pnpm docker`. all runtime cost +# (apt-get, useradd, sudoers wiring) is paid here so each `docker` invocation # is a single `docker run` with no in-container setup. # -# rebuild is content-hash gated by gha.ts (Dockerfile + docker-entrypoint.sh). -# bump anything in this file or the entrypoint and the next `pnpm gha` rebuilds. +# rebuild is content-hash gated by docker.ts (Dockerfile + docker-entrypoint.sh). +# bump anything in this file or the entrypoint and the next `pnpm docker` rebuilds. FROM ubuntu:24.04 @@ -59,7 +59,7 @@ RUN userdel -r ubuntu 2>/dev/null || true \ && echo "testuser ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/testuser \ && chmod 0440 /etc/sudoers.d/testuser -# layout matching the bind mount + named volume targets in gha.ts. +# layout matching the bind mount + named volume targets in docker.ts. RUN mkdir -p /app/action /app/action/node_modules /tmp/home/.config /tmp/home/.cache \ && chown -R testuser:testuser /app /tmp/home diff --git a/agents/claude.ts b/agents/claude.ts index 9189cfb..ef10b4e 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -949,9 +949,20 @@ export const claude = agent({ // bedrock run; if the user has set the env var manually for some other // reason (e.g. always-Bedrock org policy), `...process.env` already // carries it through and we don't disturb it. + const repoDir = process.cwd(); + + // PWD must match the spawn cwd (see opencode_v2.ts for the analogous fix). + // 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"})`). Inheriting harness PWD via + // `...process.env` ends up adding the wrong dir to the agent's allowed + // working set under `pnpm runtest` / `pnpm play`, which silently confuses + // path-relative tools. const env: Record = { ...process.env, ...homeEnv, + PWD: repoDir, }; if (isBedrockRoute) { env.CLAUDE_CODE_USE_BEDROCK = "1"; @@ -967,8 +978,6 @@ export const claude = agent({ delete env.ANTHROPIC_API_KEY; } - const repoDir = process.cwd(); - log.info(`» effort: ${effort}`); log.debug(`» starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`); log.debug(`» working directory: ${repoDir}`); diff --git a/agents/index.ts b/agents/index.ts index 6828814..a195727 100644 --- a/agents/index.ts +++ b/agents/index.ts @@ -1,5 +1,8 @@ import { claude } from "./claude.ts"; -import { opencode } from "./opencode.ts"; +// v2 harness — adapted to opencode-ai >=1.14.x SDK-v2 / Effect-ts CLI rewrite. +// The legacy v1 module (`./opencode.ts`) is kept around for reference + fast +// revert; the active runner is the v2 module below. +import { opencode } from "./opencode_v2.ts"; import type { Agent } from "./shared.ts"; export type { Agent, AgentUsage } from "./shared.ts"; diff --git a/agents/opencode.ts b/agents/opencode.ts index 22a36dd..36fddb2 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -11,13 +11,12 @@ * the agent process itself gets full env (needs LLM API keys, PATH, etc.). * security is enforced at the tool layer, not the process layer. */ -import { execFileSync } from "node:child_process"; 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, modelAliases } from "../models.ts"; +import { BEDROCK_MODEL_ID_ENV } from "../models.ts"; import type { ToolState } from "../toolState.ts"; import { getIdleMs, @@ -29,7 +28,6 @@ import { import { type AgentDiagnostic, formatAgentHangBody } from "../utils/agentHangReport.ts"; import { formatJsonValue, log } from "../utils/cli.ts"; import { installCodexAuth } from "../utils/codexHome.ts"; -import { installFromNpmTarball } from "../utils/install.ts"; import { findProviderErrorMatch } from "../utils/providerErrors.ts"; import { addSkill, installBundledSkills } from "../utils/skills.ts"; import { @@ -47,12 +45,19 @@ import { 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, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts"; +import { REVIEWER_AGENT_NAME } from "./reviewer.ts"; import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts"; import { type AgentResult, @@ -62,29 +67,13 @@ import { logTokenTable, MAX_STDERR_LINES, } from "./shared.ts"; -import { deriveSubagentModels } from "./subagentModels.ts"; -async function installOpencodeCli(): Promise { - return await installFromNpmTarball({ - packageName: "opencode-ai", - version: getDevDependencyVersion("opencode-ai"), - executablePath: "bin/opencode", - installDependencies: true, - }); -} +// re-export for the existing test (`./opencode.test.ts`) — once v1 is +// retired this module collapses and the test imports from opencodeShared. +export { geminiHighThinkingOverrides } from "./opencodeShared.ts"; -// ── config ───────────────────────────────────────────────────────────────────── - -type OpenCodeConfig = { - mcp?: Record; - permission?: Record; - provider?: Record; - agent?: Record; - experimental?: Record; - model?: string; - enabled_providers?: string[]; - [key: string]: unknown; -}; +// v1.4-era npm package shipped a per-platform binary directly at this path. +const installCli = () => installOpencodeCli({ binPath: "bin/opencode" }); // NOTE: OpenCode's per-call `max_tokens` defaults to 32_000. We previously // overrode this via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = 5000` in #616 @@ -106,22 +95,6 @@ type OpenCodeConfig = { // 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. -/** - * Build the `provider.google.models[id].options` map that pins every direct-Google - * Gemini alias to `thinkingLevel: "high"`. Sourced from the model registry so - * adding/renaming a Google alias in `action/models.ts` flows through automatically. - */ -export function geminiHighThinkingOverrides(): Record { - return Object.fromEntries( - modelAliases - .filter((a) => a.provider === "google") - .map((a) => [ - a.resolve.replace(/^google\//, ""), - { options: { thinkingConfig: { thinkingLevel: "high" } } }, - ]) - ); -} - function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string { const config: OpenCodeConfig = { permission: { @@ -171,90 +144,6 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s return JSON.stringify(config); } -/** - * Read-only `reviewfrog` subagent for lens-based review. - * - * Non-mutative + non-recursive — enforced by the prose system prompt in - * reviewer.ts. - * - * Per-subagent `model:` override is driven by the registry in - * `action/models.ts` via each alias's `subagentModel` field — see - * `deriveSubagentModels` for the reverse-lookup. Currently wired: - * Anthropic opus → sonnet, OpenAI gpt-pro → gpt and gpt → gpt-5.4, - * Google gemini-pro → gemini-flash. Other providers (xai, deepseek, - * moonshot) and already-cheap tiers inherit (no override) — either the - * absolute savings are too small to justify or there's no clean - * cheaper-but-capable sibling. - */ -function buildReviewerAgentConfig(orchestratorModel: string | undefined): Record { - const overrides = deriveSubagentModels(orchestratorModel); - return { - [REVIEWER_AGENT_NAME]: { - description: - "Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " + - "Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.", - mode: "subagent", - prompt: REVIEWER_SYSTEM_PROMPT, - ...(overrides.reviewer !== undefined ? { model: overrides.reviewer } : {}), - }, - }; -} - -// ── model auto-select fallback ────────────────────────────────────────────────── -// -// steps 1–2 of model resolution (PULLFROG_MODEL env, slug resolution) are handled -// by resolveModel() in utils/agent.ts before the agent runs. this fallback only -// handles step 3: auto-select via `opencode models`. - -function getOpenCodeModels(cliPath: string): string[] { - try { - const output = execFileSync(cliPath, ["models"], { - encoding: "utf-8", - timeout: 30_000, - env: process.env, - }); - return output - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - } catch (error) { - log.debug( - `» failed to run \`opencode models\`: ${error instanceof Error ? error.message : String(error)}` - ); - return []; - } -} - -const AUTO_SELECT_WARNING = - "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this."; - -function autoSelectModel(cliPath: string): string | undefined { - const availableModels = getOpenCodeModels(cliPath); - const availableSet = new Set(availableModels); - if (availableSet.size > 0) { - log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`); - // skip hidden aliases (internal subagent-tier targets like opencode/gpt-5.4) — - // they should never surface as a user-facing orchestrator pick. mirrors the - // selectable-list filter in components/ModelSelector.tsx and action/commands/init.ts. - const match = - modelAliases.find((a) => !a.hidden && a.preferred && availableSet.has(a.resolve)) ?? - modelAliases.find((a) => !a.hidden && availableSet.has(a.resolve)); - if (match) { - log.info( - `» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)` - ); - log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`); - return match.resolve; - } - log.info( - `» opencode has ${availableSet.size} models but none match curated aliases — letting OpenCode auto-select` - ); - } - - log.warning(`» no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`); - return undefined; -} - // ── NDJSON event types ───────────────────────────────────────────────────────── interface OpenCodeInitEvent { @@ -1220,9 +1109,9 @@ async function runOpenCode(params: RunParams): Promise { export const opencode = agent({ name: "opencode", - install: installOpencodeCli, + install: installCli, run: async (ctx) => { - const cliPath = await installOpencodeCli(); + const cliPath = await installCli(); const rawModel = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath); diff --git a/agents/opencodeShared.ts b/agents/opencodeShared.ts new file mode 100644 index 0000000..d711caf --- /dev/null +++ b/agents/opencodeShared.ts @@ -0,0 +1,144 @@ +// Shared helpers for the OpenCode agent harnesses (`./opencode.ts` v1 and +// `./opencode_v2.ts` v2). Pure config / model-registry / install glue — +// nothing here touches the NDJSON event loop, which differs between v1 and v2. +// +// Once v1 is deleted post-burn-in this module collapses back into v2; until +// then it keeps both runners synchronized so a config drift can't make v1 a +// silently-broken fallback. + +import { execFileSync } from "node:child_process"; +import { modelAliases } from "../models.ts"; +import { log } from "../utils/cli.ts"; +import { installFromNpmTarball } from "../utils/install.ts"; +import { getDevDependencyVersion } from "../utils/version.ts"; +import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts"; +import { deriveSubagentModels } from "./subagentModels.ts"; + +// ── config ───────────────────────────────────────────────────────────────────── + +export type OpenCodeConfig = { + mcp?: Record; + permission?: Record; + provider?: Record; + agent?: Record; + experimental?: Record; + model?: string; + enabled_providers?: string[]; + [key: string]: unknown; +}; + +/** + * Build the `provider.google.models[id].options` map that pins every direct-Google + * Gemini alias to `thinkingLevel: "high"`. Sourced from the model registry so + * adding/renaming a Google alias in `action/models.ts` flows through automatically. + */ +export function geminiHighThinkingOverrides(): Record { + return Object.fromEntries( + modelAliases + .filter((a) => a.provider === "google") + .map((a) => [ + a.resolve.replace(/^google\//, ""), + { options: { thinkingConfig: { thinkingLevel: "high" } } }, + ]) + ); +} + +/** + * Read-only `reviewfrog` subagent for lens-based review. Non-mutative + + * non-recursive — enforced by the system prompt in reviewer.ts. + * + * Per-subagent `model:` override is driven by the registry in + * `action/models.ts` via each alias's `subagentModel` field. Currently wired: + * Anthropic opus → sonnet, OpenAI gpt-pro → gpt and gpt → gpt-5.4, Google + * gemini-pro → gemini-flash. Other providers inherit (no override). + */ +export function buildReviewerAgentConfig( + orchestratorModel: string | undefined +): Record { + const overrides = deriveSubagentModels(orchestratorModel); + return { + [REVIEWER_AGENT_NAME]: { + description: + "Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " + + "Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.", + mode: "subagent", + prompt: REVIEWER_SYSTEM_PROMPT, + ...(overrides.reviewer !== undefined ? { model: overrides.reviewer } : {}), + }, + }; +} + +// ── install ──────────────────────────────────────────────────────────────────── + +/** + * Install the opencode-ai npm tarball and return the path to the executable. + * + * The bin path differs by version: v1.4.x and earlier shipped `bin/opencode`; + * v1.14+ renames the platform-specific binary to `bin/opencode.exe` for every + * OS via the postinstall script. Callers pass the binPath that matches their + * pinned version so a v1↔v2 swap can't silently install the wrong file. + */ +export async function installOpencodeCli(params: { binPath: string }): Promise { + return await installFromNpmTarball({ + packageName: "opencode-ai", + version: getDevDependencyVersion("opencode-ai"), + executablePath: params.binPath, + installDependencies: true, + }); +} + +// ── model auto-select fallback ────────────────────────────────────────────────── +// +// steps 1–2 of model resolution (PULLFROG_MODEL env, slug resolution) happen +// in resolveModel() in utils/agent.ts before the agent runs. this is step 3: +// auto-select via `opencode models`. + +const AUTO_SELECT_WARNING = + "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this."; + +function getOpenCodeModels(cliPath: string): string[] { + try { + const output = execFileSync(cliPath, ["models"], { + encoding: "utf-8", + timeout: 30_000, + env: process.env, + }); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + } catch (error) { + log.debug( + `» failed to run \`opencode models\`: ${error instanceof Error ? error.message : String(error)}` + ); + return []; + } +} + +export function autoSelectModel(cliPath: string): string | undefined { + const availableModels = getOpenCodeModels(cliPath); + const availableSet = new Set(availableModels); + if (availableSet.size > 0) { + log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`); + // skip hidden aliases (internal subagent-tier targets like + // opencode/gpt-5.4) — they should never surface as a user-facing + // orchestrator pick. mirrors the selectable-list filter in + // components/ModelSelector.tsx and action/commands/init.ts. + const match = + modelAliases.find((a) => !a.hidden && a.preferred && availableSet.has(a.resolve)) ?? + modelAliases.find((a) => !a.hidden && availableSet.has(a.resolve)); + if (match) { + log.info( + `» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)` + ); + log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`); + return match.resolve; + } + log.info( + `» opencode has ${availableSet.size} models but none match curated aliases — letting OpenCode auto-select` + ); + } + + log.warning(`» no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`); + return undefined; +} diff --git a/agents/opencode_v2.ts b/agents/opencode_v2.ts new file mode 100644 index 0000000..1255b22 --- /dev/null +++ b/agents/opencode_v2.ts @@ -0,0 +1,1047 @@ +/** + * 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. = { 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 + * (`/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; + [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; + toolState: ToolState; + todoTracker?: TodoTracker | undefined; + onActivityTimeout?: (() => void) | undefined; + onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined; +}; + +async function runOpenCode(params: RunParams): Promise { + 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(); + // 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 { + 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(); + + 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-- 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/` 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: /.". 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 `/{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 = { + ...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], + }), + }); + }, +}); diff --git a/agents/subagentRegistration.test.ts b/agents/subagentRegistration.test.ts index 18c5348..b33a897 100644 --- a/agents/subagentRegistration.test.ts +++ b/agents/subagentRegistration.test.ts @@ -3,7 +3,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const claudeSource = readFileSync(join(__dirname, "claude.ts"), "utf-8"); -const opencodeSource = readFileSync(join(__dirname, "opencode.ts"), "utf-8"); +const opencodeSharedSource = readFileSync(join(__dirname, "opencodeShared.ts"), "utf-8"); +const opencodeV2Source = readFileSync(join(__dirname, "opencode_v2.ts"), "utf-8"); /** * The Claude Code `--agents` JSON and OpenCode `agent` config block are the @@ -25,16 +26,16 @@ describe("subagent registration source asserts", () => { }); }); - describe("opencode.ts buildReviewerAgentConfig", () => { + describe("opencodeShared.ts buildReviewerAgentConfig", () => { it("registers reviewfrog with mode: subagent", () => { - expect(opencodeSource).toMatch(/\[REVIEWER_AGENT_NAME\]:[^}]*mode:\s*"subagent"/s); + expect(opencodeSharedSource).toMatch(/\[REVIEWER_AGENT_NAME\]:[^}]*mode:\s*"subagent"/s); }); it("uses deriveSubagentModels for the reviewer model override", () => { - expect(opencodeSource).toMatch(/deriveSubagentModels\(/); - expect(opencodeSource).toMatch(/overrides\.reviewer/); + expect(opencodeSharedSource).toMatch(/deriveSubagentModels\(/); + expect(opencodeSharedSource).toMatch(/overrides\.reviewer/); }); - it("passes orchestrator model to buildReviewerAgentConfig", () => { - expect(opencodeSource).toMatch(/buildReviewerAgentConfig\(model\)/); + it("v2 runner passes orchestrator model to buildReviewerAgentConfig", () => { + expect(opencodeV2Source).toMatch(/buildReviewerAgentConfig\(model\)/); }); }); }); diff --git a/commands/auth.ts b/commands/auth.ts index 6d70df1..2a1ab52 100644 --- a/commands/auth.ts +++ b/commands/auth.ts @@ -10,6 +10,7 @@ // secrets API. used both for first-time setup of a Codex subscription on a // repo and for rotating a stale credential. +import { spawn } from "node:child_process"; import * as p from "@clack/prompts"; import arg from "arg"; import pc from "picocolors"; @@ -22,7 +23,6 @@ import { PULLFROG_API_URL, parseGitRemote, promptScope, - type SecretScope, setActiveSpin, setPullfrogSecret, } from "./_shared.ts"; @@ -38,6 +38,43 @@ function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); } +/** matches the Codex device-auth verification URL printed by `codex login + * --device-auth`. captures the full URL (with query string) up to whitespace. + */ +const CODEX_DEVICE_URL_RE = /https:\/\/auth\.openai\.com\/codex\/device\S*/; + +/** best-effort cross-platform "open URL in default browser". swallows + * spawn errors and non-zero exits — the user can always copy-paste the URL + * Codex already printed. on Linux, falls back to `wslview` when `xdg-open` + * is missing (covers WSL where xdg-open isn't installed by default). + */ +function openInBrowser(url: string): void { + const platform = process.platform; + let cmd: string; + let args: string[]; + if (platform === "darwin") { + cmd = "open"; + args = [url]; + } else if (platform === "win32") { + // `start` is a cmd.exe builtin. the empty "" is the window title + // (required when the next argument is quoted, which happens for + // URLs with `&`). + cmd = "cmd.exe"; + args = ["/c", "start", "", url]; + } else { + cmd = "xdg-open"; + args = [url]; + } + const child = spawn(cmd, args, { stdio: "ignore", detached: true }); + child.on("error", () => { + if (platform !== "linux") return; + const fallback = spawn("wslview", [url], { stdio: "ignore", detached: true }); + fallback.on("error", () => {}); + fallback.unref(); + }); + child.unref(); +} + interface AuthCliParams { args: string[]; prog: string; @@ -60,11 +97,7 @@ function printCodexUsage(params: { stream: typeof console.log; prog: string }): params.stream("mint a Codex subscription credential and save it as CODEX_AUTH_JSON."); params.stream(""); params.stream("options:"); - params.stream(" --scope where to store the secret in Pullfrog. on"); - params.stream(" org-owned repos you're prompted to choose"); - params.stream(" interactively; user-owned repos always use"); - params.stream(" `account`. pass this flag to skip the prompt."); - params.stream(" -h, --help show help"); + params.stream(" -h, --help show help"); } export async function runCli(params: AuthCliParams): Promise { @@ -103,7 +136,6 @@ function parseCodexArgs(args: string[]) { return arg( { "--help": Boolean, - "--scope": String, "-h": "--help", }, { argv: args } @@ -126,26 +158,10 @@ async function runCodex(params: CodexCliParams): Promise { return; } - const rawScope = parsed["--scope"]; - let explicitScope: SecretScope | null = null; - if (rawScope !== undefined) { - if (rawScope === "account" || rawScope === "repo") { - explicitScope = rawScope; - } else { - console.error(`invalid --scope: ${rawScope} (must be "account" or "repo")\n`); - printCodexUsage({ stream: console.error, prog: params.prog }); - process.exit(1); - } - } - - await runCodexAuth({ explicitScope }); + await runCodexAuth(); } -interface RunCodexAuthCtx { - explicitScope: SecretScope | null; -} - -async function runCodexAuth(ctx: RunCodexAuthCtx): Promise { +async function runCodexAuth(): Promise { p.intro(pc.bgGreen(pc.black(" pullfrog auth codex "))); const spin = p.spinner(); @@ -188,16 +204,10 @@ async function runCodexAuth(ctx: RunCodexAuthCtx): Promise { // user-owned repos can only ever be "account" (Pullfrog has no per-repo // store for user accounts), so we never bother prompting. on org-owned - // repos, default to interactive prompt — matches `init`'s behavior — - // unless the caller passed `--scope` to skip it. - let scope: SecretScope; - if (ctx.explicitScope) { - scope = ctx.explicitScope; - } else if (status.isOrg) { - scope = await promptScope({ owner: remote.owner, repo: remote.repo }); - } else { - scope = "account"; - } + // repos, prompt interactively — matches `init`'s behavior. + const scope = status.isOrg + ? await promptScope({ owner: remote.owner, repo: remote.repo }) + : "account"; p.log.info( [ @@ -213,6 +223,9 @@ async function runCodexAuth(ctx: RunCodexAuthCtx): Promise { // tracks the most recent exit so the retry prompt can tell the user // *why* no auth.json was written (timeout vs. early-exit). let lastTimedOut = false; + // gate so we don't re-launch the browser if Codex prints the URL + // more than once (e.g. on a retry attempt within the same flow). + let hasOpenedDeviceUrl = false; const auth = await mintCodexAuth({ childStdio: "pipe", onChildLine: (line) => { @@ -220,7 +233,17 @@ async function runCodexAuth(ctx: RunCodexAuthCtx): Promise { // gray) so the user reads it as sub-process noise, not Pullfrog's // own prompts. the rail char matches @clack/prompts so the column // reads as one continuous flow. - process.stdout.write(`${pc.gray(p.S_BAR)} ${pc.dim(stripAnsi(line))}\n`); + const stripped = stripAnsi(line); + process.stdout.write(`${pc.gray(p.S_BAR)} ${pc.dim(stripped)}\n`); + if (hasOpenedDeviceUrl) return; + const match = stripped.match(CODEX_DEVICE_URL_RE); + if (!match) return; + hasOpenedDeviceUrl = true; + const url = match[0]; + openInBrowser(url); + process.stdout.write( + `${pc.gray(p.S_BAR)} ${pc.dim(`» opened ${url} in browser (paste manually if it didn't open)`)}\n` + ); }, onProgress: (event) => { if (event.kind === "start") { diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2e97063..bbc66e9 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -44,8 +44,8 @@ fi # this idempotent and fast (~1.5s when nothing changed). # # the lockfile lives IN the shared node_modules volume so concurrent -# `pnpm gha` invocations (e.g. `pnpm play` in one terminal and -# `pnpm runtest` in another) serialize their install instead of racing. +# `pnpm docker` invocations (e.g. `pnpm play:docker` in one terminal and +# `pnpm runtest:docker` in another) serialize their install instead of racing. # `flock -w 120` waits up to 2min before giving up — well under any # real-world install time but short enough to surface true deadlocks. mkdir -p /app/action/node_modules diff --git a/gha.ts b/docker.ts similarity index 83% rename from gha.ts rename to docker.ts index f7f5f09..cf4317f 100644 --- a/gha.ts +++ b/docker.ts @@ -1,11 +1,21 @@ -// run any node script inside the pullfrog GHA-like container. +// run any node script inside the pullfrog local docker container that +// mocks the GHA `ubuntu-24.04` runner environment. NOT a real GitHub +// Actions runner — for the real thing, see `.github/workflows/*.yml` +// and `action/commands/gha.ts` (the action's GHA entry point). // // usage: -// pnpm gha