e6d34ee01b
* add OpenRouter proxy for managed model routing and OSS program proxy layer that mints ephemeral OpenRouter keys for users without BYOK API keys. two paths: pro plan users get their selected model proxied via OpenRouter; OSS program repos (hard-coded allowlist in config.ts) get free Claude Opus. BYOK env vars (PULLFROG_MODEL/OPENCODE_MODEL) always take precedence. frontend: OSS repos see a static "Opus (Free)" badge with the model dropdown disabled and no API key requirement. all models now carry openRouterResolve metadata for proxy target resolution. Made-with: Cursor * implement OSS program: proxy infrastructure for free model credits server-side OSS allowlist determines eligible repos. action mints ephemeral OpenRouter keys via OIDC-authenticated /api/proxy-token endpoint (idempotent on runId, $10 per-key safety limit). keys are disabled on workflow completion when no running refs remain. HWM-based usage sync tracks cumulative spend per account. schema: ProxyKey model, Account.usageUsd/activeKeyId, WorkflowRun.proxyKeyId action: OIDC credential stashing, resolveProxyModel uses server oss flag frontend: isOss flows from server page to components (no client allowlist) Made-with: Cursor * address PR review: repo cross-check, key retirement lifecycle, dead code removal - proxy-token: verify runId belongs to OIDC-authenticated repo via repo relation - add retireKey() shared primitive: disable in OpenRouter first, then mark disabled in DB - rotateKey: retire old active key after swap to prevent orphans - webhook: replace inline cleanupProxyKey with retireKey calls - syncAccountUsage: skip disabled keys - remove vestigial AccountPlan/plan field from action types - add disabled field to ProxyKey schema + migration Made-with: Cursor * replace deprecated opencode/mimo-v2-flash-free with mimo-v2-pro-free Made-with: Cursor * fix migration ordering: rename disabled migration to sort after table creation Made-with: Cursor * squash proxy key migrations into single migration Made-with: Cursor * add preview repo to OSS allowlist for testing Made-with: Cursor * populate OSS allowlist from oss-program-invitees.json Made-with: Cursor * format oss-program-invitees.json Made-with: Cursor * add installed public repos to OSS allowlist split ossRepos into three provenance-tracked lists: - internalRepos (pullfrog, colinhacks, RobinTail) - installedPublicRepos (external public non-fork repos with active installs) - invitees (from oss-program-invitees.json) also adds scripts/list-oss-candidates.ts to regenerate the installed list Made-with: Cursor * fix: resolve tokens before clearing OIDC env vars resolveTokens → acquireNewToken → isOIDCAvailable() checks ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN env vars. The new OIDC stashing code was deleting them in restricted shell mode before resolveTokens ran, causing it to fall through to the GitHub App path which requires GITHUB_APP_ID/GITHUB_PRIVATE_KEY. Made-with: Cursor * derive proxy-token auth from OIDC claims, add ensureWorkflowRun upsert - proxy-token no longer requires body.runId; uses claims.run_id + claims.repository - shared ensureWorkflowRun upsert called from both webhook and proxy-token - workflow_run_requested handler now eagerly creates WorkflowRun records - eliminates race condition between webhook and action proxy-token call Made-with: Cursor * hardcode PULLFROG_ACCOUNT_ID, document preview debugging lessons GitHub node IDs are constant — no reason for this to be an env var. Removes the trailing-newline bug that caused P2025 errors. Adds wiki docs on workflow testing, Vercel env gotchas, and Neon preview branch discovery. Made-with: Cursor * fix: parse OpenRouter create-key response correctly the API returns `key` at the top level, not inside `data` Made-with: Cursor * onboarding cards, unlock OSS model selection, simplify console - add OnboardingCard component with two states: workflow install and model+test (dispatches "Tell me a joke" for test run) - replace PromptBox overlay gates with dedicated onboarding cards; PromptBox is now just the form, always enabled - use hasWorkflowRuns DB check to decide onboarding vs promptbox - unlock ModelSelector for OSS repos (was locked to Opus badge); resolve proxyModel from repo's selected model alias in run-context - rename "API key" row to "Model costs" with pure client-side states: OSS covered, auto-resolve, free model, BYOK with env var names - add "(Recommended)" badge to model aliases with recommended: true - remove OSS_MODEL_DISPLAY_NAME constant and secrets-fetching logic Made-with: Cursor * update stale xai model snapshot Made-with: Cursor * rename Permissions to Security, hide git push toggle, add shell isolation toggle with disabled state for public repos, remove opentoad agent name from logs Made-with: Cursor * chevron hover states, sidebar hooks/security entries Made-with: Cursor * address PR review feedback: rename recommended→preferred, fix dispatch orphan, update wiki docs - rename `recommended` to `preferred` in model alias registry to distinguish from the UI "Recommended" badge (which is hardcoded for opus + codex only) - cancel precreated WorkflowRun when workflow lookup fails in dispatch-workflow - replace run_sql/vercel env pull in wiki docs with neonctl + Prisma pattern - extend scripts/neon-branch.ts to output DATABASE_URL via neonctl - add Recommended badge to GPT Codex alongside Claude Opus in ModelSelector Made-with: Cursor
661 lines
22 KiB
TypeScript
661 lines
22 KiB
TypeScript
/**
|
|
* OpenToad agent — secure harness around OpenCode CLI.
|
|
*
|
|
* transparently wraps OpenCode with a security layer:
|
|
* - bash: "deny" via OPENCODE_CONFIG_CONTENT (agent cannot shell out)
|
|
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
|
|
* - MCP server injected alongside project config (not replacing)
|
|
* - 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 { execFileSync } from "node:child_process";
|
|
import { mkdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import { ghPullfrogMcpName } from "../external.ts";
|
|
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 { spawn } from "../utils/subprocess.ts";
|
|
import { ThinkingTimer } from "../utils/timer.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,
|
|
executablePath: "bin/opencode",
|
|
installDependencies: true,
|
|
});
|
|
}
|
|
|
|
// ── config ─────────────────────────────────────────────────────────────────────
|
|
|
|
type OpenCodeConfig = {
|
|
mcp?: Record<string, unknown>;
|
|
permission?: Record<string, unknown>;
|
|
provider?: Record<string, unknown>;
|
|
model?: string;
|
|
enabled_providers?: string[];
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
|
const config: OpenCodeConfig = {
|
|
permission: {
|
|
bash: "deny",
|
|
edit: "allow",
|
|
read: "allow",
|
|
webfetch: "allow",
|
|
external_directory: "deny",
|
|
},
|
|
mcp: {
|
|
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
|
},
|
|
};
|
|
|
|
if (model) {
|
|
config.model = model;
|
|
|
|
const slashIndex = model.indexOf("/");
|
|
if (slashIndex > 0) {
|
|
config.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
|
|
}
|
|
}
|
|
|
|
return JSON.stringify(config);
|
|
}
|
|
|
|
// ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
|
|
//
|
|
// priority:
|
|
// 1. PULLFROG_MODEL env var (explicit override)
|
|
// 2. explicit slug from repo config / payload
|
|
// 3. auto-select: `opencode models` → preferred aliases first, then secondary
|
|
// 4. undefined → let OpenCode decide
|
|
|
|
function getOpenCodeModels(cliPath: string): string[] {
|
|
try {
|
|
const output = execFileSync(cliPath, ["models"], {
|
|
encoding: "utf-8",
|
|
timeout: 30_000,
|
|
env: process.env,
|
|
});
|
|
return output
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
} catch (error) {
|
|
log.debug(
|
|
`» failed to run \`opencode models\`: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
const AUTO_SELECT_WARNING =
|
|
"select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
|
|
|
|
function resolveOpenCodeModel(ctx: {
|
|
cliPath: string;
|
|
modelSlug?: string | undefined;
|
|
}): string | undefined {
|
|
// 1. explicit env var override
|
|
const envModel = process.env.PULLFROG_MODEL?.trim();
|
|
if (envModel) {
|
|
log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`);
|
|
return envModel;
|
|
}
|
|
|
|
// 2. explicit slug from repo config / payload
|
|
if (ctx.modelSlug) {
|
|
const resolved = resolveCliModel(ctx.modelSlug);
|
|
if (resolved) {
|
|
log.info(`» model: ${resolved} (from repo config)`);
|
|
return resolved;
|
|
}
|
|
log.warning(`» unknown model slug "${ctx.modelSlug}" — falling through to auto-select`);
|
|
}
|
|
|
|
// 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: 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.preferred && availableSet.has(a.resolve)) ??
|
|
modelAliases.find((a) => availableSet.has(a.resolve));
|
|
if (match) {
|
|
log.info(
|
|
`» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)`
|
|
);
|
|
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
|
|
return match.resolve;
|
|
}
|
|
log.info(
|
|
`» opencode has ${availableSet.size} models but none match curated aliases — letting OpenCode auto-select`
|
|
);
|
|
}
|
|
|
|
log.warning(`» no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`);
|
|
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 {
|
|
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 } };
|
|
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;
|
|
|
|
// ── runner ──────────────────────────────────────────────────────────────────────
|
|
|
|
type RunParams = {
|
|
label: string;
|
|
cliPath: string;
|
|
args: string[];
|
|
cwd: string;
|
|
env: Record<string, string | undefined>;
|
|
};
|
|
|
|
async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
|
const startTime = performance.now();
|
|
let eventCount = 0;
|
|
const thinkingTimer = new ThinkingTimer();
|
|
|
|
let finalOutput = "";
|
|
let accumulatedTokens = { 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[] }> = [];
|
|
|
|
function buildUsage(): AgentUsage | undefined {
|
|
return accumulatedTokens.input > 0 || accumulatedTokens.output > 0
|
|
? {
|
|
agent: "opentoad",
|
|
inputTokens: accumulatedTokens.input,
|
|
outputTokens: accumulatedTokens.output,
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
const handlers = {
|
|
init: (event: OpenCodeInitEvent) => {
|
|
log.debug(
|
|
`» ${params.label} init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
|
|
);
|
|
log.debug(`» ${params.label} 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 (event.delta) {
|
|
log.debug(
|
|
`» ${params.label} thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
|
|
);
|
|
} else {
|
|
log.debug(
|
|
`» ${params.label} message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
|
|
);
|
|
finalOutput = message;
|
|
}
|
|
} else if (event.role === "user") {
|
|
log.debug(
|
|
`» ${params.label} message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
|
|
);
|
|
}
|
|
},
|
|
text: (event: OpenCodeTextEvent) => {
|
|
if (event.part?.text?.trim()) {
|
|
const message = event.part.text.trim();
|
|
log.box(message, { title: params.label });
|
|
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";
|
|
const eventTokens = event.part?.tokens;
|
|
if (eventTokens) {
|
|
accumulatedTokens.input += eventTokens.input || 0;
|
|
accumulatedTokens.output += eventTokens.output || 0;
|
|
}
|
|
if (currentStepId === stepId) {
|
|
currentStepId = null;
|
|
currentStepType = null;
|
|
}
|
|
},
|
|
tool_use: (event: OpenCodeToolUseEvent) => {
|
|
const toolName = event.part?.tool;
|
|
const toolId = event.part?.callID;
|
|
if (!toolName || !toolId) {
|
|
log.info(
|
|
`» tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (stepHistory.length > 0) {
|
|
stepHistory[stepHistory.length - 1]!.toolCalls.push(toolName);
|
|
}
|
|
|
|
thinkingTimer.markToolCall();
|
|
log.toolCall({ toolName, input: event.part?.state?.input || {} });
|
|
|
|
if (event.part?.state?.status === "completed" && event.part.state.output) {
|
|
log.debug(` output: ${event.part.state.output}`);
|
|
}
|
|
},
|
|
tool_result: (event: OpenCodeToolResultEvent) => {
|
|
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(
|
|
`» ${params.label} 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 - may indicate network latency`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (status === "error") {
|
|
const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
|
|
log.info(`» tool call failed: ${errorMsg}`);
|
|
} else if (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(
|
|
`» ${params.label} result: status=${status}, duration=${duration}ms, tool_calls=${toolCalls}`
|
|
);
|
|
|
|
if (event.status === "error") {
|
|
log.info(`» ${params.label} failed: ${JSON.stringify(event)}`);
|
|
} else {
|
|
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;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
const recentStderr: string[] = [];
|
|
const MAX_STDERR_LINES = 20;
|
|
let lastProviderError: string | null = null;
|
|
|
|
let output = "";
|
|
let stdoutBuffer = "";
|
|
|
|
try {
|
|
const result = await spawn({
|
|
cmd: params.cliPath,
|
|
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 OpenCodeEvent;
|
|
eventCount++;
|
|
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" : ""})`
|
|
: ` (${params.label} 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();
|
|
const handler = handlers[event.type as keyof typeof handlers];
|
|
if (handler) {
|
|
await handler(event as never);
|
|
} else {
|
|
log.info(
|
|
`» ${params.label} event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
|
|
);
|
|
}
|
|
} 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);
|
|
}
|
|
},
|
|
});
|
|
|
|
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 OpenCode 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) {
|
|
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
|
|
? "OpenCode produced 0 stdout events - check if the model provider 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 opentoad = agent({
|
|
name: "opentoad",
|
|
install: installOpencodeCli,
|
|
run: async (ctx) => {
|
|
const cliPath = await installOpencodeCli();
|
|
|
|
const model =
|
|
ctx.payload.proxyModel ??
|
|
resolveOpenCodeModel({
|
|
cliPath,
|
|
modelSlug: ctx.payload.model,
|
|
});
|
|
|
|
const tempHome = ctx.tmpdir;
|
|
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
|
|
|
|
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
|
|
|
|
// agent process gets full env — needs LLM API keys, PATH, locale, etc.
|
|
// 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"),
|
|
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
|
|
GOOGLE_GENERATIVE_AI_API_KEY:
|
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
|
};
|
|
|
|
const repoDir = process.cwd();
|
|
|
|
log.debug(`» starting OpenToad (OpenCode): ${cliPath} ${args.join(" ")}`);
|
|
log.debug(`» working directory: ${repoDir}`);
|
|
|
|
return runOpenCode({
|
|
label: "OpenToad",
|
|
cliPath,
|
|
args,
|
|
cwd: repoDir,
|
|
env,
|
|
});
|
|
},
|
|
});
|