71feba0a76
* fix: prevent log.writeSummary from overwriting reportProgress content The run summary was showing logs instead of the final reportProgress content because log.writeSummary() was called after reportProgress. Now log.writeSummary() checks if the summary was already overwritten by reportProgress and skips if so. Fixes #86 * refactor: replace dynamic import with static import in cli.ts Replace unnecessary dynamic import of wasSummaryOverwritten with static import. No circular dependency exists since comment.ts doesn't import from cli.ts. * Fix run summary writing --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
587 lines
18 KiB
TypeScript
587 lines
18 KiB
TypeScript
import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
|
import { join } from "node:path";
|
|
import { log } from "../utils/cli.ts";
|
|
import { spawn } from "../utils/subprocess.ts";
|
|
import { addInstructions } from "./instructions.ts";
|
|
import {
|
|
agent,
|
|
type ConfigureMcpServersParams,
|
|
createAgentEnv,
|
|
installFromNpmTarball,
|
|
setupProcessAgentEnv,
|
|
} from "./shared.ts";
|
|
|
|
export const opencode = agent({
|
|
name: "opencode",
|
|
install: async () => {
|
|
return await installFromNpmTarball({
|
|
packageName: "opencode-ai",
|
|
version: "latest",
|
|
executablePath: "bin/opencode",
|
|
installDependencies: true,
|
|
});
|
|
},
|
|
run: async ({
|
|
payload,
|
|
apiKey: _apiKey,
|
|
apiKeys,
|
|
mcpServers,
|
|
cliPath,
|
|
repo,
|
|
effort: _effort,
|
|
}) => {
|
|
// 1. configure home/config directory
|
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
|
const configDir = join(tempHome, ".config", "opencode");
|
|
mkdirSync(configDir, { recursive: true });
|
|
|
|
configureOpenCode({
|
|
mcpServers,
|
|
sandbox: payload.sandbox ?? false,
|
|
isPublicRepo: repo.isPublic,
|
|
});
|
|
|
|
const prompt = addInstructions({ payload, repo });
|
|
log.group("Full prompt", () => log.info(prompt));
|
|
|
|
// message positional must come right after "run", before flags
|
|
const args = ["run", prompt, "--format", "json"];
|
|
|
|
if (payload.sandbox) {
|
|
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
|
}
|
|
|
|
// 6. set up environment
|
|
setupProcessAgentEnv({ HOME: tempHome });
|
|
|
|
// SECURITY: build env vars from whitelisted base env to prevent API key leakage
|
|
// this prevents leaking other API keys (ANTHROPIC, GEMINI, etc.) to OpenCode subprocess
|
|
// XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path,
|
|
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
|
|
const env: Record<string, string> = {
|
|
...createAgentEnv({ HOME: tempHome }),
|
|
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
|
};
|
|
// OpenCode doesn't support GitHub App installation tokens
|
|
delete env.GITHUB_TOKEN;
|
|
|
|
// add API keys from apiKeys object
|
|
for (const [key, value] of Object.entries(apiKeys || {})) {
|
|
env[key.toUpperCase()] = value;
|
|
// also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility
|
|
if (key === "GEMINI_API_KEY") {
|
|
env.GOOGLE_GENERATIVE_AI_API_KEY = value;
|
|
}
|
|
}
|
|
|
|
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
|
|
const repoDir = process.cwd();
|
|
|
|
log.info(`🚀 Starting OpenCode CLI: ${cliPath} ${args.join(" ")}`);
|
|
log.info(`📁 Working directory: ${repoDir}`);
|
|
log.debug(`🏠 HOME: ${env.HOME}`);
|
|
log.debug(`📋 XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`);
|
|
|
|
const startTime = Date.now();
|
|
let lastActivityTime = startTime;
|
|
let eventCount = 0;
|
|
|
|
let output = "";
|
|
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
|
|
const result = await spawn({
|
|
cmd: cliPath,
|
|
args,
|
|
cwd: repoDir,
|
|
env,
|
|
timeout: 600000, // 10 minutes timeout to prevent infinite hangs
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
onStdout: async (chunk) => {
|
|
const text = chunk.toString();
|
|
output += text;
|
|
|
|
// buffer incomplete lines across chunks (NDJSON format)
|
|
stdoutBuffer += text;
|
|
const lines = stdoutBuffer.split("\n");
|
|
|
|
// keep the last element (may be incomplete) in the buffer
|
|
stdoutBuffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const event = JSON.parse(trimmed) as OpenCodeEvent;
|
|
eventCount++;
|
|
|
|
// debug log all events to diagnose ordering and missing MCP/bash tool calls
|
|
log.debug(JSON.stringify(event, null, 2));
|
|
|
|
const timeSinceLastActivity = Date.now() - lastActivityTime;
|
|
if (timeSinceLastActivity > 10000) {
|
|
const activeToolCalls = toolCallTimings.size;
|
|
const toolCallInfo =
|
|
activeToolCalls > 0
|
|
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
|
|
: " (OpenCode may be processing internally - LLM calls, planning, etc.)";
|
|
log.warning(
|
|
`⚠️ No activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
|
|
);
|
|
}
|
|
lastActivityTime = Date.now();
|
|
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
|
|
if (handler) {
|
|
await handler(event as never);
|
|
} else {
|
|
// log unhandled event types for visibility
|
|
log.info(
|
|
`📋 OpenCode event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
|
|
);
|
|
}
|
|
} catch {
|
|
// non-JSON lines are ignored (might be debug output from opencode)
|
|
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
|
|
}
|
|
}
|
|
},
|
|
onStderr: (chunk) => {
|
|
try {
|
|
const parsed = JSON.parse(chunk);
|
|
log.debug(JSON.stringify(parsed, null, 2));
|
|
} catch {
|
|
// if not JSON, fall through to regular error logging
|
|
}
|
|
const trimmed = chunk.trim();
|
|
if (trimmed) {
|
|
log.warning(trimmed);
|
|
}
|
|
},
|
|
});
|
|
|
|
const duration = Date.now() - startTime;
|
|
log.info(`✅ OpenCode CLI completed in ${duration}ms with exit code ${result.exitCode}`);
|
|
|
|
// 8. log tokens if they weren't logged yet (fallback if result event wasn't emitted)
|
|
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)],
|
|
]);
|
|
}
|
|
|
|
// 9. return result
|
|
if (result.exitCode !== 0) {
|
|
const errorMessage =
|
|
result.stderr || result.stdout || "Unknown error - no output from OpenCode CLI";
|
|
log.error(`OpenCode CLI exited with code ${result.exitCode}: ${errorMessage}`);
|
|
log.debug(`OpenCode stdout: ${result.stdout?.substring(0, 500)}`);
|
|
log.debug(`OpenCode stderr: ${result.stderr?.substring(0, 500)}`);
|
|
return {
|
|
success: false,
|
|
output: finalOutput || output,
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
output: finalOutput || output,
|
|
};
|
|
},
|
|
});
|
|
|
|
interface ConfigureOpenCodeParams {
|
|
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
|
sandbox: boolean;
|
|
isPublicRepo: boolean;
|
|
}
|
|
|
|
/**
|
|
* Configure OpenCode via opencode.json config file.
|
|
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
|
*/
|
|
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void {
|
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
|
const configDir = join(tempHome, ".config", "opencode");
|
|
mkdirSync(configDir, { recursive: true });
|
|
const configPath = join(configDir, "opencode.json");
|
|
|
|
// build MCP servers config
|
|
const opencodeMcpServers: Record<string, { type: "remote"; url: string }> = {};
|
|
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
if (serverConfig.type !== "http") {
|
|
log.error(
|
|
`unsupported MCP server type for OpenCode: ${(serverConfig as never as { type: string }).type || "unknown"}`
|
|
);
|
|
throw new Error(
|
|
`Unsupported MCP server type for OpenCode: ${(serverConfig as never as { type: string }).type || "unknown"}`
|
|
);
|
|
}
|
|
|
|
opencodeMcpServers[serverName] = {
|
|
type: "remote",
|
|
url: serverConfig.url,
|
|
};
|
|
}
|
|
|
|
// SECURITY: For PUBLIC repos, OpenCode spawns subprocesses with full process.env, leaking API keys.
|
|
// disable native bash; agents use MCP bash tool which filters secrets.
|
|
// for private repos, native bash is allowed.
|
|
const bashPermission = isPublicRepo ? "deny" : "allow";
|
|
const permission = sandbox
|
|
? {
|
|
edit: "deny",
|
|
bash: "deny",
|
|
webfetch: "deny",
|
|
doom_loop: "allow",
|
|
external_directory: "allow",
|
|
}
|
|
: {
|
|
edit: "allow",
|
|
bash: bashPermission,
|
|
webfetch: "allow",
|
|
doom_loop: "allow",
|
|
external_directory: "allow",
|
|
};
|
|
|
|
// build complete config in one object
|
|
const config = {
|
|
mcp: opencodeMcpServers,
|
|
permission,
|
|
};
|
|
|
|
const configJson = JSON.stringify(config, null, 2);
|
|
try {
|
|
writeFileSync(configPath, configJson, "utf-8");
|
|
} catch (error) {
|
|
log.error(
|
|
`failed to write OpenCode config to ${configPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
throw error;
|
|
}
|
|
|
|
log.info(`» OpenCode config written to ${configPath} (sandbox: ${sandbox})`);
|
|
log.debug(`OpenCode config contents:\n${configJson}`);
|
|
}
|
|
|
|
////////////////////////////////////////////
|
|
//////////// EVENT HANDLERS ////////////
|
|
////////////////////////////////////////////
|
|
|
|
// opencode cli event types inferred from json output format
|
|
interface OpenCodeInitEvent {
|
|
type: "init";
|
|
timestamp?: string;
|
|
session_id?: string;
|
|
model?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeMessageEvent {
|
|
type: "message";
|
|
timestamp?: string;
|
|
role?: "user" | "assistant";
|
|
content?: string;
|
|
delta?: boolean;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeTextEvent {
|
|
type: "text";
|
|
timestamp?: string;
|
|
sessionID?: string;
|
|
part?: {
|
|
id?: string;
|
|
type?: string;
|
|
text?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeStepStartEvent {
|
|
type: "step_start";
|
|
timestamp?: string;
|
|
sessionID?: string;
|
|
part?: {
|
|
id?: string;
|
|
type?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeStepFinishEvent {
|
|
type: "step_finish";
|
|
timestamp?: string;
|
|
sessionID?: string;
|
|
part?: {
|
|
id?: string;
|
|
type?: string;
|
|
reason?: string;
|
|
cost?: number;
|
|
tokens?: {
|
|
input?: number;
|
|
output?: number;
|
|
reasoning?: number;
|
|
cache?: {
|
|
read?: number;
|
|
write?: number;
|
|
};
|
|
};
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeToolUseEvent {
|
|
type: "tool_use";
|
|
timestamp?: number;
|
|
sessionID?: string;
|
|
part?: {
|
|
id?: string;
|
|
callID?: string;
|
|
tool?: string;
|
|
state?: {
|
|
status?: string;
|
|
input?: unknown;
|
|
output?: string;
|
|
};
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeToolResultEvent {
|
|
type: "tool_result";
|
|
timestamp?: number;
|
|
sessionID?: string;
|
|
part?: {
|
|
callID?: string;
|
|
state?: {
|
|
status?: string;
|
|
output?: string;
|
|
};
|
|
};
|
|
// fallback fields for older format
|
|
tool_id?: string;
|
|
status?: "success" | "error";
|
|
output?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeResultEvent {
|
|
type: "result";
|
|
timestamp?: string;
|
|
status?: "success" | "error";
|
|
stats?: {
|
|
total_tokens?: number;
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
duration_ms?: number;
|
|
tool_calls?: number;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeErrorEvent {
|
|
type: "error";
|
|
timestamp?: string;
|
|
sessionID?: string;
|
|
error?: {
|
|
name?: string;
|
|
message?: string;
|
|
data?: unknown;
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
type OpenCodeEvent =
|
|
| OpenCodeInitEvent
|
|
| OpenCodeMessageEvent
|
|
| OpenCodeTextEvent
|
|
| OpenCodeStepStartEvent
|
|
| OpenCodeStepFinishEvent
|
|
| OpenCodeToolUseEvent
|
|
| OpenCodeToolResultEvent
|
|
| OpenCodeResultEvent
|
|
| OpenCodeErrorEvent;
|
|
|
|
let finalOutput = "";
|
|
let accumulatedTokens: { input: number; output: number } = { input: 0, output: 0 };
|
|
let tokensLogged = false;
|
|
const toolCallTimings = new Map<string, number>();
|
|
let currentStepId: string | null = null;
|
|
let currentStepType: string | null = null;
|
|
let stepHistory: Array<{ stepId: string; stepType: string; toolCalls: string[] }> = [];
|
|
|
|
const messageHandlers = {
|
|
init: (event: OpenCodeInitEvent) => {
|
|
// initialization event - reset state
|
|
log.info(
|
|
`🔵 OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
|
|
);
|
|
log.info(`🔵 OpenCode init event (full): ${JSON.stringify(event)}`);
|
|
finalOutput = "";
|
|
accumulatedTokens = { input: 0, output: 0 };
|
|
tokensLogged = false;
|
|
},
|
|
message: (event: OpenCodeMessageEvent) => {
|
|
if (event.role === "assistant" && event.content?.trim()) {
|
|
const message = event.content.trim();
|
|
if (message) {
|
|
if (event.delta) {
|
|
// delta messages are streaming thoughts/reasoning
|
|
log.info(
|
|
`💭 OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
|
|
);
|
|
} else {
|
|
// complete messages
|
|
log.info(
|
|
`💬 OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
|
|
);
|
|
finalOutput = message;
|
|
}
|
|
}
|
|
} else if (event.role === "user") {
|
|
log.info(
|
|
`💬 OpenCode message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
|
|
);
|
|
}
|
|
},
|
|
text: (event: OpenCodeTextEvent) => {
|
|
// log from text events only to avoid duplicates
|
|
if (event.part?.text?.trim()) {
|
|
const message = event.part.text.trim();
|
|
log.box(message, { title: "OpenCode" });
|
|
finalOutput = message;
|
|
}
|
|
},
|
|
step_start: (event: OpenCodeStepStartEvent) => {
|
|
const stepType = event.part?.type || "unknown";
|
|
const stepId = event.part?.id || "unknown";
|
|
currentStepId = stepId;
|
|
currentStepType = stepType;
|
|
stepHistory.push({ stepId, stepType, toolCalls: [] });
|
|
},
|
|
step_finish: async (event: OpenCodeStepFinishEvent) => {
|
|
const stepId = event.part?.id || "unknown";
|
|
|
|
// accumulate tokens from step_finish events (they come here, not in result)
|
|
const eventTokens = event.part?.tokens;
|
|
if (eventTokens) {
|
|
const inputTokens = eventTokens.input || 0;
|
|
const outputTokens = eventTokens.output || 0;
|
|
|
|
// accumulate tokens (don't log yet - wait for result event)
|
|
accumulatedTokens.input += inputTokens;
|
|
accumulatedTokens.output += outputTokens;
|
|
}
|
|
|
|
// clear current step
|
|
if (currentStepId === stepId) {
|
|
currentStepId = null;
|
|
currentStepType = null;
|
|
}
|
|
},
|
|
tool_use: (event: OpenCodeToolUseEvent) => {
|
|
const toolName = event.part?.tool;
|
|
const toolId = event.part?.callID;
|
|
const parameters = event.part?.state?.input;
|
|
const status = event.part?.state?.status;
|
|
const output = event.part?.state?.output;
|
|
|
|
// debug log all tool_use events to diagnose missing bash/MCP tool calls
|
|
if (!toolName || !toolId) {
|
|
log.debug(`» tool_use event missing toolName or toolId: ${JSON.stringify(event)}`);
|
|
}
|
|
|
|
if (toolName && toolId) {
|
|
// track tool call in current step
|
|
if (stepHistory.length > 0) {
|
|
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
|
|
}
|
|
|
|
log.toolCall({
|
|
toolName,
|
|
input: parameters || {},
|
|
});
|
|
|
|
// if tool already completed (status in same event), log output
|
|
if (status === "completed" && output) {
|
|
log.debug(` output: ${output}`);
|
|
}
|
|
}
|
|
},
|
|
tool_result: (event: OpenCodeToolResultEvent) => {
|
|
// handle both new part structure and legacy flat structure
|
|
const toolId = event.part?.callID || event.tool_id;
|
|
const status = event.part?.state?.status || event.status || "unknown";
|
|
const output = event.part?.state?.output || event.output;
|
|
|
|
if (toolId) {
|
|
const toolStartTime = toolCallTimings.get(toolId);
|
|
if (toolStartTime) {
|
|
const toolDuration = Date.now() - toolStartTime;
|
|
toolCallTimings.delete(toolId);
|
|
const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
|
|
log.info(
|
|
`🔧 OpenCode tool_result${stepContext}: id=${toolId}, status=${status}, duration=${toolDuration}ms`
|
|
);
|
|
if (output) {
|
|
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
|
|
}
|
|
if (toolDuration > 5000) {
|
|
log.warning(
|
|
`⚠️ Tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (status === "error") {
|
|
const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
|
|
log.warning(`❌ Tool call failed: ${errorMsg}`);
|
|
}
|
|
},
|
|
result: async (event: OpenCodeResultEvent) => {
|
|
const status = event.status || "unknown";
|
|
const duration = event.stats?.duration_ms || 0;
|
|
const toolCalls = event.stats?.tool_calls || 0;
|
|
log.info(
|
|
`🏁 OpenCode result: status=${status}, duration=${duration}ms, tool_calls=${toolCalls}`
|
|
);
|
|
|
|
if (event.status === "error") {
|
|
log.error(`❌ OpenCode CLI failed: ${JSON.stringify(event)}`);
|
|
} else {
|
|
// log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish)
|
|
const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0;
|
|
const outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0;
|
|
const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens;
|
|
log.info(
|
|
`📊 OpenCode final stats: input=${inputTokens}, output=${outputTokens}, total=${totalTokens}, tool_calls=${toolCalls}, duration=${duration}ms`
|
|
);
|
|
|
|
if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) {
|
|
log.table([
|
|
[
|
|
{ data: "Input Tokens", header: true },
|
|
{ data: "Output Tokens", header: true },
|
|
{ data: "Total Tokens", header: true },
|
|
],
|
|
[String(inputTokens), String(outputTokens), String(totalTokens)],
|
|
]);
|
|
tokensLogged = true;
|
|
}
|
|
}
|
|
},
|
|
};
|