import { execSync, spawn } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { IntelligenceBackend, BackendRequest, BackendResult, BackendType, OpencodeBackendConfig, emptyTokenUsage, emptyBackendResult, } from "./types.js"; export class OpencodeBackend implements IntelligenceBackend { readonly name = "opencode"; readonly type: BackendType = "agent"; private config: OpencodeBackendConfig; constructor(config?: OpencodeBackendConfig) { this.config = config || { enabled: true }; } async isAvailable(): Promise { const executable = this.config.executable || "opencode"; try { const result = execSync(`${executable} --version`, { encoding: "utf-8", timeout: 5000, stdio: "pipe", }); return !!result; } catch { return false; } } async execute(request: BackendRequest): Promise { const executable = this.config.executable || "opencode"; const startTime = Date.now(); try { const serializedRequest = this.serializeRequest(request); const tempFile = path.join( os.tmpdir(), `ci-request-${request.persona}-${Date.now()}.json` ); fs.writeFileSync(tempFile, serializedRequest, "utf-8"); const command = `${executable} --non-interactive "/ci-${request.workflow} ${request.task}"`; const contextEnv = { ...process.env, CI_BACKEND_REQUEST: tempFile, CI_PROJECT_PATH: request.context.project_path, CI_PHASE: String(request.context.phase), CI_STAGE: request.context.stage, CI_AUTONOMY: request.autonomy, }; const result = execSync(command, { cwd: request.context.project_path, encoding: "utf-8", timeout: 600000, env: contextEnv, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024, }); try { fs.unlinkSync(tempFile); } catch {} return this.parseResult(result, Date.now() - startTime); } catch (err) { const execErr = err as { stderr?: string; status?: number }; try { const tempFile = path.join( os.tmpdir(), `ci-request-${request.persona}-${startTime}.json` ); if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); } catch {} if (execErr.stderr) { return emptyBackendResult( `opencode execution failed (exit ${execErr.status || "unknown"}): ${execErr.stderr}` ); } return emptyBackendResult( `opencode backend error: ${err instanceof Error ? err.message : String(err)}` ); } } private serializeRequest(request: BackendRequest): string { return JSON.stringify({ persona: request.persona, workflow: request.workflow, task: request.task, context: { project_path: request.context.project_path, phase: request.context.phase, stage: request.context.stage, specification: request.context.specification, config_path: request.context.config_path, }, autonomy: request.autonomy, }, null, 2); } private parseResult(output: string, durationMs: number): BackendResult { const jsonMatch = output.match(/\{[\s\S]*"success"[\s\S]*\}/); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[0]); return { success: parsed.success ?? true, output: parsed.output || output, artifacts: Array.isArray(parsed.artifacts) ? parsed.artifacts.filter((a: unknown) => !!a).map((a: Record) => ({ path: String(a.path || ""), content: String(a.content || ""), operation: (a.operation as "create" | "update" | "delete") || "create", })) : [], decisions: Array.isArray(parsed.decisions) ? parsed.decisions.filter((d: unknown) => !!d).map((d: Record) => ({ id: String(d.id || "D-000"), decision: String(d.decision || ""), rationale: String(d.rationale || ""), confidence: Number(d.confidence || 0.5), category: (d.category as "implementation_approach" | "technology_choice" | "architecture" | "scope" | "verification" | "security" | "deployment" | "general") || "general", alternatives_considered: Array.isArray(d.alternatives_considered) ? d.alternatives_considered.map((a: unknown) => typeof a === "string" ? { option: a, rejected_reason: "" } : (a as { option: string; rejected_reason: string }) ) : [], learnship_equivalent: String(d.learnship_equivalent || ""), human_override: d.human_override ? String(d.human_override) : null, timestamp: String(d.timestamp || new Date().toISOString()), })) : [], escalations: Array.isArray(parsed.escalations) ? parsed.escalations.filter((e: unknown) => !!e).map((e: Record) => ({ id: String(e.id || "E-000"), timestamp: String(e.timestamp || new Date().toISOString()), type: (e.type as "irreversible_action" | "verification_failure" | "low_confidence_decision" | "security_escalation" | "specification_ambiguity") || "specification_ambiguity", phase: String(e.phase || ""), description: String(e.description || ""), context: String(e.context || ""), options: Array.isArray(e.options) ? e.options : [], default_option_id: String(e.default_option_id || ""), resolution: (e.resolution as "approved" | "rejected" | "modified" | "pending" | "timeout_auto_proceed") || "pending", audit_file: String(e.audit_file || ""), })) : [], usage: parsed.usage || { ...emptyTokenUsage(), total_tokens: Math.ceil(output.length / 4), }, }; } catch {} } return { success: true, output, artifacts: [], decisions: [], escalations: [], usage: { ...emptyTokenUsage(), total_tokens: Math.ceil(output.length / 4), }, }; } }