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()}`); + } +}