+
+
+
CodeRAG
+
{isDock ? "Repo retrieval workspace" : "Repo context"}
+
+
void refreshCodeRagStatus(true)} type="button">
+ Refresh
+
+
+
{codeRagMessage}
+ {codeRagIndexSummary ?
{codeRagIndexSummary}
: null}
+
{codeRagIntegrationSummary}
+ {codeRagStorageRoot ?
Index root: {codeRagStorageRoot}
: null}
+
+ Search prompt
+
+
+ Traversal depth
+
+ adjustCodeRagDepth(-1)}
+ type="button"
+ >
+ −
+
+ handleCodeRagDepthInput(event.target.value)}
+ type="text"
+ value={String(codeRagDepth)}
+ />
+ = 6}
+ onClick={() => adjustCodeRagDepth(1)}
+ type="button"
+ >
+ +
+
+
+
+
+ void handleRunCodeRagQuery()}
+ type="button"
+ >
+ {codeRagLoading ? "Searching..." : "Search repo"}
+
+ {selectedNode ? (
+ {
+ setCodeRagQuery(`Explain ${selectedNode.name} and what it touches.`);
+ if (isDock) {
+ setActiveDockTab("repo");
+ }
+ }}
+ type="button"
+ >
+ Use selection
+
+ ) : null}
+
+
+ {codeRagError ? (
+
+ ) : null}
+
+ {codeRagResult ? (
+
+
+
Answer
+
{codeRagResult.answer}
+
+
+ {codeRagPrimaryNode ? (
+
+
+ Primary
+ {codeRagPrimaryNode.kind}
+
+
{codeRagPrimaryNode.name}
+
+ {codeRagPrimaryNode.filePath}:{codeRagPrimaryNode.startLine}-{codeRagPrimaryNode.endLine}
+
+
{codeRagPrimaryNode.doc}
+
openCodeRagContext(codeRagPrimaryNode)} type="button">
+ Open primary node
+
+
+ ) : null}
+
+ {codeRagRelatedNodes.length ? (
+
+
Related nodes
+
+ {codeRagRelatedNodes.slice(0, 4).map((node) => (
+
+
+
{node.name}
+
+ {node.relationship} · {node.filePath}:{node.startLine}
+
+
+
openCodeRagContext(node)} type="button">
+ Open
+
+
+ ))}
+
+
+ ) : null}
+
+ ) : (
+
+
+ {codeRagStatus === "ready"
+ ? "Search the repo to pin retrieval context for the coding agent and editor."
+ : "Build or export a repo-backed blueprint to initialize CodeRAG for this workspace."}
+
+
+ )}
+
+ );
+ };
+
const renderProblemsDock = () => (
@@ -3339,6 +3559,12 @@ export function BlueprintWorkbench() {
const traceCount = (latestSpans?.length ?? 0) + latestLogs.length;
const tabs: Array<{ id: IdeDockTab; label: string; badge?: number; content: ReactNode }> = [
{ id: "terminal", label: "terminal", badge: activityFeed.length || undefined, content: renderTerminalDock() },
+ {
+ id: "repo",
+ label: "repo",
+ badge: codeRagResult?.context.relatedNodes.length || (codeRagStatus === "ready" ? 1 : undefined),
+ content: renderCodeRagWorkspace("dock")
+ },
{ id: "heatmap", label: "heatmap", badge: heatmapData?.nodes.length || undefined, content: renderHeatmapDock() },
{ id: "vcr", label: "vcr", badge: vcrRecording?.frames.length || undefined, content: renderVcrDock() },
{ id: "traces", label: "traces", badge: traceCount || undefined, content: renderTracesDock() },
@@ -3373,16 +3599,6 @@ export function BlueprintWorkbench() {
const sidebarNode = selectedNode;
const sidebarContract = sidebarNode ? normalizeContract(sidebarNode.contract) : null;
const sourceNavigationTarget = sidebarNode ? getNavigationTarget(sidebarNode) : null;
- const codeRagPrimaryNode = codeRagResult?.context.primaryNode ?? null;
- const codeRagRelatedNodes = codeRagResult?.context.relatedNodes ?? [];
- const openCodeRagContext = (node: RetrievedNodeContext) =>
- handleOpenFile(node.filePath, {
- filePath: node.filePath,
- lineNumber: node.startLine,
- endLineNumber: node.endLine,
- columnStart: 1,
- symbolName: node.name
- });
return (
@@ -3412,6 +3628,7 @@ export function BlueprintWorkbench() {
void handleBuild()} type="button">Build
void handleRunAnalysis()} type="button">Analyze graph
+ setActiveDockTab("repo")} type="button">Repo
setActiveDockTab("traces")} type="button">Traces
setActiveDockTab("problems")} type="button">Problems
@@ -3423,122 +3640,7 @@ export function BlueprintWorkbench() {
-
-
-
Repo context
- void refreshCodeRagStatus(true)} type="button">
- Refresh
-
-
-
{codeRagMessage}
-
- This search prompt feeds the backend CodeRAG engine. Its retrieved context is injected into Suggest code, Implement node, and Monaco completions.
-
-
- Search prompt
-
-
- Traversal depth
- {
- const nextDepth = Number(event.target.value);
- if (!Number.isFinite(nextDepth)) {
- return;
- }
-
- setCodeRagDepth(Math.max(1, Math.min(6, Math.floor(nextDepth))));
- }}
- type="number"
- value={codeRagDepth}
- />
-
-
- void handleRunCodeRagQuery()}
- type="button"
- >
- {codeRagLoading ? "Searching..." : "Search repo"}
-
- {sidebarNode ? (
- setCodeRagQuery(`Explain ${sidebarNode.name} and what it touches.`)}
- type="button"
- >
- Use selection
-
- ) : null}
-
-
- {codeRagError ? (
-
- ) : null}
-
- {codeRagResult ? (
-
-
-
Answer
-
{codeRagResult.answer}
-
-
- {codeRagPrimaryNode ? (
-
-
- Primary
- {codeRagPrimaryNode.kind}
-
-
{codeRagPrimaryNode.name}
-
- {codeRagPrimaryNode.filePath}:{codeRagPrimaryNode.startLine}-{codeRagPrimaryNode.endLine}
-
-
{codeRagPrimaryNode.doc}
-
openCodeRagContext(codeRagPrimaryNode)} type="button">
- Open primary node
-
-
- ) : null}
-
- {codeRagRelatedNodes.length ? (
-
-
Related nodes
-
- {codeRagRelatedNodes.slice(0, 4).map((node) => (
-
-
-
{node.name}
-
- {node.relationship} · {node.filePath}:{node.startLine}
-
-
-
openCodeRagContext(node)} type="button">
- Open
-
-
- ))}
-
-
- ) : null}
-
- ) : (
-
-
- {codeRagStatus === "ready"
- ? "Search the repo to pin retrieval context for the coding agent and editor."
- : "Build or export a repo-backed blueprint to initialize CodeRAG for this workspace."}
-
-
- )}
-
+ {renderCodeRagWorkspace("sidebar")}
{navigationError ? (
@@ -3924,6 +4026,44 @@ export function BlueprintWorkbench() {
Analyze drift
+
+ {/* OpenCode Integration Section */}
+
+
OpenCode Agent
+
+
+
+ {opencodeStatus.status === "running"
+ ? "Connected"
+ : opencodeStatus.status === "starting"
+ ? "Starting..."
+ : opencodeStatus.status === "error"
+ ? "Error"
+ : "Not connected"}
+
+
+
+ setUseOpencodeForAgent(e.target.checked)}
+ type="checkbox"
+ />
+ Use OpenCode for code generation
+
+
setShowOpencodePanel(true)} type="button">
+ Configure OpenCode
+
+
+
+ ) : null}
+
+ {/* OpenCode Settings Panel */}
+ {showOpencodePanel ? (
+
+ setShowOpencodePanel(false)}
+ onStatusChange={setOpencodeStatus}
+ />
) : null}
diff --git a/src/components/opencode-settings.tsx b/src/components/opencode-settings.tsx
new file mode 100644
index 0000000..06ebbf5
--- /dev/null
+++ b/src/components/opencode-settings.tsx
@@ -0,0 +1,666 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type { OpencodeProvider, OpencodeServerInfo, OpencodeConfig, McpServerConfig as McpServer } from "@/lib/opencode/types";
+import { PROVIDER_CONFIGS } from "@/lib/opencode/types";
+import {
+ checkServerStatus,
+ startServer,
+ stopServer,
+ restartServer,
+} from "@/lib/opencode/client";
+import {
+ loadConfig,
+ saveConfig,
+ validateConfig,
+ detectProvider,
+ buildOpencodeConfig,
+} from "@/lib/opencode/config";
+import { ApiKeyValidator } from "@/lib/opencode/api-key-validator";
+
+const PROVIDERS: { id: OpencodeProvider; label: string }[] = [
+ { id: "anthropic", label: "Anthropic (Claude)" },
+ { id: "openai", label: "OpenAI (GPT)" },
+ { id: "google", label: "Google (Gemini)" },
+ { id: "azure", label: "Azure OpenAI" },
+ { id: "groq", label: "Groq" },
+ { id: "mistral", label: "Mistral" },
+ { id: "cohere", label: "Cohere" },
+ { id: "perplexity", label: "Perplexity" },
+ { id: "openrouter", label: "OpenRouter" },
+ { id: "bedrock", label: "AWS Bedrock" },
+ { id: "local", label: "Local Model" },
+];
+
+type Props = {
+ onClose?: () => void;
+ onStatusChange?: (status: OpencodeServerInfo) => void;
+};
+
+export function OpencodeSettings({ onClose, onStatusChange }: Props) {
+ const [provider, setProvider] = useState
("anthropic");
+ const [apiKey, setApiKey] = useState("");
+ const [model, setModel] = useState("");
+ const [baseUrl, setBaseUrl] = useState("");
+ const [serverStatus, setServerStatus] = useState({ status: "stopped" });
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [showAdvanced, setShowAdvanced] = useState(false);
+
+ // Track if provider was manually selected (vs auto-detected)
+ const [isManuallySelected, setIsManuallySelected] = useState(false);
+
+ // MCP server configuration
+ const [mcpServers, setMcpServers] = useState([]);
+ const [newMcpName, setNewMcpName] = useState("");
+ const [newMcpCommand, setNewMcpCommand] = useState("");
+ const [newMcpArgs, setNewMcpArgs] = useState("");
+
+ // Skills configuration
+ const [skills, setSkills] = useState([]);
+ const [newSkill, setNewSkill] = useState("");
+
+ // Hooks configuration
+ const [hooks, setHooks] = useState([]);
+ const [newHook, setNewHook] = useState("");
+
+ // Agent selection
+ const [selectedAgent, setSelectedAgent] = useState<"build" | "plan">("build");
+
+ // Load saved config on mount - ONLY on initial load
+ useEffect(() => {
+ const saved = loadConfig();
+ if (saved) {
+ setProvider(saved.provider);
+ setModel(saved.model || "");
+ setBaseUrl(saved.baseUrl || "");
+ setMcpServers(saved.mcpServers || []);
+ setSkills(saved.skills || []);
+ setHooks(saved.hooks || []);
+ if (saved.apiKey) {
+ setApiKey(saved.apiKey);
+ }
+ // Mark as manually selected since it was saved by user
+ setIsManuallySelected(true);
+ }
+
+ // Check server status
+ checkServerStatus()
+ .then((info) => {
+ setServerStatus(info);
+ onStatusChange?.(info);
+ })
+ .catch(() => {
+ setServerStatus({ status: "stopped" });
+ });
+ }, []); // Empty dependency: run only on mount
+
+ // Auto-detect provider from API key - ONLY on initial load if no saved config
+ useEffect(() => {
+ // Skip auto-detection if user manually selected provider
+ if (isManuallySelected) {
+ return;
+ }
+
+ // Only auto-detect if apiKey exists and provider still at default
+ if (apiKey && apiKey.length > 10 && provider === "anthropic") {
+ const detected = detectProvider(apiKey);
+ if (detected && detected !== provider) {
+ setProvider(detected);
+ setModel(PROVIDER_CONFIGS[detected].defaultModel);
+ }
+ }
+ }, [apiKey]); // Only depend on apiKey, not provider
+
+ // Set default model when provider changes
+ useEffect(() => {
+ if (!model) {
+ setModel(PROVIDER_CONFIGS[provider].defaultModel);
+ }
+ }, [provider, model]);
+
+ const handleStartServer = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const config = buildOpencodeConfig(provider, apiKey, {
+ model: model || undefined,
+ baseUrl: baseUrl || undefined,
+ mcpServers,
+ skills,
+ hooks,
+ });
+
+ const validation = validateConfig(config);
+ if (!validation.valid) {
+ setError(validation.error || "Invalid configuration");
+ setIsLoading(false);
+ return;
+ }
+
+ // Save config before starting
+ saveConfig(config);
+
+ const info = await startServer({
+ provider,
+ apiKey,
+ model: model || undefined,
+ baseUrl: baseUrl || undefined,
+ });
+
+ setServerStatus(info);
+ onStatusChange?.(info);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to start server");
+ setServerStatus({ status: "error", error: String(err) });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [provider, apiKey, model, baseUrl, mcpServers, skills, hooks, onStatusChange]);
+
+ const handleStopServer = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ await stopServer();
+ setServerStatus({ status: "stopped" });
+ onStatusChange?.({ status: "stopped" });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to stop server");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [onStatusChange]);
+
+ const handleRestartServer = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const info = await restartServer({
+ provider,
+ apiKey,
+ model: model || undefined,
+ baseUrl: baseUrl || undefined,
+ });
+
+ setServerStatus(info);
+ onStatusChange?.(info);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to restart server");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [provider, apiKey, model, baseUrl, onStatusChange]);
+
+ const handleAddMcpServer = useCallback(() => {
+ if (!newMcpName.trim() || !newMcpCommand.trim()) return;
+
+ const server: McpServer = {
+ name: newMcpName.trim(),
+ command: newMcpCommand.trim(),
+ args: newMcpArgs ? newMcpArgs.split(" ").filter(Boolean) : undefined,
+ };
+
+ setMcpServers((prev) => [...prev, server]);
+ setNewMcpName("");
+ setNewMcpCommand("");
+ setNewMcpArgs("");
+ }, [newMcpName, newMcpCommand, newMcpArgs]);
+
+ const handleRemoveMcpServer = useCallback((index: number) => {
+ setMcpServers((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
+ const handleAddSkill = useCallback(() => {
+ if (!newSkill.trim()) return;
+ setSkills((prev) => [...prev, newSkill.trim()]);
+ setNewSkill("");
+ }, [newSkill]);
+
+ const handleRemoveSkill = useCallback((index: number) => {
+ setSkills((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
+ const handleAddHook = useCallback(() => {
+ if (!newHook.trim()) return;
+ setHooks((prev) => [...prev, newHook.trim()]);
+ setNewHook("");
+ }, [newHook]);
+
+ const handleRemoveHook = useCallback((index: number) => {
+ setHooks((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
+ const handleSaveConfig = useCallback(() => {
+ const config = buildOpencodeConfig(provider, apiKey, {
+ model: model || undefined,
+ baseUrl: baseUrl || undefined,
+ mcpServers,
+ skills,
+ hooks,
+ });
+ saveConfig(config);
+ setError(null);
+ }, [provider, apiKey, model, baseUrl, mcpServers, skills, hooks]);
+
+ const providerConfig = PROVIDER_CONFIGS[provider];
+
+ return (
+
+
+
OpenCode Agent Settings
+ {onClose && (
+
+ ×
+
+ )}
+
+
+ {/* Server Status */}
+
+
+
+ {serverStatus.status === "running"
+ ? `Connected (${serverStatus.url})`
+ : serverStatus.status === "starting"
+ ? "Starting..."
+ : serverStatus.status === "error"
+ ? `Error: ${serverStatus.error}`
+ : "Not connected"}
+
+
+
+ {error &&
{error}
}
+
+ {/* Provider Selection */}
+
+ AI Provider
+ {
+ const newProvider = e.target.value as OpencodeProvider;
+ setProvider(newProvider);
+ setModel(PROVIDER_CONFIGS[newProvider].defaultModel);
+ setIsManuallySelected(true); // Mark as user's explicit choice
+ }}
+ >
+ {PROVIDERS.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+
+ {/* API Key */}
+
+ API Key
+ setApiKey(e.target.value)}
+ placeholder={`Enter your ${PROVIDERS.find((p) => p.id === provider)?.label || provider} API key`}
+ />
+
+ {apiKey
+ ? `✓ Key provided (${apiKey.slice(0, 8)}...)`
+ : `Required for ${provider}`}
+
+
+
+ {/* API Key Validation & Model Discovery */}
+
+
+ {/* Base URL (if required) */}
+ {providerConfig.baseUrlRequired && (
+
+ Base URL
+ setBaseUrl(e.target.value)}
+ placeholder="https://your-endpoint.com"
+ />
+
+ )}
+
+ {/* Agent Selection */}
+
+ Default Agent
+
+
+ setSelectedAgent("build")}
+ />
+ Build (Full access)
+
+
+ setSelectedAgent("plan")}
+ />
+ Plan (Read-only)
+
+
+
+ {selectedAgent === "build"
+ ? "Build agent can edit files and run commands"
+ : "Plan agent only analyzes code without making changes"}
+
+
+
+ {/* Server Controls */}
+
+ {serverStatus.status === "stopped" || serverStatus.status === "error" ? (
+
+ {isLoading ? "Starting..." : "Start OpenCode"}
+
+ ) : serverStatus.status === "running" ? (
+ <>
+
+ {isLoading ? "Restarting..." : "Restart"}
+
+
+ Stop
+
+ >
+ ) : null}
+
+ Save Config
+
+
+
+ {/* Advanced Settings Toggle */}
+
setShowAdvanced(!showAdvanced)}
+ type="button"
+ className="toggle-advanced"
+ >
+ {showAdvanced ? "▼ Hide Advanced" : "▶ Show Advanced"}
+
+
+ {showAdvanced && (
+
+ {/* MCP Servers */}
+
+
+ {/* Skills */}
+
+
Skills
+
Enable OpenCode skills for specialized capabilities
+
+
+ {skills.map((skill, index) => (
+
+ {skill}
+ handleRemoveSkill(index)} type="button">
+ ×
+
+
+ ))}
+
+
+
+ setNewSkill(e.target.value)}
+ placeholder="Skill name"
+ />
+
+ Add Skill
+
+
+
+
+ {/* Hooks */}
+
+
Hooks
+
Configure pre/post hooks for automated workflows
+
+
+ {hooks.map((hook, index) => (
+
+ {hook}
+ handleRemoveHook(index)} type="button">
+ ×
+
+
+ ))}
+
+
+
+ setNewHook(e.target.value)}
+ placeholder="Hook path or name"
+ />
+
+ Add Hook
+
+
+
+
+ {/* Base URL Override */}
+ {!providerConfig.baseUrlRequired && (
+
+ Base URL Override (optional)
+ setBaseUrl(e.target.value)}
+ placeholder="Leave empty for default endpoint"
+ />
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/lib/opencode/agent.test.ts b/src/lib/opencode/agent.test.ts
new file mode 100644
index 0000000..f1f8ac8
--- /dev/null
+++ b/src/lib/opencode/agent.test.ts
@@ -0,0 +1,145 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ isOpencodeAvailable,
+ getOpencodeUrl,
+ getAvailableBackend,
+ extractCodeFromResponse,
+ extractJsonFromResponse,
+} from "./agent";
+
+// Mock the server module
+vi.mock("./server", () => ({
+ getOpencodeServerInfo: vi.fn(),
+}));
+
+import { getOpencodeServerInfo } from "./server";
+const mockGetOpencodeServerInfo = vi.mocked(getOpencodeServerInfo);
+
+describe("OpenCode Agent", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe("isOpencodeAvailable", () => {
+ test("returns true when server is running with URL", () => {
+ mockGetOpencodeServerInfo.mockReturnValue({
+ status: "running",
+ url: "http://localhost:4096",
+ });
+
+ expect(isOpencodeAvailable()).toBe(true);
+ });
+
+ test("returns false when server is stopped", () => {
+ mockGetOpencodeServerInfo.mockReturnValue({
+ status: "stopped",
+ });
+
+ expect(isOpencodeAvailable()).toBe(false);
+ });
+
+ test("returns false when server is running without URL", () => {
+ mockGetOpencodeServerInfo.mockReturnValue({
+ status: "running",
+ });
+
+ expect(isOpencodeAvailable()).toBe(false);
+ });
+ });
+
+ describe("getOpencodeUrl", () => {
+ test("returns URL when server is running", () => {
+ mockGetOpencodeServerInfo.mockReturnValue({
+ status: "running",
+ url: "http://localhost:4096",
+ });
+
+ expect(getOpencodeUrl()).toBe("http://localhost:4096");
+ });
+
+ test("returns null when server is not running", () => {
+ mockGetOpencodeServerInfo.mockReturnValue({
+ status: "stopped",
+ });
+
+ expect(getOpencodeUrl()).toBeNull();
+ });
+ });
+
+ describe("getAvailableBackend", () => {
+ test("returns opencode when server is running", () => {
+ mockGetOpencodeServerInfo.mockReturnValue({
+ status: "running",
+ url: "http://localhost:4096",
+ });
+
+ expect(getAvailableBackend()).toBe("opencode");
+ });
+
+ test("returns nvidia when NVIDIA_API_KEY is set", () => {
+ mockGetOpencodeServerInfo.mockReturnValue({
+ status: "stopped",
+ });
+
+ vi.stubGlobal("process", {
+ ...process,
+ env: { ...process.env, NVIDIA_API_KEY: "test-key" }
+ });
+
+ expect(getAvailableBackend()).toBe("nvidia");
+ });
+
+ test("returns null when no backend is available", () => {
+ mockGetOpencodeServerInfo.mockReturnValue({
+ status: "stopped",
+ });
+
+ const originalEnv = process.env.NVIDIA_API_KEY;
+ delete process.env.NVIDIA_API_KEY;
+
+ expect(getAvailableBackend()).toBeNull();
+
+ if (originalEnv) {
+ process.env.NVIDIA_API_KEY = originalEnv;
+ }
+ });
+ });
+
+ describe("extractCodeFromResponse", () => {
+ test("extracts code from markdown code block", () => {
+ const content = "Here is the code:\n```typescript\nconst x = 1;\n```";
+ expect(extractCodeFromResponse(content)).toBe("const x = 1;");
+ });
+
+ test("extracts code from JSON with code field", () => {
+ const content = JSON.stringify({ code: "const y = 2;" });
+ expect(extractCodeFromResponse(content)).toBe("const y = 2;");
+ });
+
+ test("returns null for plain text without code", () => {
+ const content = "Just some text without code";
+ expect(extractCodeFromResponse(content)).toBeNull();
+ });
+ });
+
+ describe("extractJsonFromResponse", () => {
+ test("parses valid JSON string", () => {
+ const content = '{"key": "value"}';
+ expect(extractJsonFromResponse(content)).toEqual({ key: "value" });
+ });
+
+ test("extracts JSON embedded in text", () => {
+ const content = 'Here is the result: {"status": "ok"} and more text';
+ expect(extractJsonFromResponse(content)).toEqual({ status: "ok" });
+ });
+
+ test("returns null for invalid JSON", () => {
+ const content = "Not JSON at all";
+ expect(extractJsonFromResponse(content)).toBeNull();
+ });
+ });
+});
diff --git a/src/lib/opencode/agent.ts b/src/lib/opencode/agent.ts
new file mode 100644
index 0000000..c7affb3
--- /dev/null
+++ b/src/lib/opencode/agent.ts
@@ -0,0 +1,289 @@
+/**
+ * Agent abstraction layer - allows switching between NVIDIA and OpenCode backends
+ */
+
+import type { AgentRequest, AgentResponse, OpencodeServerInfo } from "./types";
+import { getOpencodeServerInfo } from "./server";
+
+export type AgentBackend = "nvidia" | "opencode";
+
+export type CodeGenerationRequest = {
+ prompt: string;
+ systemPrompt?: string;
+ context?: {
+ files?: string[];
+ codeSnippets?: Array<{ path: string; content: string }>;
+ previousMessages?: Array<{ role: "user" | "assistant"; content: string }>;
+ };
+ temperature?: number;
+ maxTokens?: number;
+};
+
+export type CodeGenerationResponse = {
+ success: boolean;
+ content?: string;
+ error?: string;
+};
+
+/**
+ * Check which backend is available
+ */
+export function getAvailableBackend(): AgentBackend | null {
+ const opencodeInfo = getOpencodeServerInfo();
+
+ if (opencodeInfo.status === "running" && opencodeInfo.url) {
+ return "opencode";
+ }
+
+ // Check for NVIDIA API key
+ if (process.env.NVIDIA_API_KEY) {
+ return "nvidia";
+ }
+
+ return null;
+}
+
+/**
+ * Check if OpenCode is available
+ */
+export function isOpencodeAvailable(): boolean {
+ const info = getOpencodeServerInfo();
+ return info.status === "running" && !!info.url;
+}
+
+/**
+ * Get OpenCode server URL if available
+ */
+export function getOpencodeUrl(): string | null {
+ const info = getOpencodeServerInfo();
+ return info.status === "running" ? info.url ?? null : null;
+}
+
+/**
+ * Send a code generation request to OpenCode with retry support
+ */
+export async function sendToOpencode(
+ request: CodeGenerationRequest,
+ options: { timeout?: number; retries?: number } = {}
+): Promise {
+ const { timeout = 120000, retries = 2 } = options;
+ const serverInfo = getOpencodeServerInfo();
+
+ if (serverInfo.status !== "running" || !serverInfo.url) {
+ return {
+ success: false,
+ error: "OpenCode server is not running",
+ };
+ }
+
+ let lastError = "Unknown error";
+
+ for (let attempt = 0; attempt <= retries; attempt++) {
+ try {
+ // Get or create session
+ const sessionId = await getOrCreateSession(serverInfo.url, "build");
+
+ // Build full prompt with context
+ let fullPrompt = "";
+
+ if (request.systemPrompt) {
+ fullPrompt += `System context:\n${request.systemPrompt}\n\n`;
+ }
+
+ fullPrompt += request.prompt;
+
+ if (request.context?.codeSnippets && request.context.codeSnippets.length > 0) {
+ fullPrompt += "\n\n--- Code Context ---\n";
+ for (const snippet of request.context.codeSnippets) {
+ fullPrompt += `\nFile: ${snippet.path}\n\`\`\`\n${snippet.content}\n\`\`\`\n`;
+ }
+ }
+
+ // Create abort controller for timeout
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ try {
+ // Send to OpenCode
+ const response = await fetch(`${serverInfo.url}/session/${sessionId}/message`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ parts: [{ type: "text", text: fullPrompt }],
+ }),
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ lastError = `OpenCode request failed: ${errorText}`;
+ if (attempt < retries) continue;
+ return {
+ success: false,
+ error: lastError,
+ };
+ }
+
+ const responseText = await response.text();
+
+ // Try to parse as JSON and extract text content
+ try {
+ const parsed = JSON.parse(responseText);
+
+ if (parsed.parts && Array.isArray(parsed.parts)) {
+ const textParts = parsed.parts
+ .filter((p: { type: string; text?: string }) => p.type === "text" && p.text)
+ .map((p: { text: string }) => p.text);
+
+ return {
+ success: true,
+ content: textParts.join("\n") || responseText,
+ };
+ }
+
+ return {
+ success: true,
+ content: JSON.stringify(parsed),
+ };
+ } catch {
+ return {
+ success: true,
+ content: responseText,
+ };
+ }
+ } catch (fetchError) {
+ clearTimeout(timeoutId);
+
+ if (fetchError instanceof Error && fetchError.name === "AbortError") {
+ lastError = `Request timed out after ${timeout}ms`;
+ } else {
+ lastError = fetchError instanceof Error ? fetchError.message : "Fetch failed";
+ }
+
+ if (attempt < retries) continue;
+ }
+ } catch (error) {
+ lastError = error instanceof Error ? error.message : "Unknown error";
+ if (attempt < retries) continue;
+ }
+ }
+
+ return {
+ success: false,
+ error: lastError,
+ };
+}
+
+/**
+ * Helper to get or create a session
+ */
+async function getOrCreateSession(serverUrl: string, agentType: string): Promise {
+ // List existing sessions
+ const listRes = await fetch(`${serverUrl}/session?limit=1`, {
+ headers: { "Content-Type": "application/json" },
+ });
+
+ if (listRes.ok) {
+ const sessions = await listRes.json();
+ if (sessions.length > 0) {
+ return sessions[0].id;
+ }
+ }
+
+ // Create a new session
+ const createRes = await fetch(`${serverUrl}/session`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title: `CodeFlow ${agentType} session`,
+ agent: agentType,
+ }),
+ });
+
+ if (!createRes.ok) {
+ throw new Error(`Failed to create session: ${createRes.statusText}`);
+ }
+
+ const session = await createRes.json();
+ return session.id;
+}
+
+/**
+ * Unified code generation that uses available backend
+ */
+export async function generateCodeWithAgent(
+ request: CodeGenerationRequest,
+ preferredBackend?: AgentBackend
+): Promise {
+ // Determine which backend to use
+ let backend = preferredBackend;
+
+ if (!backend) {
+ backend = getAvailableBackend() ?? undefined;
+ }
+
+ if (!backend) {
+ return {
+ success: false,
+ error: "No AI backend available. Configure OpenCode or provide NVIDIA API key.",
+ };
+ }
+
+ if (backend === "opencode") {
+ return sendToOpencode(request);
+ }
+
+ // For NVIDIA, we can't call it directly here since it needs the API key
+ // Return indicator that NVIDIA should be used
+ return {
+ success: false,
+ error: "NVIDIA_FALLBACK",
+ };
+}
+
+/**
+ * Extract code from agent response
+ */
+export function extractCodeFromResponse(content: string): string | null {
+ // Try to find code blocks
+ const codeBlockMatch = content.match(/```[\w]*\n([\s\S]*?)\n```/);
+ if (codeBlockMatch) {
+ return codeBlockMatch[1]!.trim();
+ }
+
+ // Try to parse as JSON with code field
+ try {
+ const parsed = JSON.parse(content);
+ if (parsed.code) {
+ return parsed.code;
+ }
+ } catch {
+ // Not JSON
+ }
+
+ return null;
+}
+
+/**
+ * Extract JSON from agent response
+ */
+export function extractJsonFromResponse(content: string): T | null {
+ // Try direct parse
+ try {
+ return JSON.parse(content);
+ } catch {
+ // Try to find JSON in content
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
+ if (jsonMatch) {
+ try {
+ return JSON.parse(jsonMatch[0]);
+ } catch {
+ // Not valid JSON
+ }
+ }
+ }
+
+ return null;
+}
diff --git a/src/lib/opencode/api-key-validator.tsx b/src/lib/opencode/api-key-validator.tsx
new file mode 100644
index 0000000..ff3fd99
--- /dev/null
+++ b/src/lib/opencode/api-key-validator.tsx
@@ -0,0 +1,174 @@
+/**
+ * API Key Validation Component
+ * Shows validation status and model selection dropdown
+ */
+
+import { useCallback, useEffect, useState } from "react";
+import { validateAndFetchModels } from "@/lib/opencode/modelFetcher";
+import type { OpencodeProvider } from "@/lib/opencode/types";
+
+type Props = {
+ provider: OpencodeProvider;
+ apiKey: string;
+ selectedModel?: string;
+ onModelChange?: (model: string) => void;
+};
+
+export type ValidationStatus = "idle" | "validating" | "valid" | "invalid";
+
+export function ApiKeyValidator({
+ provider,
+ apiKey,
+ selectedModel,
+ onModelChange,
+}: Props) {
+ const [status, setStatus] = useState("idle");
+ const [models, setModels] = useState([]);
+ const [error, setError] = useState(null);
+ const [validatingKey, setValidatingKey] = useState("");
+
+ // Debounced validation
+ useEffect(() => {
+ if (!apiKey || apiKey.length < 10) {
+ setStatus("idle");
+ setModels([]);
+ setError(null);
+ return;
+ }
+
+ // Skip if we're validating the same key
+ if (validatingKey === apiKey) {
+ return;
+ }
+
+ const timer = setTimeout(async () => {
+ setStatus("validating");
+ setValidatingKey(apiKey);
+ setError(null);
+
+ const result = await validateAndFetchModels(provider, apiKey);
+
+ if (result.valid && result.models) {
+ setStatus("valid");
+ setModels(result.models);
+ setError(null);
+ // Auto-select first model if available
+ if (!selectedModel && result.models.length > 0) {
+ onModelChange?.(result.models[0]);
+ }
+ } else {
+ setStatus("invalid");
+ setModels([]);
+ setError(result.error || "Validation failed");
+ }
+ }, 800); // Debounce for 800ms
+
+ return () => clearTimeout(timer);
+ }, [apiKey, provider, selectedModel, validatingKey, onModelChange]);
+
+ // Status indicator colors
+ const getStatusColor = () => {
+ switch (status) {
+ case "validating":
+ return "#f59e0b"; // amber
+ case "valid":
+ return "#10b981"; // green
+ case "invalid":
+ return "#ef4444"; // red
+ default:
+ return "#9ca3af"; // gray
+ }
+ };
+
+ const getStatusText = () => {
+ switch (status) {
+ case "validating":
+ return "Validating...";
+ case "valid":
+ return `Valid ✓ (${models.length} models)`;
+ case "invalid":
+ return `Invalid ✗`;
+ default:
+ return "Enter API key";
+ }
+ };
+
+ return (
+
+ {/* Status indicator */}
+
+
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Model dropdown */}
+ {status === "valid" && models.length > 0 && (
+
+
+ Model
+
+ onModelChange?.(e.target.value)}
+ style={{
+ width: "100%",
+ padding: "8px 12px",
+ border: "1px solid #d1d5db",
+ borderRadius: "4px",
+ fontSize: "12px",
+ backgroundColor: "#fff",
+ cursor: "pointer",
+ }}
+ >
+ {models.map((model) => (
+
+ {model}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/lib/opencode/client.ts b/src/lib/opencode/client.ts
new file mode 100644
index 0000000..792101b
--- /dev/null
+++ b/src/lib/opencode/client.ts
@@ -0,0 +1,229 @@
+/**
+ * OpenCode client wrapper for browser/server communication
+ * Since OpenCode server runs as a separate process, we need API routes to proxy requests
+ */
+
+import type { AgentRequest, AgentResponse, OpencodeServerInfo } from "./types";
+
+const API_BASE = "/api/opencode";
+
+/**
+ * Check if OpenCode server is running
+ */
+export async function checkServerStatus(): Promise {
+ const response = await fetch(`${API_BASE}/status`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to check server status: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Start OpenCode server (via API route)
+ */
+export async function startServer(config: {
+ provider: string;
+ apiKey: string;
+ model?: string;
+ baseUrl?: string;
+}): Promise {
+ const response = await fetch(`${API_BASE}/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(config),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ error: response.statusText }));
+ throw new Error(error.error || "Failed to start server");
+ }
+
+ return response.json();
+}
+
+/**
+ * Stop OpenCode server (via API route)
+ */
+export async function stopServer(): Promise {
+ const response = await fetch(`${API_BASE}/stop`, {
+ method: "POST",
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to stop server: ${response.statusText}`);
+ }
+}
+
+/**
+ * Restart OpenCode server with new config
+ */
+export async function restartServer(config: {
+ provider: string;
+ apiKey: string;
+ model?: string;
+ baseUrl?: string;
+}): Promise {
+ const response = await fetch(`${API_BASE}/restart`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(config),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ error: response.statusText }));
+ throw new Error(error.error || "Failed to restart server");
+ }
+
+ return response.json();
+}
+
+/**
+ * Send message to OpenCode agent
+ */
+export async function sendAgentMessage(request: AgentRequest): Promise {
+ const response = await fetch(`${API_BASE}/agent`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ error: response.statusText }));
+ return {
+ success: false,
+ error: error.error || "Failed to send message to agent",
+ };
+ }
+
+ return response.json();
+}
+
+/**
+ * Generate code using OpenCode agent
+ */
+export async function generateCode(params: {
+ prompt: string;
+ context?: {
+ files?: string[];
+ codeSnippets?: Array<{ path: string; content: string }>;
+ };
+}): Promise<{ code: string; explanation: string } | { error: string }> {
+ const response = await sendAgentMessage({
+ type: "build",
+ message: `Generate code: ${params.prompt}`,
+ context: params.context,
+ });
+
+ if (!response.success || !response.response) {
+ return { error: response.error || "Failed to generate code" };
+ }
+
+ // Parse the response to extract code and explanation
+ // This is a simplified parser - OpenCode responses may need more sophisticated handling
+ const codeMatch = response.response.match(/```[\w]*\n([\s\S]*?)\n```/);
+ const code = codeMatch ? codeMatch[1]!.trim() : response.response;
+
+ return {
+ code,
+ explanation: response.response,
+ };
+}
+
+/**
+ * Analyze code using OpenCode agent
+ */
+export async function analyzeCode(params: {
+ code: string;
+ filePath?: string;
+ analysisType?: "quality" | "security" | "performance" | "general";
+}): Promise<{ analysis: string; suggestions: string[] } | { error: string }> {
+ const analysisPrompt = `Analyze this code for ${params.analysisType || "general"} concerns:\n\n${params.code}`;
+
+ const response = await sendAgentMessage({
+ type: "plan", // Use plan agent for read-only analysis
+ message: analysisPrompt,
+ context: params.filePath
+ ? { codeSnippets: [{ path: params.filePath, content: params.code }] }
+ : undefined,
+ });
+
+ if (!response.success || !response.response) {
+ return { error: response.error || "Failed to analyze code" };
+ }
+
+ return {
+ analysis: response.response,
+ suggestions: [], // Could be extracted from response if formatted appropriately
+ };
+}
+
+/**
+ * Get code suggestions from OpenCode agent
+ */
+export async function getCodeSuggestions(params: {
+ partial: string;
+ context?: string;
+ filePath?: string;
+}): Promise {
+ const prompt = `Complete this code:\n\n${params.partial}\n\nContext: ${params.context || ""}`;
+
+ const response = await sendAgentMessage({
+ type: "build",
+ message: prompt,
+ context: params.filePath
+ ? { codeSnippets: [{ path: params.filePath, content: params.partial }] }
+ : undefined,
+ });
+
+ if (!response.success || !response.response) {
+ return [];
+ }
+
+ // Extract suggestions from response
+ // This is simplified - may need more sophisticated parsing
+ return [response.response];
+}
+
+/**
+ * Implement a node using OpenCode agent
+ */
+export async function implementNode(params: {
+ nodeName: string;
+ nodeType: string;
+ description: string;
+ dependencies?: string[];
+ codebaseContext?: string;
+}): Promise<{ code: string; summary: string; notes: string[] } | { error: string }> {
+ const prompt = `
+Implement ${params.nodeType} "${params.nodeName}":
+
+Description: ${params.description}
+
+${params.dependencies && params.dependencies.length > 0 ? `Dependencies: ${params.dependencies.join(", ")}` : ""}
+
+${params.codebaseContext ? `Codebase Context:\n${params.codebaseContext}` : ""}
+
+Provide production-ready implementation with proper error handling, typing, and documentation.
+`;
+
+ const response = await sendAgentMessage({
+ type: "build",
+ message: prompt,
+ });
+
+ if (!response.success || !response.response) {
+ return { error: response.error || "Failed to implement node" };
+ }
+
+ // Parse response to extract code
+ const codeMatch = response.response.match(/```[\w]*\n([\s\S]*?)\n```/);
+ const code = codeMatch ? codeMatch[1]!.trim() : "";
+
+ return {
+ code,
+ summary: `Implemented ${params.nodeName}`,
+ notes: ["Generated by OpenCode agent"],
+ };
+}
diff --git a/src/lib/opencode/config.test.ts b/src/lib/opencode/config.test.ts
new file mode 100644
index 0000000..f636d7f
--- /dev/null
+++ b/src/lib/opencode/config.test.ts
@@ -0,0 +1,136 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ detectProvider,
+ buildOpencodeConfig,
+ saveConfig,
+ loadConfig,
+ validateConfig,
+} from "./config";
+
+describe("OpenCode Config", () => {
+ beforeEach(() => {
+ // Create proper storage mocks
+ const mockStorage: Record = {};
+
+ vi.stubGlobal("localStorage", {
+ getItem: (key: string) => mockStorage[`local_${key}`] ?? null,
+ setItem: (key: string, value: string) => { mockStorage[`local_${key}`] = value; },
+ removeItem: (key: string) => { delete mockStorage[`local_${key}`]; },
+ clear: () => Object.keys(mockStorage).filter(k => k.startsWith("local_")).forEach(k => delete mockStorage[k]),
+ length: 0,
+ key: vi.fn(),
+ });
+
+ vi.stubGlobal("sessionStorage", {
+ getItem: (key: string) => mockStorage[`session_${key}`] ?? null,
+ setItem: (key: string, value: string) => { mockStorage[`session_${key}`] = value; },
+ removeItem: (key: string) => { delete mockStorage[`session_${key}`]; },
+ clear: () => Object.keys(mockStorage).filter(k => k.startsWith("session_")).forEach(k => delete mockStorage[k]),
+ length: 0,
+ key: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe("detectProvider", () => {
+ test("detects Anthropic from API key prefix", () => {
+ expect(detectProvider("sk-ant-api03-test")).toBe("anthropic");
+ });
+
+ test("detects OpenAI from longer sk- prefix", () => {
+ // OpenAI keys require 20+ chars after "sk-" (only alphanumeric, no hyphens)
+ expect(detectProvider("sk-" + "a".repeat(20))).toBe("openai");
+ expect(detectProvider("sk-proj1234567890ABCDEFgh")).toBe("openai");
+ });
+
+ test("detects OpenRouter from sk-or- prefix", () => {
+ expect(detectProvider("sk-or-test-key-123")).toBe("openrouter");
+ });
+
+ test("detects Groq from gsk_ prefix", () => {
+ expect(detectProvider("gsk_test-key-123")).toBe("groq");
+ });
+
+ test("detects Cohere from co_ prefix", () => {
+ expect(detectProvider("co_test-key-123")).toBe("cohere");
+ });
+
+ test("detects Perplexity from pplx prefix", () => {
+ expect(detectProvider("pplx_test-key-123")).toBe("perplexity");
+ });
+
+ test("does not match OpenAI for short sk- keys (prevents OpenRouter override)", () => {
+ // Short keys should not match OpenAI (requires 20+ chars)
+ expect(detectProvider("sk-or-test")).toBe("openrouter");
+ expect(detectProvider("sk-test")).toBeNull(); // too short
+ });
+
+ test("returns null for unknown key format", () => {
+ expect(detectProvider("unknown-key")).toBeNull();
+ });
+ });
+
+ describe("buildOpencodeConfig", () => {
+ test("builds config with required fields", () => {
+ const config = buildOpencodeConfig("anthropic", "test-key", {});
+
+ expect(config).toBeDefined();
+ expect(config.provider).toBe("anthropic");
+ expect(config.apiKey).toBe("test-key");
+ });
+
+ test("includes model when specified", () => {
+ const config = buildOpencodeConfig("anthropic", "test-key", {
+ model: "claude-3-sonnet",
+ });
+
+ expect(config.model).toBe("claude-3-sonnet");
+ });
+
+ test("includes MCP servers when specified", () => {
+ const config = buildOpencodeConfig("openai", "test-key", {
+ mcpServers: [
+ { name: "test-server", command: "npx test" }
+ ],
+ });
+
+ expect(config.mcpServers).toHaveLength(1);
+ expect(config.mcpServers![0].name).toBe("test-server");
+ });
+ });
+
+ describe("validateConfig", () => {
+ test("returns false for missing API key (non-local provider)", () => {
+ const result = validateConfig({ provider: "anthropic", apiKey: "" });
+ expect(result.valid).toBe(false);
+ });
+
+ test("returns true for valid config", () => {
+ const result = validateConfig({ provider: "anthropic", apiKey: "sk-ant-api03-valid" });
+ expect(result.valid).toBe(true);
+ });
+
+ test("accepts local provider without API key but with base URL", () => {
+ const result = validateConfig({ provider: "local", apiKey: "", baseUrl: "http://localhost:11434" });
+ expect(result.valid).toBe(true);
+ });
+ });
+
+ describe("saveConfig and loadConfig", () => {
+ test("saves and loads config", () => {
+ const config = buildOpencodeConfig("anthropic", "test-key", {
+ model: "claude-3-sonnet",
+ });
+
+ saveConfig(config);
+ const loaded = loadConfig();
+
+ expect(loaded).not.toBeNull();
+ expect(loaded?.provider).toBe("anthropic");
+ expect(loaded?.model).toBe("claude-3-sonnet");
+ });
+ });
+});
diff --git a/src/lib/opencode/config.ts b/src/lib/opencode/config.ts
new file mode 100644
index 0000000..b0ec696
--- /dev/null
+++ b/src/lib/opencode/config.ts
@@ -0,0 +1,141 @@
+/**
+ * OpenCode configuration helpers
+ */
+
+import type { OpencodeConfig, OpencodeProvider } from "./types";
+import { PROVIDER_CONFIGS } from "./types";
+
+const OPENCODE_CONFIG_KEY = "codeflow_opencode_config";
+
+/**
+ * Detect provider from API key format
+ */
+export function detectProvider(apiKey: string): OpencodeProvider | null {
+ for (const [provider, config] of Object.entries(PROVIDER_CONFIGS)) {
+ if (config.apiKeyFormat && config.apiKeyFormat.test(apiKey)) {
+ return provider as OpencodeProvider;
+ }
+ }
+ return null;
+}
+
+/**
+ * Build OpenCode config object from settings
+ */
+export function buildOpencodeConfig(
+ provider: OpencodeProvider,
+ apiKey: string,
+ options: {
+ model?: string;
+ baseUrl?: string;
+ logLevel?: OpencodeConfig["logLevel"];
+ mcpServers?: OpencodeConfig["mcpServers"];
+ skills?: string[];
+ hooks?: string[];
+ } = {}
+): OpencodeConfig {
+ const providerConfig = PROVIDER_CONFIGS[provider];
+
+ return {
+ provider,
+ apiKey,
+ model: options.model || providerConfig.defaultModel,
+ baseUrl: options.baseUrl,
+ logLevel: options.logLevel || "info",
+ mcpServers: options.mcpServers || [],
+ skills: options.skills || [],
+ hooks: options.hooks || [],
+ };
+}
+
+/**
+ * Convert OpencodeConfig to environment variables for OpenCode CLI
+ */
+export function configToEnv(config: OpencodeConfig): Record {
+ const env: Record = {};
+ const providerConfig = PROVIDER_CONFIGS[config.provider];
+
+ if (config.apiKey && providerConfig.apiKeyEnvVar) {
+ env[providerConfig.apiKeyEnvVar] = config.apiKey;
+ }
+
+ if (config.baseUrl) {
+ env.OPENCODE_BASE_URL = config.baseUrl;
+ }
+
+ if (config.model) {
+ env.OPENCODE_MODEL = config.model;
+ }
+
+ return env;
+}
+
+/**
+ * Save OpenCode config to localStorage
+ */
+export function saveConfig(config: OpencodeConfig): void {
+ if (typeof window === "undefined") return;
+
+ // Don't store API key in localStorage - only in session
+ const sanitized = { ...config, apiKey: undefined };
+ localStorage.setItem(OPENCODE_CONFIG_KEY, JSON.stringify(sanitized));
+
+ // Store API key in sessionStorage only
+ if (config.apiKey) {
+ sessionStorage.setItem("codeflow_opencode_api_key", config.apiKey);
+ }
+}
+
+/**
+ * Load OpenCode config from localStorage
+ */
+export function loadConfig(): OpencodeConfig | null {
+ if (typeof window === "undefined") return null;
+
+ const stored = localStorage.getItem(OPENCODE_CONFIG_KEY);
+ if (!stored) return null;
+
+ try {
+ const config = JSON.parse(stored) as OpencodeConfig;
+
+ // Restore API key from sessionStorage if available
+ const apiKey = sessionStorage.getItem("codeflow_opencode_api_key");
+ if (apiKey) {
+ config.apiKey = apiKey;
+ }
+
+ return config;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Clear stored config
+ */
+export function clearConfig(): void {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(OPENCODE_CONFIG_KEY);
+ sessionStorage.removeItem("codeflow_opencode_api_key");
+}
+
+/**
+ * Validate config
+ */
+export function validateConfig(config: OpencodeConfig): { valid: boolean; error?: string } {
+ const providerConfig = PROVIDER_CONFIGS[config.provider];
+
+ if (!config.apiKey && config.provider !== "local") {
+ return { valid: false, error: "API key is required" };
+ }
+
+ if (providerConfig.baseUrlRequired && !config.baseUrl) {
+ return { valid: false, error: "Base URL is required for this provider" };
+ }
+
+ if (config.apiKey && providerConfig.apiKeyFormat && !providerConfig.apiKeyFormat.test(config.apiKey)) {
+ return { valid: false, error: "Invalid API key format" };
+ }
+
+ return { valid: true };
+}
diff --git a/src/lib/opencode/index.ts b/src/lib/opencode/index.ts
new file mode 100644
index 0000000..ebaff5d
--- /dev/null
+++ b/src/lib/opencode/index.ts
@@ -0,0 +1,12 @@
+/**
+ * OpenCode integration module
+ * Provides client and server management for OpenCode AI agent
+ */
+
+export * from "./types";
+export * from "./config";
+export * from "./server";
+export * from "./client";
+export * from "./agent";
+export * from "./modelFetcher";
+
diff --git a/src/lib/opencode/modelFetcher.test.ts b/src/lib/opencode/modelFetcher.test.ts
new file mode 100644
index 0000000..be36429
--- /dev/null
+++ b/src/lib/opencode/modelFetcher.test.ts
@@ -0,0 +1,146 @@
+/**
+ * Model Fetcher Tests
+ * Tests API key validation and model discovery for all providers
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { validateAndFetchModels } from "./modelFetcher";
+import type { OpencodeProvider } from "./types";
+
+describe("Model Fetcher", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("validateAndFetchModels", () => {
+ it("returns error for empty API key", async () => {
+ const result = await validateAndFetchModels("openai", "");
+ expect(result.valid).toBe(false);
+ expect(result.error).toBe("API key too short");
+ });
+
+ it("returns error for short API key", async () => {
+ const result = await validateAndFetchModels("openai", "short");
+ expect(result.valid).toBe(false);
+ expect(result.error).toBe("API key too short");
+ });
+
+ it("handles unknown provider gracefully", async () => {
+ const result = await validateAndFetchModels(
+ "unknown" as OpencodeProvider,
+ "test-key-12345"
+ );
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain("Unknown provider");
+ });
+
+ it("returns valid result for Cohere (no API check)", async () => {
+ const result = await validateAndFetchModels(
+ "cohere",
+ "test-key-12345"
+ );
+ expect(result.valid).toBe(true);
+ expect(result.models).toBeDefined();
+ expect(result.models?.length).toBeGreaterThan(0);
+ expect(result.models).toContain("command-r-plus");
+ });
+
+ it("returns valid result for Perplexity (no API check)", async () => {
+ const result = await validateAndFetchModels(
+ "perplexity",
+ "test-key-12345"
+ );
+ expect(result.valid).toBe(true);
+ expect(result.models).toBeDefined();
+ expect(result.models?.length).toBeGreaterThan(0);
+ expect(result.models).toContain(
+ "llama-3.1-sonar-large-128k-online"
+ );
+ });
+
+ it("returns valid result for Azure (no API check)", async () => {
+ const result = await validateAndFetchModels("azure", "test-key-12345");
+ expect(result.valid).toBe(true);
+ expect(result.models).toContain("gpt-4");
+ expect(result.models).toContain("gpt-3.5-turbo");
+ });
+
+ it("returns valid result for Bedrock (no API check)", async () => {
+ const result = await validateAndFetchModels("bedrock", "test-key-12345");
+ expect(result.valid).toBe(true);
+ expect(result.models).toContain("anthropic.claude-v3-5-sonnet");
+ });
+
+ it("returns valid result for local (no API check)", async () => {
+ const result = await validateAndFetchModels("local", "test-key-12345");
+ expect(result.valid).toBe(true);
+ expect(result.models).toContain("custom");
+ });
+
+ it("handles providers with API validation", async () => {
+ // These would make actual API calls in a real scenario
+ // For unit tests, we're just ensuring they don't crash with long keys
+ const testKey = "test-key-" + "x".repeat(50);
+
+ // Test that function doesn't crash
+ const result = await validateAndFetchModels("openai", testKey);
+ // Result will be invalid due to mock key, but function should handle it
+ expect(result).toHaveProperty("valid");
+ expect(result).toHaveProperty("error");
+ });
+ });
+
+ describe("Supported providers", () => {
+ const providers: OpencodeProvider[] = [
+ "anthropic",
+ "openai",
+ "google",
+ "azure",
+ "groq",
+ "cohere",
+ "mistral",
+ "perplexity",
+ "openrouter",
+ "bedrock",
+ "local",
+ ];
+
+ it("should handle all 11 providers without crashing", async () => {
+ const testKey = "test-key-" + "x".repeat(50);
+
+ for (const provider of providers) {
+ const result = await validateAndFetchModels(provider, testKey);
+ expect(result).toHaveProperty("valid");
+ // Either valid with models or invalid with error
+ if (result.valid) {
+ expect(result.models).toBeDefined();
+ expect(Array.isArray(result.models)).toBe(true);
+ } else {
+ expect(result.error).toBeDefined();
+ }
+ }
+ });
+ });
+
+ describe("Error handling", () => {
+ it("handles timeout gracefully", async () => {
+ // Simulate a very short key that will still trigger API calls
+ const result = await validateAndFetchModels("openai", "sk-" + "x".repeat(100));
+ // Should either return valid result or error, not throw
+ expect(result).toHaveProperty("valid");
+ });
+
+ it("provides meaningful error messages", async () => {
+ const result = await validateAndFetchModels("openai", "invalid-key");
+ if (!result.valid) {
+ expect(result.error).toBeDefined();
+ expect(typeof result.error).toBe("string");
+ expect(result.error!.length).toBeGreaterThan(0);
+ }
+ });
+ });
+});
diff --git a/src/lib/opencode/modelFetcher.ts b/src/lib/opencode/modelFetcher.ts
new file mode 100644
index 0000000..4b9d71a
--- /dev/null
+++ b/src/lib/opencode/modelFetcher.ts
@@ -0,0 +1,351 @@
+/**
+ * OpenCode Model Fetcher
+ * Validates API keys and fetches available models from provider APIs
+ */
+
+import type { OpencodeProvider } from "./types";
+
+export type ValidationResult = {
+ valid: boolean;
+ error?: string;
+ models?: string[];
+};
+
+/**
+ * Validate API key and fetch available models for a provider
+ */
+export async function validateAndFetchModels(
+ provider: OpencodeProvider,
+ apiKey: string
+): Promise {
+ if (!apiKey || apiKey.length < 10) {
+ return { valid: false, error: "API key too short" };
+ }
+
+ try {
+ switch (provider) {
+ case "openai":
+ return await fetchOpenAIModels(apiKey);
+ case "anthropic":
+ return await fetchAnthropicModels(apiKey);
+ case "google":
+ return await fetchGoogleModels(apiKey);
+ case "groq":
+ return await fetchGroqModels(apiKey);
+ case "cohere":
+ return await fetchCohereModels(apiKey);
+ case "mistral":
+ return await fetchMistralModels(apiKey);
+ case "perplexity":
+ return await fetchPerplexityModels(apiKey);
+ case "openrouter":
+ return await fetchOpenRouterModels(apiKey);
+ case "azure":
+ return { valid: true, models: ["gpt-4", "gpt-3.5-turbo"] };
+ case "bedrock":
+ return { valid: true, models: ["anthropic.claude-v3-5-sonnet"] };
+ case "local":
+ return { valid: true, models: ["custom"] };
+ default:
+ return { valid: false, error: `Unknown provider: ${provider}` };
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ return { valid: false, error: message };
+ }
+}
+
+/**
+ * Fetch OpenAI models
+ */
+async function fetchOpenAIModels(apiKey: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+
+ try {
+ const response = await fetch("https://api.openai.com/v1/models", {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeout);
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return { valid: false, error: "Invalid OpenAI API key" };
+ }
+ return { valid: false, error: `OpenAI API error: ${response.status}` };
+ }
+
+ const data = (await response.json()) as { data: Array<{ id: string }> };
+ const models = data.data
+ .map((m) => m.id)
+ .filter(
+ (id) =>
+ id.includes("gpt") &&
+ !id.includes("vision") &&
+ !id.includes("dall-e")
+ )
+ .slice(0, 20); // Limit to 20 recent models
+
+ if (models.length === 0) {
+ return { valid: false, error: "No GPT models found in account" };
+ }
+
+ return { valid: true, models };
+ } catch (err) {
+ clearTimeout(timeout);
+ if (err instanceof Error && err.name === "AbortError") {
+ return { valid: false, error: "OpenAI API request timed out" };
+ }
+ return { valid: false, error: `Failed to fetch OpenAI models` };
+ }
+}
+
+/**
+ * Fetch Anthropic models
+ */
+async function fetchAnthropicModels(apiKey: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+
+ try {
+ const response = await fetch(
+ "https://api.anthropic.com/v1/models",
+ {
+ headers: {
+ "x-api-key": apiKey,
+ "anthropic-version": "2023-06-01",
+ },
+ signal: controller.signal,
+ }
+ );
+
+ clearTimeout(timeout);
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return { valid: false, error: "Invalid Anthropic API key" };
+ }
+ return { valid: false, error: `Anthropic API error: ${response.status}` };
+ }
+
+ const data = (await response.json()) as { data: Array<{ id: string }> };
+ const models = data.data.map((m) => m.id);
+
+ if (models.length === 0) {
+ return { valid: false, error: "No Claude models found in account" };
+ }
+
+ return { valid: true, models };
+ } catch (err) {
+ clearTimeout(timeout);
+ if (err instanceof Error && err.name === "AbortError") {
+ return { valid: false, error: "Anthropic API request timed out" };
+ }
+ return { valid: false, error: "Failed to fetch Anthropic models" };
+ }
+}
+
+/**
+ * Fetch Google models
+ */
+async function fetchGoogleModels(apiKey: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+
+ try {
+ const response = await fetch(
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`,
+ { signal: controller.signal }
+ );
+
+ clearTimeout(timeout);
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return { valid: false, error: "Invalid Google API key" };
+ }
+ return { valid: false, error: `Google API error: ${response.status}` };
+ }
+
+ const data = (await response.json()) as { models: Array<{ name: string }> };
+ const models = data.models
+ .map((m) => m.name.replace("models/", ""))
+ .filter((id) => id.includes("gemini"));
+
+ if (models.length === 0) {
+ return { valid: false, error: "No Gemini models found" };
+ }
+
+ return { valid: true, models };
+ } catch (err) {
+ clearTimeout(timeout);
+ if (err instanceof Error && err.name === "AbortError") {
+ return { valid: false, error: "Google API request timed out" };
+ }
+ return { valid: false, error: "Failed to fetch Google models" };
+ }
+}
+
+/**
+ * Fetch Groq models
+ */
+async function fetchGroqModels(apiKey: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+
+ try {
+ const response = await fetch("https://api.groq.com/openai/v1/models", {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeout);
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return { valid: false, error: "Invalid Groq API key" };
+ }
+ return { valid: false, error: `Groq API error: ${response.status}` };
+ }
+
+ const data = (await response.json()) as { data: Array<{ id: string }> };
+ const models = data.data.map((m) => m.id);
+
+ if (models.length === 0) {
+ return { valid: false, error: "No Groq models found" };
+ }
+
+ return { valid: true, models };
+ } catch (err) {
+ clearTimeout(timeout);
+ if (err instanceof Error && err.name === "AbortError") {
+ return { valid: false, error: "Groq API request timed out" };
+ }
+ return { valid: false, error: "Failed to fetch Groq models" };
+ }
+}
+
+/**
+ * Fetch Cohere models
+ */
+async function fetchCohereModels(apiKey: string): Promise {
+ // Cohere doesn't have a public models list endpoint
+ // Return default models and assume key is valid if format is correct
+ return {
+ valid: true,
+ models: [
+ "command-r-plus",
+ "command-r",
+ "command",
+ "command-light",
+ "command-nightly",
+ ],
+ };
+}
+
+/**
+ * Fetch Mistral models
+ */
+async function fetchMistralModels(apiKey: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+
+ try {
+ const response = await fetch("https://api.mistral.ai/v1/models", {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeout);
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return { valid: false, error: "Invalid Mistral API key" };
+ }
+ return { valid: false, error: `Mistral API error: ${response.status}` };
+ }
+
+ const data = (await response.json()) as { data: Array<{ id: string }> };
+ const models = data.data.map((m) => m.id);
+
+ if (models.length === 0) {
+ return { valid: false, error: "No Mistral models found" };
+ }
+
+ return { valid: true, models };
+ } catch (err) {
+ clearTimeout(timeout);
+ if (err instanceof Error && err.name === "AbortError") {
+ return { valid: false, error: "Mistral API request timed out" };
+ }
+ return { valid: false, error: "Failed to fetch Mistral models" };
+ }
+}
+
+/**
+ * Fetch Perplexity models
+ */
+async function fetchPerplexityModels(
+ apiKey: string
+): Promise {
+ // Perplexity doesn't have a public models list endpoint
+ // Return default models and assume key is valid if format is correct
+ return {
+ valid: true,
+ models: [
+ "llama-3.1-sonar-large-128k-online",
+ "llama-3.1-sonar-small-128k-online",
+ "llama-3.1-sonar-large-128k-chat",
+ "llama-3.1-sonar-small-128k-chat",
+ ],
+ };
+}
+
+/**
+ * Fetch OpenRouter models
+ */
+async function fetchOpenRouterModels(
+ apiKey: string
+): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+
+ try {
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeout);
+
+ if (!response.ok) {
+ return { valid: false, error: `OpenRouter API error: ${response.status}` };
+ }
+
+ const data = (await response.json()) as { data: Array<{ id: string }> };
+ // Filter for popular models and limit to 30
+ const models = data.data
+ .map((m) => m.id)
+ .filter((id) => !id.includes("deprecated"))
+ .slice(0, 30);
+
+ if (models.length === 0) {
+ return { valid: false, error: "No OpenRouter models found" };
+ }
+
+ return { valid: true, models };
+ } catch (err) {
+ clearTimeout(timeout);
+ if (err instanceof Error && err.name === "AbortError") {
+ return { valid: false, error: "OpenRouter request timed out" };
+ }
+ return { valid: false, error: "Failed to fetch OpenRouter models" };
+ }
+}
diff --git a/src/lib/opencode/server.test.ts b/src/lib/opencode/server.test.ts
new file mode 100644
index 0000000..544d092
--- /dev/null
+++ b/src/lib/opencode/server.test.ts
@@ -0,0 +1,22 @@
+import { describe, test, expect } from "vitest";
+import { getOpencodeServerInfo } from "./server";
+
+describe("OpenCode Server", () => {
+ describe("getOpencodeServerInfo", () => {
+ test("returns stopped status when server is not started", () => {
+ const info = getOpencodeServerInfo();
+ expect(info.status).toBe("stopped");
+ });
+
+ test("returns server info object with status property", () => {
+ const info = getOpencodeServerInfo();
+ expect(info).toHaveProperty("status");
+ expect(typeof info.status).toBe("string");
+ });
+
+ test("status is one of valid values", () => {
+ const info = getOpencodeServerInfo();
+ expect(["stopped", "starting", "running", "stopping", "error"]).toContain(info.status);
+ });
+ });
+});
diff --git a/src/lib/opencode/server.ts b/src/lib/opencode/server.ts
new file mode 100644
index 0000000..b1244d0
--- /dev/null
+++ b/src/lib/opencode/server.ts
@@ -0,0 +1,399 @@
+/**
+ * OpenCode server lifecycle management
+ * Handles starting, stopping, and monitoring OpenCode server
+ */
+
+import { spawn } from "cross-spawn";
+import type { ChildProcess } from "child_process";
+import type { OpencodeConfig, OpencodeServerInfo, OpencodeServerStatus } from "./types";
+import { configToEnv } from "./config";
+
+const DEFAULT_PORT = 4096;
+const DEFAULT_HOSTNAME = "127.0.0.1";
+const STARTUP_TIMEOUT = 30000; // Increased from 10s to 30s
+const HEALTH_CHECK_INTERVAL = 5000;
+
+class OpencodeServer {
+ private process: ChildProcess | null = null;
+ private status: OpencodeServerStatus = "stopped";
+ private url: string | null = null;
+ private error: string | null = null;
+ private port: number = DEFAULT_PORT;
+ private hostname: string = DEFAULT_HOSTNAME;
+ private healthCheckInterval: NodeJS.Timeout | null = null;
+ private listeners: Set<(info: OpencodeServerInfo) => void> = new Set();
+
+ /**
+ * Start OpenCode server with the given configuration
+ */
+ async start(config: OpencodeConfig): Promise {
+ if (this.status === "running") {
+ return this.getInfo();
+ }
+
+ if (this.status === "starting") {
+ throw new Error("Server is already starting");
+ }
+
+ this.setStatus("starting");
+
+ try {
+ const env = {
+ ...process.env,
+ ...configToEnv(config),
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
+ };
+
+ const args = [
+ "serve",
+ `--hostname=${this.hostname}`,
+ `--port=${this.port}`,
+ ];
+
+ if (config.logLevel) {
+ args.push(`--log-level=${config.logLevel}`);
+ }
+
+ console.log("[OpenCode] Starting server with args:", args);
+ console.log("[OpenCode] Provider:", config.provider);
+ console.log("[OpenCode] API Key set:", !!config.apiKey);
+
+ this.process = spawn("opencode", args, {
+ env,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ const serverUrl = await this.waitForServerStart();
+ this.url = serverUrl;
+ this.setStatus("running");
+ this.startHealthChecks();
+
+ this.process.on("exit", (code: number | null) => {
+ this.handleExit(code);
+ });
+
+ this.process.on("error", (err: Error) => {
+ this.handleError(err);
+ });
+
+ return this.getInfo();
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ console.error("[OpenCode] Startup failed:", errorMsg);
+ this.handleError(err instanceof Error ? err : new Error(String(err)));
+ throw err;
+ }
+ }
+
+ /**
+ * Stop the OpenCode server
+ */
+ async stop(): Promise {
+ this.stopHealthChecks();
+
+ if (this.process) {
+ this.process.kill("SIGTERM");
+
+ // Force kill after 5 seconds if it doesn't stop
+ await new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ if (this.process) {
+ this.process.kill("SIGKILL");
+ }
+ resolve(undefined);
+ }, 5000);
+
+ this.process?.on("exit", () => {
+ clearTimeout(timeout);
+ resolve(undefined);
+ });
+ });
+
+ this.process = null;
+ }
+
+ this.url = null;
+ this.error = null;
+ this.setStatus("stopped");
+ }
+
+ /**
+ * Restart the server with new configuration
+ */
+ async restart(config: OpencodeConfig): Promise {
+ await this.stop();
+ return this.start(config);
+ }
+
+ /**
+ * Get current server info
+ */
+ getInfo(): OpencodeServerInfo {
+ return {
+ status: this.status,
+ url: this.url ?? undefined,
+ error: this.error ?? undefined,
+ pid: this.process?.pid,
+ };
+ }
+
+ /**
+ * Subscribe to server status changes
+ */
+ subscribe(listener: (info: OpencodeServerInfo) => void): () => void {
+ this.listeners.add(listener);
+
+ // Immediately notify with current status
+ listener(this.getInfo());
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ /**
+ * Wait for server to start and return URL
+ */
+ private async waitForServerStart(): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ // Try health check as fallback
+ const fallbackUrl = `http://${this.hostname}:${this.port}`;
+ this.checkServerHealth(fallbackUrl)
+ .then(() => {
+ resolve(fallbackUrl);
+ })
+ .catch(() => {
+ reject(new Error(`Server failed to start within ${STARTUP_TIMEOUT}ms`));
+ });
+ }, STARTUP_TIMEOUT);
+
+ let output = "";
+ const checkOutput = (data: Buffer) => {
+ output += data.toString();
+
+ // Log output for debugging
+ console.log("[OpenCode Server]", data.toString().trim());
+
+ const lines = output.split("\n");
+
+ for (const line of lines) {
+ // Match various server startup messages
+ if (line.includes("listening") || line.includes("server running") || line.includes("Listening")) {
+ // Try to extract URL from common patterns
+ let url = null;
+
+ // Pattern: "listening on http://..."
+ const match1 = line.match(/(?:listening|running)\s+(?:on\s+)?(https?:\/\/[^\s]+)/i);
+ if (match1) {
+ url = match1[1];
+ }
+
+ // Pattern: "http://..." anywhere in the line
+ if (!url) {
+ const match2 = line.match(/(https?:\/\/[^\s]+)/);
+ if (match2) {
+ url = match2[1];
+ }
+ }
+
+ // If we found a URL or just got the listening message, assume server is ready
+ if (url || line.includes("listening")) {
+ clearTimeout(timeout);
+ resolve(url || `http://${this.hostname}:${this.port}`);
+ return;
+ }
+ }
+ }
+ };
+
+ this.process?.stdout?.on("data", checkOutput);
+ this.process?.stderr?.on("data", checkOutput);
+ });
+ }
+
+ /**
+ * Check if server is healthy via HTTP
+ */
+ private async checkServerHealth(url: string): Promise {
+ const maxAttempts = 10;
+ let lastError: Error | null = null;
+
+ for (let i = 0; i < maxAttempts; i++) {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
+
+ try {
+ const response = await fetch(`${url}/health`, {
+ method: "GET",
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (response.ok || response.status === 404) {
+ return;
+ }
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ } catch (err) {
+ lastError = err instanceof Error ? err : new Error(String(err));
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ }
+
+ throw lastError || new Error("Server health check failed");
+ }
+
+ /**
+ * Perform health check on the server
+ */
+ private async healthCheck(): Promise {
+ if (!this.url) return false;
+
+ try {
+ const response = await fetch(`${this.url}/health`, {
+ method: "GET",
+ signal: AbortSignal.timeout(2000),
+ });
+
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Start periodic health checks
+ */
+ private startHealthChecks(): void {
+ this.stopHealthChecks();
+
+ this.healthCheckInterval = setInterval(async () => {
+ const healthy = await this.healthCheck();
+
+ if (!healthy && this.status === "running") {
+ this.setStatus("error", "Server health check failed");
+ }
+ }, HEALTH_CHECK_INTERVAL);
+ }
+
+ /**
+ * Stop periodic health checks
+ */
+ private stopHealthChecks(): void {
+ if (this.healthCheckInterval) {
+ clearInterval(this.healthCheckInterval);
+ this.healthCheckInterval = null;
+ }
+ }
+
+ /**
+ * Handle server exit
+ */
+ private handleExit(code: number | null): void {
+ this.process = null;
+ this.url = null;
+
+ if (code !== 0 && code !== null) {
+ this.setStatus("error", `Server exited with code ${code}`);
+ } else {
+ this.setStatus("stopped");
+ }
+ }
+
+ /**
+ * Handle server error
+ */
+ private handleError(err: Error): void {
+ this.process = null;
+ this.url = null;
+ this.setStatus("error", err.message);
+ }
+
+ /**
+ * Update status and notify listeners
+ */
+ private setStatus(status: OpencodeServerStatus, error?: string): void {
+ this.status = status;
+ this.error = error ?? null;
+
+ const info = this.getInfo();
+ this.listeners.forEach((listener) => listener(info));
+ }
+
+ /**
+ * Set custom port (must be called before start)
+ */
+ setPort(port: number): void {
+ if (this.status !== "stopped") {
+ throw new Error("Cannot change port while server is running");
+ }
+ this.port = port;
+ }
+
+ /**
+ * Set custom hostname (must be called before start)
+ */
+ setHostname(hostname: string): void {
+ if (this.status !== "stopped") {
+ throw new Error("Cannot change hostname while server is running");
+ }
+ this.hostname = hostname;
+ }
+}
+
+// Singleton instance
+let serverInstance: OpencodeServer | null = null;
+
+/**
+ * Get the singleton OpenCode server instance
+ */
+export function getOpencodeServer(): OpencodeServer {
+ if (!serverInstance) {
+ serverInstance = new OpencodeServer();
+ }
+ return serverInstance;
+}
+
+/**
+ * Helper: Start OpenCode server
+ */
+export async function startOpencodeServer(config: OpencodeConfig): Promise {
+ const server = getOpencodeServer();
+ return server.start(config);
+}
+
+/**
+ * Helper: Stop OpenCode server
+ */
+export async function stopOpencodeServer(): Promise {
+ const server = getOpencodeServer();
+ return server.stop();
+}
+
+/**
+ * Helper: Restart OpenCode server
+ */
+export async function restartOpencodeServer(config: OpencodeConfig): Promise {
+ const server = getOpencodeServer();
+ return server.restart(config);
+}
+
+/**
+ * Helper: Get server info
+ */
+export function getOpencodeServerInfo(): OpencodeServerInfo {
+ const server = getOpencodeServer();
+ return server.getInfo();
+}
+
+/**
+ * Helper: Subscribe to server status changes
+ */
+export function subscribeToServerStatus(listener: (info: OpencodeServerInfo) => void): () => void {
+ const server = getOpencodeServer();
+ return server.subscribe(listener);
+}
diff --git a/src/lib/opencode/types.ts b/src/lib/opencode/types.ts
new file mode 100644
index 0000000..c841312
--- /dev/null
+++ b/src/lib/opencode/types.ts
@@ -0,0 +1,154 @@
+/**
+ * OpenCode integration types
+ */
+
+export type OpencodeProvider =
+ | "anthropic"
+ | "openai"
+ | "google"
+ | "azure"
+ | "bedrock"
+ | "cohere"
+ | "groq"
+ | "mistral"
+ | "perplexity"
+ | "openrouter"
+ | "local";
+
+export type OpencodeConfig = {
+ provider: OpencodeProvider;
+ apiKey?: string;
+ model?: string;
+ baseUrl?: string;
+ logLevel?: "debug" | "info" | "warn" | "error";
+ mcpServers?: McpServerConfig[];
+ skills?: string[];
+ hooks?: string[];
+};
+
+export type McpServerConfig = {
+ name: string;
+ command: string;
+ args?: string[];
+ env?: Record;
+};
+
+export type OpencodeServerStatus =
+ | "stopped"
+ | "starting"
+ | "running"
+ | "error";
+
+export type OpencodeServerInfo = {
+ status: OpencodeServerStatus;
+ url?: string;
+ error?: string;
+ pid?: number;
+};
+
+export type AgentType = "build" | "plan" | "general";
+
+export type AgentRequest = {
+ type: AgentType;
+ message: string;
+ context?: {
+ files?: string[];
+ codeSnippets?: Array<{ path: string; content: string }>;
+ previousMessages?: Array<{ role: "user" | "assistant"; content: string }>;
+ };
+};
+
+export type AgentResponse = {
+ success: boolean;
+ response?: string;
+ actions?: Array<{
+ type: "file_edit" | "file_create" | "file_delete" | "bash_command" | "code_suggestion";
+ payload: unknown;
+ }>;
+ error?: string;
+};
+
+export type ProviderModelConfig = {
+ provider: OpencodeProvider;
+ defaultModel: string;
+ apiKeyEnvVar: string;
+ apiKeyFormat?: RegExp;
+ baseUrlRequired?: boolean;
+};
+
+export const PROVIDER_CONFIGS: Record = {
+ anthropic: {
+ provider: "anthropic",
+ defaultModel: "claude-sonnet-4.5",
+ apiKeyEnvVar: "ANTHROPIC_API_KEY",
+ apiKeyFormat: /^sk-ant-api03-[\w-]+$/,
+ },
+ openai: {
+ provider: "openai",
+ defaultModel: "gpt-5.4-mini",
+ apiKeyEnvVar: "OPENAI_API_KEY",
+ // More specific: OpenAI keys are longer (20+ chars after "sk-")
+ apiKeyFormat: /^sk-[a-zA-Z0-9]{20,}$/,
+ },
+ google: {
+ provider: "google",
+ defaultModel: "gemini-2.5-flash",
+ apiKeyEnvVar: "GOOGLE_API_KEY",
+ // Google API keys don't have a consistent prefix; skip auto-detection
+ },
+ azure: {
+ provider: "azure",
+ defaultModel: "gpt-4",
+ apiKeyEnvVar: "AZURE_OPENAI_API_KEY",
+ baseUrlRequired: true,
+ // Azure uses base64-encoded tokens; skip auto-detection
+ },
+ bedrock: {
+ provider: "bedrock",
+ defaultModel: "anthropic.claude-v3-5-sonnet",
+ apiKeyEnvVar: "AWS_ACCESS_KEY_ID",
+ // AWS uses AKIA format; risky to detect without full access key
+ },
+ cohere: {
+ provider: "cohere",
+ defaultModel: "command-r-plus",
+ apiKeyEnvVar: "COHERE_API_KEY",
+ // Cohere keys typically start with "co_"
+ apiKeyFormat: /^co_[\w-]+$/,
+ },
+ groq: {
+ provider: "groq",
+ defaultModel: "llama-3.3-70b-versatile",
+ apiKeyEnvVar: "GROQ_API_KEY",
+ // Groq keys start with "gsk_"
+ apiKeyFormat: /^gsk_[\w-]+$/,
+ },
+ mistral: {
+ provider: "mistral",
+ defaultModel: "mistral-large-latest",
+ apiKeyEnvVar: "MISTRAL_API_KEY",
+ // Mistral keys start with "k_" or are UUIDs; using common pattern
+ apiKeyFormat: /^[\w-]{32,}$/,
+ },
+ perplexity: {
+ provider: "perplexity",
+ defaultModel: "llama-3.1-sonar-large-128k-online",
+ apiKeyEnvVar: "PERPLEXITY_API_KEY",
+ // Perplexity keys start with "pplx_"
+ apiKeyFormat: /^pplx[-_][\w-]+$/,
+ },
+ openrouter: {
+ provider: "openrouter",
+ defaultModel: "anthropic/claude-3.5-sonnet",
+ apiKeyEnvVar: "OPENROUTER_API_KEY",
+ // OpenRouter keys start with "sk-or-"
+ apiKeyFormat: /^sk-or-[\w-]+$/,
+ },
+ local: {
+ provider: "local",
+ defaultModel: "custom",
+ apiKeyEnvVar: "",
+ baseUrlRequired: true,
+ // Local models don't require API keys
+ },
+};
diff --git a/src/lib/server/terminal-sessions.ts b/src/lib/server/terminal-sessions.ts
new file mode 100644
index 0000000..bbbdc68
--- /dev/null
+++ b/src/lib/server/terminal-sessions.ts
@@ -0,0 +1,245 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+
+export const TERMINAL_REPO_PATH_HEADER = "x-codeflow-repo-path";
+
+export type TerminalSessionStatus = "running" | "exited" | "error";
+
+export type TerminalSessionSummary = {
+ id: string;
+ title: string;
+ cwd: string;
+ shell: string;
+ status: TerminalSessionStatus;
+ startedAt: string;
+ lastActivityAt: string;
+ exitCode: number | null;
+};
+
+export type TerminalSessionSnapshot = TerminalSessionSummary & {
+ output: string;
+ truncated: boolean;
+};
+
+type InternalTerminalSession = TerminalSessionSnapshot & {
+ child: ChildProcessWithoutNullStreams;
+};
+
+const DEFAULT_WORKSPACE_ROOT = process.env.CODEFLOW_REPO_ROOT ?? /* turbopackIgnore: true */ process.cwd();
+const OUTPUT_CAP_BYTES = 128 * 1024;
+const OUTPUT_TRUNCATION_NOTICE = "[CodeFlow] Older terminal output truncated.\n";
+
+const sessions = new Map();
+let sessionCounter = 0;
+
+const stripTruncationNotice = (value: string): string =>
+ value.startsWith(OUTPUT_TRUNCATION_NOTICE) ? value.slice(OUTPUT_TRUNCATION_NOTICE.length) : value;
+
+const clampOutput = (value: string): { output: string; truncated: boolean } => {
+ const buffer = Buffer.from(value, "utf8");
+ if (buffer.byteLength <= OUTPUT_CAP_BYTES) {
+ return { output: value, truncated: false };
+ }
+
+ const noticeBytes = Buffer.byteLength(OUTPUT_TRUNCATION_NOTICE, "utf8");
+ const remainingBytes = Math.max(0, OUTPUT_CAP_BYTES - noticeBytes);
+ const tail = buffer.subarray(Math.max(0, buffer.byteLength - remainingBytes)).toString("utf8");
+ return {
+ output: `${OUTPUT_TRUNCATION_NOTICE}${tail}`,
+ truncated: true
+ };
+};
+
+const appendOutput = (session: InternalTerminalSession, chunk: string) => {
+ if (!chunk) {
+ return;
+ }
+
+ const next = `${stripTruncationNotice(session.output)}${chunk}`;
+ const clamped = clampOutput(next);
+ session.output = clamped.output;
+ session.truncated = clamped.truncated;
+ session.lastActivityAt = new Date().toISOString();
+};
+
+const toSummary = (session: InternalTerminalSession): TerminalSessionSummary => ({
+ id: session.id,
+ title: session.title,
+ cwd: session.cwd,
+ shell: session.shell,
+ status: session.status,
+ startedAt: session.startedAt,
+ lastActivityAt: session.lastActivityAt,
+ exitCode: session.exitCode
+});
+
+const toSnapshot = (session: InternalTerminalSession): TerminalSessionSnapshot => ({
+ ...toSummary(session),
+ output: session.output,
+ truncated: session.truncated
+});
+
+const resolveInitialCwd = async (cwd?: string): Promise => {
+ const resolved = cwd?.trim() ? path.resolve(cwd.trim()) : path.resolve(DEFAULT_WORKSPACE_ROOT);
+ const stats = await fs.stat(resolved).catch(() => null);
+
+ if (!stats?.isDirectory()) {
+ throw new Error(`Terminal working directory does not exist or is not a directory: ${resolved}`);
+ }
+
+ return resolved;
+};
+
+const getShellPath = (): string => {
+ const configuredShell = process.env.CODEFLOW_TERMINAL_SHELL?.trim();
+ if (configuredShell) {
+ return configuredShell;
+ }
+
+ return process.env.SHELL?.trim() || "/bin/sh";
+};
+
+const recordInput = (session: InternalTerminalSession, input: string) => {
+ const printable = input
+ .replace(/\r/g, "")
+ .split("\n")
+ .map((line) => line.trimEnd())
+ .filter((line) => line.length > 0)
+ .join("\n");
+
+ if (!printable) {
+ return;
+ }
+
+ appendOutput(
+ session,
+ `${printable
+ .split("\n")
+ .map((line) => `$ ${line}`)
+ .join("\n")}\n`
+ );
+};
+
+export const listTerminalSessions = (): TerminalSessionSummary[] =>
+ [...sessions.values()]
+ .map(toSummary)
+ .sort((left, right) => right.startedAt.localeCompare(left.startedAt));
+
+export const getTerminalSession = (id: string): TerminalSessionSnapshot | null => {
+ const session = sessions.get(id);
+ return session ? toSnapshot(session) : null;
+};
+
+export const createTerminalSession = async (options?: {
+ cwd?: string;
+ title?: string;
+}): Promise => {
+ const cwd = await resolveInitialCwd(options?.cwd);
+ const shell = getShellPath();
+ const child = spawn(shell, [], {
+ cwd,
+ env: {
+ ...process.env,
+ TERM: process.env.TERM || "xterm-256color"
+ },
+ stdio: ["pipe", "pipe", "pipe"]
+ });
+ const startedAt = new Date().toISOString();
+ sessionCounter += 1;
+
+ const session: InternalTerminalSession = {
+ id: randomUUID(),
+ title: options?.title?.trim() || `Shell ${sessionCounter}`,
+ cwd,
+ shell,
+ status: "running",
+ startedAt,
+ lastActivityAt: startedAt,
+ exitCode: null,
+ output: "",
+ truncated: false,
+ child
+ };
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ appendOutput(session, chunk.toString("utf8"));
+ });
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ appendOutput(session, chunk.toString("utf8"));
+ });
+
+ child.on("error", (error) => {
+ session.status = "error";
+ session.exitCode = null;
+ appendOutput(session, `\n[CodeFlow] Terminal process error: ${error.message}\n`);
+ });
+
+ child.on("close", (code) => {
+ session.status = session.status === "error" ? "error" : "exited";
+ session.exitCode = code;
+ appendOutput(session, `\n[CodeFlow] Terminal exited with code ${code ?? "unknown"}.\n`);
+ });
+
+ sessions.set(session.id, session);
+ return toSnapshot(session);
+};
+
+export const writeTerminalInput = async (
+ id: string,
+ input: string,
+ options?: { echoInput?: boolean }
+): Promise => {
+ const session = sessions.get(id);
+ if (!session) {
+ throw new Error(`Terminal session ${id} was not found.`);
+ }
+
+ if (session.status !== "running") {
+ throw new Error(`Terminal session ${id} is no longer running.`);
+ }
+
+ if (options?.echoInput ?? true) {
+ recordInput(session, input);
+ }
+
+ await new Promise((resolve, reject) => {
+ session.child.stdin.write(input, (error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ resolve();
+ });
+ });
+
+ session.lastActivityAt = new Date().toISOString();
+ return toSnapshot(session);
+};
+
+export const closeTerminalSession = (id: string): boolean => {
+ const session = sessions.get(id);
+ if (!session) {
+ return false;
+ }
+
+ if (session.status === "running") {
+ session.child.kill("SIGTERM");
+ }
+
+ sessions.delete(id);
+ return true;
+};
+
+export const shutdownAllTerminalSessions = () => {
+ for (const session of sessions.values()) {
+ if (session.status === "running") {
+ session.child.kill("SIGTERM");
+ }
+ }
+
+ sessions.clear();
+};