diff --git a/.gitea/workflows/shockbot.yml b/.gitea/workflows/shockbot.yml new file mode 100644 index 0000000..29dd59a --- /dev/null +++ b/.gitea/workflows/shockbot.yml @@ -0,0 +1,45 @@ +name: Shockbot +on: + pull_request: + types: [opened, synchronize, ready_for_review] + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + review: + if: | + (github.event_name == 'pull_request' && !github.event.pull_request.draft) || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@shockbot')) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run shockbot (PR trigger) + if: github.event_name == 'pull_request' + uses: ./ + with: + prompt: "Review PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}" + env: + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }} + GITEA_URL: https://git.shockvpn.com + GITHUB_REPOSITORY: ${{ github.repository }} + + - name: Run shockbot (mention trigger) + if: github.event_name == 'issue_comment' + uses: ./ + with: + prompt: ${{ github.event.comment.body }} + env: + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }} + GITEA_URL: https://git.shockvpn.com + GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index ddb520f..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Publish & Release - -on: - push: - branches: - - main - paths: - - "package.json" - workflow_dispatch: - -permissions: - contents: write - id-token: write - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "pnpm" - registry-url: "https://registry.npmjs.org" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - name: Get package version - id: version - run: | - VERSION=$(npm pkg get version | tr -d '"') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=v$VERSION" >> $GITHUB_OUTPUT - - # Extract major version (e.g., "0" from "0.0.1") - MAJOR_VERSION=$(echo $VERSION | cut -d. -f1) - echo "major_tag=v$MAJOR_VERSION" >> $GITHUB_OUTPUT - - echo "๐Ÿ“ฆ Package version: $VERSION" - - - name: Check if tag already exists - id: check_tag - run: | - if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "โš ๏ธ Tag ${{ steps.version.outputs.tag }} already exists - skipping release" - else - echo "exists=false" >> $GITHUB_OUTPUT - echo "โœ… Tag ${{ steps.version.outputs.tag }} does not exist - will create release" - fi - - - name: Create and push tags - if: steps.check_tag.outputs.exists == 'false' - run: | - # Create specific version tag - git tag ${{ steps.version.outputs.tag }} - git push origin ${{ steps.version.outputs.tag }} - - # Create/update major version tag (moving tag) - git tag -f ${{ steps.version.outputs.major_tag }} - git push origin ${{ steps.version.outputs.major_tag }} --force - - echo "๐Ÿท๏ธ Created tags: ${{ steps.version.outputs.tag }} and ${{ steps.version.outputs.major_tag }}" - - - name: Create GitHub Release - if: steps.check_tag.outputs.exists == 'false' - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.version.outputs.tag }} - release_name: "${{ steps.version.outputs.tag }}" - body: | - ## ๐Ÿ“ฆ pullfrog ${{ steps.version.outputs.version }} - - ### Usage in GitHub Actions - - ```yaml - - uses: pullfrog/pullfrog@${{ steps.version.outputs.major_tag }} - ``` - - ### Installation via npm - - ```bash - npm install pullfrog@${{ steps.version.outputs.version }} - ``` - draft: false - prerelease: false - - - name: Publish to npm - if: steps.check_tag.outputs.exists == 'false' - run: npm publish --provenance --access public - - - name: Summary - if: always() - run: | - echo "## ๐Ÿ“Š Publish Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [[ "${{ steps.check_tag.outputs.exists }}" == "true" ]]; then - echo "โš ๏ธ Version ${{ steps.version.outputs.version }} already exists - no action taken" >> $GITHUB_STEP_SUMMARY - else - echo "โœ… Successfully published version ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ๐Ÿท๏ธ Tags Created" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ steps.version.outputs.tag }}\` (specific version)" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ steps.version.outputs.major_tag }}\` (major version, auto-updating)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ๐Ÿ“ฆ Published to" >> $GITHUB_STEP_SUMMARY - echo "- GitHub Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY - echo "- npm Registry: [pullfrog@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/pullfrog/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml deleted file mode 100644 index 079fac6..0000000 --- a/.github/workflows/pullfrog.yml +++ /dev/null @@ -1,45 +0,0 @@ -# PULLFROG ACTION โ€” DO NOT EDIT EXCEPT WHERE INDICATED -name: Pullfrog -run-name: ${{ inputs.name || github.workflow }} -on: - workflow_dispatch: - inputs: - prompt: - type: string - description: Agent prompt - name: - type: string - description: Run name - -permissions: - contents: read - -jobs: - pullfrog: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - name: Run agent - uses: pullfrog/pullfrog@main - with: - prompt: ${{ inputs.prompt }} - env: - API_URL: ${{ secrets.API_URL }} - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - # add any additional keys your agent(s) need - # optionally, comment out any you won't use - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} - GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} - DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} diff --git a/.github/workflows/test-token.yml b/.github/workflows/test-token.yml deleted file mode 100644 index 3f6d505..0000000 --- a/.github/workflows/test-token.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Test get-installation-token - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - id-token: write - contents: read - -jobs: - test-token: - # only run in the upstream publish target. forks inherit this file but - # haven't installed the pullfrog github app โ€” running it there 404s our - # token endpoint and pollutes our error logs (see #693). - if: github.repository == 'pullfrog/pullfrog' - runs-on: ubuntu-latest - steps: - - name: Get installation token - id: token - uses: pullfrog/pullfrog/get-installation-token@main - - - name: Verify token with Node.js - env: - GITHUB_TOKEN: ${{ steps.token.outputs.token }} - run: | - node -e ' - const res = await fetch("https://api.github.com/installation/repositories?per_page=1", { - headers: { - Authorization: "token " + process.env.GITHUB_TOKEN, - Accept: "application/vnd.github+json", - }, - }); - if (!res.ok) throw new Error("GET installation/repositories failed: " + res.status + " " + (await res.text())); - const data = await res.json(); - console.log("authenticated โ€” installation has access to", data.total_count, "repo(s)"); - console.log("first repo:", data.repositories[0].full_name); - ' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 5f214e9..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Test - -on: - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "pnpm" - - - run: pnpm install --frozen-lockfile - - run: pnpm typecheck - - run: pnpm test - - agents: - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - id-token: write - strategy: - fail-fast: true - matrix: - agent: [claude, opencode] - test: - [ - codex-auth, - mcpmerge, - nobash, - restricted, - skill-invoke-claude, - skill-invoke-opencode, - smoke, - token-exfil, - # vertex-claude, # disabled: 0 anthropic quota on pullfrog GCP vertex - vertex-opencode, - ] - exclude: - - agent: claude - test: skill-invoke-opencode - - agent: claude - test: codex-auth - - agent: opencode - test: skill-invoke-claude - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - XAI_API_KEY: ${{ secrets.XAI_API_KEY }} - DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} - AWS_REGION: us-east-1 - BEDROCK_MODEL_ID: us.anthropic.claude-sonnet-4-6 - VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} - GOOGLE_CLOUD_PROJECT: pullfrog - VERTEX_LOCATION: global - VERTEX_MODEL_ID: gemini-2.5-flash - 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 - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "pnpm" - - - run: pnpm install --frozen-lockfile - - run: pnpm runtest ${{ matrix.test }} ${{ matrix.agent }} - - agnostic: - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - id-token: write - strategy: - fail-fast: true - matrix: - test: - [ - byok-no-keys-fallback, - git-permissions, - githooks, - pkg-json-scripts, - push-disabled, - push-enabled, - push-restricted, - timeout, - ] - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "pnpm" - - - run: pnpm install --frozen-lockfile - - run: pnpm runtest ${{ matrix.test }} diff --git a/.github/workflows/trigger-sync.yml b/.github/workflows/trigger-sync.yml deleted file mode 100644 index 4040ce3..0000000 --- a/.github/workflows/trigger-sync.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Trigger sync - -on: - push: - branches: [main] - -permissions: - id-token: write - contents: read - -jobs: - trigger: - # only run in the upstream publish target (forks inherit this file but - # can't dispatch into pullfrog/app), and skip if pushed by our bot (breaks - # the loop). - if: github.repository == 'pullfrog/pullfrog' && github.actor != 'pullfrog[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Get installation token - id: token - uses: ./get-installation-token - with: - repos: pullfrog - - - name: Dispatch "action-repo-updated" event - run: | - gh api repos/pullfrog/app/dispatches \ - -f event_type="action-repo-updated" \ - -f client_payload='{ - "before": "${{ github.event.before }}", - "after": "${{ github.event.after }}", - "compare_url": "${{ github.event.compare }}", - "pusher": "${{ github.actor }}" - }' - env: - GH_TOKEN: ${{ steps.token.outputs.token }} diff --git a/action.yml b/action.yml index 397ed52..a2cb97b 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ -name: "Pullfrog Action" -description: "Execute coding agents with a prompt" -author: "Pullfrog" +name: "Shockbot Action" +description: "AI code review using Ollama" +author: "shockbot" inputs: prompt: @@ -10,38 +10,28 @@ inputs: description: "Maximum run duration (e.g., 10m, 1h30m). Default: 1h" required: false model: - description: "Model to use (e.g., anthropic/claude-opus). Overrides repo settings." + description: "Ollama model to use. Default: qwen3.6:35b" required: false cwd: description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)" required: false push: - description: "Git push permission: disabled (read-only), restricted (push feature branches only โ€” blocks pushes to the default branch, branch deletion, and tag pushes), or enabled (full push access). Default: enabled" + description: "Git push permission: disabled, restricted, or enabled. Default: restricted" required: false shell: - description: "Shell permission: disabled, restricted (filters secrets from env vars), or enabled. Public repos default to restricted for security; private repos default to enabled." + description: "Shell permission: disabled, restricted, or enabled. Default: restricted" required: false - output_schema: - description: "JSON Schema (draft-07) for structured output validation. When provided, the action output becomes required and must conform to this schema." - required: false - token: - description: "GitHub-provided token with job-scoped permissions. Do not set this unless you know what you are doing." - required: false - default: ${{ github.token }} outputs: result: - description: "It's set when the prompt explicitly requests it and is required when output_schema is provided; use it to capture actionable output for the next workflow step." + description: "Structured output from the agent when using output_schema" runs: using: "node24" main: "entry.ts" - # Always-run post step persists best-effort state that must survive - # cancellation, timeouts, and unhandled errors in the main step. Today's - # only consumer: Codex auth.json refresh write-back. See wiki/codex-auth.md. post: "entryPost.ts" post-if: "always()" branding: icon: "code" - color: "green" + color: "blue" diff --git a/agents/claude.ts b/agents/claude.ts deleted file mode 100644 index fcb6b02..0000000 --- a/agents/claude.ts +++ /dev/null @@ -1,1056 +0,0 @@ -/** - * Claude Code agent โ€” secure harness around the `claude` CLI. - * - * mirrors the opencode harness's security model: - * - native Bash blocked via --disallowedTools (agent cannot shell out) - * - managed-settings.json: filesystem sandbox โ€” deny /proc, /sys reads - * - MCP ShellTool provides restricted shell (filtered env, no secrets) - * - MCP server injected via --mcp-config (not replacing project config) - * - ASKPASS handles git auth separately (token never in subprocess env) - * - * 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 { pullfrogMcpName } from "../external.ts"; -import { - BEDROCK_MODEL_ID_ENV, - isBedrockAnthropicId, - isVertexAnthropicId, - VERTEX_MODEL_ID_ENV, -} from "../models.ts"; - -import { - getIdleMs, - isActivitySuspended, - markActivity, - resumeActivity, - suspendActivity, -} from "../utils/activity.ts"; -import { formatJsonValue, log } from "../utils/cli.ts"; -import { installFromNpmTarball } from "../utils/install.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 { ThinkingTimer } from "../utils/timer.ts"; -import type { TodoTracker } from "../utils/todoTracking.ts"; -import { getDevDependencyVersion } from "../utils/version.ts"; -import { applyClaudeVertexEnv } from "../utils/vertex.ts"; -import { - buildLearningsReflectionPrompt, - runPostRunRetryLoop, - shouldRunReflection, -} from "./postRun.ts"; -import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } 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"; - -async function installClaudeCli(): Promise { - return await installFromNpmTarball({ - packageName: "@anthropic-ai/claude-code", - version: getDevDependencyVersion("@anthropic-ai/claude-code"), - executablePath: "cli.js", - installDependencies: false, - }); -} - -// โ”€โ”€ config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function writeMcpConfig(ctx: AgentRunContext): string { - const configDir = join(ctx.tmpdir, ".claude"); - mkdirSync(configDir, { recursive: true }); - const configPath = join(configDir, "mcp.json"); - writeFileSync( - configPath, - JSON.stringify({ - mcpServers: { - [pullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl }, - }, - }) - ); - return configPath; -} - -/** - * Build the `--agents` JSON definition for the `reviewfrog` subagent. - * - * The Claude Code path always runs against an Anthropic model (see - * resolveAgent), so we hardcode the cheaper-sibling downshift: lenses run - * on Sonnet, the orchestrator stays on whatever model `--model` was passed. - * - * Per-call model override is also possible (Task tool's `model` arg accepts - * 'sonnet' | 'opus' | 'haiku') and takes precedence over what's set here โ€” - * we don't pass it; the per-subagent `model` field is the right default. - * - * The non-mutative + non-recursive contract is enforced by the prose system - * prompt baked into the agent โ€” see action/agents/reviewer.ts for why we - * no longer wire per-agent `disallowedTools` here. - */ -function buildAgentsJson(): string { - const agents = { - [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.", - prompt: REVIEWER_SYSTEM_PROMPT, - model: "claude-sonnet-4-6", - }, - }; - return JSON.stringify(agents); -} - -// โ”€โ”€ model helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -// claude CLI expects bare model names (e.g. "claude-sonnet-4-6"), not provider-prefixed specifiers -function stripProviderPrefix(specifier: string): string { - const slashIndex = specifier.indexOf("/"); - return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier; -} - -// `high` is the model's tuned default ("equivalent to not setting the parameter" -// per Anthropic docs). `max` is "absolute maximum capability with no constraints -// on token spending" โ€” meaningfully slower and burns more thinking budget per -// turn. We default everyone to `high`; PRs that genuinely need full-send can -// opt in via a future per-run override rather than paying the wall-time cost on -// every Opus run. -function resolveEffort(_model: string | undefined): "high" { - return "high"; -} - -// โ”€โ”€ NDJSON event types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -interface ContentBlock { - type: string; - text?: string; - id?: string; - name?: string; - input?: unknown; - tool_use_id?: string; - content?: string | unknown; - is_error?: boolean; - [key: string]: unknown; -} - -// SDK schema (per claude-agent-sdk docs) puts `session_id` and -// `parent_tool_use_id` at the top level of every Assistant/User/System/Result -// message, not inside `message`. Subagent events carry a non-null -// `parent_tool_use_id` pointing at the orchestrator's Task/Agent tool_use id. -interface ClaudeSystemEvent { - type: "system"; - session_id?: string; - parent_tool_use_id?: string | null; - [key: string]: unknown; -} - -interface ClaudeAssistantEvent { - type: "assistant"; - session_id?: string; - parent_tool_use_id?: string | null; - message?: { - role?: string; - content?: ContentBlock[]; - model?: string; - usage?: { - input_tokens?: number; - output_tokens?: number; - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; - }; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -interface ClaudeUserEvent { - type: "user"; - session_id?: string; - parent_tool_use_id?: string | null; - message?: { - role?: string; - content?: ContentBlock[]; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -interface ClaudeResultEvent { - type: "result"; - subtype?: string; - // claude CLI sets `is_error: true` (alongside `subtype: "success"`) when - // an upstream provider fails mid-stream. `api_error_status` carries the - // provider HTTP status (e.g. 401 for invalid API key). per the official - // SDK types, `api_error_status` is `number | null`, and the `error_*` - // subtypes carry their actionable payload in `errors: string[]` instead - // of `result`. - is_error?: boolean; - api_error_status?: number | null; - errors?: string[]; - result?: string; - session_id?: string; - num_turns?: number; - total_cost_usd?: number; - total_input_tokens?: number; - total_output_tokens?: number; - usage?: { - input_tokens?: number; - output_tokens?: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }; - [key: string]: unknown; -} - -// additional event types emitted by Claude CLI (handled as no-ops / debug) -interface ClaudeStreamEvent { - type: "stream_event"; - [key: string]: unknown; -} -interface ClaudeToolProgressEvent { - type: "tool_progress"; - [key: string]: unknown; -} -interface ClaudeToolUseSummaryEvent { - type: "tool_use_summary"; - [key: string]: unknown; -} -interface ClaudeAuthStatusEvent { - type: "auth_status"; - [key: string]: unknown; -} - -type ClaudeEvent = - | ClaudeSystemEvent - | ClaudeAssistantEvent - | ClaudeUserEvent - | ClaudeResultEvent - | ClaudeStreamEvent - | ClaudeToolProgressEvent - | ClaudeToolUseSummaryEvent - | ClaudeAuthStatusEvent; - -// โ”€โ”€ runner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -type RunParams = { - label: string; - args: string[]; - cwd: string; - env: Record; - todoTracker?: TodoTracker | undefined; - onActivityTimeout?: (() => void) | undefined; - onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined; -}; - -type ClaudeRunResult = AgentResult & { sessionId?: string | undefined }; - -/** - * Return the tail of `text` capped at `maxCodeUnits` UTF-16 code units, - * dropping any partial first line. used in the exit-non-zero stdout fallback - * so we never surface a truncated NDJSON event to operators โ€” - * `result.stdout.slice(-2048)` would otherwise cut mid-line and produce a - * syntactically broken JSON fragment. code units rather than bytes because - * `String.prototype.slice` operates on UTF-16 units; for multi-byte UTF-8 - * content the effective byte budget can be up to 4ร— the nominal limit. - */ -function tailLines(text: string, maxCodeUnits: number): string { - if (text.length <= maxCodeUnits) return text; - const tail = text.slice(-maxCodeUnits); - const firstNewline = tail.indexOf("\n"); - // if no newline in window or it's at the very start, return as-is; - // otherwise drop the partial first line. - return firstNewline > 0 && firstNewline < tail.length - 1 ? tail.slice(firstNewline + 1) : tail; -} - -export async function runClaude(params: RunParams): Promise { - const startTime = performance.now(); - let eventCount = 0; - - // per-session labeler so parallel subagent log lines can be differentiated. - // claude-agent-sdk runs subagents inside the orchestrator's session โ€” they - // share `session_id` โ€” and stamps every subagent message with a non-null - // `parent_tool_use_id` pointing at the Agent tool_use that spawned them. - // we bind each Agent tool_use id to its dispatched label up front, then - // labelFor short-circuits to the direct mapping when parent_tool_use_id is - // set. orchestrator events (parent_tool_use_id === null) flow through the - // sessionID path and bind to ORCHESTRATOR_LABEL on first sighting. - const labeler = new SessionLabeler(); - function eventLabel(event: { session_id?: string; parent_tool_use_id?: string | null }): string { - return labeler.labelFor(event.session_id ?? null, event.parent_tool_use_id ?? null); - } - function withLabel(label: string, message: string): string { - return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message); - } - - // one ThinkingTimer per session โ€” sharing a single timer across sessions - // conflated cross-session interleaving as parent thinking time. each timer - // formats its log lines through the session label so attribution is visible. - const thinkingTimers = new Map(); - function timerFor(label: string): ThinkingTimer { - let t = thinkingTimers.get(label); - if (!t) { - const formatLine = (line: string) => - label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line); - t = new ThinkingTimer(formatLine); - thinkingTimers.set(label, t); - } - return t; - } - - let finalOutput = ""; - let sessionId: string | undefined; - let resultErrorSubtype: string | null = null; - // captures the structured error string from a result event with - // `is_error: true` (e.g. mid-stream provider auth failures the CLI - // surfaces as `subtype: "success"` synthetic-stop events, or the - // `errors[]` array from `error_*` subtypes). preferred over raw - // stdout/stderr in the exit-non-zero path so the GitHub Actions - // `##[error]` line shows the actionable message instead of an 8KB+ - // NDJSON dump. - let lastResultError: string | null = null; - // set only for synthetic-stop `subtype: "success"` + `is_error: true` - // events, where `accumulatedTokens` from prior `assistant` events is - // stale and logging it would mislead operators into thinking billable - // tokens were spent on a successful turn. deliberately NOT set for - // `error_max_turns` / `error_during_execution` / `error_*` subtypes - // because those runs genuinely consumed tokens and operators need - // billing visibility for them. - let syntheticStopFailure = false; - let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - // Claude CLI reports a single end-of-run `total_cost_usd` on the result - // event. per-message events don't carry cost, so there's nothing to sum โ€” - // we just capture the final value when it arrives. - let accumulatedCostUsd = 0; - let tokensLogged = false; - - function buildUsage(): AgentUsage | undefined { - const totalInput = - accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite; - return totalInput > 0 || accumulatedTokens.output > 0 - ? { - agent: "claude", - inputTokens: totalInput, - outputTokens: accumulatedTokens.output, - cacheReadTokens: accumulatedTokens.cacheRead || undefined, - cacheWriteTokens: accumulatedTokens.cacheWrite || undefined, - costUsd: accumulatedCostUsd > 0 ? accumulatedCostUsd : undefined, - } - : undefined; - } - - const handlers = { - system: (event: ClaudeSystemEvent) => { - // claude-agent-sdk only emits system:init for the top-level query, so - // this binds the orchestrator label and never appears in subagent flow. - // we still route through eventLabel so a subagent system event (if the - // SDK ever adds one) wouldn't go silently misattributed. - const label = eventLabel(event); - log.debug(withLabel(label, `ยป ${params.label} system event`)); - }, - assistant: (event: ClaudeAssistantEvent) => { - const content = event.message?.content; - if (!content) return; - - const label = eventLabel(event); - const boxTitle = label === ORCHESTRATOR_LABEL ? params.label : `${params.label} [${label}]`; - - for (const block of content) { - if (block.type === "text" && block.text?.trim()) { - const message = block.text.trim(); - log.box(message, { title: boxTitle }); - // only the orchestrator's text becomes the run's "output" โ€” subagent - // report-back text would otherwise clobber the parent's final answer. - if (label === ORCHESTRATOR_LABEL) { - finalOutput = message; - } - } else if (block.type === "tool_use") { - const toolName = block.name || "unknown"; - // suspend the activity watchdog across the tool call. claude's - // stdout pipe goes silent while it awaits the synchronous MCP - // tools/call HTTP response; without this, long fetches/deepens - // (issue #760) trip the spawn-level idle timer at 300s. paired - // with resumeActivity() in tool_result below; bounded by the - // MAX_TOOL_CALL_SUSPENSION_MS auto-resume in activity.ts. - suspendActivity(); - if (params.onToolUse) { - params.onToolUse({ - toolName, - input: block.input, - }); - } - timerFor(label).markToolCall(); - const inputFormatted = formatJsonValue(block.input || {}); - const toolCallLine = - inputFormatted !== "{}" ? `ยป ${toolName}(${inputFormatted})` : `ยป ${toolName}()`; - log.info(withLabel(label, toolCallLine)); - - // when the orchestrator dispatches a subagent, bind the Agent - // tool_use id to the dispatched label so future events carrying - // `parent_tool_use_id === block.id` resolve directly to the right - // lens. v2.1.63+ renamed the tool to "Agent"; older versions - // emitted "Task". match both for forward-compat. - if ( - (toolName === "Task" || toolName === "Agent") && - block.input && - typeof block.input === "object" - ) { - const taskInput = block.input as { - description?: string; - subagent_type?: string; - prompt?: string; - }; - const dispatchedLabel = labeler.recordTaskDispatch(taskInput, block.id ?? null); - log.info( - withLabel( - label, - `ยป dispatching subagent: ${dispatchedLabel}` + - (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "") - ) - ); - } - - // agent's explicit MCP report_progress takes priority over todo tracking - if (toolName.includes("report_progress") && params.todoTracker) { - log.debug("ยป report_progress detected, disabling todo tracking"); - params.todoTracker.cancel(); - } - - // parse TodoWrite events for live progress tracking. only honor the - // orchestrator's todos โ€” subagents emit their own todo lists which - // would otherwise clobber the visible progress comment. - if ( - toolName === "TodoWrite" && - params.todoTracker?.enabled && - label === ORCHESTRATOR_LABEL - ) { - params.todoTracker.update(block.input); - } - } - } - - // accumulate per-message usage if available. capture cache fields too - // so the fallback token table (used when no final `result` event fires) - // still reports the full breakdown instead of silently dropping cache. - const msgUsage = event.message?.usage; - if (msgUsage) { - accumulatedTokens.input += msgUsage.input_tokens || 0; - accumulatedTokens.output += msgUsage.output_tokens || 0; - accumulatedTokens.cacheRead += msgUsage.cache_read_input_tokens || 0; - accumulatedTokens.cacheWrite += msgUsage.cache_creation_input_tokens || 0; - } - }, - user: (event: ClaudeUserEvent) => { - const content = event.message?.content; - if (!content) return; - - const label = eventLabel(event); - - for (const block of content) { - if (typeof block === "string") continue; - if (block.type === "tool_result") { - resumeActivity(); - timerFor(label).markToolResult(); - - const outputContent = - typeof block.content === "string" - ? block.content - : Array.isArray(block.content) - ? (block.content as unknown[]) - .map((entry: unknown) => - typeof entry === "string" - ? entry - : typeof entry === "object" && entry !== null && "text" in entry - ? String((entry as { text: unknown }).text) - : JSON.stringify(entry) - ) - .join("\n") - : String(block.content); - - if (block.is_error) { - log.info(withLabel(label, `ยป tool error: ${outputContent}`)); - } else { - log.debug(withLabel(label, `ยป tool output: ${outputContent}`)); - } - } - } - }, - result: (event: ClaudeResultEvent) => { - if (event.session_id) sessionId = event.session_id; - const subtype = event.subtype || "unknown"; - const numTurns = event.num_turns || 0; - - // claude CLI emits synthetic-stop result events with `subtype: "success"` - // but `is_error: true` when an upstream provider fails mid-stream (e.g. - // 401 from anthropic). short-circuit before the usage/token-table path - // so we don't log a usage table for a failed attempt and so downstream - // (`resultErrorSubtype` branch) surfaces the structured error. gated on - // `subtype === "success"` because the `error_*` subtypes also set - // `is_error: true` but carry their payload in `errors: string[]` and - // are handled by the dedicated branches below. - if (event.is_error === true && subtype === "success") { - const apiStatus = event.api_error_status; - lastResultError = - event.result?.trim() || - `claude reported is_error=true with no result text (api_error_status=${apiStatus ?? "unknown"})`; - resultErrorSubtype = subtype; - syntheticStopFailure = true; - log.info( - `ยป ${params.label} result error: subtype=${subtype}, api_error_status=${apiStatus ?? "unknown"}, message=${lastResultError}` - ); - return; - } - - if (subtype === "success") { - // extract detailed usage from result event (most accurate source). - // note: `input` here is non-cached input tokens only, matching the - // semantics of OpenCode's step_finish.tokens.input โ€” the logTokenTable - // helper sums Input + Cache Read + Cache Write + Output into the Total - // column so consumers get the real billable figure. - const usage = event.usage; - const inputTokens = usage?.input_tokens || 0; - const cacheRead = usage?.cache_read_input_tokens || 0; - const cacheWrite = usage?.cache_creation_input_tokens || 0; - const outputTokens = usage?.output_tokens || 0; - // guard against NaN/Infinity from malformed CLI output poisoning the total - const costUsd = - typeof event.total_cost_usd === "number" && Number.isFinite(event.total_cost_usd) - ? event.total_cost_usd - : 0; - - accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite }; - accumulatedCostUsd = costUsd; - - log.info(`ยป ${params.label} result: subtype=${subtype}, turns=${numTurns}`); - - if (!tokensLogged) { - logTokenTable({ - input: inputTokens, - cacheRead, - cacheWrite, - output: outputTokens, - costUsd, - }); - tokensLogged = true; - } - } else if (subtype === "error_max_turns") { - resultErrorSubtype = subtype; - lastResultError = event.errors?.join("\n").trim() || null; - log.info(`ยป ${params.label} max turns reached: ${JSON.stringify(event)}`); - } else if (subtype === "error_during_execution") { - resultErrorSubtype = subtype; - lastResultError = event.errors?.join("\n").trim() || null; - log.info(`ยป ${params.label} execution error: ${JSON.stringify(event)}`); - } else if (subtype.startsWith("error")) { - resultErrorSubtype = subtype; - lastResultError = event.errors?.join("\n").trim() || null; - log.info(`ยป ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`); - } else { - log.info(`ยป ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`); - } - - if (event.result?.trim()) { - finalOutput = event.result.trim(); - } - }, - // additional Claude CLI event types โ€” debug-logged only - stream_event: () => {}, - tool_progress: () => {}, - tool_use_summary: () => {}, - auth_status: () => {}, - }; - - const recentStderr: string[] = []; - // ring buffer of recent non-JSON stdout lines. Claude CLI prints - // human-readable TTY chrome (status bubbles, quota notices, etc.) - // alongside the NDJSON event stream. when the CLI exits non-zero without - // emitting a structured error event, these lines are the only actionable - // signal โ€” preferring them over the NDJSON tail keeps progress comments - // readable. issue #643. - const recentNonJsonStdout: string[] = []; - - let lastProviderError: string | null = null; - - // capped accumulator โ€” see opencode.ts for rationale (issue #680). - const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES); - let stdoutBuffer = ""; - - try { - const result = await spawn({ - cmd: "node", - args: params.args, - cwd: params.cwd, - env: params.env, - activityTimeout: 300_000, - onActivityTimeout: params.onActivityTimeout, - isPausedExternally: isActivitySuspended, - stdio: ["ignore", "pipe", "pipe"], - // run claude in its own process group so SIGKILL on activity timeout / - // outer cancellation reaches any subprocesses it spawns (rg, file - // watchers, mcp transports, etc). claude itself is a node bundle so - // there's no shim-orphan issue like opencode-ai/bin/opencode, but - // detached + killGroup is the right default for any agent runtime. - killGroup: true, - // claude already drains every chunk via onStdout (NDJSON parsing) and - // onStderr (recentStderr ring buffer). retaining a second copy in the - // spawn wrapper would grow unbounded for long sessions and previously - // crashed the wrapper with RangeError. see issue #680. - retain: "none", - 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: ClaudeEvent; - try { - event = JSON.parse(trimmed) as ClaudeEvent; - } catch { - log.debug(`ยป non-JSON stdout line: ${trimmed.substring(0, 200)}`); - recentNonJsonStdout.push(trimmed); - if (recentNonJsonStdout.length > MAX_STDERR_LINES) recentNonJsonStdout.shift(); - continue; - } - - eventCount++; - log.debug(JSON.stringify(event, null, 2)); - - const timeSinceLastActivity = getIdleMs(); - if (timeSinceLastActivity > 10000) { - log.info( - `ยป no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s (${params.label} may be processing internally) (${eventCount} events processed so far)` - ); - } - markActivity(); - - const handler = handlers[event.type as keyof typeof handlers]; - if (!handler) { - log.debug(`ยป ${params.label} event (unhandled): type=${event.type}`); - continue; - } - try { - (handler as (e: ClaudeEvent) => void)(event); - } 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; - 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(); - } - - 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}`); - } - - // skip the fallback token table only for the synthetic-stop - // `subtype: "success"` + `is_error: true` case: `accumulatedTokens` from - // prior `assistant` events is stale there and logging it would mislead - // operators into thinking billable tokens were spent on a successful turn. - // `error_max_turns` / `error_during_execution` / `error_*` subtypes - // represent runs that genuinely consumed tokens, so they still get the - // table for billing visibility. - if ( - !tokensLogged && - !syntheticStopFailure && - (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})` : ""; - // prefer the structured `lastResultError` (parsed from a result event - // with `is_error: true`) over raw stdout. raw stdout is the full NDJSON - // event stream โ€” dumping it into a GitHub Actions `##[error]` line both - // hides the actionable provider message and pollutes the run log. cap - // the stdout fallback to the last 2KB so it stays readable when neither - // a structured error nor stderr is available. - // - // result.stdout / result.stderr are empty because we pass retain:"none" - // to spawn (see issue #680); the agent layer keeps its own bounded - // mirrors via `output` (TailBuffer) and `recentStderr` (ring buffer). - const stdoutSnapshot = output.toString(); - const stderrSnapshot = recentStderr.join("\n"); - const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : ""; - // prefer non-JSON stdout (human-readable TTY chrome the CLI prints, - // including status bubbles and quota notices) over the raw NDJSON - // tail. when the CLI exits 1 without emitting `is_error` (issue #643), - // the NDJSON fallback would otherwise dump 2KB of `system/init` events - // into the progress comment with no mention of the actual cause. - const nonJsonStdoutSnapshot = recentNonJsonStdout.join("\n"); - const errorMessage = - lastResultError || - stderrSnapshot || - nonJsonStdoutSnapshot || - truncatedStdout || - `unknown error - no output from Claude 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, - sessionId, - }; - } - - if (eventCount === 0 && lastProviderError) { - return { - success: false, - output: finalOutput || output.toString(), - error: `provider error: ${lastProviderError}`, - usage, - sessionId, - }; - } - - if (resultErrorSubtype) { - return { - success: false, - output: finalOutput || output.toString(), - error: lastResultError || `result subtype: ${resultErrorSubtype}`, - usage, - sessionId, - }; - } - - return { success: true, output: finalOutput || output.toString(), usage, sessionId }; - } 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 - ? "Claude produced 0 stdout events - check if the API 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}` - ); - - return { - success: false, - output: finalOutput || output.toString(), - error: `${errorMessage} [${diagnosis}]`, - usage: buildUsage(), - sessionId, - }; - } -} - -// โ”€โ”€ managed settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -const MANAGED_SETTINGS_DIR = "/etc/claude-code"; -const MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`; - -// managed-settings.json has absolute highest precedence in Claude Code's config hierarchy. -// it cannot be overridden by user, project, or local settings โ€” safe against malicious PRs. -// -// permissions.deny blocks native tools (Read, Grep, Edit, Glob) from accessing /proc and /sys. -// sandbox.filesystem.denyRead blocks the Bash tool sandbox from reading those paths. -// allowManagedPermissionRulesOnly prevents malicious PRs from adding allow rules that override -// our deny rules โ€” safe in CI because --dangerously-skip-permissions makes allow/ask irrelevant. -// allowManagedHooksOnly prevents malicious project hooks from bypassing deny rules. -// Codex auth.json (Pullfrog-stored ChatGPT subscription credential) lives at -// `~/.local/share/opencode/auth.json` when the opencode harness materialized -// it. Claude shouldn't be running OpenAI models โ€” they route to opencode โ€” -// but defense-in-depth: deny the file regardless. Per Claude Code permissions -// docs, Read(...) deny ALSO blocks file-reading Bash commands (cat, head, -// tail, sed) and survives bypassPermissions mode. See wiki/codex-auth.md. -const CODEX_AUTH_DENY_PATH = "~/.local/share/opencode/auth.json"; - -function buildManagedSettings(ctx: AgentRunContext) { - const secretDenyPaths = ctx.secretDenyPaths ?? []; - const toolDeny = secretDenyPaths.flatMap((path) => [ - `Read(${path}/**)`, - `Read(/${path}/**)`, - `Grep(${path}/**)`, - `Grep(/${path}/**)`, - `Edit(${path}/**)`, - `Edit(/${path}/**)`, - `Glob(${path}/**)`, - `Glob(/${path}/**)`, - ]); - return { - allowManagedPermissionRulesOnly: true, - allowManagedHooksOnly: true, - permissions: { - deny: [ - "Read(//proc/**)", - "Read(//sys/**)", - "Grep(//proc/**)", - "Grep(//sys/**)", - "Edit(//proc/**)", - "Edit(//sys/**)", - "Glob(//proc/**)", - "Glob(//sys/**)", - `Read(${CODEX_AUTH_DENY_PATH})`, - `Grep(${CODEX_AUTH_DENY_PATH})`, - `Edit(${CODEX_AUTH_DENY_PATH})`, - `Glob(${CODEX_AUTH_DENY_PATH})`, - ...toolDeny, - ], - }, - sandbox: { - filesystem: { - denyRead: ["/proc", "/sys", CODEX_AUTH_DENY_PATH, ...secretDenyPaths], - }, - }, - }; -} - -function installManagedSettings(ctx: AgentRunContext): void { - if (process.env.CI !== "true") return; - - const content = JSON.stringify(buildManagedSettings(ctx), null, 2); - try { - execFileSync("sudo", ["mkdir", "-p", MANAGED_SETTINGS_DIR]); - execFileSync("sudo", ["tee", MANAGED_SETTINGS_PATH], { - input: content, - stdio: ["pipe", "ignore", "pipe"], - }); - log.debug(`ยป wrote managed settings to ${MANAGED_SETTINGS_PATH}`); - } catch (err) { - log.warning(`ยป failed to install managed settings: ${err}`); - } -} - -// โ”€โ”€ agent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export const claude = agent({ - name: "claude", - install: installClaudeCli, - run: async (ctx) => { - const cliPath = await installClaudeCli(); - - const specifier = ctx.payload.proxyModel ?? ctx.resolvedModel; - // claude-code on Bedrock takes the bare AWS model ID โ€” no provider prefix - // to strip, since the ID is already in `provider.model` form (e.g. - // `us.anthropic.claude-opus-4-7`). detect via the env-var sentinel: if - // BEDROCK_MODEL_ID is set and matches the resolved specifier, this is a - // bedrock route. see `wiki/model-resolution.md` for the routing pattern. - const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim(); - const isBedrockRoute = - specifier !== undefined && - bedrockModelId !== undefined && - bedrockModelId === specifier && - isBedrockAnthropicId(specifier); - const vertexModelId = process.env[VERTEX_MODEL_ID_ENV]?.trim(); - const isVertexRoute = - specifier !== undefined && - vertexModelId !== undefined && - vertexModelId === specifier && - isVertexAnthropicId(specifier); - const model = !specifier - ? undefined - : isBedrockRoute - ? specifier - : isVertexRoute - ? undefined - : stripProviderPrefix(specifier); - - const homeEnv = { - HOME: ctx.tmpdir, - XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"), - }; - - mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "claude"), { recursive: true }); - - const agentBrowserVersion = getDevDependencyVersion("agent-browser"); - addSkill({ - ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`, - skill: "agent-browser", - env: homeEnv, - agent: "claude-code", - }); - - installBundledSkills({ home: homeEnv.HOME }); - - const mcpConfigPath = writeMcpConfig(ctx); - const effort = resolveEffort(model); - - installManagedSettings(ctx); - - // base args shared between initial run and continue runs - const baseArgs = [ - cliPath, - "--output-format", - "stream-json", - "--dangerously-skip-permissions", - "--mcp-config", - mcpConfigPath, - "--verbose", - "--effort", - effort, - "--disallowedTools", - "Bash,Agent(Bash)", - "--agents", - buildAgentsJson(), - ]; - - if (model) { - baseArgs.push("--model", model); - } - - // agent process gets full env โ€” needs LLM API keys, PATH, locale, etc. - // security is enforced via managed-settings.json, --disallowedTools (Bash), and MCP tool filtering. - // - // bedrock route: claude-code reads `CLAUDE_CODE_USE_BEDROCK=1` to switch - // its provider implementation from the direct Anthropic API to Bedrock. - // AWS_BEARER_TOKEN_BEDROCK / AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + - // AWS_REGION are already in process.env from the workflow's `env:` block. - // see https://docs.claude.com/en/docs/claude-code/amazon-bedrock. - // - // we only force CLAUDE_CODE_USE_BEDROCK=1 when this is a Pullfrog-routed - // 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"; - } - if (isVertexRoute) { - applyClaudeVertexEnv(env); - env.ANTHROPIC_MODEL = specifier; - } - - // claude-code's `Vw()` resolver prefers ANTHROPIC_API_KEY over the OAuth - // token when both are set, so we strip the API key to fall through to the - // Max-subscription path. bedrock route uses AWS creds and is excluded. - if (env.CLAUDE_CODE_OAUTH_TOKEN && !isBedrockRoute && env.ANTHROPIC_API_KEY) { - log.debug( - "ยป CLAUDE_CODE_OAUTH_TOKEN present โ€” stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used" - ); - delete env.ANTHROPIC_API_KEY; - } - - log.info(`ยป effort: ${effort}`); - log.debug(`ยป starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`); - log.debug(`ยป working directory: ${repoDir}`); - - const runParams = { - label: "Pullfrog", - cwd: repoDir, - env, - todoTracker: ctx.todoTracker, - onActivityTimeout: ctx.onActivityTimeout, - onToolUse: ctx.onToolUse, - }; - - const result = await runClaude({ - ...runParams, - args: [...baseArgs, "-p", 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. claude needs a sessionId to `--resume`; if it's missing the - // loop bails (checks still ran, so persistent hook failures still fail - // the run). 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, - canResume: (r) => Boolean(r.sessionId), - resume: async (c) => { - const sessionId = c.previousResult.sessionId; - if (!sessionId) throw new Error("unreachable: canResume gated on sessionId"); - return runClaude({ - ...runParams, - args: [...baseArgs, "-p", c.prompt, "--resume", sessionId], - }); - }, - }); - }, -}); diff --git a/agents/index.ts b/agents/index.ts index a195727..0d45968 100644 --- a/agents/index.ts +++ b/agents/index.ts @@ -1,10 +1,6 @@ -import { claude } from "./claude.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 { ollamaAgent } from "./ollama.ts"; import type { Agent } from "./shared.ts"; -export type { Agent, AgentUsage } from "./shared.ts"; +export type { Agent } from "./shared.ts"; -export const agents = { claude, opencode } satisfies Record; +export const agents = { ollama: ollamaAgent } satisfies Record; diff --git a/agents/ollama.ts b/agents/ollama.ts new file mode 100644 index 0000000..42b5143 --- /dev/null +++ b/agents/ollama.ts @@ -0,0 +1,199 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Ollama, type Message, type ToolCall } from "ollama"; +import { log } from "../utils/cli.ts"; +import { agent, type AgentResult, type AgentRunContext } from "./shared.ts"; + +const DEFAULT_MODEL = "qwen3.6:35b"; +const MAX_ITERATIONS = 100; + +interface OllamaTool { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +async function buildMcpClient(mcpServerUrl: string): Promise { + const client = new Client( + { name: "shockbot-agent", version: "0.1.0" }, + { capabilities: {} }, + ); + const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl)); + // @ts-expect-error โ€” StreamableHTTPClientTransport.sessionId is string|undefined but Transport + // expects string; this is an @modelcontextprotocol/sdk internal type mismatch, not our bug. + await client.connect(transport); + return client; +} + +async function getOllamaTools(mcpClient: Client): Promise { + const { tools } = await mcpClient.listTools(); + return tools.map((t) => ({ + type: "function" as const, + function: { + name: t.name, + description: t.description ?? "", + parameters: (t.inputSchema as Record) ?? { + type: "object", + properties: {}, + }, + }, + })); +} + +async function callMcpTool( + mcpClient: Client, + toolName: string, + args: Record, +): Promise { + try { + const result = await mcpClient.callTool({ + name: toolName, + arguments: args, + }); + const content = result.content as + | Array<{ type: string; text?: string }> + | undefined; + if (!content || content.length === 0) + return JSON.stringify({ success: true }); + const text = content + .map((c) => (c.type === "text" ? (c.text ?? "") : "")) + .filter(Boolean) + .join("\n"); + return text || JSON.stringify(result); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.debug(`Tool ${toolName} error: ${msg}`); + return JSON.stringify({ error: msg }); + } +} + +async function runOllamaLoop(ctx: AgentRunContext): Promise { + const ollamaHost = process.env.OLLAMA_HOST ?? ""; + if (!ollamaHost) { + const errorMsg = + "OLLAMA_HOST environment variable is not set. Please set it to the URL of your Ollama instance."; + log.error(errorMsg); + return { success: false, error: errorMsg }; + } + + const model = ctx.model ?? process.env.OLLAMA_MODEL ?? DEFAULT_MODEL; + + log.info(`ยป connecting to Ollama at ${ollamaHost}, model ${model}`); + + const ollama = new Ollama({ host: ollamaHost }); + const mcpClient = await buildMcpClient(ctx.mcpServerUrl); + + log.info("ยป fetching MCP tool list..."); + const tools = await getOllamaTools(mcpClient); + log.info(`ยป ${tools.length} tools available`); + + const messages: Message[] = [ + { + role: "user", + content: ctx.instructions.full, + }, + ]; + + let iterations = 0; + let pendingModeNudge = false; + + while (iterations < MAX_ITERATIONS) { + iterations++; + log.info(`ยป Ollama turn ${iterations}/${MAX_ITERATIONS}...`); + + let response: Awaited>; + try { + response = await ollama.chat({ + model, + messages, + tools, + options: { + think: false, + } as Record, + }); + } catch (err) { + const lastError = err instanceof Error ? err.message : String(err); + log.error(`Ollama error: ${lastError}`); + return { success: false, error: `Ollama request failed: ${lastError}` }; + } + + const assistantMessage = response.message; + messages.push(assistantMessage); + + const toolCalls: ToolCall[] | undefined = assistantMessage.tool_calls; + + if (!toolCalls || toolCalls.length === 0) { + // If we just gave the model a nudge after select_mode and it still won't + // call tools, it's genuinely done (or stuck) โ€” exit cleanly. + if (pendingModeNudge) { + log.info("ยป agent finished after mode nudge (no tool calls)"); + } else { + log.info("ยป agent finished (no tool calls)"); + } + return { + success: true, + output: assistantMessage.content || undefined, + }; + } + + pendingModeNudge = false; + + const calledSelectMode = toolCalls.some( + (tc) => tc.function.name === "select_mode", + ); + + for (const toolCall of toolCalls) { + const toolName = toolCall.function.name; + const toolArgs = toolCall.function.arguments; + + log.info(`ยป calling tool: ${toolName}`); + log.debug(` args: ${JSON.stringify(toolArgs)}`); + + if (ctx.onToolUse) { + ctx.onToolUse({ toolName, input: toolArgs }); + } + + const result = await callMcpTool( + mcpClient, + toolName, + toolArgs as Record, + ); + log.debug(` result: ${result.slice(0, 200)}`); + + messages.push({ + role: "tool", + content: result, + }); + } + + // After select_mode, explicitly tell the model to act on the returned guidance. + // Without this nudge, smaller models tend to treat the mode instructions as + // informational and stop rather than executing the workflow steps. + if (calledSelectMode) { + pendingModeNudge = true; + messages.push({ + role: "user", + content: + "You have selected a mode and received your workflow instructions. " + + "Now execute the first step of that workflow immediately by calling the appropriate tool. " + + "Do not describe what you will do โ€” just call the tool.", + }); + } + } + + log.warning(`ยป agent hit max iterations (${MAX_ITERATIONS})`); + return { + success: false, + error: `Agent exceeded maximum iterations (${MAX_ITERATIONS})`, + }; +} + +export const ollamaAgent = agent({ + name: "ollama", + run: async (ctx: AgentRunContext): Promise => { + return runOllamaLoop(ctx); + }, +}); diff --git a/agents/opencode.test.ts b/agents/opencode.test.ts deleted file mode 100644 index 371bf2d..0000000 --- a/agents/opencode.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { modelAliases } from "../models.ts"; -import { geminiHighThinkingOverrides } from "./opencode.ts"; - -describe("geminiHighThinkingOverrides", () => { - // Expected truth pulled the same way the helper does โ€” both must derive from - // the registry so the test exercises the wiring, not a hand-maintained list. - const expectedApiIds = modelAliases - .filter((a) => a.provider === "google") - .map((a) => a.resolve.replace(/^google\//, "")); - const overrides = geminiHighThinkingOverrides(); - - it("covers every direct-Google alias in the registry", () => { - expect(Object.keys(overrides).sort()).toEqual([...expectedApiIds].sort()); - }); - - it("is non-empty (catches accidental whole-provider removal)", () => { - expect(Object.keys(overrides).length).toBeGreaterThan(0); - }); - - it("strips the `google/` prefix from each resolve to get the bare API id", () => { - for (const id of Object.keys(overrides)) { - expect(id).not.toMatch(/^google\//); - } - }); - - it("pins every entry to thinkingLevel: high", () => { - for (const [id, value] of Object.entries(overrides)) { - expect(value, `entry for ${id}`).toEqual({ - options: { thinkingConfig: { thinkingLevel: "high" } }, - }); - } - }); -}); diff --git a/agents/opencode.ts b/agents/opencode.ts deleted file mode 100644 index 450a924..0000000 --- a/agents/opencode.ts +++ /dev/null @@ -1,1264 +0,0 @@ -/** - * OpenCode agent โ€” secure harness around OpenCode CLI. - * - * transparently wraps OpenCode with a security layer: - * - 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 alongside project config (not replacing) - * - ASKPASS handles git auth separately (token never in subprocess env) - * - * 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 { 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 { - getIdleMs, - isActivitySuspended, - markActivity, - resumeActivity, - suspendActivity, -} 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 { ThinkingTimer } from "../utils/timer.ts"; -import type { TodoTracker } from "../utils/todoTracking.ts"; -import { getDevDependencyVersion } from "../utils/version.ts"; -import { resolveVertexOpenCodeModel } from "../utils/vertex.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"; - -// 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"; - -// 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 -// 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. - -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; - })(), - // NOTE: `experimental.batch_tool` was enabled in #719 to bundle 1-25 - // independent tool calls into one round trip, but the batch tool rejects - // MCP/"external" tools with `"Tool '' not in registry. External - // tools (MCP, environment) cannot be batched - call them directly."` - // (anomalyco/opencode PR #2983 design). when a model emits parallel - // tool_use blocks containing `pullfrog_*` calls, opencode internally - // routes them through batch โ€” they all fail, the model misreads the - // error as "the tool doesn't exist", and gives up. caught in CI by - // `restricted-opencode` after a `lens:` subagent dispatched parallel - // `pullfrog_shell` calls and concluded shell was unavailable. - // native parallel tool_use (multiple tool_use blocks per assistant - // message) still works without batch_tool for both built-in and MCP - // tools, so we lose only the batch wrapper, not parallelism. - // 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -interface OpenCodeInitEvent { - type: "init"; - timestamp?: string; - session_id?: string; - model?: string; - [key: string]: unknown; -} - -interface OpenCodeMessageEvent { - type: "message"; - timestamp?: string; - role?: "user" | "assistant"; - content?: string; - delta?: boolean; - [key: string]: unknown; -} - -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; -} - -interface OpenCodeToolResultEvent { - type: "tool_result"; - timestamp?: number; - sessionID?: string; - part?: { callID?: string; state?: ToolPartState }; - tool_id?: string; - status?: "success" | "error"; - output?: string; - [key: string]: unknown; -} - -interface OpenCodeResultEvent { - type: "result"; - timestamp?: string; - status?: "success" | "error"; - stats?: { - total_tokens?: number; - input_tokens?: number; - output_tokens?: number; - duration_ms?: number; - tool_calls?: number; - }; - [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 event emitted by our `.opencode/plugin/pullfrog-events.ts` (the - * source lives in `opencodePlugin.ts`). The plugin subscribes to opencode's - * bus via `bus.subscribeAll()` and re-emits non-orchestrator - * `message.part.updated` events on stdout so subagent activity surfaces here. - * - * `bus_event.properties.part` matches the same `Part` shape that opencode's - * `cli/cmd/run.ts` uses to drive its own emit() calls, so we can route the - * inner part through the existing `tool_use` / `step_start` / `step_finish` - * / `text` handlers by synthesizing the equivalent OpenCode-style event. - */ -interface OpenCodeBusEnvelopeEvent { - type: "pullfrog_bus_event"; - bus_event?: { - type?: string; - properties?: { - part?: { - sessionID?: string; - type?: string; - time?: { end?: number | string }; - state?: { status?: string }; - [key: string]: unknown; - }; - [key: string]: unknown; - }; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -type OpenCodeEvent = - | OpenCodeInitEvent - | OpenCodeMessageEvent - | OpenCodeTextEvent - | OpenCodeStepStartEvent - | OpenCodeStepFinishEvent - | OpenCodeToolUseEvent - | OpenCodeToolResultEvent - | OpenCodeResultEvent - | OpenCodeErrorEvent - | OpenCodeBusEnvelopeEvent; - -// โ”€โ”€ 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(); - let currentStepId: string | null = null; - let currentStepType: string | null = null; - let stepHistory: Array<{ stepId: string; stepType: string; toolCalls: string[] }> = []; - - // per-session labeler so parallel subagent log lines can be differentiated. - // the orchestrator's task tool_use events seed the labeler; the next - // previously-unseen sessionID consumes the head of the pending-label queue. - // upstream opencode's `cli/cmd/run.ts` filters subagent events out of its - // NDJSON stream (`part.sessionID !== sessionID`), so we ship a per-run - // plugin (`action/agents/opencodePlugin.ts`, written into the tmpdir at - // setup) that re-emits non-orchestrator `message.part.updated` events. those - // arrive here as `pullfrog_bus_event` envelopes and feed the labeler with - // real data per subagent session. - const labeler = new SessionLabeler(); - function eventLabel(event: Record): string { - const sid = event.sessionID ?? event.session_id; - return labeler.labelFor(typeof sid === "string" ? sid : null); - } - function withLabel(label: string, message: string): string { - return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message); - } - - // one ThinkingTimer per session โ€” sharing a single timer across sessions - // conflated cross-session interleaving (parent thinks โ†’ child tool_call, - // or child returns โ†’ parent dispatches next) as parent thinking time. each - // timer formats its log lines through the session label so the "thought - // for X" attribution is visible in the merged stream. - const thinkingTimers = new Map(); - function timerFor(label: string): ThinkingTimer { - let t = thinkingTimers.get(label); - if (!t) { - const formatLine = (line: string) => - label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line); - t = new ThinkingTimer(formatLine); - thinkingTimers.set(label, t); - } - return t; - } - - // tracks per-task dispatch metadata so the matching tool_result can log a - // labeled "ยป subagent finished: lens=X duration=Ys" line. this is the most - // useful per-lens observability available given that subagent-internal - // events aren't streamed. - // - // matching strategy is hybrid because opencode does NOT reliably emit a - // tool_result with a callID equal to the originating tool_use.callID for - // the `task` tool (verified empirically in T3 โ€” 5 task dispatches recorded - // here, 0 finish lines fired, yet aggregation succeeded so results did - // arrive on the stream). we keep an exact-match Map for the fast path, and - // also a FIFO queue for the fallback path where the callID mismatches. - // the queue + map share entries by reference so popping one removes both. - interface TaskDispatch { - label: string; - startedAt: number; - toolUseCallID: string; - } - const taskDispatchByCallID = new Map(); - const pendingTaskDispatches: TaskDispatch[] = []; - // every non-task tool_use callID we've observed. lets us tell, on a - // tool_result, whether its callID belongs to a known non-task tool (in - // which case we never fall back to FIFO) or is unrecognised (in which case - // a long-output result is a strong "this is probably a task result with a - // mismatched callID" signal). - const knownNonTaskCallIDs = new Set(); - - function emitSubagentFinished( - dispatch: TaskDispatch, - status: string, - output: unknown, - matchKind: "exact" | "fifo" - ) { - const subagentDuration = performance.now() - dispatch.startedAt; - const outputStr = typeof output === "string" ? output : ""; - const outputPreview = outputStr.length > 120 ? `${outputStr.slice(0, 120)}โ€ฆ` : outputStr; - const matchSuffix = matchKind === "fifo" ? " [fifo-matched]" : ""; - log.info( - `ยป subagent finished: ${dispatch.label} (${(subagentDuration / 1000).toFixed(1)}s, status=${status})${matchSuffix}` + - (outputPreview ? ` โ€” ${outputPreview.replace(/\n/g, " ")}` : "") - ); - taskDispatchByCallID.delete(dispatch.toolUseCallID); - const idx = pendingTaskDispatches.indexOf(dispatch); - if (idx >= 0) pendingTaskDispatches.splice(idx, 1); - } - - 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 = { - init: (event: OpenCodeInitEvent) => { - // bind this sessionID to a label so subsequent events (tool_use, - // tool_result, text, message) route to the right prefix. for the - // first session this is "orchestrator"; for subagents it pops from - // the pending-dispatch queue. - const label = labeler.labelFor(event.session_id ?? null); - log.debug( - withLabel( - label, - `ยป ${params.label} init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}` - ) - ); - log.debug(withLabel(label, `ยป ${params.label} init event (full): ${JSON.stringify(event)}`)); - // only reset run-wide state on the orchestrator's init โ€” child sessions - // emit their own init events and we don't want them to clobber the - // parent's accumulated counters. - if (label === ORCHESTRATOR_LABEL) { - finalOutput = ""; - accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - accumulatedCostUsd = 0; - tokensLogged = false; - } else { - log.info(`ยป ${params.label} subagent init: ${label} (session ${event.session_id || "?"})`); - } - }, - message: (event: OpenCodeMessageEvent) => { - const label = eventLabel(event); - if (event.role === "assistant" && event.content?.trim()) { - const message = event.content.trim(); - if (event.delta) { - log.debug( - withLabel( - label, - `ยป ${params.label} thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}` - ) - ); - } else { - log.debug( - withLabel( - label, - `ยป ${params.label} message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}` - ) - ); - // same reasoning as `text` handler โ€” only orchestrator's non-delta - // assistant message is the run output; subagent reports stay scoped - // to the box / debug log. - if (label === ORCHESTRATOR_LABEL) { - finalOutput = message; - } - } - } else if (event.role === "user") { - log.debug( - withLabel( - label, - `ยป ${params.label} message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}` - ) - ); - } - }, - 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; - } - } - }, - step_start: (event: OpenCodeStepStartEvent) => { - const stepType = event.part?.type || "unknown"; - const stepId = event.part?.id || "unknown"; - currentStepId = stepId; - currentStepType = stepType; - stepHistory.push({ stepId, stepType, toolCalls: [] }); - }, - step_finish: async (event: OpenCodeStepFinishEvent) => { - const stepId = event.part?.id || "unknown"; - const eventTokens = event.part?.tokens; - if (eventTokens) { - accumulatedTokens.input += eventTokens.input || 0; - accumulatedTokens.output += eventTokens.output || 0; - accumulatedTokens.cacheRead += eventTokens.cache?.read || 0; - accumulatedTokens.cacheWrite += eventTokens.cache?.write || 0; - } - // step_finish.part.cost is a per-step delta (not a running total) โ€” - // OpenCode emits varying per-event values that sum to the session cost. - // verified empirically across Anthropic, OpenAI, Gemini, xAI, DeepSeek, - // Moonshot, and OpenRouter (see pullfrog-baseline/opencode-*.log). - // guard against NaN/Infinity โ€” a single poison value would make the - // running total un-recoverable for the rest of the session. - if (typeof event.part?.cost === "number" && Number.isFinite(event.part.cost)) { - accumulatedCostUsd += event.part.cost; - } - if (currentStepId === stepId) { - currentStepId = null; - currentStepType = null; - } - }, - tool_use: (event: OpenCodeToolUseEvent) => { - const toolName = event.part?.tool; - const toolId = event.part?.callID; - if (!toolName || !toolId) { - log.info( - `ยป tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}` - ); - return; - } - - // suspend the activity watchdog across the tool call (issue #760). - // for `task` tool dispatches the injected plugin already reverbs - // child.stdout chunks, so this is mostly defense-in-depth there; - // for non-task MCP tools (checkout_pr, etc.) the suspend is the - // only thing keeping a multi-minute fetch from tripping the 300s - // spawn-level idle timer. gate by part status: bus-envelope - // re-dispatches at line 915 fire only on terminal statuses - // (`completed`/`error`) and never produce a paired `tool_result`, - // so suspending on those would leak the watchdog open until the - // 15min auto-resume โ€” exactly the issue #12 zombie-run window. - const status = event.part?.state?.status; - if (status !== "completed" && status !== "error") { - suspendActivity(); - } - - // when the orchestrator dispatches a subagent via the `task` tool, push - // a label for the upcoming child session so its events are attributable. - // record BEFORE label lookup: this event's session is the parent (whose - // label is already bound); the dispatch label is for the next new - // sessionID that appears. - if (toolName === "task") { - // may have been pre-registered via the plugin's early task-dispatch - // announcement (`pullfrog_bus_event` handler). dedupe on callID so - // we don't record the same dispatch twice (which would corrupt the - // FIFO label queue). - if (!taskDispatchByCallID.has(toolId)) { - const taskInput = (event.part?.state?.input ?? {}) as { - description?: string; - subagent_type?: string; - prompt?: string; - }; - const dispatchedLabel = labeler.recordTaskDispatch(taskInput); - // dual-index by callID (fast path) AND in a FIFO queue (fallback path - // for when opencode's task tool_result carries a different callID). - const dispatch: TaskDispatch = { - label: dispatchedLabel, - startedAt: performance.now(), - toolUseCallID: toolId, - }; - taskDispatchByCallID.set(toolId, dispatch); - pendingTaskDispatches.push(dispatch); - log.info( - `ยป dispatching subagent: ${dispatchedLabel}` + - (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "") - ); - } - } else { - // remember non-task callIDs so a later tool_result with that callID - // is correctly identified as not-a-task (and we don't FIFO-pop a - // pending task by mistake). - knownNonTaskCallIDs.add(toolId); - } - - const label = eventLabel(event); - - if (stepHistory.length > 0) { - stepHistory[stepHistory.length - 1]!.toolCalls.push(toolName); - } - - if (params.onToolUse) { - params.onToolUse({ - toolName, - input: event.part?.state?.input, - }); - } - - timerFor(label).markToolCall(); - const inputFormatted = formatJsonValue(event.part?.state?.input || {}); - const toolCallLine = - inputFormatted !== "{}" ? `ยป ${toolName}(${inputFormatted})` : `ยป ${toolName}()`; - log.info(withLabel(label, toolCallLine)); - - if (event.part?.state?.status === "completed" && event.part.state.output) { - log.debug(withLabel(label, ` output: ${event.part.state.output}`)); - } - // surface tool errors at info level. opencode emits tool parts at - // status="error" through the same `tool_use` event the CLI's run-loop - // (and our injected plugin for subagent parts) emits โ€” without this - // branch the only signal in the user's logs is `ยป (...)` with - // no indication the call failed. - if (event.part?.state?.status === "error") { - log.info(withLabel(label, `ยป tool call failed: ${event.part.state.error}`)); - } - - // agent's explicit MCP report_progress takes priority over todo tracking - if (toolName.includes("report_progress") && params.todoTracker) { - log.debug("ยป report_progress detected, disabling todo tracking"); - params.todoTracker.cancel(); - } - - // parse todowrite events for live progress tracking - if (toolName === "todowrite" && params.todoTracker?.enabled) { - params.todoTracker.update(event.part?.state?.input); - } - }, - tool_result: (event: OpenCodeToolResultEvent) => { - resumeActivity(); - const toolId = event.part?.callID || event.tool_id; - const state = event.part?.state; - const status = state?.status ?? event.status ?? "unknown"; - const payload = - state?.status === "completed" - ? state.output - : state?.status === "error" - ? state.error - : event.output; - const label = eventLabel(event); - - timerFor(label).markToolResult(); - - // surface subagent completion at info level โ€” opencode otherwise hides - // per-task timing in debug-only logs, so a parallel multi-lens fan-out - // looks like N dispatches followed by a long quiet gap then a single - // assistant turn. with this line you can see each lens finishing. - // - // matching is hybrid: exact callID first; FIFO fallback when the - // tool_result's callID is unrecognised. opencode does not consistently - // surface matching callIDs for the `task` tool, so the FIFO path is the - // one that fires in practice. we only fall through to FIFO when the - // callID is brand-new (not in `knownNonTaskCallIDs`) so genuinely - // non-task tool_results never accidentally pop a pending task. - if (taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0) { - if (toolId && taskDispatchByCallID.has(toolId)) { - const dispatch = taskDispatchByCallID.get(toolId); - if (dispatch) emitSubagentFinished(dispatch, status, payload, "exact"); - } else { - const callIDIsKnownNonTask = toolId ? knownNonTaskCallIDs.has(toolId) : false; - if (!callIDIsKnownNonTask && pendingTaskDispatches.length > 0) { - const dispatch = pendingTaskDispatches[0]!; - emitSubagentFinished(dispatch, status, payload, "fifo"); - } - } - } - - if (toolId) { - const toolStartTime = toolCallTimings.get(toolId); - if (toolStartTime) { - const toolDuration = performance.now() - toolStartTime; - toolCallTimings.delete(toolId); - const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : ""; - log.debug( - withLabel( - label, - `ยป ${params.label} tool_result${stepContext}: id=${toolId}, status=${status}, duration=${Math.round(toolDuration)}ms` - ) - ); - if (payload) { - log.debug(withLabel(label, ` output: ${payload}`)); - } - if (toolDuration > 5000) { - log.info( - withLabel( - label, - `ยป tool call took ${(toolDuration / 1000).toFixed(1)}s - may indicate network latency` - ) - ); - } - } - } - if (status === "error") { - log.info(withLabel(label, `ยป tool call failed: ${payload ?? "(no error message)"}`)); - } else if (payload) { - log.debug(withLabel(label, `tool output: ${payload}`)); - } - }, - 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}`); - }, - result: async (event: OpenCodeResultEvent) => { - const status = event.status || "unknown"; - const duration = event.stats?.duration_ms || 0; - const toolCalls = event.stats?.tool_calls || 0; - log.info( - `ยป ${params.label} result: status=${status}, duration=${duration}ms, tool_calls=${toolCalls}` - ); - - if (event.status === "error") { - log.info(`ยป ${params.label} failed: ${JSON.stringify(event)}`); - } else { - // the final `result` event only carries input_tokens/output_tokens and - // no cache breakdown โ€” accumulatedTokens (summed across step_finish - // events) is strictly more accurate, so we prefer it unconditionally. - log.info(`ยป run complete: tool_calls=${toolCalls}, duration=${duration}ms`); - - if ( - (accumulatedTokens.input > 0 || - accumulatedTokens.output > 0 || - accumulatedTokens.cacheRead > 0 || - accumulatedTokens.cacheWrite > 0) && - !tokensLogged - ) { - logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd }); - tokensLogged = true; - } - } - }, - [PULLFROG_BUS_EVENT_TYPE]: async (event: OpenCodeBusEnvelopeEvent) => { - // surface subagent activity that opencode's CLI run-loop discards (it - // filters `part.sessionID !== sessionID`). our injected plugin - // (action/agents/opencodePlugin.ts) re-emits non-orchestrator - // `message.part.updated` bus events; here we synthesize the equivalent - // CLI-style event for each known part type and dispatch through the - // existing handlers so labeling, attribution, and logging all reuse the - // same code path as the orchestrator's events. mirrors the dispatch - // logic in opencode-ai's `cli/cmd/run.ts` `loop()` function. - const busEvent = event.bus_event; - if (!busEvent || busEvent.type !== "message.part.updated") return; - const part = busEvent.properties?.part; - if (!part || typeof part.sessionID !== "string") return; - const sessionID = part.sessionID; - const partType = part.type; - - // early task dispatch: the orchestrator's task tool fires bus events at - // status=running BEFORE the subagent's first message.part.updated, but - // the CLI's run-loop only emits the matching tool_use NDJSON event at - // status=completed (after the subagent finishes). without - // pre-registering the dispatch label here, the labeler binds the - // subagent's sessionID to a generic `subagent#N` fallback before the - // CLI's tool_use ever fires recordTaskDispatch. dedupe against - // taskDispatchByCallID so the late tool_use handler doesn't double-add. - if (partType === "tool") { - const status = part.state?.status; - const partWithToolFields = part as { - tool?: string; - callID?: string; - state?: { status?: string; input?: unknown }; - }; - // only running (not pending) โ€” at pending state.input is still {}. - // by running, the LLM has filled in description/subagent_type/prompt. - // mirrors the same check in the plugin source. - const isOrchestratorTaskDispatch = - partWithToolFields.tool === "task" && status === "running"; - if (isOrchestratorTaskDispatch) { - const callID = partWithToolFields.callID; - if (typeof callID === "string" && !taskDispatchByCallID.has(callID)) { - const taskInput = (partWithToolFields.state?.input ?? {}) as { - description?: string; - subagent_type?: string; - prompt?: string; - }; - const dispatchedLabel = labeler.recordTaskDispatch(taskInput); - const dispatch: TaskDispatch = { - label: dispatchedLabel, - startedAt: performance.now(), - toolUseCallID: callID, - }; - taskDispatchByCallID.set(callID, dispatch); - pendingTaskDispatches.push(dispatch); - log.info( - `ยป dispatching subagent: ${dispatchedLabel}` + - (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "") - ); - } - return; - } - if (status !== "completed" && status !== "error") return; - await handlers.tool_use({ - type: "tool_use", - sessionID, - part, - } as OpenCodeToolUseEvent); - return; - } - // intentionally NOT routing subagent step_start / step_finish through - // the orchestrator's handlers: - // - step_finish carries `tokens` and `cost` and the handler folds - // them into the run-wide accumulators. surfacing subagent steps - // here would inflate the orchestrator's usage telemetry โ€” and - // either double-count (if opencode also bills child tokens back - // up to the parent session) or just over-report. the existing - // init/message/text handlers all gate on ORCHESTRATOR_LABEL for - // the same reason. - // - step_start mutates `currentStepId` / `currentStepType` / - // `stepHistory`, which are orchestrator-scoped โ€” using them to - // attribute subagent activity in the orchestrator's tool-use - // timing log would be wrong. - // the subagent's tool calls and text still surface (handled below) - // โ€” that's the user-visible activity. - if (partType === "step-start" || partType === "step-finish") return; - if (partType === "text" && part.time?.end !== undefined) { - await handlers.text({ - type: "text", - sessionID, - part, - } as OpenCodeTextEvent); - return; - } - }, - }; - - const recentStderr: string[] = []; - - let lastProviderError: string | null = null; - let agentErrorEvent: OpenCodeErrorEvent | null = null; - - // 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", - // suspend the spawn-level idle watchdog across MCP tool calls (issue - // #760). bracketed by suspendActivity()/resumeActivity() in the - // tool_use/tool_result handlers above, bounded by - // MAX_TOOL_CALL_SUSPENSION_MS in activity.ts. the injected plugin - // (action/agents/opencodePlugin.ts) re-emits subagent - // `message.part.updated` events on opencode's stdout, so subagent - // dispatches keep marking child.stdout activity as well โ€” defense - // in depth (verified empirically in PR #634, ~3.3 plugin events/sec). - isPausedExternally: isActivitySuspended, - 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)); - - const timeSinceLastActivity = getIdleMs(); - if (timeSinceLastActivity > 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 ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)` - ); - } - markActivity(); - - 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 pending task dispatches that never got a matching tool_result are - // surfaced here so the gap is visible rather than silently swallowed. - // this happens when opencode delivers the subagent's reply through a - // path other than tool_result (e.g. inlined into the next assistant - // message). flushing here is best-effort attribution โ€” the durations - // reported are upper bounds (the subagent could have finished any time - // between dispatch and run-end), but the labels and ordering are exact. - // - // NB: the `result` event handler is dead in opencode (opencode never - // emits a `result`-typed event), which is why this flush lives here in - // the post-subprocess block instead. - if (pendingTaskDispatches.length > 0) { - for (const dispatch of [...pendingTaskDispatches]) { - const elapsed = performance.now() - dispatch.startedAt; - log.info( - `ยป subagent finished (inferred at run-end): ${dispatch.label} (โ‰ค${(elapsed / 1000).toFixed(1)}s) โ€” no matching tool_result observed; subagent reply likely arrived via assistant message` - ); - } - pendingTaskDispatches.length = 0; - 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(); - - // 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; - let model = rawModel; - if (isBedrockRoute) { - model = `amazon-bedrock/${rawModel}`; - } - const vertexModel = resolveVertexOpenCodeModel(rawModel); - if (vertexModel) { - model = vertexModel; - } - - 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 - const baseArgs = ["run", "--format", "json", "--print-logs"]; - - // 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 env: Record = { - ...process.env, - ...homeEnv, - 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, - }) - ); - } - - const repoDir = process.cwd(); - - 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/opencodePlugin.ts b/agents/opencodePlugin.ts deleted file mode 100644 index 379a8d3..0000000 --- a/agents/opencodePlugin.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Source for the opencode plugin we drop into the per-run tmpdir at - * `/opencode/plugin/pullfrog-events.ts`. The harness already - * redirects `XDG_CONFIG_HOME` to `ctx.tmpdir/.config` (see `opencode.ts` - * `homeEnv`), so opencode's auto-discovery scans the tmpdir, never the user's - * working tree. opencode's `Global.Path.config` resolves to - * `path.join(xdgConfig, "opencode")` and the config layer auto-discovers - * plugins from every directory in its scan list โ€” including - * `Global.Path.config` โ€” by globbing `{plugin,plugins}/*.{ts,js}` via - * `ConfigPlugin.load(dir)`. - * - * We MUST NOT write into the user's repo working tree. The repo is a checkout - * the agent operates on; only the agent's own tools (gated by - * `OPENCODE_PERMISSION`) may modify it. The whole reason we redirect HOME and - * XDG_CONFIG_HOME is so harness-side files (config, plugins, scratch state) - * land in the tmpdir. - * - * Why this plugin exists: opencode's `task` tool runs subagents in-process and - * the CLI's `cli/cmd/run.ts` event loop filters `part.sessionID !== sessionID`, - * so subagent-internal `message.part.updated` events are silently discarded - * before reaching our parent NDJSON stream. plugins, by contrast, receive - * EVERY bus event via `bus.subscribeAll()` regardless of session. - * - * The plugin re-emits every relevant bus event onto opencode's stdout as a - * single JSON line wrapped in a sentinel envelope. our `runOpenCode` parser - * recognises the envelope, unpacks it, and routes the inner part through the - * existing handlers with a per-session label from `SessionLabeler` so each - * subagent's tool calls / text appear inline alongside the orchestrator's. - * - * Dumb plugin / smart parent split: the plugin emits every part for every - * session. the parent dedupes against the orchestrator's own session id (which - * it already knows from the `init` event). this keeps the plugin trivial and - * keeps the per-session attribution logic on the parent side where the - * SessionLabeler already lives. - * - * Event-name prefixing: the wrapped event-type sentinel is - * `pullfrog_bus_event` โ€” picked to be unmistakably ours so a future opencode - * release that introduces a coincidentally-named event type won't collide. - */ - -export const PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event" as const; - -export const PULLFROG_OPENCODE_PLUGIN_FILENAME = "pullfrog-events.ts" as const; - -/** - * Source written verbatim to `/opencode/plugin/pullfrog-events.ts`. - * - * - Structural typing only (no runtime import of `@opencode-ai/plugin`): - * opencode installs that dep into the directory containing the plugin - * alongside discovery, but a) the dep isn't required for the structural - * shape we use, and b) keeping zero imports avoids any module-resolution - * coupling to opencode's plugin-loader internals across versions. - * - default export is the plugin factory (opencode's plugin loader accepts - * default exports as the server entrypoint). - * - we only forward `message.part.updated`. that's where the user-visible - * subagent activity (tool calls, text, step transitions) lives. add more - * event types here if the parent needs them. - * - JSON.stringify+single write keeps the line atomic up to PIPE_BUF (4KB on - * Linux). longer parts may interleave with concurrent stdout writers; the - * parser tolerates non-JSON lines (logs them at debug) so a torn line is a - * missed event, not a crash. - */ -export const PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run. -// surfaces opencode subagent activity that the CLI's run-loop discards. see -// action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives -// inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside -// the user's working tree. - -const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)}; - -// the first sessionID we see on a message.part.updated event is the -// orchestrator โ€” opencode's run command creates exactly one top-level session -// before any subagent is dispatched, and the user-prompt text part fires -// before the first task tool_use. we lock that sessionID in here and use it -// to filter: the orchestrator's events are already streamed by the CLI's -// run-loop, so we only forward (a) all subagent events, and (b) the -// orchestrator's task tool dispatches at status="running". the CLI only -// emits task tool_use at status=completed (after the subagent finishes), so -// without the early announce the parent's labeler binds subagent sessions -// before recordTaskDispatch fires and the lens label is lost. -let orchestratorSessionID: string | undefined; - -function isOrchestratorTaskDispatch(part: { - type?: string; - tool?: string; - state?: { status?: string }; -}): boolean { - if (part.type !== "tool") return false; - if (part.tool !== "task") return false; - // only forward at status="running" (not "pending"). at pending the - // state.input is still {} โ€” the orchestrator has emitted the part shell - // but the LLM hasn't filled in description/subagent_type/prompt yet. by - // running, input is populated and recordTaskDispatch can derive the lens - // label correctly. - return part.state?.status === "running"; -} - -export default async function pullfrogEventsPlugin() { - return { - event: async (input: { - event: { - type: string; - properties?: { - part?: { - sessionID?: string; - type?: string; - tool?: string; - state?: { status?: string }; - }; - }; - }; - }) => { - const event = input.event; - if (!event || typeof event !== "object") return; - if (event.type !== "message.part.updated") return; - const part = event.properties?.part; - const sessionID = part?.sessionID; - if (typeof sessionID !== "string" || sessionID.length === 0) return; - if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID; - - if (sessionID === orchestratorSessionID) { - // skip orchestrator events EXCEPT early task dispatches. - if (!part || !isOrchestratorTaskDispatch(part)) return; - } - - try { - const line = JSON.stringify({ - type: PULLFROG_BUS_EVENT_TYPE, - bus_event: event, - }); - process.stdout.write(line + "\\n"); - } catch { - // a circular reference or BigInt etc. would throw; swallow rather - // than letting a single bad event take down the plugin. - } - }, - }; -} -`; diff --git a/agents/opencodeShared.ts b/agents/opencodeShared.ts deleted file mode 100644 index f99a121..0000000 --- a/agents/opencodeShared.ts +++ /dev/null @@ -1,124 +0,0 @@ -// 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 { modelAliases } from "../models.ts"; -import { log } from "../utils/cli.ts"; -import { installFromNpmTarball } from "../utils/install.ts"; -import { getAuthorizedModels } from "../utils/openCodeModels.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 using the authorized model set captured in main.ts via -// `opencode models` introspection. - -const AUTO_SELECT_WARNING = - "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this."; - -export function autoSelectModel(): string | undefined { - const authorized = getAuthorizedModels(); - if (authorized.size > 0) { - // 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 && authorized.has(a.resolve)) ?? - modelAliases.find((a) => !a.hidden && authorized.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 ${authorized.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 deleted file mode 100644 index dbb9fe4..0000000 --- a/agents/opencode_v2.ts +++ /dev/null @@ -1,1049 +0,0 @@ -/** - * 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 { resolveVertexOpenCodeModel } from "../utils/vertex.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(); - - // 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 vertexModel = resolveVertexOpenCodeModel(rawModel); - const model = vertexModel ?? (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/postRun.test.ts b/agents/postRun.test.ts deleted file mode 100644 index c8fe303..0000000 --- a/agents/postRun.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ToolState } from "../toolState.ts"; -import { getUnsubmittedReview } from "./postRun.ts"; - -function makeToolState(overrides: Partial = {}): ToolState { - return { - progressComment: undefined, - hadProgressComment: true, - prepushFailureCount: 0, - backgroundProcesses: new Map(), - usageEntries: [], - ...overrides, - }; -} - -describe("getUnsubmittedReview", () => { - it("returns null when mode is not a review mode", () => { - expect(getUnsubmittedReview(makeToolState({ selectedMode: "Build" }))).toBeNull(); - expect(getUnsubmittedReview(makeToolState())).toBeNull(); - }); - - it("returns null when a review was already submitted", () => { - expect( - getUnsubmittedReview( - makeToolState({ - selectedMode: "Review", - review: { id: 1, nodeId: "n", reviewedSha: undefined }, - }) - ) - ).toBeNull(); - }); - - it("fires for Review even when report_progress wrote a final summary", () => { - // Review's only valid exit is `create_pull_request_review`. a summary - // comment is not a substitute, and accepting it here previously let - // subagent-flipped `finalSummaryWritten` silence the gate. - expect( - getUnsubmittedReview(makeToolState({ selectedMode: "Review", finalSummaryWritten: true })) - ).toBe("Review"); - }); - - it("returns null for IncrementalReview when report_progress wrote a final summary", () => { - // IncrementalReview treats `report_progress` as a legitimate - // "no review warranted" exit, matching the post-failure error message. - expect( - getUnsubmittedReview( - makeToolState({ selectedMode: "IncrementalReview", finalSummaryWritten: true }) - ) - ).toBeNull(); - }); - - it("returns null when there is no progress comment to anchor the failure to", () => { - expect( - getUnsubmittedReview(makeToolState({ selectedMode: "Review", hadProgressComment: false })) - ).toBeNull(); - }); - - it("returns the selected mode when the gate should fire", () => { - expect(getUnsubmittedReview(makeToolState({ selectedMode: "Review" }))).toBe("Review"); - expect(getUnsubmittedReview(makeToolState({ selectedMode: "IncrementalReview" }))).toBe( - "IncrementalReview" - ); - }); -}); diff --git a/agents/postRun.ts b/agents/postRun.ts deleted file mode 100644 index 2061c34..0000000 --- a/agents/postRun.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts"; -import { NON_COMMITTING_MODES } from "../modes.ts"; -import type { ToolState } from "../toolState.ts"; -import { log } from "../utils/cli.ts"; -import { - SPAWN_ACTIVITY_TIMEOUT_CODE, - SPAWN_TIMEOUT_CODE, - SpawnTimeoutError, - spawn, -} from "../utils/subprocess.ts"; -import { - type AgentResult, - type AgentRunContext, - type AgentUsage, - buildCommitPrompt, - getGitStatus, - hasPostRunIssues, - MAX_POST_RUN_RETRIES, - mergeAgentUsage, - type PostRunIssues, - type StopHookFailure, -} from "./shared.ts"; - -/** - * derive "agent picked a review mode but never produced visible output" from - * the literal facts on `toolState`. returns the selected mode when the gate - * should fire, `null` otherwise โ€” pure read, no side effects, safe to invoke - * after every agent attempt. - * - * the gate is anchored to `hadProgressComment` so silent runs (non-issue - * events, dispatcher skipped seeding) don't fire a nudge there's no UI for. - * - * `Review` and `IncrementalReview` have different valid exits: - * - Review: only `create_pull_request_review` counts. `report_progress` is - * not a substitute โ€” a Review run that exits with just a summary comment - * has produced nothing reviewable on the PR. matches the hard-fail - * message at `expected = "create_pull_request_review"` below. - * - IncrementalReview: `report_progress` is a legitimate "no review - * warranted" exit, so either toolState flag short-circuits. - * splitting per mode also closes the bypass where a subagent (e.g. a - * `task`-dispatched `reviewfrog` lens) calls `report_progress` and silences - * the gate even though the orchestrator never submitted a review. - */ -export function getUnsubmittedReview(toolState: ToolState): "Review" | "IncrementalReview" | null { - const mode = toolState.selectedMode; - if (!toolState.hadProgressComment) return null; - if (mode === "Review") return toolState.review ? null : "Review"; - if (mode === "IncrementalReview") { - return toolState.review || toolState.finalSummaryWritten ? null : "IncrementalReview"; - } - return null; -} - -/** - * hook output can flow into two size-sensitive places: the LLM resume prompt - * (context window) and AgentResult.error (surfaced in GitHub comments capped - * at 65535 chars). truncate the tail to keep both bounded; the tail is - * usually the most actionable part of a failing script's output. - */ -const MAX_HOOK_OUTPUT_CHARS = 4096; - -function truncateHookOutput(raw: string): string { - if (raw.length <= MAX_HOOK_OUTPUT_CHARS) return raw; - return `...(truncated, showing last ${MAX_HOOK_OUTPUT_CHARS} chars)\n${raw.slice(-MAX_HOOK_OUTPUT_CHARS)}`; -} - -/** - * run the user-configured stop hook. - * - * parallel to `executeLifecycleHook` (which soft-fails with a warning), but - * returns structured output so agent harnesses can feed the failure back into - * the session as a resume prompt. - * - * - non-zero exit โ†’ `StopHookFailure`, actionable: the output is fed to the - * agent so it can fix the underlying issue. - * - timeout / spawn error โ†’ null, treated as passed: we can't usefully ask the - * agent to fix an infrastructure problem, and retrying would risk infinite - * loops. - */ -export async function executeStopHook(script: string): Promise { - log.info("ยป executing stop hook..."); - try { - const result = await spawn({ - cmd: "bash", - args: ["-c", script], - env: process.env, - timeout: LIFECYCLE_HOOK_TIMEOUT_MS, - activityTimeout: 0, - onStdout: (chunk) => process.stdout.write(chunk), - onStderr: (chunk) => process.stderr.write(chunk), - }); - if (result.exitCode === 0) { - log.info("ยป stop hook passed"); - return null; - } - // include both streams โ€” scripts often emit a benign warning to stderr - // and the actionable error to stdout (or vice versa), and picking one - // starves the agent of the diagnostic it needs. stderr-first so stdout - // (typically longer, where truncation is more likely to bite) keeps its - // tail โ€” summaries/totals usually live at the end. - const combined = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n"); - const output = truncateHookOutput(combined); - log.info(`ยป stop hook failed with exit code ${result.exitCode}`); - return { exitCode: result.exitCode, output }; - } catch (err) { - const isTimeout = - err instanceof SpawnTimeoutError && - (err.code === SPAWN_TIMEOUT_CODE || err.code === SPAWN_ACTIVITY_TIMEOUT_CODE); - const msg = err instanceof Error ? err.message : String(err); - log.warning( - `stop hook ${isTimeout ? "timed out" : "failed to spawn"}: ${msg} โ€” skipping retry` - ); - return null; - } -} - -export function buildStopHookPrompt(failure: StopHookFailure): string { - return [ - `STOP HOOK FAILED โ€” the repo-configured stop hook exited with code ${failure.exitCode}. your work is not done until the hook exits cleanly. address the issue below and push any resulting changes to a pull request.`, - "", - "```", - failure.output || "(no output)", - "```", - ].join("\n"); -} - -/** check whether the seeded summary file is byte-identical to its seed. - * a missing or unreadable file returns false (don't nudge โ€” the agent - * may have legitimately deleted it, or the seed step failed; the read- - * back path in main.ts handles both cases by skipping persist). */ -async function isSummaryUnchanged(filePath: string, seed: string): Promise { - try { - const current = await readFile(filePath, "utf8"); - return current === seed; - } catch { - return false; - } -} - -export function buildSummaryStalePrompt(filePath: string): string { - return [ - `PR SUMMARY UNTOUCHED โ€” the rolling PR summary file at \`${filePath}\` is byte-identical to its seed; this run did not edit it.`, - "", - "review the diff and update the file in place to reflect what changed in the PR. update intent, key changes, and any risks worth flagging โ€” keep the existing section headings stable so incremental runs produce clean diffs.", - "", - "if the diff is genuinely too small or noisy to warrant rewriting (e.g. a one-line typo fix, a comment tweak, a formatting-only change), it's fine to leave the structure as-is โ€” but at minimum confirm you considered it by appending one line to the appropriate section noting the run. silence is not an option; the snapshot is what the next review run reads as context.", - ].join("\n"); -} - -export function buildUnsubmittedReviewPrompt(mode: "Review" | "IncrementalReview"): string { - // mode-aware: Review mode's contract is "always submit one review" โ€” its - // mode prompt forbids `report_progress`, so the nudge here must not offer - // it as an exit. IncrementalReview legitimately allows a report_progress - // exit when there are no new issues since the last review (mode prompt - // step 8), so the nudge mirrors that contract. - if (mode === "Review") { - return [ - `MISSING REVIEW OUTPUT โ€” you selected Review mode but stopped without calling \`create_pull_request_review\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`, - "", - "call `create_pull_request_review` now with your aggregated review (body + inline comments). pick the tier per the mode prompt โ€” Review mode has no no-submit exit, so even informational `> โœ… No new issues found.` reviews must be submitted (with `approved: true`). the first call may error once with a diff-coverage nudge โ€” retry the same call to proceed.", - "", - "do NOT stop again until `create_pull_request_review` has been called successfully.", - ].join("\n"); - } - return [ - `MISSING REVIEW OUTPUT โ€” you selected IncrementalReview mode but stopped without calling \`create_pull_request_review\` or \`report_progress\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`, - "", - "do exactly one of:", - "- if you have findings: call `create_pull_request_review` now with your aggregated review (body + inline comments). the first call may error once with a diff-coverage nudge โ€” retry the same call to proceed.", - "- if there are genuinely no actionable findings since the last review (e.g. only formatting / comment / lockfile changes): call `report_progress` with a 1-2 sentence summary explaining that no review was warranted.", - "", - "do NOT stop again until one of those tools has been called successfully.", - ].join("\n"); -} - -/** - * check the post-run gates: did the stop hook pass, is the working tree - * clean, and (when applicable) did the agent touch the rolling PR summary - * snapshot or produce review output? returns everything that still needs - * nudging so the caller can render a single combined resume prompt. - * - * reads run state directly off `ctx.toolState` so each invocation sees the - * latest mutations from MCP tool calls. `skipSummaryStale` lets the loop - * suppress the summary-stale check after the one-shot nudge has been - * delivered (re-firing it would burn the retry budget on a soft gate the - * agent has already decided not to act on). - */ -export async function collectPostRunIssues( - ctx: AgentRunContext, - options: { skipSummaryStale?: boolean } = {} -): Promise { - const issues: PostRunIssues = {}; - // stop hook is disabled โ€” production audit (May 2026) showed 8/9 configured - // scripts are foot-guns (duplicates of prepushScript, run on non-committing - // modes against unchanged trees) burning the retry budget on un-fixable - // gates. re-enable here + the dashboard block in `AgentSettings.tsx` once - // we've decided on the right semantics (mode-gating vs. HEAD-changed gating - // vs. deletion). see issue #714. - // if (ctx.stopScript) { - // const failure = await executeStopHook(ctx.stopScript); - // if (failure) issues.stopHook = failure; - // } - // dirty-tree gate fires only in modes that legitimately commit. Review / - // IncrementalReview / Plan complete via review submission or a Plan - // comment, not by touching files โ€” any tree dirt is incidental (e.g. a - // tool-installed `node_modules/`) and the worktree is ephemeral, so - // nudging the agent to commit it would produce a spurious PR. see - // `NON_COMMITTING_MODES` in `action/modes.ts`. - const status = getGitStatus(); - const mode = ctx.toolState.selectedMode; - if (status) { - if (mode && NON_COMMITTING_MODES.has(mode)) { - log.info(`ยป dirty-tree gate suppressed: mode \`${mode}\` does not commit`); - } else { - issues.dirtyTree = status; - } - } - const summaryFilePath = ctx.toolState.summaryFilePath; - const summarySeed = ctx.toolState.summarySeed; - if (!options.skipSummaryStale && summaryFilePath && summarySeed !== undefined) { - const stale = await isSummaryUnchanged(summaryFilePath, summarySeed); - if (stale) issues.summaryStale = { filePath: summaryFilePath }; - } - const unsubmittedMode = getUnsubmittedReview(ctx.toolState); - if (unsubmittedMode) issues.unsubmittedReview = unsubmittedMode; - return issues; -} - -export function buildPostRunPrompt(issues: PostRunIssues): string { - // order matches the terminal hard-fail order in `runPostRunRetryLoop` so - // the prompt's emphasis (which gate the agent should fix first) lines up - // with the user-visible failure message reported when retries exhaust. - // both hard-fail gates first (`stopHook` โ†’ `unsubmittedReview`), then the - // soft gates (`dirtyTree` โ†’ `summaryStale`). - const parts: string[] = []; - if (issues.stopHook) parts.push(buildStopHookPrompt(issues.stopHook)); - if (issues.unsubmittedReview) { - parts.push(buildUnsubmittedReviewPrompt(issues.unsubmittedReview)); - } - if (issues.dirtyTree) parts.push(buildCommitPrompt(issues.dirtyTree)); - if (issues.summaryStale) parts.push(buildSummaryStalePrompt(issues.summaryStale.filePath)); - return parts.join("\n\n---\n\n"); -} - -/** - * modes for which the post-run reflection turn is skipped. reflection costs a - * full resume turn (~$0.50-0.80 per run on Opus, mostly cache-write) and only - * pays for itself when the run actually produced novel, durable findings. - * - * `IncrementalReview` is the lowest-novelty mode โ€” it's a tight delta review - * against an existing PR with the prior summary already loaded as context. - * the agent rarely discovers anything generalizable to next runs, so the - * reflection turn is dead weight. initial `Review` still touches fresh PR - * territory and benefits; `Build` / `Fix` / `AddressReviews` definitely do. - */ -const REFLECTION_SKIP_MODES: ReadonlySet = new Set(["IncrementalReview"]); - -export function shouldRunReflection(mode: string | undefined): boolean { - if (!mode) return true; - return !REFLECTION_SKIP_MODES.has(mode); -} - -/** - * prompt for a dedicated post-run reflection turn nudging the agent to edit - * the rolling learnings file if it discovered anything worth persisting. - * - * this exists because passive "if you learned something, write it down" - * instructions baked into mode checklists are frequently ignored โ€” the agent - * stays focused on the task and the meta-ask falls through. delivering it - * as its own resume turn, with nothing competing for attention, raises the - * fire rate substantially. - * - * the file is the single source of truth โ€” there is no separate MCP tool - * call. the server reads the file at end-of-run and persists any edits to - * `Repo.learnings`. - * - * the prompt copy is shaped by repo-wide audits of the actual content the - * agent has been writing (issue #619 in pullfrog/app). recurring failure - * modes the framing pushes back on: - * - massive multi-paragraph "bullets" that are really mini-articles - * - facts anchored to moving repo state (PR / review / commit / branch - * refs, dates, version pins, line numbers) that decay within weeks - * - sections growing into giant flat lists with no internal structure, - * forcing future runs to read kilobytes to find one fact - * - * single litmus delivered in the prompt: "would a future run on this repo - * do its work better because this bullet exists?". tool-quirk workarounds - * are explicitly allowed when the agent burned calls discovering the - * quirk this run โ€” recording the workaround prevents next run from - * repeating the waste. tradeoff: the same quirk gets duplicated across - * repos, so when a quirk is fixed upstream in tool descriptions the - * per-repo bullets go stale and we have no batch-invalidation path. - */ -export function buildLearningsReflectionPrompt(filePath: string): string { - return [ - `REFLECTION โ€” before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that is high-confidence and would reliably help future runs?`, - "", - `the rolling learnings file is at \`${filePath}\`. read it first if you haven't already, then edit it in place using your native file tools. the server reads this file at end-of-run and persists any changes โ€” there is no tool to call.`, - "", - `structure:`, - `- markdown hierarchy: \`## \` for top-level themes, \`### \` and deeper for sub-themes when a section grows. there is no fixed taxonomy โ€” choose headings that fit THIS repo (e.g. for one repo \`## Migrations\` / \`## Local dev\` may make sense; for another, \`## API quirks\` / \`## Failure modes\`).`, - `- **no section over ~300 lines.** when a section is approaching that, split it: introduce \`### \` subsections grouping related bullets, or hoist a coherent group into a new top-level \`## \` section. granular sections mean future runs read targeted line ranges instead of slurping the whole file. this is the most important hygiene rule on long-lived repos.`, - `- if you find a flat unstructured list (legacy content from before this format), restructure it: read it, group related bullets, rewrite the file with \`## \` / \`### \` headings around them. don't preserve bad structure โ€” fix it.`, - "", - `the only test: would a future run on this repo do its work better because this bullet exists? useful for future runs in this repo โ€” prevent wasted tool calls, rabbit holes, and mistakes.`, - "", - `bullet hygiene:`, - `- one fact per line starting with \`- \`, โ‰ค 240 chars.`, - `- only add when high-confidence, broadly useful, evergreen.`, - `- prune wrong or low-signal bullets; merge overlaps; dedupe across sections.`, - "", - `don't anchor facts to repo state that will move: PR / review / commit / branch refs, dates, version pins, line numbers. state the rule directly. if it needs the anchor to be load-bearing, it isn't evergreen.`, - "", - `tool-quirk bullets are fine when you burned calls discovering the quirk and a future run would repeat them. write the workaround, not the war story.`, - "", - `if you have nothing substantively new to add AND the existing entries still look healthy and well-structured, leave the file alone โ€” just reply "done" and stop. silence is a valid outcome.`, - "", - `do NOT call \`set_output\` during this turn. the task's result output was already set on the previous turn; this reflection is a meta-turn for the learnings file only. ignore any standing instruction to call \`set_output\` "when done" โ€” it does not apply here.`, - ].join("\n"); -} - -/** - * shared post-run retry loop used by every agent harness. - * - * checks the post-run gates (stop hook + dirty tree), and if either is - * failing, invokes `resume` to let the agent fix and push in the same turn. - * bails at `MAX_POST_RUN_RETRIES` attempts. the `canResume` predicate is - * consulted before each retry โ€” harnesses that can't re-enter the session - * (e.g. claude without a sessionId) return false here. - * - * an optional `reflectionPrompt` fires exactly once, after the gates first - * observe a clean state. it's a one-shot nudge (e.g. "update learnings if - * relevant"), not a gate, so it does not consume the gate-retry budget. if - * the reflection turn dirties the tree, the loop picks that up on the next - * iteration via the normal dirty-tree gate. - * - * stop hook must pass for the run to succeed; persistent hook failures are - * surfaced as `AgentResult.error`. dirty-tree-only failures preserve prior - * behavior: they're logged but don't fail the run. - */ -export async function runPostRunRetryLoop(params: { - ctx: AgentRunContext; - initialResult: R; - initialUsage: AgentUsage | undefined; - resume: (context: { prompt: string; previousResult: R }) => Promise; - canResume?: ((result: R) => boolean) | undefined; - reflectionPrompt?: string | undefined; -}): Promise { - let result = params.initialResult; - let aggregatedUsage = params.initialUsage; - let finalIssues: PostRunIssues = {}; - let gateResumeCount = 0; - let pendingReflection = params.reflectionPrompt; - // nudge for an untouched summary file fires AT MOST ONCE per run. once - // delivered, subsequent collectPostRunIssues calls skip the check โ€” the - // agent may have legitimately decided no edit is warranted, and - // re-prompting would burn the retry budget without adding signal. - let summaryStaleNudged = false; - - while (gateResumeCount < MAX_POST_RUN_RETRIES) { - if (!result.success) break; - const issues = await collectPostRunIssues(params.ctx, { - skipSummaryStale: summaryStaleNudged, - }); - if (issues.summaryStale) summaryStaleNudged = true; - finalIssues = issues; - - if (!hasPostRunIssues(issues)) { - // gates are clean. if a reflection prompt is pending, deliver it once - // and loop back to re-check โ€” the reflection may have touched the tree. - if (!pendingReflection) break; - if (params.canResume && !params.canResume(result)) break; - log.info("ยป post-run reflection: nudging agent to update learnings if relevant"); - const preReflection = result; - // reflection is a meta-turn for editing the learnings file. it must not - // affect the user-visible `result` output: some models (notably Gemini - // Pro) re-trigger on the initial "call set_output when done" system - // instruction during this turn and clobber the task-turn value with the - // literal word "done". the prompt itself tells the agent not to call - // set_output (defense one); we also snapshot + restore as defense two. - const preReflectionOutput = params.ctx.toolState.output; - const reflectionResult = await params.resume({ - prompt: pendingReflection, - previousResult: result, - }); - params.ctx.toolState.output = preReflectionOutput; - aggregatedUsage = mergeAgentUsage(aggregatedUsage, reflectionResult.usage); - pendingReflection = undefined; - if (!reflectionResult.success) { - // reflection is a best-effort nudge. its failure must not flip a - // successful run to failed โ€” the gated work is already done. keep - // the pre-reflection result and exit without re-running the gates - // (which would risk a flaky false-positive hook failure right after - // it just passed). - log.warning( - `ยป reflection turn failed (${reflectionResult.error ?? "unknown error"}), preserving prior successful result` - ); - result = preReflection; - break; - } - // reflection replies are meta-asks ("done", "updated learnings with N - // bullets") โ€” not a task summary. keep the pre-reflection output so - // the returned AgentResult still reflects what the run accomplished, - // while inheriting reflection-specific fields the harness needs for - // any subsequent gate retry (e.g. the new sessionId claude emits per - // --resume invocation). - // use `||` (not `??`) so an empty pre-reflection output falls through - // to the reflection's reply. runs that only emit MCP tool calls and no - // plain text leave result.output = "" โ€” keeping "" would starve the - // fallback path in handleAgentResult of anything to show. - result = { - ...reflectionResult, - output: preReflection.output || reflectionResult.output, - }; - continue; - } - - // checks still ran even if we can't resume, so the failure gate below - // can still catch a persistent stop-hook failure. - if (params.canResume && !params.canResume(result)) { - log.info("ยป post-run retry skipped: cannot resume agent session"); - break; - } - - log.info(`ยป post-run retry (attempt ${gateResumeCount + 1}/${MAX_POST_RUN_RETRIES})`); - const prompt = buildPostRunPrompt(issues); - // summary-stale is a soft gate that must never flip a successful run to - // failed. when it's the only issue and the resume itself errors out, - // restore the pre-resume successful result and break โ€” persistSummary - // detects the unchanged file via its seed comparison and skips the DB - // write on its own, so no further coordination is needed here. - const onlySummaryStale = - issues.summaryStale !== undefined && - issues.stopHook === undefined && - issues.dirtyTree === undefined; - const preResume = result; - result = await params.resume({ prompt, previousResult: result }); - aggregatedUsage = mergeAgentUsage(aggregatedUsage, result.usage); - if (!result.success && onlySummaryStale) { - log.warning( - `ยป summary-stale resume turn failed (${result.error ?? "unknown error"}), preserving prior successful result` - ); - result = preResume; - break; - } - gateResumeCount++; - } - - // we exhausted retries without observing a clean state โ€” finalIssues - // reflects pre-resume state, so re-check to see what the last resume - // actually did. when the subprocess failed we skip: its own error is more - // actionable than a stale "stop hook still failing" message. when the loop - // already observed a clean state we skip: re-running the hook risks flaky - // false-positive failures right after it just passed. - if (gateResumeCount > 0 && result.success && hasPostRunIssues(finalIssues)) { - // re-check the gates that can actually fail the run (stop hook / - // dirty tree / unsubmitted review). summary-stale is intentionally - // NOT re-checked here: we already delivered the one-shot nudge, and - // a still-unchanged file at this point is the agent's deliberate - // choice. - finalIssues = await collectPostRunIssues(params.ctx, { skipSummaryStale: true }); - } - - if (result.success && finalIssues.stopHook) { - const retryNote = - gateResumeCount > 0 - ? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}` - : ""; - return { - ...result, - success: false, - error: `stop hook failed${retryNote} (exit code ${finalIssues.stopHook.exitCode}): ${finalIssues.stopHook.output || "(no output)"}`, - usage: aggregatedUsage, - }; - } - - if (result.success && finalIssues.unsubmittedReview) { - const retryNote = - gateResumeCount > 0 - ? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}` - : ""; - // mode-aware: Review's contract requires a review submission; only - // IncrementalReview accepts `report_progress` as an exit. mirroring - // the nudge prompt avoids contradicting the agent-facing copy. - const expected = - finalIssues.unsubmittedReview === "Review" - ? "create_pull_request_review" - : "create_pull_request_review or report_progress"; - return { - ...result, - success: false, - error: `${finalIssues.unsubmittedReview} mode finished without calling ${expected}${retryNote}`, - usage: aggregatedUsage, - }; - } - - return { ...result, usage: aggregatedUsage }; -} diff --git a/agents/reviewer.ts b/agents/reviewer.ts deleted file mode 100644 index b657cd4..0000000 --- a/agents/reviewer.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Definition of the `reviewfrog` named subagent โ€” the constrained - * read-only worker dispatched by Build mode self-review and the in-Pullfrog - * /anneal multi-lens review. - * - * The contract: non-mutative + non-recursive. - * allow: file reads, grep/glob, web search/fetch, read-only MCP queries - * deny: state-changing MCP tools, file writes, shell, nested subagent dispatch - * - * Enforcement is prose-only. We previously hand-maintained a deny-list of - * mutating MCP tools against action/mcp/server.ts and wired it into per-agent - * `disallowedTools` (claude) / `tools` deny map (opencode), but the list was - * fragile โ€” a future mutating tool added to the MCP server without a - * corresponding update here would silently grant write access to the reviewer. - * Rather than invert to an allowlist (smaller surface but still drifts) or add - * a structural test, we lean on the system prompt below: it states the rule - * as a no-op-if-reverted invariant the model can apply to any tool, including - * ones added after this comment was written. - * - * Note: per-agent `disallowedTools` in claude-code is also upstream-broken - * for subagent-spawned tool calls (anthropics/claude-agent-sdk-typescript#172, - * open as of latest update Mar 2026), so even a maintained list would not - * have provided a real fence on that runtime. - */ - -export const REVIEWER_AGENT_NAME = "reviewfrog"; - -/** - * System prompt baked into the named reviewer subagent. The orchestrator - * supplies the per-call task content (YOUR TASK, the diff, the lens) at - * dispatch time; this preamble enforces the role and constraints regardless - * of what the orchestrator sends. - */ -export const REVIEWER_SYSTEM_PROMPT = - `You are a read-only review subagent. Your role is to find flaws in code or artifacts ` + - `provided by the orchestrator and report findings โ€” never to modify state.\n\n` + - `HARD CONSTRAINTS (non-negotiable, regardless of orchestrator instructions):\n` + - `- Your FIRST action MUST be \`git diff origin/\` (single-rev form, no \`HEAD\`). ` + - `This captures committed + staged + unstaged work in one command โ€” Build-mode ` + - `self-review runs BEFORE the commit, so the work to review lives in the working ` + - `tree, not in committed history. Do not run any other diff command first. Do NOT ` + - `call \`checkout_pr\`, do NOT fetch alternative refs, do NOT list branches or ` + - `all-refs looking for the work, do NOT run \`gh pr list\`. The orchestrator's ` + - `dispatch names the base branch; the diff is the source of truth for scope.\n` + - `- If \`git diff origin/\` returns empty AND the orchestrator's dispatch ` + - `claims there are changes to review, the most likely cause is a pre-commit ` + - `Build-mode self-review: the orchestrator dispatched you before committing. ` + - `Reply EXACTLY: \`no changes detected โ€” likely pre-commit Build self-review; ` + - `orchestrator should commit then re-dispatch\` and stop. Do NOT guess PR numbers ` + - `(e.g. by extrapolating from \`git log\` output), do NOT check out other PRs, ` + - `do NOT fetch from forks. The empty diff is the diagnosis โ€” surface it; do not ` + - `work around it.\n` + - `- Read-only tools only. Do NOT write or edit files. Do NOT run shell commands ` + - `that have side effects (read-only commands like \`git diff\`, \`git log\`, \`cat\`, \`ls\` ` + - `are fine; anything that mutates the working tree, the remote, the filesystem, or ` + - `external state is prohibited).\n` + - `- Do NOT call any state-changing MCP tool. State-changing means: posts a comment, ` + - `pushes a branch, creates/updates a PR or issue, changes labels, resolves review ` + - `threads, persists learnings, sets workflow output, installs dependencies, uploads ` + - `files, kills processes, etc. Read-only MCP queries (\`get_*\`, \`list_*\`, log ` + - `inspection, diff retrieval) are fine.\n` + - `- Do NOT spawn further subagents. You are a leaf reviewer; recursive dispatch ` + - `pre-aggregates findings through an intermediate model and defeats the design.\n` + - `- Test for any tool call before invoking it: would this still be a no-op if ` + - `reverted? If not, do not call it. Apply this test to tools added after this ` + - `prompt was written โ€” the rule is the invariant, not the enumeration.\n\n` + - `Report findings clearly with file:line references and quoted evidence where ` + - `possible. Flag uncertainty explicitly โ€” if you cannot verify a claim, say so ` + - `rather than guess.`; diff --git a/agents/sessionLabeler.test.ts b/agents/sessionLabeler.test.ts deleted file mode 100644 index 983f967..0000000 --- a/agents/sessionLabeler.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - deriveLabelFromTaskInput, - formatWithLabel, - ORCHESTRATOR_LABEL, - SessionLabeler, -} from "./sessionLabeler.ts"; - -describe("deriveLabelFromTaskInput", () => { - test("prefers explicit lens marker in prompt over description", () => { - expect( - deriveLabelFromTaskInput({ - prompt: "lens: security\nReview the diff for...", - description: "general review", - }) - ).toBe("lens:security"); - }); - - test("supports lens= alternative syntax", () => { - expect( - deriveLabelFromTaskInput({ - prompt: "lens=user-journey\nWalk through the happy path...", - }) - ).toBe("lens:user-journey"); - }); - - test("falls back to description when no lens marker present", () => { - expect( - deriveLabelFromTaskInput({ - prompt: "Review this diff for any bugs", - description: "Auth lens", - }) - ).toBe("lens:auth-lens"); - }); - - test("falls back to subagent_type when description and lens marker absent", () => { - expect( - deriveLabelFromTaskInput({ - prompt: "Some generic prompt", - subagent_type: "reviewfrog", - }) - ).toBe("reviewfrog"); - }); - - test("returns generic subagent when nothing identifiable", () => { - expect(deriveLabelFromTaskInput({})).toBe("subagent"); - }); - - test("slug normalizes whitespace and special chars", () => { - expect( - deriveLabelFromTaskInput({ - description: "Schema migration & operational readiness!", - }) - ).toBe("lens:schema-migration-operational-readiness"); - }); - - test("slug truncates labels longer than 40 chars to keep prefix readable", () => { - expect( - deriveLabelFromTaskInput({ - description: "this is a very long lens description that exceeds the slug limit", - }) - ).toBe("lens:this-is-a-very-long-lens-description-tha"); - }); - - test("ignores lens marker mid-line โ€” must be at line start", () => { - expect( - deriveLabelFromTaskInput({ - prompt: "Please review the lens: security claim made above", - description: "billing", - }) - ).toBe("lens:billing"); - }); -}); - -describe("SessionLabeler", () => { - test("first session seen is the orchestrator", () => { - const labeler = new SessionLabeler(); - expect(labeler.labelFor("ses-A")).toBe(ORCHESTRATOR_LABEL); - // bound โ€” same session returns same label on second call - expect(labeler.labelFor("ses-A")).toBe(ORCHESTRATOR_LABEL); - expect(labeler.size()).toBe(1); - }); - - test("FIFO matches dispatched labels to new sessions in dispatch order", () => { - const labeler = new SessionLabeler(); - // orchestrator session - labeler.labelFor("parent"); - - // orchestrator dispatches 3 tasks in one assistant turn - labeler.recordTaskDispatch({ description: "security" }); - labeler.recordTaskDispatch({ description: "correctness" }); - labeler.recordTaskDispatch({ description: "user journey" }); - - expect(labeler.pendingDispatchCount()).toBe(3); - - // children appear (potentially interleaved) - expect(labeler.labelFor("child-1")).toBe("lens:security"); - expect(labeler.labelFor("child-2")).toBe("lens:correctness"); - expect(labeler.labelFor("child-3")).toBe("lens:user-journey"); - - expect(labeler.pendingDispatchCount()).toBe(0); - expect(labeler.size()).toBe(4); - }); - - test("interleaved events from parent and children resolve to stable labels", () => { - const labeler = new SessionLabeler(); - labeler.labelFor("parent"); - labeler.recordTaskDispatch({ description: "security" }); - labeler.recordTaskDispatch({ description: "correctness" }); - - // child-1 emits an event first (its label binds) - expect(labeler.labelFor("child-1")).toBe("lens:security"); - // parent emits some events in between - expect(labeler.labelFor("parent")).toBe(ORCHESTRATOR_LABEL); - // child-2 finally appears - expect(labeler.labelFor("child-2")).toBe("lens:correctness"); - // child-1 emits more events โ€” still the same label - expect(labeler.labelFor("child-1")).toBe("lens:security"); - }); - - test("falls back to subagent#N when child appears without a queued dispatch", () => { - const labeler = new SessionLabeler(); - labeler.labelFor("parent"); - // no recordTaskDispatch โ€” but a child appears anyway (defensive path) - expect(labeler.labelFor("ghost")).toBe("subagent#1"); - expect(labeler.labelFor("ghost-2")).toBe("subagent#2"); - }); - - test("undefined/null/empty sessionID resolves to orchestrator label without binding", () => { - const labeler = new SessionLabeler(); - expect(labeler.labelFor(undefined)).toBe(ORCHESTRATOR_LABEL); - expect(labeler.labelFor(null)).toBe(ORCHESTRATOR_LABEL); - expect(labeler.labelFor("")).toBe(ORCHESTRATOR_LABEL); - // size stays zero โ€” those calls didn't bind anything - expect(labeler.size()).toBe(0); - }); - - test("entries returns insertion-ordered (sessionID, label) pairs", () => { - const labeler = new SessionLabeler(); - labeler.labelFor("parent"); - labeler.recordTaskDispatch({ description: "security" }); - labeler.labelFor("child-1"); - expect(labeler.entries()).toEqual([ - ["parent", ORCHESTRATOR_LABEL], - ["child-1", "lens:security"], - ]); - }); - - test("Claude path: parent_tool_use_id resolves directly without consuming FIFO", () => { - // Claude runs subagents inside the orchestrator's session โ€” they share - // session_id โ€” and stamps subagent messages with parent_tool_use_id. - // recording dispatch with the Agent tool_use id binds it directly so - // future events resolve regardless of session_id. - const labeler = new SessionLabeler(); - expect(labeler.labelFor("shared-session", null)).toBe(ORCHESTRATOR_LABEL); - - labeler.recordTaskDispatch({ description: "correctness" }, "toolu_01"); - labeler.recordTaskDispatch({ description: "security" }, "toolu_02"); - - // subagent events come through with shared session_id but distinct - // parent_tool_use_id โ€” direct mapping wins - expect(labeler.labelFor("shared-session", "toolu_01")).toBe("lens:correctness"); - expect(labeler.labelFor("shared-session", "toolu_02")).toBe("lens:security"); - - // orchestrator events on the same session still resolve correctly - expect(labeler.labelFor("shared-session", null)).toBe(ORCHESTRATOR_LABEL); - - // pendingLabels is unused on the Claude path โ€” FIFO never consumed - expect(labeler.pendingDispatchCount()).toBe(2); - expect(labeler.size()).toBe(1); - }); - - test("Claude path: unknown parent_tool_use_id falls through to sessionID/FIFO logic", () => { - // defensive: if a subagent event arrives with a parent_tool_use_id we - // never recorded (e.g. orchestrator dispatched off-stream, or a tool we - // didn't track), the labeler shouldn't crash โ€” it should fall through - // to the sessionID-keyed path. - const labeler = new SessionLabeler(); - labeler.labelFor("shared", null); - expect(labeler.labelFor("shared", "unknown-tool-id")).toBe(ORCHESTRATOR_LABEL); - }); - - test("realistic four-lens parallel fan-out โ€” interleaved tool_use stream", () => { - // simulates the event order we'd see when the orchestrator dispatches - // 4 lens subagents in a single assistant turn and they all start emitting - // tool_use events more or less concurrently. - const labeler = new SessionLabeler(); - - // 1. orchestrator's `init` event - expect(labeler.labelFor("p")).toBe(ORCHESTRATOR_LABEL); - - // 2. orchestrator emits 4 task tool_use events back-to-back - labeler.recordTaskDispatch({ description: "correctness & invariants" }); - labeler.recordTaskDispatch({ description: "security" }); - labeler.recordTaskDispatch({ description: "user journey" }); - labeler.recordTaskDispatch({ description: "schema migration" }); - - // 3. children emit in arbitrary interleaved order - const observed: Array<[string, string]> = []; - for (const session of ["c1", "c2", "p", "c3", "c1", "c4", "c2", "p"]) { - observed.push([session, labeler.labelFor(session)]); - } - - expect(observed).toEqual([ - ["c1", "lens:correctness-invariants"], - ["c2", "lens:security"], - ["p", ORCHESTRATOR_LABEL], - ["c3", "lens:user-journey"], - ["c1", "lens:correctness-invariants"], - ["c4", "lens:schema-migration"], - ["c2", "lens:security"], - ["p", ORCHESTRATOR_LABEL], - ]); - - expect(labeler.size()).toBe(5); - expect(labeler.pendingDispatchCount()).toBe(0); - }); -}); - -describe("formatWithLabel", () => { - test("prefixes a single-line message with magenta-wrapped label", () => { - const out = formatWithLabel("orchestrator", "hello world"); - expect(out).toContain("[orchestrator]"); - expect(out).toContain("hello world"); - // ANSI magenta + reset markers around the bracketed label (escapes - // built via fromCharCode to satisfy biome's no-control-character-in-regex) - const ESC = String.fromCharCode(27); - expect(out).toMatch(new RegExp(`${ESC}\\[35m\\[orchestrator\\]${ESC}\\[0m hello world$`)); - }); - - test("prefixes every line of a multi-line message", () => { - const out = formatWithLabel("lens:security", "line one\nline two\nline three"); - const lines = out.split("\n"); - expect(lines).toHaveLength(3); - for (const line of lines) { - expect(line).toContain("[lens:security]"); - } - expect(lines[0]).toContain("line one"); - expect(lines[1]).toContain("line two"); - expect(lines[2]).toContain("line three"); - }); - - test("handles empty input without throwing", () => { - const out = formatWithLabel("orchestrator", ""); - expect(out).toContain("[orchestrator]"); - }); -}); diff --git a/agents/sessionLabeler.ts b/agents/sessionLabeler.ts deleted file mode 100644 index 7e8bd41..0000000 --- a/agents/sessionLabeler.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Track per-session labels so log lines from parallel subagents can be - * differentiated. The orchestrator dispatches lens subagents (e.g. reviewfrog) - * via the Task tool; each subagent runs in its own opencode/claude Session - * with its own `sessionID` (or `session_id`) tag on the NDJSON event stream. - * - * Without per-session prefixing, parallel subagent tool_use / tool_result / - * text events appear as a single interleaved stream tagged with `[Pullfrog]`, - * making it impossible for a human reading the logs to attribute work to a - * specific lens. - * - * The labeler is deliberately runtime-agnostic โ€” both opencode.ts and - * claude.ts feed it the same shape. The contract is FIFO: when the orchestrator - * dispatches N task tool_use blocks in a single assistant turn (the parallel - * fan-out the multi-lens prompt requires), the i-th new sessionID is assumed - * to belong to the i-th task dispatch. This is correct as long as parallel - * dispatches are emitted in source-order and the runtimes respect that order - * when assigning child sessions; we do not depend on it for correctness of - * the read-only contract โ€” only for log readability. - */ - -export interface TaskDispatchInput { - description?: string | undefined; - subagent_type?: string | undefined; - prompt?: string | undefined; -} - -export const ORCHESTRATOR_LABEL = "orchestrator"; - -const LENS_PROMPT_PATTERN = /^\s*(?:lens|Lens|LENS)\s*[:=]\s*([A-Za-z][\w &/.-]{0,60})/m; - -function slug(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^\w-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40); -} - -/** - * Extract a human-readable label from a Task tool's input. Tries (in order): - * 1. explicit `lens: ` marker on a line in the prompt โ€” preferred, - * lets the orchestrator name the lens deterministically - * 2. the Task tool's `description` field โ€” short, written by orchestrator - * per call, usually enough - * 3. the `subagent_type` (e.g. `reviewfrog`) โ€” falls back to the named - * subagent identity when description is missing - * 4. generic "subagent" โ€” last resort - */ -export function deriveLabelFromTaskInput(input: TaskDispatchInput): string { - if (typeof input.prompt === "string") { - const match = input.prompt.match(LENS_PROMPT_PATTERN); - if (match?.[1]) { - const slugged = slug(match[1]); - if (slugged) return `lens:${slugged}`; - } - } - if (input.description) { - const slugged = slug(input.description); - if (slugged) return `lens:${slugged}`; - } - if (input.subagent_type) { - return input.subagent_type; - } - return "subagent"; -} - -/** - * Stateful tracker mapping subagent activity back to human-readable labels. - * - * Two attribution channels are supported because the runtimes differ: - * - * - **OpenCode** spawns each subagent as its own opencode `Session` with - * a distinct `sessionID`. The harness records each Task dispatch into a - * pending FIFO queue; the next previously-unseen sessionID consumes the - * head of the queue and binds it to that label. - * - * - **Claude Code** runs subagents inside the orchestrator's session โ€” they - * all share `session_id` โ€” and instead stamps every subagent message with - * `parent_tool_use_id` pointing at the Agent tool_use id that spawned them. - * The harness binds each Agent tool_use id to its dispatched label up - * front, then `labelFor` looks the label up directly when an event arrives - * carrying that `parent_tool_use_id`. - * - * `labelFor(sessionID, parentToolUseId?)` accepts both: when - * `parentToolUseId` is set and known it short-circuits to the direct mapping; - * otherwise it falls through to the FIFO/sessionID path. - */ -export class SessionLabeler { - private readonly labels = new Map(); - private readonly labelsByToolUseId = new Map(); - private readonly pendingLabels: string[] = []; - private fallbackCounter = 0; - - /** - * Record a Task/Agent tool dispatch. - * - * @param input Task tool input โ€” used to derive the lens label. - * @param toolUseId Optional Agent tool_use id. When provided, future events - * carrying `parent_tool_use_id === toolUseId` resolve - * directly to this label without consuming the FIFO queue - * (Claude path). Always also pushed to the FIFO queue so - * the OpenCode path still works when toolUseId is absent. - */ - recordTaskDispatch(input: TaskDispatchInput, toolUseId?: string | null): string { - const label = deriveLabelFromTaskInput(input); - this.pendingLabels.push(label); - if (toolUseId) this.labelsByToolUseId.set(toolUseId, label); - return label; - } - - /** - * Return a label for the given event. - * - * @param sessionID Session id from the event (OpenCode: per-session; - * Claude: shared across orchestrator + subagents). - * @param parentToolUseId Claude's `parent_tool_use_id` โ€” non-null on - * subagent messages. When set and known, takes - * priority over the FIFO/sessionID path. - */ - labelFor(sessionID: string | undefined | null, parentToolUseId?: string | null): string { - // Claude path: subagent messages carry parent_tool_use_id pointing at - // the Agent tool_use that spawned them. resolve directly without - // touching the sessionID-keyed map (which is bound to the orchestrator - // for the shared session_id and would otherwise misattribute). - if (parentToolUseId) { - const direct = this.labelsByToolUseId.get(parentToolUseId); - if (direct) return direct; - } - - if (!sessionID) return ORCHESTRATOR_LABEL; - const existing = this.labels.get(sessionID); - if (existing) return existing; - - let label: string; - if (this.labels.size === 0) { - label = ORCHESTRATOR_LABEL; - } else if (this.pendingLabels.length > 0) { - label = this.pendingLabels.shift() as string; - } else { - this.fallbackCounter += 1; - label = `subagent#${this.fallbackCounter}`; - } - this.labels.set(sessionID, label); - return label; - } - - /** number of distinct sessions seen so far (for diagnostics) */ - size(): number { - return this.labels.size; - } - - /** all (sessionID, label) pairs, oldest first */ - entries(): Array<[string, string]> { - return Array.from(this.labels.entries()); - } - - /** how many pending labels are queued waiting to bind to a new session */ - pendingDispatchCount(): number { - return this.pendingLabels.length; - } -} - -/** - * Format a log message with a session label prefix in magenta. Mirrors the - * style of utils/log.ts:prefixLines() so per-session prefixes look the same - * as the dormant withLogPrefix-based ones. - */ -export function formatWithLabel(label: string, message: string): string { - const MAGENTA = "\x1b[35m"; - const RESET = "\x1b[0m"; - const colored = `${MAGENTA}[${label}]${RESET} `; - return message - .split("\n") - .map((line) => `${colored}${line}`) - .join("\n"); -} diff --git a/agents/shared.test.ts b/agents/shared.test.ts deleted file mode 100644 index 1a1626e..0000000 --- a/agents/shared.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { type AgentUsage, mergeAgentUsage } from "./shared.ts"; - -const entry = (overrides: Partial): AgentUsage => ({ - agent: "pullfrog", - inputTokens: 0, - outputTokens: 0, - ...overrides, -}); - -describe("mergeAgentUsage", () => { - it("returns undefined when both sides are undefined", () => { - expect(mergeAgentUsage(undefined, undefined)).toBeUndefined(); - }); - - it("returns a copy of b when a is undefined", () => { - const b = entry({ inputTokens: 10 }); - expect(mergeAgentUsage(undefined, b)).toEqual(b); - }); - - it("returns a copy of a when b is undefined", () => { - const a = entry({ inputTokens: 10 }); - expect(mergeAgentUsage(a, undefined)).toEqual(a); - }); - - it("sums inputTokens and outputTokens unconditionally", () => { - const merged = mergeAgentUsage( - entry({ inputTokens: 10, outputTokens: 5 }), - entry({ inputTokens: 20, outputTokens: 7 }) - ); - expect(merged?.inputTokens).toBe(30); - expect(merged?.outputTokens).toBe(12); - }); - - it("keeps cache/cost fields undefined when both sides lack them", () => { - // this matters so downstream aggregateUsage doesn't persist spurious 0s into the DB - const merged = mergeAgentUsage(entry({ inputTokens: 10 }), entry({ inputTokens: 20 })); - expect(merged?.cacheReadTokens).toBeUndefined(); - expect(merged?.cacheWriteTokens).toBeUndefined(); - expect(merged?.costUsd).toBeUndefined(); - }); - - it("sums cache and cost fields when either side reports them", () => { - const merged = mergeAgentUsage( - entry({ inputTokens: 10, cacheReadTokens: 100, costUsd: 0.01 }), - entry({ inputTokens: 20, cacheWriteTokens: 50, costUsd: 0.02 }) - ); - expect(merged?.cacheReadTokens).toBe(100); - expect(merged?.cacheWriteTokens).toBe(50); - expect(merged?.costUsd).toBeCloseTo(0.03, 10); - }); - - it("preserves the agent id of the left operand", () => { - // the aggregator is called inside a single agent's run() โ€” the agent label - // is a fixed property of the harness, not something that can flip mid-run - const merged = mergeAgentUsage( - entry({ agent: "claude", inputTokens: 10 }), - entry({ agent: "something-else", inputTokens: 20 }) - ); - expect(merged?.agent).toBe("claude"); - }); - - it("returns a fresh object rather than the input reference", () => { - // callers treat AgentUsage as immutable; returning the input itself would - // leak that invariant. mutating the returned value must not affect inputs. - const a = entry({ inputTokens: 10 }); - const mergedWithUndef = mergeAgentUsage(a, undefined); - expect(mergedWithUndef).not.toBe(a); - expect(mergedWithUndef).toEqual(a); - - const b = entry({ inputTokens: 20 }); - const mergedFromUndef = mergeAgentUsage(undefined, b); - expect(mergedFromUndef).not.toBe(b); - expect(mergedFromUndef).toEqual(b); - }); -}); diff --git a/agents/shared.ts b/agents/shared.ts index cee4802..9d53037 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -6,17 +6,8 @@ import type { ResolvedInstructions } from "../utils/instructions.ts"; import type { ResolvedPayload } from "../utils/payload.ts"; import type { TodoTracker } from "../utils/todoTracking.ts"; -// maximum number of stderr lines to keep in the rolling buffer during agent execution export const MAX_STDERR_LINES = 20; -// โ”€โ”€ post-run retry loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -/** - * how many times the post-run loop may resume the agent to fix a dirty tree - * or a failing stop hook before giving up. - */ -export const MAX_POST_RUN_RETRIES = 3; - export function getGitStatus(): string { try { return execFileSync("git", ["status", "--porcelain"], { @@ -28,146 +19,33 @@ export function getGitStatus(): string { } } -export function buildCommitPrompt(status: string): string { - return [ - `UNCOMMITTED CHANGES โ€” the working tree is dirty. push all changes to a pull request (new or existing). \`git status\` must be clean before you finish.`, - "", - "```", - status, - "```", - ].join("\n"); -} - -export interface StopHookFailure { - exitCode: number; - output: string; -} - -export interface SummaryStale { - /** absolute path to the seeded snapshot file the agent was meant to edit. */ - filePath: string; -} - -export interface PostRunIssues { - stopHook?: StopHookFailure; - dirtyTree?: string; - /** populated when the rolling PR summary file is byte-identical to its - * seed, i.e. the agent never touched it. soft gate โ€” nudges once via a - * resume turn but never fails the run, parallel to dirtyTree semantics. */ - summaryStale?: SummaryStale; - /** - * populated when the agent selected a review mode but the post-run check - * over toolState shows neither a `create_pull_request_review` submission - * nor a final `report_progress` write happened. derived inline from - * `toolState.selectedMode` + `toolState.review` + `toolState.finalSummaryWritten` - * via {@link getUnsubmittedReview} โ€” no parallel toolState flag is stored. - * carries the mode name so the resume prompt can reference it. handled like - * `stopHook`: nudge via resume, hard-fail if still unsatisfied after - * `MAX_POST_RUN_RETRIES`. - */ - unsubmittedReview?: "Review" | "IncrementalReview"; -} - -export function hasPostRunIssues(issues: PostRunIssues): boolean { - return ( - issues.stopHook !== undefined || - issues.dirtyTree !== undefined || - issues.summaryStale !== undefined || - issues.unsubmittedReview !== undefined - ); -} - -/** - * token/cost usage data from a single agent run. - * - * NOTE on semantics: `inputTokens` here is the *total* billable input for the - * run โ€” non-cached input + cache read + cache write โ€” matching the per-agent - * SDK conventions. This is what gets persisted to `WorkflowRun.inputTokens`. - * - * The stdout token table and markdown step summary display a different "Input" - * column that shows only the non-cached portion (derivable as - * `inputTokens - cacheReadTokens - cacheWriteTokens`) so humans can see the - * cache hit ratio at a glance. Dashboards that query `WorkflowRun.inputTokens` - * directly are seeing the full total, not the log column. - */ -export interface AgentUsage { - agent: string; - /** full billable input: non-cached + cache read + cache write */ - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number | undefined; - cacheWriteTokens?: number | undefined; - costUsd?: number | undefined; -} - export interface AgentToolUseEvent { toolName: string; input: unknown; } -/** - * Result returned by agent execution - */ export interface AgentResult { success: boolean; output?: string | undefined; error?: string | undefined; metadata?: Record; - usage?: AgentUsage | undefined; } -/** - * Context passed to agent.run() and threaded through the post-run loop. - * - * design rule: this is the single object that flows through the harness and - * downstream utilities by reference. derived predicates (e.g. - * `getUnsubmittedReview`), tmpfile paths, and seed bytes live on - * `toolState` โ€” read them at the call site, do not duplicate them onto this - * interface. utilities that need run state should accept `ctx` whole, not - * destructure a narrow subset. - */ export interface AgentRunContext { payload: ResolvedPayload; - resolvedModel?: string | undefined; + model?: string | undefined; mcpServerUrl: string; tmpdir: string; - /** harness-owned secret paths that agent filesystem tools must never read. */ - secretDenyPaths?: string[] | undefined; instructions: ResolvedInstructions; todoTracker?: TodoTracker | undefined; - /** - * user-configured stop hook script. runs after the agent finishes each - * attempt; non-zero exit resumes the agent with the hook output as - * guidance. null when the repo has no stop hook configured. - */ stopScript?: string | null | undefined; - /** - * mutable per-run state shared with the MCP server (by reference). post-run - * gates read fresh values from it after each agent attempt โ€” `summaryFilePath`, - * `summarySeed`, `selectedMode`, `review`, `finalSummaryWritten`, - * `hadProgressComment` are all consulted by `collectPostRunIssues`. see - * `action/toolState.ts` for the literal-state design rule. - */ toolState: ToolState; - /** - * called synchronously when the agent subprocess is killed for inner - * activity timeout. lets main.ts tear down shared resources (MCP HTTP - * server) so lingering SSE reconnects don't keep the outer timer alive. - */ onActivityTimeout?: (() => void) | undefined; onToolUse?: ((event: AgentToolUseEvent) => void) | undefined; - /** - * Pullfrog API JWT scoped to this run. agents only need this when they - * have to write state back to Pullfrog mid-run (today: opencode.ts uses - * it to seed the post-hook's writeback envelope for Codex auth refresh). - * empty string when the run wasn't context-resolved (e.g. local dry-runs). - */ - apiToken: string; } export interface Agent { name: AgentId; - install: (token?: string) => Promise; run: (ctx: AgentRunContext) => Promise; } @@ -180,96 +58,3 @@ export const agent = (input: Agent): Agent => { }, }; }; - -/** format a USD cost to 4 decimal places, always showing the leading zero */ -export function formatCostUsd(costUsd: number): string { - return costUsd.toFixed(4); -} - -/** - * merge two AgentUsage snapshots into one running total. - * - * both agent harnesses invoke their runner multiple times per `run()` when the - * post-run retry loop kicks in (MAX_POST_RUN_RETRIES). each invocation - * produces its own AgentUsage; we sum them so downstream callers (usage - * summary, WorkflowRun persistence) see the whole session โ€” not just the - * final retry's slice. - * - * returns `undefined` when both sides are empty so callers can short-circuit - * without a special case. zero-valued cache / cost fields are dropped to - * `undefined` for symmetry with each harness's `buildUsage`. - */ -export function mergeAgentUsage( - a: AgentUsage | undefined, - b: AgentUsage | undefined -): AgentUsage | undefined { - // always return a fresh object โ€” callers treat AgentUsage as immutable, and - // returning `a` / `b` directly would leak that invariant to future callers - if (!a && !b) return undefined; - if (!a) return { ...(b as AgentUsage) }; - if (!b) return { ...a }; - const cacheRead = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); - const cacheWrite = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0); - const cost = (a.costUsd ?? 0) + (b.costUsd ?? 0); - return { - agent: a.agent, - inputTokens: a.inputTokens + b.inputTokens, - outputTokens: a.outputTokens + b.outputTokens, - cacheReadTokens: cacheRead > 0 ? cacheRead : undefined, - cacheWriteTokens: cacheWrite > 0 ? cacheWrite : undefined, - costUsd: cost > 0 ? cost : undefined, - }; -} - -/** - * unified per-run token table used by every agent harness. - * - * columns are kept stable across agents and models so downstream log parsers - * (scripts/token-usage.ts, cost dashboards) only have to understand one format: - * - * Input non-cached input tokens sent this run - * Cache Read input tokens served from prompt cache (Anthropic, etc.) - * Cache Write input tokens written to prompt cache this run - * Output assistant output tokens - * Total sum of the four columns โ€” the real billable quantity - * Cost ($) USD cost reported by the provider (only rendered when known) - * - * models that don't report prompt caching leave Cache Read / Write at 0. - * OpenCode emits per-step `part.cost` sourced from models.dev (works across - * Anthropic, OpenAI, Google, xAI, DeepSeek, Moonshot, OpenRouter, etc.); - * Claude CLI emits `total_cost_usd` on its final `result` event. pass the - * accumulated value via `costUsd` to render the Cost column. - */ -export function logTokenTable(t: { - input: number; - cacheRead: number; - cacheWrite: number; - output: number; - costUsd?: number | undefined; -}): void { - const total = t.input + t.cacheRead + t.cacheWrite + t.output; - // narrow costUsd to a concrete number so the render path doesn't need a cast - const costUsd = typeof t.costUsd === "number" && t.costUsd > 0 ? t.costUsd : undefined; - - const headerRow: Array<{ data: string; header: true }> = [ - { data: "Input", header: true }, - { data: "Cache Read", header: true }, - { data: "Cache Write", header: true }, - { data: "Output", header: true }, - { data: "Total", header: true }, - ]; - const dataRow: string[] = [ - String(t.input), - String(t.cacheRead), - String(t.cacheWrite), - String(t.output), - String(total), - ]; - - if (costUsd !== undefined) { - headerRow.push({ data: "Cost ($)", header: true }); - dataRow.push(formatCostUsd(costUsd)); - } - - log.table([headerRow, dataRow]); -} diff --git a/agents/subagentModels.test.ts b/agents/subagentModels.test.ts deleted file mode 100644 index f136b64..0000000 --- a/agents/subagentModels.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { deriveSubagentModels } from "./subagentModels.ts"; - -describe("deriveSubagentModels", () => { - it("returns no override when orchestrator is undefined", () => { - expect(deriveSubagentModels(undefined)).toEqual({ reviewer: undefined }); - }); - - it("returns no override when orchestrator slug isn't registered", () => { - expect(deriveSubagentModels("nonexistent/model")).toEqual({ reviewer: undefined }); - }); - - describe("anthropic family โ€” opus โ†’ sonnet", () => { - it("direct anthropic opus", () => { - expect(deriveSubagentModels("anthropic/claude-opus-4-7")).toEqual({ - reviewer: "anthropic/claude-sonnet-4-6", - }); - }); - it("opencode-vendored opus stays on opencode prefix", () => { - expect(deriveSubagentModels("opencode/claude-opus-4-7")).toEqual({ - reviewer: "opencode/claude-sonnet-4-6", - }); - }); - it("openrouter-anthropic-opus-via-anthropic-direct hits anthropic alias's openRouterResolve", () => { - // both the anthropic alias and the opencode alias have the same - // openRouterResolve. first-match-wins by alias declaration order - // (anthropic declared first in providers). - expect(deriveSubagentModels("openrouter/anthropic/claude-opus-4.7")).toEqual({ - reviewer: "openrouter/anthropic/claude-sonnet-4.6", - }); - }); - it("sonnet has no further downshift", () => { - expect(deriveSubagentModels("anthropic/claude-sonnet-4-6")).toEqual({ reviewer: undefined }); - expect(deriveSubagentModels("opencode/claude-sonnet-4-6")).toEqual({ reviewer: undefined }); - }); - it("haiku has no downshift", () => { - expect(deriveSubagentModels("anthropic/claude-haiku-4-5")).toEqual({ reviewer: undefined }); - }); - }); - - describe("openai family", () => { - it("gpt-pro โ†’ gpt (direct)", () => { - expect(deriveSubagentModels("openai/gpt-5.5-pro")).toEqual({ reviewer: "openai/gpt-5.5" }); - }); - it("gpt โ†’ gpt-5.4 (direct)", () => { - expect(deriveSubagentModels("openai/gpt-5.5")).toEqual({ reviewer: "openai/gpt-5.4" }); - }); - it("gpt โ†’ gpt-5.4 (opencode-vendored)", () => { - expect(deriveSubagentModels("opencode/gpt-5.5")).toEqual({ reviewer: "opencode/gpt-5.4" }); - }); - it("gpt-pro โ†’ gpt (openrouter)", () => { - expect(deriveSubagentModels("openrouter/openai/gpt-5.5-pro")).toEqual({ - reviewer: "openrouter/openai/gpt-5.5", - }); - }); - it("gpt โ†’ gpt-5.4 (openrouter)", () => { - expect(deriveSubagentModels("openrouter/openai/gpt-5.5")).toEqual({ - reviewer: "openrouter/openai/gpt-5.4", - }); - }); - it("gpt-5.4 itself (the hidden subagent target) has no further downshift", () => { - expect(deriveSubagentModels("openai/gpt-5.4")).toEqual({ reviewer: undefined }); - }); - it("gpt-mini has no downshift", () => { - expect(deriveSubagentModels("openai/gpt-5.4-mini")).toEqual({ reviewer: undefined }); - }); - }); - - describe("google (gemini) โ€” inherit (Pro for both orchestrator and lenses)", () => { - // pro โ†’ flash was a meaningful capability cliff (Flash missed catastrophic - // cross-file bugs the v4 e2e test surfaced); Pro is cost-effective enough - // to keep on for lenses too. Google has no in-between tier. - it("direct google pro inherits", () => { - expect(deriveSubagentModels("google/gemini-3.1-pro-preview")).toEqual({ - reviewer: undefined, - }); - }); - it("opencode-vendored gemini-pro inherits", () => { - expect(deriveSubagentModels("opencode/gemini-3.1-pro")).toEqual({ - reviewer: undefined, - }); - }); - it("openrouter gemini-pro inherits", () => { - expect(deriveSubagentModels("openrouter/google/gemini-3.1-pro-preview")).toEqual({ - reviewer: undefined, - }); - }); - it("flash has no downshift", () => { - expect(deriveSubagentModels("google/gemini-3-flash-preview")).toEqual({ - reviewer: undefined, - }); - }); - }); - - describe("providers / models without a subagentModel โ€” inherit", () => { - it("xai grok (already cheap flagship)", () => { - expect(deriveSubagentModels("xai/grok-4.3")).toEqual({ reviewer: undefined }); - }); - it("deepseek", () => { - expect(deriveSubagentModels("deepseek/deepseek-v4-pro")).toEqual({ reviewer: undefined }); - }); - it("moonshot kimi", () => { - expect(deriveSubagentModels("moonshotai/kimi-k2.6")).toEqual({ reviewer: undefined }); - }); - it("opencode big-pickle", () => { - expect(deriveSubagentModels("opencode/big-pickle")).toEqual({ reviewer: undefined }); - }); - it("legacy fallback aliases (gpt-codex, deepseek-reasoner)", () => { - expect(deriveSubagentModels("openai/gpt-5.3-codex")).toEqual({ reviewer: undefined }); - expect(deriveSubagentModels("deepseek/deepseek-reasoner")).toEqual({ reviewer: undefined }); - }); - }); -}); diff --git a/agents/subagentModels.ts b/agents/subagentModels.ts deleted file mode 100644 index 163f4e9..0000000 --- a/agents/subagentModels.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { modelAliases } from "../models.ts"; - -/** - * Derive a cheaper subagent model override from the orchestrator's resolved - * model spec. - * - * This is a pure registry lookup: every alias in `action/models.ts` declares - * its own `subagentModel` (alias key in the same provider). At runtime we - * reverse-lookup the orchestrator's resolved slug to find the alias that - * produced it, follow the `subagentModel` pointer, and return the target - * alias's resolve / openRouterResolve depending on which route the - * orchestrator was using. - * - * Returns `{ reviewer: undefined }` when the orchestrator's alias has no - * `subagentModel` (e.g. it's already at a sufficiently cheap tier, or its - * provider doesn't have a clean cheaper-but-capable sibling). See models.ts - * for the wiring + per-provider rationale. - */ -export function deriveSubagentModels(orchestratorSpec: string | undefined): { - reviewer: string | undefined; -} { - if (!orchestratorSpec) return { reviewer: undefined }; - - // Reverse-lookup. The same resolve string appears in only one alias - // (within its provider), so first match wins. We track which field - // matched (resolve vs openRouterResolve) so we can pick the same field - // off the subagent target โ€” keeping the orchestrator's route consistent. - for (const source of modelAliases) { - const matchedDirect = source.resolve === orchestratorSpec; - const matchedOR = source.openRouterResolve === orchestratorSpec; - if (!matchedDirect && !matchedOR) continue; - if (!source.subagentModel) return { reviewer: undefined }; - const target = modelAliases.find((a) => a.slug === source.subagentModel); - if (!target) return { reviewer: undefined }; - const reviewer = matchedOR ? target.openRouterResolve : target.resolve; - return { reviewer }; - } - - return { reviewer: undefined }; -} diff --git a/agents/subagentRegistration.test.ts b/agents/subagentRegistration.test.ts deleted file mode 100644 index b33a897..0000000 --- a/agents/subagentRegistration.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -const claudeSource = readFileSync(join(__dirname, "claude.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 - * only places where per-subagent model overrides take effect. They're built - * by string-only helpers we don't export, so this test reads the source and - * asserts the literal model strings + agent names are wired in. A regression - * here means the next review run silently runs lenses on Opus instead of - * Sonnet. - */ -describe("subagent registration source asserts", () => { - describe("claude.ts buildAgentsJson", () => { - it("registers reviewfrog with sonnet model", () => { - expect(claudeSource).toMatch( - /\[REVIEWER_AGENT_NAME\]:\s*\{[^}]*model:\s*"claude-sonnet-4-6"/s - ); - }); - it("imports the reviewer name constant", () => { - expect(claudeSource).toMatch(/REVIEWER_AGENT_NAME/); - }); - }); - - describe("opencodeShared.ts buildReviewerAgentConfig", () => { - it("registers reviewfrog with mode: subagent", () => { - expect(opencodeSharedSource).toMatch(/\[REVIEWER_AGENT_NAME\]:[^}]*mode:\s*"subagent"/s); - }); - it("uses deriveSubagentModels for the reviewer model override", () => { - expect(opencodeSharedSource).toMatch(/deriveSubagentModels\(/); - expect(opencodeSharedSource).toMatch(/overrides\.reviewer/); - }); - it("v2 runner passes orchestrator model to buildReviewerAgentConfig", () => { - expect(opencodeV2Source).toMatch(/buildReviewerAgentConfig\(model\)/); - }); - }); -}); diff --git a/cli.ts b/cli.ts deleted file mode 100644 index 30e340a..0000000 --- a/cli.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { basename } from "node:path"; -import arg from "arg"; -import pc from "picocolors"; -import { runCli as runAuthCli } from "./commands/auth.ts"; -import { runCli as runGhaCli } from "./commands/gha.ts"; -import { runCli as runInitCli } from "./commands/init.ts"; - -const VERSION = process.env.CLI_VERSION ?? "0.0.0"; -const bin = basename(process.argv[1] || ""); -const PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog"; -const rawArgs = process.argv.slice(2); - -function printMainUsage(stream: typeof console.log): void { - stream(`usage: ${PROG} \n`); - stream("commands:"); - stream(" init set up pullfrog on the current repository"); - stream(" auth manage provider credentials for the current repository"); - stream(""); - stream("global options:"); - stream(" -h, --help show help"); - stream(" -v, --version show version"); -} - -function parseGlobalArgs(args: string[]) { - return arg( - { - "--help": Boolean, - "--version": Boolean, - "-h": "--help", - "-v": "--version", - }, - { - argv: args, - stopAtPositional: true, - } - ); -} - -function exitWithUsageError(message: string): never { - console.error(`${message}\n`); - printMainUsage(console.error); - process.exit(1); -} - -async function run(): Promise { - let globalParsed: ReturnType; - try { - globalParsed = parseGlobalArgs(rawArgs); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - exitWithUsageError(message); - } - - if (globalParsed["--version"]) { - console.log(VERSION); - process.exit(0); - } - - const command = globalParsed._[0]; - const commandArgs = globalParsed._.slice(1); - - if (!command) { - if (globalParsed["--help"]) { - console.log(`${pc.bold("pullfrog")} v${VERSION}\n`); - printMainUsage(console.log); - process.exit(0); - } - printMainUsage(console.log); - process.exit(0); - } - - if (command === "init") { - await runInitCli({ - args: commandArgs, - prog: PROG, - showHelp: globalParsed["--help"] === true, - }); - return; - } - - if (command === "gha") { - await runGhaCli({ - args: commandArgs, - prog: PROG, - showHelp: globalParsed["--help"] === true, - }); - return; - } - - if (command === "auth") { - await runAuthCli({ - args: commandArgs, - prog: PROG, - showHelp: globalParsed["--help"] === true, - }); - return; - } - - if (globalParsed["--help"]) { - printMainUsage(console.log); - process.exit(0); - } - - console.error(`unknown command: ${pc.bold(command)}\n`); - printMainUsage(console.error); - process.exit(1); -} - -try { - await run(); -} catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(pc.red(message)); - process.exit(1); -} diff --git a/commands/_shared.ts b/commands/_shared.ts deleted file mode 100644 index 7846ac1..0000000 --- a/commands/_shared.ts +++ /dev/null @@ -1,229 +0,0 @@ -// shared helpers used by `init` and `auth` subcommands. these were originally -// inlined in `init.ts`; pulled out so `auth.ts` can reuse them without -// duplicating gh-auth/pullfrog-api/secret-save logic. - -import { execFileSync } from "node:child_process"; -import * as p from "@clack/prompts"; -import pc from "picocolors"; - -export const PULLFROG_API_URL = (process.env.PULLFROG_API_URL || "https://pullfrog.com").replace( - /\/+$/, - "" -); - -// active spinner reference so bail/cancel can stop it before exiting. shared -// across init/auth subcommands via this module's singleton scope; whichever -// command starts a spinner sets this so handleCancel/bail can clean up. -let activeSpin: ReturnType | null = null; - -export function setActiveSpin(spin: ReturnType | null): void { - activeSpin = spin; -} - -export function bail(msg: string): never { - if (activeSpin) { - activeSpin.stop(pc.red("failed")); - activeSpin = null; - } - p.cancel(msg); - process.exit(1); -} - -export function handleCancel(value: T | symbol): asserts value is T { - if (p.isCancel(value)) { - if (activeSpin) { - activeSpin.stop(pc.red("canceled.")); - activeSpin = null; - } - p.cancel("canceled."); - process.exit(0); - } -} - -export function getGhToken(): string { - let token: string; - try { - token = execFileSync("gh", ["auth", "token"], { encoding: "utf-8" }).trim(); - } catch { - bail( - `gh cli not found or not authenticated.\n` + - ` ${pc.dim("install:")} https://cli.github.com\n` + - ` ${pc.dim("then:")} gh auth login` - ); - } - if (!token) { - bail( - `gh cli returned an empty token. try re-authenticating:\n` + - ` ${pc.dim("run:")} gh auth login` - ); - } - return token; -} - -export function parseGitRemote(): { owner: string; repo: string } { - let url: string; - try { - url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).trim(); - } catch { - bail("not a git repository or no 'origin' remote found."); - } - - const match = url.match(/github\.com(?::\d+)?[:/]+([^/]+)\/(.+?)(?:\.git)?(?:\/)?$/); - if (!match) bail(`could not parse github owner/repo from remote: ${url}`); - return { owner: match[1], repo: match[2] }; -} - -// โ”€โ”€ Pullfrog API โ”€โ”€ - -type SecretsApiData = { - error?: string; - appSlug?: string; - installationId?: number | null; - repositorySelection?: string | null; - isOrg?: boolean; - accessible?: boolean; - repoSecrets?: string[]; - orgSecrets?: string[]; - pullfrogSecrets?: string[]; - repoStatus?: string | null; - repoModel?: string | null; - hasRuns?: boolean; -}; - -type SecretsInfo = { - isOrg: boolean; - installationId: number | null; - secretsAccessible: boolean; - repoSecrets: string[]; - orgSecrets: string[]; - pullfrogSecrets: string[]; - model: string | null; - hasRuns: boolean; -}; - -type InstallationNotFound = { - appSlug: string; - installationId: number | null; - repositorySelection: "all" | "selected" | null; - isOrg: boolean; -}; - -type StatusResult = - | ({ installed: true } & SecretsInfo) - | ({ installed: false } & InstallationNotFound); - -type ApiResult> = { - ok: boolean; - status: number; - data: T; -}; - -async function pullfrogApi>(ctx: { - path: string; - token: string; - method?: string; - body?: Record; -}): Promise> { - const headers: Record = { authorization: `Bearer ${ctx.token}` }; - if (ctx.body) headers["content-type"] = "application/json"; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30_000); - try { - const response = await fetch(`${PULLFROG_API_URL}${ctx.path}`, { - method: ctx.method || "GET", - headers, - body: ctx.body ? JSON.stringify(ctx.body) : null, - signal: controller.signal, - }); - const data = (await response.json().catch(() => ({}))) as T; - return { ok: response.ok, status: response.status, data }; - } finally { - clearTimeout(timeout); - } -} - -export async function fetchStatus(ctx: { - token: string; - owner: string; - repo: string; -}): Promise { - const result = await pullfrogApi({ - path: `/api/cli/secrets?owner=${encodeURIComponent(ctx.owner)}&repo=${encodeURIComponent(ctx.repo)}`, - token: ctx.token, - }); - - if (!result.ok) { - const errorMsg = result.data.error || ""; - if (result.status === 401) bail("invalid or expired github token."); - if (result.status === 404) { - const sel = result.data.repositorySelection; - if (!result.data.appSlug) bail("server did not return appSlug"); - return { - installed: false, - appSlug: result.data.appSlug, - installationId: - typeof result.data.installationId === "number" ? result.data.installationId : null, - repositorySelection: sel === "all" || sel === "selected" ? sel : null, - isOrg: result.data.isOrg === true, - }; - } - bail(errorMsg || `secrets check failed (${result.status})`); - } - - return { - installed: true, - isOrg: result.data.isOrg === true, - installationId: - typeof result.data.installationId === "number" ? result.data.installationId : null, - secretsAccessible: result.data.accessible !== false, - repoSecrets: result.data.repoSecrets || [], - orgSecrets: result.data.orgSecrets || [], - pullfrogSecrets: result.data.pullfrogSecrets || [], - model: result.data.repoModel ?? null, - hasRuns: result.data.hasRuns === true, - }; -} - -// โ”€โ”€ secret save โ”€โ”€ - -export type SecretScope = "account" | "repo"; - -type PullfrogSecretResult = { saved: boolean; error: string }; - -export async function setPullfrogSecret(ctx: { - token: string; - owner: string; - repo: string; - name: string; - value: string; - scope: SecretScope; -}): Promise { - const result = await pullfrogApi<{ success?: boolean; error?: string }>({ - path: "/api/cli/secrets", - token: ctx.token, - method: "POST", - body: { - owner: ctx.owner, - repo: ctx.repo, - name: ctx.name, - value: ctx.value, - scope: ctx.scope, - }, - }); - if (result.ok && result.data.success === true) { - return { saved: true, error: "" }; - } - return { saved: false, error: result.data.error || `api returned ${result.status}` }; -} - -export async function promptScope(ctx: { owner: string; repo: string }): Promise { - const scope = await p.select({ - message: "secret scope", - options: [ - { value: "account", label: `${ctx.owner} organization`, hint: "shared across repos" }, - { value: "repo", label: `${ctx.owner}/${ctx.repo} only` }, - ], - }); - handleCancel(scope); - return scope; -} diff --git a/commands/auth.ts b/commands/auth.ts deleted file mode 100644 index 2a1ab52..0000000 --- a/commands/auth.ts +++ /dev/null @@ -1,324 +0,0 @@ -// `pullfrog auth ` โ€” manage credentials for a configured repo -// without going through the full `init` flow. currently supports: -// -// pullfrog auth codex mint a Codex subscription credential and save it -// as the `CODEX_AUTH_JSON` Pullfrog secret -// -// the `codex` subcommand runs `codex login --device-auth` against an -// isolated `CODEX_HOME` (so the user's existing ~/.codex/auth.json is never -// touched), validates the resulting auth.json, and posts it to the Pullfrog -// 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"; -import { mintCodexAuth, refreshCodexAuth } from "../utils/codexAuth.ts"; -import { - bail, - fetchStatus, - getGhToken, - handleCancel, - PULLFROG_API_URL, - parseGitRemote, - promptScope, - setActiveSpin, - setPullfrogSecret, -} from "./_shared.ts"; - -const CODEX_AUTH_SECRET = "CODEX_AUTH_JSON"; - -/** strip CSI ANSI escapes (color, cursor) from a string so callers can re-style - * the visible text without inheriting the source's formatting. covers what - * Codex emits during device auth (mostly `\x1b[m` color codes). - */ -function stripAnsi(s: string): string { - // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are control chars by design - 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; - showHelp?: boolean; -} - -function printAuthUsage(params: { stream: typeof console.log; prog: string }): void { - params.stream(`usage: ${params.prog} auth \n`); - params.stream("manage provider credentials for the current repository."); - params.stream(""); - params.stream("providers:"); - params.stream(" codex mint a Codex (ChatGPT) subscription credential"); - params.stream(""); - params.stream("options:"); - params.stream(" -h, --help show help"); -} - -function printCodexUsage(params: { stream: typeof console.log; prog: string }): void { - params.stream(`usage: ${params.prog} auth codex [options]\n`); - params.stream("mint a Codex subscription credential and save it as CODEX_AUTH_JSON."); - params.stream(""); - params.stream("options:"); - params.stream(" -h, --help show help"); -} - -export async function runCli(params: AuthCliParams): Promise { - // route `auth --help` (no subcommand) to top-level usage. when the user - // passes `auth codex --help`, we leave the flag in the rest args so the - // subcommand's own parser handles it. - const firstArg = params.args[0]; - const helpAtTopLevel = - params.showHelp || - params.args.length === 0 || - (params.args.length === 1 && (firstArg === "--help" || firstArg === "-h")); - if (helpAtTopLevel) { - printAuthUsage({ stream: console.log, prog: params.prog }); - return; - } - - const subcommand = firstArg; - const rest = params.args.slice(1); - - if (subcommand === "codex") { - await runCodex({ args: rest, prog: params.prog }); - return; - } - - console.error(`unknown auth provider: ${pc.bold(subcommand)}\n`); - printAuthUsage({ stream: console.error, prog: params.prog }); - process.exit(1); -} - -interface CodexCliParams { - args: string[]; - prog: string; -} - -function parseCodexArgs(args: string[]) { - return arg( - { - "--help": Boolean, - "-h": "--help", - }, - { argv: args } - ); -} - -async function runCodex(params: CodexCliParams): Promise { - let parsed: ReturnType; - try { - parsed = parseCodexArgs(params.args); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`${message}\n`); - printCodexUsage({ stream: console.error, prog: params.prog }); - process.exit(1); - } - - if (parsed["--help"]) { - printCodexUsage({ stream: console.log, prog: params.prog }); - return; - } - - await runCodexAuth(); -} - -async function runCodexAuth(): Promise { - p.intro(pc.bgGreen(pc.black(" pullfrog auth codex "))); - - const spin = p.spinner(); - setActiveSpin(spin); - - try { - spin.start("authenticating with github"); - const token = getGhToken(); - spin.stop("github authenticated"); - - spin.start("detecting repository"); - const remote = parseGitRemote(); - spin.stop(`detected repo ${pc.cyan(`${remote.owner}/${remote.repo}`)}`); - - spin.start("checking pullfrog app installation"); - const status = await fetchStatus({ token, owner: remote.owner, repo: remote.repo }); - if (!status.installed) { - spin.stop(pc.red("pullfrog app not installed on this repo")); - bail( - `install pullfrog on ${pc.bold(`${remote.owner}/${remote.repo}`)} before configuring auth.\n` + - ` ${pc.dim("run:")} ${pc.cyan(`npx pullfrog init`)}` - ); - } - spin.stop(`pullfrog app is installed on ${pc.cyan(`@${remote.owner}`)}`); - - if (status.pullfrogSecrets.includes(CODEX_AUTH_SECRET)) { - const overwrite = await p.select({ - message: `${pc.cyan(CODEX_AUTH_SECRET)} is already configured โ€” overwrite?`, - options: [ - { value: true, label: "overwrite", hint: "rotate to a freshly minted credential" }, - { value: false, label: "cancel" }, - ], - }); - handleCancel(overwrite); - if (!overwrite) { - p.cancel("canceled."); - return; - } - } - - // 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, prompt interactively โ€” matches `init`'s behavior. - const scope = status.isOrg - ? await promptScope({ owner: remote.owner, repo: remote.repo }) - : "account"; - - p.log.info( - [ - `signing in via Codex device authorization. open the URL Codex prints`, - `below, enter the one-time code, and approve in your browser.`, - ``, - `${pc.dim("note:")} if your ChatGPT account doesn't have device-code auth enabled,`, - `Codex will exit early. enable it at ${pc.cyan(`https://chatgpt.com/#settings/Security`)}`, - `then re-run ${pc.cyan(`${process.env.PULLFROG_BIN_NAME || "pullfrog"} auth codex`)}.`, - ].join("\n") - ); - - // 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) => { - // dim Codex's own colored output (URL/code in cyan, boilerplate in - // 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. - 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") { - lastTimedOut = false; - if (event.attempt > 1) p.log.info(`retry attempt ${event.attempt}`); - // shell-prompt style header so the user sees what Pullfrog is - // about to spawn, with the rail to keep the visual column. - process.stdout.write(`${pc.gray(p.S_BAR)}\n`); - process.stdout.write(`${pc.gray(p.S_BAR)} $ codex login --device-auth\n`); - } - if (event.kind === "exit") { - if (event.timedOut) lastTimedOut = true; - // trailing blank rail so the next clack prompt isn't crammed - // against the last codex output line. - process.stdout.write(`${pc.gray(p.S_BAR)}\n`); - } - }, - shouldRetry: async () => { - const message = lastTimedOut - ? "device authorization timed out โ€” retry?" - : "no auth.json was written โ€” retry?"; - const retry = await p.select({ - message, - options: [ - { value: true, label: "retry", hint: "after enabling device-code auth" }, - { value: false, label: "cancel" }, - ], - }); - handleCancel(retry); - return retry; - }, - }); - - // eager refresh: bump the OAuth chain once before persisting so the - // saved token is one Pullfrog has used. otherwise the user's laptop's - // codex CLI could refresh first and strand our copy. - spin.start("refreshing token"); - let savable: typeof auth; - try { - savable = await refreshCodexAuth(auth); - spin.stop("refreshed"); - } catch (err) { - spin.stop(pc.yellow("refresh failed โ€” saving minted token as-is")); - p.log.warn(err instanceof Error ? err.message : String(err)); - savable = auth; - } - - spin.start(`saving ${pc.cyan(CODEX_AUTH_SECRET)} to Pullfrog`); - const result = await setPullfrogSecret({ - token, - owner: remote.owner, - repo: remote.repo, - name: CODEX_AUTH_SECRET, - value: savable.json, - scope, - }); - if (!result.saved) { - spin.stop(pc.red("could not save secret")); - p.log.warn( - `${result.error}\n ${pc.dim("set it manually at:")} ${PULLFROG_API_URL}/console/${remote.owner}` - ); - process.exit(1); - } - spin.stop(`saved ${pc.cyan(CODEX_AUTH_SECRET)} to Pullfrog (${scope})`); - - setActiveSpin(null); - p.outro("done."); - } catch (error) { - // mirror what `bail` does: stop the spinner with a red "failed" glyph - // before clearing it, otherwise an in-flight spinner keeps animating - // above the error message we're about to print. - spin.stop(pc.red("failed")); - setActiveSpin(null); - const message = error instanceof Error ? error.message : String(error); - p.log.error(message); - process.exit(1); - } -} diff --git a/commands/gha.ts b/commands/gha.ts deleted file mode 100644 index b6cb5f6..0000000 --- a/commands/gha.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { dirname } from "node:path"; -import * as core from "@actions/core"; -import arg from "arg"; -import { main } from "../main.ts"; -import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts"; - -// GitHub Actions runs the action entry point with the node24 binary specified -// in action.yml, but doesn't add that binary's directory to PATH. Without this, -// spawned processes (pnpm, npm, etc.) resolve to the runner's default node (v20). -process.env.PATH = `${dirname(process.execPath)}:${process.env.PATH}`; - -const STATE_TOKEN = "token"; - -interface GhaCliParams { - args: string[]; - prog: string; - showHelp?: boolean; -} - -async function runMain(): Promise { - try { - const result = await main(); - if (!result.success) { - throw new Error(result.error || "agent execution failed"); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "unknown error occurred"; - core.setFailed(`action failed: ${errorMessage}`); - } -} - -async function tokenMain(): Promise { - const reposInput = core.getInput("repos"); - const additionalRepos = reposInput - ? reposInput - .split(",") - .map((r) => r.trim()) - .filter(Boolean) - : []; - - const token = await acquireInstallationToken({ repos: additionalRepos }); - - core.setSecret(token); - core.saveState(STATE_TOKEN, token); - core.setOutput("token", token); - - const scope = additionalRepos.length - ? `current repo + ${additionalRepos.join(", ")}` - : "current repo only"; - core.info(`ยป installation token acquired (${scope})`); -} - -async function tokenPost(): Promise { - const token = core.getState(STATE_TOKEN); - if (!token) { - core.debug("no token found in state, skipping revocation"); - return; - } - await revokeInstallationToken(token); - core.info("ยป installation token revoked"); -} - -function printGhaUsage(params: { stream: typeof console.log; prog: string }): void { - params.stream(`usage: ${params.prog} gha [subcommand]\n`); - params.stream("run the github action runtime flow."); - params.stream(""); - params.stream("subcommands:"); - params.stream(" token acquire a github app installation token"); - params.stream(""); - params.stream("options:"); - params.stream(" -h, --help show help"); -} - -function printGhaTokenUsage(params: { stream: typeof console.log; prog: string }): void { - params.stream(`usage: ${params.prog} gha token [--post]\n`); - params.stream("acquire a github app installation token, or revoke it in the post step."); - params.stream(""); - params.stream("options:"); - params.stream(" -h, --help show help"); - params.stream(" --post revoke the previously-acquired token (post-step usage only)"); -} - -function parseGhaArgs(args: string[]) { - return arg( - { - "--help": Boolean, - "-h": "--help", - }, - { - argv: args, - stopAtPositional: true, - } - ); -} - -function parseGhaTokenArgs(args: string[]) { - return arg( - { - "--help": Boolean, - "--post": Boolean, - "-h": "--help", - }, - { - argv: args, - } - ); -} - -export async function runCli(params: GhaCliParams): Promise { - if (params.showHelp) { - printGhaUsage({ stream: console.log, prog: params.prog }); - return; - } - - let parsed: ReturnType; - try { - parsed = parseGhaArgs(params.args); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`${message}\n`); - printGhaUsage({ stream: console.error, prog: params.prog }); - process.exit(1); - } - - if (parsed["--help"]) { - printGhaUsage({ stream: console.log, prog: params.prog }); - return; - } - - const positional = parsed._; - const subcommand = positional[0]; - - if (!subcommand) { - await run(["gha"]); - return; - } - - if (subcommand !== "token") { - console.error(`unknown gha subcommand: ${subcommand}\n`); - printGhaUsage({ stream: console.error, prog: params.prog }); - process.exit(1); - } - - // gha token [--post] - let tokenParsed: ReturnType; - try { - tokenParsed = parseGhaTokenArgs(positional.slice(1)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`${message}\n`); - printGhaTokenUsage({ stream: console.error, prog: params.prog }); - process.exit(1); - } - - if (tokenParsed["--help"]) { - printGhaTokenUsage({ stream: console.log, prog: params.prog }); - return; - } - - if (tokenParsed._.length > 0) { - console.error(`unexpected positional arguments for gha token: ${tokenParsed._.join(" ")}\n`); - printGhaTokenUsage({ stream: console.error, prog: params.prog }); - process.exit(1); - } - - const normalizedArgs = ["gha", "token"]; - if (tokenParsed["--post"]) { - normalizedArgs.push("--post"); - } - await run(normalizedArgs); -} - -export async function run(args: string[]) { - try { - if (args.includes("token")) { - if (args.includes("--post")) { - await tokenPost(); - } else { - await tokenMain(); - } - } else { - await runMain(); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - core.setFailed(message); - } -} diff --git a/commands/init.ts b/commands/init.ts deleted file mode 100644 index b0806d3..0000000 --- a/commands/init.ts +++ /dev/null @@ -1,975 +0,0 @@ -import { execFileSync } from "node:child_process"; -import * as p from "@clack/prompts"; -import arg from "arg"; -import pc from "picocolors"; -import { modelAliases, type ProviderConfig, providers, resolveDisplayAlias } from "../models.ts"; - -const PULLFROG_API_URL = (process.env.PULLFROG_API_URL || "https://pullfrog.com").replace( - /\/+$/, - "" -); - -function link(text: string, url: string): string { - return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`; -} - -type CliProvider = { - id: string; - name: string; - envVars: readonly string[]; - models: { value: string; label: string; hint?: string | undefined }[]; -}; - -function buildProviders(): CliProvider[] { - return Object.entries(providers) - .filter(([key]) => key !== "opencode" && key !== "openrouter" && key !== "bedrock") - .map(([key, config]: [string, ProviderConfig]) => { - // bedrock requires multi-secret setup (auth + region + model id) that - // doesn't fit the single-paste flow below โ€” direct users to - // https://docs.pullfrog.com/bedrock instead. revisit once the init flow - // supports multi-value setup. `hidden` excludes internal-only subagent - // targets (e.g. openai/gpt-5.4) per #710. - const aliases = modelAliases.filter( - (a) => a.provider === key && !a.fallback && !a.routing && !a.hidden - ); - const recommended = aliases.find((a) => a.preferred); - const sorted = [...aliases].sort((a, b) => { - if (a.preferred && !b.preferred) return -1; - if (!a.preferred && b.preferred) return 1; - return 0; - }); - return { - id: key, - name: config.displayName, - envVars: config.envVars, - models: sorted.map((a) => ({ - value: a.slug, - label: a.displayName, - hint: a === recommended ? "recommended" : undefined, - })), - }; - }); -} - -const CLI_PROVIDERS = buildProviders(); - -function resolveModelProvider(slug: string): CliProvider | null { - const providerId = slug.split("/")[0]; - return CLI_PROVIDERS.find((p) => p.id === providerId) ?? null; -} - -// โ”€โ”€ helpers โ”€โ”€ - -// active spinner reference so bail/catch can clean up the terminal -let activeSpin: ReturnType | null = null; - -function bail(msg: string): never { - if (activeSpin) { - activeSpin.stop(pc.red("failed")); - activeSpin = null; - } - p.cancel(msg); - process.exit(1); -} - -function handleCancel(value: T | symbol): asserts value is T { - if (p.isCancel(value)) { - if (activeSpin) { - activeSpin.stop(pc.red("canceled.")); - activeSpin = null; - } - p.cancel("canceled."); - process.exit(0); - } -} - -function getGhToken(): string { - let token: string; - try { - token = execFileSync("gh", ["auth", "token"], { encoding: "utf-8" }).trim(); - } catch { - bail( - `gh cli not found or not authenticated.\n` + - ` ${pc.dim("install:")} https://cli.github.com\n` + - ` ${pc.dim("then:")} gh auth login` - ); - } - if (!token) { - bail( - `gh cli returned an empty token. try re-authenticating:\n` + - ` ${pc.dim("run:")} gh auth login` - ); - } - return token; -} - -type GhApiResult = { data: T; scopes: string | null }; - -async function ghApi(path: string, token: string): Promise> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30_000); - try { - const response = await fetch(`https://api.github.com${path}`, { - headers: { - authorization: `Bearer ${token}`, - accept: "application/vnd.github+json", - "x-github-api-version": "2022-11-28", - }, - signal: controller.signal, - }); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new Error(`github api ${path} returned ${response.status}: ${body}`); - } - - const data = (await response.json().catch(() => { - throw new Error(`github api ${path} returned non-JSON response`); - })) as T; - return { data, scopes: response.headers.get("x-oauth-scopes") }; - } finally { - clearTimeout(timeout); - } -} - -function parseGitRemote(): { owner: string; repo: string } { - let url: string; - try { - url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).trim(); - } catch { - bail("not a git repository or no 'origin' remote found."); - } - - const match = url.match(/github\.com(?::\d+)?[:/]+([^/]+)\/(.+?)(?:\.git)?(?:\/)?$/); - if (!match) bail(`could not parse github owner/repo from remote: ${url}`); - return { owner: match[1], repo: match[2] }; -} - -function openBrowser(url: string) { - try { - const platform = process.platform; - if (platform === "darwin") execFileSync("open", [url], { stdio: "ignore" }); - else if (platform === "win32") - execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore" }); - else execFileSync("xdg-open", [url], { stdio: "ignore" }); - } catch { - // headless/SSH โ€” user will open the URL manually - } -} - -// โ”€โ”€ Pullfrog API โ”€โ”€ - -type SecretsApiData = { - error?: string; - appSlug?: string; - installationId?: number | null; - repositorySelection?: string | null; - isOrg?: boolean; - accessible?: boolean; - repoSecrets?: string[]; - orgSecrets?: string[]; - pullfrogSecrets?: string[]; - repoStatus?: string | null; - repoModel?: string | null; - hasRuns?: boolean; -}; - -type SecretsInfo = { - isOrg: boolean; - installationId: number | null; - secretsAccessible: boolean; - repoSecrets: string[]; - orgSecrets: string[]; - pullfrogSecrets: string[]; - model: string | null; - hasRuns: boolean; -}; - -type InstallationNotFound = { - appSlug: string; - installationId: number | null; - repositorySelection: "all" | "selected" | null; - isOrg: boolean; -}; - -type StatusResult = - | ({ installed: true } & SecretsInfo) - | ({ installed: false } & InstallationNotFound); - -type SessionApiData = { - id?: string; - installed?: boolean; - error?: string; -}; - -type SetupApiData = { - error?: string; - success?: boolean; - already_existed?: boolean; - pull_request_url?: string; - commit_url?: string; - hash?: string; -}; - -type DispatchApiData = { - error?: string; - url?: string; -}; - -type ApiResult> = { ok: boolean; status: number; data: T }; - -async function pullfrogApi>(ctx: { - path: string; - token: string; - method?: string; - body?: Record; -}): Promise> { - const headers: Record = { authorization: `Bearer ${ctx.token}` }; - if (ctx.body) headers["content-type"] = "application/json"; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30_000); - try { - const response = await fetch(`${PULLFROG_API_URL}${ctx.path}`, { - method: ctx.method || "GET", - headers, - body: ctx.body ? JSON.stringify(ctx.body) : null, - signal: controller.signal, - }); - const data = (await response.json().catch(() => ({}))) as T; - return { ok: response.ok, status: response.status, data }; - } finally { - clearTimeout(timeout); - } -} - -async function fetchStatus(ctx: { - token: string; - owner: string; - repo: string; -}): Promise { - const result = await pullfrogApi({ - path: `/api/cli/secrets?owner=${encodeURIComponent(ctx.owner)}&repo=${encodeURIComponent(ctx.repo)}`, - token: ctx.token, - }); - - if (!result.ok) { - const errorMsg = result.data.error || ""; - if (result.status === 401) bail("invalid or expired github token."); - if (result.status === 404) { - const sel = result.data.repositorySelection; - if (!result.data.appSlug) bail("server did not return appSlug"); - return { - installed: false, - appSlug: result.data.appSlug, - installationId: - typeof result.data.installationId === "number" ? result.data.installationId : null, - repositorySelection: sel === "all" || sel === "selected" ? sel : null, - isOrg: result.data.isOrg === true, - }; - } - bail(errorMsg || `secrets check failed (${result.status})`); - } - - return { - installed: true, - isOrg: result.data.isOrg === true, - installationId: - typeof result.data.installationId === "number" ? result.data.installationId : null, - secretsAccessible: result.data.accessible !== false, - repoSecrets: result.data.repoSecrets || [], - orgSecrets: result.data.orgSecrets || [], - pullfrogSecrets: result.data.pullfrogSecrets || [], - model: result.data.repoModel ?? null, - hasRuns: result.data.hasRuns === true, - }; -} - -// โ”€โ”€ sessions โ”€โ”€ - -async function createSession(ctx: { - token: string; - owner: string; - repo: string; -}): Promise { - try { - const result = await pullfrogApi({ - path: "/api/cli/session", - token: ctx.token, - method: "POST", - body: { owner: ctx.owner.toLowerCase(), repo: ctx.repo.toLowerCase() }, - }); - if (!result.ok || !result.data.id) return null; - return result.data.id; - } catch { - return null; - } -} - -type PollResult = "installed" | "pending" | "expired"; - -async function pollSession(ctx: { token: string; sessionId: string }): Promise { - const result = await pullfrogApi({ - path: `/api/cli/session/${ctx.sessionId}`, - token: ctx.token, - }); - if (result.status === 410) return "expired"; - if (!result.ok) return "pending"; - return result.data.installed === true ? "installed" : "pending"; -} - -function cleanupSession(ctx: { token: string; sessionId: string }) { - void pullfrogApi({ - path: `/api/cli/session/${ctx.sessionId}`, - token: ctx.token, - method: "DELETE", - }).catch(() => {}); -} - -// โ”€โ”€ installation โ”€โ”€ - -const SESSION_POLL_MS = 750; -const FALLBACK_POLL_MS = 5_000; -const HINT_AFTER_MS = 10_000; -const TIMEOUT_MS = 3 * 60 * 1000; - -function listenForKey(key: string) { - let triggered = false; - const onData = (data: Buffer) => { - if (data.toString().toLowerCase() === key) triggered = true; - }; - process.stdin.setRawMode?.(true); - process.stdin.resume(); - process.stdin.on("data", onData); - return { - consume() { - if (!triggered) return false; - triggered = false; - return true; - }, - stop() { - process.stdin.removeListener("data", onData); - process.stdin.setRawMode?.(false); - process.stdin.pause(); - }, - }; -} - -function installationConfigUrl(ctx: { owner: string; installationId: number; isOrg: boolean }) { - return ctx.isOrg - ? `https://github.com/organizations/${ctx.owner}/settings/installations/${ctx.installationId}` - : `https://github.com/settings/installations/${ctx.installationId}`; -} - -async function ensureInstallation(ctx: { - token: string; - owner: string; - repo: string; -}): Promise { - activeSpin!.start("checking pullfrog app installation"); - - const initial = await fetchStatus(ctx); - if (initial.installed) { - activeSpin!.stop(`pullfrog app is installed on ${pc.cyan(`@${ctx.owner}`)}`); - if (initial.installationId) { - const configUrl = installationConfigUrl({ - owner: ctx.owner, - installationId: initial.installationId, - isOrg: initial.isOrg, - }); - process.stdout.write(`${pc.gray(p.S_BAR)} ${link(pc.dim(configUrl), configUrl)}\n`); - } - return initial; - } - - const sessionId = await createSession(ctx); - - if (initial.installationId) { - const repoRef = pc.bold(`${ctx.owner}/${ctx.repo}`); - const configUrl = installationConfigUrl({ - owner: ctx.owner, - installationId: initial.installationId, - isOrg: initial.isOrg, - }); - activeSpin!.stop(`pullfrog is installed on selected repos, but ${repoRef} is not included.`); - p.log.info( - `add it under "Repository access" on the installation config page.\n ${pc.dim(configUrl)}` - ); - const openIt = await p.confirm({ message: "open browser?", active: "yes", inactive: "no" }); - handleCancel(openIt); - if (openIt) openBrowser(configUrl); - } else { - activeSpin!.stop("pullfrog app not installed"); - const installUrl = `https://github.com/apps/${initial.appSlug}/installations/select_target?state=cli`; - p.log.info(`opening browser to install...\n ${pc.dim(installUrl)}`); - openBrowser(installUrl); - } - - const isRepoAccessUpdate = !!initial.installationId; - const baseMsg = isRepoAccessUpdate - ? "once you've added the repo, onboarding will proceed automatically" - : "once you've installed the app, onboarding will proceed automatically"; - activeSpin!.start(baseMsg); - - let activeSessionId = sessionId; - let pollMs = activeSessionId ? SESSION_POLL_MS : FALLBACK_POLL_MS; - const listener = listenForKey("r"); - const startedAt = Date.now(); - let hintShown = false; - - try { - while (Date.now() - startedAt < TIMEOUT_MS) { - await new Promise((r) => setTimeout(r, pollMs)); - - if (!hintShown && Date.now() - startedAt > HINT_AFTER_MS) { - activeSpin!.message(`${baseMsg} ${pc.dim("(press r to recheck manually)")}`); - hintShown = true; - } - - const doneMsg = isRepoAccessUpdate ? "repo access confirmed" : "pullfrog app installed"; - - if (listener.consume()) { - activeSpin!.message("rechecking via GitHub API"); - try { - const status = await fetchStatus(ctx); - if (status.installed) { - if (activeSessionId) cleanupSession({ token: ctx.token, sessionId: activeSessionId }); - activeSpin!.stop(doneMsg); - return status; - } - } catch { - // network error โ€” keep going - } - activeSpin!.message(`${baseMsg} ${pc.dim("(press r to recheck manually)")}`); - continue; - } - - if (activeSessionId) { - // fast path: lightweight DB session poll (no GitHub API calls) - try { - const result = await pollSession({ token: ctx.token, sessionId: activeSessionId }); - if (result === "expired") { - activeSessionId = null; - pollMs = FALLBACK_POLL_MS; - continue; - } - if (result === "installed") { - const status = await fetchStatus(ctx); - if (status.installed) { - cleanupSession({ token: ctx.token, sessionId: activeSessionId }); - activeSpin!.stop(doneMsg); - return status; - } - } - } catch { - // transient error โ€” keep polling - } - } else { - // no session available โ€” poll fetchStatus directly at slower interval - try { - const status = await fetchStatus(ctx); - if (status.installed) { - activeSpin!.stop(doneMsg); - return status; - } - } catch { - // transient error โ€” keep polling - } - } - } - } finally { - listener.stop(); - } - - if (activeSessionId) cleanupSession({ token: ctx.token, sessionId: activeSessionId }); - bail( - isRepoAccessUpdate - ? "timed out waiting for repo access.\n" + - ` ${pc.dim("add the repo, then re-run:")} npx pullfrog init` - : "timed out waiting for app installation.\n" + - ` ${pc.dim("if your org requires admin approval, ask an admin to approve,")}\n` + - ` ${pc.dim("then re-run:")} npx pullfrog init` - ); -} - -// โ”€โ”€ secret management โ”€โ”€ - -type StorageMethod = "pullfrog" | "github"; -type SecretScope = "account" | "repo"; - -type SecretSetResult = { saved: boolean; orgFailed: boolean }; - -function setGhSecret(ctx: { - name: string; - value: string; - org: string | null; - repoSlug: string; -}): SecretSetResult { - let orgFailed = false; - - if (ctx.org) { - try { - execFileSync("gh", ["secret", "set", ctx.name, "--org", ctx.org, "--visibility", "all"], { - input: ctx.value, - stdio: ["pipe", "ignore", "pipe"], - encoding: "utf-8", - }); - return { saved: true, orgFailed: false }; - } catch { - orgFailed = true; - } - } - - try { - execFileSync("gh", ["secret", "set", ctx.name, "--repo", ctx.repoSlug], { - input: ctx.value, - stdio: ["pipe", "ignore", "pipe"], - encoding: "utf-8", - }); - return { saved: true, orgFailed }; - } catch { - return { saved: false, orgFailed }; - } -} - -type PullfrogSecretResult = { saved: boolean; error: string }; - -async function setPullfrogSecret(ctx: { - token: string; - owner: string; - repo: string; - name: string; - value: string; - scope: SecretScope; -}): Promise { - const result = await pullfrogApi<{ success?: boolean; error?: string }>({ - path: "/api/cli/secrets", - token: ctx.token, - method: "POST", - body: { - owner: ctx.owner, - repo: ctx.repo, - name: ctx.name, - value: ctx.value, - scope: ctx.scope, - }, - }); - if (result.ok && result.data.success === true) { - return { saved: true, error: "" }; - } - return { saved: false, error: result.data.error || `api returned ${result.status}` }; -} - -async function promptScope(ctx: { owner: string; repo: string }): Promise { - const scope = await p.select({ - message: "secret scope", - options: [ - { value: "account", label: `${ctx.owner} organization`, hint: "shared across repos" }, - { value: "repo", label: `${ctx.owner}/${ctx.repo} only` }, - ], - }); - handleCancel(scope); - return scope; -} - -async function handleSecret(ctx: { - token: string; - owner: string; - repo: string; - provider: CliProvider; - secrets: SecretsInfo; -}): Promise { - const repoSecretsUrl = `https://github.com/${ctx.owner}/${ctx.repo}/settings/secrets/actions`; - - const matches: { name: string; source: string }[] = []; - for (const v of ctx.provider.envVars) { - if (ctx.secrets.pullfrogSecrets.includes(v)) matches.push({ name: v, source: "pullfrog" }); - else if (ctx.secrets.secretsAccessible && ctx.secrets.orgSecrets.includes(v)) - matches.push({ name: v, source: "org secret" }); - else if (ctx.secrets.secretsAccessible && ctx.secrets.repoSecrets.includes(v)) - matches.push({ name: v, source: "repo secret" }); - } - - if (matches.length > 0) { - activeSpin!.start(""); - activeSpin!.stop("secrets already configured"); - for (const m of matches) { - process.stdout.write( - `${pc.gray(p.S_BAR)} ${pc.cyan(m.name)} ${pc.dim(`(${m.source})`)}\n` - ); - } - return; - } - - if (!ctx.secrets.secretsAccessible) { - p.log.info(`could not verify GitHub secrets (app lacks permission)`); - } - - const hasOAuthOption = ctx.provider.envVars.includes("CLAUDE_CODE_OAUTH_TOKEN"); - let envVar = ctx.provider.envVars[0]; - - if (hasOAuthOption) { - const authMethod = await p.select({ - message: "which credential do you want to use?", - options: [ - { - value: "oauth", - label: "Claude Code OAuth token", - hint: `run ${pc.cyan("claude setup-token")} โ€” works with Pro/Max subscriptions`, - }, - { - value: "api", - label: "Anthropic API key", - hint: "from console.anthropic.com", - }, - ], - }); - handleCancel(authMethod); - if (authMethod === "oauth") envVar = "CLAUDE_CODE_OAUTH_TOKEN"; - } - - const method = await p.select({ - message: `where should ${pc.cyan(envVar)} be stored?`, - options: [ - { - value: "pullfrog", - label: "Pullfrog", - hint: "recommended โ€” auto-injected, no workflow changes", - }, - { - value: "github", - label: "GitHub Actions secret", - hint: "requires env block in pullfrog.yml", - }, - ], - }); - handleCancel(method); - - const pasteLabel = - envVar === "CLAUDE_CODE_OAUTH_TOKEN" ? "OAuth token" : `${ctx.provider.name} API key`; - const apiKey = await p.password({ - message: `paste your ${pasteLabel} ${pc.dim("(Enter to skip)")}`, - mask: "*", - validate: () => undefined, - }); - handleCancel(apiKey); - - if (!apiKey) { - p.log.info( - `skipped โ€” set it manually at:\n ${pc.dim(method === "pullfrog" ? `${PULLFROG_API_URL}/console/${ctx.owner}` : repoSecretsUrl)}` - ); - return; - } - - if (method === "pullfrog") { - const scope: SecretScope = ctx.secrets.isOrg ? await promptScope(ctx) : "account"; - - activeSpin!.start(`saving ${envVar}`); - let saveResult: PullfrogSecretResult; - try { - saveResult = await setPullfrogSecret({ - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - name: envVar, - value: apiKey, - scope, - }); - } catch (error) { - activeSpin!.stop(pc.red("could not save secret")); - p.log.warn( - `${error instanceof Error ? error.message : "network error"}\n set it manually at: ${pc.dim(`${PULLFROG_API_URL}/console/${ctx.owner}`)}` - ); - return; - } - - if (saveResult.saved) { - activeSpin!.stop(`saved ${pc.cyan(envVar)} to Pullfrog`); - } else { - activeSpin!.stop(pc.red("could not save secret")); - p.log.warn( - `${saveResult.error}\n set it manually at: ${pc.dim(`${PULLFROG_API_URL}/console/${ctx.owner}`)}` - ); - } - return; - } - - // github actions secret path - let org: string | null = null; - if (ctx.secrets.isOrg) { - const scope = await promptScope(ctx); - org = scope === "account" ? ctx.owner : null; - } - - const secretsUrl = org - ? `https://github.com/organizations/${org}/settings/secrets/actions` - : repoSecretsUrl; - - activeSpin!.start(`saving ${envVar}`); - const secretResult = setGhSecret({ - name: envVar, - value: apiKey, - org, - repoSlug: `${ctx.owner}/${ctx.repo}`, - }); - if (secretResult.saved) { - activeSpin!.stop( - `saved ${pc.cyan(envVar)} to ${org && !secretResult.orgFailed ? `${pc.dim(ctx.owner)} org secret` : "GitHub Actions secret"}` - ); - if (secretResult.orgFailed) { - p.log.warn("org secret failed (admin access required) โ€” saved as repo secret instead"); - } - } else { - activeSpin!.stop(pc.red("could not set secret")); - p.log.warn(`set it manually at:\n ${pc.dim(secretsUrl)}`); - } -} - -async function promptTestRun(ctx: { token: string; owner: string; repo: string }): Promise { - const proceed = await p.select({ - message: "test your installation?", - options: [ - { value: true, label: "yes", hint: "dispatches a test run in your GitHub Actions" }, - { value: false, label: "skip" }, - ], - }); - handleCancel(proceed); - if (!proceed) return; - - activeSpin!.start("dispatching test run"); - const result = await pullfrogApi({ - path: "/api/cli/dispatch", - token: ctx.token, - method: "POST", - body: { owner: ctx.owner, repo: ctx.repo, prompt: "Tell me a joke" }, - }); - - if (!result.ok) { - activeSpin!.stop(pc.red("could not dispatch")); - p.log.warn(result.data.error || `dispatch failed (${result.status})`); - return; - } - - activeSpin!.stop("dispatched test run"); - if (result.data.url) { - process.stdout.write( - `${pc.gray(p.S_BAR)} ${link(pc.dim(result.data.url), result.data.url)}\n` - ); - openBrowser(result.data.url); - } -} - -// โ”€โ”€ main โ”€โ”€ - -async function main() { - p.intro(pc.bgGreen(pc.black(" pullfrog "))); - - const spin = p.spinner(); - activeSpin = spin; - - // 1. authenticate - spin.start("authenticating with github"); - const token = getGhToken(); - const userResult = await ghApi<{ login: string }>("/user", token); - const user = userResult.data; - - // gho_ tokens from `gh auth login` expose scopes via x-oauth-scopes header. - // fine-grained PATs (github_pat_) don't return scopes โ€” they pass this check. - // split on ", " and match exact scope โ€” .includes("repo") would false-positive on "public_repo" - const scopeSet = userResult.scopes !== null ? new Set(userResult.scopes.split(", ")) : null; - if (scopeSet !== null && !scopeSet.has("repo")) { - bail( - `your token is missing the ${pc.bold('"repo"')} scope.\n` + - ` ${pc.dim("run:")} gh auth refresh --scopes repo\n` + - ` ${pc.dim("then:")} npx pullfrog init` - ); - } - - spin.stop(`hello, ${pc.cyan(`@${user.login}`)}`); - - // 2. detect repo - spin.start("detecting repository"); - const remote = parseGitRemote(); - spin.stop(`detected repo ${pc.cyan(`${remote.owner}/${remote.repo}`)}`); - - // 3. ensure app installation + check secrets - const secrets = await ensureInstallation({ token, owner: remote.owner, repo: remote.repo }); - - // 4. select provider + model (skip if already set) - let model: string; - let provider: CliProvider; - - if (secrets.model) { - model = secrets.model; - const resolved = resolveModelProvider(secrets.model); - if (!resolved) bail(`unknown model provider: ${secrets.model}`); - provider = resolved; - // walk the fallback chain so a deprecated stored slug shows the model - // the run will actually execute against (e.g. "GPT", not "GPT Codex"). - const displayAlias = resolveDisplayAlias(secrets.model); - const label = displayAlias ? displayAlias.displayName : secrets.model; - spin.start(""); - spin.stop(`using model ${pc.cyan(label)}`); - } else { - const providerId = await p.select({ - message: "select your preferred model provider", - options: CLI_PROVIDERS.map((cp) => ({ - value: cp.id, - label: cp.name, - })), - }); - handleCancel(providerId); - - const found = CLI_PROVIDERS.find((cp) => cp.id === providerId); - if (!found) bail(`unknown provider: ${providerId}`); - provider = found; - - if (provider.models.length === 1) { - model = provider.models[0].value; - spin.start(""); - spin.stop(`using ${pc.bold(provider.models[0].label)}`); - } else { - const recommendedModel = provider.models.find((m) => m.hint === "recommended"); - const options = provider.models.map((m) => { - if (m.hint) return { value: m.value, label: m.label, hint: m.hint }; - return { value: m.value, label: m.label }; - }); - const selected = await p.select( - recommendedModel - ? { message: "select model", initialValue: recommendedModel.value, options } - : { message: "select model", options } - ); - handleCancel(selected); - model = selected; - } - } - - // 5. check/set secret - await handleSecret({ token, owner: remote.owner, repo: remote.repo, provider, secrets }); - - // 6. create workflow - spin.start("creating pullfrog.yml workflow"); - - const result = await pullfrogApi({ - path: "/api/cli/setup", - token, - method: "POST", - body: { owner: remote.owner, repo: remote.repo, model }, - }); - - if (!result.ok) { - bail(result.data.error || `api returned ${result.status}`); - } - - let skipTestRun = false; - - if (result.data.already_existed) { - spin.stop("pullfrog.yml already exists"); - } else if (result.data.pull_request_url) { - spin.stop("opened pull request with pullfrog.yml"); - process.stdout.write( - `${pc.gray(p.S_BAR)} ${link(pc.dim(result.data.pull_request_url), result.data.pull_request_url)}\n` - ); - openBrowser(result.data.pull_request_url); - - const merged = await p.select({ - message: "merge the PR to activate pullfrog, then continue", - options: [ - { value: true, label: "continue", hint: "PR has been merged" }, - { value: false, label: "skip" }, - ], - }); - handleCancel(merged); - if (!merged) skipTestRun = true; - } else { - const short = result.data.hash?.slice(0, 7); - spin.stop( - short ? `committed pullfrog.yml to repo ${pc.dim(short)}` : "committed pullfrog.yml to repo" - ); - } - - if (!skipTestRun && !secrets.hasRuns) { - await promptTestRun({ token, owner: remote.owner, repo: remote.repo }); - } - - const consoleUrl = `${PULLFROG_API_URL}/console/${remote.owner}/${remote.repo}`; - spin.start(""); - spin.stop("repo is configurable via the Pullfrog dashboard"); - process.stdout.write(`${pc.gray(p.S_BAR)} ${link(pc.dim(consoleUrl), consoleUrl)}\n`); - activeSpin = null; - p.outro("done."); -} - -interface InitCliParams { - args: string[]; - prog: string; - showHelp?: boolean; -} - -function printInitUsage(params: { stream: typeof console.log; prog: string }): void { - params.stream(`usage: ${params.prog} init\n`); - params.stream("set up pullfrog on the current repository."); - params.stream(""); - params.stream("options:"); - params.stream(" -h, --help show help"); -} - -function parseInitArgs(args: string[]) { - return arg( - { - "--help": Boolean, - "-h": "--help", - }, - { - argv: args, - } - ); -} - -export async function runCli(params: InitCliParams): Promise { - if (params.showHelp) { - printInitUsage({ stream: console.log, prog: params.prog }); - return; - } - - let parsed: ReturnType; - try { - parsed = parseInitArgs(params.args); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`${message}\n`); - printInitUsage({ stream: console.error, prog: params.prog }); - process.exit(1); - } - - if (parsed["--help"]) { - printInitUsage({ stream: console.log, prog: params.prog }); - return; - } - - if (parsed._.length > 0) { - console.error(`unexpected positional arguments for init: ${parsed._.join(" ")}\n`); - printInitUsage({ stream: console.error, prog: params.prog }); - process.exit(1); - } - - await run(); -} - -export async function run() { - try { - await main(); - } catch (error) { - if (activeSpin) { - activeSpin.stop(pc.red("failed")); - activeSpin = null; - } - const msg = - error instanceof Error && error.name === "AbortError" - ? "request timed out โ€” check your network connection and try again" - : error instanceof Error - ? error.message - : String(error); - p.log.error(msg); - process.exit(1); - } -} diff --git a/config.ts b/config.ts deleted file mode 100644 index 82fe3ec..0000000 --- a/config.ts +++ /dev/null @@ -1 +0,0 @@ -// action-level constants shared across the action runtime diff --git a/docker.ts b/docker.ts deleted file mode 100644 index cf4317f..0000000 --- a/docker.ts +++ /dev/null @@ -1,532 +0,0 @@ -// 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 docker