Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd9c3382c7 | |||
| ba1966f17c | |||
| 0055aef618 | |||
| 9f566d20e4 | |||
| 6c5d228c04 | |||
| 51659fee71 | |||
| bf68e0d915 | |||
| a7b8dcbced | |||
| 248d11d73d | |||
| cb8e33360c | |||
| 7454e66533 | |||
| c0f6f9ef2a | |||
| 6b18b6730b | |||
| e9ce67fec6 | |||
| 64f2238316 | |||
| e6d34ee01b | |||
| 39525547b5 | |||
| 3ff11f97eb | |||
| b31800c213 | |||
| 3a1ffde545 | |||
| cccf1775d6 | |||
| 026cc7a276 | |||
| c6a3ee0e9a | |||
| 30d68e53a7 | |||
| 8a734c32f4 | |||
| 2e37fb3dfa | |||
| cbbcb64859 | |||
| df9598ea5f | |||
| 250fe7eaa1 | |||
| 4a8c432a48 |
@@ -25,7 +25,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
# add any additional keys your agent(s) need
|
||||
# optionally, comment out any you won't use
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
@@ -27,12 +27,13 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
agent: [opentoad]
|
||||
agent: [claude, opentoad]
|
||||
test:
|
||||
[mcpmerge, nobash, restricted, smoke]
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
@@ -41,11 +42,11 @@ jobs:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
||||
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
@@ -79,7 +80,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Claude Code agent — secure harness around the `claude` CLI.
|
||||
*
|
||||
* mirrors the opentoad harness's security model:
|
||||
* - native Bash blocked via --disallowedTools (agent cannot shell out)
|
||||
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
|
||||
* - MCP server injected via --mcp-config (not replacing project config)
|
||||
* - ASKPASS handles git auth separately (token never in subprocess env)
|
||||
*
|
||||
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
|
||||
* security is enforced at the tool layer, not the process layer.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import { resolveModelSlug } from "../models.ts";
|
||||
import { getIdleMs, markActivity } from "../utils/activity.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||||
import { addSkill } from "../utils/skills.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
import { type AgentResult, type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
|
||||
|
||||
async function installClaudeCli(): Promise<string> {
|
||||
return await installFromNpmTarball({
|
||||
packageName: "@anthropic-ai/claude-code",
|
||||
version: getDevDependencyVersion("@anthropic-ai/claude-code"),
|
||||
executablePath: "cli.js",
|
||||
installDependencies: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── config ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeMcpConfig(ctx: AgentRunContext): string {
|
||||
const configDir = join(ctx.tmpdir, ".claude");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
const configPath = join(configDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
|
||||
},
|
||||
})
|
||||
);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
// ── model resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
function resolveClaudeModel(modelSlug: string | undefined): string | undefined {
|
||||
// 1. explicit env var override
|
||||
const envModel = process.env.PULLFROG_MODEL?.trim();
|
||||
if (envModel) {
|
||||
const slashIndex = envModel.indexOf("/");
|
||||
const cliModel = slashIndex > 0 ? envModel.slice(slashIndex + 1) : envModel;
|
||||
log.info(`» model: ${cliModel} (override via PULLFROG_MODEL)`);
|
||||
return cliModel;
|
||||
}
|
||||
|
||||
if (!modelSlug) return undefined;
|
||||
|
||||
// 2. resolve slug to concrete specifier (e.g. "anthropic/claude-opus" → "anthropic/claude-opus-4-6")
|
||||
// then strip the "anthropic/" prefix to get the Claude CLI model name
|
||||
const resolved = resolveModelSlug(modelSlug);
|
||||
if (resolved) {
|
||||
const slashIndex = resolved.indexOf("/");
|
||||
const cliModel = slashIndex > 0 ? resolved.slice(slashIndex + 1) : resolved;
|
||||
log.info(`» model: ${cliModel} (resolved from ${modelSlug})`);
|
||||
return cliModel;
|
||||
}
|
||||
|
||||
log.warning(`» unknown model slug "${modelSlug}" — letting Claude Code auto-select`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── NDJSON event types ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
tool_use_id?: string;
|
||||
content?: string | unknown;
|
||||
is_error?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ClaudeSystemEvent {
|
||||
type: "system";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ClaudeAssistantEvent {
|
||||
type: "assistant";
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: ContentBlock[];
|
||||
model?: string;
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ClaudeUserEvent {
|
||||
type: "user";
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: ContentBlock[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ClaudeResultEvent {
|
||||
type: "result";
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
session_id?: string;
|
||||
num_turns?: number;
|
||||
total_cost_usd?: number;
|
||||
total_input_tokens?: number;
|
||||
total_output_tokens?: number;
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// additional event types emitted by Claude CLI (handled as no-ops / debug)
|
||||
interface ClaudeStreamEvent {
|
||||
type: "stream_event";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
interface ClaudeToolProgressEvent {
|
||||
type: "tool_progress";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
interface ClaudeToolUseSummaryEvent {
|
||||
type: "tool_use_summary";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
interface ClaudeAuthStatusEvent {
|
||||
type: "auth_status";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ClaudeEvent =
|
||||
| ClaudeSystemEvent
|
||||
| ClaudeAssistantEvent
|
||||
| ClaudeUserEvent
|
||||
| ClaudeResultEvent
|
||||
| ClaudeStreamEvent
|
||||
| ClaudeToolProgressEvent
|
||||
| ClaudeToolUseSummaryEvent
|
||||
| ClaudeAuthStatusEvent;
|
||||
|
||||
// ── runner ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RunParams = {
|
||||
label: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
};
|
||||
|
||||
async function runClaude(params: RunParams): Promise<AgentResult> {
|
||||
const startTime = performance.now();
|
||||
let eventCount = 0;
|
||||
const thinkingTimer = new ThinkingTimer();
|
||||
|
||||
let finalOutput = "";
|
||||
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
let costUsd: number | undefined;
|
||||
let tokensLogged = false;
|
||||
|
||||
function buildUsage(): AgentUsage | undefined {
|
||||
const totalInput =
|
||||
accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
|
||||
return totalInput > 0 || accumulatedTokens.output > 0
|
||||
? {
|
||||
agent: "claude",
|
||||
inputTokens: totalInput,
|
||||
outputTokens: accumulatedTokens.output,
|
||||
cacheReadTokens: accumulatedTokens.cacheRead || undefined,
|
||||
cacheWriteTokens: accumulatedTokens.cacheWrite || undefined,
|
||||
costUsd,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
system: (_event: ClaudeSystemEvent) => {
|
||||
log.debug(`» ${params.label} system event`);
|
||||
},
|
||||
assistant: (event: ClaudeAssistantEvent) => {
|
||||
const content = event.message?.content;
|
||||
if (!content) return;
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text?.trim()) {
|
||||
const message = block.text.trim();
|
||||
log.box(message, { title: params.label });
|
||||
finalOutput = message;
|
||||
} else if (block.type === "tool_use") {
|
||||
const toolName = block.name || "unknown";
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({ toolName, input: block.input || {} });
|
||||
|
||||
// agent's explicit MCP report_progress takes priority over todo tracking
|
||||
if (toolName.includes("report_progress") && params.todoTracker) {
|
||||
log.debug("» report_progress detected, disabling todo tracking");
|
||||
params.todoTracker.cancel();
|
||||
}
|
||||
|
||||
// parse TodoWrite events for live progress tracking
|
||||
if (toolName === "TodoWrite" && params.todoTracker?.enabled) {
|
||||
params.todoTracker.update(block.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// accumulate per-message usage if available
|
||||
const msgUsage = event.message?.usage;
|
||||
if (msgUsage) {
|
||||
accumulatedTokens.input += msgUsage.input_tokens || 0;
|
||||
accumulatedTokens.output += msgUsage.output_tokens || 0;
|
||||
}
|
||||
},
|
||||
user: (event: ClaudeUserEvent) => {
|
||||
const content = event.message?.content;
|
||||
if (!content) return;
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === "string") continue;
|
||||
if (block.type === "tool_result") {
|
||||
thinkingTimer.markToolResult();
|
||||
|
||||
const outputContent =
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
? (block.content as unknown[])
|
||||
.map((entry: unknown) =>
|
||||
typeof entry === "string"
|
||||
? entry
|
||||
: typeof entry === "object" && entry !== null && "text" in entry
|
||||
? String((entry as { text: unknown }).text)
|
||||
: JSON.stringify(entry)
|
||||
)
|
||||
.join("\n")
|
||||
: String(block.content);
|
||||
|
||||
if (block.is_error) {
|
||||
log.info(`» tool error: ${outputContent}`);
|
||||
} else {
|
||||
log.debug(`» tool output: ${outputContent}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
result: (event: ClaudeResultEvent) => {
|
||||
const subtype = event.subtype || "unknown";
|
||||
const numTurns = event.num_turns || 0;
|
||||
|
||||
if (subtype === "success") {
|
||||
// extract detailed usage from result event (most accurate source)
|
||||
const usage = event.usage;
|
||||
const inputTokens = usage?.input_tokens || 0;
|
||||
const cacheRead = usage?.cache_read_input_tokens || 0;
|
||||
const cacheWrite = usage?.cache_creation_input_tokens || 0;
|
||||
const outputTokens = usage?.output_tokens || 0;
|
||||
const totalInput = inputTokens + cacheRead + cacheWrite;
|
||||
|
||||
accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite };
|
||||
costUsd = event.total_cost_usd ?? undefined;
|
||||
|
||||
log.info(
|
||||
`» ${params.label} result: subtype=${subtype}, turns=${numTurns}, cost=$${costUsd?.toFixed(4) ?? "?"}`
|
||||
);
|
||||
|
||||
if (!tokensLogged) {
|
||||
log.table([
|
||||
[
|
||||
{ data: "Cost", header: true },
|
||||
{ data: "Input", header: true },
|
||||
{ data: "Cache Read", header: true },
|
||||
{ data: "Cache Write", header: true },
|
||||
{ data: "Output", header: true },
|
||||
],
|
||||
[
|
||||
`$${costUsd?.toFixed(4) || "0.0000"}`,
|
||||
String(totalInput),
|
||||
String(cacheRead),
|
||||
String(cacheWrite),
|
||||
String(outputTokens),
|
||||
],
|
||||
]);
|
||||
tokensLogged = true;
|
||||
}
|
||||
} else if (subtype === "error_max_turns") {
|
||||
log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`);
|
||||
} else if (subtype === "error_during_execution") {
|
||||
log.info(`» ${params.label} execution error: ${JSON.stringify(event)}`);
|
||||
} else {
|
||||
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
||||
}
|
||||
|
||||
if (event.result?.trim()) {
|
||||
finalOutput = event.result.trim();
|
||||
}
|
||||
},
|
||||
// additional Claude CLI event types — debug-logged only
|
||||
stream_event: () => {},
|
||||
tool_progress: () => {},
|
||||
tool_use_summary: () => {},
|
||||
auth_status: () => {},
|
||||
};
|
||||
|
||||
const recentStderr: string[] = [];
|
||||
const MAX_STDERR_LINES = 20;
|
||||
let lastProviderError: string | null = null;
|
||||
|
||||
let output = "";
|
||||
let stdoutBuffer = "";
|
||||
|
||||
try {
|
||||
const result = await spawn({
|
||||
cmd: "node",
|
||||
args: params.args,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
activityTimeout: 0,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
markActivity();
|
||||
|
||||
stdoutBuffer += text;
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
stdoutBuffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(trimmed) as ClaudeEvent;
|
||||
eventCount++;
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
|
||||
const timeSinceLastActivity = getIdleMs();
|
||||
if (timeSinceLastActivity > 10000) {
|
||||
log.info(
|
||||
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s (${params.label} may be processing internally) (${eventCount} events processed so far)`
|
||||
);
|
||||
}
|
||||
markActivity();
|
||||
const handler = handlers[event.type as keyof typeof handlers];
|
||||
if (handler) {
|
||||
(handler as (e: ClaudeEvent) => void)(event);
|
||||
} else {
|
||||
log.debug(`» ${params.label} event (unhandled): type=${event.type}`);
|
||||
}
|
||||
} catch {
|
||||
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
recentStderr.push(trimmed);
|
||||
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
|
||||
|
||||
const providerError = detectProviderError(trimmed);
|
||||
if (providerError) {
|
||||
lastProviderError = providerError;
|
||||
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||||
} else {
|
||||
log.debug(trimmed);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
await params.todoTracker?.flush();
|
||||
} else {
|
||||
params.todoTracker?.cancel();
|
||||
}
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
log.info(
|
||||
`» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`
|
||||
);
|
||||
|
||||
if (eventCount === 0) {
|
||||
const stderrContext = recentStderr.join("\n");
|
||||
const diagnosis = lastProviderError
|
||||
? `provider error: ${lastProviderError}`
|
||||
: "unknown cause (no stdout events received)";
|
||||
log.info(`» ${params.label} produced 0 events (${diagnosis})`);
|
||||
if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
|
||||
}
|
||||
|
||||
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
|
||||
const totalTokens = accumulatedTokens.input + accumulatedTokens.output;
|
||||
log.table([
|
||||
[
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
{ data: "Total Tokens", header: true },
|
||||
],
|
||||
[String(accumulatedTokens.input), String(accumulatedTokens.output), String(totalTokens)],
|
||||
]);
|
||||
}
|
||||
|
||||
const usage = buildUsage();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
||||
const errorMessage =
|
||||
result.stderr ||
|
||||
result.stdout ||
|
||||
`unknown error - no output from Claude CLI${errorContext}`;
|
||||
log.error(
|
||||
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
|
||||
);
|
||||
log.debug(`stdout: ${result.stdout?.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${result.stderr?.substring(0, 500)}`);
|
||||
return { success: false, output: finalOutput || output, error: errorMessage, usage };
|
||||
}
|
||||
|
||||
if (eventCount === 0 && lastProviderError) {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
error: `provider error: ${lastProviderError}`,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: finalOutput || output, usage };
|
||||
} catch (error) {
|
||||
params.todoTracker?.cancel();
|
||||
const duration = performance.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const isActivityTimeout = errorMessage.includes("activity timeout");
|
||||
|
||||
const stderrContext = recentStderr.slice(-10).join("\n");
|
||||
const diagnosis = lastProviderError
|
||||
? `likely cause: ${lastProviderError}`
|
||||
: eventCount === 0
|
||||
? "Claude produced 0 stdout events - check if the API is reachable"
|
||||
: `${eventCount} events were processed before the hang`;
|
||||
|
||||
log.info(
|
||||
`» ${params.label} ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`
|
||||
);
|
||||
log.info(`» diagnosis: ${diagnosis}`);
|
||||
if (stderrContext)
|
||||
log.info(
|
||||
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
error: `${errorMessage} [${diagnosis}]`,
|
||||
usage: buildUsage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── agent ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const claude = agent({
|
||||
name: "claude",
|
||||
install: installClaudeCli,
|
||||
run: async (ctx) => {
|
||||
const cliPath = await installClaudeCli();
|
||||
|
||||
const model = ctx.payload.proxyModel ?? resolveClaudeModel(ctx.payload.model);
|
||||
|
||||
const homeEnv = {
|
||||
HOME: ctx.tmpdir,
|
||||
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
|
||||
};
|
||||
|
||||
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "claude"), { recursive: true });
|
||||
|
||||
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
|
||||
addSkill({
|
||||
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
|
||||
skill: "agent-browser",
|
||||
env: homeEnv,
|
||||
agent: "claude",
|
||||
});
|
||||
|
||||
const mcpConfigPath = writeMcpConfig(ctx);
|
||||
|
||||
const args = [
|
||||
cliPath,
|
||||
"-p",
|
||||
ctx.instructions.full,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--mcp-config",
|
||||
mcpConfigPath,
|
||||
"--verbose",
|
||||
"--no-session-persistence",
|
||||
"--disallowedTools",
|
||||
"Bash",
|
||||
"Agent(Bash)",
|
||||
];
|
||||
|
||||
if (model) {
|
||||
args.push("--model", model);
|
||||
}
|
||||
|
||||
// agent process gets full env — needs LLM API keys, PATH, locale, etc.
|
||||
// security is enforced via --disallowedTools (Bash + Bash subagent) and MCP tool filtering.
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
...homeEnv,
|
||||
};
|
||||
|
||||
const repoDir = process.cwd();
|
||||
|
||||
log.debug(`» starting Pullfrog (Claude Code): node ${args.join(" ")}`);
|
||||
log.debug(`» working directory: ${repoDir}`);
|
||||
|
||||
return runClaude({
|
||||
label: "Pullfrog",
|
||||
args,
|
||||
cwd: repoDir,
|
||||
env,
|
||||
todoTracker: ctx.todoTracker,
|
||||
});
|
||||
},
|
||||
});
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { claude } from "./claude.ts";
|
||||
import { opentoad } from "./opentoad.ts";
|
||||
import type { Agent } from "./shared.ts";
|
||||
|
||||
export type { Agent, AgentUsage } from "./shared.ts";
|
||||
|
||||
export const agents = { opentoad } satisfies Record<string, Agent>;
|
||||
export const agents = { claude, opentoad } satisfies Record<string, Agent>;
|
||||
|
||||
+64
-46
@@ -19,17 +19,18 @@ import { modelAliases, resolveCliModel } from "../models.ts";
|
||||
import { getIdleMs, markActivity } from "../utils/activity.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||||
import { addSkill } from "../utils/skills.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
import { type AgentResult, type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
|
||||
|
||||
// pinned CLI version
|
||||
const OPENCODE_CLI_VERSION = "1.1.56";
|
||||
|
||||
async function installOpencodeCli(): Promise<string> {
|
||||
return await installFromNpmTarball({
|
||||
packageName: "opencode-ai",
|
||||
version: OPENCODE_CLI_VERSION,
|
||||
version: getDevDependencyVersion("opencode-ai"),
|
||||
executablePath: "bin/opencode",
|
||||
installDependencies: true,
|
||||
});
|
||||
@@ -53,7 +54,8 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
edit: "allow",
|
||||
read: "allow",
|
||||
webfetch: "allow",
|
||||
external_directory: "deny",
|
||||
external_directory: "allow",
|
||||
skill: "allow",
|
||||
},
|
||||
mcp: {
|
||||
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
||||
@@ -75,9 +77,9 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
// ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
|
||||
//
|
||||
// priority:
|
||||
// 1. OPENCODE_MODEL env var (explicit override)
|
||||
// 1. PULLFROG_MODEL env var (explicit override)
|
||||
// 2. explicit slug from repo config / payload
|
||||
// 3. auto-select: `opencode models` → recommended aliases first, then secondary
|
||||
// 3. auto-select: `opencode models` → preferred aliases first, then secondary
|
||||
// 4. undefined → let OpenCode decide
|
||||
|
||||
function getOpenCodeModels(cliPath: string): string[] {
|
||||
@@ -107,9 +109,9 @@ function resolveOpenCodeModel(ctx: {
|
||||
modelSlug?: string | undefined;
|
||||
}): string | undefined {
|
||||
// 1. explicit env var override
|
||||
const envModel = process.env.OPENCODE_MODEL?.trim();
|
||||
const envModel = process.env.PULLFROG_MODEL?.trim();
|
||||
if (envModel) {
|
||||
log.info(`» model: ${envModel} (override via OPENCODE_MODEL)`);
|
||||
log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`);
|
||||
return envModel;
|
||||
}
|
||||
|
||||
@@ -117,7 +119,11 @@ function resolveOpenCodeModel(ctx: {
|
||||
if (ctx.modelSlug) {
|
||||
const resolved = resolveCliModel(ctx.modelSlug);
|
||||
if (resolved) {
|
||||
log.info(`» model: ${resolved} (from repo config)`);
|
||||
if (resolved !== ctx.modelSlug) {
|
||||
log.info(`» model: ${ctx.modelSlug} (resolved to ${resolved})`);
|
||||
} else {
|
||||
log.info(`» model: ${resolved}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
log.warning(`» unknown model slug "${ctx.modelSlug}" — falling through to auto-select`);
|
||||
@@ -125,17 +131,17 @@ function resolveOpenCodeModel(ctx: {
|
||||
|
||||
// 3. auto-select: ask OpenCode what's available, pick our best curated match.
|
||||
// `opencode models` returns `provider/model-id` specifiers matching our resolve values exactly.
|
||||
// two-pass: recommended (top-tier per provider) first, then secondary models.
|
||||
// two-pass: preferred (top-tier per provider) first, then secondary models.
|
||||
const availableModels = getOpenCodeModels(ctx.cliPath);
|
||||
const availableSet = new Set(availableModels);
|
||||
if (availableSet.size > 0) {
|
||||
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
|
||||
const match =
|
||||
modelAliases.find((a) => a.recommended && availableSet.has(a.resolve)) ??
|
||||
modelAliases.find((a) => a.preferred && availableSet.has(a.resolve)) ??
|
||||
modelAliases.find((a) => availableSet.has(a.resolve));
|
||||
if (match) {
|
||||
log.info(
|
||||
`» model: ${match.resolve} (auto-selected${match.recommended ? " — recommended" : ""} curated match)`
|
||||
`» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)`
|
||||
);
|
||||
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
|
||||
return match.resolve;
|
||||
@@ -149,27 +155,6 @@ function resolveOpenCodeModel(ctx: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── provider error detection ───────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_ERROR_PATTERNS = [
|
||||
{ pattern: "429", label: "rate limited (429)" },
|
||||
{ pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" },
|
||||
{ pattern: "quota", label: "quota error" },
|
||||
{ pattern: "status: 500", label: "provider 500 error" },
|
||||
{ pattern: "INTERNAL", label: "provider internal error" },
|
||||
{ pattern: "status: 503", label: "provider unavailable (503)" },
|
||||
{ pattern: "UNAVAILABLE", label: "provider unavailable" },
|
||||
{ pattern: "rate limit", label: "rate limited" },
|
||||
{ pattern: "limit: 0", label: "zero quota" },
|
||||
];
|
||||
|
||||
function detectProviderError(text: string): string | null {
|
||||
for (const entry of PROVIDER_ERROR_PATTERNS) {
|
||||
if (text.includes(entry.pattern)) return entry.label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── NDJSON event types ─────────────────────────────────────────────────────────
|
||||
|
||||
interface OpenCodeInitEvent {
|
||||
@@ -290,6 +275,7 @@ type RunParams = {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
};
|
||||
|
||||
async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
@@ -308,7 +294,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
function buildUsage(): AgentUsage | undefined {
|
||||
return accumulatedTokens.input > 0 || accumulatedTokens.output > 0
|
||||
? {
|
||||
agent: "opentoad",
|
||||
agent: "pullfrog",
|
||||
inputTokens: accumulatedTokens.input,
|
||||
outputTokens: accumulatedTokens.output,
|
||||
}
|
||||
@@ -390,6 +376,17 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
if (event.part?.state?.status === "completed" && event.part.state.output) {
|
||||
log.debug(` output: ${event.part.state.output}`);
|
||||
}
|
||||
|
||||
// agent's explicit MCP report_progress takes priority over todo tracking
|
||||
if (toolName.includes("report_progress") && params.todoTracker) {
|
||||
log.debug("» report_progress detected, disabling todo tracking");
|
||||
params.todoTracker.cancel();
|
||||
}
|
||||
|
||||
// parse todowrite events for live progress tracking
|
||||
if (toolName === "todowrite" && params.todoTracker?.enabled) {
|
||||
params.todoTracker.update(event.part?.state?.input);
|
||||
}
|
||||
},
|
||||
tool_result: (event: OpenCodeToolResultEvent) => {
|
||||
const toolId = event.part?.callID || event.tool_id;
|
||||
@@ -469,7 +466,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
args: params.args,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
activityTimeout: 0,
|
||||
activityTimeout: 300_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
@@ -531,6 +528,12 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
},
|
||||
});
|
||||
|
||||
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}`
|
||||
@@ -584,6 +587,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
|
||||
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");
|
||||
@@ -621,13 +625,27 @@ export const opentoad = agent({
|
||||
run: async (ctx) => {
|
||||
const cliPath = await installOpencodeCli();
|
||||
|
||||
const model = resolveOpenCodeModel({
|
||||
cliPath,
|
||||
modelSlug: ctx.payload.model,
|
||||
});
|
||||
const model =
|
||||
ctx.payload.proxyModel ??
|
||||
resolveOpenCodeModel({
|
||||
cliPath,
|
||||
modelSlug: ctx.payload.model,
|
||||
});
|
||||
|
||||
const tempHome = ctx.tmpdir;
|
||||
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
|
||||
const homeEnv = {
|
||||
HOME: ctx.tmpdir,
|
||||
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
|
||||
};
|
||||
|
||||
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
|
||||
|
||||
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
|
||||
addSkill({
|
||||
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
|
||||
skill: "agent-browser",
|
||||
env: homeEnv,
|
||||
agent: "opencode",
|
||||
});
|
||||
|
||||
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
||||
|
||||
@@ -635,8 +653,7 @@ export const opentoad = agent({
|
||||
// security is enforced via OPENCODE_CONFIG_CONTENT (bash: deny) and MCP tool filtering.
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
HOME: tempHome,
|
||||
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
||||
...homeEnv,
|
||||
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
||||
@@ -644,15 +661,16 @@ export const opentoad = agent({
|
||||
|
||||
const repoDir = process.cwd();
|
||||
|
||||
log.debug(`» starting OpenToad (OpenCode): ${cliPath} ${args.join(" ")}`);
|
||||
log.debug(`» starting Pullfrog (OpenCode): ${cliPath} ${args.join(" ")}`);
|
||||
log.debug(`» working directory: ${repoDir}`);
|
||||
|
||||
return runOpenCode({
|
||||
label: "OpenToad",
|
||||
label: "Pullfrog",
|
||||
cliPath,
|
||||
args,
|
||||
cwd: repoDir,
|
||||
env,
|
||||
todoTracker: ctx.todoTracker,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
|
||||
/**
|
||||
* token/cost usage data from a single agent run
|
||||
@@ -33,6 +34,7 @@ export interface AgentRunContext {
|
||||
mcpServerUrl: string;
|
||||
tmpdir: string;
|
||||
instructions: ResolvedInstructions;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
@@ -45,7 +47,6 @@ export const agent = (input: Agent): Agent => {
|
||||
return {
|
||||
...input,
|
||||
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||
log.info(`» agent: ${input.name}`);
|
||||
if (ctx.payload.model) log.info(`» model: ${ctx.payload.model}`);
|
||||
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
|
||||
log.info(`» push: ${ctx.payload.push}`);
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ export type { ModelAlias, ModelProvider, ProviderConfig } from "./models.ts";
|
||||
export {
|
||||
getModelEnvVars,
|
||||
getModelProvider,
|
||||
getProviderDisplayName,
|
||||
modelAliases,
|
||||
parseModel,
|
||||
providers,
|
||||
@@ -211,7 +212,7 @@ interface PullRequestSynchronizeEvent extends BasePayloadEvent {
|
||||
title: string;
|
||||
body: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -25743,6 +25743,7 @@ async function apiFetch(options) {
|
||||
}
|
||||
|
||||
// utils/retry.ts
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
var defaultShouldRetry = (error2) => {
|
||||
if (!(error2 instanceof Error)) return false;
|
||||
return error2.name === "AbortError" || error2.message.includes("fetch failed") || error2.message.includes("ECONNRESET") || error2.message.includes("ETIMEDOUT");
|
||||
@@ -25763,7 +25764,7 @@ async function retry(fn, options = {}) {
|
||||
}
|
||||
const delay = delayMs * attempt;
|
||||
log.info(`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
@@ -25898,10 +25899,15 @@ var findInstallationId = async (jwt, repoOwner, repoName) => {
|
||||
);
|
||||
};
|
||||
async function acquireTokenViaGitHubApp(opts) {
|
||||
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) {
|
||||
throw new Error(
|
||||
"cannot acquire token via GitHub App: GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set"
|
||||
);
|
||||
}
|
||||
const repoContext = parseRepoContext();
|
||||
const config = {
|
||||
appId: process.env.GITHUB_APP_ID,
|
||||
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n"),
|
||||
privateKey: process.env.GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"),
|
||||
repoOwner: repoContext.owner,
|
||||
repoName: repoContext.name
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export type {
|
||||
export {
|
||||
getModelEnvVars,
|
||||
getModelProvider,
|
||||
getProviderDisplayName,
|
||||
ghPullfrogMcpName,
|
||||
modelAliases,
|
||||
parseModel,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { deleteProgressComment, reportProgress } from "./mcp/comment.ts";
|
||||
import {
|
||||
initToolState,
|
||||
startMcpHttpServer,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
||||
} from "./utils/activity.ts";
|
||||
import { resolveAgent } from "./utils/agent.ts";
|
||||
import { apiFetch } from "./utils/apiFetch.ts";
|
||||
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
||||
import { resolveBody } from "./utils/body.ts";
|
||||
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
||||
@@ -34,6 +36,7 @@ import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
||||
import { killTrackedChildren } from "./utils/subprocess.ts";
|
||||
import { parseTimeString, TIMEOUT_DISABLED } from "./utils/time.ts";
|
||||
import { Timer } from "./utils/timer.ts";
|
||||
import { createTodoTracker } from "./utils/todoTracking.ts";
|
||||
import { getJobToken, resolveTokens } from "./utils/token.ts";
|
||||
import { resolveRun } from "./utils/workflow.ts";
|
||||
|
||||
@@ -63,6 +66,71 @@ function resolveOutputSchema(): Record<string, unknown> | undefined {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
import type { ResolvedPayload } from "./utils/payload.ts";
|
||||
|
||||
interface OidcCredentials {
|
||||
requestUrl: string;
|
||||
requestToken: string;
|
||||
}
|
||||
|
||||
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
||||
try {
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
|
||||
const response = await apiFetch({
|
||||
path: "/api/proxy-token",
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${oidcToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
log.warning(`proxy key mint failed (${response.status})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { key: string };
|
||||
return data.key;
|
||||
} catch (error) {
|
||||
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
} finally {
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProxyModel(ctx: {
|
||||
payload: ResolvedPayload;
|
||||
oss: boolean;
|
||||
proxyModel?: string | undefined;
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
}): Promise<void> {
|
||||
// env override = BYOK escape hatch, don't proxy
|
||||
if (process.env.PULLFROG_MODEL?.trim()) return;
|
||||
|
||||
// OSS: server decided the model
|
||||
if (ctx.oss && ctx.proxyModel) {
|
||||
if (!ctx.oidcCredentials) {
|
||||
log.warning("» oss repo but no OIDC credentials available — skipping proxy");
|
||||
return;
|
||||
}
|
||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
|
||||
if (!key) return;
|
||||
|
||||
process.env.OPENROUTER_API_KEY = key;
|
||||
core.setSecret(key);
|
||||
ctx.payload.proxyModel = ctx.proxyModel;
|
||||
log.info(`» proxy: oss → ${ctx.proxyModel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// managed billing will add its path here later
|
||||
}
|
||||
|
||||
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||
@@ -101,25 +169,59 @@ export async function main(): Promise<MainResult> {
|
||||
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
|
||||
timer.checkpoint("runContextData");
|
||||
|
||||
// inject account-level secrets into process.env (YAML secrets take precedence)
|
||||
if (runContext.dbSecrets) {
|
||||
for (const [key, value] of Object.entries(runContext.dbSecrets)) {
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
core.setSecret(value);
|
||||
}
|
||||
}
|
||||
const count = Object.keys(runContext.dbSecrets).length;
|
||||
if (count > 0) log.info(`» ${count} db secret(s) loaded`);
|
||||
}
|
||||
|
||||
// resolve payload to determine shell permission
|
||||
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
||||
toolState.model = payload.model;
|
||||
if (payload.event.trigger === "pull_request_synchronize") {
|
||||
toolState.beforeSha = payload.event.before_sha;
|
||||
}
|
||||
|
||||
// resolve tokens:
|
||||
// - gitToken: contents permission based on push setting (assumed exfiltratable)
|
||||
// - mcpToken: full installation token (not exfiltratable via MCP tools)
|
||||
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
|
||||
await using tokenRef = await resolveTokens({ push: payload.push });
|
||||
|
||||
// stash OIDC credentials in memory before wiping from process.env
|
||||
// the agent's shell commands can't access JS variables, so this is safe
|
||||
const oidcCredentials: OidcCredentials | null =
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
||||
? {
|
||||
requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
|
||||
requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
|
||||
}
|
||||
: null;
|
||||
|
||||
// clear OIDC env vars in restricted mode to prevent agent from minting tokens
|
||||
if (payload.shell !== "enabled") {
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
}
|
||||
|
||||
// proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing)
|
||||
await resolveProxyModel({
|
||||
payload,
|
||||
oss: runContext.oss,
|
||||
proxyModel: runContext.proxyModel,
|
||||
oidcCredentials,
|
||||
});
|
||||
|
||||
// create octokit with MCP token for GitHub API calls
|
||||
const octokit = createOctokit(tokenRef.mcpToken);
|
||||
|
||||
const runInfo = await resolveRun({ octokit });
|
||||
let toolContext: ToolContext | undefined;
|
||||
let progressCallbackDisabled = false;
|
||||
let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
|
||||
|
||||
try {
|
||||
if (payload.cwd && process.cwd() !== payload.cwd) {
|
||||
@@ -147,10 +249,11 @@ export async function main(): Promise<MainResult> {
|
||||
await using gitAuthServer = await startGitAuthServer(tmpdir);
|
||||
setGitAuthServer(gitAuthServer);
|
||||
|
||||
const agent = resolveAgent();
|
||||
const agent = resolveAgent({ model: payload.proxyModel ? undefined : payload.model });
|
||||
|
||||
validateAgentApiKey({
|
||||
agent,
|
||||
model: payload.proxyModel ?? payload.model,
|
||||
owner: runContext.repo.owner,
|
||||
name: runContext.repo.name,
|
||||
});
|
||||
@@ -187,6 +290,7 @@ export async function main(): Promise<MainResult> {
|
||||
apiToken: runContext.apiToken,
|
||||
modes,
|
||||
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
||||
prepushScript: runContext.repoSettings.prepushScript,
|
||||
prApproveEnabled: runContext.repoSettings.prApproveEnabled,
|
||||
modeInstructions: runContext.repoSettings.modeInstructions,
|
||||
toolState,
|
||||
@@ -205,6 +309,7 @@ export async function main(): Promise<MainResult> {
|
||||
repo: runContext.repo,
|
||||
modes,
|
||||
outputSchema,
|
||||
learnings: runContext.repoSettings.learnings,
|
||||
});
|
||||
// log instructions as soon as they are fully resolved
|
||||
const logParts = [
|
||||
@@ -224,11 +329,22 @@ export async function main(): Promise<MainResult> {
|
||||
checkIntervalMs: DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
|
||||
});
|
||||
activityTimeout.promise.catch(() => {}); // prevent unhandled rejection if agent wins race
|
||||
todoTracker = createTodoTracker(async (body) => {
|
||||
if (progressCallbackDisabled || !toolContext) return;
|
||||
try {
|
||||
await reportProgress(toolContext, { body });
|
||||
} catch (err) {
|
||||
log.debug(`progress update failed: ${err}`);
|
||||
}
|
||||
});
|
||||
toolState.todoTracker = todoTracker;
|
||||
|
||||
const agentPromise = agent.run({
|
||||
payload,
|
||||
mcpServerUrl: mcpHttpServer.url,
|
||||
tmpdir,
|
||||
instructions,
|
||||
todoTracker,
|
||||
});
|
||||
|
||||
// timeout enforcement: default is 1 hour, but can be overridden via flags in the prompt:
|
||||
@@ -270,7 +386,7 @@ export async function main(): Promise<MainResult> {
|
||||
);
|
||||
}
|
||||
|
||||
// post-agent review cleanup: reportReviewNodeId → follow-up dispatch → delete progress comment.
|
||||
// post-agent review cleanup: reportReviewNodeId → follow-up re-review dispatch.
|
||||
// runs after the agent exits so ordering is architecturally guaranteed (no LLM involvement).
|
||||
// best-effort: cleanup failures must not turn a successful agent run into a failure.
|
||||
if (toolContext) {
|
||||
@@ -279,6 +395,25 @@ export async function main(): Promise<MainResult> {
|
||||
});
|
||||
}
|
||||
|
||||
// clean up stranded progress comments. two cases:
|
||||
// 1. wasUpdated=false: nothing wrote to the comment ("Leaping into action" orphan)
|
||||
// 2. tracker published a checklist but the agent never wrote a final summary
|
||||
// (hasPublished=true, finalSummaryWritten=false).
|
||||
// in both cases, delete the comment so it doesn't linger with stale content.
|
||||
// wasUpdated is intentionally NOT set here — cleanup is not a real progress update.
|
||||
// uses finalSummaryWritten (not todoTracker.enabled) so cleanup survives API failures
|
||||
// in report_progress where cancel() ran but the write didn't succeed.
|
||||
const trackerWasLastWriter = todoTracker?.hasPublished && !toolState.finalSummaryWritten;
|
||||
if (
|
||||
toolContext &&
|
||||
toolState.progressCommentId &&
|
||||
(!toolState.wasUpdated || trackerWasLastWriter)
|
||||
) {
|
||||
await deleteProgressComment(toolContext).catch((error) => {
|
||||
log.debug(`stranded progress comment cleanup failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
await writeJobSummary(toolState);
|
||||
|
||||
// emit structured output marker for test validation
|
||||
@@ -294,6 +429,8 @@ export async function main(): Promise<MainResult> {
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
|
||||
progressCallbackDisabled = true;
|
||||
todoTracker?.cancel();
|
||||
killTrackedChildren();
|
||||
log.error(errorMessage);
|
||||
|
||||
|
||||
+256
-118
@@ -5,6 +5,7 @@ import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
||||
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
@@ -139,6 +140,7 @@ export type CheckoutPrResult = {
|
||||
url: string;
|
||||
headRepo: string;
|
||||
diffPath: string;
|
||||
incrementalDiffPath?: string | undefined;
|
||||
toc: string;
|
||||
instructions: string;
|
||||
};
|
||||
@@ -166,135 +168,236 @@ export async function fetchAndFormatPrDiff(params: FetchPrDiffParams): Promise<F
|
||||
|
||||
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 {
|
||||
prNumber: number;
|
||||
isFork: boolean;
|
||||
forkUrl?: string | undefined; // only set when isFork is true
|
||||
type EnsureBeforeShaParams = {
|
||||
sha: string;
|
||||
octokit: Octokit;
|
||||
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.
|
||||
* 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(
|
||||
pullNumber: number,
|
||||
params: CheckoutPrBranchParams
|
||||
): Promise<CheckoutPrBranchResult> {
|
||||
const { octokit, owner, name, gitToken, toolState } = params;
|
||||
log.info(`» checking out PR #${pullNumber}...`);
|
||||
export async function checkoutPrBranch(pr: PrData, params: CheckoutPrBranchParams): Promise<void> {
|
||||
const { octokit, owner, name, gitToken, toolState, beforeSha } = params;
|
||||
log.info(`» checking out PR #${pr.number}...`);
|
||||
|
||||
// fetch PR metadata
|
||||
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;
|
||||
const isFork = pr.headRepoFullName !== pr.baseRepoFullName;
|
||||
|
||||
// always use pr-{number} as local branch name for consistency
|
||||
// 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 =
|
||||
$("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)
|
||||
// this handles fork PRs where head branch name might match base branch name
|
||||
const currentSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
||||
const alreadyOnBranch = currentSha === pr.data.head.sha;
|
||||
toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
||||
const alreadyOnBranch = toolState.checkoutSha === pr.headSha;
|
||||
|
||||
if (alreadyOnBranch) {
|
||||
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
|
||||
} else {
|
||||
// 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,
|
||||
});
|
||||
// fetch base branch so origin/<base> exists for diff operations
|
||||
log.debug(`» fetching base branch (${pr.baseRef})...`);
|
||||
await $git("fetch", ["--no-tags", "origin", pr.baseRef], { 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
|
||||
// -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)
|
||||
log.debug(`» fetching PR #${pullNumber} (${localBranch})...`);
|
||||
await $git("fetch", ["--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`], {
|
||||
log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
|
||||
await $git("fetch", ["--no-tags", "origin", `pull/${pr.number}/head:${localBranch}`], {
|
||||
token: gitToken,
|
||||
});
|
||||
|
||||
// checkout the branch
|
||||
$("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)
|
||||
// fetch if we skipped checkout (already on branch) - otherwise already fetched above
|
||||
if (alreadyOnBranch) {
|
||||
log.debug(`» fetching base branch (${baseBranch})...`);
|
||||
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
|
||||
token: gitToken,
|
||||
});
|
||||
const beforeShaReachable = beforeSha
|
||||
? await ensureBeforeShaReachable({
|
||||
sha: beforeSha,
|
||||
octokit,
|
||||
owner,
|
||||
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
|
||||
// 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.
|
||||
if (isFork) {
|
||||
const remoteName = `pr-${pullNumber}`;
|
||||
const remoteName = `pr-${pr.number}`;
|
||||
// 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)
|
||||
try {
|
||||
$("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 {
|
||||
// remote already exists, update its URL
|
||||
$("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
|
||||
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false });
|
||||
// set merge ref so git knows the remote branch name (may differ from local)
|
||||
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false });
|
||||
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
|
||||
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
|
||||
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${pr.headRef}'`);
|
||||
|
||||
// warn if maintainer can't modify (push will likely fail)
|
||||
if (!pr.data.maintainer_can_modify) {
|
||||
if (!pr.maintainerCanModify) {
|
||||
log.warning(
|
||||
`» 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.`
|
||||
@@ -303,21 +406,21 @@ export async function checkoutPrBranch(
|
||||
} else {
|
||||
// for same-repo PRs, push to origin
|
||||
$("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
|
||||
toolState.issueNumber = pullNumber;
|
||||
toolState.issueNumber = pr.number;
|
||||
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
|
||||
// git config is the primary mechanism, but toolState serves as a reliable fallback
|
||||
// in case git config reads fail in certain environments
|
||||
toolState.pushDest = {
|
||||
remoteName: isFork ? `pr-${pullNumber}` : "origin",
|
||||
remoteBranch: headBranch,
|
||||
remoteName: isFork ? `pr-${pr.number}` : "origin",
|
||||
remoteBranch: pr.headRef,
|
||||
localBranch,
|
||||
};
|
||||
|
||||
@@ -326,12 +429,6 @@ export async function checkoutPrBranch(
|
||||
event: "post-checkout",
|
||||
script: params.postCheckoutScript,
|
||||
});
|
||||
|
||||
return {
|
||||
prNumber: pullNumber,
|
||||
isFork,
|
||||
forkUrl: isFork ? `https://github.com/${headRepo.full_name}.git` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function CheckoutPrTool(ctx: ToolContext) {
|
||||
@@ -342,7 +439,28 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
"Returns diffPath pointing to the formatted diff file.",
|
||||
parameters: CheckoutPr,
|
||||
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,
|
||||
owner: ctx.repo.owner,
|
||||
name: ctx.repo.name,
|
||||
@@ -350,20 +468,38 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
toolState: ctx.toolState,
|
||||
shell: ctx.payload.shell,
|
||||
postCheckoutScript: ctx.postCheckoutScript,
|
||||
beforeSha: ctx.toolState.beforeSha,
|
||||
});
|
||||
|
||||
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`);
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) {
|
||||
throw new Error(
|
||||
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
|
||||
);
|
||||
}
|
||||
|
||||
ctx.toolState.checkoutSha = pr.data.head.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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch PR files and format with line numbers
|
||||
const formatResult = await fetchAndFormatPrDiff({
|
||||
@@ -374,28 +510,29 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
});
|
||||
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
|
||||
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
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`);
|
||||
const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`);
|
||||
writeFileSync(diffPath, formatResult.content);
|
||||
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 {
|
||||
success: true,
|
||||
number: pr.data.number,
|
||||
title: pr.data.title,
|
||||
base: pr.data.base.ref,
|
||||
number: prResponse.data.number,
|
||||
title: prResponse.data.title,
|
||||
base: pr.baseRef,
|
||||
localBranch: `pr-${pull_number}`,
|
||||
remoteBranch: `refs/heads/${pr.data.head.ref}`,
|
||||
isFork: headRepo.full_name !== pr.data.base.repo.full_name,
|
||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||
url: pr.data.html_url,
|
||||
headRepo: headRepo.full_name,
|
||||
remoteBranch: `refs/heads/${pr.headRef}`,
|
||||
isFork: pr.headRepoFullName !== pr.baseRepoFullName,
|
||||
maintainerCanModify: pr.maintainerCanModify,
|
||||
url: prResponse.data.html_url,
|
||||
headRepo: pr.headRepoFullName,
|
||||
diffPath,
|
||||
incrementalDiffPath,
|
||||
toc: formatResult.toc,
|
||||
instructions:
|
||||
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
|
||||
@@ -403,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. ` +
|
||||
`review files selectively based on relevance rather than reading everything sequentially. ` +
|
||||
`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;
|
||||
}),
|
||||
});
|
||||
|
||||
+84
-28
@@ -9,8 +9,15 @@ import { retry } from "../utils/retry.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
/** PATCH workflow-run with plan comment node_id so plan revisions can update that comment in place. */
|
||||
async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string): Promise<void> {
|
||||
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
|
||||
|
||||
// IMPORTANT: this route authenticates via Pullfrog API JWT (verifyApiToken),
|
||||
// NOT a GitHub token. use ctx.apiToken here. see wiki/api-auth.md.
|
||||
export async function updateCommentNodeId(
|
||||
ctx: ToolContext,
|
||||
field: CommentNodeIdField,
|
||||
nodeId: string
|
||||
): Promise<void> {
|
||||
if (ctx.runId === undefined || !ctx.apiToken) return;
|
||||
try {
|
||||
await retry(
|
||||
@@ -22,7 +29,7 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ planCommentNodeId }),
|
||||
body: JSON.stringify({ [field]: nodeId }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
|
||||
@@ -30,11 +37,11 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
|
||||
{
|
||||
maxAttempts: 3,
|
||||
delayMs: 2000,
|
||||
label: "updatePlanCommentId",
|
||||
label: `updateCommentNodeId(${field})`,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
log.warning(`updatePlanCommentId exhausted retries: ${error}`);
|
||||
log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +55,7 @@ export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
||||
interface BuildCommentFooterParams {
|
||||
octokit?: OctokitWithPlugins | undefined;
|
||||
customParts?: string[] | undefined;
|
||||
model?: string | undefined;
|
||||
}
|
||||
|
||||
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
|
||||
@@ -70,17 +78,14 @@ async function buildCommentFooter(params: BuildCommentFooterParams): Promise<str
|
||||
}
|
||||
}
|
||||
|
||||
const footerParams = {
|
||||
return buildPullfrogFooter({
|
||||
triggeredBy: true,
|
||||
workflowRun: runId
|
||||
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (params.customParts && params.customParts.length > 0) {
|
||||
return buildPullfrogFooter({ ...footerParams, customParts: params.customParts });
|
||||
}
|
||||
return buildPullfrogFooter(footerParams);
|
||||
customParts: params.customParts,
|
||||
model: params.model,
|
||||
});
|
||||
}
|
||||
|
||||
function buildImplementPlanLink(
|
||||
@@ -95,11 +100,17 @@ function buildImplementPlanLink(
|
||||
|
||||
export interface AddFooterCtx {
|
||||
octokit?: OctokitWithPlugins | undefined;
|
||||
toolState?: { model?: string | undefined } | undefined;
|
||||
}
|
||||
|
||||
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
|
||||
if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
|
||||
throw new Error(
|
||||
"body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> tags."
|
||||
);
|
||||
}
|
||||
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
||||
const footer = await buildCommentFooter({ octokit: ctx.octokit });
|
||||
const footer = await buildCommentFooter({ octokit: ctx.octokit, model: ctx.toolState?.model });
|
||||
return `${bodyWithoutFooter}${footer}`;
|
||||
}
|
||||
|
||||
@@ -107,9 +118,9 @@ export const Comment = type({
|
||||
issueNumber: type.number.describe("the issue number to comment on"),
|
||||
body: type.string.describe("the comment body content"),
|
||||
type: type
|
||||
.enumerated("Plan", "Comment")
|
||||
.enumerated("Plan", "Summary", "Comment")
|
||||
.describe(
|
||||
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
|
||||
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). Comment: regular comment (default)."
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
@@ -118,11 +129,35 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "create_issue_comment",
|
||||
description:
|
||||
"Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
|
||||
"Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments, type: 'Summary' for PR summary comments.",
|
||||
parameters: Comment,
|
||||
execute: execute(async ({ issueNumber, body, type: commentType }) => {
|
||||
const bodyWithFooter = await addFooter(ctx, body);
|
||||
|
||||
// if a summary comment already exists (found by select_mode), update instead of creating
|
||||
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
|
||||
log.info(
|
||||
`» redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
|
||||
);
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: ctx.toolState.existingSummaryCommentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
if (result.data.node_id) {
|
||||
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
@@ -131,7 +166,10 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
});
|
||||
|
||||
if (commentType === "Plan" && result.data.node_id) {
|
||||
await updatePlanCommentId(ctx, result.data.node_id);
|
||||
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
|
||||
}
|
||||
if (commentType === "Summary" && result.data.node_id) {
|
||||
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -228,6 +266,7 @@ export async function reportProgress(
|
||||
const footer = await buildCommentFooter({
|
||||
octokit: ctx.octokit,
|
||||
customParts,
|
||||
model: ctx.toolState.model,
|
||||
});
|
||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
@@ -241,7 +280,7 @@ export async function reportProgress(
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
if (isPlanMode && result.data.node_id) {
|
||||
await updatePlanCommentId(ctx, result.data.node_id);
|
||||
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -265,6 +304,7 @@ export async function reportProgress(
|
||||
const footer = await buildCommentFooter({
|
||||
octokit: ctx.octokit,
|
||||
customParts,
|
||||
model: ctx.toolState.model,
|
||||
});
|
||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
@@ -278,7 +318,7 @@ export async function reportProgress(
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
if (isPlanMode && result.data.node_id) {
|
||||
await updatePlanCommentId(ctx, result.data.node_id);
|
||||
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -289,7 +329,7 @@ export async function reportProgress(
|
||||
};
|
||||
}
|
||||
|
||||
// null = progress comment was deliberately deleted (e.g. by create_pull_request_review)
|
||||
// null = progress comment was deleted by stranded-comment cleanup in main.ts
|
||||
if (existingCommentId === null) {
|
||||
return { body, action: "skipped" };
|
||||
}
|
||||
@@ -325,6 +365,7 @@ export async function reportProgress(
|
||||
const footer = await buildCommentFooter({
|
||||
octokit: ctx.octokit,
|
||||
customParts,
|
||||
model: ctx.toolState.model,
|
||||
});
|
||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
@@ -336,7 +377,7 @@ export async function reportProgress(
|
||||
});
|
||||
|
||||
if (updateResult.data.node_id) {
|
||||
await updatePlanCommentId(ctx, updateResult.data.node_id);
|
||||
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -359,17 +400,33 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "report_progress",
|
||||
description:
|
||||
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
|
||||
"Share progress on the associated GitHub issue/PR. The first call creates a comment; subsequent calls update it in place. You MUST call this at the end of every run with a brief final summary (1-3 sentences). The completed task list is automatically appended in a collapsible section — do not restate individual steps.",
|
||||
parameters: ReportProgress,
|
||||
execute: execute(async (params) => {
|
||||
const reportParams: { body: string; target_plan_comment?: boolean } = { body: params.body };
|
||||
let body = params.body;
|
||||
|
||||
// for non-plan calls: stop auto-updates, wait for in-flight writes to settle,
|
||||
// then append completed task list collapsible
|
||||
if (!params.target_plan_comment && ctx.toolState.todoTracker) {
|
||||
ctx.toolState.todoTracker.cancel();
|
||||
await ctx.toolState.todoTracker.settled();
|
||||
const collapsible = ctx.toolState.todoTracker.renderCollapsible();
|
||||
if (collapsible) {
|
||||
body = `${body}\n\n${collapsible}`;
|
||||
}
|
||||
}
|
||||
|
||||
const reportParams: { body: string; target_plan_comment?: boolean } = { body };
|
||||
if (params.target_plan_comment !== undefined) {
|
||||
reportParams.target_plan_comment = params.target_plan_comment;
|
||||
}
|
||||
const result = await reportProgress(ctx, reportParams);
|
||||
|
||||
if (!params.target_plan_comment) {
|
||||
ctx.toolState.finalSummaryWritten = true;
|
||||
}
|
||||
|
||||
if (result.action === "skipped") {
|
||||
// no-op: no comment target, but progress is still tracked for job summary
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
@@ -387,9 +444,9 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
|
||||
/**
|
||||
* Delete the progress comment if it exists.
|
||||
* Used after submitting a PR review since the review body contains all necessary info.
|
||||
* Sets progressCommentId to null, which prevents future report_progress calls from
|
||||
* creating a new comment (the agent may call report_progress again after this).
|
||||
* Used by main.ts for stranded-comment cleanup (orphaned "Leaping into action" or
|
||||
* checklist left by the todo tracker when the agent didn't call report_progress).
|
||||
* Sets progressCommentId to null so subsequent report_progress calls are no-ops.
|
||||
*/
|
||||
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
|
||||
const existingCommentId = ctx.toolState.progressCommentId;
|
||||
@@ -414,7 +471,6 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
||||
|
||||
// set to null (not undefined) so report_progress skips instead of creating a new comment
|
||||
ctx.toolState.progressCommentId = null;
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
+14
@@ -2,6 +2,7 @@ import { regex } from "arkregex";
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { StoredPushDest, ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
@@ -143,6 +144,8 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
? ["--force", "-u", pushDest.remoteName, refspec]
|
||||
: ["-u", pushDest.remoteName, refspec];
|
||||
|
||||
await executeLifecycleHook({ event: "prepush", script: ctx.prepushScript });
|
||||
|
||||
log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`);
|
||||
if (force) {
|
||||
log.warning(`force pushing - this will overwrite remote history`);
|
||||
@@ -218,6 +221,8 @@ const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
||||
// (avoids false positives like --exclude matching --exec)
|
||||
const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
||||
|
||||
const COLLAPSE_THRESHOLD = 200;
|
||||
|
||||
// SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
|
||||
// this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
|
||||
//
|
||||
@@ -269,6 +274,15 @@ export function GitTool(ctx: ToolContext) {
|
||||
}
|
||||
|
||||
const output = $("git", [subcommand, ...args], { log: false });
|
||||
const lineCount = output.split("\n").length;
|
||||
if (lineCount > COLLAPSE_THRESHOLD) {
|
||||
log.group(`git ${subcommand} output (${lineCount} lines)`, () => {
|
||||
log.info(output);
|
||||
});
|
||||
} else if (output) {
|
||||
log.info(output);
|
||||
}
|
||||
|
||||
return { success: true, output };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type } from "arktype";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
const UpdateLearningsParams = type({
|
||||
learnings: type.string.describe(
|
||||
"the FULL merged learnings as a flat bullet list. each line starts with `- `. one discrete, actionable fact per bullet. combine existing bullets from the prompt with your new discoveries. deduplicate — if an existing bullet covers the same fact, update it in place rather than adding a new one. drop bullets that are clearly wrong or no longer relevant to the current codebase. keep the list focused and concise."
|
||||
),
|
||||
});
|
||||
|
||||
export function UpdateLearningsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "update_learnings",
|
||||
description:
|
||||
"persist operational learnings about this repository (setup steps, test commands, key conventions, patterns). ONLY call this when you have high confidence the information is correct and broadly useful for future runs — not for one-off findings or uncertain observations. format: flat bullet list (`- ` per line, one fact per bullet). pass the FULL merged list — combine existing learnings from the prompt with new discoveries. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.",
|
||||
parameters: UpdateLearningsParams,
|
||||
execute: execute(async (params) => {
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
learnings: params.learnings,
|
||||
model: ctx.toolState.model,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`failed to update learnings: ${error}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,7 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
||||
workflowRun: ctx.runId
|
||||
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||
: undefined,
|
||||
model: ctx.toolState.model,
|
||||
});
|
||||
|
||||
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
||||
|
||||
+88
-52
@@ -1,5 +1,6 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import { getApiUrl } from "../utils/apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
@@ -8,10 +9,10 @@ import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
function isStatusError(err: unknown): err is { status: number; message?: string } {
|
||||
return (
|
||||
typeof err === "object" && err !== null && "status" in err && typeof err.status === "number"
|
||||
);
|
||||
function getHttpStatus(err: unknown): number | undefined {
|
||||
if (typeof err !== "object" || err === null) return undefined;
|
||||
const status = (err as Record<string, unknown>).status;
|
||||
return typeof status === "number" ? status : undefined;
|
||||
}
|
||||
|
||||
// one-shot review tool
|
||||
@@ -35,7 +36,7 @@ export const CreatePullRequestReview = type({
|
||||
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
|
||||
),
|
||||
line: type.number.describe(
|
||||
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
|
||||
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format."
|
||||
),
|
||||
side: type
|
||||
.enumerated("LEFT", "RIGHT")
|
||||
@@ -51,9 +52,11 @@ export const CreatePullRequestReview = type({
|
||||
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
|
||||
)
|
||||
.optional(),
|
||||
start_line: type.number.describe(
|
||||
"Start line of the comment range. For single-line comments, set equal to 'line'. The range [start_line, line] defines which lines a suggestion replaces."
|
||||
),
|
||||
start_line: type.number
|
||||
.describe(
|
||||
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces."
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.array()
|
||||
.describe(
|
||||
@@ -67,14 +70,15 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
name: "create_pull_request_review",
|
||||
description:
|
||||
"Submit a review for an existing pull request. " +
|
||||
"Each call creates a permanent, visible review on the PR — NEVER submit test or diagnostic reviews. " +
|
||||
"Reviews with no body AND no comments are silently skipped (nothing to post). " +
|
||||
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
|
||||
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
|
||||
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
|
||||
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
|
||||
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
|
||||
" Commenting on files or lines outside the diff will cause GitHub API errors." +
|
||||
" Put feedback about code outside the diff in 'body' instead.",
|
||||
" If GitHub rejects comments due to incorrect line numbers, re-read the diff and retry.",
|
||||
parameters: CreatePullRequestReview,
|
||||
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
||||
if (body) body = fixDoubleEscapedString(body);
|
||||
@@ -82,6 +86,19 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
// set issue context (PRs are issues)
|
||||
ctx.toolState.issueNumber = pull_number;
|
||||
|
||||
// skip empty COMMENT reviews (no body, no inline comments) — nothing to post.
|
||||
// APPROVE reviews are never skipped: the approval stamp itself is the content.
|
||||
if (!approved && !body && comments.length === 0) {
|
||||
log.info(
|
||||
"review has no body and no inline comments — skipping submission (no issues found)"
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "no issues found — nothing to post",
|
||||
};
|
||||
}
|
||||
|
||||
// enforce prApproveEnabled: downgrade APPROVE to COMMENT if disabled
|
||||
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
|
||||
if (event === "APPROVE" && !ctx.prApproveEnabled) {
|
||||
@@ -95,6 +112,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
pull_number,
|
||||
event,
|
||||
};
|
||||
let latestHeadSha: string | undefined;
|
||||
if (commit_id) {
|
||||
params.commit_id = commit_id;
|
||||
} else {
|
||||
@@ -103,28 +121,39 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
});
|
||||
params.commit_id = pr.data.head.sha;
|
||||
latestHeadSha = pr.data.head.sha;
|
||||
// anchor to checkout sha so line numbers match the diff the agent analyzed
|
||||
params.commit_id = ctx.toolState.checkoutSha ?? latestHeadSha;
|
||||
if (ctx.toolState.checkoutSha && latestHeadSha !== ctx.toolState.checkoutSha) {
|
||||
log.info(
|
||||
`anchoring review to checkout ${ctx.toolState.checkoutSha.slice(0, 7)} ` +
|
||||
`(HEAD is now ${latestHeadSha.slice(0, 7)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (comments.length > 0) {
|
||||
type ReviewComment = (typeof params.comments & {})[number];
|
||||
params.comments = comments.map((comment) => {
|
||||
let commentBody = fixDoubleEscapedString(comment.body || "");
|
||||
if (comment.suggestion !== undefined) {
|
||||
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
||||
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
||||
}
|
||||
type ReviewComment = NonNullable<typeof params.comments>[number];
|
||||
const reviewComments = comments.map((comment) => {
|
||||
let commentBody = fixDoubleEscapedString(comment.body || "");
|
||||
if (comment.suggestion !== undefined) {
|
||||
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
||||
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
||||
}
|
||||
const side = comment.side || "RIGHT";
|
||||
const reviewComment: ReviewComment = {
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
body: commentBody,
|
||||
side,
|
||||
};
|
||||
if (comment.start_line != null && comment.start_line !== comment.line) {
|
||||
reviewComment.start_line = comment.start_line;
|
||||
reviewComment.start_side = side;
|
||||
}
|
||||
return reviewComment;
|
||||
});
|
||||
|
||||
const side = comment.side || "RIGHT";
|
||||
const reviewComment: ReviewComment = {
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
body: commentBody,
|
||||
side,
|
||||
start_line: comment.start_line,
|
||||
start_side: side,
|
||||
};
|
||||
return reviewComment;
|
||||
});
|
||||
if (reviewComments.length > 0) {
|
||||
params.comments = reviewComments;
|
||||
}
|
||||
|
||||
// no body → single-step createReview (no footer needed)
|
||||
@@ -135,20 +164,24 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
? await createAndSubmitWithFooter(ctx, params, {
|
||||
body,
|
||||
approved: approved ?? false,
|
||||
hasComments: comments.length > 0,
|
||||
hasComments: reviewComments.length > 0,
|
||||
})
|
||||
: await ctx.octokit.rest.pulls.createReview(params);
|
||||
} catch (err: unknown) {
|
||||
if (isStatusError(err) && err.status === 422 && params.comments?.length) {
|
||||
const paths = [...new Set(params.comments.map((comment) => comment.path))];
|
||||
throw new Error(
|
||||
`${err.message ?? "422 Unprocessable Entity"}. ` +
|
||||
`The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. ` +
|
||||
`GitHub cannot resolve one or more of these paths in the PR diff (common when the PR has >100 changed files and some are truncated). ` +
|
||||
`Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
|
||||
|
||||
const details = params.comments.map((c) => {
|
||||
const line = c.line ?? 0;
|
||||
const startLine = c.start_line ?? line;
|
||||
const range = startLine !== line ? `${startLine}-${line}` : `${line}`;
|
||||
return `${c.path}:${range} (${c.side ?? "RIGHT"})`;
|
||||
});
|
||||
throw new Error(
|
||||
`GitHub rejected inline comment(s) with "Line could not be resolved". ` +
|
||||
`This usually means the diff changed since you last read it (new commits pushed). ` +
|
||||
`Re-read the diff to get current line numbers, or move failing comments to the review body. ` +
|
||||
`Affected: ${details.join(", ")}`
|
||||
);
|
||||
}
|
||||
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||
if (!result.data.id) {
|
||||
@@ -169,13 +202,16 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
|
||||
// detect commits pushed since checkout and guide the agent to review them
|
||||
// inline instead of dispatching a separate workflow run
|
||||
const headMovedDuringReview =
|
||||
ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha;
|
||||
|
||||
if (headMovedDuringReview) {
|
||||
const fromSha = ctx.toolState.checkoutSha!;
|
||||
const toSha = params.commit_id!;
|
||||
// advance checkoutSha so the next review submission tracks correctly
|
||||
if (
|
||||
ctx.toolState.checkoutSha &&
|
||||
latestHeadSha &&
|
||||
latestHeadSha !== ctx.toolState.checkoutSha
|
||||
) {
|
||||
const fromSha = ctx.toolState.checkoutSha;
|
||||
const toSha = latestHeadSha;
|
||||
// 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;
|
||||
|
||||
log.info(
|
||||
@@ -193,10 +229,9 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
from: fromSha,
|
||||
to: toSha,
|
||||
instructions:
|
||||
`New commits were pushed while you were reviewing. ` +
|
||||
`Run \`git pull\` to fetch them, then review the incremental diff ` +
|
||||
`with \`git diff ${fromSha}...HEAD\`. Submit another review covering ` +
|
||||
`only the new changes. Do not repeat feedback from your previous review.`,
|
||||
`new commits were pushed while you were reviewing. ` +
|
||||
`call \`${ghPullfrogMcpName}/checkout_pr\` again to fetch the latest version — it will compute the incremental diff automatically. ` +
|
||||
`submit another review covering only the new changes. do not repeat feedback from your previous review.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -245,6 +280,7 @@ async function createAndSubmitWithFooter(
|
||||
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||
: undefined,
|
||||
customParts,
|
||||
model: ctx.toolState.model,
|
||||
});
|
||||
|
||||
return ctx.octokit.rest.pulls.submitReview({
|
||||
|
||||
+118
-17
@@ -2,12 +2,13 @@ import { type } from "arktype";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const SelectModeParams = type({
|
||||
mode: type.string.describe(
|
||||
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')"
|
||||
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts', 'Summarize')"
|
||||
),
|
||||
"issue_number?": type("number").describe(
|
||||
"optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)"
|
||||
@@ -18,6 +19,10 @@ function resolveMode(modes: Mode[], modeName: string): Mode | null {
|
||||
return modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
function learningsStep(n: number): string {
|
||||
return `${n}. **learnings** (only if high confidence): if you discovered something about repo setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs, call \`${ghPullfrogMcpName}/update_learnings\` to persist it. skip this step if you are unsure or the finding is speculative/one-off. format as a flat bullet list (\`- \` per line, one fact per bullet). merge with existing learnings from the prompt — pass the FULL merged list. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`;
|
||||
}
|
||||
|
||||
const modeGuidance: Record<string, string> = {
|
||||
Build: `### Checklist
|
||||
|
||||
@@ -39,6 +44,8 @@ const modeGuidance: Record<string, string> = {
|
||||
- create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR link
|
||||
|
||||
${learningsStep(5)}
|
||||
|
||||
### Notes
|
||||
|
||||
For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
@@ -85,7 +92,9 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
- push changes via \`${ghPullfrogMcpName}/push_branch\`
|
||||
- reply to each comment using \`${ghPullfrogMcpName}/reply_to_review_comment\`
|
||||
- resolve addressed threads via \`${ghPullfrogMcpName}/resolve_review_thread\`
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary`,
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary
|
||||
|
||||
${learningsStep(6)}`,
|
||||
|
||||
Review: `### Checklist
|
||||
|
||||
@@ -102,16 +111,15 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
|
||||
3. Self-critique: review all drafted comments and drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable.
|
||||
|
||||
4. Submit a **single** review:
|
||||
- call \`${ghPullfrogMcpName}/create_pull_request_review\` with all comments and a unified summary body
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with the summary
|
||||
- if no actionable issues found, skip the review — just call \`report_progress\` noting the PR was reviewed`,
|
||||
4. Submit:
|
||||
- **actionable issues found**: call \`${ghPullfrogMcpName}/create_pull_request_review\` with all comments, a 1-3 sentence summary body, and \`approved: false\`. 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., "Reviewed — no issues found.").`,
|
||||
|
||||
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.
|
||||
|
||||
@@ -125,10 +133,10 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
|
||||
5. Self-critique: drop any comments that are praise, style preferences, speculative, about pre-existing code, or not actionable.
|
||||
|
||||
6. Submit a **single** review:
|
||||
- if actionable issues found: call \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, all comments, and an **empty body** (do NOT include a summary — inline comments speak for themselves and a top-level comment clutters the PR conversation on every re-review)
|
||||
- if no actionable issues found: submit with \`approved: true\` and an **empty body** (no inline comments, no summary)
|
||||
- do NOT call \`${ghPullfrogMcpName}/report_progress\` — incremental reviews should be silent`,
|
||||
6. Submit:
|
||||
- **actionable issues found**: call \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, all comments, and an **empty body** — inline comments speak for themselves, and a top-level body clutters the PR conversation on every re-review cycle. Then call \`report_progress\` with a 1-sentence summary.
|
||||
- **no actionable issues, 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
|
||||
|
||||
@@ -138,7 +146,9 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
|
||||
2. Produce a structured, actionable plan with clear milestones.
|
||||
|
||||
3. Call \`${ghPullfrogMcpName}/report_progress\` with the plan.`,
|
||||
3. Call \`${ghPullfrogMcpName}/report_progress\` with the plan.
|
||||
|
||||
${learningsStep(4)}`,
|
||||
|
||||
PlanEdit: `### Checklist (editing existing plan)
|
||||
|
||||
@@ -169,7 +179,9 @@ An existing plan comment was found for this issue. Update that comment with the
|
||||
|
||||
5. Finalize:
|
||||
- push changes via \`${ghPullfrogMcpName}/push_branch\`
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary`,
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary
|
||||
|
||||
${learningsStep(6)}`,
|
||||
|
||||
Task: `### Checklist
|
||||
|
||||
@@ -184,7 +196,43 @@ An existing plan comment was found for this issue. Update that comment with the
|
||||
3. Finalize:
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with results
|
||||
- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
|
||||
- if the task involved labeling, commenting, or other GitHub operations, perform those directly`,
|
||||
- if the task involved labeling, commenting, or other GitHub operations, perform those directly
|
||||
|
||||
${learningsStep(4)}`,
|
||||
|
||||
Summarize: `### Checklist
|
||||
|
||||
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`.
|
||||
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt:
|
||||
- the diff file path
|
||||
- PR metadata (title, file count, commit count, base/head branches)
|
||||
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with plain-language \`##\` titles and before/after framing
|
||||
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
|
||||
- instruct it to return the full summary markdown as its final response
|
||||
3. After the subagent completes, call \`${ghPullfrogMcpName}/create_issue_comment\` with \`type: "Summary"\` and the summary body.
|
||||
4. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Posted PR summary.").
|
||||
|
||||
### Effort
|
||||
|
||||
Use mini or auto effort.`,
|
||||
|
||||
SummaryUpdate: `### Checklist (updating existing summary)
|
||||
|
||||
An existing summary comment was found for this PR. Update it rather than creating a new one.
|
||||
|
||||
1. Use \`previousSummaryBody\` from this response as the current summary to revise.
|
||||
2. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`.
|
||||
3. Delegate a subagent with:
|
||||
- the diff file path and PR metadata
|
||||
- the existing summary body (\`previousSummaryBody\`) so it can update rather than rewrite from scratch
|
||||
- format instructions from EVENT INSTRUCTIONS (if any)
|
||||
- instruct it to produce an updated summary reflecting the current state of the PR and return it as its final response
|
||||
4. After the subagent completes, call \`${ghPullfrogMcpName}/edit_issue_comment\` with \`commentId: existingSummaryCommentId\` (from this response) and the updated summary body.
|
||||
5. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Updated PR summary.").
|
||||
|
||||
### Effort
|
||||
|
||||
Use mini or auto effort.`,
|
||||
};
|
||||
|
||||
type OrchestratorGuidance = {
|
||||
@@ -218,16 +266,22 @@ function buildOrchestratorGuidance(mode: Mode, opts: BuildGuidanceOpts = {}): Or
|
||||
// matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
|
||||
export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
|
||||
|
||||
// matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment
|
||||
export type SummaryCommentResponsePayload = { error: string } | { commentId: number; body: string };
|
||||
|
||||
// IMPORTANT: these routes authenticate via GitHub installation token (getEnrichedRepo),
|
||||
// NOT the Pullfrog API JWT (ctx.apiToken). use ctx.githubInstallationToken here.
|
||||
// see wiki/api-auth.md for the two auth patterns.
|
||||
async function fetchExistingPlanComment(
|
||||
ctx: ToolContext,
|
||||
issueNumber: number
|
||||
): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
|
||||
if (!ctx.apiToken) return null;
|
||||
if (!ctx.githubInstallationToken) return null;
|
||||
try {
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${ctx.apiToken}` },
|
||||
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const data = (await response.json()) as PlanCommentResponsePayload;
|
||||
@@ -237,6 +291,35 @@ async function fetchExistingPlanComment(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExistingSummaryComment(
|
||||
ctx: ToolContext,
|
||||
prNumber: number
|
||||
): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> {
|
||||
if (!ctx.githubInstallationToken) {
|
||||
log.warning("fetchExistingSummaryComment: no token, skipping");
|
||||
return null;
|
||||
}
|
||||
const path = `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`;
|
||||
try {
|
||||
const response = await apiFetch({
|
||||
path,
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const data = (await response.json()) as SummaryCommentResponsePayload;
|
||||
if (response.ok && "commentId" in data) {
|
||||
return data;
|
||||
}
|
||||
const errMsg = "error" in data ? data.error : "(no error body)";
|
||||
log.warning(`fetchExistingSummaryComment: ${response.status} ${path} — ${errMsg}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
log.warning("fetchExistingSummaryComment failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SelectModeTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "select_mode",
|
||||
@@ -287,6 +370,24 @@ export function SelectModeTool(ctx: ToolContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedMode.name === "Summarize") {
|
||||
const prNumber = ctx.payload.event.issue_number;
|
||||
if (prNumber !== undefined) {
|
||||
const existing = await fetchExistingSummaryComment(ctx, prNumber);
|
||||
if (existing !== null) {
|
||||
ctx.toolState.existingSummaryCommentId = existing.commentId;
|
||||
return {
|
||||
...buildOrchestratorGuidance(selectedMode, {
|
||||
...guidanceOpts,
|
||||
overrideGuidance: modeGuidance.SummaryUpdate,
|
||||
}),
|
||||
existingSummaryCommentId: existing.commentId,
|
||||
previousSummaryBody: existing.body,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildOrchestratorGuidance(selectedMode, guidanceOpts);
|
||||
}),
|
||||
});
|
||||
|
||||
+30
-2
@@ -1,15 +1,18 @@
|
||||
// this must be imported first
|
||||
import "./arkConfig.ts";
|
||||
import { createServer } from "node:net";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { FastMCP, type Tool } from "fastmcp";
|
||||
import type { AgentUsage } from "../agents/index.ts";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import type { PrepResult } from "../prep/index.ts";
|
||||
import { closeBrowserDaemon } from "../utils/browser.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { OctokitWithPlugins } from "../utils/github.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
import type { RunContextData } from "../utils/runContextData.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { CheckoutPrTool } from "./checkout.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
import {
|
||||
@@ -29,6 +32,7 @@ import { GetIssueCommentsTool } from "./issueComments.ts";
|
||||
import { GetIssueEventsTool } from "./issueEvents.ts";
|
||||
import { IssueInfoTool } from "./issueInfo.ts";
|
||||
import { AddLabelsTool } from "./labels.ts";
|
||||
import { UpdateLearningsTool } from "./learnings.ts";
|
||||
import { SetOutputTool } from "./output.ts";
|
||||
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
@@ -49,6 +53,8 @@ export type BackgroundProcess = {
|
||||
pidPath: string;
|
||||
};
|
||||
|
||||
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
|
||||
|
||||
export type StoredPushDest = {
|
||||
remoteName: string;
|
||||
remoteBranch: string;
|
||||
@@ -66,8 +72,12 @@ export interface ToolState {
|
||||
issueNumber?: number;
|
||||
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
|
||||
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;
|
||||
backgroundProcesses: Map<string, BackgroundProcess>;
|
||||
browserDaemon?: BrowserDaemon | undefined;
|
||||
review?: {
|
||||
id: number;
|
||||
nodeId: string;
|
||||
@@ -80,13 +90,23 @@ export interface ToolState {
|
||||
};
|
||||
// undefined = no comment yet, number = active comment, null = deliberately deleted
|
||||
progressCommentId: number | null | undefined;
|
||||
// immutable snapshot: true if a progress comment was pre-created at init time.
|
||||
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
|
||||
hadProgressComment: boolean;
|
||||
lastProgressBody?: string;
|
||||
wasUpdated?: boolean;
|
||||
// set after a non-plan report_progress successfully writes the final summary.
|
||||
// decoupled from todoTracker.enabled so cleanup detection survives API failures.
|
||||
finalSummaryWritten?: boolean;
|
||||
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
||||
existingPlanCommentId?: number;
|
||||
previousPlanBody?: string;
|
||||
// set by select_mode when Summarize mode and summary-comment API returns existing summary
|
||||
existingSummaryCommentId?: number;
|
||||
output?: string;
|
||||
usageEntries: AgentUsage[];
|
||||
model?: string | undefined;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
}
|
||||
|
||||
interface InitToolStateParams {
|
||||
@@ -103,6 +123,7 @@ export function initToolState(params: InitToolStateParams): ToolState {
|
||||
|
||||
return {
|
||||
progressCommentId: resolvedId,
|
||||
hadProgressComment: !!resolvedId,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
};
|
||||
@@ -117,6 +138,7 @@ export interface ToolContext {
|
||||
apiToken: string;
|
||||
modes: Mode[];
|
||||
postCheckoutScript: string | null;
|
||||
prepushScript: string | null;
|
||||
prApproveEnabled: boolean;
|
||||
modeInstructions: Record<string, string>;
|
||||
toolState: ToolState;
|
||||
@@ -188,9 +210,13 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
|
||||
GitTool(ctx),
|
||||
GitFetchTool(ctx),
|
||||
UploadFileTool(ctx),
|
||||
SetOutputTool(ctx, outputSchema),
|
||||
];
|
||||
|
||||
const isStandalone = ctx.payload.event.trigger === "unknown";
|
||||
if (isStandalone || outputSchema) {
|
||||
tools.push(SetOutputTool(ctx, outputSchema));
|
||||
}
|
||||
|
||||
// MCP shell with filtered env (no secrets leaked to child processes)
|
||||
if (ctx.payload.shell === "restricted") {
|
||||
tools.push(ShellTool(ctx));
|
||||
@@ -210,6 +236,7 @@ function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): To
|
||||
DeleteBranchTool(ctx),
|
||||
CreatePullRequestTool(ctx),
|
||||
UpdatePullRequestBodyTool(ctx),
|
||||
UpdateLearningsTool(ctx),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -301,7 +328,7 @@ async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
|
||||
// already dead
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await sleep(200);
|
||||
for (const proc of backgroundProcesses.values()) {
|
||||
try {
|
||||
process.kill(-proc.pid, "SIGKILL");
|
||||
@@ -329,6 +356,7 @@ export async function startMcpHttpServer(
|
||||
return {
|
||||
url: startResult.url,
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
closeBrowserDaemon(ctx.toolState);
|
||||
await killBackgroundProcesses(ctx.toolState);
|
||||
await startResult.server.stop();
|
||||
},
|
||||
|
||||
+24
-2
@@ -4,7 +4,9 @@ import { randomUUID } from "node:crypto";
|
||||
import { closeSync, openSync, writeFileSync } from "node:fs";
|
||||
import { userInfo } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { type } from "arktype";
|
||||
import { ensureBrowserDaemon } from "../utils/browser.ts";
|
||||
import { log } from "../utils/log.ts";
|
||||
import { resolveEnv } from "../utils/secrets.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
@@ -115,7 +117,12 @@ function spawnShell(params: SpawnParams): ChildProcess {
|
||||
// sudo is only needed for unshare; the actual command should run as the normal user
|
||||
// to avoid ownership mismatches with files created by the Node.js parent process.
|
||||
const username = userInfo().username;
|
||||
const escaped = params.command.replace(/'/g, "'\\''");
|
||||
// su -p resets PATH on many Linux systems (ALWAYS_SET_PATH in /etc/login.defs).
|
||||
// restore it from the SANDBOX_PATH env var that survives the su transition.
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: we need to restore the PATH variable
|
||||
const pathRestore = 'export PATH="${SANDBOX_PATH:-$PATH}"; ';
|
||||
const escaped = (pathRestore + params.command).replace(/'/g, "'\\''");
|
||||
envArgs.push(`SANDBOX_PATH=${params.env.PATH ?? ""}`);
|
||||
return spawn(
|
||||
"sudo",
|
||||
[
|
||||
@@ -195,6 +202,21 @@ Do NOT use this tool for git commands — use the dedicated git tools instead.`,
|
||||
const cwd = params.working_directory ?? process.cwd();
|
||||
const env = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
|
||||
|
||||
if (params.command.includes("agent-browser")) {
|
||||
const daemonError = ensureBrowserDaemon(ctx.toolState);
|
||||
if (daemonError) {
|
||||
return {
|
||||
output: `browser daemon unavailable: ${daemonError}`,
|
||||
exit_code: 1,
|
||||
timed_out: false,
|
||||
};
|
||||
}
|
||||
const binDir = ctx.toolState.browserDaemon?.binDir;
|
||||
if (binDir) {
|
||||
env.PATH = `${binDir}:${env.PATH ?? ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.background) {
|
||||
const tempDir = getTempDir();
|
||||
const handle = `bg-${randomUUID().slice(0, 8)}`;
|
||||
@@ -305,7 +327,7 @@ export function KillBackgroundTool(ctx: ToolContext) {
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await sleep(200);
|
||||
try {
|
||||
process.kill(-proc.pid, "SIGKILL");
|
||||
} catch {
|
||||
|
||||
+19
-4
@@ -35,7 +35,10 @@ describe("getModelProvider", () => {
|
||||
|
||||
describe("getModelEnvVars", () => {
|
||||
it("returns correct env vars for anthropic", () => {
|
||||
expect(getModelEnvVars("anthropic/claude-opus")).toEqual(["ANTHROPIC_API_KEY"]);
|
||||
expect(getModelEnvVars("anthropic/claude-opus")).toEqual([
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns correct env vars for google (multiple)", () => {
|
||||
@@ -47,6 +50,18 @@ describe("getModelEnvVars", () => {
|
||||
it("returns empty array for unknown provider", () => {
|
||||
expect(getModelEnvVars("unknown/model")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty env vars for free opencode models", () => {
|
||||
expect(getModelEnvVars("opencode/big-pickle")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/nemotron-3-super-free")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
|
||||
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelSlug", () => {
|
||||
@@ -84,10 +99,10 @@ describe("modelAliases registry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("has exactly one recommended model per provider", () => {
|
||||
it("has exactly one preferred model per provider", () => {
|
||||
for (const providerKey of Object.keys(providers)) {
|
||||
const recommended = modelAliases.filter((a) => a.provider === providerKey && a.recommended);
|
||||
expect(recommended.length, `${providerKey} should have exactly 1 recommended model`).toBe(1);
|
||||
const preferred = modelAliases.filter((a) => a.provider === providerKey && a.preferred);
|
||||
expect(preferred.length, `${providerKey} should have exactly 1 preferred model`).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,15 +16,23 @@ export interface ModelAlias {
|
||||
displayName: string;
|
||||
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
|
||||
resolve: string;
|
||||
/** full models.dev specifier for the OpenRouter equivalent (undefined for free models) */
|
||||
openRouterResolve: string | undefined;
|
||||
/** top-tier pick for this provider — preferred during auto-select */
|
||||
recommended: boolean;
|
||||
preferred: boolean;
|
||||
/** whether this alias is free and requires no API key */
|
||||
isFree: boolean;
|
||||
}
|
||||
|
||||
interface ModelDef {
|
||||
displayName: string;
|
||||
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
|
||||
resolve: string;
|
||||
recommended?: boolean;
|
||||
/** full models.dev specifier for the OpenRouter equivalent, e.g. "openrouter/anthropic/claude-opus-4.6" */
|
||||
openRouterResolve?: string;
|
||||
preferred?: boolean;
|
||||
envVars?: readonly string[];
|
||||
isFree?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
@@ -42,45 +50,84 @@ function provider(config: ProviderConfig): ProviderConfig {
|
||||
export const providers = {
|
||||
anthropic: provider({
|
||||
displayName: "Anthropic",
|
||||
envVars: ["ANTHROPIC_API_KEY"],
|
||||
envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
||||
models: {
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "anthropic/claude-opus-4-6",
|
||||
recommended: true,
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
preferred: true,
|
||||
},
|
||||
"claude-sonnet": {
|
||||
displayName: "Claude Sonnet",
|
||||
resolve: "anthropic/claude-sonnet-4-6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
"claude-haiku": {
|
||||
displayName: "Claude Haiku",
|
||||
resolve: "anthropic/claude-haiku-4-5",
|
||||
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
|
||||
},
|
||||
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "anthropic/claude-sonnet-4-6" },
|
||||
"claude-haiku": { displayName: "Claude Haiku", resolve: "anthropic/claude-haiku-4-5" },
|
||||
},
|
||||
}),
|
||||
openai: provider({
|
||||
displayName: "OpenAI",
|
||||
envVars: ["OPENAI_API_KEY"],
|
||||
models: {
|
||||
"gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true },
|
||||
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" },
|
||||
o3: { displayName: "O3", resolve: "openai/o3" },
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "openai/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
|
||||
preferred: true,
|
||||
},
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "openai/codex-mini-latest",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
},
|
||||
o3: {
|
||||
displayName: "O3",
|
||||
resolve: "openai/o3",
|
||||
},
|
||||
},
|
||||
}),
|
||||
google: provider({
|
||||
displayName: "Google",
|
||||
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
|
||||
envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
models: {
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
resolve: "google/gemini-3.1-pro-preview",
|
||||
recommended: true,
|
||||
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||
preferred: true,
|
||||
},
|
||||
"gemini-flash": {
|
||||
displayName: "Gemini Flash",
|
||||
resolve: "google/gemini-3-flash-preview",
|
||||
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
|
||||
},
|
||||
"gemini-flash": { displayName: "Gemini Flash", resolve: "google/gemini-3-flash-preview" },
|
||||
},
|
||||
}),
|
||||
xai: provider({
|
||||
displayName: "xAI",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
models: {
|
||||
grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true },
|
||||
"grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" },
|
||||
"grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" },
|
||||
grok: {
|
||||
displayName: "Grok",
|
||||
resolve: "xai/grok-4",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4",
|
||||
preferred: true,
|
||||
},
|
||||
"grok-fast": {
|
||||
displayName: "Grok Fast",
|
||||
resolve: "xai/grok-4-fast",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4-fast",
|
||||
},
|
||||
"grok-code-fast": {
|
||||
displayName: "Grok Code Fast",
|
||||
resolve: "xai/grok-code-fast-1",
|
||||
openRouterResolve: "openrouter/x-ai/grok-code-fast-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
deepseek: provider({
|
||||
@@ -90,16 +137,26 @@ export const providers = {
|
||||
"deepseek-reasoner": {
|
||||
displayName: "DeepSeek Reasoner",
|
||||
resolve: "deepseek/deepseek-reasoner",
|
||||
recommended: true,
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
preferred: true,
|
||||
},
|
||||
"deepseek-chat": {
|
||||
displayName: "DeepSeek Chat",
|
||||
resolve: "deepseek/deepseek-chat",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
},
|
||||
"deepseek-chat": { displayName: "DeepSeek Chat", resolve: "deepseek/deepseek-chat" },
|
||||
},
|
||||
}),
|
||||
moonshotai: provider({
|
||||
displayName: "Moonshot AI",
|
||||
envVars: ["MOONSHOT_API_KEY"],
|
||||
models: {
|
||||
"kimi-k2": { displayName: "Kimi K2", resolve: "moonshotai/kimi-k2.5", recommended: true },
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "moonshotai/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
preferred: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
opencode: provider({
|
||||
@@ -109,21 +166,74 @@ export const providers = {
|
||||
"big-pickle": {
|
||||
displayName: "Big Pickle",
|
||||
resolve: "opencode/big-pickle",
|
||||
recommended: true,
|
||||
preferred: true,
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
},
|
||||
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
|
||||
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
|
||||
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
|
||||
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
|
||||
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
|
||||
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
|
||||
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
|
||||
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" },
|
||||
"mimo-v2-flash-free": {
|
||||
displayName: "MiMo V2 Flash",
|
||||
resolve: "opencode/mimo-v2-flash-free",
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "opencode/claude-opus-4-6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
},
|
||||
"claude-sonnet": {
|
||||
displayName: "Claude Sonnet",
|
||||
resolve: "opencode/claude-sonnet-4-6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
"claude-haiku": {
|
||||
displayName: "Claude Haiku",
|
||||
resolve: "opencode/claude-haiku-4-5",
|
||||
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
|
||||
},
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "opencode/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
|
||||
},
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "opencode/gpt-5.1-codex-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
},
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
resolve: "opencode/gemini-3.1-pro",
|
||||
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"gemini-flash": {
|
||||
displayName: "Gemini Flash",
|
||||
resolve: "opencode/gemini-3-flash",
|
||||
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
|
||||
},
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "opencode/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
},
|
||||
"gpt-5-nano": {
|
||||
displayName: "GPT Nano",
|
||||
resolve: "opencode/gpt-5-nano",
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
},
|
||||
"mimo-v2-pro-free": {
|
||||
displayName: "MiMo V2 Pro",
|
||||
resolve: "opencode/mimo-v2-pro-free",
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
},
|
||||
"minimax-m2.5-free": {
|
||||
displayName: "MiniMax M2.5",
|
||||
resolve: "opencode/minimax-m2.5-free",
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
},
|
||||
"nemotron-3-super-free": {
|
||||
displayName: "Nemotron 3 Super",
|
||||
resolve: "opencode/nemotron-3-super-free",
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
},
|
||||
"minimax-m2.5-free": { displayName: "MiniMax M2.5", resolve: "opencode/minimax-m2.5-free" },
|
||||
},
|
||||
}),
|
||||
openrouter: provider({
|
||||
@@ -133,35 +243,59 @@ export const providers = {
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
recommended: true,
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
preferred: true,
|
||||
},
|
||||
"claude-sonnet": {
|
||||
displayName: "Claude Sonnet",
|
||||
resolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
"claude-haiku": {
|
||||
displayName: "Claude Haiku",
|
||||
resolve: "openrouter/anthropic/claude-haiku-4.5",
|
||||
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
|
||||
},
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "openrouter/openai/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
|
||||
},
|
||||
"gpt-codex": { displayName: "GPT Codex", resolve: "openrouter/openai/gpt-5.3-codex" },
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
},
|
||||
"o4-mini": {
|
||||
displayName: "O4 Mini",
|
||||
resolve: "openrouter/openai/o4-mini",
|
||||
openRouterResolve: "openrouter/openai/o4-mini",
|
||||
},
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
resolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||
},
|
||||
"gemini-flash": {
|
||||
displayName: "Gemini Flash",
|
||||
resolve: "openrouter/google/gemini-3-flash-preview",
|
||||
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
|
||||
},
|
||||
grok: {
|
||||
displayName: "Grok",
|
||||
resolve: "openrouter/x-ai/grok-4",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4",
|
||||
},
|
||||
grok: { displayName: "Grok", resolve: "openrouter/x-ai/grok-4" },
|
||||
"deepseek-chat": {
|
||||
displayName: "DeepSeek Chat",
|
||||
resolve: "openrouter/deepseek/deepseek-chat-v3.1",
|
||||
resolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
},
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
},
|
||||
"kimi-k2": { displayName: "Kimi K2", resolve: "openrouter/moonshotai/kimi-k2.5" },
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, ProviderConfig>;
|
||||
@@ -182,9 +316,24 @@ export function getModelProvider(slug: string): string {
|
||||
return parseModel(slug).provider;
|
||||
}
|
||||
|
||||
export function getProviderDisplayName(slug: string): string | undefined {
|
||||
const parsed = parseModel(slug);
|
||||
return (providers as Record<string, ProviderConfig>)[parsed.provider]?.displayName;
|
||||
}
|
||||
|
||||
export function getModelEnvVars(slug: string): string[] {
|
||||
const p = getModelProvider(slug);
|
||||
return (providers as Record<string, ProviderConfig>)[p]?.envVars.slice() ?? [];
|
||||
const parsed = parseModel(slug);
|
||||
const providerConfig = (providers as Record<string, ProviderConfig>)[parsed.provider];
|
||||
if (!providerConfig) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const modelConfig = providerConfig.models[parsed.model];
|
||||
if (modelConfig?.envVars) {
|
||||
return modelConfig.envVars.slice();
|
||||
}
|
||||
|
||||
return providerConfig.envVars.slice();
|
||||
}
|
||||
|
||||
// ── derived flat list ──────────────────────────────────────────────────────────
|
||||
@@ -196,7 +345,9 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
|
||||
provider: providerKey,
|
||||
displayName: def.displayName,
|
||||
resolve: def.resolve,
|
||||
recommended: def.recommended ?? false,
|
||||
openRouterResolve: def.openRouterResolve,
|
||||
preferred: def.preferred ?? false,
|
||||
isFree: def.isFree ?? false,
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ModeSchema = type({
|
||||
prompt: "string",
|
||||
});
|
||||
|
||||
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress — it will update the same comment. Never create additional comments manually.`;
|
||||
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share your **final** results in 1-3 sentences. The completed task list is automatically preserved in a collapsible section below your summary — do not repeat individual steps in the summary. Focus on the outcome and link to any artifacts (PRs, branches). Never create additional comments manually.`;
|
||||
|
||||
const dependencyInstallationStep = `If this task will require running tests, builds, linters, or CLI commands that need installed packages, call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW. This is non-blocking and allows dependencies to install in the background while you continue. Later, call \`${ghPullfrogMcpName}/await_dependency_installation\` before running commands that need them. Skip this step if only reading code or answering questions.`;
|
||||
|
||||
@@ -47,14 +47,12 @@ export function computeModes(): Mode[] {
|
||||
|
||||
7. **COMMIT** - Commit your changes using \`${ghPullfrogMcpName}/git\` (e.g., \`git add .\` then \`git commit -m "message"\`), then push with \`${ghPullfrogMcpName}/push_branch\`. Do NOT use \`git push\` directly - it requires credentials that only the MCP tool provides.
|
||||
|
||||
8. **PROGRESS** - ${reportProgressInstruction}
|
||||
|
||||
9. **PR** - Determine whether to create a PR (if not already on a PR branch):
|
||||
8. **PR** - Determine whether to create a PR (if not already on a PR branch):
|
||||
- **Default behavior**: Create a PR using ${ghPullfrogMcpName}/create_pull_request with an informative title and body. If you are working in the context of an issue (check EVENT DATA for \`issue_number\` where \`is_pr\` is not true), include "Closes #<issue_number>" in the PR body to auto-close the issue when merged.
|
||||
- **Draft PR request**: If the user explicitly asks for a draft PR (e.g. "draft PR", "create as draft", "WIP"), create a PR with \`draft: true\`.
|
||||
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
|
||||
|
||||
10. **FINAL REPORT** - Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes:
|
||||
9. **FINAL REPORT** - ${reportProgressInstruction} Ensure the summary includes:
|
||||
- A summary of what was accomplished
|
||||
- Links to any artifacts created (PRs, branches, issues)
|
||||
- If you created a PR, ALWAYS include the PR link. e.g.:
|
||||
@@ -66,7 +64,6 @@ export function computeModes(): Mode[] {
|
||||
[\`pullfrog/branch-name\`](https://github.com/pullfrog/scratch/tree/pullfrog/branch-name) • [Create PR ➔](https://github.com/pullfrog/scratch/compare/main...pullfrog/branch-name?quick_pull=1&title=<informative_title>&body=<informative_body>)
|
||||
\`\`\`
|
||||
|
||||
Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task. Please review the PR." If your previous report_progress call already contains all the necessary information and links, skip the final call entirely.
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -120,14 +117,13 @@ Keep the progress comment extremely brief. The summary should be 1-2 sentences m
|
||||
- **Impact analysis**: Identify what was removed, renamed, or deprecated in the PR. Use grep to search the broader codebase for remaining references to those things in code, tests, docs, comments, and configs. Report stale references in the review body.
|
||||
- Do NOT stop at "this looks reasonable." Dig until you either find a problem or have concrete evidence there isn't one.
|
||||
|
||||
4. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable: the author should need to change something in response. 2-3 sentences max. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 5. NO COMPLIMENTS. NO NITPICKING ABOUT CHANGES UNRELATED TO THE MAIN CHANGE. Non-actionable comments (praise, style preferences, minor optimizatfixons, documentation nits) must not be drafted.
|
||||
4. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable: the author should need to change something in response. 2-3 sentences max. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 5. NO COMPLIMENTS. NO NITPICKING ABOUT CHANGES UNRELATED TO THE MAIN CHANGE. Non-actionable comments (praise, style preferences, minor optimizations, documentation nits) must not be drafted.
|
||||
|
||||
5. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. If issues were found, include urgency level and any concerns about code outside the diff. If no issues were found, write a brief approval summary (e.g., "Changes look good. No issues found.").
|
||||
|
||||
6. **SUBMIT** — Always submit a review via ${ghPullfrogMcpName}/create_pull_request_review:
|
||||
- \`body\`: The summary from step 5
|
||||
- \`comments\`: The inline comments from step 4
|
||||
- \`approved\`: Set to \`true\` ONLY if the review contains no actionable feedback — neither inline comments nor actionable content in the body. An approval signals "no changes needed."
|
||||
6. **SUBMIT** — Determine whether to submit a review:
|
||||
- **Issues found**: Submit via ${ghPullfrogMcpName}/create_pull_request_review with the summary body from step 5, the inline comments from step 4, and \`approved: false\`. Then call \`report_progress\` with a 1-sentence summary (e.g., "Reviewed — found 3 issues.").
|
||||
- **No issues found**: Do NOT submit a review. Call \`${ghPullfrogMcpName}/report_progress\` with a brief note (e.g., "Reviewed — no issues found.").
|
||||
|
||||
${permalinkTip}
|
||||
`,
|
||||
@@ -138,12 +134,9 @@ ${permalinkTip}
|
||||
"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.
|
||||
|
||||
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:
|
||||
\`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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -159,12 +152,10 @@ ${permalinkTip}
|
||||
|
||||
6. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable. 2-3 sentences max. Use the NEW line number from the full PR diff. NO COMPLIMENTS. NO NITPICKING.
|
||||
|
||||
7. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. Focus on what changed since the last review and whether the new changes are sound. If issues were found, include urgency level. If no issues were found, write a brief approval summary.
|
||||
|
||||
8. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
|
||||
- \`body\`: The summary from step 7
|
||||
- \`comments\`: The inline comments from step 6
|
||||
- \`approved\`: Set to \`true\` ONLY if the review contains no actionable feedback — neither inline comments nor actionable content in the body. An approval signals "no changes needed."
|
||||
7. **SUBMIT** — Determine whether to submit a review:
|
||||
- **Issues found**: Submit via ${ghPullfrogMcpName}/create_pull_request_review with \`approved: false\`, the inline comments from step 6, and an **empty body** — inline comments speak for themselves, and a top-level body clutters the PR conversation on every re-review cycle. Then call \`report_progress\` with a 1-sentence summary (e.g., "Re-reviewed — found 2 issues in the new commits.").
|
||||
- **No issues, 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}
|
||||
`,
|
||||
@@ -308,9 +299,25 @@ Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in
|
||||
- **Draft PR request**: If the user explicitly asks for a draft PR (e.g. "draft PR", "create as draft", "WIP"), create a PR with \`draft: true\`.
|
||||
- **Branch-only request**: If the user explicitly asks for a branch without a PR (e.g. "don't create a PR", "branch only", "just create a branch"), do NOT create a PR. Simply push the branch and report the branch link.
|
||||
|
||||
5. **PROGRESS** - ${reportProgressInstruction}`,
|
||||
},
|
||||
{
|
||||
name: "Summarize",
|
||||
description:
|
||||
"Summarize a PR with a structured comment that is updated in place on subsequent pushes",
|
||||
prompt: `Follow these steps.
|
||||
|
||||
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number to get PR metadata and diffPath.
|
||||
|
||||
2. **ANALYZE** - Read the diff file. Use the TOC to selectively read relevant sections — do not read the entire file unless the PR is small.
|
||||
|
||||
3. **SUMMARIZE** - Write a structured summary following the format from EVENT INSTRUCTIONS. If no format instructions are provided, produce a concise summary with a TL;DR, key changes list, and per-change sections with human-readable \`##\` titles and before/after framing.
|
||||
|
||||
4. **POST** - Call ${ghPullfrogMcpName}/create_issue_comment with type: 'Summary' and the summary body.
|
||||
|
||||
5. **PROGRESS** - ${reportProgressInstruction}
|
||||
|
||||
Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task." If your previous report_progress call already contains all the necessary information and links, skip the final call entirely.`,
|
||||
${permalinkTip}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/pullfrog",
|
||||
"version": "0.0.179",
|
||||
"version": "0.0.185",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
@@ -31,7 +31,6 @@
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"@opencode-ai/sdk": "^1.0.143",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"@toon-format/toon": "^1.0.0",
|
||||
"ajv": "^8.18.0",
|
||||
@@ -47,6 +46,8 @@
|
||||
"turndown": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.85",
|
||||
"agent-browser": "0.21.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
@@ -54,6 +55,7 @@
|
||||
"arg": "^5.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"husky": "^9.0.0",
|
||||
"opencode-ai": "1.1.56",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.17",
|
||||
"yaml": "^2.8.2"
|
||||
|
||||
Generated
+304
-15
@@ -26,9 +26,6 @@ importers:
|
||||
'@octokit/webhooks-types':
|
||||
specifier: ^7.6.1
|
||||
version: 7.6.1
|
||||
'@opencode-ai/sdk':
|
||||
specifier: ^1.0.143
|
||||
version: 1.0.143
|
||||
'@standard-schema/spec':
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
@@ -69,6 +66,9 @@ importers:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.2
|
||||
devDependencies:
|
||||
'@anthropic-ai/claude-code':
|
||||
specifier: 2.1.85
|
||||
version: 2.1.85
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.26.0
|
||||
version: 1.26.0(zod@4.3.6)
|
||||
@@ -81,6 +81,9 @@ importers:
|
||||
'@types/turndown':
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.6
|
||||
agent-browser:
|
||||
specifier: 0.21.0
|
||||
version: 0.21.0
|
||||
arg:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
@@ -90,12 +93,15 @@ importers:
|
||||
husky:
|
||||
specifier: ^9.0.0
|
||||
version: 9.1.7
|
||||
opencode-ai:
|
||||
specifier: 1.1.56
|
||||
version: 1.1.56
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(@types/node@24.7.2)(yaml@2.8.2)
|
||||
version: 4.0.17(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
@@ -114,6 +120,11 @@ packages:
|
||||
'@actions/io@1.1.3':
|
||||
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
|
||||
|
||||
'@anthropic-ai/claude-code@2.1.85':
|
||||
resolution: {integrity: sha512-3/q3xTpk9EnBfQ/XsHGkOZniOgQx4sqD95CDKw1mvN1Qw5+9IZTp6ILdds02d7vOM6YuLL0G0zhqsMSAFVse4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@ark/fs@0.56.0':
|
||||
resolution: {integrity: sha512-zY/wDDhcvmt6/upQwZM766PAnvIzdEMcgydUGd9pqY9FMGNo9I9uE4RYAfms9AeUUtbZJu2h2Ua0tvFsO5XF4Q==}
|
||||
|
||||
@@ -454,6 +465,95 @@ packages:
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
@@ -547,9 +647,6 @@ packages:
|
||||
'@octokit/webhooks-types@7.6.1':
|
||||
resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==}
|
||||
|
||||
'@opencode-ai/sdk@1.0.143':
|
||||
resolution: {integrity: sha512-dtmkBfJ7IIAHzL6KCzAlwc9GybfJONVeCsF6ePYySpkuhslDbRkZBJYb5vqGd1H5zdsgjc6JjuvmOf0rPWUL6A==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.55.1':
|
||||
resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==}
|
||||
cpu: [arm]
|
||||
@@ -753,6 +850,10 @@ packages:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
agent-browser@0.21.0:
|
||||
resolution: {integrity: sha512-isVHEeb2WL5hLhr4o+zNmcYwmBrldxvrH+FIoRoUmDxyrHr3bhIS6L8BlUMHqT77YtkPq0YSmwoBRrwqeouw9Q==}
|
||||
hasBin: true
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
@@ -1209,6 +1310,10 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
@@ -1316,6 +1421,65 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
opencode-ai@1.1.56:
|
||||
resolution: {integrity: sha512-OAF0G/1jVXpOrCh++M5gFEZ0bRLiXOnbxSYMFx5TOoD0OhCjHJS1JlARzaLDAx461qBnn+jocI9BBxD0wwFH3Q==}
|
||||
hasBin: true
|
||||
|
||||
opencode-darwin-arm64@1.1.56:
|
||||
resolution: {integrity: sha512-0HqvLm7tcYZr4VJgEzi3Wicia5M9yCX66O7Cv470Qu4+GGbCC2sTTmzQu6pCehSleYgkZsiSBNRcFcW/6F7v0Q==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
opencode-darwin-x64-baseline@1.1.56:
|
||||
resolution: {integrity: sha512-Z9QO9cTC9TnlUxTfEtDbvZbRFxc5Je8rs2Ei0cLH46W9gHGhh1fW4oX64tcrlhF5NUDKJzr/qwdoMfmKkeu53A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
opencode-darwin-x64@1.1.56:
|
||||
resolution: {integrity: sha512-8ZhNd4sFbDviT1OJs42C35Bx2/z6mkcb7uNhPHX3KKm27KOm7cYjFQa1UTF5or3ZpjkjPQpR/cpo7TELvnnmRA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
opencode-linux-arm64-musl@1.1.56:
|
||||
resolution: {integrity: sha512-f30SmYX4xE2fUsnNl66dDX++8iTMI9PLXz7BRHhgXL4XAdMpUUJbOKIsw3ZUB0KWHwjR8cjkEJQOZxR3HPbrZg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-arm64@1.1.56:
|
||||
resolution: {integrity: sha512-wEmIEXiEKghurQYgKY5yFUFOmnT/QIlAbYnVeH73gSqOwlYFUUlowXLDJnF+3OGd6m000qalhCxjExL7qKINWw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-x64-baseline-musl@1.1.56:
|
||||
resolution: {integrity: sha512-EC8EGTJCgTZgGIMZdQjOKGXw02+igo0am1Ry6wrPrB0li7XBlYbY6dz8tL3FlknRVMLamtGtb3tXQ6tqbwjX0g==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-x64-baseline@1.1.56:
|
||||
resolution: {integrity: sha512-jKOvhkNLcn0h6zmKX+hDdkODijSVDxjAGTZp8BPbJCCJVxnKll9dYfDMTqxi3YN6yXp6sBvyaJT1mcpf8knkeA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-x64-musl@1.1.56:
|
||||
resolution: {integrity: sha512-9BFYBPgpY2RrUd7/Ul0VNh+6B9l0FmCNRUV4yYmeFGee9ZDgPem5YGicsKAMQqS/5X0jW3ZWje+KBbBQz4RMwQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-x64@1.1.56:
|
||||
resolution: {integrity: sha512-0FP3BzLjn+a9naTtm9hpHibJ+eV1RkI+tItcDQXKwHtvzoBUvytDRP8v6TotEHNtNF0ZLAb3OGurwDIIdtLarg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
opencode-windows-x64-baseline@1.1.56:
|
||||
resolution: {integrity: sha512-zEokdohfoDjWzwULvlSq0Y/tRoVmz2/6GofYO936buPQf09cBcPqpAgLUjZV+pQI0Atyd05YG3BCO4PQxmXzdw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
opencode-windows-x64@1.1.56:
|
||||
resolution: {integrity: sha512-zm/oaWT5uGrW3DumKRHiqv2L2pKwrTvhsT4XUSfIPLQn5EvrYNU6bh3WFO4v71ZBjSIKx5Q7rEvTEQvFiFEZFQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
|
||||
@@ -1760,6 +1924,18 @@ snapshots:
|
||||
|
||||
'@actions/io@1.1.3': {}
|
||||
|
||||
'@anthropic-ai/claude-code@2.1.85':
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
'@img/sharp-linux-arm': 0.34.5
|
||||
'@img/sharp-linux-arm64': 0.34.5
|
||||
'@img/sharp-linux-x64': 0.34.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
|
||||
'@ark/fs@0.56.0': {}
|
||||
|
||||
'@ark/schema@0.56.0':
|
||||
@@ -1936,6 +2112,68 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.12.0
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@mixmark-io/domino@2.2.0': {}
|
||||
@@ -2060,8 +2298,6 @@ snapshots:
|
||||
|
||||
'@octokit/webhooks-types@7.6.1': {}
|
||||
|
||||
'@opencode-ai/sdk@1.0.143': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.55.1':
|
||||
optional: true
|
||||
|
||||
@@ -2182,13 +2418,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.17
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.7.2)(yaml@2.8.2)
|
||||
vite: 7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@4.0.17':
|
||||
dependencies:
|
||||
@@ -2222,6 +2458,8 @@ snapshots:
|
||||
mime-types: 3.0.2
|
||||
negotiator: 1.0.0
|
||||
|
||||
agent-browser@0.21.0: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
@@ -2752,6 +2990,9 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jiti@2.6.1:
|
||||
optional: true
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
jose@6.2.0: {}
|
||||
@@ -2855,6 +3096,53 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
opencode-ai@1.1.56:
|
||||
optionalDependencies:
|
||||
opencode-darwin-arm64: 1.1.56
|
||||
opencode-darwin-x64: 1.1.56
|
||||
opencode-darwin-x64-baseline: 1.1.56
|
||||
opencode-linux-arm64: 1.1.56
|
||||
opencode-linux-arm64-musl: 1.1.56
|
||||
opencode-linux-x64: 1.1.56
|
||||
opencode-linux-x64-baseline: 1.1.56
|
||||
opencode-linux-x64-baseline-musl: 1.1.56
|
||||
opencode-linux-x64-musl: 1.1.56
|
||||
opencode-windows-x64: 1.1.56
|
||||
opencode-windows-x64-baseline: 1.1.56
|
||||
|
||||
opencode-darwin-arm64@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-darwin-x64-baseline@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-darwin-x64@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-linux-arm64-musl@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-linux-arm64@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-linux-x64-baseline-musl@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-linux-x64-baseline@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-linux-x64-musl@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-linux-x64@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-windows-x64-baseline@1.1.56:
|
||||
optional: true
|
||||
|
||||
opencode-windows-x64@1.1.56:
|
||||
optional: true
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
parse-ms@4.0.0: {}
|
||||
@@ -3159,7 +3447,7 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2):
|
||||
vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -3170,12 +3458,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 24.7.2
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@4.0.17(@types/node@24.7.2)(yaml@2.8.2):
|
||||
vitest@4.0.17(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.17
|
||||
'@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.17
|
||||
'@vitest/runner': 4.0.17
|
||||
'@vitest/snapshot': 4.0.17
|
||||
@@ -3192,7 +3481,7 @@ snapshots:
|
||||
tinyexec: 1.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 7.3.1(@types/node@24.7.2)(yaml@2.8.2)
|
||||
vite: 7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.7.2
|
||||
|
||||
@@ -37509,9 +37509,282 @@ function getApiUrl() {
|
||||
return raw;
|
||||
}
|
||||
|
||||
// models.ts
|
||||
function provider(config) {
|
||||
return config;
|
||||
}
|
||||
var providers = {
|
||||
anthropic: provider({
|
||||
displayName: "Anthropic",
|
||||
envVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
||||
models: {
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "anthropic/claude-opus-4-6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
preferred: true
|
||||
},
|
||||
"claude-sonnet": {
|
||||
displayName: "Claude Sonnet",
|
||||
resolve: "anthropic/claude-sonnet-4-6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
|
||||
},
|
||||
"claude-haiku": {
|
||||
displayName: "Claude Haiku",
|
||||
resolve: "anthropic/claude-haiku-4-5",
|
||||
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
|
||||
}
|
||||
}
|
||||
}),
|
||||
openai: provider({
|
||||
displayName: "OpenAI",
|
||||
envVars: ["OPENAI_API_KEY"],
|
||||
models: {
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "openai/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
|
||||
preferred: true
|
||||
},
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "openai/codex-mini-latest",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
|
||||
},
|
||||
o3: {
|
||||
displayName: "O3",
|
||||
resolve: "openai/o3"
|
||||
}
|
||||
}
|
||||
}),
|
||||
google: provider({
|
||||
displayName: "Google",
|
||||
envVars: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
models: {
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
resolve: "google/gemini-3.1-pro-preview",
|
||||
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||
preferred: true
|
||||
},
|
||||
"gemini-flash": {
|
||||
displayName: "Gemini Flash",
|
||||
resolve: "google/gemini-3-flash-preview",
|
||||
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
|
||||
}
|
||||
}
|
||||
}),
|
||||
xai: provider({
|
||||
displayName: "xAI",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
models: {
|
||||
grok: {
|
||||
displayName: "Grok",
|
||||
resolve: "xai/grok-4",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4",
|
||||
preferred: true
|
||||
},
|
||||
"grok-fast": {
|
||||
displayName: "Grok Fast",
|
||||
resolve: "xai/grok-4-fast",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4-fast"
|
||||
},
|
||||
"grok-code-fast": {
|
||||
displayName: "Grok Code Fast",
|
||||
resolve: "xai/grok-code-fast-1",
|
||||
openRouterResolve: "openrouter/x-ai/grok-code-fast-1"
|
||||
}
|
||||
}
|
||||
}),
|
||||
deepseek: provider({
|
||||
displayName: "DeepSeek",
|
||||
envVars: ["DEEPSEEK_API_KEY"],
|
||||
models: {
|
||||
"deepseek-reasoner": {
|
||||
displayName: "DeepSeek Reasoner",
|
||||
resolve: "deepseek/deepseek-reasoner",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
preferred: true
|
||||
},
|
||||
"deepseek-chat": {
|
||||
displayName: "DeepSeek Chat",
|
||||
resolve: "deepseek/deepseek-chat",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2"
|
||||
}
|
||||
}
|
||||
}),
|
||||
moonshotai: provider({
|
||||
displayName: "Moonshot AI",
|
||||
envVars: ["MOONSHOT_API_KEY"],
|
||||
models: {
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "moonshotai/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
preferred: true
|
||||
}
|
||||
}
|
||||
}),
|
||||
opencode: provider({
|
||||
displayName: "OpenCode",
|
||||
envVars: ["OPENCODE_API_KEY"],
|
||||
models: {
|
||||
"big-pickle": {
|
||||
displayName: "Big Pickle",
|
||||
resolve: "opencode/big-pickle",
|
||||
preferred: true,
|
||||
envVars: [],
|
||||
isFree: true
|
||||
},
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "opencode/claude-opus-4-6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6"
|
||||
},
|
||||
"claude-sonnet": {
|
||||
displayName: "Claude Sonnet",
|
||||
resolve: "opencode/claude-sonnet-4-6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
|
||||
},
|
||||
"claude-haiku": {
|
||||
displayName: "Claude Haiku",
|
||||
resolve: "opencode/claude-haiku-4-5",
|
||||
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
|
||||
},
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "opencode/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex"
|
||||
},
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "opencode/gpt-5.1-codex-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
|
||||
},
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
resolve: "opencode/gemini-3.1-pro",
|
||||
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview"
|
||||
},
|
||||
"gemini-flash": {
|
||||
displayName: "Gemini Flash",
|
||||
resolve: "opencode/gemini-3-flash",
|
||||
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
|
||||
},
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "opencode/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5"
|
||||
},
|
||||
"gpt-5-nano": {
|
||||
displayName: "GPT Nano",
|
||||
resolve: "opencode/gpt-5-nano",
|
||||
envVars: [],
|
||||
isFree: true
|
||||
},
|
||||
"mimo-v2-pro-free": {
|
||||
displayName: "MiMo V2 Pro",
|
||||
resolve: "opencode/mimo-v2-pro-free",
|
||||
envVars: [],
|
||||
isFree: true
|
||||
},
|
||||
"minimax-m2.5-free": {
|
||||
displayName: "MiniMax M2.5",
|
||||
resolve: "opencode/minimax-m2.5-free",
|
||||
envVars: [],
|
||||
isFree: true
|
||||
},
|
||||
"nemotron-3-super-free": {
|
||||
displayName: "Nemotron 3 Super",
|
||||
resolve: "opencode/nemotron-3-super-free",
|
||||
envVars: [],
|
||||
isFree: true
|
||||
}
|
||||
}
|
||||
}),
|
||||
openrouter: provider({
|
||||
displayName: "OpenRouter",
|
||||
envVars: ["OPENROUTER_API_KEY"],
|
||||
models: {
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
preferred: true
|
||||
},
|
||||
"claude-sonnet": {
|
||||
displayName: "Claude Sonnet",
|
||||
resolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
|
||||
},
|
||||
"claude-haiku": {
|
||||
displayName: "Claude Haiku",
|
||||
resolve: "openrouter/anthropic/claude-haiku-4.5",
|
||||
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
|
||||
},
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "openrouter/openai/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex"
|
||||
},
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
|
||||
},
|
||||
"o4-mini": {
|
||||
displayName: "O4 Mini",
|
||||
resolve: "openrouter/openai/o4-mini",
|
||||
openRouterResolve: "openrouter/openai/o4-mini"
|
||||
},
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
resolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview"
|
||||
},
|
||||
"gemini-flash": {
|
||||
displayName: "Gemini Flash",
|
||||
resolve: "openrouter/google/gemini-3-flash-preview",
|
||||
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
|
||||
},
|
||||
grok: {
|
||||
displayName: "Grok",
|
||||
resolve: "openrouter/x-ai/grok-4",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4"
|
||||
},
|
||||
"deepseek-chat": {
|
||||
displayName: "DeepSeek Chat",
|
||||
resolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2"
|
||||
},
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5"
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
var modelAliases = Object.entries(providers).flatMap(
|
||||
([providerKey, config]) => Object.entries(config.models).map(([modelId, def]) => ({
|
||||
slug: `${providerKey}/${modelId}`,
|
||||
provider: providerKey,
|
||||
displayName: def.displayName,
|
||||
resolve: def.resolve,
|
||||
openRouterResolve: def.openRouterResolve,
|
||||
preferred: def.preferred ?? false,
|
||||
isFree: def.isFree ?? false
|
||||
}))
|
||||
);
|
||||
|
||||
// utils/buildPullfrogFooter.ts
|
||||
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
||||
function formatModelLabel(slug) {
|
||||
const alias = modelAliases.find((a) => a.slug === slug);
|
||||
if (!alias) return `\`${slug}\``;
|
||||
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
|
||||
}
|
||||
function buildPullfrogFooter(params) {
|
||||
const parts = [];
|
||||
if (params.customParts) {
|
||||
@@ -37527,12 +37800,12 @@ function buildPullfrogFooter(params) {
|
||||
if (params.triggeredBy) {
|
||||
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
||||
}
|
||||
const allParts = [
|
||||
...parts,
|
||||
"[pullfrog.com](https://pullfrog.com)",
|
||||
"[\u{1D54F}](https://x.com/pullfrogai)"
|
||||
];
|
||||
if (params.model) {
|
||||
parts.push(`Using ${formatModelLabel(params.model)}`);
|
||||
}
|
||||
const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
|
||||
return `
|
||||
|
||||
${PULLFROG_DIVIDER}
|
||||
<sup>${FROG_LOGO} \uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
|
||||
}
|
||||
@@ -41256,8 +41529,8 @@ var LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
||||
var Comment = type({
|
||||
issueNumber: type.number.describe("the issue number to comment on"),
|
||||
body: type.string.describe("the comment body content"),
|
||||
type: type.enumerated("Plan", "Comment").describe(
|
||||
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
|
||||
type: type.enumerated("Plan", "Summary", "Comment").describe(
|
||||
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). Comment: regular comment (default)."
|
||||
).optional()
|
||||
});
|
||||
var EditComment = type({
|
||||
@@ -41284,7 +41557,7 @@ var core3 = __toESM(require_core(), 1);
|
||||
// package.json
|
||||
var package_default = {
|
||||
name: "@pullfrog/pullfrog",
|
||||
version: "0.0.179",
|
||||
version: "0.0.185",
|
||||
type: "module",
|
||||
files: [
|
||||
"index.js",
|
||||
@@ -41315,7 +41588,6 @@ var package_default = {
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"@opencode-ai/sdk": "^1.0.143",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"@toon-format/toon": "^1.0.0",
|
||||
ajv: "^8.18.0",
|
||||
@@ -41331,6 +41603,8 @@ var package_default = {
|
||||
turndown: "^7.2.0"
|
||||
},
|
||||
devDependencies: {
|
||||
"@anthropic-ai/claude-code": "2.1.85",
|
||||
"agent-browser": "0.21.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
@@ -41338,6 +41612,7 @@ var package_default = {
|
||||
arg: "^5.0.2",
|
||||
esbuild: "^0.25.9",
|
||||
husky: "^9.0.0",
|
||||
"opencode-ai": "1.1.56",
|
||||
typescript: "^5.9.3",
|
||||
vitest: "^4.0.17",
|
||||
yaml: "^2.8.2"
|
||||
@@ -41480,10 +41755,15 @@ async function validateStuckProgressComment(params) {
|
||||
repo: params.repo,
|
||||
comment_id: commentId
|
||||
});
|
||||
if (commentResult.data.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
const body = commentResult.data.body ?? "";
|
||||
if (body.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
}
|
||||
if (/^- \[[ x]\] |^- \*\*→\*\* |^- ~~/.test(body)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on a todo checklist`);
|
||||
return commentId;
|
||||
}
|
||||
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
|
||||
return null;
|
||||
} catch (error2) {
|
||||
|
||||
@@ -19,20 +19,20 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
|
||||
"releaseDate": "2026-01",
|
||||
},
|
||||
"openai": {
|
||||
"modelId": "gpt-5.4",
|
||||
"releaseDate": "2026-03-05",
|
||||
"modelId": "gpt-5.4-nano",
|
||||
"releaseDate": "2026-03-17",
|
||||
},
|
||||
"opencode": {
|
||||
"modelId": "nemotron-3-super-free",
|
||||
"releaseDate": "2026-03-11",
|
||||
"modelId": "qwen3.6-plus-free",
|
||||
"releaseDate": "2026-03-30",
|
||||
},
|
||||
"openrouter": {
|
||||
"modelId": "openrouter/hunter-alpha",
|
||||
"releaseDate": "2026-03-11",
|
||||
"modelId": "qwen/qwen3.6-plus-preview:free",
|
||||
"releaseDate": "2026-03-30",
|
||||
},
|
||||
"xai": {
|
||||
"modelId": "grok-4.20-experimental-beta-0304-reasoning",
|
||||
"releaseDate": "2026-03-04",
|
||||
"modelId": "grok-4.20-multi-agent-0309",
|
||||
"releaseDate": "2026-03-09",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents)
|
||||
const expectedAgentEnvVars = [
|
||||
"GITHUB_TOKEN",
|
||||
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
|
||||
"OPENCODE_MODEL",
|
||||
"PULLFROG_MODEL",
|
||||
].sort();
|
||||
|
||||
const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort();
|
||||
@@ -114,7 +114,7 @@ describe("ci workflow consistency", () => {
|
||||
|
||||
it("changed-agents.sh treats legacy agent files as non-agent changes", () => {
|
||||
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
|
||||
input: JSON.stringify(["action/agents/claude.ts", "action/agents/gemini.ts"]),
|
||||
input: JSON.stringify(["action/agents/codex.ts", "action/agents/gemini.ts"]),
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(JSON.parse(output)).toEqual(["opentoad"]);
|
||||
|
||||
+86
-2
@@ -46,6 +46,82 @@ describe("models.dev validity", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── openRouterResolve coverage ─────────────────────────────────────────────────
|
||||
|
||||
// models that have no OpenRouter equivalent and require BYOK.
|
||||
// add a model here ONLY when it genuinely doesn't exist on both models.dev and OpenRouter.
|
||||
const BYOK_ONLY_MODELS = new Set(["openai/o3"]);
|
||||
|
||||
describe("openRouterResolve completeness", () => {
|
||||
for (const alias of modelAliases) {
|
||||
if (alias.isFree) continue;
|
||||
if (BYOK_ONLY_MODELS.has(alias.slug)) continue;
|
||||
it(`${alias.slug} has openRouterResolve`, () => {
|
||||
expect(
|
||||
alias.openRouterResolve,
|
||||
`non-free model "${alias.slug}" is missing openRouterResolve — add it or add to BYOK_ONLY_MODELS`
|
||||
).toBeDefined();
|
||||
});
|
||||
}
|
||||
|
||||
for (const alias of modelAliases) {
|
||||
if (!alias.isFree) continue;
|
||||
it(`${alias.slug} (free) does not need openRouterResolve`, () => {
|
||||
expect(alias.openRouterResolve).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("openRouterResolve models.dev validity", async () => {
|
||||
const data = await api;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const alias of modelAliases) {
|
||||
if (!alias.openRouterResolve) continue;
|
||||
if (seen.has(alias.openRouterResolve)) continue;
|
||||
seen.add(alias.openRouterResolve);
|
||||
|
||||
const parsed = parseResolve(alias.openRouterResolve);
|
||||
|
||||
it(`${alias.openRouterResolve} exists on models.dev`, () => {
|
||||
const providerData = data[parsed.provider];
|
||||
expect(providerData, `provider "${parsed.provider}" not found on models.dev`).toBeDefined();
|
||||
const model = providerData.models[parsed.modelId];
|
||||
expect(
|
||||
model,
|
||||
`model "${parsed.modelId}" not found under ${parsed.provider} on models.dev`
|
||||
).toBeDefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type OpenRouterModel = { id: string };
|
||||
type OpenRouterModelsResponse = { data: OpenRouterModel[] };
|
||||
|
||||
const openRouterApi = fetch("https://openrouter.ai/api/v1/models").then(
|
||||
(r) => r.json() as Promise<OpenRouterModelsResponse>
|
||||
);
|
||||
|
||||
describe("openRouterResolve OpenRouter API validity", async () => {
|
||||
const orData = await openRouterApi;
|
||||
const orModelIds = new Set(orData.data.map((m) => m.id));
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const alias of modelAliases) {
|
||||
if (!alias.openRouterResolve) continue;
|
||||
const orModelId = alias.openRouterResolve.slice("openrouter/".length);
|
||||
if (seen.has(orModelId)) continue;
|
||||
seen.add(orModelId);
|
||||
|
||||
it(`${orModelId} exists on OpenRouter`, () => {
|
||||
expect(
|
||||
orModelIds.has(orModelId),
|
||||
`model "${orModelId}" not found in OpenRouter API (/api/v1/models)`
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("latest model per provider snapshot", async () => {
|
||||
const data = await api;
|
||||
const providerKeys = Object.keys(providers) as ModelProvider[];
|
||||
@@ -58,10 +134,16 @@ describe("latest model per provider snapshot", async () => {
|
||||
|
||||
let latest: { modelId: string; releaseDate: string } | undefined;
|
||||
for (const [modelId, model] of Object.entries(providerData.models)) {
|
||||
if (model.status === "deprecated") continue;
|
||||
// skip non-GA models so beta/nightly churn doesn't break the snapshot
|
||||
if (model.status) continue;
|
||||
const rd = model.release_date;
|
||||
if (!rd) continue;
|
||||
if (!latest || rd > latest.releaseDate) {
|
||||
// tiebreak by modelId for stable ordering when release dates match
|
||||
if (
|
||||
!latest ||
|
||||
rd > latest.releaseDate ||
|
||||
(rd === latest.releaseDate && modelId > latest.modelId)
|
||||
) {
|
||||
latest = { modelId, releaseDate: rd };
|
||||
}
|
||||
}
|
||||
@@ -70,6 +152,8 @@ describe("latest model per provider snapshot", async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// when this fails, a provider shipped a new model. check whether we need
|
||||
// to add or update an alias in models.ts before updating the snapshot.
|
||||
it("matches snapshot", () => {
|
||||
expect(latestByProvider).toMatchSnapshot();
|
||||
});
|
||||
|
||||
+1
-1
@@ -307,7 +307,7 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
||||
|
||||
// use anthropic sonnet to avoid google quota issues and gemini doom-looping
|
||||
if (ctx.agent === "opentoad") {
|
||||
env.OPENCODE_MODEL ??= "anthropic/claude-sonnet-4-5";
|
||||
env.PULLFROG_MODEL ??= "anthropic/claude-sonnet-4-5";
|
||||
}
|
||||
|
||||
// build file-based env vars for MCP servers that don't inherit parent env
|
||||
|
||||
@@ -216,10 +216,6 @@ export async function runAgentStreaming(options: RunStreamingOptions): Promise<A
|
||||
GITHUB_OUTPUT: githubOutputFile,
|
||||
};
|
||||
|
||||
// clear CI runner's GITHUB_TOKEN so ensureGitHubToken() mints a
|
||||
// properly scoped token for the target GITHUB_REPOSITORY via OIDC
|
||||
delete subEnv.GITHUB_TOKEN;
|
||||
|
||||
const child = spawn("node", ["play.ts", "--raw", JSON.stringify(fixture)], {
|
||||
cwd: actionDir,
|
||||
env: subEnv as Record<string, string>,
|
||||
|
||||
+36
-1
@@ -1,6 +1,41 @@
|
||||
import type { Agent } from "../agents/index.ts";
|
||||
import { agents } from "../agents/index.ts";
|
||||
import { getModelProvider } from "../models.ts";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export function resolveAgent(): Agent {
|
||||
function hasEnvVar(name: string): boolean {
|
||||
const val = process.env[name];
|
||||
return typeof val === "string" && val.length > 0;
|
||||
}
|
||||
|
||||
function hasClaudeCodeAuth(): boolean {
|
||||
return hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") || hasEnvVar("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
export function resolveAgent(ctx?: { model?: string | undefined }): Agent {
|
||||
// 1. explicit env var override (escape hatch)
|
||||
const envAgent = process.env.PULLFROG_AGENT?.trim();
|
||||
if (envAgent) {
|
||||
if (envAgent in agents) {
|
||||
log.info(`» agent: ${envAgent} (override via PULLFROG_AGENT)`);
|
||||
return agents[envAgent as keyof typeof agents];
|
||||
}
|
||||
log.warning(`» unknown PULLFROG_AGENT="${envAgent}" — falling through to auto-select`);
|
||||
}
|
||||
|
||||
// 2. if model is Anthropic and Claude Code credentials are available, use Claude Code
|
||||
if (ctx?.model) {
|
||||
try {
|
||||
const provider = getModelProvider(ctx.model);
|
||||
if (provider === "anthropic" && hasClaudeCodeAuth()) {
|
||||
log.info(`» agent: claude (auto-selected for ${ctx.model})`);
|
||||
return agents.claude;
|
||||
}
|
||||
} catch {
|
||||
// invalid model slug format — fall through
|
||||
}
|
||||
}
|
||||
|
||||
// 3. default: OpenCode (universal, supports all providers)
|
||||
return agents.opentoad;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { validateAgentApiKey } from "./apiKeys.ts";
|
||||
|
||||
const base = {
|
||||
agent: { name: "opentoad" },
|
||||
owner: "test-owner",
|
||||
name: "test-repo",
|
||||
};
|
||||
|
||||
const savedEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// strip all known provider keys so tests start clean
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.endsWith("_API_KEY") || key === "CLAUDE_CODE_OAUTH_TOKEN") delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...savedEnv };
|
||||
});
|
||||
|
||||
describe("validateAgentApiKey", () => {
|
||||
describe("free model (no keys required)", () => {
|
||||
it("passes with zero env keys", () => {
|
||||
expect(() => validateAgentApiKey({ ...base, model: "opencode/big-pickle" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes for other free opencode models", () => {
|
||||
for (const slug of [
|
||||
"opencode/gpt-5-nano",
|
||||
"opencode/mimo-v2-pro-free",
|
||||
"opencode/minimax-m2.5-free",
|
||||
"opencode/nemotron-3-super-free",
|
||||
]) {
|
||||
expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyed model", () => {
|
||||
it("passes when the required key is present", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "sk-test";
|
||||
expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when the required key is missing", () => {
|
||||
expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).toThrow(
|
||||
"no API key found"
|
||||
);
|
||||
});
|
||||
|
||||
it("passes for opencode keyed model with OPENCODE_API_KEY", () => {
|
||||
process.env.OPENCODE_API_KEY = "sk-test";
|
||||
expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws for opencode keyed model without OPENCODE_API_KEY", () => {
|
||||
expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).toThrow(
|
||||
"no API key found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no model (auto-select)", () => {
|
||||
it("passes when any known provider key is present", () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test";
|
||||
expect(() => validateAgentApiKey({ ...base, model: undefined })).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when no provider keys are present", () => {
|
||||
expect(() => validateAgentApiKey({ ...base, model: undefined })).toThrow("no API key found");
|
||||
});
|
||||
});
|
||||
});
|
||||
+28
-5
@@ -1,4 +1,4 @@
|
||||
import { providers } from "../models.ts";
|
||||
import { getModelEnvVars, providers } from "../models.ts";
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
|
||||
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
|
||||
@@ -20,18 +20,41 @@ to fix this, add the required secret to your GitHub repository:
|
||||
4. set the value to your API key
|
||||
5. click "Add secret"
|
||||
|
||||
configure your model at ${settingsUrl}`;
|
||||
configure your model at ${settingsUrl}
|
||||
|
||||
for full setup instructions, see https://docs.pullfrog.com/keys`;
|
||||
}
|
||||
|
||||
function hasEnvVar(name: string): boolean {
|
||||
const value = process.env[name];
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
/** check if the user has a BYOK key for the given model's provider (does not throw) */
|
||||
export function hasProviderKey(model: string): boolean {
|
||||
const requiredVars = getModelEnvVars(model);
|
||||
if (requiredVars.length === 0) return true;
|
||||
return requiredVars.some((v) => hasEnvVar(v));
|
||||
}
|
||||
|
||||
export function validateAgentApiKey(params: {
|
||||
agent: { name: string };
|
||||
model: string | undefined;
|
||||
owner: string;
|
||||
name: string;
|
||||
}): void {
|
||||
const hasAnyKey = Object.entries(process.env).some(
|
||||
([key, value]) => value && typeof value === "string" && knownApiKeys.has(key)
|
||||
);
|
||||
// if a specific model is configured, only check that model's required env vars
|
||||
if (params.model) {
|
||||
const requiredVars = getModelEnvVars(params.model);
|
||||
// free models have no required env vars — skip validation entirely
|
||||
if (requiredVars.length === 0) return;
|
||||
if (requiredVars.some((v) => hasEnvVar(v))) return;
|
||||
|
||||
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
||||
}
|
||||
|
||||
// no model configured — auto-select requires at least one known provider key
|
||||
const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k));
|
||||
if (!hasAnyKey) {
|
||||
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { filterEnv } from "./secrets.ts";
|
||||
import { getDevDependencyVersion } from "./version.ts";
|
||||
|
||||
// agent-browser already discovers chrome via `which` and the playwright cache as fallbacks,
|
||||
// so this list only needs to cover the GHA ubuntu-latest runner where we know the exact path.
|
||||
const CHROME_PATHS = ["/usr/bin/google-chrome-stable"];
|
||||
|
||||
let systemChromePath: string | undefined;
|
||||
|
||||
function findSystemChromePath(): string | undefined {
|
||||
if (typeof systemChromePath === "string") {
|
||||
// return cached result but normalize to undefined if empty
|
||||
return systemChromePath || undefined;
|
||||
}
|
||||
for (const p of CHROME_PATHS) {
|
||||
if (existsSync(p)) {
|
||||
systemChromePath = p;
|
||||
log.info(`found system chrome: ${p}`);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
// set to an empty string to indicate no system chrome found
|
||||
// and to avoid repeated lookups
|
||||
systemChromePath = "";
|
||||
log.info(`no system chrome found (checked: ${CHROME_PATHS.join(", ")})`);
|
||||
}
|
||||
|
||||
function buildEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = { ...filterEnv() };
|
||||
const chromePath = findSystemChromePath();
|
||||
if (chromePath) {
|
||||
env.AGENT_BROWSER_EXECUTABLE_PATH = chromePath;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* ensure the agent-browser daemon is running by issuing a real command.
|
||||
*
|
||||
* agent-browser is stateful — it manages a persistent browser process via a
|
||||
* daemon that communicates over a Unix socket. we start the daemon here,
|
||||
* outside of ShellTool, because ShellTool's child process lifecycle would
|
||||
* kill it between invocations and the daemon must survive across calls.
|
||||
*
|
||||
* despite ShellTool commands running inside unshare-sandboxed namespaces,
|
||||
* they can still reach this daemon because the Unix socket is discoverable
|
||||
* regardless of unshare's PID/mount isolation. starting the daemon in the
|
||||
* host namespace keeps it alive while sandboxed shells come and go.
|
||||
*
|
||||
* agent-browser auto-starts its daemon on the first CLI invocation and
|
||||
* keeps it alive via the socket for subsequent commands.
|
||||
* we run `open about:blank` as the seed command to trigger this.
|
||||
* idempotent — only runs once.
|
||||
*/
|
||||
export function ensureBrowserDaemon(toolState: ToolState): string | undefined {
|
||||
if (toolState.browserDaemon) {
|
||||
return toolState.browserDaemon.error;
|
||||
}
|
||||
|
||||
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
|
||||
log.info(`installing agent-browser@${agentBrowserVersion}...`);
|
||||
const install = spawnSync("npm", ["install", "-g", `agent-browser@${agentBrowserVersion}`], {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (install.status !== 0) {
|
||||
const error = `agent-browser install failed: ${(install.stderr || install.stdout || "unknown error").trim()}`;
|
||||
log.error(error);
|
||||
toolState.browserDaemon = { error };
|
||||
return error;
|
||||
}
|
||||
log.info("agent-browser installed");
|
||||
|
||||
let binDir: string;
|
||||
try {
|
||||
const binPath = execFileSync("which", ["agent-browser"], { encoding: "utf-8" }).trim();
|
||||
binDir = dirname(binPath);
|
||||
log.info(`agent-browser binary: ${binPath}`);
|
||||
} catch {
|
||||
const error = "agent-browser binary not found in PATH after install";
|
||||
log.error(error);
|
||||
toolState.browserDaemon = { error };
|
||||
return error;
|
||||
}
|
||||
|
||||
const env = buildEnv();
|
||||
|
||||
// `open about:blank` triggers daemon auto-start and returns once the daemon + browser are ready
|
||||
log.info("starting browser daemon...");
|
||||
const seed = spawnSync("agent-browser", ["open", "about:blank"], {
|
||||
env,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (seed.status !== 0) {
|
||||
const output = (seed.stderr || seed.stdout || "unknown error").trim();
|
||||
const error = `agent-browser open about:blank failed (exit=${seed.status}): ${output}`;
|
||||
log.error(error);
|
||||
toolState.browserDaemon = { error };
|
||||
return error;
|
||||
}
|
||||
log.debug(`seed command done (exit=0): ${(seed.stdout || "").trim()}`);
|
||||
|
||||
toolState.browserDaemon = { binDir };
|
||||
log.info("browser daemon ready");
|
||||
}
|
||||
|
||||
export function closeBrowserDaemon(toolState: ToolState): void {
|
||||
if (!toolState.browserDaemon?.binDir) {
|
||||
delete toolState.browserDaemon;
|
||||
return;
|
||||
}
|
||||
delete toolState.browserDaemon;
|
||||
|
||||
try {
|
||||
log.info("closing browser daemon...");
|
||||
spawnSync("agent-browser", ["close"], {
|
||||
env: filterEnv(),
|
||||
stdio: "pipe",
|
||||
timeout: 10_000,
|
||||
});
|
||||
log.info("browser daemon closed");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { modelAliases } from "../models.ts";
|
||||
|
||||
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||
|
||||
const FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
||||
@@ -18,13 +20,21 @@ export interface BuildPullfrogFooterParams {
|
||||
/** alternative: just pass a pre-built URL directly (for shortlinks etc.) */
|
||||
workflowRunUrl?: string | undefined;
|
||||
/** arbitrary custom parts (e.g., action links) */
|
||||
customParts?: string[];
|
||||
customParts?: string[] | undefined;
|
||||
/** model slug from payload (e.g., "anthropic/claude-opus"). shown in footer as "Using `Model Name`" */
|
||||
model?: string | undefined;
|
||||
}
|
||||
|
||||
function formatModelLabel(slug: string): string {
|
||||
const alias = modelAliases.find((a) => a.slug === slug);
|
||||
if (!alias) return `\`${slug}\``;
|
||||
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* build a pullfrog footer with configurable parts
|
||||
* always includes: frog logo at start, pullfrog.com link and X link at end
|
||||
* order: action links (customParts) > workflow run > attribution > reference links
|
||||
* always includes: frog logo at start and X link at end
|
||||
* order: action links (customParts) > workflow run > model > attribution > reference links
|
||||
*/
|
||||
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
|
||||
const parts: string[] = [];
|
||||
@@ -45,15 +55,13 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
|
||||
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
||||
}
|
||||
|
||||
const allParts = [
|
||||
...parts,
|
||||
"[pullfrog.com](https://pullfrog.com)",
|
||||
"[𝕏](https://x.com/pullfrogai)",
|
||||
];
|
||||
if (params.model) {
|
||||
parts.push(`Using ${formatModelLabel(params.model)}`);
|
||||
}
|
||||
|
||||
return `
|
||||
${PULLFROG_DIVIDER}
|
||||
<sup>${FROG_LOGO} | ${allParts.join(" | ")}</sup>`;
|
||||
const allParts = [...parts, "[𝕏](https://x.com/pullfrogai)"];
|
||||
|
||||
return `\n\n${PULLFROG_DIVIDER}\n<sup>${FROG_LOGO} | ${allParts.join(" | ")}</sup>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-1
@@ -115,9 +115,10 @@ const testEnvAllowList = new Set([
|
||||
"GITHUB_PRIVATE_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
"OPENCODE_MODEL",
|
||||
"PULLFROG_MODEL",
|
||||
"LOG_LEVEL",
|
||||
"DEBUG",
|
||||
"NODE_ENV",
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
|
||||
triggeredBy: true,
|
||||
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
|
||||
customParts,
|
||||
model: ctx.toolState.model,
|
||||
});
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
|
||||
+28
-11
@@ -277,11 +277,17 @@ const findInstallationId = async (
|
||||
|
||||
// for local development only
|
||||
async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<string> {
|
||||
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) {
|
||||
throw new Error(
|
||||
"cannot acquire token via GitHub App: GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set"
|
||||
);
|
||||
}
|
||||
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
const config: GitHubAppConfig = {
|
||||
appId: process.env.GITHUB_APP_ID!,
|
||||
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
|
||||
appId: process.env.GITHUB_APP_ID,
|
||||
privateKey: process.env.GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"),
|
||||
repoOwner: repoContext.owner,
|
||||
repoName: repoContext.name,
|
||||
};
|
||||
@@ -292,20 +298,31 @@ async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<str
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a GitHub token is available in the environment.
|
||||
* ensure a GitHub token is available in the environment.
|
||||
*
|
||||
* If neither `GITHUB_TOKEN` nor `GH_TOKEN` is set, attempts to acquire an
|
||||
* installation token using `GITHUB_APP_ID` / `GITHUB_PRIVATE_KEY`.
|
||||
* when OIDC is available (CI), always mints a fresh token scoped to
|
||||
* GITHUB_REPOSITORY — overriding any inherited GITHUB_TOKEN that may
|
||||
* be scoped to the wrong repo.
|
||||
*
|
||||
* **Not intended for production use** — this is a convenience for local
|
||||
* development and test harnesses where tokens aren't pre-provisioned.
|
||||
* otherwise falls back to GitHub App credentials for local development.
|
||||
*
|
||||
* only called from play.ts (test/dev path) — the live action calls
|
||||
* main() directly and never calls this.
|
||||
*/
|
||||
export async function ensureGitHubToken(): Promise<void> {
|
||||
// when OIDC is available, always mint a fresh token scoped to
|
||||
// GITHUB_REPOSITORY. the inherited GITHUB_TOKEN may be scoped to a
|
||||
// different repo (e.g., runner token for pullfrog/app when tests
|
||||
// target pullfrog/test-repo).
|
||||
if (isOIDCAvailable()) {
|
||||
const token = await acquireNewToken();
|
||||
process.env.GITHUB_TOKEN = token;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
|
||||
if (isOIDCAvailable() || (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY)) {
|
||||
const token = await acquireNewToken();
|
||||
process.env.GITHUB_TOKEN = token;
|
||||
}
|
||||
const token = await acquireNewToken();
|
||||
process.env.GITHUB_TOKEN = token;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export interface InstallFromNpmTarballParams {
|
||||
@@ -172,7 +173,7 @@ async function fetchWithRetry(
|
||||
const waitSeconds = parseInt(retryAfter, 10);
|
||||
if (!Number.isNaN(waitSeconds) && waitSeconds > 0) {
|
||||
log.info(`» rate limited, waiting ${waitSeconds} seconds before retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
|
||||
await sleep(waitSeconds * 1000);
|
||||
const retryResponse = await fetch(url, { headers });
|
||||
if (!retryResponse.ok) {
|
||||
throw new Error(
|
||||
|
||||
+16
-3
@@ -11,6 +11,7 @@ interface InstructionsContext {
|
||||
repo: RunContextData["repo"];
|
||||
modes: Mode[];
|
||||
outputSchema?: Record<string, unknown> | undefined;
|
||||
learnings: string | null;
|
||||
}
|
||||
|
||||
function buildRuntimeContext(ctx: InstructionsContext): string {
|
||||
@@ -211,7 +212,13 @@ When posting comments via ${ghPullfrogMcpName}, write as a professional team mem
|
||||
|
||||
### Progress reporting
|
||||
|
||||
ALWAYS use \`report_progress\` to share your results and progress — never \`create_issue_comment\`. The \`report_progress\` tool updates the pre-created progress comment on the issue/PR. Using \`create_issue_comment\` instead creates duplicate comments and leaves the progress comment stuck in its initial state. The \`create_issue_comment\` tool is only for creating NEW standalone comments unrelated to your task progress.
|
||||
**Task list**: at the start of every run, create an internal task list based on the steps in your current mode. Update it as you complete each step. The system automatically renders this list to the progress comment — you do not need to call \`report_progress\` for this.
|
||||
|
||||
**\`report_progress\`**: you MUST call this exactly once at the end of every run with a brief final summary (1-3 sentences). Never call it for intermediate status updates (e.g., "Checking for changes...", "Starting review...") — the task list handles live progress automatically. Calling \`report_progress\` replaces the task list with your summary and preserves the completed task list in a collapsible section. Keep the summary concise — do not repeat what the task list already shows. Focus on the outcome (what was accomplished, links to artifacts) rather than listing individual steps.
|
||||
|
||||
Never use \`create_issue_comment\` for task progress — that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task (e.g., Plan comments, PR Summary comments).
|
||||
|
||||
**After a PR review is submitted**, still call \`report_progress\` with your final summary. The progress comment persists as a record of what was done.
|
||||
|
||||
### If you get stuck
|
||||
|
||||
@@ -328,13 +335,20 @@ interface AssembleFullPromptInput {
|
||||
runtime: string;
|
||||
system: string;
|
||||
contextSections: string;
|
||||
learnings: string | null;
|
||||
}
|
||||
|
||||
function assembleFullPrompt(ctx: AssembleFullPromptInput): string {
|
||||
const learningsSection = ctx.learnings
|
||||
? `************* REPO INTELLIGENCE *************\n\n${ctx.learnings}`
|
||||
: "";
|
||||
|
||||
const rawFull = `************* RUNTIME CONTEXT *************
|
||||
|
||||
${ctx.runtime}
|
||||
|
||||
${learningsSection}
|
||||
|
||||
${ctx.system}
|
||||
|
||||
${ctx.contextSections}`;
|
||||
@@ -359,8 +373,6 @@ ${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}
|
||||
|
||||
Follow the mode guidance to complete the task. Use your native file and shell tools for local operations, and the ${ghPullfrogMcpName} MCP tools for GitHub/git operations.
|
||||
|
||||
When done, call \`${ghPullfrogMcpName}/set_output\` with the final result. This makes it available as the GitHub Action output.
|
||||
|
||||
### No-action cases
|
||||
|
||||
If the task clearly requires no work, call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`;
|
||||
@@ -385,6 +397,7 @@ If the task clearly requires no work, call \`${ghPullfrogMcpName}/report_progres
|
||||
runtime: inputs.runtime,
|
||||
system,
|
||||
contextSections,
|
||||
learnings: ctx.learnings,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -161,6 +161,9 @@ export function resolvePayload(
|
||||
// permissions: inputs > repoSettings > fallbacks
|
||||
push: inputs.push ?? repoSettings.push ?? "restricted",
|
||||
shell: resolvedShell,
|
||||
|
||||
// set by proxy logic in main.ts when routing through OpenRouter
|
||||
proxyModel: undefined as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -70,11 +70,20 @@ async function validateStuckProgressComment(
|
||||
comment_id: commentId,
|
||||
});
|
||||
|
||||
if (commentResult.data.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
const body = commentResult.data.body ?? "";
|
||||
|
||||
if (body.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
}
|
||||
|
||||
// detect stranded todo checklists left by the tracker when the process was killed
|
||||
// before the agent could call report_progress with a final summary
|
||||
if (/^- \[[ x]\] |^- \*\*→\*\* |^- ~~/.test(body)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on a todo checklist`);
|
||||
return commentId;
|
||||
}
|
||||
|
||||
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export type RetryOptions = {
|
||||
@@ -38,7 +39,7 @@ export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {})
|
||||
|
||||
const delay = delayMs * attempt;
|
||||
log.info(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { WriteablePayload } from "../external.ts";
|
||||
import { deleteProgressComment } from "../mcp/comment.ts";
|
||||
import { reportReviewNodeId } from "../mcp/review.ts";
|
||||
import type { ToolContext } from "../mcp/server.ts";
|
||||
import { log } from "./cli.ts";
|
||||
@@ -34,8 +33,6 @@ export async function postReviewCleanup(ctx: ToolContext): Promise<void> {
|
||||
"follow-up re-review dispatch"
|
||||
);
|
||||
}
|
||||
|
||||
await bestEffort(() => deleteProgressComment(ctx), "delete progress comment");
|
||||
}
|
||||
|
||||
async function bestEffort(fn: () => Promise<unknown>, label: string): Promise<void> {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export async function handleAgentResult(ctx: HandleAgentResultParams): Promise<M
|
||||
};
|
||||
}
|
||||
|
||||
if (!ctx.toolState.wasUpdated && ctx.toolState.progressCommentId && !ctx.silent) {
|
||||
if (!ctx.toolState.wasUpdated && ctx.toolState.hadProgressComment && !ctx.silent) {
|
||||
const error = ctx.result.error || "agent completed without reporting progress";
|
||||
try {
|
||||
await reportErrorToComment({
|
||||
|
||||
@@ -14,15 +14,20 @@ export interface RepoSettings {
|
||||
modes: Mode[];
|
||||
setupScript: string | null;
|
||||
postCheckoutScript: string | null;
|
||||
prepushScript: string | null;
|
||||
push: PushPermission;
|
||||
shell: ShellPermission;
|
||||
prApproveEnabled: boolean;
|
||||
modeInstructions: Record<string, string>;
|
||||
learnings: string | null;
|
||||
}
|
||||
|
||||
export interface RunContext {
|
||||
settings: RepoSettings;
|
||||
apiToken: string;
|
||||
oss: boolean;
|
||||
proxyModel?: string | undefined;
|
||||
dbSecrets?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
const defaultSettings: RepoSettings = {
|
||||
@@ -30,15 +35,18 @@ const defaultSettings: RepoSettings = {
|
||||
modes: [],
|
||||
setupScript: null,
|
||||
postCheckoutScript: null,
|
||||
prepushScript: null,
|
||||
push: "restricted",
|
||||
shell: "restricted",
|
||||
prApproveEnabled: false,
|
||||
modeInstructions: {},
|
||||
learnings: null,
|
||||
};
|
||||
|
||||
const defaultRunContext: RunContext = {
|
||||
settings: defaultSettings,
|
||||
apiToken: "",
|
||||
oss: false,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -73,6 +81,9 @@ export async function fetchRunContext(params: {
|
||||
const data = (await response.json()) as {
|
||||
settings: RepoSettings | null;
|
||||
apiToken: string;
|
||||
oss?: boolean;
|
||||
proxyModel?: string;
|
||||
dbSecrets?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
if (data === null) {
|
||||
@@ -86,8 +97,12 @@ export async function fetchRunContext(params: {
|
||||
modes: data.settings?.modes ?? [],
|
||||
setupScript: data.settings?.setupScript ?? null,
|
||||
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
|
||||
prepushScript: data.settings?.prepushScript ?? null,
|
||||
},
|
||||
apiToken: data.apiToken,
|
||||
oss: data.oss ?? false,
|
||||
proxyModel: data.proxyModel,
|
||||
dbSecrets: data.dbSecrets,
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface RunContextData {
|
||||
};
|
||||
repoSettings: RepoSettings;
|
||||
apiToken: string;
|
||||
oss: boolean;
|
||||
proxyModel?: string | undefined;
|
||||
dbSecrets?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
interface ResolveRunContextDataParams {
|
||||
@@ -42,5 +45,8 @@ export async function resolveRunContextData(
|
||||
},
|
||||
repoSettings: runContext.settings,
|
||||
apiToken: runContext.apiToken,
|
||||
oss: runContext.oss,
|
||||
proxyModel: runContext.proxyModel,
|
||||
dbSecrets: runContext.dbSecrets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ export type SetupGitParams = GitContext;
|
||||
* setup git configuration and authentication for the repository.
|
||||
* - configures git identity (user.email, user.name)
|
||||
* - 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.
|
||||
* it is assumed to be potentially exfiltratable, so it has limited scope.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export function addSkill(params: {
|
||||
ref: string;
|
||||
skill: string;
|
||||
env: Record<string, string>;
|
||||
agent: string;
|
||||
}): void {
|
||||
const result = spawnSync(
|
||||
"npx",
|
||||
["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"],
|
||||
{
|
||||
env: { ...process.env, ...params.env },
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
if (result.status === 0) {
|
||||
log.info(`installed ${params.skill} skill (${params.agent})`);
|
||||
} else {
|
||||
log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`);
|
||||
}
|
||||
}
|
||||
+19
-16
@@ -76,7 +76,8 @@ export interface SpawnOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string;
|
||||
timeout?: number;
|
||||
// activity timeout: kill process if no stdout/stderr for this many ms (default: 30s, 0 to disable)
|
||||
// activity timeout: kill process if no stdout for this many ms (default: 30s, 0 to disable).
|
||||
// only stdout resets the timer — stderr (e.g. provider error retries) does not count as progress.
|
||||
activityTimeout?: number;
|
||||
cwd?: string;
|
||||
stdio?: ("pipe" | "ignore" | "inherit")[];
|
||||
@@ -95,7 +96,6 @@ export interface SpawnResult {
|
||||
* Spawn a subprocess with streaming callbacks and buffered results
|
||||
*/
|
||||
export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
const { cmd, args, env, input, timeout, cwd, stdio, onStdout, onStderr } = options;
|
||||
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
|
||||
|
||||
installSignalHandler();
|
||||
@@ -106,13 +106,13 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// security: caller must provide complete env object, not merged with process.env
|
||||
const child = nodeSpawn(cmd, args, {
|
||||
env: env || {
|
||||
const child = nodeSpawn(options.cmd, options.args, {
|
||||
env: options.env || {
|
||||
PATH: process.env.PATH || "",
|
||||
HOME: process.env.HOME || "",
|
||||
},
|
||||
stdio: stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: cwd || process.cwd(),
|
||||
stdio: options.stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: options.cwd || process.cwd(),
|
||||
});
|
||||
|
||||
// track child for cleanup on Ctrl+C
|
||||
@@ -125,7 +125,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
let lastActivityTime = performance.now();
|
||||
|
||||
// overall timeout
|
||||
if (timeout) {
|
||||
if (options.timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
isTimedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
@@ -135,12 +135,14 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}, timeout);
|
||||
}, options.timeout);
|
||||
}
|
||||
|
||||
// activity timeout: kill if no output for too long
|
||||
if (activityTimeoutMs > 0) {
|
||||
log.debug(`spawn activity timer: pid=${child.pid} cmd=${cmd} timeout=${activityTimeoutMs}ms`);
|
||||
log.debug(
|
||||
`spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
|
||||
);
|
||||
activityCheckIntervalId = setInterval(() => {
|
||||
const idleMs = performance.now() - lastActivityTime;
|
||||
log.debug(
|
||||
@@ -149,7 +151,9 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
if (idleMs > activityTimeoutMs) {
|
||||
isActivityTimedOut = true;
|
||||
const idleSec = Math.round(idleMs / 1000);
|
||||
log.info(`no output for ${idleSec}s from pid=${child.pid} (${cmd}), killing process`);
|
||||
log.info(
|
||||
`no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process`
|
||||
);
|
||||
child.kill("SIGKILL");
|
||||
clearInterval(activityCheckIntervalId);
|
||||
}
|
||||
@@ -165,16 +169,15 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stdoutBuffer += chunk;
|
||||
onStdout?.(chunk);
|
||||
options.onStdout?.(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stderrBuffer += chunk;
|
||||
onStderr?.(chunk);
|
||||
options.onStderr?.(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,7 +189,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
|
||||
|
||||
if (isTimedOut) {
|
||||
reject(new Error(`process timed out after ${timeout}ms`));
|
||||
reject(new Error(`process timed out after ${options.timeout}ms`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,8 +225,8 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
});
|
||||
});
|
||||
|
||||
if (input && child.stdin && stdio?.[0] !== "ignore") {
|
||||
child.stdin.write(input);
|
||||
if (options.input && child.stdin && options.stdio?.[0] !== "ignore") {
|
||||
child.stdin.write(options.input);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { log } from "./log.ts";
|
||||
|
||||
type TodoItem = {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||
};
|
||||
|
||||
function isValidTodoStatus(value: string): value is TodoItem["status"] {
|
||||
return (
|
||||
value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled"
|
||||
);
|
||||
}
|
||||
|
||||
function parseTodowriteInput(input: unknown): { todos: unknown[]; merge: boolean } | undefined {
|
||||
if (!input || typeof input !== "object" || !("todos" in input)) return undefined;
|
||||
if (!Array.isArray(input.todos)) return undefined;
|
||||
const merge = "merge" in input && input.merge === true;
|
||||
return { todos: input.todos, merge };
|
||||
}
|
||||
|
||||
function parseTodoItem(entry: unknown, index: number): TodoItem | undefined {
|
||||
if (!entry || typeof entry !== "object") return undefined;
|
||||
if (!("content" in entry) || typeof entry.content !== "string") return undefined;
|
||||
const id = "id" in entry && typeof entry.id === "string" ? entry.id : String(index);
|
||||
const status =
|
||||
"status" in entry && typeof entry.status === "string" && isValidTodoStatus(entry.status)
|
||||
? entry.status
|
||||
: "pending";
|
||||
return { id, content: entry.content, status };
|
||||
}
|
||||
|
||||
function renderTodoMarkdown(todos: TodoItem[]): string {
|
||||
return todos
|
||||
.map((todo) => {
|
||||
switch (todo.status) {
|
||||
case "completed":
|
||||
return `- [x] ${todo.content}`;
|
||||
case "cancelled":
|
||||
return `- ~~${todo.content}~~`;
|
||||
case "in_progress":
|
||||
return `- [ ] <img src="https://uploads.pullfrog.com/Progress%20Indicator.gif" width="11" style="visibility: visible; max-width: 100%;" /> ${todo.content}`;
|
||||
case "pending":
|
||||
return `- [ ] ${todo.content}`;
|
||||
default:
|
||||
todo.status satisfies never;
|
||||
return `- [ ] ${todo.content}`;
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export type TodoTracker = {
|
||||
update: (input: unknown) => void;
|
||||
flush: () => Promise<void>;
|
||||
cancel: () => void;
|
||||
/** resolves when any in-flight onUpdate call completes */
|
||||
settled: () => Promise<void>;
|
||||
renderCollapsible: () => string;
|
||||
readonly enabled: boolean;
|
||||
/** true after the tracker has successfully called onUpdate at least once */
|
||||
readonly hasPublished: boolean;
|
||||
};
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
|
||||
export function createTodoTracker(onUpdate: (body: string) => Promise<void>): TodoTracker {
|
||||
const state = new Map<string, TodoItem>();
|
||||
let enabled = true;
|
||||
let hasPublished = false;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inflightPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (!enabled) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
if (!enabled || state.size === 0) return;
|
||||
const markdown = renderTodoMarkdown(Array.from(state.values()));
|
||||
inflightPromise = inflightPromise
|
||||
.then(async () => {
|
||||
if (!enabled) return;
|
||||
await onUpdate(markdown);
|
||||
hasPublished = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
log.debug(`todo progress update failed: ${err}`);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
update(input: unknown) {
|
||||
if (!enabled) return;
|
||||
const parsed = parseTodowriteInput(input);
|
||||
if (!parsed) return;
|
||||
if (!parsed.merge) state.clear();
|
||||
for (const [index, entry] of parsed.todos.entries()) {
|
||||
const item = parseTodoItem(entry, index);
|
||||
if (item) state.set(item.id, item);
|
||||
}
|
||||
log.debug(`» todowrite: ${state.size} items tracked`);
|
||||
scheduleUpdate();
|
||||
},
|
||||
|
||||
async flush() {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
if (!enabled || state.size === 0) return;
|
||||
const markdown = renderTodoMarkdown(Array.from(state.values()));
|
||||
inflightPromise = inflightPromise
|
||||
.then(async () => {
|
||||
if (!enabled) return;
|
||||
await onUpdate(markdown);
|
||||
hasPublished = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
log.debug(`todo progress flush failed: ${err}`);
|
||||
});
|
||||
await inflightPromise;
|
||||
},
|
||||
|
||||
cancel() {
|
||||
enabled = false;
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
async settled() {
|
||||
await inflightPromise;
|
||||
},
|
||||
|
||||
renderCollapsible(): string {
|
||||
if (state.size === 0) return "";
|
||||
const todos = Array.from(state.values());
|
||||
const completed = todos.filter((t) => t.status === "completed").length;
|
||||
const markdown = renderTodoMarkdown(todos);
|
||||
return `<details>\n<summary>Task list (${completed}/${todos.length} completed)</summary>\n\n${markdown}\n\n</details>`;
|
||||
},
|
||||
|
||||
get enabled() {
|
||||
return enabled;
|
||||
},
|
||||
|
||||
get hasPublished() {
|
||||
return hasPublished;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import semver from "semver";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
export function getDevDependencyVersion(name: keyof typeof packageJson.devDependencies): string {
|
||||
const version = packageJson.devDependencies[name];
|
||||
if (!semver.valid(version)) {
|
||||
throw new Error(`dev dependency "${name}" must be a pinned version, got "${version}"`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
Reference in New Issue
Block a user