From 0055aef61803c009778461d8e0a2dad11ffd0962 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 31 Mar 2026 03:29:54 +0000 Subject: [PATCH] feat: add Claude Code agent for Anthropic model users (#502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Claude Code agent for Anthropic model users Re-adds Claude Code support (removed in #478) so users with Anthropic API keys or Claude Code OAuth tokens can use their Claude subscriptions directly. When an Anthropic model is selected and Claude Code credentials are available, the system auto-selects the Claude agent instead of OpenCode. The harness mirrors opentoad's security model: native Bash blocked via --disallowedTools, MCP ShellTool for restricted shell, ASKPASS for git auth. Includes NDJSON streaming, provider error detection, cache/cost tracking, browser skill, and todo progress tracking. Key changes: - action/agents/claude.ts: full Claude Code harness - action/utils/agent.ts: auto-select Claude for anthropic/* models - action/utils/providerErrors.ts: extracted shared provider error detection - action/utils/skills.ts: extracted shared skill installation (agent-aware) - action/models.ts: add CLAUDE_CODE_OAUTH_TOKEN to anthropic envVars - action/utils/docker.ts: add CLAUDE_CODE_OAUTH_TOKEN to test env allowlist - CI: add claude to test matrix, pass CLAUDE_CODE_OAUTH_TOKEN secret Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove unused toolId variable, fix apiKeys test env cleanup The apiKeys test cleanup stripped *_API_KEY vars but missed CLAUDE_CODE_OAUTH_TOKEN which doesn't match that pattern. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: strip provider prefix from PULLFROG_MODEL in Claude agent the env override path was returning the raw value (e.g. "anthropic/claude-sonnet-4-5") without stripping the provider prefix, causing the Claude CLI to receive an invalid model ID. Made-with: Cursor * fix: remove dead cliPath field, add CLAUDE_CODE_OAUTH_TOKEN to workflows remove unused cliPath from Claude agent RunParams, and pass CLAUDE_CODE_OAUTH_TOKEN through all pullfrog.yml workflow templates so users with Claude Pro/Team subscriptions can use their membership. Made-with: Cursor * fix: block Bash subagent in Claude Code disallowedTools Made-with: Cursor * chore: update model snapshot (opencode/openrouter latest → qwen3.6-plus-free) Made-with: Cursor --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/pullfrog.yml | 1 + .github/workflows/test.yml | 3 +- agents/claude.ts | 562 +++++++++++++++++++++++++ agents/index.ts | 3 +- agents/opentoad.ts | 43 +- entry | 513 +++++++++++++++++++--- main.ts | 2 +- models.test.ts | 5 +- models.ts | 2 +- package.json | 1 + pnpm-lock.yaml | 171 ++++++++ post | 3 +- test/__snapshots__/models.test.ts.snap | 4 +- test/ci.test.ts | 2 +- utils/agent.ts | 37 +- utils/apiKeys.test.ts | 2 +- utils/docker.ts | 1 + utils/providerErrors.ts | 18 + utils/skills.ts | 24 ++ 19 files changed, 1284 insertions(+), 113 deletions(-) create mode 100644 agents/claude.ts create mode 100644 utils/providerErrors.ts create mode 100644 utils/skills.ts diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index d04932b..0b4f6c1 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -37,6 +37,7 @@ jobs: # 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 }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 121add6..a8b9dea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,12 +27,13 @@ jobs: strategy: fail-fast: true matrix: - agent: [opentoad] + agent: [claude, opentoad] test: [mcpmerge, nobash, restricted, smoke] 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 }} diff --git a/agents/claude.ts b/agents/claude.ts new file mode 100644 index 0000000..5eb8095 --- /dev/null +++ b/agents/claude.ts @@ -0,0 +1,562 @@ +/** + * Claude Code agent — secure harness around the `claude` CLI. + * + * mirrors the opentoad harness's security model: + * - native Bash blocked via --disallowedTools (agent cannot shell out) + * - 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 { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { ghPullfrogMcpName } from "../external.ts"; +import { resolveModelSlug } from "../models.ts"; +import { getIdleMs, markActivity } from "../utils/activity.ts"; +import { log } from "../utils/cli.ts"; +import { installFromNpmTarball } from "../utils/install.ts"; +import { detectProviderError } from "../utils/providerErrors.ts"; +import { addSkill } from "../utils/skills.ts"; +import { spawn } from "../utils/subprocess.ts"; +import { ThinkingTimer } from "../utils/timer.ts"; +import type { TodoTracker } from "../utils/todoTracking.ts"; +import { getDevDependencyVersion } from "../utils/version.ts"; +import { type AgentResult, type AgentRunContext, type AgentUsage, agent } 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: { + [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl }, + }, + }) + ); + return configPath; +} + +// ── model resolution ───────────────────────────────────────────────────────── + +function resolveClaudeModel(modelSlug: string | undefined): string | undefined { + // 1. explicit env var override + const envModel = process.env.PULLFROG_MODEL?.trim(); + if (envModel) { + const slashIndex = envModel.indexOf("/"); + const cliModel = slashIndex > 0 ? envModel.slice(slashIndex + 1) : envModel; + log.info(`» model: ${cliModel} (override via PULLFROG_MODEL)`); + return cliModel; + } + + if (!modelSlug) return undefined; + + // 2. resolve slug to concrete specifier (e.g. "anthropic/claude-opus" → "anthropic/claude-opus-4-6") + // then strip the "anthropic/" prefix to get the Claude CLI model name + const resolved = resolveModelSlug(modelSlug); + if (resolved) { + const slashIndex = resolved.indexOf("/"); + const cliModel = slashIndex > 0 ? resolved.slice(slashIndex + 1) : resolved; + log.info(`» model: ${cliModel} (resolved from ${modelSlug})`); + return cliModel; + } + + log.warning(`» unknown model slug "${modelSlug}" — letting Claude Code auto-select`); + return undefined; +} + +// ── 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; +} + +interface ClaudeSystemEvent { + type: "system"; + [key: string]: unknown; +} + +interface ClaudeAssistantEvent { + type: "assistant"; + 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"; + message?: { + role?: string; + content?: ContentBlock[]; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface ClaudeResultEvent { + type: "result"; + subtype?: 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; +}; + +async function runClaude(params: RunParams): Promise { + const startTime = performance.now(); + let eventCount = 0; + const thinkingTimer = new ThinkingTimer(); + + let finalOutput = ""; + let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + let costUsd: number | undefined; + 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, + } + : undefined; + } + + const handlers = { + system: (_event: ClaudeSystemEvent) => { + log.debug(`» ${params.label} system event`); + }, + assistant: (event: ClaudeAssistantEvent) => { + const content = event.message?.content; + if (!content) return; + + for (const block of content) { + if (block.type === "text" && block.text?.trim()) { + const message = block.text.trim(); + log.box(message, { title: params.label }); + finalOutput = message; + } else if (block.type === "tool_use") { + const toolName = block.name || "unknown"; + thinkingTimer.markToolCall(); + log.toolCall({ toolName, input: block.input || {} }); + + // 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(block.input); + } + } + } + + // accumulate per-message usage if available + const msgUsage = event.message?.usage; + if (msgUsage) { + accumulatedTokens.input += msgUsage.input_tokens || 0; + accumulatedTokens.output += msgUsage.output_tokens || 0; + } + }, + user: (event: ClaudeUserEvent) => { + const content = event.message?.content; + if (!content) return; + + for (const block of content) { + if (typeof block === "string") continue; + if (block.type === "tool_result") { + thinkingTimer.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(`» tool error: ${outputContent}`); + } else { + log.debug(`» tool output: ${outputContent}`); + } + } + } + }, + result: (event: ClaudeResultEvent) => { + const subtype = event.subtype || "unknown"; + const numTurns = event.num_turns || 0; + + if (subtype === "success") { + // extract detailed usage from result event (most accurate source) + 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; + const totalInput = inputTokens + cacheRead + cacheWrite; + + accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite }; + costUsd = event.total_cost_usd ?? undefined; + + log.info( + `» ${params.label} result: subtype=${subtype}, turns=${numTurns}, cost=$${costUsd?.toFixed(4) ?? "?"}` + ); + + if (!tokensLogged) { + log.table([ + [ + { data: "Cost", header: true }, + { data: "Input", header: true }, + { data: "Cache Read", header: true }, + { data: "Cache Write", header: true }, + { data: "Output", header: true }, + ], + [ + `$${costUsd?.toFixed(4) || "0.0000"}`, + String(totalInput), + String(cacheRead), + String(cacheWrite), + String(outputTokens), + ], + ]); + tokensLogged = true; + } + } else if (subtype === "error_max_turns") { + log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`); + } else if (subtype === "error_during_execution") { + log.info(`» ${params.label} execution error: ${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[] = []; + const MAX_STDERR_LINES = 20; + let lastProviderError: string | null = null; + + let output = ""; + let stdoutBuffer = ""; + + try { + const result = await spawn({ + cmd: "node", + args: params.args, + cwd: params.cwd, + env: params.env, + activityTimeout: 0, + stdio: ["ignore", "pipe", "pipe"], + onStdout: async (chunk) => { + const text = chunk.toString(); + output += text; + markActivity(); + + stdoutBuffer += text; + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const event = JSON.parse(trimmed) as ClaudeEvent; + 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) { + (handler as (e: ClaudeEvent) => void)(event); + } else { + log.debug(`» ${params.label} event (unhandled): type=${event.type}`); + } + } catch { + log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`); + } + } + }, + onStderr: (chunk) => { + const trimmed = chunk.trim(); + if (!trimmed) return; + + recentStderr.push(trimmed); + if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift(); + + const providerError = detectProviderError(trimmed); + if (providerError) { + lastProviderError = providerError; + log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`); + } 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}`); + } + + if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) { + const totalTokens = accumulatedTokens.input + accumulatedTokens.output; + log.table([ + [ + { data: "Input Tokens", header: true }, + { data: "Output Tokens", header: true }, + { data: "Total Tokens", header: true }, + ], + [String(accumulatedTokens.input), String(accumulatedTokens.output), String(totalTokens)], + ]); + } + + const usage = buildUsage(); + + if (result.exitCode !== 0) { + const errorContext = lastProviderError ? ` (${lastProviderError})` : ""; + const errorMessage = + result.stderr || + result.stdout || + `unknown error - no output from Claude CLI${errorContext}`; + log.error( + `${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}` + ); + log.debug(`stdout: ${result.stdout?.substring(0, 500)}`); + log.debug(`stderr: ${result.stderr?.substring(0, 500)}`); + return { success: false, output: finalOutput || output, error: errorMessage, usage }; + } + + if (eventCount === 0 && lastProviderError) { + return { + success: false, + output: finalOutput || output, + error: `provider error: ${lastProviderError}`, + usage, + }; + } + + return { success: true, output: finalOutput || output, usage }; + } catch (error) { + params.todoTracker?.cancel(); + const duration = performance.now() - startTime; + const errorMessage = error instanceof Error ? error.message : String(error); + const isActivityTimeout = errorMessage.includes("activity timeout"); + + 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, + error: `${errorMessage} [${diagnosis}]`, + usage: buildUsage(), + }; + } +} + +// ── agent ─────────────────────────────────────────────────────────────────────── + +export const claude = agent({ + name: "claude", + install: installClaudeCli, + run: async (ctx) => { + const cliPath = await installClaudeCli(); + + const model = ctx.payload.proxyModel ?? resolveClaudeModel(ctx.payload.model); + + 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", + }); + + const mcpConfigPath = writeMcpConfig(ctx); + + const args = [ + cliPath, + "-p", + ctx.instructions.full, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--mcp-config", + mcpConfigPath, + "--verbose", + "--no-session-persistence", + "--disallowedTools", + "Bash", + "Agent(Bash)", + ]; + + if (model) { + args.push("--model", model); + } + + // agent process gets full env — needs LLM API keys, PATH, locale, etc. + // security is enforced via --disallowedTools (Bash + Bash subagent) and MCP tool filtering. + const env: Record = { + ...process.env, + ...homeEnv, + }; + + const repoDir = process.cwd(); + + log.debug(`» starting Pullfrog (Claude Code): node ${args.join(" ")}`); + log.debug(`» working directory: ${repoDir}`); + + return runClaude({ + label: "Pullfrog", + args, + cwd: repoDir, + env, + todoTracker: ctx.todoTracker, + }); + }, +}); diff --git a/agents/index.ts b/agents/index.ts index 429ec66..b095e46 100644 --- a/agents/index.ts +++ b/agents/index.ts @@ -1,6 +1,7 @@ +import { claude } from "./claude.ts"; import { opentoad } from "./opentoad.ts"; import type { Agent } from "./shared.ts"; export type { Agent, AgentUsage } from "./shared.ts"; -export const agents = { opentoad } satisfies Record; +export const agents = { claude, opentoad } satisfies Record; diff --git a/agents/opentoad.ts b/agents/opentoad.ts index 81a1346..8768fcc 100644 --- a/agents/opentoad.ts +++ b/agents/opentoad.ts @@ -10,7 +10,7 @@ * 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, spawnSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; @@ -19,6 +19,8 @@ import { modelAliases, resolveCliModel } from "../models.ts"; import { getIdleMs, markActivity } from "../utils/activity.ts"; import { log } from "../utils/cli.ts"; import { installFromNpmTarball } from "../utils/install.ts"; +import { detectProviderError } from "../utils/providerErrors.ts"; +import { addSkill } from "../utils/skills.ts"; import { spawn } from "../utils/subprocess.ts"; import { ThinkingTimer } from "../utils/timer.ts"; import type { TodoTracker } from "../utils/todoTracking.ts"; @@ -153,44 +155,6 @@ function resolveOpenCodeModel(ctx: { return undefined; } -// ── provider error detection ─────────────────────────────────────────────────── - -const PROVIDER_ERROR_PATTERNS = [ - { pattern: "429", label: "rate limited (429)" }, - { pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" }, - { pattern: "quota", label: "quota error" }, - { pattern: "status: 500", label: "provider 500 error" }, - { pattern: "INTERNAL", label: "provider internal error" }, - { pattern: "status: 503", label: "provider unavailable (503)" }, - { pattern: "UNAVAILABLE", label: "provider unavailable" }, - { pattern: "rate limit", label: "rate limited" }, - { pattern: "limit: 0", label: "zero quota" }, -]; - -function detectProviderError(text: string): string | null { - for (const entry of PROVIDER_ERROR_PATTERNS) { - if (text.includes(entry.pattern)) return entry.label; - } - return null; -} - -function addSkill(params: { ref: string; skill: string; env: Record }): void { - const result = spawnSync( - "npx", - ["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", "opencode", "-y"], - { - env: { ...process.env, ...params.env }, - stdio: "pipe", - timeout: 30_000, - } - ); - if (result.status === 0) { - log.info(`installed ${params.skill} skill`); - } else { - log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`); - } -} - // ── NDJSON event types ───────────────────────────────────────────────────────── interface OpenCodeInitEvent { @@ -680,6 +644,7 @@ export const opentoad = agent({ ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`, skill: "agent-browser", env: homeEnv, + agent: "opencode", }); const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; diff --git a/entry b/entry index 80dd82a..b0fc77c 100755 --- a/entry +++ b/entry @@ -3682,7 +3682,7 @@ var require_util2 = __commonJS({ "use strict"; var { redirectStatusSet, referrerPolicySet: referrerPolicyTokens, badPortsSet } = require_constants2(); var { getGlobalOrigin } = require_global(); - var { performance: performance7 } = __require("perf_hooks"); + var { performance: performance8 } = __require("perf_hooks"); var { isBlobLike, toUSVString, ReadableStreamFrom } = require_util(); var assert3 = __require("assert"); var { isUint8Array } = __require("util/types"); @@ -3845,7 +3845,7 @@ var require_util2 = __commonJS({ } } function coarsenedSharedCurrentTime(crossOriginIsolatedCapability) { - return performance7.now(); + return performance8.now(); } function createOpaqueTimingInfo(timingInfo) { return { @@ -56217,7 +56217,7 @@ var require_util11 = __commonJS({ var { redirectStatusSet, referrerPolicyTokens, badPortsSet } = require_constants8(); var { getGlobalOrigin } = require_global3(); var { collectAnHTTPQuotedString, parseMIMEType } = require_data_url(); - var { performance: performance7 } = __require("node:perf_hooks"); + var { performance: performance8 } = __require("node:perf_hooks"); var { ReadableStreamFrom, isValidHTTPToken, normalizedMethodRecordsBase } = require_util10(); var assert3 = __require("node:assert"); var { isUint8Array } = __require("node:util/types"); @@ -56372,7 +56372,7 @@ var require_util11 = __commonJS({ }; } function coarsenedSharedCurrentTime(crossOriginIsolatedCapability) { - return coarsenTime(performance7.now(), crossOriginIsolatedCapability); + return coarsenTime(performance8.now(), crossOriginIsolatedCapability); } function createOpaqueTimingInfo(timingInfo) { return { @@ -98890,14 +98890,14 @@ var require_turndown_cjs = __commonJS({ } else if (node2.nodeType === 1) { replacement = replacementForNode.call(self2, node2); } - return join13(output, replacement); + return join14(output, replacement); }, ""); } function postProcess(output) { var self2 = this; this.rules.forEach(function(rule) { if (typeof rule.append === "function") { - output = join13(output, rule.append(self2.options)); + output = join14(output, rule.append(self2.options)); } }); return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, ""); @@ -98909,7 +98909,7 @@ var require_turndown_cjs = __commonJS({ if (whitespace.leading || whitespace.trailing) content = content.trim(); return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing; } - function join13(output, replacement) { + function join14(output, replacement) { var s1 = trimTrailingNewlines(output); var s2 = trimLeadingNewlines(replacement); var nls = Math.max(output.length - s1.length, replacement.length - s2.length); @@ -107678,7 +107678,7 @@ function provider(config3) { var providers = { anthropic: provider({ displayName: "Anthropic", - envVars: ["ANTHROPIC_API_KEY"], + envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], models: { "claude-opus": { displayName: "Claude Opus", @@ -107934,6 +107934,9 @@ function parseModel(slug) { } return { provider: slug.slice(0, slashIdx), model: slug.slice(slashIdx + 1) }; } +function getModelProvider(slug) { + return parseModel(slug).provider; +} function getModelEnvVars(slug) { const parsed2 = parseModel(slug); const providerConfig = providers[parsed2.provider]; @@ -144745,6 +144748,7 @@ var package_default = { turndown: "^7.2.0" }, devDependencies: { + "@anthropic-ai/claude-code": "2.1.85", "agent-browser": "0.21.0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^24.7.2", @@ -149216,9 +149220,8 @@ ${permalinkTip}` } var modes = computeModes(); -// agents/opentoad.ts -import { execFileSync as execFileSync2, spawnSync as spawnSync5 } from "node:child_process"; -import { mkdirSync as mkdirSync3 } from "node:fs"; +// agents/claude.ts +import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync6 } from "node:fs"; import { join as join10 } from "node:path"; import { performance as performance6 } from "node:perf_hooks"; @@ -149307,6 +149310,44 @@ async function installFromNpmTarball(params) { return cliPath; } +// utils/providerErrors.ts +var PROVIDER_ERROR_PATTERNS = [ + { pattern: "429", label: "rate limited (429)" }, + { pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" }, + { pattern: "quota", label: "quota error" }, + { pattern: "status: 500", label: "provider 500 error" }, + { pattern: "INTERNAL", label: "provider internal error" }, + { pattern: "status: 503", label: "provider unavailable (503)" }, + { pattern: "UNAVAILABLE", label: "provider unavailable" }, + { pattern: "rate limit", label: "rate limited" }, + { pattern: "limit: 0", label: "zero quota" } +]; +function detectProviderError(text) { + for (const entry of PROVIDER_ERROR_PATTERNS) { + if (text.includes(entry.pattern)) return entry.label; + } + return null; +} + +// utils/skills.ts +import { spawnSync as spawnSync5 } from "node:child_process"; +function addSkill(params) { + const result = spawnSync5( + "npx", + ["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"], + { + env: { ...process.env, ...params.env }, + stdio: "pipe", + timeout: 3e4 + } + ); + if (result.status === 0) { + log.info(`installed ${params.skill} skill (${params.agent})`); + } else { + log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`); + } +} + // utils/timer.ts import { performance as performance5 } from "node:perf_hooks"; var Timer = class { @@ -149364,7 +149405,360 @@ var agent = (input) => { }; }; +// agents/claude.ts +async function installClaudeCli() { + return await installFromNpmTarball({ + packageName: "@anthropic-ai/claude-code", + version: getDevDependencyVersion("@anthropic-ai/claude-code"), + executablePath: "cli.js", + installDependencies: false + }); +} +function writeMcpConfig(ctx) { + const configDir = join10(ctx.tmpdir, ".claude"); + mkdirSync3(configDir, { recursive: true }); + const configPath = join10(configDir, "mcp.json"); + writeFileSync6( + configPath, + JSON.stringify({ + mcpServers: { + [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } + } + }) + ); + return configPath; +} +function resolveClaudeModel(modelSlug) { + const envModel = process.env.PULLFROG_MODEL?.trim(); + if (envModel) { + const slashIndex = envModel.indexOf("/"); + const cliModel = slashIndex > 0 ? envModel.slice(slashIndex + 1) : envModel; + log.info(`\xBB model: ${cliModel} (override via PULLFROG_MODEL)`); + return cliModel; + } + if (!modelSlug) return void 0; + const resolved = resolveModelSlug(modelSlug); + if (resolved) { + const slashIndex = resolved.indexOf("/"); + const cliModel = slashIndex > 0 ? resolved.slice(slashIndex + 1) : resolved; + log.info(`\xBB model: ${cliModel} (resolved from ${modelSlug})`); + return cliModel; + } + log.warning(`\xBB unknown model slug "${modelSlug}" \u2014 letting Claude Code auto-select`); + return void 0; +} +async function runClaude(params) { + const startTime = performance6.now(); + let eventCount = 0; + const thinkingTimer = new ThinkingTimer(); + let finalOutput = ""; + let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + let costUsd; + let tokensLogged = false; + function buildUsage() { + const totalInput = accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite; + return totalInput > 0 || accumulatedTokens.output > 0 ? { + agent: "claude", + inputTokens: totalInput, + outputTokens: accumulatedTokens.output, + cacheReadTokens: accumulatedTokens.cacheRead || void 0, + cacheWriteTokens: accumulatedTokens.cacheWrite || void 0, + costUsd + } : void 0; + } + const handlers2 = { + system: (_event) => { + log.debug(`\xBB ${params.label} system event`); + }, + assistant: (event) => { + const content = event.message?.content; + if (!content) return; + for (const block of content) { + if (block.type === "text" && block.text?.trim()) { + const message = block.text.trim(); + log.box(message, { title: params.label }); + finalOutput = message; + } else if (block.type === "tool_use") { + const toolName = block.name || "unknown"; + thinkingTimer.markToolCall(); + log.toolCall({ toolName, input: block.input || {} }); + if (toolName.includes("report_progress") && params.todoTracker) { + log.debug("\xBB report_progress detected, disabling todo tracking"); + params.todoTracker.cancel(); + } + if (toolName === "TodoWrite" && params.todoTracker?.enabled) { + params.todoTracker.update(block.input); + } + } + } + const msgUsage = event.message?.usage; + if (msgUsage) { + accumulatedTokens.input += msgUsage.input_tokens || 0; + accumulatedTokens.output += msgUsage.output_tokens || 0; + } + }, + user: (event) => { + const content = event.message?.content; + if (!content) return; + for (const block of content) { + if (typeof block === "string") continue; + if (block.type === "tool_result") { + thinkingTimer.markToolResult(); + const outputContent = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content.map( + (entry) => typeof entry === "string" ? entry : typeof entry === "object" && entry !== null && "text" in entry ? String(entry.text) : JSON.stringify(entry) + ).join("\n") : String(block.content); + if (block.is_error) { + log.info(`\xBB tool error: ${outputContent}`); + } else { + log.debug(`\xBB tool output: ${outputContent}`); + } + } + } + }, + result: (event) => { + const subtype = event.subtype || "unknown"; + const numTurns = event.num_turns || 0; + if (subtype === "success") { + 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; + const totalInput = inputTokens + cacheRead + cacheWrite; + accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite }; + costUsd = event.total_cost_usd ?? void 0; + log.info( + `\xBB ${params.label} result: subtype=${subtype}, turns=${numTurns}, cost=$${costUsd?.toFixed(4) ?? "?"}` + ); + if (!tokensLogged) { + log.table([ + [ + { data: "Cost", header: true }, + { data: "Input", header: true }, + { data: "Cache Read", header: true }, + { data: "Cache Write", header: true }, + { data: "Output", header: true } + ], + [ + `$${costUsd?.toFixed(4) || "0.0000"}`, + String(totalInput), + String(cacheRead), + String(cacheWrite), + String(outputTokens) + ] + ]); + tokensLogged = true; + } + } else if (subtype === "error_max_turns") { + log.info(`\xBB ${params.label} max turns reached: ${JSON.stringify(event)}`); + } else if (subtype === "error_during_execution") { + log.info(`\xBB ${params.label} execution error: ${JSON.stringify(event)}`); + } else { + log.info(`\xBB ${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 = []; + const MAX_STDERR_LINES = 20; + let lastProviderError = null; + let output = ""; + let stdoutBuffer = ""; + try { + const result = await spawn({ + cmd: "node", + args: params.args, + cwd: params.cwd, + env: params.env, + activityTimeout: 0, + stdio: ["ignore", "pipe", "pipe"], + onStdout: async (chunk) => { + const text = chunk.toString(); + output += text; + markActivity(); + stdoutBuffer += text; + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() || ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const event = JSON.parse(trimmed); + eventCount++; + log.debug(JSON.stringify(event, null, 2)); + const timeSinceLastActivity = getIdleMs(); + if (timeSinceLastActivity > 1e4) { + log.info( + `\xBB no activity for ${(timeSinceLastActivity / 1e3).toFixed(1)}s (${params.label} may be processing internally) (${eventCount} events processed so far)` + ); + } + markActivity(); + const handler2 = handlers2[event.type]; + if (handler2) { + handler2(event); + } else { + log.debug(`\xBB ${params.label} event (unhandled): type=${event.type}`); + } + } catch { + log.debug(`\xBB non-JSON stdout line: ${trimmed.substring(0, 200)}`); + } + } + }, + onStderr: (chunk) => { + const trimmed = chunk.trim(); + if (!trimmed) return; + recentStderr.push(trimmed); + if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift(); + const providerError = detectProviderError(trimmed); + if (providerError) { + lastProviderError = providerError; + log.info(`\xBB provider error detected (${providerError}): ${trimmed.substring(0, 500)}`); + } else { + log.debug(trimmed); + } + } + }); + if (result.exitCode === 0) { + await params.todoTracker?.flush(); + } else { + params.todoTracker?.cancel(); + } + const duration4 = performance6.now() - startTime; + log.info( + `\xBB ${params.label} completed in ${Math.round(duration4)}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(`\xBB ${params.label} produced 0 events (${diagnosis})`); + if (stderrContext) log.info(`\xBB last stderr output: +${stderrContext}`); + } + if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) { + const totalTokens = accumulatedTokens.input + accumulatedTokens.output; + log.table([ + [ + { data: "Input Tokens", header: true }, + { data: "Output Tokens", header: true }, + { data: "Total Tokens", header: true } + ], + [String(accumulatedTokens.input), String(accumulatedTokens.output), String(totalTokens)] + ]); + } + const usage = buildUsage(); + if (result.exitCode !== 0) { + const errorContext = lastProviderError ? ` (${lastProviderError})` : ""; + const errorMessage = result.stderr || result.stdout || `unknown error - no output from Claude CLI${errorContext}`; + log.error( + `${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}` + ); + log.debug(`stdout: ${result.stdout?.substring(0, 500)}`); + log.debug(`stderr: ${result.stderr?.substring(0, 500)}`); + return { success: false, output: finalOutput || output, error: errorMessage, usage }; + } + if (eventCount === 0 && lastProviderError) { + return { + success: false, + output: finalOutput || output, + error: `provider error: ${lastProviderError}`, + usage + }; + } + return { success: true, output: finalOutput || output, usage }; + } catch (error49) { + params.todoTracker?.cancel(); + const duration4 = performance6.now() - startTime; + const errorMessage = error49 instanceof Error ? error49.message : String(error49); + const isActivityTimeout = errorMessage.includes("activity timeout"); + 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( + `\xBB ${params.label} ${isActivityTimeout ? "hung" : "failed"} after ${(duration4 / 1e3).toFixed(1)}s: ${errorMessage}` + ); + log.info(`\xBB diagnosis: ${diagnosis}`); + if (stderrContext) + log.info( + `\xBB recent stderr (last ${Math.min(recentStderr.length, 10)} lines): +${stderrContext}` + ); + return { + success: false, + output: finalOutput || output, + error: `${errorMessage} [${diagnosis}]`, + usage: buildUsage() + }; + } +} +var claude = agent({ + name: "claude", + install: installClaudeCli, + run: async (ctx) => { + const cliPath = await installClaudeCli(); + const model = ctx.payload.proxyModel ?? resolveClaudeModel(ctx.payload.model); + const homeEnv = { + HOME: ctx.tmpdir, + XDG_CONFIG_HOME: join10(ctx.tmpdir, ".config") + }; + mkdirSync3(join10(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" + }); + const mcpConfigPath = writeMcpConfig(ctx); + const args2 = [ + cliPath, + "-p", + ctx.instructions.full, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--mcp-config", + mcpConfigPath, + "--verbose", + "--no-session-persistence", + "--disallowedTools", + "Bash", + "Agent(Bash)" + ]; + if (model) { + args2.push("--model", model); + } + const env2 = { + ...process.env, + ...homeEnv + }; + const repoDir = process.cwd(); + log.debug(`\xBB starting Pullfrog (Claude Code): node ${args2.join(" ")}`); + log.debug(`\xBB working directory: ${repoDir}`); + return runClaude({ + label: "Pullfrog", + args: args2, + cwd: repoDir, + env: env2, + todoTracker: ctx.todoTracker + }); + } +}); + // agents/opentoad.ts +import { execFileSync as execFileSync2 } from "node:child_process"; +import { mkdirSync as mkdirSync4 } from "node:fs"; +import { join as join11 } from "node:path"; +import { performance as performance7 } from "node:perf_hooks"; async function installOpencodeCli() { return await installFromNpmTarball({ packageName: "opencode-ai", @@ -149449,41 +149843,8 @@ function resolveOpenCodeModel(ctx) { log.warning(`\xBB no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`); return void 0; } -var PROVIDER_ERROR_PATTERNS = [ - { pattern: "429", label: "rate limited (429)" }, - { pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" }, - { pattern: "quota", label: "quota error" }, - { pattern: "status: 500", label: "provider 500 error" }, - { pattern: "INTERNAL", label: "provider internal error" }, - { pattern: "status: 503", label: "provider unavailable (503)" }, - { pattern: "UNAVAILABLE", label: "provider unavailable" }, - { pattern: "rate limit", label: "rate limited" }, - { pattern: "limit: 0", label: "zero quota" } -]; -function detectProviderError(text) { - for (const entry of PROVIDER_ERROR_PATTERNS) { - if (text.includes(entry.pattern)) return entry.label; - } - return null; -} -function addSkill(params) { - const result = spawnSync5( - "npx", - ["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", "opencode", "-y"], - { - env: { ...process.env, ...params.env }, - stdio: "pipe", - timeout: 3e4 - } - ); - if (result.status === 0) { - log.info(`installed ${params.skill} skill`); - } else { - log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`); - } -} async function runOpenCode(params) { - const startTime = performance6.now(); + const startTime = performance7.now(); let eventCount = 0; const thinkingTimer = new ThinkingTimer(); let finalOutput = ""; @@ -149588,7 +149949,7 @@ async function runOpenCode(params) { if (toolId) { const toolStartTime = toolCallTimings.get(toolId); if (toolStartTime) { - const toolDuration = performance6.now() - toolStartTime; + const toolDuration = performance7.now() - toolStartTime; toolCallTimings.delete(toolId); const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : ""; log.debug( @@ -149708,7 +150069,7 @@ async function runOpenCode(params) { } else { params.todoTracker?.cancel(); } - const duration4 = performance6.now() - startTime; + const duration4 = performance7.now() - startTime; log.info( `\xBB ${params.label} completed in ${Math.round(duration4)}ms with exit code ${result.exitCode}` ); @@ -149752,7 +150113,7 @@ ${stderrContext}`); return { success: true, output: finalOutput || output, usage }; } catch (error49) { params.todoTracker?.cancel(); - const duration4 = performance6.now() - startTime; + const duration4 = performance7.now() - startTime; const errorMessage = error49 instanceof Error ? error49.message : String(error49); const isActivityTimeout = errorMessage.includes("activity timeout"); const stderrContext = recentStderr.slice(-10).join("\n"); @@ -149785,14 +150146,15 @@ var opentoad = agent({ }); const homeEnv = { HOME: ctx.tmpdir, - XDG_CONFIG_HOME: join10(ctx.tmpdir, ".config") + XDG_CONFIG_HOME: join11(ctx.tmpdir, ".config") }; - mkdirSync3(join10(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true }); + mkdirSync4(join11(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true }); const agentBrowserVersion = getDevDependencyVersion("agent-browser"); addSkill({ ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`, skill: "agent-browser", - env: homeEnv + env: homeEnv, + agent: "opencode" }); const args2 = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; const env2 = { @@ -149816,10 +150178,35 @@ var opentoad = agent({ }); // agents/index.ts -var agents = { opentoad }; +var agents = { claude, opentoad }; // utils/agent.ts -function resolveAgent() { +function hasEnvVar(name) { + const val = process.env[name]; + return typeof val === "string" && val.length > 0; +} +function hasClaudeCodeAuth() { + return hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") || hasEnvVar("ANTHROPIC_API_KEY"); +} +function resolveAgent(ctx) { + const envAgent = process.env.PULLFROG_AGENT?.trim(); + if (envAgent) { + if (envAgent in agents) { + log.info(`\xBB agent: ${envAgent} (override via PULLFROG_AGENT)`); + return agents[envAgent]; + } + log.warning(`\xBB unknown PULLFROG_AGENT="${envAgent}" \u2014 falling through to auto-select`); + } + if (ctx?.model) { + try { + const provider2 = getModelProvider(ctx.model); + if (provider2 === "anthropic" && hasClaudeCodeAuth()) { + log.info(`\xBB agent: claude (auto-selected for ${ctx.model})`); + return agents.claude; + } + } catch { + } + } return agents.opentoad; } @@ -149844,7 +150231,7 @@ configure your model at ${settingsUrl} for full setup instructions, see https://docs.pullfrog.com/keys`; } -function hasEnvVar(name) { +function hasEnvVar2(name) { const value2 = process.env[name]; return typeof value2 === "string" && value2.length > 0; } @@ -149852,10 +150239,10 @@ function validateAgentApiKey(params) { if (params.model) { const requiredVars = getModelEnvVars(params.model); if (requiredVars.length === 0) return; - if (requiredVars.some((v) => hasEnvVar(v))) return; + if (requiredVars.some((v) => hasEnvVar2(v))) return; throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); } - const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k)); + const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar2(k)); if (!hasAnyKey) { throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); } @@ -149994,9 +150381,9 @@ ${ctx.error}` : ctx.error; // utils/gitAuthServer.ts import { randomUUID as randomUUID3 } from "node:crypto"; -import { writeFileSync as writeFileSync6 } from "node:fs"; +import { writeFileSync as writeFileSync7 } from "node:fs"; import { createServer as createServer2 } from "node:http"; -import { join as join11 } from "node:path"; +import { join as join12 } from "node:path"; var CODE_TTL_MS = 5 * 60 * 1e3; var TAMPER_WINDOW_MS = 6e4; function revokeGitHubToken(token) { @@ -150068,7 +150455,7 @@ async function startGitAuthServer(tmpdir2) { function writeAskpassScript(code) { const scriptId = randomUUID3(); const scriptName = `askpass-${scriptId}.js`; - const scriptPath = join11(tmpdir2, scriptName); + const scriptPath = join12(tmpdir2, scriptName); const content = [ `#!/usr/bin/env node`, `var a=process.argv[2]||"";`, @@ -150083,7 +150470,7 @@ async function startGitAuthServer(tmpdir2) { `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`, `})}).on("error",function(){process.exit(1)})}` ].join("\n"); - writeFileSync6(scriptPath, content, { mode: 448 }); + writeFileSync7(scriptPath, content, { mode: 448 }); return scriptPath; } async function close() { @@ -150754,9 +151141,9 @@ async function resolveRunContextData(params) { import { execSync as execSync3 } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join as join12 } from "node:path"; +import { join as join13 } from "node:path"; function createTempDirectory() { - const sharedTempDir = mkdtempSync(join12(tmpdir(), "pullfrog-")); + const sharedTempDir = mkdtempSync(join13(tmpdir(), "pullfrog-")); process.env.PULLFROG_TEMP_DIR = sharedTempDir; log.info(`\xBB created temp dir at ${sharedTempDir}`); return sharedTempDir; @@ -151116,7 +151503,7 @@ async function main() { const tmpdir2 = createTempDirectory(); const gitAuthServer = __using(_stack, await startGitAuthServer(tmpdir2), true); setGitAuthServer(gitAuthServer); - const agent2 = resolveAgent(); + const agent2 = resolveAgent({ model: payload.proxyModel ? void 0 : payload.model }); validateAgentApiKey({ agent: agent2, model: payload.proxyModel ?? payload.model, diff --git a/main.ts b/main.ts index 51fcaec..6c15ed6 100644 --- a/main.ts +++ b/main.ts @@ -237,7 +237,7 @@ export async function main(): Promise { await using gitAuthServer = await startGitAuthServer(tmpdir); setGitAuthServer(gitAuthServer); - const agent = resolveAgent(); + const agent = resolveAgent({ model: payload.proxyModel ? undefined : payload.model }); validateAgentApiKey({ agent, diff --git a/models.test.ts b/models.test.ts index 39f3911..02f818c 100644 --- a/models.test.ts +++ b/models.test.ts @@ -35,7 +35,10 @@ describe("getModelProvider", () => { describe("getModelEnvVars", () => { it("returns correct env vars for anthropic", () => { - expect(getModelEnvVars("anthropic/claude-opus")).toEqual(["ANTHROPIC_API_KEY"]); + expect(getModelEnvVars("anthropic/claude-opus")).toEqual([ + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + ]); }); it("returns correct env vars for google (multiple)", () => { diff --git a/models.ts b/models.ts index a862f19..04a0a48 100644 --- a/models.ts +++ b/models.ts @@ -50,7 +50,7 @@ function provider(config: ProviderConfig): ProviderConfig { export const providers = { anthropic: provider({ displayName: "Anthropic", - envVars: ["ANTHROPIC_API_KEY"], + envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], models: { "claude-opus": { displayName: "Claude Opus", diff --git a/package.json b/package.json index 22d8935..15c9fe5 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "turndown": "^7.2.0" }, "devDependencies": { + "@anthropic-ai/claude-code": "2.1.85", "agent-browser": "0.21.0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^24.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8494447..ed8bc01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: specifier: ^7.2.0 version: 7.2.2 devDependencies: + '@anthropic-ai/claude-code': + specifier: 2.1.85 + version: 2.1.85 '@modelcontextprotocol/sdk': specifier: ^1.26.0 version: 1.26.0(zod@4.3.6) @@ -117,6 +120,11 @@ packages: '@actions/io@1.1.3': resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + '@anthropic-ai/claude-code@2.1.85': + resolution: {integrity: sha512-3/q3xTpk9EnBfQ/XsHGkOZniOgQx4sqD95CDKw1mvN1Qw5+9IZTp6ILdds02d7vOM6YuLL0G0zhqsMSAFVse4w==} + engines: {node: '>=18.0.0'} + hasBin: true + '@ark/fs@0.56.0': resolution: {integrity: sha512-zY/wDDhcvmt6/upQwZM766PAnvIzdEMcgydUGd9pqY9FMGNo9I9uE4RYAfms9AeUUtbZJu2h2Ua0tvFsO5XF4Q==} @@ -457,6 +465,95 @@ packages: peerDependencies: hono: ^4 + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -1827,6 +1924,18 @@ snapshots: '@actions/io@1.1.3': {} + '@anthropic-ai/claude-code@2.1.85': + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + '@ark/fs@0.56.0': {} '@ark/schema@0.56.0': @@ -2003,6 +2112,68 @@ snapshots: dependencies: hono: 4.12.0 + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@mixmark-io/domino@2.2.0': {} diff --git a/post b/post index 00ffc9d..68ff738 100755 --- a/post +++ b/post @@ -37516,7 +37516,7 @@ function provider(config) { var providers = { anthropic: provider({ displayName: "Anthropic", - envVars: ["ANTHROPIC_API_KEY"], + envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], models: { "claude-opus": { displayName: "Claude Opus", @@ -41603,6 +41603,7 @@ var package_default = { turndown: "^7.2.0" }, devDependencies: { + "@anthropic-ai/claude-code": "2.1.85", "agent-browser": "0.21.0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^24.7.2", diff --git a/test/__snapshots__/models.test.ts.snap b/test/__snapshots__/models.test.ts.snap index 79db772..d331568 100644 --- a/test/__snapshots__/models.test.ts.snap +++ b/test/__snapshots__/models.test.ts.snap @@ -27,8 +27,8 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = ` "releaseDate": "2026-03-30", }, "openrouter": { - "modelId": "xiaomi/mimo-v2-pro", - "releaseDate": "2026-03-18", + "modelId": "qwen/qwen3.6-plus-preview:free", + "releaseDate": "2026-03-30", }, "xai": { "modelId": "grok-4.20-multi-agent-0309", diff --git a/test/ci.test.ts b/test/ci.test.ts index 851b369..d19805f 100644 --- a/test/ci.test.ts +++ b/test/ci.test.ts @@ -114,7 +114,7 @@ describe("ci workflow consistency", () => { it("changed-agents.sh treats legacy agent files as non-agent changes", () => { const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], { - input: JSON.stringify(["action/agents/claude.ts", "action/agents/gemini.ts"]), + input: JSON.stringify(["action/agents/codex.ts", "action/agents/gemini.ts"]), encoding: "utf-8", }); expect(JSON.parse(output)).toEqual(["opentoad"]); diff --git a/utils/agent.ts b/utils/agent.ts index 5a3f351..098adf0 100644 --- a/utils/agent.ts +++ b/utils/agent.ts @@ -1,6 +1,41 @@ import type { Agent } from "../agents/index.ts"; import { agents } from "../agents/index.ts"; +import { getModelProvider } from "../models.ts"; +import { log } from "./cli.ts"; -export function resolveAgent(): Agent { +function hasEnvVar(name: string): boolean { + const val = process.env[name]; + return typeof val === "string" && val.length > 0; +} + +function hasClaudeCodeAuth(): boolean { + return hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") || hasEnvVar("ANTHROPIC_API_KEY"); +} + +export function resolveAgent(ctx?: { model?: string | undefined }): Agent { + // 1. explicit env var override (escape hatch) + const envAgent = process.env.PULLFROG_AGENT?.trim(); + if (envAgent) { + if (envAgent in agents) { + log.info(`» agent: ${envAgent} (override via PULLFROG_AGENT)`); + return agents[envAgent as keyof typeof agents]; + } + log.warning(`» unknown PULLFROG_AGENT="${envAgent}" — falling through to auto-select`); + } + + // 2. if model is Anthropic and Claude Code credentials are available, use Claude Code + if (ctx?.model) { + try { + const provider = getModelProvider(ctx.model); + if (provider === "anthropic" && hasClaudeCodeAuth()) { + log.info(`» agent: claude (auto-selected for ${ctx.model})`); + return agents.claude; + } + } catch { + // invalid model slug format — fall through + } + } + + // 3. default: OpenCode (universal, supports all providers) return agents.opentoad; } diff --git a/utils/apiKeys.test.ts b/utils/apiKeys.test.ts index ae5ef2c..3e20823 100644 --- a/utils/apiKeys.test.ts +++ b/utils/apiKeys.test.ts @@ -12,7 +12,7 @@ const savedEnv = { ...process.env }; beforeEach(() => { // strip all known provider keys so tests start clean for (const key of Object.keys(process.env)) { - if (key.endsWith("_API_KEY")) delete process.env[key]; + if (key.endsWith("_API_KEY") || key === "CLAUDE_CODE_OAUTH_TOKEN") delete process.env[key]; } }); diff --git a/utils/docker.ts b/utils/docker.ts index 9e608ea..dc7c671 100644 --- a/utils/docker.ts +++ b/utils/docker.ts @@ -115,6 +115,7 @@ const testEnvAllowList = new Set([ "GITHUB_PRIVATE_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "PULLFROG_MODEL", diff --git a/utils/providerErrors.ts b/utils/providerErrors.ts new file mode 100644 index 0000000..65a5380 --- /dev/null +++ b/utils/providerErrors.ts @@ -0,0 +1,18 @@ +const PROVIDER_ERROR_PATTERNS = [ + { pattern: "429", label: "rate limited (429)" }, + { pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" }, + { pattern: "quota", label: "quota error" }, + { pattern: "status: 500", label: "provider 500 error" }, + { pattern: "INTERNAL", label: "provider internal error" }, + { pattern: "status: 503", label: "provider unavailable (503)" }, + { pattern: "UNAVAILABLE", label: "provider unavailable" }, + { pattern: "rate limit", label: "rate limited" }, + { pattern: "limit: 0", label: "zero quota" }, +]; + +export function detectProviderError(text: string): string | null { + for (const entry of PROVIDER_ERROR_PATTERNS) { + if (text.includes(entry.pattern)) return entry.label; + } + return null; +} diff --git a/utils/skills.ts b/utils/skills.ts new file mode 100644 index 0000000..b309eb7 --- /dev/null +++ b/utils/skills.ts @@ -0,0 +1,24 @@ +import { spawnSync } from "node:child_process"; +import { log } from "./cli.ts"; + +export function addSkill(params: { + ref: string; + skill: string; + env: Record; + agent: string; +}): void { + const result = spawnSync( + "npx", + ["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"], + { + env: { ...process.env, ...params.env }, + stdio: "pipe", + timeout: 30_000, + } + ); + if (result.status === 0) { + log.info(`installed ${params.skill} skill (${params.agent})`); + } else { + log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`); + } +}