548 lines
18 KiB
TypeScript
548 lines
18 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, 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,
|
|
installFromNpmTarball,
|
|
setupProcessAgentEnv,
|
|
} from "./shared.ts";
|
|
|
|
// import { createOpencode } from "@opencode-ai/sdk"
|
|
|
|
// const { client } = await createOpencode({
|
|
// config: {
|
|
// ''
|
|
// }
|
|
// })
|
|
|
|
// 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?: string;
|
|
tool_name?: string;
|
|
tool_id?: string;
|
|
parameters?: unknown;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OpenCodeToolResultEvent {
|
|
type: "tool_result";
|
|
timestamp?: string;
|
|
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;
|
|
}
|
|
|
|
type OpenCodeEvent =
|
|
| OpenCodeInitEvent
|
|
| OpenCodeMessageEvent
|
|
| OpenCodeTextEvent
|
|
| OpenCodeStepStartEvent
|
|
| OpenCodeStepFinishEvent
|
|
| OpenCodeToolUseEvent
|
|
| OpenCodeToolResultEvent
|
|
| OpenCodeResultEvent;
|
|
|
|
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"}`
|
|
);
|
|
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.info(
|
|
`📝 OpenCode text output: ${message.substring(0, 200)}${message.length > 200 ? "..." : ""}`
|
|
);
|
|
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) => {
|
|
if (event.tool_name && event.tool_id) {
|
|
toolCallTimings.set(event.tool_id, Date.now());
|
|
const paramsStr = event.parameters
|
|
? JSON.stringify(event.parameters).substring(0, 500)
|
|
: "{}";
|
|
const stepContext = currentStepId
|
|
? ` (step=${currentStepType || "unknown"}, stepId=${currentStepId.substring(0, 20)}...)`
|
|
: "";
|
|
log.info(`🔧 OpenCode tool_use: ${event.tool_name}${stepContext}, id=${event.tool_id}`);
|
|
log.info(` Parameters: ${paramsStr}${paramsStr.length >= 500 ? "..." : ""}`);
|
|
|
|
// track tool call in current step
|
|
if (stepHistory.length > 0) {
|
|
stepHistory[stepHistory.length - 1].toolCalls.push(event.tool_name);
|
|
}
|
|
|
|
log.toolCall({
|
|
toolName: event.tool_name,
|
|
input: event.parameters || {},
|
|
});
|
|
}
|
|
},
|
|
tool_result: (event: OpenCodeToolResultEvent) => {
|
|
if (event.tool_id) {
|
|
const toolStartTime = toolCallTimings.get(event.tool_id);
|
|
if (toolStartTime) {
|
|
const toolDuration = Date.now() - toolStartTime;
|
|
toolCallTimings.delete(event.tool_id);
|
|
const status = event.status || "unknown";
|
|
const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
|
|
const outputPreview =
|
|
typeof event.output === "string"
|
|
? event.output.substring(0, 500)
|
|
: JSON.stringify(event.output).substring(0, 500);
|
|
log.info(
|
|
`🔧 OpenCode tool_result${stepContext}: id=${event.tool_id}, status=${status}, duration=${toolDuration}ms`
|
|
);
|
|
if (outputPreview && outputPreview !== "{}" && outputPreview !== "null") {
|
|
log.info(` Output: ${outputPreview}${outputPreview.length >= 500 ? "..." : ""}`);
|
|
}
|
|
if (toolDuration > 5000) {
|
|
log.warning(
|
|
`⚠️ Tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (event.status === "error") {
|
|
const errorMsg =
|
|
typeof event.output === "string" ? event.output : JSON.stringify(event.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) {
|
|
await log.summaryTable([
|
|
[
|
|
{ data: "Input Tokens", header: true },
|
|
{ data: "Output Tokens", header: true },
|
|
{ data: "Total Tokens", header: true },
|
|
],
|
|
[String(inputTokens), String(outputTokens), String(totalTokens)],
|
|
]);
|
|
tokensLogged = true;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
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, prepResults, repo }) => {
|
|
// 1. configure home/config directory
|
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
|
const configDir = join(tempHome, ".config", "opencode");
|
|
mkdirSync(configDir, { recursive: true });
|
|
|
|
configureOpenCodeMcpServers({ mcpServers });
|
|
configureOpenCodeSandbox({ sandbox: payload.sandbox ?? false });
|
|
|
|
const prompt = addInstructions({ payload, prepResults, repo });
|
|
log.group("Full prompt", (log) => log.info(prompt));
|
|
const args = ["run", "--format", "json", prompt];
|
|
|
|
if (payload.sandbox) {
|
|
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
|
}
|
|
|
|
// 6. set up environment
|
|
setupProcessAgentEnv({ HOME: tempHome });
|
|
|
|
// build env vars: start with process.env (includes all API_KEY vars loaded by config())
|
|
// exclude GITHUB_TOKEN - OpenCode should use MCP server for GitHub operations, not direct token
|
|
// then override with apiKeys and HOME
|
|
const env: Record<string, string> = {
|
|
...(Object.fromEntries(
|
|
Object.entries(process.env).filter(
|
|
([key, value]) => value !== undefined && key !== "GITHUB_TOKEN"
|
|
)
|
|
) as Record<string, string>),
|
|
HOME: tempHome,
|
|
};
|
|
|
|
// add/override API keys from apiKeys object (uppercase keys)
|
|
for (const [key, value] of Object.entries(apiKeys || {})) {
|
|
env[key.toUpperCase()] = 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}`);
|
|
const startTime = Date.now();
|
|
let lastActivityTime = startTime;
|
|
let eventCount = 0;
|
|
|
|
let output = "";
|
|
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;
|
|
|
|
// parse each line as JSON (opencode outputs one JSON object per line)
|
|
const lines = text.split("\n");
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const event = JSON.parse(trimmed) as OpenCodeEvent;
|
|
eventCount++;
|
|
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 (but don't spam)
|
|
if (
|
|
event.type &&
|
|
![
|
|
"init",
|
|
"message",
|
|
"text",
|
|
"step_start",
|
|
"step_finish",
|
|
"tool_use",
|
|
"tool_result",
|
|
"result",
|
|
].includes(event.type)
|
|
) {
|
|
log.debug(`📋 OpenCode event (unhandled): type=${event.type}`);
|
|
}
|
|
}
|
|
} catch {
|
|
// non-JSON lines are ignored
|
|
}
|
|
}
|
|
},
|
|
onStderr: (chunk) => {
|
|
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;
|
|
await log.summaryTable([
|
|
[
|
|
{ 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,
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Configure MCP servers for OpenCode using opencode.json config file.
|
|
* OpenCode uses opencode.json with mcp section supporting remote servers with type: "remote" and url.
|
|
*/
|
|
function configureOpenCodeMcpServers({
|
|
mcpServers,
|
|
}: {
|
|
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
|
}): void {
|
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
|
const configDir = join(tempHome, ".config", "opencode");
|
|
mkdirSync(configDir, { recursive: true });
|
|
const configPath = join(configDir, "opencode.json");
|
|
|
|
// convert to opencode's expected format
|
|
const opencodeMcpServers: Record<string, { type: "remote"; url: string; enabled?: boolean }> = {};
|
|
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
if (serverConfig.type !== "http") {
|
|
throw new Error(
|
|
`Unsupported MCP server type for OpenCode: ${(serverConfig as any).type || "unknown"}`
|
|
);
|
|
}
|
|
|
|
opencodeMcpServers[serverName] = {
|
|
type: "remote",
|
|
url: serverConfig.url,
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
// read existing config if it exists, or create new one
|
|
let config: Record<string, unknown> = {};
|
|
try {
|
|
if (existsSync(configPath)) {
|
|
const existingConfig = readFileSync(configPath, "utf-8");
|
|
config = JSON.parse(existingConfig);
|
|
}
|
|
} catch {
|
|
// config doesn't exist yet or is invalid, start fresh
|
|
}
|
|
|
|
config.mcp = opencodeMcpServers;
|
|
|
|
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
log.info(`MCP config written to ${configPath}`);
|
|
}
|
|
|
|
/**
|
|
* Configure OpenCode sandbox mode via opencode.json.
|
|
* When sandbox is enabled, restricts tools to read-only operations.
|
|
*/
|
|
function configureOpenCodeSandbox({ sandbox }: { sandbox: boolean }): void {
|
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
|
const configDir = join(tempHome, ".config", "opencode");
|
|
mkdirSync(configDir, { recursive: true });
|
|
const configPath = join(configDir, "opencode.json");
|
|
|
|
// read existing config if it exists, or create new one
|
|
let config: Record<string, unknown> = {};
|
|
try {
|
|
if (existsSync(configPath)) {
|
|
const existingConfig = readFileSync(configPath, "utf-8");
|
|
config = JSON.parse(existingConfig);
|
|
}
|
|
} catch {
|
|
// config doesn't exist yet or is invalid, start fresh
|
|
}
|
|
|
|
if (sandbox) {
|
|
// sandbox mode: disable write, bash, and webfetch tools
|
|
config.tools = {
|
|
write: false,
|
|
bash: false,
|
|
webfetch: false,
|
|
};
|
|
} else {
|
|
// normal mode: enable all tools (or don't set tools config to use defaults)
|
|
config.tools = {
|
|
write: true,
|
|
bash: true,
|
|
webfetch: true,
|
|
};
|
|
}
|
|
|
|
// preserve MCP config if it was already set by configureOpenCodeMcpServers
|
|
// (this function is called after configureOpenCodeMcpServers, so MCP config should already exist)
|
|
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
log.info(`OpenCode config written to ${configPath} (sandbox: ${sandbox})`);
|
|
}
|