import type { ResolvedModel } from "@tasks/shared/model-routing"; import type { WorkerJobContext, WorkerSession } from "@tasks/shared/worker"; import { buildSkillFullContext, loadAgentSkills } from "./load-skills.js"; /** * Configuration for a headless OpenCode server (`opencode serve` / `opencode web`). * Auth is HTTP basic when OPENCODE_SERVER_PASSWORD is set on the server. */ export type OpenCodeConfig = { serverUrl: string; /** Basic-auth password; empty = no Authorization header. */ serverPassword: string; /** Basic-auth username (OpenCode default: opencode). */ serverUsername: string; /** Repo root to scan for .agents/skills/ and to describe in the prompt. */ repoRoot: string; /** OpenCode provider id registered by the OmniRoute plugin (default omniroute). */ providerId: string; }; export type OpenCodeSessionOptions = { config: OpenCodeConfig; resolvedModel: ResolvedModel; roleId: string; briefId?: string; briefTitle?: string; workflowPrompt?: string; briefAcceptance?: string; briefFiles?: string[]; maxSteps?: number; }; type SessionCreateResponse = { id: string; title?: string }; type MessageResponse = { info?: { role?: string; error?: { name?: string; message?: string } }; parts?: Array<{ type?: string; text?: string; tool?: string }>; }; function basicAuthHeader(username: string, password: string): string { const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); return `Basic ${token}`; } function stripProviderPrefix(modelId: string, providerId: string): string { const prefix = `${providerId}/`; return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId; } /** * Create an OpenCode session that drives a remote `opencode serve` over HTTP. * Prefer this over OpenHands on CT 116 — same OmniRoute catalog via the plugin, * no Docker-in-Docker sandbox, one concurrent agent until RAM upgrade. */ export async function createOpenCodeSession( context: WorkerJobContext, options: OpenCodeSessionOptions, ): Promise { const { config, resolvedModel, roleId, briefId, briefTitle, workflowPrompt, briefAcceptance, briefFiles, } = options; const apiBase = config.serverUrl.replace(/\/$/, ""); const headers: Record = { "Content-Type": "application/json", Accept: "application/json", }; if (config.serverPassword) { headers.Authorization = basicAuthHeader(config.serverUsername, config.serverPassword); } let skillContext = ""; try { const skills = await loadAgentSkills(config.repoRoot); skillContext = buildSkillFullContext(skills); } catch (e) { console.warn("[opencode] skill loading failed, continuing without skills:", e); } const systemParts: string[] = [ `You are an AI agent executing a brief for convoy "${context.convoyId}".`, `Role: ${roleId}`, briefId ? `Brief ID: ${briefId}` : "", briefTitle ? `Brief: ${briefTitle}` : "", `Working directory: ${config.repoRoot}`, workflowPrompt ? `\n## Task\n${workflowPrompt}` : "", briefAcceptance ? `\n## Acceptance criteria\n${briefAcceptance}` : "", briefFiles?.length ? `\n## Files to examine\n${briefFiles.map((f) => `- ${f}`).join("\n")}` : "", skillContext || "", "\n## Instructions\nUse the available tools to inspect and modify the repo. Run lint/type-check when appropriate. Finish with a concise summary of what changed.", ].filter(Boolean); const systemPrompt = systemParts.join("\n"); const modelId = stripProviderPrefix(resolvedModel.modelId, config.providerId); async function createSession(title: string): Promise { const res = await fetch(`${apiBase}/session`, { method: "POST", headers, body: JSON.stringify({ title }), }); if (!res.ok) { throw new Error(`OpenCode session create failed (${res.status}): ${await res.text()}`); } const data = (await res.json()) as SessionCreateResponse; if (!data.id) throw new Error("OpenCode session create returned no id"); return data.id; } async function sendPrompt(sessionId: string, prompt: string): Promise<{ summary: string; toolCalls: number; }> { const res = await fetch(`${apiBase}/session/${sessionId}/message`, { method: "POST", headers, body: JSON.stringify({ model: { providerID: config.providerId, modelID: modelId }, system: systemPrompt, parts: [{ type: "text", text: prompt }], }), }); if (!res.ok) { throw new Error(`OpenCode prompt failed (${res.status}): ${await res.text()}`); } const data = (await res.json()) as MessageResponse; if (data.info?.error) { throw new Error( `OpenCode error ${data.info.error.name ?? "unknown"}: ${data.info.error.message ?? ""}`, ); } const textParts = (data.parts ?? []) .filter((p) => p.type === "text" && p.text) .map((p) => p.text as string); const toolCalls = (data.parts ?? []).filter((p) => p.type === "tool" || p.tool).length; return { summary: textParts.join("\n").trim() || "OpenCode session completed with no text summary.", toolCalls, }; } return { context, async run(prompt: string) { console.info( `[opencode] session.run roleId=${roleId} brief=${briefId ?? "none"} model=${config.providerId}/${modelId}`, ); try { const title = `convoy:${context.convoyId}:${roleId}:${briefId ?? "none"}`; const sessionId = await createSession(title); console.info(`[opencode] session started id=${sessionId}`); const result = await sendPrompt(sessionId, prompt); console.info( `[opencode] session complete tools=${result.toolCalls} summary_len=${result.summary.length}`, ); return result; } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error(`[opencode] session failed:`, msg); return { summary: `OpenCode session failed: ${msg}`, toolCalls: 0, }; } }, }; }