b472aa1ba9
* chore: create empty commit for PR Co-authored-by: Cursor <cursoragent@cursor.com> * chore(action): trigger preview repo creation workflow Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): merge repo opencode config and log provider key presence Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): add effort-aware model override precedence Add OPENCODE_MODEL_MINI and OPENCODE_MODEL_MAX support with fallback to OPENCODE_MODEL and auto-selection, and keep provider resolution aligned via inline OpenCode config when overrides are used. Update workflow env wiring, test allowlists, and docs (including OpenRouter setup guidance and sidebar ordering) to document the new behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(effort): clarify OpenCode precedence and add section link Add a concise OpenCode precedence callout in the summary area and link directly to the OpenCode section for full details and examples. Co-authored-by: Cursor <cursoragent@cursor.com> * Restructure dash (#372) * Restructure dash * WIP * WIP * refactor trigger UI: extract PR summary card, add mentions section, rename labels Co-authored-by: Cursor <cursoragent@cursor.com> * clean up console UI: remove info icons from section descriptions, rename mentions trigger Co-authored-by: Cursor <cursoragent@cursor.com> * fix review feedback: layout, terminology, form scope - extract console sidebar sections to module-level constant - align three-column layout breakpoints to xl (match sidebar visibility) - fix mixed shell/bash terminology in beta page - scope FormProvider to trigger sections only, restore autoComplete="off" Co-authored-by: Cursor <cursoragent@cursor.com> * Bump --------- Co-authored-by: Cursor <cursoragent@cursor.com> * chore: create empty commit for PR Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): merge repo opencode config and log provider key presence Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): add effort-aware model override precedence Add OPENCODE_MODEL_MINI and OPENCODE_MODEL_MAX support with fallback to OPENCODE_MODEL and auto-selection, and keep provider resolution aligned via inline OpenCode config when overrides are used. Update workflow env wiring, test allowlists, and docs (including OpenRouter setup guidance and sidebar ordering) to document the new behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: update agent rules and OpenCode config docs Align AGENTS guidance with current preferences and apply review-driven wording updates in OpenCode-related files. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
867 lines
29 KiB
TypeScript
867 lines
29 KiB
TypeScript
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
|
|
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
|
// changes to web search configuration should be reflected in wiki/websearch.md
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import { ghPullfrogMcpName } from "../external.ts";
|
|
import { getIdleMs, markActivity } from "../utils/activity.ts";
|
|
import { log } from "../utils/cli.ts";
|
|
import { installFromNpmTarball } from "../utils/install.ts";
|
|
import { spawn } from "../utils/subprocess.ts";
|
|
import { ThinkingTimer } from "../utils/timer.ts";
|
|
import { type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
|
|
|
|
// pinned CLI version — no 1-1 package.json dependency for the CLI package
|
|
// (package.json has @opencode-ai/sdk which is the SDK, not the CLI)
|
|
const OPENCODE_CLI_VERSION = "1.1.56";
|
|
|
|
// known provider error patterns in stderr (from --print-logs output).
|
|
// when OpenCode encounters these, it often goes silent on stdout (Issue #752),
|
|
// so we surface them prominently instead of burying them in debug warnings.
|
|
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;
|
|
}
|
|
|
|
type OpenCodeConfig = {
|
|
mcp?: Record<string, unknown>;
|
|
permission?: Record<string, unknown>;
|
|
provider?: Record<string, unknown>;
|
|
model?: string;
|
|
enabled_providers?: string[];
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type RecordPropertyContext = {
|
|
value: unknown;
|
|
key: string;
|
|
};
|
|
|
|
type RepoConfigLoadContext = {
|
|
repoConfigPath: string;
|
|
};
|
|
|
|
type ProviderFromModelContext = {
|
|
model: string;
|
|
};
|
|
|
|
type InlineConfigOverrideContext = {
|
|
model: string;
|
|
};
|
|
|
|
type InlineConfigOverride = {
|
|
providerId: string;
|
|
content: string;
|
|
};
|
|
|
|
type ModelOverrideResolutionContext = {
|
|
effort: AgentRunContext["payload"]["effort"];
|
|
env: NodeJS.ProcessEnv;
|
|
};
|
|
|
|
type ModelOverrideResolution = {
|
|
model: string;
|
|
source: "OPENCODE_MODEL_MINI" | "OPENCODE_MODEL_MAX" | "OPENCODE_MODEL";
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function getRecordProperty(ctx: RecordPropertyContext): Record<string, unknown> | undefined {
|
|
if (!isRecord(ctx.value)) {
|
|
return undefined;
|
|
}
|
|
const propertyValue = ctx.value[ctx.key];
|
|
if (!isRecord(propertyValue)) {
|
|
return undefined;
|
|
}
|
|
return propertyValue;
|
|
}
|
|
|
|
function loadRepoOpenCodeConfig(ctx: RepoConfigLoadContext): OpenCodeConfig | undefined {
|
|
if (!existsSync(ctx.repoConfigPath)) {
|
|
log.info(`» repo opencode.json not found at ${ctx.repoConfigPath}`);
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const rawConfig = readFileSync(ctx.repoConfigPath, "utf-8");
|
|
const parsedConfig = JSON.parse(rawConfig);
|
|
if (!isRecord(parsedConfig)) {
|
|
log.warning(`» repo opencode.json is not an object: ${ctx.repoConfigPath}`);
|
|
return undefined;
|
|
}
|
|
|
|
const providerConfig = getRecordProperty({ value: parsedConfig, key: "provider" });
|
|
if (providerConfig) {
|
|
const providerNames = Object.keys(providerConfig);
|
|
log.info(`» repo opencode provider config detected: ${providerNames.join(", ")}`);
|
|
}
|
|
|
|
const result: OpenCodeConfig = parsedConfig;
|
|
log.info(`» loaded repo opencode.json from ${ctx.repoConfigPath}`);
|
|
return result;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
log.warning(`» failed to parse repo opencode.json at ${ctx.repoConfigPath}: ${errorMessage}`);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function parseProviderFromModel(ctx: ProviderFromModelContext): string | undefined {
|
|
const trimmedModel = ctx.model.trim();
|
|
const slashIndex = trimmedModel.indexOf("/");
|
|
if (slashIndex <= 0) {
|
|
return undefined;
|
|
}
|
|
const providerId = trimmedModel.slice(0, slashIndex).trim().toLowerCase();
|
|
if (!providerId) {
|
|
return undefined;
|
|
}
|
|
return providerId;
|
|
}
|
|
|
|
function buildInlineConfigOverride(
|
|
ctx: InlineConfigOverrideContext
|
|
): InlineConfigOverride | undefined {
|
|
const providerId = parseProviderFromModel({ model: ctx.model });
|
|
if (!providerId) {
|
|
return undefined;
|
|
}
|
|
const inlineConfig: OpenCodeConfig = {
|
|
model: ctx.model,
|
|
enabled_providers: [providerId],
|
|
};
|
|
return {
|
|
providerId,
|
|
content: JSON.stringify(inlineConfig),
|
|
};
|
|
}
|
|
|
|
function readNonEmptyEnvVar(ctx: { env: NodeJS.ProcessEnv; name: string }): string | undefined {
|
|
const value = ctx.env[ctx.name];
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function resolveModelOverride(
|
|
ctx: ModelOverrideResolutionContext
|
|
): ModelOverrideResolution | undefined {
|
|
if (ctx.effort === "mini") {
|
|
const miniModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MINI" });
|
|
if (miniModel) {
|
|
return { model: miniModel, source: "OPENCODE_MODEL_MINI" };
|
|
}
|
|
}
|
|
|
|
if (ctx.effort === "max") {
|
|
const maxModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MAX" });
|
|
if (maxModel) {
|
|
return { model: maxModel, source: "OPENCODE_MODEL_MAX" };
|
|
}
|
|
}
|
|
|
|
const baseModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL" });
|
|
if (!baseModel) {
|
|
return undefined;
|
|
}
|
|
|
|
return { model: baseModel, source: "OPENCODE_MODEL" };
|
|
}
|
|
|
|
async function installOpencode(): Promise<string> {
|
|
return await installFromNpmTarball({
|
|
packageName: "opencode-ai",
|
|
version: OPENCODE_CLI_VERSION,
|
|
executablePath: "bin/opencode",
|
|
installDependencies: true,
|
|
});
|
|
}
|
|
|
|
export const opencode = agent({
|
|
name: "opencode",
|
|
install: installOpencode,
|
|
run: async (ctx) => {
|
|
// install CLI at start of run
|
|
const cliPath = await installOpencode();
|
|
|
|
// 1. configure home/config directory
|
|
const tempHome = ctx.tmpdir;
|
|
const configDir = join(tempHome, ".config", "opencode");
|
|
mkdirSync(configDir, { recursive: true });
|
|
|
|
configureOpenCode(ctx);
|
|
|
|
// message positional must come right after "run", before flags.
|
|
// --print-logs makes OpenCode write internal logs to stderr (otherwise they only go to a log file).
|
|
// this is critical for debugging since opencode run suppresses errors by default (Issue #752).
|
|
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
|
|
|
// resolve model override from environment.
|
|
// precedence:
|
|
// 1) effort-specific overrides (OPENCODE_MODEL_MINI / OPENCODE_MODEL_MAX)
|
|
// 2) OPENCODE_MODEL fallback
|
|
// 3) OpenCode auto-select
|
|
const modelOverride = resolveModelOverride({
|
|
effort: ctx.payload.effort,
|
|
env: process.env,
|
|
});
|
|
if (modelOverride) {
|
|
args.push("--model", modelOverride.model);
|
|
log.info(`» model: ${modelOverride.model} (override via ${modelOverride.source})`);
|
|
} else {
|
|
log.info(`» model: auto-selected by OpenCode`);
|
|
}
|
|
|
|
process.env.HOME = tempHome;
|
|
|
|
// 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: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: tempHome,
|
|
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
|
// set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set)
|
|
GOOGLE_GENERATIVE_AI_API_KEY:
|
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
|
};
|
|
|
|
if (modelOverride) {
|
|
const inlineOverride = buildInlineConfigOverride({ model: modelOverride.model });
|
|
if (inlineOverride) {
|
|
env.OPENCODE_CONFIG_CONTENT = inlineOverride.content;
|
|
log.info(
|
|
`» OpenCode inline config override enabled: provider=${inlineOverride.providerId}, model=${modelOverride.model}`
|
|
);
|
|
} else {
|
|
log.warning(
|
|
`» skipping OpenCode inline config override: unable to parse provider from model "${modelOverride.model}"`
|
|
);
|
|
}
|
|
}
|
|
|
|
const hasOpenRouterKey = Boolean(env.OPENROUTER_API_KEY);
|
|
const hasAnthropicKey = Boolean(env.ANTHROPIC_API_KEY);
|
|
const hasOpenAiKey = Boolean(env.OPENAI_API_KEY);
|
|
const hasGoogleKey = Boolean(
|
|
env.GOOGLE_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_GENERATIVE_AI_API_KEY
|
|
);
|
|
log.info(
|
|
`» provider key presence: OPENROUTER=${hasOpenRouterKey ? "set" : "unset"}, ANTHROPIC=${hasAnthropicKey ? "set" : "unset"}, OPENAI=${hasOpenAiKey ? "set" : "unset"}, GOOGLE=${hasGoogleKey ? "set" : "unset"}`
|
|
);
|
|
|
|
// OpenCode doesn't support GitHub App installation tokens
|
|
delete env.GITHUB_TOKEN;
|
|
|
|
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
|
|
const repoDir = process.cwd();
|
|
|
|
log.debug(`» starting OpenCode: ${cliPath} ${args.join(" ")}`);
|
|
log.debug(`» working directory: ${repoDir}`);
|
|
log.debug(`» HOME: ${env.HOME}`);
|
|
log.debug(`» XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`);
|
|
|
|
const startTime = performance.now();
|
|
let eventCount = 0;
|
|
const thinkingTimer = new ThinkingTimer();
|
|
|
|
// reset module-level state before each run (same pattern as claude/codex/gemini).
|
|
// without this, a failed subprocess that never emits an init event would
|
|
// carry stale token counts or output from a prior delegation run.
|
|
finalOutput = "";
|
|
accumulatedTokens = { input: 0, output: 0 };
|
|
tokensLogged = false;
|
|
|
|
// track recent stderr lines for provider error diagnosis.
|
|
// when OpenCode goes silent on stdout, these are the only clue.
|
|
const recentStderr: string[] = [];
|
|
const MAX_STDERR_LINES = 20;
|
|
let lastProviderError: string | null = null;
|
|
|
|
let output = "";
|
|
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
|
|
|
|
try {
|
|
const result = await spawn({
|
|
cmd: cliPath,
|
|
args,
|
|
cwd: repoDir,
|
|
env,
|
|
activityTimeout: 0, // process-level activity timeout (5min) is the single authority
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
onStdout: async (chunk) => {
|
|
const text = chunk.toString();
|
|
output += text;
|
|
markActivity(); // reset activity timeout on any CLI output
|
|
|
|
// 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/shell tool calls
|
|
log.debug(JSON.stringify(event, null, 2));
|
|
|
|
const timeSinceLastActivity = getIdleMs();
|
|
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.info(
|
|
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
|
|
);
|
|
}
|
|
markActivity(); // reset activity timeout on every event
|
|
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
|
|
if (handler) {
|
|
await handler(event as never, thinkingTimer);
|
|
} 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) => {
|
|
const trimmed = chunk.trim();
|
|
if (!trimmed) return;
|
|
|
|
// track recent stderr for diagnosis
|
|
recentStderr.push(trimmed);
|
|
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
|
|
|
|
// detect provider errors and surface them prominently
|
|
const providerError = detectProviderError(trimmed);
|
|
if (providerError) {
|
|
lastProviderError = providerError;
|
|
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
|
} else {
|
|
//agent OpenCode's --print-logs output goes to stderr. demote internal
|
|
// INFO/DEBUG bus traffic to debug so it doesn't drown out tool
|
|
// call logs in the GitHub Actions step output.
|
|
log.debug(trimmed);
|
|
}
|
|
},
|
|
});
|
|
|
|
const duration = performance.now() - startTime;
|
|
log.info(
|
|
`» OpenCode CLI completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`
|
|
);
|
|
|
|
// if zero events processed, something went wrong - surface stderr context
|
|
if (eventCount === 0) {
|
|
const stderrContext = recentStderr.join("\n");
|
|
const diagnosis = lastProviderError
|
|
? `provider error: ${lastProviderError}`
|
|
: "unknown cause (no stdout events received)";
|
|
log.info(`» OpenCode produced 0 events (${diagnosis})`);
|
|
if (stderrContext) {
|
|
log.info(`» last stderr output:\n${stderrContext}`);
|
|
}
|
|
}
|
|
|
|
// 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)],
|
|
]);
|
|
}
|
|
|
|
const usage = buildOpenCodeUsage();
|
|
|
|
// return result
|
|
if (result.exitCode !== 0) {
|
|
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
|
const errorMessage =
|
|
result.stderr ||
|
|
result.stdout ||
|
|
`unknown error - no output from OpenCode CLI${errorContext}`;
|
|
log.error(
|
|
`OpenCode CLI exited with code ${result.exitCode}${errorContext}: ${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,
|
|
usage,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
output: finalOutput || output,
|
|
usage,
|
|
};
|
|
} catch (error) {
|
|
// activity timeout or process timeout - surface the real cause
|
|
const duration = performance.now() - startTime;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const isActivityTimeout = errorMessage.includes("activity timeout");
|
|
|
|
// build a diagnostic message that includes provider context
|
|
const stderrContext = recentStderr.slice(-10).join("\n");
|
|
const diagnosis = lastProviderError
|
|
? `likely cause: ${lastProviderError}`
|
|
: eventCount === 0
|
|
? "OpenCode produced 0 stdout events - check if the model provider is reachable"
|
|
: `${eventCount} events were processed before the hang`;
|
|
|
|
log.info(
|
|
`» OpenCode ${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: buildOpenCodeUsage(),
|
|
};
|
|
}
|
|
},
|
|
});
|
|
|
|
/**
|
|
* 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(ctx: AgentRunContext): void {
|
|
const configDir = join(ctx.tmpdir, ".config", "opencode");
|
|
mkdirSync(configDir, { recursive: true });
|
|
const configPath = join(configDir, "opencode.json");
|
|
const repoConfigPath = join(process.cwd(), "opencode.json");
|
|
const repoConfig = loadRepoOpenCodeConfig({ repoConfigPath });
|
|
if (repoConfig?.model) {
|
|
log.info(`» repo opencode model configured: ${repoConfig.model}`);
|
|
}
|
|
|
|
// build MCP servers config
|
|
const opencodeMcpServers: Record<string, unknown> = {};
|
|
const repoMcpServers = getRecordProperty({ value: repoConfig, key: "mcp" });
|
|
if (repoMcpServers) {
|
|
Object.assign(opencodeMcpServers, repoMcpServers);
|
|
}
|
|
opencodeMcpServers[ghPullfrogMcpName] = { type: "remote" as const, url: ctx.mcpServerUrl };
|
|
|
|
// build permission object based on tool permissions
|
|
// note: OpenCode has no built-in web search tool
|
|
const shell = ctx.payload.shell;
|
|
const permission: Record<string, unknown> = {};
|
|
const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" });
|
|
if (repoPermission) {
|
|
Object.assign(permission, repoPermission);
|
|
}
|
|
permission.edit = "deny";
|
|
permission.read = "deny";
|
|
permission.bash = shell !== "enabled" ? "deny" : "allow";
|
|
permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow";
|
|
permission.external_directory = "deny";
|
|
|
|
// build complete config in one object
|
|
const config: OpenCodeConfig = {};
|
|
if (repoConfig) {
|
|
Object.assign(config, repoConfig);
|
|
}
|
|
config.mcp = opencodeMcpServers;
|
|
config.permission = 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}`);
|
|
log.debug(`» disallowed built-ins: ${JSON.stringify(permission)}`);
|
|
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;
|
|
|
|
function buildOpenCodeUsage(): AgentUsage | undefined {
|
|
return accumulatedTokens.input > 0 || accumulatedTokens.output > 0
|
|
? {
|
|
agent: "opencode",
|
|
inputTokens: accumulatedTokens.input,
|
|
outputTokens: accumulatedTokens.output,
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
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.debug(
|
|
`» OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
|
|
);
|
|
log.debug(`» 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.debug(
|
|
`» OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
|
|
);
|
|
} else {
|
|
// complete messages
|
|
log.debug(
|
|
`» OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
|
|
);
|
|
finalOutput = message;
|
|
}
|
|
}
|
|
} else if (event.role === "user") {
|
|
log.debug(
|
|
`» 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, thinkingTimer: ThinkingTimer) => {
|
|
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;
|
|
|
|
if (!toolName || !toolId) {
|
|
// surface dropped tool_use events visibly so missing tool calls are diagnosable
|
|
log.info(
|
|
`» tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// track tool call in current step
|
|
if (stepHistory.length > 0) {
|
|
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
|
|
}
|
|
|
|
thinkingTimer.markToolCall();
|
|
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, thinkingTimer: ThinkingTimer) => {
|
|
// 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;
|
|
|
|
thinkingTimer.markToolResult();
|
|
|
|
if (toolId) {
|
|
const toolStartTime = toolCallTimings.get(toolId);
|
|
if (toolStartTime) {
|
|
const toolDuration = performance.now() - toolStartTime;
|
|
toolCallTimings.delete(toolId);
|
|
const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
|
|
log.debug(
|
|
`» OpenCode tool_result${stepContext}: id=${toolId}, status=${status}, duration=${Math.round(toolDuration)}ms`
|
|
);
|
|
if (output) {
|
|
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
|
|
}
|
|
if (toolDuration > 5000) {
|
|
log.info(
|
|
`» ⚠️ 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.info(`» ❌ tool call failed: ${errorMsg}`);
|
|
} else if (output) {
|
|
// log successful tool result so it appears in captured output
|
|
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
|
|
log.debug(`tool output: ${outputStr}`);
|
|
}
|
|
},
|
|
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.info(`» 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(`» run complete: 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;
|
|
}
|
|
}
|
|
},
|
|
};
|