Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd9c3382c7 | |||
| ba1966f17c | |||
| 0055aef618 | |||
| 9f566d20e4 | |||
| 6c5d228c04 |
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, string | undefined>;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
};
|
||||
|
||||
async function runClaude(params: RunParams): Promise<AgentResult> {
|
||||
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<string, string | undefined> = {
|
||||
...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,
|
||||
});
|
||||
},
|
||||
});
|
||||
+2
-1
@@ -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<string, Agent>;
|
||||
export const agents = { claude, opentoad } satisfies Record<string, Agent>;
|
||||
|
||||
+6
-41
@@ -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";
|
||||
@@ -52,7 +54,7 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
edit: "allow",
|
||||
read: "allow",
|
||||
webfetch: "allow",
|
||||
external_directory: "deny",
|
||||
external_directory: "allow",
|
||||
skill: "allow",
|
||||
},
|
||||
mcp: {
|
||||
@@ -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<string, string> }): 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 {
|
||||
@@ -502,7 +466,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
args: params.args,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
activityTimeout: 0,
|
||||
activityTimeout: 300_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
@@ -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"];
|
||||
|
||||
@@ -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",
|
||||
@@ -107721,7 +107721,7 @@ var providers = {
|
||||
}),
|
||||
google: provider({
|
||||
displayName: "Google",
|
||||
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
|
||||
envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
models: {
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
@@ -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];
|
||||
@@ -144699,7 +144702,7 @@ var import_semver = __toESM(require_semver2(), 1);
|
||||
// package.json
|
||||
var package_default = {
|
||||
name: "@pullfrog/pullfrog",
|
||||
version: "0.0.184",
|
||||
version: "0.0.185",
|
||||
type: "module",
|
||||
files: [
|
||||
"index.js",
|
||||
@@ -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",
|
||||
@@ -145007,20 +145011,19 @@ function installSignalHandler() {
|
||||
});
|
||||
}
|
||||
async function spawn(options) {
|
||||
const { cmd, args: args2, env: env2, input, timeout, cwd, stdio, onStdout, onStderr } = options;
|
||||
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
|
||||
installSignalHandler();
|
||||
const startTime = performance3.now();
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
return new Promise((resolve2, reject) => {
|
||||
const child = nodeSpawn(cmd, args2, {
|
||||
env: env2 || {
|
||||
const child = nodeSpawn(options.cmd, options.args, {
|
||||
env: options.env || {
|
||||
PATH: process.env.PATH || "",
|
||||
HOME: process.env.HOME || ""
|
||||
},
|
||||
stdio: stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: cwd || process.cwd()
|
||||
stdio: options.stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: options.cwd || process.cwd()
|
||||
});
|
||||
trackChild({ child });
|
||||
let timeoutId;
|
||||
@@ -145028,7 +145031,7 @@ async function spawn(options) {
|
||||
let isTimedOut = false;
|
||||
let isActivityTimedOut = false;
|
||||
let lastActivityTime = performance3.now();
|
||||
if (timeout) {
|
||||
if (options.timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
isTimedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
@@ -145037,10 +145040,12 @@ async function spawn(options) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5e3);
|
||||
}, timeout);
|
||||
}, options.timeout);
|
||||
}
|
||||
if (activityTimeoutMs > 0) {
|
||||
log.debug(`spawn activity timer: pid=${child.pid} cmd=${cmd} timeout=${activityTimeoutMs}ms`);
|
||||
log.debug(
|
||||
`spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
|
||||
);
|
||||
activityCheckIntervalId = setInterval(() => {
|
||||
const idleMs = performance3.now() - lastActivityTime;
|
||||
log.debug(
|
||||
@@ -145049,7 +145054,9 @@ async function spawn(options) {
|
||||
if (idleMs > activityTimeoutMs) {
|
||||
isActivityTimedOut = true;
|
||||
const idleSec = Math.round(idleMs / 1e3);
|
||||
log.info(`no output for ${idleSec}s from pid=${child.pid} (${cmd}), killing process`);
|
||||
log.info(
|
||||
`no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process`
|
||||
);
|
||||
child.kill("SIGKILL");
|
||||
clearInterval(activityCheckIntervalId);
|
||||
}
|
||||
@@ -145063,15 +145070,14 @@ async function spawn(options) {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stdoutBuffer += chunk;
|
||||
onStdout?.(chunk);
|
||||
options.onStdout?.(chunk);
|
||||
});
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stderrBuffer += chunk;
|
||||
onStderr?.(chunk);
|
||||
options.onStderr?.(chunk);
|
||||
});
|
||||
}
|
||||
child.on("close", (exitCode) => {
|
||||
@@ -145080,7 +145086,7 @@ async function spawn(options) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
|
||||
if (isTimedOut) {
|
||||
reject(new Error(`process timed out after ${timeout}ms`));
|
||||
reject(new Error(`process timed out after ${options.timeout}ms`));
|
||||
return;
|
||||
}
|
||||
if (isActivityTimedOut) {
|
||||
@@ -145108,8 +145114,8 @@ async function spawn(options) {
|
||||
durationMs
|
||||
});
|
||||
});
|
||||
if (input && child.stdin && stdio?.[0] !== "ignore") {
|
||||
child.stdin.write(input);
|
||||
if (options.input && child.stdin && options.stdio?.[0] !== "ignore") {
|
||||
child.stdin.write(options.input);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
@@ -148198,7 +148204,8 @@ ${learningsStep(6)}`,
|
||||
|
||||
6. Submit:
|
||||
- **actionable issues found**: call \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, all comments, and an **empty body** \u2014 inline comments speak for themselves, and a top-level body clutters the PR conversation on every re-review cycle. Then call \`report_progress\` with a 1-sentence summary.
|
||||
- **no actionable issues found**: do NOT submit a review. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Re-reviewed \u2014 no new issues found.").`,
|
||||
- **no actionable issues, but substantive changes or prior fixes confirmed**: post a brief comment (1-3 sentences) via \`${ghPullfrogMcpName}/create_issue_comment\` confirming the review happened and listing which prior review issues were resolved. Substantive = new functionality, behavior changes, architectural changes, or fixes to previously flagged issues.
|
||||
- **no actionable issues, non-substantive changes only** (e.g., trivial formatting, import reordering, comment tweaks with no functional impact): do NOT submit a review. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Re-reviewed \u2014 no new issues found.").`,
|
||||
Plan: `### Checklist
|
||||
|
||||
1. Analyze the task and gather context:
|
||||
@@ -149048,7 +149055,8 @@ ${permalinkTip}
|
||||
|
||||
7. **SUBMIT** \u2014 Determine whether to submit a review:
|
||||
- **Issues found**: Submit via ${ghPullfrogMcpName}/create_pull_request_review with \`approved: false\`, the inline comments from step 6, and an **empty body** \u2014 inline comments speak for themselves, and a top-level body clutters the PR conversation on every re-review cycle. Then call \`report_progress\` with a 1-sentence summary (e.g., "Re-reviewed \u2014 found 2 issues in the new commits.").
|
||||
- **No issues found**: Do NOT submit a review. Call \`report_progress\` with a brief note (e.g., "Re-reviewed \u2014 no new issues found.").
|
||||
- **No issues, but substantive changes or prior fixes confirmed**: Post a brief comment (1-3 sentences) via ${ghPullfrogMcpName}/create_issue_comment confirming the review happened and listing which prior review issues were resolved. Substantive = new functionality, behavior changes, architectural changes, or fixes to previously flagged issues.
|
||||
- **No issues, non-substantive changes only** (e.g., trivial formatting, import reordering, comment tweaks with no functional impact): Do NOT submit a review. Call \`report_progress\` with a brief note (e.g., "Re-reviewed \u2014 no new issues found.").
|
||||
|
||||
${permalinkTip}
|
||||
`
|
||||
@@ -149212,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";
|
||||
|
||||
@@ -149303,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 {
|
||||
@@ -149360,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",
|
||||
@@ -149376,7 +149774,7 @@ function buildSecurityConfig(ctx, model) {
|
||||
edit: "allow",
|
||||
read: "allow",
|
||||
webfetch: "allow",
|
||||
external_directory: "deny",
|
||||
external_directory: "allow",
|
||||
skill: "allow"
|
||||
},
|
||||
mcp: {
|
||||
@@ -149445,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 = "";
|
||||
@@ -149584,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(
|
||||
@@ -149647,7 +150012,7 @@ async function runOpenCode(params) {
|
||||
args: params.args,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
activityTimeout: 0,
|
||||
activityTimeout: 3e5,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
@@ -149704,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}`
|
||||
);
|
||||
@@ -149748,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");
|
||||
@@ -149781,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 = {
|
||||
@@ -149812,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;
|
||||
}
|
||||
|
||||
@@ -149840,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;
|
||||
}
|
||||
@@ -149848,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 }));
|
||||
}
|
||||
@@ -149990,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) {
|
||||
@@ -150064,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]||"";`,
|
||||
@@ -150079,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() {
|
||||
@@ -150717,7 +151108,8 @@ async function fetchRunContext(params) {
|
||||
},
|
||||
apiToken: data.apiToken,
|
||||
oss: data.oss ?? false,
|
||||
proxyModel: data.proxyModel
|
||||
proxyModel: data.proxyModel,
|
||||
dbSecrets: data.dbSecrets
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -150742,7 +151134,8 @@ async function resolveRunContextData(params) {
|
||||
repoSettings: runContext.settings,
|
||||
apiToken: runContext.apiToken,
|
||||
oss: runContext.oss,
|
||||
proxyModel: runContext.proxyModel
|
||||
proxyModel: runContext.proxyModel,
|
||||
dbSecrets: runContext.dbSecrets
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150750,9 +151143,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;
|
||||
@@ -151066,6 +151459,16 @@ async function main() {
|
||||
const initialOctokit = createOctokit(jobToken);
|
||||
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
|
||||
timer.checkpoint("runContextData");
|
||||
if (runContext.dbSecrets) {
|
||||
for (const [key, value2] of Object.entries(runContext.dbSecrets)) {
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value2;
|
||||
core5.setSecret(value2);
|
||||
}
|
||||
}
|
||||
const count = Object.keys(runContext.dbSecrets).length;
|
||||
if (count > 0) log.info(`\xBB ${count} db secret(s) loaded`);
|
||||
}
|
||||
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
||||
toolState.model = payload.model;
|
||||
if (payload.event.trigger === "pull_request_synchronize") {
|
||||
@@ -151112,7 +151515,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,
|
||||
|
||||
@@ -169,6 +169,18 @@ export async function main(): Promise<MainResult> {
|
||||
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
|
||||
timer.checkpoint("runContextData");
|
||||
|
||||
// inject account-level secrets into process.env (YAML secrets take precedence)
|
||||
if (runContext.dbSecrets) {
|
||||
for (const [key, value] of Object.entries(runContext.dbSecrets)) {
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
core.setSecret(value);
|
||||
}
|
||||
}
|
||||
const count = Object.keys(runContext.dbSecrets).length;
|
||||
if (count > 0) log.info(`» ${count} db secret(s) loaded`);
|
||||
}
|
||||
|
||||
// resolve payload to determine shell permission
|
||||
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
||||
toolState.model = payload.model;
|
||||
@@ -237,7 +249,7 @@ export async function main(): Promise<MainResult> {
|
||||
await using gitAuthServer = await startGitAuthServer(tmpdir);
|
||||
setGitAuthServer(gitAuthServer);
|
||||
|
||||
const agent = resolveAgent();
|
||||
const agent = resolveAgent({ model: payload.proxyModel ? undefined : payload.model });
|
||||
|
||||
validateAgentApiKey({
|
||||
agent,
|
||||
|
||||
+2
-1
@@ -135,7 +135,8 @@ ${learningsStep(6)}`,
|
||||
|
||||
6. Submit:
|
||||
- **actionable issues found**: call \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, all comments, and an **empty body** — inline comments speak for themselves, and a top-level body clutters the PR conversation on every re-review cycle. Then call \`report_progress\` with a 1-sentence summary.
|
||||
- **no actionable issues found**: do NOT submit a review. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Re-reviewed — no new issues found.").`,
|
||||
- **no actionable issues, but substantive changes or prior fixes confirmed**: post a brief comment (1-3 sentences) via \`${ghPullfrogMcpName}/create_issue_comment\` confirming the review happened and listing which prior review issues were resolved. Substantive = new functionality, behavior changes, architectural changes, or fixes to previously flagged issues.
|
||||
- **no actionable issues, non-substantive changes only** (e.g., trivial formatting, import reordering, comment tweaks with no functional impact): do NOT submit a review. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Re-reviewed — no new issues found.").`,
|
||||
|
||||
Plan: `### Checklist
|
||||
|
||||
|
||||
+4
-1
@@ -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)", () => {
|
||||
|
||||
@@ -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",
|
||||
@@ -93,7 +93,7 @@ export const providers = {
|
||||
}),
|
||||
google: provider({
|
||||
displayName: "Google",
|
||||
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
|
||||
envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
models: {
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
|
||||
@@ -154,7 +154,8 @@ ${permalinkTip}
|
||||
|
||||
7. **SUBMIT** — Determine whether to submit a review:
|
||||
- **Issues found**: Submit via ${ghPullfrogMcpName}/create_pull_request_review with \`approved: false\`, the inline comments from step 6, and an **empty body** — inline comments speak for themselves, and a top-level body clutters the PR conversation on every re-review cycle. Then call \`report_progress\` with a 1-sentence summary (e.g., "Re-reviewed — found 2 issues in the new commits.").
|
||||
- **No issues found**: Do NOT submit a review. Call \`report_progress\` with a brief note (e.g., "Re-reviewed — no new issues found.").
|
||||
- **No issues, but substantive changes or prior fixes confirmed**: Post a brief comment (1-3 sentences) via ${ghPullfrogMcpName}/create_issue_comment confirming the review happened and listing which prior review issues were resolved. Substantive = new functionality, behavior changes, architectural changes, or fixes to previously flagged issues.
|
||||
- **No issues, non-substantive changes only** (e.g., trivial formatting, import reordering, comment tweaks with no functional impact): Do NOT submit a review. Call \`report_progress\` with a brief note (e.g., "Re-reviewed — no new issues found.").
|
||||
|
||||
${permalinkTip}
|
||||
`,
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/pullfrog",
|
||||
"version": "0.0.184",
|
||||
"version": "0.0.185",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
@@ -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",
|
||||
|
||||
Generated
+171
@@ -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': {}
|
||||
|
||||
@@ -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",
|
||||
@@ -37559,7 +37559,7 @@ var providers = {
|
||||
}),
|
||||
google: provider({
|
||||
displayName: "Google",
|
||||
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
|
||||
envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
models: {
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
@@ -41557,7 +41557,7 @@ var core3 = __toESM(require_core(), 1);
|
||||
// package.json
|
||||
var package_default = {
|
||||
name: "@pullfrog/pullfrog",
|
||||
version: "0.0.184",
|
||||
version: "0.0.185",
|
||||
type: "module",
|
||||
files: [
|
||||
"index.js",
|
||||
@@ -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",
|
||||
|
||||
@@ -23,12 +23,12 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
|
||||
"releaseDate": "2026-03-17",
|
||||
},
|
||||
"opencode": {
|
||||
"modelId": "mimo-v2-pro-free",
|
||||
"releaseDate": "2026-03-18",
|
||||
"modelId": "qwen3.6-plus-free",
|
||||
"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",
|
||||
|
||||
+1
-1
@@ -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"]);
|
||||
|
||||
+36
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export interface RunContext {
|
||||
apiToken: string;
|
||||
oss: boolean;
|
||||
proxyModel?: string | undefined;
|
||||
dbSecrets?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
const defaultSettings: RepoSettings = {
|
||||
@@ -82,6 +83,7 @@ export async function fetchRunContext(params: {
|
||||
apiToken: string;
|
||||
oss?: boolean;
|
||||
proxyModel?: string;
|
||||
dbSecrets?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
if (data === null) {
|
||||
@@ -100,6 +102,7 @@ export async function fetchRunContext(params: {
|
||||
apiToken: data.apiToken,
|
||||
oss: data.oss ?? false,
|
||||
proxyModel: data.proxyModel,
|
||||
dbSecrets: data.dbSecrets,
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface RunContextData {
|
||||
apiToken: string;
|
||||
oss: boolean;
|
||||
proxyModel?: string | undefined;
|
||||
dbSecrets?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
interface ResolveRunContextDataParams {
|
||||
@@ -46,5 +47,6 @@ export async function resolveRunContextData(
|
||||
apiToken: runContext.apiToken,
|
||||
oss: runContext.oss,
|
||||
proxyModel: runContext.proxyModel,
|
||||
dbSecrets: runContext.dbSecrets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
+19
-16
@@ -76,7 +76,8 @@ export interface SpawnOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string;
|
||||
timeout?: number;
|
||||
// activity timeout: kill process if no stdout/stderr for this many ms (default: 30s, 0 to disable)
|
||||
// activity timeout: kill process if no stdout for this many ms (default: 30s, 0 to disable).
|
||||
// only stdout resets the timer — stderr (e.g. provider error retries) does not count as progress.
|
||||
activityTimeout?: number;
|
||||
cwd?: string;
|
||||
stdio?: ("pipe" | "ignore" | "inherit")[];
|
||||
@@ -95,7 +96,6 @@ export interface SpawnResult {
|
||||
* Spawn a subprocess with streaming callbacks and buffered results
|
||||
*/
|
||||
export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
const { cmd, args, env, input, timeout, cwd, stdio, onStdout, onStderr } = options;
|
||||
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
|
||||
|
||||
installSignalHandler();
|
||||
@@ -106,13 +106,13 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// security: caller must provide complete env object, not merged with process.env
|
||||
const child = nodeSpawn(cmd, args, {
|
||||
env: env || {
|
||||
const child = nodeSpawn(options.cmd, options.args, {
|
||||
env: options.env || {
|
||||
PATH: process.env.PATH || "",
|
||||
HOME: process.env.HOME || "",
|
||||
},
|
||||
stdio: stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: cwd || process.cwd(),
|
||||
stdio: options.stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: options.cwd || process.cwd(),
|
||||
});
|
||||
|
||||
// track child for cleanup on Ctrl+C
|
||||
@@ -125,7 +125,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
let lastActivityTime = performance.now();
|
||||
|
||||
// overall timeout
|
||||
if (timeout) {
|
||||
if (options.timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
isTimedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
@@ -135,12 +135,14 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}, timeout);
|
||||
}, options.timeout);
|
||||
}
|
||||
|
||||
// activity timeout: kill if no output for too long
|
||||
if (activityTimeoutMs > 0) {
|
||||
log.debug(`spawn activity timer: pid=${child.pid} cmd=${cmd} timeout=${activityTimeoutMs}ms`);
|
||||
log.debug(
|
||||
`spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
|
||||
);
|
||||
activityCheckIntervalId = setInterval(() => {
|
||||
const idleMs = performance.now() - lastActivityTime;
|
||||
log.debug(
|
||||
@@ -149,7 +151,9 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
if (idleMs > activityTimeoutMs) {
|
||||
isActivityTimedOut = true;
|
||||
const idleSec = Math.round(idleMs / 1000);
|
||||
log.info(`no output for ${idleSec}s from pid=${child.pid} (${cmd}), killing process`);
|
||||
log.info(
|
||||
`no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process`
|
||||
);
|
||||
child.kill("SIGKILL");
|
||||
clearInterval(activityCheckIntervalId);
|
||||
}
|
||||
@@ -165,16 +169,15 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stdoutBuffer += chunk;
|
||||
onStdout?.(chunk);
|
||||
options.onStdout?.(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stderrBuffer += chunk;
|
||||
onStderr?.(chunk);
|
||||
options.onStderr?.(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,7 +189,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
|
||||
|
||||
if (isTimedOut) {
|
||||
reject(new Error(`process timed out after ${timeout}ms`));
|
||||
reject(new Error(`process timed out after ${options.timeout}ms`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,8 +225,8 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
});
|
||||
});
|
||||
|
||||
if (input && child.stdin && stdio?.[0] !== "ignore") {
|
||||
child.stdin.write(input);
|
||||
if (options.input && child.stdin && options.stdio?.[0] !== "ignore") {
|
||||
child.stdin.write(options.input);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user