Compare commits

...

8 Commits

Author SHA1 Message Date
Colin McDonnell cd9c3382c7 fix: remove unused variable in yes test, regenerate prisma client
Made-with: Cursor
2026-03-31 05:53:01 +00:00
Colin McDonnell ba1966f17c feat: encrypted account-level secrets (#501)
* feat: add encrypted account-level secrets with UI for adding API keys

Adds AccountSecret model with AES-256-GCM encryption, API routes for
CRUD, "Add secret" button in model costs section, and injects decrypted
secrets into action env (YAML secrets take precedence).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: repo secrets, sidebar icons, lazy learnings history

- add repo-level secrets with inheritance from org secrets
- add icons to console sidebar sections
- fix learnings history modal: lazy fetch with hover prefetch,
  strip content from list response, load content per-expansion

Made-with: Cursor

* add input validation bounds for secrets and fix client-side name filter

Made-with: Cursor

* refactor: migrate all client-side data fetching to TanStack Query

Replace manual useState/useEffect/fetch patterns and the custom
usePolling hook with useQuery, useInfiniteQuery, and useMutation
across the entire frontend for consistent caching, background
refetching, and reactive invalidation.

- ActiveWorkflowRunsSection: useQuery + refetchInterval
- WorkflowRunHistory: useInfiniteQuery + polling query
- LearningsSection: useQuery per revision (lazy)
- FlagsSettings: self-contained useQuery + useMutation
- SecretsCard: useMutation for delete
- AddWorkflowButton, VerifyWorkflowButton: useMutation
- EmailSignupForm, email-waitlist: useMutation
- providers.tsx: enable refetchOnWindowFocus
- Delete usePolling.ts (no remaining consumers)

Made-with: Cursor

* address PR review: squash migrations, rename accountSecrets → dbSecrets

Squash the two separate secrets migrations into a single migration.
Rename the wire format field from accountSecrets to dbSecrets since
it now carries merged account + repo secrets.

Made-with: Cursor

* fix: update proxyKeys.ts imports after cache.ts -> yes package migration

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 05:02:38 +00:00
Colin McDonnell 0055aef618 feat: add Claude Code agent for Anthropic model users (#502)
* feat: add Claude Code agent for Anthropic model users

Re-adds Claude Code support (removed in #478) so users with Anthropic API
keys or Claude Code OAuth tokens can use their Claude subscriptions directly.

When an Anthropic model is selected and Claude Code credentials are available,
the system auto-selects the Claude agent instead of OpenCode. The harness
mirrors opentoad's security model: native Bash blocked via --disallowedTools,
MCP ShellTool for restricted shell, ASKPASS for git auth. Includes NDJSON
streaming, provider error detection, cache/cost tracking, browser skill,
and todo progress tracking.

Key changes:
- action/agents/claude.ts: full Claude Code harness
- action/utils/agent.ts: auto-select Claude for anthropic/* models
- action/utils/providerErrors.ts: extracted shared provider error detection
- action/utils/skills.ts: extracted shared skill installation (agent-aware)
- action/models.ts: add CLAUDE_CODE_OAUTH_TOKEN to anthropic envVars
- action/utils/docker.ts: add CLAUDE_CODE_OAUTH_TOKEN to test env allowlist
- CI: add claude to test matrix, pass CLAUDE_CODE_OAUTH_TOKEN secret

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove unused toolId variable, fix apiKeys test env cleanup

The apiKeys test cleanup stripped *_API_KEY vars but missed
CLAUDE_CODE_OAUTH_TOKEN which doesn't match that pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: strip provider prefix from PULLFROG_MODEL in Claude agent

the env override path was returning the raw value (e.g.
"anthropic/claude-sonnet-4-5") without stripping the provider prefix,
causing the Claude CLI to receive an invalid model ID.

Made-with: Cursor

* fix: remove dead cliPath field, add CLAUDE_CODE_OAUTH_TOKEN to workflows

remove unused cliPath from Claude agent RunParams, and pass
CLAUDE_CODE_OAUTH_TOKEN through all pullfrog.yml workflow templates
so users with Claude Pro/Team subscriptions can use their membership.

Made-with: Cursor

* fix: block Bash subagent in Claude Code disallowedTools

Made-with: Cursor

* chore: update model snapshot (opencode/openrouter latest → qwen3.6-plus-free)

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:29:54 +00:00
Colin McDonnell 9f566d20e4 fix proxy key usage tracking and add OSS spend reporting (#500)
* fix proxy key usage tracking and add OSS spend reporting

replace ProxyKey.disabled with finalizedAt to fix a race where keys
were disabled before their usage was synced, causing all HWM values to
be zero. retireKey now fetches final usage from OpenRouter and records
it atomically with optimistic concurrency. syncAccountUsage skips keys
that fail to fetch rather than recording false zeros.

other fixes:
- wrap OpenRouter API calls in retry logic (exponential backoff)
- reconcileStaleWorkflowRuns now retires proxy keys for completed runs
- subprocess activity timeout only tracks stdout (stderr retry loops
  no longer prevent hung agent detection)
- add oss-spend script (single bulk fetch from OpenRouter) and
  backfill-proxy-key-usage script

Made-with: Cursor

* address review: move isActiveKey check inside transaction, remove redundant guard

Made-with: Cursor
2026-03-31 02:18:08 +00:00
Colin McDonnell 6c5d228c04 allow external_directory reads — not a security boundary
Made-with: Cursor
2026-03-30 21:37:36 +00:00
Colin McDonnell 51659fee71 drop inkeep/agents from oss program, bump action to 0.0.184
Made-with: Cursor
2026-03-30 16:15:57 +00:00
Colin McDonnell bf68e0d915 fix: add blank line before footer divider to fix rendering after details
GitHub markdown needs a blank line between </details> and subsequent
HTML elements. Without it, the footer renders inside the collapsed
section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:58:18 +00:00
Mateusz Burzyński a7b8dcbced Rework incremental diffing (#499)
* Improve our deepening logic

* Use consistent SHA for PR-related operations in CheckoutPrTool

* compute `deepenDepth` at more appropriate time

* fix stale comment

* add comments for `alreadyOnBranch`

* ensure before sha is available

* small cleanup

* computeIncrementalDiff

* move the util

* improve algorithm

* improve algorithm further

* get rid of temp result array

* add comment

* compute incremental diff and updte instructions

* add comment

* update stale comment

* get rid of redundant rev-parse call

* improve comment

* strenghten the instructions

* make diff paths unique
2026-03-27 16:09:13 +00:00
33 changed files with 2369 additions and 483 deletions
+1
View File
@@ -37,6 +37,7 @@ jobs:
# add any additional keys your agent(s) need # add any additional keys your agent(s) need
# optionally, comment out any you won't use # optionally, comment out any you won't use
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
+2 -1
View File
@@ -27,12 +27,13 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
agent: [opentoad] agent: [claude, opentoad]
test: test:
[mcpmerge, nobash, restricted, smoke] [mcpmerge, nobash, restricted, smoke]
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
+562
View File
@@ -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
View File
@@ -1,6 +1,7 @@
import { claude } from "./claude.ts";
import { opentoad } from "./opentoad.ts"; import { opentoad } from "./opentoad.ts";
import type { Agent } from "./shared.ts"; import type { Agent } from "./shared.ts";
export type { Agent, AgentUsage } 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
View File
@@ -10,7 +10,7 @@
* the agent process itself gets full env (needs LLM API keys, PATH, etc.). * the agent process itself gets full env (needs LLM API keys, PATH, etc.).
* security is enforced at the tool layer, not the process layer. * 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 { mkdirSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
@@ -19,6 +19,8 @@ import { modelAliases, resolveCliModel } from "../models.ts";
import { getIdleMs, markActivity } from "../utils/activity.ts"; import { getIdleMs, markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.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 { spawn } from "../utils/subprocess.ts";
import { ThinkingTimer } from "../utils/timer.ts"; import { ThinkingTimer } from "../utils/timer.ts";
import type { TodoTracker } from "../utils/todoTracking.ts"; import type { TodoTracker } from "../utils/todoTracking.ts";
@@ -52,7 +54,7 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
edit: "allow", edit: "allow",
read: "allow", read: "allow",
webfetch: "allow", webfetch: "allow",
external_directory: "deny", external_directory: "allow",
skill: "allow", skill: "allow",
}, },
mcp: { mcp: {
@@ -153,44 +155,6 @@ function resolveOpenCodeModel(ctx: {
return undefined; 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 ───────────────────────────────────────────────────────── // ── NDJSON event types ─────────────────────────────────────────────────────────
interface OpenCodeInitEvent { interface OpenCodeInitEvent {
@@ -502,7 +466,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
args: params.args, args: params.args,
cwd: params.cwd, cwd: params.cwd,
env: params.env, env: params.env,
activityTimeout: 0, activityTimeout: 300_000,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
onStdout: async (chunk) => { onStdout: async (chunk) => {
const text = chunk.toString(); const text = chunk.toString();
@@ -680,6 +644,7 @@ export const opentoad = agent({
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`, ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
skill: "agent-browser", skill: "agent-browser",
env: homeEnv, env: homeEnv,
agent: "opencode",
}); });
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
+792 -223
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -212,7 +212,7 @@ interface PullRequestSynchronizeEvent extends BasePayloadEvent {
title: string; title: string;
body: string | null; body: string | null;
branch: string; branch: string;
/** SHA before the push -- used to compute incremental diff via `git diff before_sha...HEAD` */ /** SHA before the push -- used to compute incremental range-diff between PR versions */
before_sha: string; before_sha: string;
} }
+16 -1
View File
@@ -169,9 +169,24 @@ export async function main(): Promise<MainResult> {
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken }); const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
timer.checkpoint("runContextData"); 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 // resolve payload to determine shell permission
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings); const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
toolState.model = payload.model; toolState.model = payload.model;
if (payload.event.trigger === "pull_request_synchronize") {
toolState.beforeSha = payload.event.before_sha;
}
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange // resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
await using tokenRef = await resolveTokens({ push: payload.push }); await using tokenRef = await resolveTokens({ push: payload.push });
@@ -234,7 +249,7 @@ export async function main(): Promise<MainResult> {
await using gitAuthServer = await startGitAuthServer(tmpdir); await using gitAuthServer = await startGitAuthServer(tmpdir);
setGitAuthServer(gitAuthServer); setGitAuthServer(gitAuthServer);
const agent = resolveAgent(); const agent = resolveAgent({ model: payload.proxyModel ? undefined : payload.model });
validateAgentApiKey({ validateAgentApiKey({
agent, agent,
+256 -166
View File
@@ -5,6 +5,7 @@ import { type } from "arktype";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { $git } from "../utils/gitAuth.ts"; import { $git } from "../utils/gitAuth.ts";
import { executeLifecycleHook } from "../utils/lifecycle.ts"; import { executeLifecycleHook } from "../utils/lifecycle.ts";
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -139,6 +140,7 @@ export type CheckoutPrResult = {
url: string; url: string;
headRepo: string; headRepo: string;
diffPath: string; diffPath: string;
incrementalDiffPath?: string | undefined;
toc: string; toc: string;
instructions: string; instructions: string;
}; };
@@ -166,135 +168,236 @@ export async function fetchAndFormatPrDiff(params: FetchPrDiffParams): Promise<F
import type { GitContext } from "../utils/setup.ts"; import type { GitContext } from "../utils/setup.ts";
type CheckoutPrBranchParams = GitContext; export type PrData = {
number: number;
headSha: string;
headRef: string;
headRepoFullName: string;
baseRef: string;
baseRepoFullName: string;
maintainerCanModify: boolean;
};
interface CheckoutPrBranchResult { type EnsureBeforeShaParams = {
prNumber: number; sha: string;
isFork: boolean; octokit: Octokit;
forkUrl?: string | undefined; // only set when isFork is true owner: string;
repo: string;
gitToken: string;
isShallow: boolean;
};
type CreateTempBranchParams = {
octokit: Octokit;
owner: string;
repo: string;
ref: string;
sha: string;
};
async function createTempBranch(params: CreateTempBranchParams) {
const response = await params.octokit.rest.git.createRef({
owner: params.owner,
repo: params.repo,
ref: `refs/heads/${params.ref}`,
sha: params.sha,
});
return {
data: response.data,
async [Symbol.asyncDispose]() {
try {
await params.octokit.rest.git.deleteRef({
owner: params.owner,
repo: params.repo,
ref: `heads/${params.ref}`,
});
log.debug(`» deleted temp branch ${params.ref}`);
} catch (e) {
log.debug(
`» failed to delete temp branch ${params.ref}: ${e instanceof Error ? e.message : String(e)}`
);
}
},
};
} }
async function ensureBeforeShaReachable(params: EnsureBeforeShaParams): Promise<boolean> {
try {
$("git", ["cat-file", "-t", params.sha], { log: false });
log.debug(`» before_sha ${params.sha.slice(0, 7)} is reachable`);
return true;
} catch {
// not available locally — create a temporary branch to fetch it
}
const tempBranch = `pullfrog/tmp/${params.sha.slice(0, 12)}`;
try {
log.debug(`» before_sha ${params.sha.slice(0, 7)} not reachable, creating temp branch...`);
await using _ref = await createTempBranch({
octokit: params.octokit,
owner: params.owner,
repo: params.repo,
sha: params.sha,
ref: tempBranch,
});
await $git(
"fetch",
["--no-tags", ...(params.isShallow ? ["--depth=1"] : []), "origin", tempBranch],
{ token: params.gitToken }
);
log.debug(`» fetched before_sha via temp branch ${tempBranch}`);
return true;
} catch (e) {
log.debug(`» failed to fetch before_sha: ${e instanceof Error ? e.message : String(e)}`);
return false;
}
}
type CheckoutPrBranchParams = GitContext & {
beforeSha?: string | undefined;
};
/** /**
* Shared helper to checkout a PR branch and configure fork remotes. * Shared helper to checkout a PR branch and configure fork remotes.
* Assumes origin remote is already configured with authentication. * Assumes origin remote is already configured with authentication.
* Updates toolState.issueNumber and toolState.pushUrl (for fork PRs). * Updates toolState.issueNumber, toolState.checkoutSha, and toolState.pushUrl (for fork PRs).
*/ */
export async function checkoutPrBranch( export async function checkoutPrBranch(pr: PrData, params: CheckoutPrBranchParams): Promise<void> {
pullNumber: number, const { octokit, owner, name, gitToken, toolState, beforeSha } = params;
params: CheckoutPrBranchParams log.info(`» checking out PR #${pr.number}...`);
): Promise<CheckoutPrBranchResult> {
const { octokit, owner, name, gitToken, toolState } = params;
log.info(`» checking out PR #${pullNumber}...`);
// fetch PR metadata const isFork = pr.headRepoFullName !== pr.baseRepoFullName;
const pr = await octokit.rest.pulls.get({
owner,
repo: name,
pull_number: pullNumber,
});
const headRepo = pr.data.head.repo;
if (!headRepo) {
throw new Error(`PR #${pullNumber} source repository was deleted`);
}
const isFork = headRepo.full_name !== pr.data.base.repo.full_name;
const baseBranch = pr.data.base.ref;
const headBranch = pr.data.head.ref;
// always use pr-{number} as local branch name for consistency // always use pr-{number} as local branch name for consistency
// this avoids naming conflicts and makes push config simpler // this avoids naming conflicts and makes push config simpler
const localBranch = `pr-${pullNumber}`; const localBranch = `pr-${pr.number}`;
// compute deepen depth for shallow clones. actions/checkout uses depth=1
// by default, which breaks rebase/log because git can't find the merge base.
// use the GitHub compare API to fetch exactly enough history.
const isShallow = const isShallow =
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true"; $("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
let deepenArgs: string[] = [];
if (isShallow) {
let depth = 1000; // fallback
try {
const comparison = await octokit.rest.repos.compareCommits({
owner,
repo: name,
base: baseBranch,
head: `pull/${pullNumber}/head`,
});
depth = comparison.data.behind_by + 10;
log.debug(
`» PR is ${comparison.data.behind_by} commits behind ${baseBranch}, deepening by ${depth}`
);
} catch {
log.debug(`» compare API failed, falling back to --deepen=${depth}`);
}
deepenArgs = [`--deepen=${depth}`];
}
// check if we're already on the correct commit (not just branch name) toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
// this handles fork PRs where head branch name might match base branch name const alreadyOnBranch = toolState.checkoutSha === pr.headSha;
const currentSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
const alreadyOnBranch = currentSha === pr.data.head.sha;
if (alreadyOnBranch) { // fetch base branch so origin/<base> exists for diff operations
log.debug(`already on PR branch ${localBranch}, skipping checkout`); log.debug(`» fetching base branch (${pr.baseRef})...`);
} else { await $git("fetch", ["--no-tags", "origin", pr.baseRef], { token: gitToken });
// fetch base branch so origin/<base> exists for diff operations
log.debug(`» fetching base branch (${baseBranch})...`);
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
token: gitToken,
});
// alreadyOnBranch only matches for repeated checkout_pr calls for the same PR in one session
// (without the tip moving), or if an external setup already checked out the PR head.
// normal PR-triggered runs won't match here — actions/checkout lands on a synthesized
// merge commit whose SHA differs from pr.headSha.
//
// so the fetch+checkout block below will almost always execute, and the fetched HEAD
// might differ from pr.headSha. toolState.checkoutSha is set after to capture the actual SHA.
if (!alreadyOnBranch) {
// checkout base branch first to avoid "refusing to fetch into current branch" error // checkout base branch first to avoid "refusing to fetch into current branch" error
// -B creates or resets the branch to match origin/baseBranch // -B creates or resets the branch to match origin/baseBranch
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`], { log: false }); $("git", ["checkout", "-B", pr.baseRef, `origin/${pr.baseRef}`], { log: false });
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs) // fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
log.debug(`» fetching PR #${pullNumber} (${localBranch})...`); log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
await $git("fetch", ["--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`], { await $git("fetch", ["--no-tags", "origin", `pull/${pr.number}/head:${localBranch}`], {
token: gitToken, token: gitToken,
}); });
// checkout the branch // checkout the branch
$("git", ["checkout", localBranch], { log: false }); $("git", ["checkout", localBranch], { log: false });
log.debug(`» checked out PR #${pullNumber}`); log.debug(`» checked out PR #${pr.number}`);
// make sure toolState.checkoutSha is set to the actual checked-out SHA (which might be different from pr.headSha)
toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
} }
// ensure base branch is fetched (needed for diff operations) const beforeShaReachable = beforeSha
// fetch if we skipped checkout (already on branch) - otherwise already fetched above ? await ensureBeforeShaReachable({
if (alreadyOnBranch) { sha: beforeSha,
log.debug(`» fetching base branch (${baseBranch})...`); octokit,
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], { owner,
token: gitToken, repo: name,
}); gitToken,
isShallow,
})
: false;
// compute deepen depth for shallow clones. actions/checkout uses depth=1
// by default, which breaks rebase/log because git can't find the merge base.
// use the GitHub compare API to fetch exactly enough history.
// computed after checkout so compareCommits uses the actual checked-out SHA.
if (isShallow) {
let deepenDepth = 0;
try {
// ahead_by = PR commits past merge base, behind_by = base commits past merge base.
// --deepen extends ALL shallow roots equally (can't deepen a single branch),
// so we need the max across both the PR head and before_sha to ensure all
// three points (base, head, before_sha) reach the merge base in a single deepen call.
const [prComparison, beforeShaComparison] = await Promise.all([
octokit.rest.repos.compareCommits({
owner,
repo: name,
base: pr.baseRef,
head: toolState.checkoutSha,
}),
beforeSha && beforeShaReachable
? octokit.rest.repos.compareCommits({
owner,
repo: name,
base: pr.baseRef,
head: beforeSha,
})
: undefined,
]);
deepenDepth =
Math.max(
prComparison.data.ahead_by,
prComparison.data.behind_by,
beforeShaComparison?.data.ahead_by ?? 0,
beforeShaComparison?.data.behind_by ?? 0
) + 10;
log.debug(
`» PR: ${prComparison.data.ahead_by} ahead / ${prComparison.data.behind_by} behind` +
(beforeShaComparison
? `, before_sha: ${beforeShaComparison.data.ahead_by} ahead / ${beforeShaComparison.data.behind_by} behind`
: "") +
`, deepen by ${deepenDepth}`
);
} catch {
deepenDepth = 1000;
log.debug(`» compare API failed, falling back to --deepen=${deepenDepth}`);
}
// deepen after both branches are fetched so the merge base is reachable from both sides
if (deepenDepth) {
log.debug(`» deepening by ${deepenDepth} to reach merge base...`);
await $git("fetch", [`--deepen=${deepenDepth}`, "--no-tags", "origin"], {
token: gitToken,
});
}
} }
// configure push remote for this branch // configure push remote for this branch
// NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure // NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure
// fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit. // fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit.
if (isFork) { if (isFork) {
const remoteName = `pr-${pullNumber}`; const remoteName = `pr-${pr.number}`;
// SECURITY: fork URL without token - auth is injected via GIT_ASKPASS in $git() // SECURITY: fork URL without token - auth is injected via GIT_ASKPASS in $git()
const forkUrl = `https://github.com/${headRepo.full_name}.git`; const forkUrl = `https://github.com/${pr.headRepoFullName}.git`;
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam) // add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
try { try {
$("git", ["remote", "add", remoteName, forkUrl], { log: false }); $("git", ["remote", "add", remoteName, forkUrl], { log: false });
log.debug(`» added remote '${remoteName}' for fork ${headRepo.full_name}`); log.debug(`» added remote '${remoteName}' for fork ${pr.headRepoFullName}`);
} catch { } catch {
// remote already exists, update its URL // remote already exists, update its URL
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false }); $("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
log.debug(`» updated remote '${remoteName}' for fork ${headRepo.full_name}`); log.debug(`» updated remote '${remoteName}' for fork ${pr.headRepoFullName}`);
} }
// set branch push config so `git push` knows where to push // set branch push config so `git push` knows where to push
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false }); $("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false });
// set merge ref so git knows the remote branch name (may differ from local) // set merge ref so git knows the remote branch name (may differ from local)
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false }); $("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`); log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${pr.headRef}'`);
// warn if maintainer can't modify (push will likely fail) // warn if maintainer can't modify (push will likely fail)
if (!pr.data.maintainer_can_modify) { if (!pr.maintainerCanModify) {
log.warning( log.warning(
`» fork PR has maintainer_can_modify=false - push operations will fail. ` + `» fork PR has maintainer_can_modify=false - push operations will fail. ` +
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.` `ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
@@ -303,21 +406,21 @@ export async function checkoutPrBranch(
} else { } else {
// for same-repo PRs, push to origin // for same-repo PRs, push to origin
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"], { log: false }); $("git", ["config", `branch.${localBranch}.pushRemote`, "origin"], { log: false });
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false }); $("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
} }
// update toolState // update toolState
toolState.issueNumber = pullNumber; toolState.issueNumber = pr.number;
if (isFork) { if (isFork) {
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`; toolState.pushUrl = `https://github.com/${pr.headRepoFullName}.git`;
} }
// store push destination so push_branch can use it directly // store push destination so push_branch can use it directly
// git config is the primary mechanism, but toolState serves as a reliable fallback // git config is the primary mechanism, but toolState serves as a reliable fallback
// in case git config reads fail in certain environments // in case git config reads fail in certain environments
toolState.pushDest = { toolState.pushDest = {
remoteName: isFork ? `pr-${pullNumber}` : "origin", remoteName: isFork ? `pr-${pr.number}` : "origin",
remoteBranch: headBranch, remoteBranch: pr.headRef,
localBranch, localBranch,
}; };
@@ -326,50 +429,6 @@ export async function checkoutPrBranch(
event: "post-checkout", event: "post-checkout",
script: params.postCheckoutScript, script: params.postCheckoutScript,
}); });
return {
prNumber: pullNumber,
isFork,
forkUrl: isFork ? `https://github.com/${headRepo.full_name}.git` : undefined,
};
}
type DeepenForBeforeShaParams = {
gitToken: string;
beforeSha: string;
};
function deepenForBeforeSha(params: DeepenForBeforeShaParams): void {
const isShallow =
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
if (!isShallow) return;
const maxIterations = 10;
for (let i = 0; i < maxIterations; i++) {
try {
$("git", ["cat-file", "-t", params.beforeSha], { log: false });
log.debug(`» before_sha ${params.beforeSha.slice(0, 7)} is now reachable`);
return;
} catch {
// not reachable yet, deepen
}
log.debug(
`» deepening by 50 to reach before_sha ${params.beforeSha.slice(0, 7)} (attempt ${i + 1}/${maxIterations})`
);
try {
$git("fetch", ["--deepen=50", "--no-tags", "origin"], {
token: params.gitToken,
});
} catch {
log.debug(`» deepen for before_sha failed (force-push may have rewritten history)`);
return;
}
}
log.debug(
`» before_sha ${params.beforeSha.slice(0, 7)} not reachable after ${maxIterations * 50} commits`
);
} }
export function CheckoutPrTool(ctx: ToolContext) { export function CheckoutPrTool(ctx: ToolContext) {
@@ -380,7 +439,28 @@ export function CheckoutPrTool(ctx: ToolContext) {
"Returns diffPath pointing to the formatted diff file.", "Returns diffPath pointing to the formatted diff file.",
parameters: CheckoutPr, parameters: CheckoutPr,
execute: execute(async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
await checkoutPrBranch(pull_number, { const prResponse = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
const headRepo = prResponse.data.head.repo;
if (!headRepo) {
throw new Error(`PR #${pull_number} source repository was deleted`);
}
const pr: PrData = {
number: pull_number,
headSha: prResponse.data.head.sha,
headRef: prResponse.data.head.ref,
headRepoFullName: headRepo.full_name,
baseRef: prResponse.data.base.ref,
baseRepoFullName: prResponse.data.base.repo.full_name,
maintainerCanModify: prResponse.data.maintainer_can_modify,
};
await checkoutPrBranch(pr, {
octokit: ctx.octokit, octokit: ctx.octokit,
owner: ctx.repo.owner, owner: ctx.repo.owner,
name: ctx.repo.name, name: ctx.repo.name,
@@ -388,31 +468,39 @@ export function CheckoutPrTool(ctx: ToolContext) {
toolState: ctx.toolState, toolState: ctx.toolState,
shell: ctx.payload.shell, shell: ctx.payload.shell,
postCheckoutScript: ctx.postCheckoutScript, postCheckoutScript: ctx.postCheckoutScript,
beforeSha: ctx.toolState.beforeSha,
}); });
// for incremental review/rereview: deepen the clone to include before_sha const tempDir = process.env.PULLFROG_TEMP_DIR;
// so `git diff before_sha...HEAD` works without the agent needing to fetch manually if (!tempDir) {
const event = ctx.payload.event; throw new Error(
if ("before_sha" in event && event.before_sha) { "PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
deepenForBeforeSha({ );
gitToken: ctx.gitToken, }
beforeSha: event.before_sha,
const headShort = ctx.toolState.checkoutSha!.slice(0, 7);
// compute incremental diff if we have a beforeSha to compare against
let incrementalDiffPath: string | undefined;
if (ctx.toolState.beforeSha && ctx.toolState.checkoutSha) {
const beforeShort = ctx.toolState.beforeSha.slice(0, 7);
const incremental = computeIncrementalDiff({
baseBranch: pr.baseRef,
beforeSha: ctx.toolState.beforeSha,
headSha: ctx.toolState.checkoutSha,
}); });
if (incremental) {
incrementalDiffPath = join(
tempDir,
`pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
);
writeFileSync(incrementalDiffPath, incremental);
log.info(
`» incremental diff computed (${incremental.length} bytes) → ${incrementalDiffPath}`
);
}
} }
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
const headRepo = pr.data.head.repo;
if (!headRepo) {
throw new Error(`PR #${pull_number} source repository was deleted`);
}
ctx.toolState.checkoutSha = pr.data.head.sha;
// fetch PR files and format with line numbers // fetch PR files and format with line numbers
const formatResult = await fetchAndFormatPrDiff({ const formatResult = await fetchAndFormatPrDiff({
octokit: ctx.octokit, octokit: ctx.octokit,
@@ -422,28 +510,29 @@ export function CheckoutPrTool(ctx: ToolContext) {
}); });
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n"); const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`); log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
const tempDir = process.env.PULLFROG_TEMP_DIR; const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`);
if (!tempDir) {
throw new Error(
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
);
}
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
writeFileSync(diffPath, formatResult.content); writeFileSync(diffPath, formatResult.content);
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`); log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
const incrementalInstructions = incrementalDiffPath
? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version ` +
`(computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, ` +
`then use diffPath for full PR context. do NOT skip the incremental diff.`
: "";
return { return {
success: true, success: true,
number: pr.data.number, number: prResponse.data.number,
title: pr.data.title, title: prResponse.data.title,
base: pr.data.base.ref, base: pr.baseRef,
localBranch: `pr-${pull_number}`, localBranch: `pr-${pull_number}`,
remoteBranch: `refs/heads/${pr.data.head.ref}`, remoteBranch: `refs/heads/${pr.headRef}`,
isFork: headRepo.full_name !== pr.data.base.repo.full_name, isFork: pr.headRepoFullName !== pr.baseRepoFullName,
maintainerCanModify: pr.data.maintainer_can_modify, maintainerCanModify: pr.maintainerCanModify,
url: pr.data.html_url, url: prResponse.data.html_url,
headRepo: headRepo.full_name, headRepo: pr.headRepoFullName,
diffPath, diffPath,
incrementalDiffPath,
toc: formatResult.toc, toc: formatResult.toc,
instructions: instructions:
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` + `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
@@ -451,7 +540,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` + `for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
`review files selectively based on relevance rather than reading everything sequentially. ` + `review files selectively based on relevance rather than reading everything sequentially. ` +
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` + `the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.`, `when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` +
incrementalInstructions,
} satisfies CheckoutPrResult; } satisfies CheckoutPrResult;
}), }),
}); });
+7 -5
View File
@@ -1,5 +1,6 @@
import type { RestEndpointMethodTypes } from "@octokit/rest"; import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import { ghPullfrogMcpName } from "../external.ts";
import { apiFetch } from "../utils/apiFetch.ts"; import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts"; import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
@@ -208,7 +209,9 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
) { ) {
const fromSha = ctx.toolState.checkoutSha; const fromSha = ctx.toolState.checkoutSha;
const toSha = latestHeadSha; const toSha = latestHeadSha;
// advance checkoutSha so the next review submission tracks correctly // store old checkoutSha as beforeSha so the next checkout_pr computes an incremental diff
ctx.toolState.beforeSha = fromSha;
// advance checkoutSha so the next review submission tracks correctly (just in case, checkout_pr will overwrite it again)
ctx.toolState.checkoutSha = toSha; ctx.toolState.checkoutSha = toSha;
log.info( log.info(
@@ -226,10 +229,9 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
from: fromSha, from: fromSha,
to: toSha, to: toSha,
instructions: instructions:
`New commits were pushed while you were reviewing. ` + `new commits were pushed while you were reviewing. ` +
`Run \`git pull\` to fetch them, then review the incremental diff ` + `call \`${ghPullfrogMcpName}/checkout_pr\` again to fetch the latest version — it will compute the incremental diff automatically. ` +
`with \`git diff ${fromSha}...HEAD\`. Submit another review covering ` + `submit another review covering only the new changes. do not repeat feedback from your previous review.`,
`only the new changes. Do not repeat feedback from your previous review.`,
}, },
}; };
} }
+4 -3
View File
@@ -117,9 +117,9 @@ ${learningsStep(6)}`,
IncrementalReview: `### Checklist IncrementalReview: `### Checklist
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change. 1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available).
2. Generate the incremental diff using the \`before_sha\` from EVENT DATA: \`git diff <before_sha>...HEAD\`. This isolates only the new commits. If the command fails (e.g., force-push rewrote history), fall back to reviewing the full PR diff. 2. If \`incrementalDiffPath\` is present, read it to see what changed since the last review. This is a range-diff that isolates the net changes, filtering out base branch noise. If not present, fall back to reviewing the full PR diff.
3. Fetch previous reviews via \`${ghPullfrogMcpName}/list_pull_request_reviews\`. For the most recent Pullfrog review, call \`${ghPullfrogMcpName}/get_review_comments\` with the review ID to retrieve specific prior line-level feedback. 3. Fetch previous reviews via \`${ghPullfrogMcpName}/list_pull_request_reviews\`. For the most recent Pullfrog review, call \`${ghPullfrogMcpName}/get_review_comments\` with the review ID to retrieve specific prior line-level feedback.
@@ -135,7 +135,8 @@ ${learningsStep(6)}`,
6. Submit: 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. - **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 Plan: `### Checklist
+3
View File
@@ -72,6 +72,9 @@ export interface ToolState {
issueNumber?: number; issueNumber?: number;
// PR HEAD sha at checkout time — used to detect new commits pushed during a review // PR HEAD sha at checkout time — used to detect new commits pushed during a review
checkoutSha?: string; checkoutSha?: string;
// SHA to diff incrementally against — set from event payload on first checkout,
// then from checkoutSha when review.ts detects new commits mid-review
beforeSha?: string;
selectedMode?: string; selectedMode?: string;
backgroundProcesses: Map<string, BackgroundProcess>; backgroundProcesses: Map<string, BackgroundProcess>;
browserDaemon?: BrowserDaemon | undefined; browserDaemon?: BrowserDaemon | undefined;
+4 -1
View File
@@ -35,7 +35,10 @@ describe("getModelProvider", () => {
describe("getModelEnvVars", () => { describe("getModelEnvVars", () => {
it("returns correct env vars for anthropic", () => { 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)", () => { it("returns correct env vars for google (multiple)", () => {
+2 -2
View File
@@ -50,7 +50,7 @@ function provider(config: ProviderConfig): ProviderConfig {
export const providers = { export const providers = {
anthropic: provider({ anthropic: provider({
displayName: "Anthropic", displayName: "Anthropic",
envVars: ["ANTHROPIC_API_KEY"], envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
models: { models: {
"claude-opus": { "claude-opus": {
displayName: "Claude Opus", displayName: "Claude Opus",
@@ -93,7 +93,7 @@ export const providers = {
}), }),
google: provider({ google: provider({
displayName: "Google", displayName: "Google",
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"], envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
models: { models: {
"gemini-pro": { "gemini-pro": {
displayName: "Gemini Pro", displayName: "Gemini Pro",
+4 -6
View File
@@ -134,12 +134,9 @@ ${permalinkTip}
"Re-review a PR after new commits are pushed; focus on new changes since the last review", "Re-review a PR after new commits are pushed; focus on new changes since the last review",
prompt: `Follow these steps to incrementally re-review the PR after new commits were pushed. Focus on what changed since the last review. prompt: `Follow these steps to incrementally re-review the PR after new commits were pushed. Focus on what changed since the last review.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This gives you the full PR diff via \`diffPath\`. 1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This returns \`diffPath\` (full PR diff) and \`incrementalDiffPath\` (changes since last reviewed version, if available).
2. **INCREMENTAL DIFF** - EVENT DATA contains \`before_sha\` (the HEAD before this push). Generate the incremental diff: 2. **INCREMENTAL DIFF** - If \`incrementalDiffPath\` is present, read it to see what changed since the last review. This is a range-diff that isolates only the net changes, filtering out base branch noise. If not present, fall back to reviewing the full PR diff.
\`git diff <before_sha>...HEAD\`
This shows the changes introduced by this push. Cross-reference with previous reviews (step 3) to confirm coverage of all unreviewed changes — the full PR diff fills any gaps.
**If the diff command fails** (e.g., force-push rewrote history), fall back to reviewing the full PR diff from step 1.
3. **FETCH PREVIOUS REVIEWS** - Use ${ghPullfrogMcpName}/list_pull_request_reviews to find previous Pullfrog reviews. For the most recent one, call ${ghPullfrogMcpName}/get_review_comments with the review ID to see specific line-level feedback. This lets you understand what feedback was already given. 3. **FETCH PREVIOUS REVIEWS** - Use ${ghPullfrogMcpName}/list_pull_request_reviews to find previous Pullfrog reviews. For the most recent one, call ${ghPullfrogMcpName}/get_review_comments with the review ID to see specific line-level feedback. This lets you understand what feedback was already given.
@@ -157,7 +154,8 @@ ${permalinkTip}
7. **SUBMIT** — Determine whether to submit a review: 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."). - **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} ${permalinkTip}
`, `,
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/pullfrog", "name": "@pullfrog/pullfrog",
"version": "0.0.183", "version": "0.0.185",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
@@ -46,6 +46,7 @@
"turndown": "^7.2.0" "turndown": "^7.2.0"
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/claude-code": "2.1.85",
"agent-browser": "0.21.0", "agent-browser": "0.21.0",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
+171
View File
@@ -66,6 +66,9 @@ importers:
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.2.2 version: 7.2.2
devDependencies: devDependencies:
'@anthropic-ai/claude-code':
specifier: 2.1.85
version: 2.1.85
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
specifier: ^1.26.0 specifier: ^1.26.0
version: 1.26.0(zod@4.3.6) version: 1.26.0(zod@4.3.6)
@@ -117,6 +120,11 @@ packages:
'@actions/io@1.1.3': '@actions/io@1.1.3':
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} 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': '@ark/fs@0.56.0':
resolution: {integrity: sha512-zY/wDDhcvmt6/upQwZM766PAnvIzdEMcgydUGd9pqY9FMGNo9I9uE4RYAfms9AeUUtbZJu2h2Ua0tvFsO5XF4Q==} resolution: {integrity: sha512-zY/wDDhcvmt6/upQwZM766PAnvIzdEMcgydUGd9pqY9FMGNo9I9uE4RYAfms9AeUUtbZJu2h2Ua0tvFsO5XF4Q==}
@@ -457,6 +465,95 @@ packages:
peerDependencies: peerDependencies:
hono: ^4 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': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -1827,6 +1924,18 @@ snapshots:
'@actions/io@1.1.3': {} '@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/fs@0.56.0': {}
'@ark/schema@0.56.0': '@ark/schema@0.56.0':
@@ -2003,6 +2112,68 @@ snapshots:
dependencies: dependencies:
hono: 4.12.0 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': {} '@jridgewell/sourcemap-codec@1.5.5': {}
'@mixmark-io/domino@2.2.0': {} '@mixmark-io/domino@2.2.0': {}
+5 -3
View File
@@ -37516,7 +37516,7 @@ function provider(config) {
var providers = { var providers = {
anthropic: provider({ anthropic: provider({
displayName: "Anthropic", displayName: "Anthropic",
envVars: ["ANTHROPIC_API_KEY"], envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
models: { models: {
"claude-opus": { "claude-opus": {
displayName: "Claude Opus", displayName: "Claude Opus",
@@ -37559,7 +37559,7 @@ var providers = {
}), }),
google: provider({ google: provider({
displayName: "Google", displayName: "Google",
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"], envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
models: { models: {
"gemini-pro": { "gemini-pro": {
displayName: "Gemini Pro", displayName: "Gemini Pro",
@@ -37805,6 +37805,7 @@ function buildPullfrogFooter(params) {
} }
const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"]; const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
return ` return `
${PULLFROG_DIVIDER} ${PULLFROG_DIVIDER}
<sup>${FROG_LOGO}&nbsp;&nbsp;\uFF5C ${allParts.join(" \uFF5C ")}</sup>`; <sup>${FROG_LOGO}&nbsp;&nbsp;\uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
} }
@@ -41556,7 +41557,7 @@ var core3 = __toESM(require_core(), 1);
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.183", version: "0.0.185",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",
@@ -41602,6 +41603,7 @@ var package_default = {
turndown: "^7.2.0" turndown: "^7.2.0"
}, },
devDependencies: { devDependencies: {
"@anthropic-ai/claude-code": "2.1.85",
"agent-browser": "0.21.0", "agent-browser": "0.21.0",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
+4 -4
View File
@@ -23,12 +23,12 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
"releaseDate": "2026-03-17", "releaseDate": "2026-03-17",
}, },
"opencode": { "opencode": {
"modelId": "mimo-v2-pro-free", "modelId": "qwen3.6-plus-free",
"releaseDate": "2026-03-18", "releaseDate": "2026-03-30",
}, },
"openrouter": { "openrouter": {
"modelId": "xiaomi/mimo-v2-pro", "modelId": "qwen/qwen3.6-plus-preview:free",
"releaseDate": "2026-03-18", "releaseDate": "2026-03-30",
}, },
"xai": { "xai": {
"modelId": "grok-4.20-multi-agent-0309", "modelId": "grok-4.20-multi-agent-0309",
+1 -1
View File
@@ -114,7 +114,7 @@ describe("ci workflow consistency", () => {
it("changed-agents.sh treats legacy agent files as non-agent changes", () => { it("changed-agents.sh treats legacy agent files as non-agent changes", () => {
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], { 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", encoding: "utf-8",
}); });
expect(JSON.parse(output)).toEqual(["opentoad"]); expect(JSON.parse(output)).toEqual(["opentoad"]);
+36 -1
View File
@@ -1,6 +1,41 @@
import type { Agent } from "../agents/index.ts"; import type { Agent } from "../agents/index.ts";
import { agents } 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; return agents.opentoad;
} }
+1 -1
View File
@@ -12,7 +12,7 @@ const savedEnv = { ...process.env };
beforeEach(() => { beforeEach(() => {
// strip all known provider keys so tests start clean // strip all known provider keys so tests start clean
for (const key of Object.keys(process.env)) { 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];
} }
}); });
+1 -3
View File
@@ -61,9 +61,7 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
const allParts = [...parts, "[𝕏](https://x.com/pullfrogai)"]; const allParts = [...parts, "[𝕏](https://x.com/pullfrogai)"];
return ` return `\n\n${PULLFROG_DIVIDER}\n<sup>${FROG_LOGO}&nbsp;&nbsp; ${allParts.join(" ")}</sup>`;
${PULLFROG_DIVIDER}
<sup>${FROG_LOGO}&nbsp;&nbsp; ${allParts.join(" ")}</sup>`;
} }
/** /**
+1
View File
@@ -115,6 +115,7 @@ const testEnvAllowList = new Set([
"GITHUB_PRIVATE_KEY", "GITHUB_PRIVATE_KEY",
"OPENAI_API_KEY", "OPENAI_API_KEY",
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GEMINI_API_KEY", "GEMINI_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY",
"PULLFROG_MODEL", "PULLFROG_MODEL",
+18
View File
@@ -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;
}
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { postProcessRangeDiff } from "./rangeDiff.ts";
describe("postProcessRangeDiff", () => {
it("returns null for identical patches", () => {
const input = "1: abc1234 = 1: def5678 x";
expect(postProcessRangeDiff(input)).toBeNull();
});
it("returns null for empty input", () => {
expect(postProcessRangeDiff("")).toBeNull();
expect(postProcessRangeDiff(" ")).toBeNull();
});
it("returns null when no changes exist between versions", () => {
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/file.ts ##",
" @@ src/file.ts",
" +const a = 1;",
" +const b = 2;",
].join("\n");
expect(postProcessRangeDiff(input)).toBeNull();
});
it("strips inner diff prefix from content lines", () => {
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/math.ts ##",
" @@ src/math.ts",
" + const a = 1;",
" -+ const b = 2;",
" ++ const b = 3;",
" + const c = 4;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/math.ts ##
@@ src/math.ts
const a = 1;
- const b = 2;
+ const b = 3;
const c = 4;"
`);
});
it("handles context lines (inner space prefix)", () => {
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/file.ts ##",
" @@ src/file.ts",
" const base = true;",
" - const old = true;",
" + const new_ = true;",
" const end = true;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/file.ts ##
@@ src/file.ts
const base = true;
-const old = true;
+const new_ = true;
const end = true;"
`);
});
it("trims context lines around changes", () => {
const contextBefore = Array.from(
{ length: 9 },
(_, i) => ` +const line${i + 1} = ${i + 1};`
);
const contextAfter = Array.from(
{ length: 6 },
(_, i) => ` +const line${i + 11} = ${i + 11};`
);
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/large.ts ##",
" @@ src/large.ts",
...contextBefore,
" -+const line10 = 10;",
" ++const line10 = 100;",
...contextAfter,
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/large.ts ##
@@ src/large.ts
...
const line7 = 7;
const line8 = 8;
const line9 = 9;
-const line10 = 10;
+const line10 = 100;
const line11 = 11;
const line12 = 12;
const line13 = 13;"
`);
});
it("handles multiple files", () => {
const input = [
"1: abc ! 1: def x",
" ## src/a.ts ##",
" @@ src/a.ts",
" -+old line a",
" ++new line a",
" ## src/b.ts ##",
" @@ src/b.ts",
" +unchanged",
" -+old line b",
" ++new line b",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/a.ts ##
@@ src/a.ts
-old line a
+new line a
## src/b.ts ##
@@ src/b.ts
unchanged
-old line b
+new line b"
`);
});
it("handles new file added in new version", () => {
const input = [
"1: abc ! 1: def x",
" +## src/new.ts (new) ##",
" +@@ src/new.ts (new)",
" ++export const x = 1;",
" ++export const y = 2;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
"+## src/new.ts (new) ##
+@@ src/new.ts (new)
+export const x = 1;
+export const y = 2;"
`);
});
it("handles file removed in new version", () => {
const input = [
"1: abc ! 1: def x",
" -## src/old.ts ##",
" -@@ src/old.ts",
" --export const x = 1;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
"-## src/old.ts ##
-@@ src/old.ts
-export const x = 1;"
`);
});
it("filters out metadata section via context trimming", () => {
const input = [
"1: abc ! 1: def x",
" @@ Metadata",
" Author: Test <test@test.com>",
" ## Commit message ##",
" x",
" ## src/file.ts ##",
" @@ src/file.ts",
" +const a = 1;",
" -+const b = 2;",
" ++const b = 3;",
" +const c = 4;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/file.ts ##
@@ src/file.ts
const a = 1;
-const b = 2;
+const b = 3;
const c = 4;"
`);
});
it("uses custom context line count", () => {
const contextBefore = Array.from(
{ length: 5 },
(_, i) => ` +const line${i + 1} = ${i + 1};`
);
const contextAfter = Array.from(
{ length: 5 },
(_, i) => ` +const line${i + 7} = ${i + 7};`
);
const input = [
"1: abc1234 ! 1: def5678 x",
" ## src/file.ts ##",
" @@ src/file.ts",
...contextBefore,
" -+const changed = old;",
" ++const changed = new;",
...contextAfter,
].join("\n");
expect(postProcessRangeDiff(input, 1)).toMatchInlineSnapshot(`
" ## src/file.ts ##
@@ src/file.ts
...
const line5 = 5;
-const changed = old;
+const changed = new;
const line7 = 7;"
`);
});
it("handles two separate change regions in the same file", () => {
const middle = Array.from({ length: 10 }, (_, i) => ` +const mid${i + 1} = ${i + 1};`);
const input = [
"1: abc ! 1: def x",
" ## src/file.ts ##",
" @@ src/file.ts",
" -+const first = old;",
" ++const first = new;",
...middle,
" -+const second = old;",
" ++const second = new;",
].join("\n");
expect(postProcessRangeDiff(input)).toMatchInlineSnapshot(`
" ## src/file.ts ##
@@ src/file.ts
-const first = old;
+const first = new;
const mid1 = 1;
const mid2 = 2;
const mid3 = 3;
...
const mid8 = 8;
const mid9 = 9;
const mid10 = 10;
-const second = old;
+const second = new;"
`);
});
});
+182
View File
@@ -0,0 +1,182 @@
import { log } from "./cli.ts";
import { $ } from "./shell.ts";
type ComputeIncrementalDiffParams = {
baseBranch: string;
beforeSha: string;
headSha: string;
};
/**
* computes the incremental diff between two versions of a PR using range-diff
* on virtual squash commits created via `git commit-tree`.
*
* each PR version is squashed into a single synthetic commit (merge-base → tip tree),
* then range-diff compares those two single-commit ranges. this:
* - isolates each version's net effect (base branch noise eliminated via per-version merge bases)
* - avoids commit-matching issues that raw range-diff has with rebases/squashes/reordering
* - creates only loose git objects, no branches or refs (unlike temp-branch squash approaches)
*
* unlike fetchAndFormatPrDiff/formatFilesWithLineNumbers, this output has no line numbers.
* range-diff compares *patches* (diffs-of-diffs), not file trees — its hunk headers are
* `@@ file.ts` breadcrumbs, not positional `@@ -X,Y +A,B @@` markers. reconstructing
* line numbers would require cross-referencing with the v2 diff or content-matching against
* file trees, both of which are fragile (duplicate lines, hunk boundary shifts after rebase).
* a structured interdiff approach (diff two parsed patches, compare only +/- keys via Myers)
* could approximate line numbers but loses semantic precision: range-diff understands patch
* structure natively (rename detection, hunk-aware matching, dual-prefix inner/outer changes),
* while flat key-sequence comparison can misalign duplicate lines and can't distinguish
* "new addition to the PR" from "existing code newly modified by the PR". range-diff is the
* right abstraction here — the incremental diff answers "how did the changeset evolve?",
* not "where in the file is this?", and forcing positional line numbers onto it would be
* semantically misleading.
*
* alternatives considered:
* - plain git diff (two-tree or three-dot): includes base branch changes, no PR isolation
* - patch-text diffing (interdiff / diff-of-diffs): fragile, hunk offset noise on rebase
* - range-diff on raw commit ranges: confused by commit reorganization across force-pushes
*/
export function computeIncrementalDiff(params: ComputeIncrementalDiffParams): string | null {
try {
// $1=beforeSha, $2=baseBranch, $3=headSha
const raw = $(
"sh",
[
"-c",
'old_base=$(git merge-base "$1" "origin/$2") && ' +
'new_base=$(git merge-base "$3" "origin/$2") && ' +
"git range-diff --no-color " +
'"$old_base..$(git commit-tree "$1^{tree}" -p "$old_base" -m x)" ' +
'"$new_base..$(git commit-tree "$3^{tree}" -p "$new_base" -m x)"',
"--",
params.beforeSha,
params.baseBranch,
params.headSha,
],
{ log: false }
);
return postProcessRangeDiff(raw);
} catch (e) {
log.debug(`» range-diff failed: ${e instanceof Error ? e.message : String(e)}`);
return null;
}
}
function isDiffPrefix(ch: string): boolean {
return ch === " " || ch === "+" || ch === "-";
}
/**
* transforms git range-diff output into a clean incremental diff.
*
* range-diff content lines have two prefix characters:
* 1st (outer): range-diff level — space (same in both), + (new only), - (old only)
* 2nd (inner): original diff level — space (context), + (added), - (removed)
*
* stripping the inner prefix produces a standard unified-diff-like output where
* +/- means "changed between PR versions" rather than "changed vs base branch".
*
* uses a streaming approach: a ring buffer of before-context lines is flushed when
* a change is hit, then afterCount lines of after-context are emitted directly.
* nearest preceding ## / @@ headers are force-included when outside the context window.
*/
export function postProcessRangeDiff(raw: string, contextLines = 3): string | null {
if (!raw.trim()) return null;
if (/^\d+:\s+\w+\s+=\s+\d+:/m.test(raw)) return null;
type Line = { prefix: string; from: number; to: number; seq: number };
const beforeBuf: Line[] = [];
let lastFileHdr: Line | null = null;
let lastHunkHdr: Line | null = null;
let fileHdrEmitted = true;
let hunkHdrEmitted = true;
let out = "";
let afterRemaining = 0;
let lastEmittedSeq = -2;
let seq = 0;
let hasChanges = false;
function emit(line: Line) {
if (lastEmittedSeq >= 0 && line.seq > lastEmittedSeq + 1) out += (out ? "\n" : "") + "...";
out += (out ? "\n" : "") + line.prefix + raw.slice(line.from, line.to);
lastEmittedSeq = line.seq;
if (lastFileHdr?.seq === line.seq) fileHdrEmitted = true;
if (lastHunkHdr?.seq === line.seq) hunkHdrEmitted = true;
}
function flushBefore() {
if (lastFileHdr && !fileHdrEmitted) emit(lastFileHdr);
if (lastHunkHdr && !hunkHdrEmitted) emit(lastHunkHdr);
for (const line of beforeBuf) {
if (line.seq > lastEmittedSeq) emit(line);
}
beforeBuf.length = 0;
}
let cursor = 0;
while (cursor < raw.length) {
const eol = raw.indexOf("\n", cursor);
const lineEnd = eol === -1 ? raw.length : eol;
if (raw.charCodeAt(cursor) >= 48 && raw.charCodeAt(cursor) <= 57) {
cursor = lineEnd + 1;
continue;
}
if (lineEnd - cursor >= 5 && raw.startsWith(" ", cursor)) {
const prefix = raw[cursor + 4];
if (isDiffPrefix(prefix)) {
const contentPos = cursor + 5;
const isOuterChange = prefix !== " ";
let line: Line;
let isChange = false;
if (contentPos >= lineEnd) {
line = { prefix, from: lineEnd, to: lineEnd, seq };
} else if (isDiffPrefix(raw[contentPos])) {
isChange = isOuterChange;
line = { prefix, from: contentPos + 1, to: lineEnd, seq };
} else {
line = { prefix, from: contentPos, to: lineEnd, seq };
if (
raw.startsWith("## ", contentPos) &&
!raw.startsWith("## Commit message", contentPos)
) {
lastFileHdr = line;
fileHdrEmitted = false;
lastHunkHdr = null;
hunkHdrEmitted = true;
} else if (
raw.startsWith("@@", contentPos) &&
!raw.startsWith("@@ Metadata", contentPos)
) {
lastHunkHdr = line;
hunkHdrEmitted = false;
}
}
if (isChange) {
hasChanges = true;
flushBefore();
emit(line);
afterRemaining = contextLines;
} else if (afterRemaining > 0) {
emit(line);
afterRemaining--;
} else {
if (beforeBuf.length >= contextLines) beforeBuf.shift();
beforeBuf.push(line);
}
seq++;
}
}
cursor = lineEnd + 1;
}
return hasChanges ? out : null;
}
+3
View File
@@ -27,6 +27,7 @@ export interface RunContext {
apiToken: string; apiToken: string;
oss: boolean; oss: boolean;
proxyModel?: string | undefined; proxyModel?: string | undefined;
dbSecrets?: Record<string, string> | undefined;
} }
const defaultSettings: RepoSettings = { const defaultSettings: RepoSettings = {
@@ -82,6 +83,7 @@ export async function fetchRunContext(params: {
apiToken: string; apiToken: string;
oss?: boolean; oss?: boolean;
proxyModel?: string; proxyModel?: string;
dbSecrets?: Record<string, string>;
} | null; } | null;
if (data === null) { if (data === null) {
@@ -100,6 +102,7 @@ export async function fetchRunContext(params: {
apiToken: data.apiToken, apiToken: data.apiToken,
oss: data.oss ?? false, oss: data.oss ?? false,
proxyModel: data.proxyModel, proxyModel: data.proxyModel,
dbSecrets: data.dbSecrets,
}; };
} catch { } catch {
clearTimeout(timeoutId); clearTimeout(timeoutId);
+2
View File
@@ -14,6 +14,7 @@ export interface RunContextData {
apiToken: string; apiToken: string;
oss: boolean; oss: boolean;
proxyModel?: string | undefined; proxyModel?: string | undefined;
dbSecrets?: Record<string, string> | undefined;
} }
interface ResolveRunContextDataParams { interface ResolveRunContextDataParams {
@@ -46,5 +47,6 @@ export async function resolveRunContextData(
apiToken: runContext.apiToken, apiToken: runContext.apiToken,
oss: runContext.oss, oss: runContext.oss,
proxyModel: runContext.proxyModel, proxyModel: runContext.proxyModel,
dbSecrets: runContext.dbSecrets,
}; };
} }
-1
View File
@@ -64,7 +64,6 @@ export type SetupGitParams = GitContext;
* setup git configuration and authentication for the repository. * setup git configuration and authentication for the repository.
* - configures git identity (user.email, user.name) * - configures git identity (user.email, user.name)
* - sets up authentication via gitToken (minimal contents:write) * - sets up authentication via gitToken (minimal contents:write)
* - for PR events, checks out the PR branch using shared helper
* *
* gitToken is a minimal-permission token (contents + workflows) used for git operations. * gitToken is a minimal-permission token (contents + workflows) used for git operations.
* it is assumed to be potentially exfiltratable, so it has limited scope. * it is assumed to be potentially exfiltratable, so it has limited scope.
+24
View File
@@ -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
View File
@@ -76,7 +76,8 @@ export interface SpawnOptions {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
input?: string; input?: string;
timeout?: number; 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; activityTimeout?: number;
cwd?: string; cwd?: string;
stdio?: ("pipe" | "ignore" | "inherit")[]; stdio?: ("pipe" | "ignore" | "inherit")[];
@@ -95,7 +96,6 @@ export interface SpawnResult {
* Spawn a subprocess with streaming callbacks and buffered results * Spawn a subprocess with streaming callbacks and buffered results
*/ */
export async function spawn(options: SpawnOptions): Promise<SpawnResult> { 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; const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
installSignalHandler(); installSignalHandler();
@@ -106,13 +106,13 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// security: caller must provide complete env object, not merged with process.env // security: caller must provide complete env object, not merged with process.env
const child = nodeSpawn(cmd, args, { const child = nodeSpawn(options.cmd, options.args, {
env: env || { env: options.env || {
PATH: process.env.PATH || "", PATH: process.env.PATH || "",
HOME: process.env.HOME || "", HOME: process.env.HOME || "",
}, },
stdio: stdio || ["pipe", "pipe", "pipe"], stdio: options.stdio || ["pipe", "pipe", "pipe"],
cwd: cwd || process.cwd(), cwd: options.cwd || process.cwd(),
}); });
// track child for cleanup on Ctrl+C // track child for cleanup on Ctrl+C
@@ -125,7 +125,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
let lastActivityTime = performance.now(); let lastActivityTime = performance.now();
// overall timeout // overall timeout
if (timeout) { if (options.timeout) {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
isTimedOut = true; isTimedOut = true;
child.kill("SIGTERM"); child.kill("SIGTERM");
@@ -135,12 +135,14 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
child.kill("SIGKILL"); child.kill("SIGKILL");
} }
}, 5000); }, 5000);
}, timeout); }, options.timeout);
} }
// activity timeout: kill if no output for too long // activity timeout: kill if no output for too long
if (activityTimeoutMs > 0) { 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(() => { activityCheckIntervalId = setInterval(() => {
const idleMs = performance.now() - lastActivityTime; const idleMs = performance.now() - lastActivityTime;
log.debug( log.debug(
@@ -149,7 +151,9 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
if (idleMs > activityTimeoutMs) { if (idleMs > activityTimeoutMs) {
isActivityTimedOut = true; isActivityTimedOut = true;
const idleSec = Math.round(idleMs / 1000); 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"); child.kill("SIGKILL");
clearInterval(activityCheckIntervalId); clearInterval(activityCheckIntervalId);
} }
@@ -165,16 +169,15 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
updateActivity(); updateActivity();
const chunk = data.toString(); const chunk = data.toString();
stdoutBuffer += chunk; stdoutBuffer += chunk;
onStdout?.(chunk); options.onStdout?.(chunk);
}); });
} }
if (child.stderr) { if (child.stderr) {
child.stderr.on("data", (data: Buffer) => { child.stderr.on("data", (data: Buffer) => {
updateActivity();
const chunk = data.toString(); const chunk = data.toString();
stderrBuffer += chunk; stderrBuffer += chunk;
onStderr?.(chunk); options.onStderr?.(chunk);
}); });
} }
@@ -186,7 +189,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId); if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
if (isTimedOut) { if (isTimedOut) {
reject(new Error(`process timed out after ${timeout}ms`)); reject(new Error(`process timed out after ${options.timeout}ms`));
return; return;
} }
@@ -222,8 +225,8 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
}); });
}); });
if (input && child.stdin && stdio?.[0] !== "ignore") { if (options.input && child.stdin && options.stdio?.[0] !== "ignore") {
child.stdin.write(input); child.stdin.write(options.input);
child.stdin.end(); child.stdin.end();
} }
}); });
+1 -1
View File
@@ -39,7 +39,7 @@ function renderTodoMarkdown(todos: TodoItem[]): string {
case "cancelled": case "cancelled":
return `- ~~${todo.content}~~`; return `- ~~${todo.content}~~`;
case "in_progress": case "in_progress":
return `- **→** ${todo.content}`; return `- [ ] <img src="https://uploads.pullfrog.com/Progress%20Indicator.gif" width="11" style="visibility: visible; max-width: 100%;" /> ${todo.content}`;
case "pending": case "pending":
return `- [ ] ${todo.content}`; return `- [ ] ${todo.content}`;
default: default: