diff --git a/src/client/app/settings/SkillsSection.tsx b/src/client/app/settings/SkillsSection.tsx index 2fd5d64fa..d43b6a7be 100644 --- a/src/client/app/settings/SkillsSection.tsx +++ b/src/client/app/settings/SkillsSection.tsx @@ -20,6 +20,7 @@ const PROVIDER_LABELS: Record = { codex: "Codex", cursor: "Cursor", pi: "Pi", + opencode: "opencode", } function formatInstallCount(count: number) { diff --git a/src/client/components/auth/AuthCard.tsx b/src/client/components/auth/AuthCard.tsx index f47382d59..d507f303a 100644 --- a/src/client/components/auth/AuthCard.tsx +++ b/src/client/components/auth/AuthCard.tsx @@ -7,6 +7,7 @@ import { AUTH_SERVICE_ICONS } from "../provider-icons" import { Button } from "../ui/button" import { CopyButton } from "../ui/copy-button" import { Input } from "../ui/input" +import { OpenCodeSignInDialog } from "./OpenCodeSignInDialog" /** "2.1.218" → "v2.1.218"; calendar/otherwise-shaped versions pass through. */ function displayVersion(version: string | null): string | null { @@ -223,21 +224,31 @@ export function AuthCard({ service, socket, className, + badge, }: { service: AuthServiceSnapshot socket: KannaSocket className?: string + /** Small pill beside the name (onboarding uses it to mark a recommendation). */ + badge?: string }) { const Icon = AUTH_SERVICE_ICONS[service.service] const version = displayVersion(service.version) const installing = service.installState === "installing" const loginActive = service.login.phase !== "idle" + const [openCodeDialogOpen, setOpenCodeDialogOpen] = useState(false) const startLogin = () => { if (service.service === "openrouter") { void startOpenRouterOauth(socket).catch(() => undefined) return } + if (service.service === "opencode") { + // opencode's picker runs in a terminal inside a dialog — see + // OpenCodeSignInDialog for why there is no server-driven flow. + setOpenCodeDialogOpen(true) + return + } void socket.command({ type: "auth.login.start", service: service.service }).catch(() => undefined) } const install = () => { @@ -274,9 +285,22 @@ export function AuthCard({ action = Update to {displayVersion(service.latestVersion)} } else if (service.authStatus === "signed_in") { action = ( - - - +
+ {/* opencode holds one credential per provider, so connecting another + stays useful after the first — every other service is one account. */} + {service.service === "opencode" ? ( + + ) : null} + + + +
) } else if (service.authStatus === "outdated") { // The installed CLI can't run the commands Kanna drives — updating is @@ -300,6 +324,11 @@ export function AuthCard({
{service.label} + {badge ? ( + + {badge} + + ) : null} {version ? ( {version} ) : null} @@ -313,6 +342,14 @@ export function AuthCard({
{service.statusDetail}
) : null} + {service.service === "opencode" ? ( + + ) : null}
) } diff --git a/src/client/components/auth/OpenCodeSignInDialog.tsx b/src/client/components/auth/OpenCodeSignInDialog.tsx new file mode 100644 index 000000000..a19a8810c --- /dev/null +++ b/src/client/components/auth/OpenCodeSignInDialog.tsx @@ -0,0 +1,151 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { Check, Loader2 } from "lucide-react" +import type { AuthServiceSnapshot } from "../../../shared/types" +import type { KannaSocket, SocketStatus } from "../../app/socket" +import { TerminalPane } from "../chat-ui/TerminalPane" +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogGhostButton, + DialogHeader, + DialogTitle, +} from "../ui/dialog" + +/** + * opencode's sign-in dialog. + * + * Unlike the other harnesses, opencode has no single account and no device + * flow: `opencode auth login` is an interactive picker over ~100 providers, + * and several of them (GitHub Copilot, ChatGPT, Claude Pro/Max) finish in a + * browser rather than with a pasted key. Scraping that would only ever cover + * the API-key subset, so instead the real CLI runs inside this dialog and + * Kanna watches for the credential to land — the same shape as the OAuth + * cards, with the CLI standing in for the provider's web page. + * + * Completion is detected by polling the auth snapshot rather than by watching + * the terminal: the credential file is the source of truth, and it means + * adding a *second* provider (when already signed in) is detected too. + */ + +/** How often to re-probe while the dialog is open. */ +const POLL_INTERVAL_MS = 2_000 +/** Let the ✓ land before the dialog closes itself. */ +const SUCCESS_LINGER_MS = 900 + +export function OpenCodeSignInDialog({ + service, + socket, + open, + onOpenChange, +}: { + service: AuthServiceSnapshot + socket: KannaSocket + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [connectionStatus, setConnectionStatus] = useState("connecting") + const [connected, setConnected] = useState(false) + + // A fresh terminal per opening, so reopening never replays a finished login. + const terminalId = useMemo( + () => (open ? `opencode-login-${Math.random().toString(36).slice(2, 10)}` : null), + [open] + ) + + // The credentials already present when the dialog opened. Anything beyond + // this baseline is what the user just added — which is how "add another + // provider" is detected even though the card already reads as signed in. + const baselineRef = useRef(null) + useEffect(() => { + if (open) { + baselineRef.current = service.authStatus === "signed_in" ? service.account ?? "" : null + setConnected(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + useEffect(() => socket.onStatus(setConnectionStatus), [socket]) + + // Poll the probe while the dialog is open; the CLI writes auth.json when the + // user finishes, and the next refresh turns that into a signed_in snapshot. + useEffect(() => { + if (!open || connected) return + const timer = setInterval(() => { + void socket.command({ type: "auth.refresh", service: service.service }).catch(() => undefined) + }, POLL_INTERVAL_MS) + return () => clearInterval(timer) + }, [open, connected, socket, service.service]) + + useEffect(() => { + if (!open || connected) return + if (service.authStatus !== "signed_in") return + // Signed in and the credential list changed (or there was none before). + if (baselineRef.current !== null && (service.account ?? "") === baselineRef.current) return + setConnected(true) + }, [open, connected, service.authStatus, service.account]) + + useEffect(() => { + if (!connected) return + const timer = setTimeout(() => onOpenChange(false), SUCCESS_LINGER_MS) + return () => clearTimeout(timer) + }, [connected, onOpenChange]) + + // Tear the shell down on close — the login process should not outlive the dialog. + useEffect(() => { + if (open || !terminalId) return + void socket.command({ type: "terminal.close", terminalId }).catch(() => undefined) + }, [open, terminalId, socket]) + + return ( + + + + Sign in to opencode + + Pick a provider and follow the prompts. opencode keeps credentials on this machine, and + you can connect as many providers as you like. + + + + +
+ {open && terminalId ? ( + + ) : null} +
+
+ + +
+ {connected ? ( + <> + + + Connected{service.account ? ` — ${service.account}` : ""} + + + ) : ( + <> + + Waiting for a credential… + + )} +
+ onOpenChange(false)}> + {connected ? "Close" : "Cancel"} + +
+
+
+ ) +} diff --git a/src/client/components/auth/SetupWizard.tsx b/src/client/components/auth/SetupWizard.tsx index fc59863d4..59d8af44b 100644 --- a/src/client/components/auth/SetupWizard.tsx +++ b/src/client/components/auth/SetupWizard.tsx @@ -14,7 +14,13 @@ type SetupStep = (typeof STEP_ORDER)[number] /** Auto-advance delay after a skippable step connects — long enough to see the ✓ land. */ const AUTO_ADVANCE_MS = 900 -const AGENT_SERVICES: AuthServiceId[] = ["claude", "codex", "cursor"] +const AGENT_SERVICES: AuthServiceId[] = ["claude", "codex", "cursor", "opencode"] + +/** Cards that carry a pill in the agents step. */ +const AGENT_BADGES: Partial> = { + // opencode ships free models, so it's the fastest card to get running. + opencode: "Recommended", +} function StepHeading({ title, description }: { title: string; description: string }) { return ( @@ -210,7 +216,12 @@ export function SetupWizard() { />
{services.agents.map((service) => ( - + ))}
void onModelChange: (provider: AgentProvider, model: string) => void onModelOptionChange: (change: ModelOptionChange) => void diff --git a/src/client/components/provider-icons.tsx b/src/client/components/provider-icons.tsx index 9e381cb74..9b9bd6f15 100644 --- a/src/client/components/provider-icons.tsx +++ b/src/client/components/provider-icons.tsx @@ -94,17 +94,40 @@ export function OpenRouterIcon({ className, ...props }: SVGProps) ) } +// Placeholder mark (terminal prompt) — swap for opencode's real logo. +export function OpenCodeIcon({ className, ...props }: SVGProps) { + return ( + + ) +} + export const PROVIDER_ICONS: Record = { claude: AnthropicIcon, codex: OpenAIIcon, cursor: CursorIcon, pi: PiIcon, + opencode: OpenCodeIcon, } export const AUTH_SERVICE_ICONS: Record = { claude: AnthropicIcon, codex: OpenAIIcon, cursor: CursorIcon, + opencode: OpenCodeIcon, gh: GitHubIcon, openrouter: OpenRouterIcon, } diff --git a/src/client/lib/composer.ts b/src/client/lib/composer.ts index 13fa7e0d8..d095d5400 100644 --- a/src/client/lib/composer.ts +++ b/src/client/lib/composer.ts @@ -110,6 +110,14 @@ export function getEffectiveComposerState( planMode: composerState.planMode, autoPlan: composerState.autoPlan, } + case "opencode": + return { + provider: "opencode", + model: providerDefaults.opencode.model, + modelOptions: { ...providerDefaults.opencode.modelOptions }, + planMode: composerState.planMode, + autoPlan: composerState.autoPlan, + } default: return assertNever(activeProvider) } diff --git a/src/client/stores/chatPreferencesStore.test.ts b/src/client/stores/chatPreferencesStore.test.ts index 1ac4a8a60..6f2f6895d 100644 --- a/src/client/stores/chatPreferencesStore.test.ts +++ b/src/client/stores/chatPreferencesStore.test.ts @@ -107,6 +107,12 @@ describe("migrateChatPreferencesState", () => { planMode: false, autoPlan: false, }, + opencode: { + model: "opencode/north-mini-code-free", + modelOptions: {}, + planMode: false, + autoPlan: false, + }, }, chatStates: {}, legacyComposerState: { diff --git a/src/client/stores/providerAuthStore.test.ts b/src/client/stores/providerAuthStore.test.ts index dffba52ed..ef62e618f 100644 --- a/src/client/stores/providerAuthStore.test.ts +++ b/src/client/stores/providerAuthStore.test.ts @@ -24,7 +24,7 @@ function service( function snapshotWith(status: AuthServiceSnapshot["authStatus"]): ProviderAuthSnapshot { return { - services: (["claude", "codex", "cursor", "gh", "openrouter"] as const).map((id) => + services: (["claude", "codex", "cursor", "opencode", "gh", "openrouter"] as const).map((id) => service(id, status), ), } diff --git a/src/client/stores/providerAuthStore.ts b/src/client/stores/providerAuthStore.ts index 6cef48f2a..f5f64c20d 100644 --- a/src/client/stores/providerAuthStore.ts +++ b/src/client/stores/providerAuthStore.ts @@ -141,13 +141,14 @@ export function getSetupStatus(snapshot: ProviderAuthSnapshot | null): SetupStat const services = snapshot?.services ?? [] const byId = new Map(services.map((service) => [service.service, service])) const isConnected = (id: AuthServiceId) => byId.get(id)?.authStatus === "signed_in" - const relevant: AuthServiceId[] = ["claude", "codex", "cursor", "gh", "openrouter"] + const relevant: AuthServiceId[] = ["claude", "codex", "cursor", "opencode", "gh", "openrouter"] const resolved = services.length > 0 && relevant.every((id) => { const status = byId.get(id)?.authStatus return status !== undefined && status !== "unknown" }) const githubConnected = isConnected("gh") - const anyAgentConnected = isConnected("claude") || isConnected("codex") || isConnected("cursor") + const anyAgentConnected = + isConnected("claude") || isConnected("codex") || isConnected("cursor") || isConnected("opencode") const openRouterConnected = isConnected("openrouter") return { resolved, diff --git a/src/server/acp-protocol.ts b/src/server/acp-protocol.ts new file mode 100644 index 000000000..070d04df1 --- /dev/null +++ b/src/server/acp-protocol.ts @@ -0,0 +1,221 @@ +// Minimal typed subset of the Agent Client Protocol (ACP), vendored the same +// way codex-app-server-protocol.ts vendors the codex app-server types. +// +// ACP is the editor<->agent protocol behind Zed's agent panel: newline-delimited +// JSON-RPC 2.0 over the agent's stdio. Kanna speaks the *client* half. +// +// Field shapes here were captured from a live `opencode acp` (v1.18.8) session +// rather than transcribed from the spec, so they reflect what an agent actually +// puts on the wire. Anything Kanna does not consume is left off. +// +// Spec: https://agentclientprotocol.com + +export const ACP_PROTOCOL_VERSION = 1 + +export type AcpRequestId = string | number + +export interface AcpJsonRpcResponse { + id: AcpRequestId + result?: TResult + error?: { code?: number; message?: string; data?: unknown } +} + +/** A request *from* the agent that the client must answer (permissions, fs, terminal). */ +export interface AcpServerRequest { + id: AcpRequestId + method: string + params?: Record +} + +export interface AcpNotification { + method: string + params?: Record +} + +// ---------------------------------------------------------------- initialize + +export interface AcpInitializeParams { + protocolVersion: number + clientCapabilities: { + fs: { readTextFile: boolean; writeTextFile: boolean } + terminal: boolean + } + clientInfo: { name: string; title: string; version: string } +} + +export interface AcpInitializeResult { + protocolVersion: number + agentCapabilities?: { + loadSession?: boolean + promptCapabilities?: { image?: boolean; audio?: boolean; embeddedContext?: boolean } + sessionCapabilities?: Record + } + agentInfo?: { name?: string; version?: string } + authMethods?: Array<{ id: string; name?: string; description?: string }> +} + +// ------------------------------------------------------------------ sessions + +export interface AcpNewSessionParams { + cwd: string + mcpServers: unknown[] +} + +export interface AcpResumeSessionParams { + sessionId: string + cwd: string + mcpServers: unknown[] +} + +/** + * Session-scoped settings the agent exposes for the client to render as + * pickers. opencode reports two: `model` (its full provider/model list) and + * `mode` (build/plan — its agent selector). Both `session/new` and + * `session/set_config_option` answer with the full, updated list. + */ +export interface AcpConfigOption { + id: string + name?: string + category?: string + type?: string + currentValue?: string + options?: Array<{ value: string; name?: string; description?: string }> +} + +export interface AcpSessionResult { + /** Absent on session/resume, which reuses the id the client passed in. */ + sessionId?: string + configOptions?: AcpConfigOption[] +} + +export interface AcpSetConfigOptionParams { + sessionId: string + /** Note: `configId`, not `optionId` — the latter is rejected as invalid params. */ + configId: string + value: string +} + +// -------------------------------------------------------------------- prompt + +export interface AcpPromptParams { + sessionId: string + prompt: Array<{ type: "text"; text: string }> +} + +/** + * `refusal` and the `max_*` reasons are terminal-but-not-successful; Kanna + * renders them as an errored result so the turn does not look like it simply + * ended. + */ +export type AcpStopReason = + | "end_turn" + | "max_tokens" + | "max_turn_requests" + | "refusal" + | "cancelled" + +export interface AcpPromptResult { + stopReason: AcpStopReason + usage?: { + inputTokens?: number + outputTokens?: number + totalTokens?: number + thoughtTokens?: number + cachedReadTokens?: number + } +} + +export interface AcpCancelParams { + sessionId: string +} + +// ------------------------------------------------------------ session/update + +export interface AcpTextContent { + type: "text" + text: string +} + +export type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed" + +export interface AcpToolCallUpdate { + sessionUpdate: "tool_call" | "tool_call_update" + toolCallId: string + /** + * On the first frame this is the tool's name ("read", "bash"); on later + * frames agents overwrite it with a human label ("notes.txt", "ls"), so the + * name must be latched from the first frame that carries it. + */ + title?: string + kind?: string + status?: AcpToolCallStatus + locations?: Array<{ path?: string }> + /** Empty on the `pending` frame; populated once the agent resolves arguments. */ + rawInput?: Record + rawOutput?: unknown + content?: Array<{ type: string; content?: AcpTextContent }> +} + +export interface AcpMessageChunkUpdate { + sessionUpdate: "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk" + messageId?: string + content?: AcpTextContent +} + +export interface AcpUsageUpdate { + sessionUpdate: "usage_update" + used?: number + size?: number + cost?: { amount?: number; currency?: string } +} + +export interface AcpPlanUpdate { + sessionUpdate: "plan" + entries?: Array<{ content?: string; priority?: string; status?: string }> +} + +export interface AcpAvailableCommandsUpdate { + sessionUpdate: "available_commands_update" + availableCommands?: Array<{ name?: string; description?: string }> +} + +export type AcpSessionUpdate = + | AcpToolCallUpdate + | AcpMessageChunkUpdate + | AcpUsageUpdate + | AcpPlanUpdate + | AcpAvailableCommandsUpdate + | { sessionUpdate: string } + +export interface AcpSessionUpdateParams { + sessionId: string + update: AcpSessionUpdate +} + +// -------------------------------------------------------- permission (client) + +export interface AcpPermissionOption { + optionId: string + name?: string + kind?: "allow_once" | "allow_always" | "reject_once" | "reject_always" +} + +export interface AcpRequestPermissionParams { + sessionId: string + options?: AcpPermissionOption[] + toolCall?: { toolCallId?: string; title?: string } +} + +// -------------------------------------------------------------------- guards + +export function isAcpResponse(value: Record): value is AcpJsonRpcResponse & Record { + return value.id !== undefined && value.method === undefined +} + +export function isAcpServerRequest(value: Record): value is AcpServerRequest & Record { + return value.id !== undefined && typeof value.method === "string" +} + +export function isAcpNotification(value: Record): value is AcpNotification & Record { + return value.id === undefined && typeof value.method === "string" +} diff --git a/src/server/agent.ts b/src/server/agent.ts index 6f60faf92..d3e56b42f 100644 --- a/src/server/agent.ts +++ b/src/server/agent.ts @@ -22,6 +22,7 @@ import type { AnalyticsReporter } from "./analytics" import { NoopAnalyticsReporter } from "./analytics" import { CodexAppServerManager } from "./codex-app-server" import { CursorCliManager } from "./cursor-cli" +import { OpenCodeAcpManager } from "./opencode-acp" import { PiAgentManager, resolvePiConnection } from "./pi-agent" import { type GenerateChatTitleResult, generateTitleForChatDetailed } from "./generate-title" import type { ClaudeRateLimitInfoRaw, ClaudeUsageRaw } from "./usage-limits" @@ -41,6 +42,7 @@ import { applyCursorModels, type ClaudeSdkModelInfo, cursorModelIdForOptions, + applyOpenCodeModels, getServerProviderCatalog, normalizeClaudeModelOptions, normalizeCodexModelOptions, @@ -177,6 +179,7 @@ interface AgentCoordinatorArgs { analytics?: AnalyticsReporter codexManager?: CodexAppServerManager cursorManager?: CursorCliManager + openCodeManager?: OpenCodeAcpManager piManager?: PiAgentManager resolvePiConnection?: () => Promise generateTitle?: (messageContent: string, cwd: string) => Promise @@ -817,6 +820,7 @@ export class AgentCoordinator { private readonly analytics: AnalyticsReporter private readonly codexManager: CodexAppServerManager private readonly cursorManager: CursorCliManager + private readonly openCodeManager: OpenCodeAcpManager private readonly piManager: PiAgentManager private readonly resolvePiConnection: () => Promise private readonly generateTitle: (messageContent: string, cwd: string) => Promise @@ -825,6 +829,7 @@ export class AgentCoordinator { private reportBackgroundError: ((message: string) => void) | null = null private onClaudeRateLimit: ((info: ClaudeRateLimitInfoRaw) => void) | null = null private cursorModelCatalogApplied = false + private openCodeModelCatalogApplied = false readonly activeTurns = new Map() readonly drainingStreams = new Map() readonly claudeSessions = new Map() @@ -835,6 +840,7 @@ export class AgentCoordinator { this.analytics = args.analytics ?? NoopAnalyticsReporter this.codexManager = args.codexManager ?? new CodexAppServerManager() this.cursorManager = args.cursorManager ?? new CursorCliManager() + this.openCodeManager = args.openCodeManager ?? new OpenCodeAcpManager() this.piManager = args.piManager ?? new PiAgentManager() this.resolvePiConnection = args.resolvePiConnection ?? resolvePiConnection this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed @@ -949,6 +955,26 @@ export class AgentCoordinator { } } + /** + * Overlay the account's opencode model list (`opencode models`) on the + * catalog — the opencode analog of refreshCursorModelCatalog. Which models + * appear depends on the user's configured opencode providers, so failure + * (binary missing) is expected and quiet. + */ + async refreshOpenCodeModelCatalog(options: { force?: boolean } = {}) { + if (options.force) this.openCodeModelCatalogApplied = false + if (this.openCodeModelCatalogApplied) return + try { + const models = await this.openCodeManager.listModels() + this.openCodeModelCatalogApplied = true + if (applyOpenCodeModels(models)) { + this.emitStateChange(undefined, { immediate: true }) + } + } catch { + // Keep the static fallback catalog; the next opencode turn retries. + } + } + async stopDraining(chatId: string) { const draining = this.drainingStreams.get(chatId) @@ -965,6 +991,7 @@ export class AgentCoordinator { claudeSession.session.close() this.claudeSessions.delete(chatId) } + this.openCodeManager.stopSession(chatId) this.piManager.closeChat(chatId) this.emitStateChange(chatId) } @@ -1003,6 +1030,16 @@ export class AgentCoordinator { } } + if (provider === "opencode") { + return { + model: normalizeServerModel(provider, options.model), + effort: undefined, + serviceTier: undefined, + planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false, + autoPlan: false, + } + } + if (provider === "pi") { const modelOptions = normalizePiModelOptions(options.modelOptions, options.effort) return { @@ -1111,6 +1148,7 @@ export class AgentCoordinator { // resume the old thread on switch-back; kill it so the cleared session // token actually takes effect. Cursor spawns per turn — nothing to close. this.codexManager.stopSession(chatId) + this.openCodeManager.stopSession(chatId) this.piManager.closeChat(chatId) await this.store.setSessionToken(chatId, null) await this.store.setPendingForkSessionToken(chatId, null) @@ -1162,6 +1200,24 @@ export class AgentCoordinator { } case "cursor": return this.checkSessionArtifactFn("cursor", { cwd: args.cwd, sessionToken: args.sessionToken }) === "missing" + case "opencode": { + // Like codex: a resume that silently became a fresh session is the + // signal. Preflighting here is free — the turn's own startSession + // reuses the warm session. + if (!args.sessionToken) return false + try { + const started = await this.openCodeManager.startSession({ + chatId: args.chatId, + cwd: args.cwd, + model: args.model, + planMode: false, + sessionToken: args.sessionToken, + }) + return started.resumeFellBack + } catch { + return false + } + } case "codex": { // No token → nothing to resume; a fork in progress must not be disturbed. if (!args.sessionToken || args.pendingForkSessionToken) return false @@ -1394,6 +1450,21 @@ export class AgentCoordinator { model: args.model, sessionToken: chat.sessionToken, }) + } else if (args.provider === "opencode") { + void this.refreshOpenCodeModelCatalog() + const started = await this.openCodeManager.startSession({ + chatId: args.chatId, + cwd: project.localPath, + model: args.model, + planMode: args.planMode, + sessionToken: chat.sessionToken, + }) + turn = await this.openCodeManager.startTurn({ + chatId: args.chatId, + content: buildPromptText(wireContent, args.attachments), + model: args.model, + }) + void started } else if (args.provider === "pi") { // A missing connection or session boot failure surfaces as an error // result in the turn stream (like Cursor spawn failures) rather than throwing. @@ -1749,6 +1820,12 @@ export class AgentCoordinator { const skills = await this.piManager.listSkills({ chatId: command.chatId, cwd }) return { provider: "pi", skills, origin: "live" } } + case "opencode": { + // ACP pushes the command list on session/update; before the first + // session exists there is nothing to report. + const skills = command.chatId ? this.openCodeManager.listSkills(command.chatId) : null + return { provider: "opencode", skills: skills ?? [], origin: skills ? "live" : "filesystem" } + } } } diff --git a/src/server/app-settings.test.ts b/src/server/app-settings.test.ts index ea98c56e6..de174379e 100644 --- a/src/server/app-settings.test.ts +++ b/src/server/app-settings.test.ts @@ -75,6 +75,12 @@ function expectedSettingsSnapshot(filePath: string, overrides: Partial void) { + super() + let buffer = "" + this.stdin.on("data", (chunk) => { + buffer += chunk.toString() + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + for (const line of lines) { + if (!line.trim()) continue + const message = JSON.parse(line) + this.messages.push(message) + this.onMessage?.(message, this) + } + }) + } + + kill() { + this.killed = true + } + + reply(id: unknown, result: unknown) { + this.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`) + } + + replyError(id: unknown, message: string) { + this.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32602, message } })}\n`) + } + + update(sessionId: string, update: unknown) { + this.stdout.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "session/update", params: { sessionId, update } })}\n` + ) + } + + request(id: unknown, method: string, params: unknown) { + this.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`) + } +} + +const SESSION_ID = "ses_057a824d8ffeDkZ5dYxzXArhGO" + +const CONFIG_OPTIONS = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "opencode/big-pickle", + options: [ + { value: "opencode/big-pickle", name: "OpenCode Zen/Big Pickle" }, + { value: "opencode/north-mini-code-free", name: "OpenCode Zen/North Mini Code Free" }, + ], + }, + { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: "build", + options: [{ value: "build", name: "build" }, { value: "plan", name: "plan" }], + }, +] + +/** Answers the handshake the way the real binary does. */ +function handshakeResponder(extra?: (message: any, child: FakeAcpProcess) => void) { + return (message: any, child: FakeAcpProcess) => { + switch (message.method) { + case "initialize": + child.reply(message.id, { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + agentInfo: { name: "OpenCode", version: "1.18.8" }, + }) + return + case "session/new": + child.reply(message.id, { sessionId: SESSION_ID, configOptions: CONFIG_OPTIONS }) + return + case "session/set_config_option": + child.reply(message.id, { configOptions: CONFIG_OPTIONS }) + return + default: + extra?.(message, child) + } + } +} + +async function collect(stream: AsyncIterable) { + const items: any[] = [] + for await (const item of stream) items.push(item) + return items +} + +function entries(events: any[]) { + return events.filter((event) => event.type === "transcript").map((event) => event.entry) +} + +describe("translateOpenCodeTool", () => { + test("maps opencode tool names onto Kanna's canonical tools", () => { + expect(translateOpenCodeTool("read", "read", { filePath: "/a/notes.txt" })).toEqual({ + toolName: "Read", + input: { file_path: "/a/notes.txt" }, + }) + expect(translateOpenCodeTool("bash", "execute", { command: "ls", workdir: "/a" })).toEqual({ + toolName: "Bash", + input: { command: "ls", description: undefined }, + }) + expect(translateOpenCodeTool("edit", "edit", { filePath: "/a", oldString: "x", newString: "y" })).toEqual({ + toolName: "Edit", + input: { file_path: "/a", old_string: "x", new_string: "y" }, + }) + }) + + test("falls back to the ACP kind for tools it does not know by name", () => { + expect(translateOpenCodeTool("some_mcp_thing", "execute", { command: "make" })).toEqual({ + toolName: "Bash", + input: { command: "make", description: undefined }, + }) + // No name match and no useful kind: passes through, rendering as unknown_tool. + expect(translateOpenCodeTool("weird", "other", { a: 1 })).toEqual({ + toolName: "weird", + input: { a: 1 }, + }) + }) +}) + +describe("AcpTurnTranslator", () => { + test("coalesces streamed message chunks into one assistant_text entry", () => { + const translator = new AcpTurnTranslator() + const events = [ + ...translator.handleUpdate({ + sessionUpdate: "agent_message_chunk", + messageId: "msg_1", + content: { type: "text", text: "Created " }, + }), + ...translator.handleUpdate({ + sessionUpdate: "agent_message_chunk", + messageId: "msg_1", + content: { type: "text", text: "`greeting.txt`." }, + }), + ] + expect(events).toEqual([]) + expect(entries(translator.flushText())).toEqual([ + expect.objectContaining({ kind: "assistant_text", text: "Created `greeting.txt`." }), + ]) + }) + + test("flushes the previous message when the message id changes", () => { + const translator = new AcpTurnTranslator() + translator.handleUpdate({ + sessionUpdate: "agent_message_chunk", + messageId: "msg_1", + content: { type: "text", text: "first" }, + }) + const flushed = translator.handleUpdate({ + sessionUpdate: "agent_message_chunk", + messageId: "msg_2", + content: { type: "text", text: "second" }, + }) + expect(entries(flushed)).toEqual([expect.objectContaining({ kind: "assistant_text", text: "first" })]) + expect(entries(translator.flushText())).toEqual([ + expect.objectContaining({ kind: "assistant_text", text: "second" }), + ]) + }) + + test("drops reasoning and the echoed user prompt", () => { + const translator = new AcpTurnTranslator() + expect( + translator.handleUpdate({ + sessionUpdate: "agent_thought_chunk", + messageId: "msg_1", + content: { type: "text", text: "Let me read notes.txt first." }, + }) + ).toEqual([]) + expect( + translator.handleUpdate({ + sessionUpdate: "user_message_chunk", + messageId: "msg_0", + content: { type: "text", text: "Read notes.txt" }, + }) + ).toEqual([]) + }) + + test("emits a tool_call once arguments arrive, then its result", () => { + const translator = new AcpTurnTranslator() + + // Frame 1: names the tool, but rawInput is still empty. + expect( + translator.handleUpdate({ + sessionUpdate: "tool_call", + toolCallId: "call_1", + title: "read", + kind: "read", + status: "pending", + locations: [], + rawInput: {}, + }) + ).toEqual([]) + + // Frame 2: arguments land, and the title has already been relabeled. + const started = entries( + translator.handleUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "call_1", + status: "in_progress", + kind: "read", + title: "notes.txt", + locations: [{ path: "/a/notes.txt" }], + rawInput: { filePath: "/a/notes.txt" }, + }) + ) + expect(started).toHaveLength(1) + expect(started[0]).toMatchObject({ + kind: "tool_call", + tool: { toolKind: "read_file", toolName: "Read", toolId: "call_1", input: { filePath: "/a/notes.txt" } }, + }) + + const finished = entries( + translator.handleUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "call_1", + status: "completed", + title: "notes.txt", + content: [{ type: "content", content: { type: "text", text: "hello world" } }], + }) + ) + expect(finished).toEqual([ + expect.objectContaining({ kind: "tool_result", toolId: "call_1", content: "hello world", isError: false }), + ]) + }) + + test("emits a tool_call exactly once even when arguments repeat across frames", () => { + const translator = new AcpTurnTranslator() + const frame = { + sessionUpdate: "tool_call_update" as const, + toolCallId: "call_bash", + status: "in_progress" as const, + kind: "execute", + title: "ls", + rawInput: { command: "ls", workdir: "/a" }, + } + expect(entries(translator.handleUpdate({ ...frame, sessionUpdate: "tool_call" }))).toHaveLength(1) + + expect(entries(translator.handleUpdate(frame))).toHaveLength(0) + expect(entries(translator.handleUpdate(frame))).toHaveLength(0) + }) + + test("waits for the real arguments when a pending frame carries partial input", () => { + const translator = new AcpTurnTranslator() + + // Real opencode bash frames: the pending one announces only the cwd. + expect( + entries( + translator.handleUpdate({ + sessionUpdate: "tool_call", + toolCallId: "call_bash", + title: "bash", + kind: "execute", + status: "pending", + locations: [{ path: "/repo" }], + rawInput: { cwd: "/repo" }, + }) + ) + ).toEqual([]) + + const started = entries( + translator.handleUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "call_bash", + status: "in_progress", + kind: "execute", + title: "ls", + rawInput: { command: "ls", workdir: "/repo" }, + }) + ) + expect(started[0]).toMatchObject({ + kind: "tool_call", + tool: { toolName: "Bash", toolKind: "bash", input: { command: "ls" } }, + }) + }) + + test("marks failed tool calls as errors", () => { + const translator = new AcpTurnTranslator() + const events = entries( + translator.handleUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "call_x", + title: "bash", + kind: "execute", + status: "failed", + rawInput: { command: "false" }, + content: [{ type: "content", content: { type: "text", text: "exit 1" } }], + }) + ) + // A call that fails before any in_progress frame still gets both entries. + expect(events.map((entry) => entry.kind)).toEqual(["tool_call", "tool_result"]) + expect(events[1]).toMatchObject({ isError: true, content: "exit 1" }) + }) + + test("orders text emitted before a tool call above it", () => { + const translator = new AcpTurnTranslator() + translator.handleUpdate({ + sessionUpdate: "agent_message_chunk", + messageId: "msg_1", + content: { type: "text", text: "Reading the file." }, + }) + const events = entries( + translator.handleUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "call_1", + title: "read", + kind: "read", + status: "in_progress", + rawInput: { filePath: "/a" }, + }) + ) + expect(events.map((entry) => entry.kind)).toEqual(["assistant_text", "tool_call"]) + }) + + test("maps usage_update onto the context window, including the window size", () => { + const translator = new AcpTurnTranslator() + const events = entries( + translator.handleUpdate({ + sessionUpdate: "usage_update", + used: 8446, + size: 200000, + cost: { amount: 0, currency: "USD" }, + }) + ) + expect(events).toEqual([ + expect.objectContaining({ + kind: "context_window_updated", + usage: expect.objectContaining({ usedTokens: 8446, maxTokens: 200000 }), + }), + ]) + }) + + test("renders an ACP plan as a todo list", () => { + const translator = new AcpTurnTranslator() + const events = entries( + translator.handleUpdate({ + sessionUpdate: "plan", + entries: [ + { content: "Read notes.txt", status: "completed", priority: "high" }, + { content: "Write greeting.txt", status: "in_progress", priority: "medium" }, + ], + }) + ) + expect(events[0]).toMatchObject({ + kind: "tool_call", + tool: { + toolKind: "todo_write", + input: { + todos: [ + { content: "Read notes.txt", status: "completed" }, + { content: "Write greeting.txt", status: "in_progress" }, + ], + }, + }, + }) + }) +}) + +describe("OpenCodeAcpManager", () => { + test("handshakes, opens a session, and applies model + mode", async () => { + const child = new FakeAcpProcess(handshakeResponder()) + const manager = new OpenCodeAcpManager({ spawnProcess: () => child as any }) + + const started = await manager.startSession({ + chatId: "chat-1", + cwd: "/repo", + model: "opencode/north-mini-code-free", + planMode: true, + sessionToken: null, + }) + + expect(started).toEqual({ sessionToken: SESSION_ID, resumeFellBack: false }) + + const initialize = child.messages.find((message) => message.method === "initialize") + expect(initialize.params).toMatchObject({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + }) + + const configs = child.messages.filter((message) => message.method === "session/set_config_option") + expect(configs.map((message) => message.params)).toEqual([ + { sessionId: SESSION_ID, configId: "model", value: "opencode/north-mini-code-free" }, + { sessionId: SESSION_ID, configId: "mode", value: "plan" }, + ]) + manager.stopAll() + }) + + test("reports the model the session confirmed, not the one requested", async () => { + // The agent rejects the requested id and stays on its current model. + const child = new FakeAcpProcess( + handshakeResponder((message, process) => { + if (message.method === "session/set_config_option" && message.params.configId === "model") { + process.replyError(message.id, "unknown model") + } + }) + ) + const manager = new OpenCodeAcpManager({ spawnProcess: () => child as any }) + await manager.startSession({ + chatId: "chat-1", + cwd: "/repo", + model: "anthropic/claude-sonnet-5", + planMode: false, + sessionToken: null, + }) + + // session/new advertised currentValue "opencode/big-pickle". + expect(manager.getSessionModel("chat-1")).toBe("opencode/big-pickle") + + const turn = await manager.startTurn({ + chatId: "chat-1", + content: "hi", + model: "anthropic/claude-sonnet-5", + }) + const collected = collect(turn.stream) + await turn.interrupt() + const events = await collected + expect(entries(events)[0]).toMatchObject({ kind: "system_init", model: "opencode/big-pickle" }) + manager.stopAll() + }) + + test("resumes an existing session instead of replaying it with session/load", async () => { + const child = new FakeAcpProcess( + handshakeResponder((message, process) => { + if (message.method === "session/resume") { + process.reply(message.id, { configOptions: CONFIG_OPTIONS }) + } + }) + ) + const manager = new OpenCodeAcpManager({ spawnProcess: () => child as any }) + + const started = await manager.startSession({ + chatId: "chat-1", + cwd: "/repo", + model: "opencode/big-pickle", + planMode: false, + sessionToken: SESSION_ID, + }) + + expect(started).toEqual({ sessionToken: SESSION_ID, resumeFellBack: false }) + expect(child.messages.some((message) => message.method === "session/load")).toBe(false) + expect(child.messages.some((message) => message.method === "session/new")).toBe(false) + manager.stopAll() + }) + + test("falls back to a fresh session when resume fails, and reports it", async () => { + const child = new FakeAcpProcess( + handshakeResponder((message, process) => { + if (message.method === "session/resume") { + process.replyError(message.id, "session not found") + } + }) + ) + const manager = new OpenCodeAcpManager({ spawnProcess: () => child as any }) + + const started = await manager.startSession({ + chatId: "chat-1", + cwd: "/repo", + model: "opencode/big-pickle", + planMode: false, + sessionToken: "ses_gone", + }) + + expect(started).toEqual({ sessionToken: SESSION_ID, resumeFellBack: true }) + manager.stopAll() + }) + + test("streams a full turn as transcript entries", async () => { + const child = new FakeAcpProcess( + handshakeResponder((message, process) => { + if (message.method !== "session/prompt") return + process.update(SESSION_ID, { + sessionUpdate: "agent_thought_chunk", + messageId: "msg_a", + content: { type: "text", text: "Let me read notes.txt first." }, + }) + process.update(SESSION_ID, { + sessionUpdate: "tool_call", + toolCallId: "call_read", + title: "read", + kind: "read", + status: "pending", + locations: [], + rawInput: {}, + }) + process.update(SESSION_ID, { + sessionUpdate: "tool_call_update", + toolCallId: "call_read", + status: "in_progress", + kind: "read", + title: "read", + rawInput: { filePath: "/repo/notes.txt" }, + }) + process.update(SESSION_ID, { + sessionUpdate: "tool_call_update", + toolCallId: "call_read", + status: "completed", + title: "notes.txt", + content: [{ type: "content", content: { type: "text", text: "hello world" } }], + }) + process.update(SESSION_ID, { + sessionUpdate: "agent_message_chunk", + messageId: "msg_b", + content: { type: "text", text: "It says " }, + }) + process.update(SESSION_ID, { + sessionUpdate: "agent_message_chunk", + messageId: "msg_b", + content: { type: "text", text: "hello world." }, + }) + process.update(SESSION_ID, { sessionUpdate: "usage_update", used: 8446, size: 200000 }) + process.reply(message.id, { stopReason: "end_turn" }) + }) + ) + const manager = new OpenCodeAcpManager({ spawnProcess: () => child as any }) + await manager.startSession({ + chatId: "chat-1", + cwd: "/repo", + model: "opencode/big-pickle", + planMode: false, + sessionToken: null, + }) + + const turn = await manager.startTurn({ + chatId: "chat-1", + content: "read notes.txt", + model: "opencode/big-pickle", + }) + const events = await collect(turn.stream) + + expect(events[0]).toEqual({ type: "session_token", sessionToken: SESSION_ID }) + expect(entries(events).map((entry) => entry.kind)).toEqual([ + "system_init", + "tool_call", + "tool_result", + "context_window_updated", + "assistant_text", + "result", + ]) + expect(entries(events).at(-2)).toMatchObject({ text: "It says hello world." }) + expect(entries(events).at(-1)).toMatchObject({ kind: "result", subtype: "success", isError: false }) + manager.stopAll() + }) + + test("auto-approves permission requests with the broadest offered option", async () => { + const child = new FakeAcpProcess( + handshakeResponder((message, process) => { + if (message.method !== "session/prompt") return + process.request(99, "session/request_permission", { + sessionId: SESSION_ID, + toolCall: { toolCallId: "call_bash", title: "bash" }, + options: [ + { optionId: "reject", kind: "reject_once", name: "No" }, + { optionId: "once", kind: "allow_once", name: "Yes" }, + { optionId: "always", kind: "allow_always", name: "Always" }, + ], + }) + process.reply(message.id, { stopReason: "end_turn" }) + }) + ) + const manager = new OpenCodeAcpManager({ spawnProcess: () => child as any }) + await manager.startSession({ + chatId: "chat-1", + cwd: "/repo", + model: "opencode/big-pickle", + planMode: false, + sessionToken: null, + }) + const turn = await manager.startTurn({ chatId: "chat-1", content: "run ls", model: "opencode/big-pickle" }) + await collect(turn.stream) + + const answer = child.messages.find((message) => message.id === 99) + expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "always" } }) + manager.stopAll() + }) + + test("interrupt cancels the session and closes the stream", async () => { + const child = new FakeAcpProcess(handshakeResponder()) + const manager = new OpenCodeAcpManager({ spawnProcess: () => child as any }) + await manager.startSession({ + chatId: "chat-1", + cwd: "/repo", + model: "opencode/big-pickle", + planMode: false, + sessionToken: null, + }) + + const turn = await manager.startTurn({ chatId: "chat-1", content: "count to 500", model: "opencode/big-pickle" }) + const collected = collect(turn.stream) + await turn.interrupt() + await collected + + const cancel = child.messages.find((message) => message.method === "session/cancel") + expect(cancel.params).toEqual({ sessionId: SESSION_ID }) + manager.stopAll() + }) + + test("surfaces a crashed process as an errored result", async () => { + const child = new FakeAcpProcess(handshakeResponder()) + const manager = new OpenCodeAcpManager({ spawnProcess: () => child as any }) + await manager.startSession({ + chatId: "chat-1", + cwd: "/repo", + model: "opencode/big-pickle", + planMode: false, + sessionToken: null, + }) + const turn = await manager.startTurn({ chatId: "chat-1", content: "hi", model: "opencode/big-pickle" }) + const collected = collect(turn.stream) + child.stderr.write("opencode: fatal\n") + await Bun.sleep(5) + child.emit("close", 1) + const events = await collected + + expect(entries(events).at(-1)).toMatchObject({ + kind: "result", + subtype: "error", + isError: true, + result: "opencode: fatal", + }) + manager.stopAll() + }) +}) + +describe("parseOpenCodeAuthList", () => { + // Verbatim `opencode auth list` output (v1.18.8). + test("reads connected providers out of the box-drawn list", () => { + expect( + parseOpenCodeAuthList( + [ + "\u250c Credentials /home/u/.local/share/opencode/auth.json", + "\u2502", + "\u25cf Anthropic api", + "\u25cf OpenCode Zen api", + "\u2502", + "\u2514 2 credentials", + ].join("\n") + ) + ).toEqual({ providers: ["Anthropic", "OpenCode Zen"] }) + }) + + test("reports no providers for a fresh install", () => { + expect( + parseOpenCodeAuthList( + ["\u250c Credentials /home/u/.local/share/opencode/auth.json", "\u2502", "\u2514 0 credentials"].join("\n") + ) + ).toEqual({ providers: [] }) + }) +}) + +describe("parseOpenCodeVersion", () => { + test("reads the bare version the CLI prints", () => { + expect(parseOpenCodeVersion("1.18.8")).toBe("1.18.8") + expect(parseOpenCodeVersion("")).toBeNull() + }) +}) + +describe("parseOpenCodeModelList", () => { + test("takes the label and context window from `--verbose` model JSON", () => { + // Trimmed from real `opencode models --verbose` output. + const output = [ + "opencode/big-pickle", + "{", + ' "id": "big-pickle",', + ' "providerID": "opencode",', + ' "name": "Big Pickle",', + ' "cost": { "input": 0, "output": 0 },', + ' "limit": { "context": 200000, "output": 32000 }', + "}", + "anthropic/claude-sonnet-5", + "{", + ' "id": "claude-sonnet-5",', + ' "name": "Claude Sonnet 5",', + ' "limit": { "context": 1000000 }', + "}", + ].join("\n") + + expect(parseOpenCodeModelList(output)).toEqual([ + { id: "opencode/big-pickle", label: "Big Pickle", contextWindowTokens: 200000 }, + { id: "anthropic/claude-sonnet-5", label: "Claude Sonnet 5", contextWindowTokens: 1000000 }, + ]) + }) + + test("parses `opencode models` output and derives labels", () => { + expect( + parseOpenCodeModelList( + [ + "opencode/big-pickle", + "opencode/north-mini-code-free", + "anthropic/claude-sonnet-5", + "", + "opencode/big-pickle", + ].join("\n") + ) + ).toEqual([ + { id: "opencode/big-pickle", label: "Big Pickle" }, + { id: "opencode/north-mini-code-free", label: "North Mini Code Free" }, + { id: "anthropic/claude-sonnet-5", label: "Claude Sonnet 5" }, + ]) + }) + + test("ignores banner and status lines", () => { + expect(parseOpenCodeModelList("Loading models...\n \nopencode/big-pickle\n")).toEqual([ + { id: "opencode/big-pickle", label: "Big Pickle" }, + ]) + }) +}) diff --git a/src/server/opencode-acp.ts b/src/server/opencode-acp.ts new file mode 100644 index 000000000..9c8575533 --- /dev/null +++ b/src/server/opencode-acp.ts @@ -0,0 +1,889 @@ +import { spawn } from "node:child_process" +import { randomUUID } from "node:crypto" +import { createInterface } from "node:readline" +import type { Readable, Writable } from "node:stream" +import { asNumber, asRecord, asString } from "../shared/json" +import { normalizeToolCall } from "../shared/tools" +import type { ContextWindowUsageSnapshot, HarnessSkill, TranscriptEntry } from "../shared/types" +import { AsyncQueue } from "./async-queue" +import type { HarnessEvent, HarnessTurn } from "./harness-types" +import { timestamped } from "./transcript" +import { + ACP_PROTOCOL_VERSION, + type AcpConfigOption, + type AcpInitializeParams, + type AcpInitializeResult, + type AcpJsonRpcResponse, + type AcpMessageChunkUpdate, + type AcpPlanUpdate, + type AcpPromptResult, + type AcpRequestId, + type AcpRequestPermissionParams, + type AcpSessionResult, + type AcpSessionUpdate, + type AcpToolCallUpdate, + type AcpUsageUpdate, + isAcpNotification, + isAcpResponse, + isAcpServerRequest, +} from "./acp-protocol" + +/** + * Adapter for opencode (`opencode acp`), spoken over the Agent Client Protocol. + * + * Shape-wise this is the codex app-server adapter, not the cursor one: a + * persistent per-chat child process exchanging newline-delimited JSON-RPC, + * rather than cursor's one-process-per-turn NDJSON stream. Kanna is the ACP + * *client*; opencode is the agent. + * + * initialize -> protocol + capability handshake + * session/new | session/resume-> session id (resume survives process restarts) + * session/set_config_option -> model picker + build/plan mode + * session/prompt -> one turn; resolves with a stop reason + * session/update (notif) -> streamed text / tool calls / usage + * session/cancel (notif) -> interrupt + * + * Because ACP is agent-agnostic, everything here except the binary name and the + * tool-name table is reusable for any other ACP agent (gemini-cli, + * claude-code-acp, …) — see the spike notes before generalizing. + * + * Two deliberate client-capability choices: + * - fs.readTextFile/writeTextFile = false: opencode then does its own file IO + * rather than round-tripping every read through Kanna. + * - terminal = false: same, opencode runs its own shell. + * Both keep Kanna a pure observer of the turn, which is what the transcript + * model wants. + */ + +interface AcpChildProcess { + stdin: Writable + stdout: Readable + stderr: Readable + kill(signal?: NodeJS.Signals | number): void + on(event: "close", listener: (code: number | null) => void): this + on(event: "error", listener: (error: Error) => void): this +} + +export type SpawnOpenCodeAcp = (cwd: string) => AcpChildProcess + +interface PendingRequest { + method: string + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +interface PendingTurn { + queue: AsyncQueue + translator: AcpTurnTranslator + resolved: boolean +} + +interface SessionContext { + chatId: string + cwd: string + child: AcpChildProcess + pendingRequests: Map + pendingTurn: PendingTurn | null + sessionId: string | null + /** Latest `available_commands_update`, surfaced to the composer's "/" menu. */ + availableCommands: HarnessSkill[] + configOptions: AcpConfigOption[] + /** The model the session confirmed, read back from configOptions. */ + model: string | null + stderrLines: string[] + closed: boolean +} + +export interface StartOpenCodeSessionArgs { + chatId: string + cwd: string + model: string + planMode: boolean + sessionToken: string | null +} + +export interface StartOpenCodeTurnArgs { + chatId: string + content: string + model: string +} + +export interface OpenCodeModelListEntry { + id: string + label: string + /** From the model's `limit.context`, when `--verbose` reported it. */ + contextWindowTokens?: number +} + +/** + * Parse `opencode models --verbose`, which lists only the models the user's + * configured providers actually expose: + * + * opencode/big-pickle + * { "id": "big-pickle", "name": "Big Pickle", "limit": { "context": 200000 }, … } + * + * A bare "provider/model" line with no JSON body (plain `opencode models`) is + * still accepted, with the label derived from the id — so the parser works + * against either flag. + */ +export function parseOpenCodeModelList(output: string): OpenCodeModelListEntry[] { + const entries: OpenCodeModelListEntry[] = [] + const seen = new Set() + const lines = output.split("\n") + + for (let index = 0; index < lines.length; index += 1) { + const id = lines[index]!.trim() + // A slug line: "provider/model", no whitespace, not part of a JSON body. + if (!id || !id.includes("/") || /\s/.test(id) || id.startsWith("{") || seen.has(id)) continue + seen.add(id) + + // A "{" on the next line opens this model's JSON body; consume to its close. + let detail: Record | null = null + if (lines[index + 1]?.trim() === "{") { + const body: string[] = [] + let depth = 0 + let cursor = index + 1 + for (; cursor < lines.length; cursor += 1) { + const line = lines[cursor]! + body.push(line) + depth += (line.match(/{/g)?.length ?? 0) - (line.match(/}/g)?.length ?? 0) + if (depth === 0) break + } + try { + detail = asRecord(JSON.parse(body.join("\n"))) + } catch { + detail = null + } + index = cursor + } + + const name = asString(detail?.name) + const contextWindowTokens = asNumber(asRecord(detail?.limit)?.context) + entries.push({ + id, + label: name || deriveOpenCodeModelLabel(id), + ...(contextWindowTokens && contextWindowTokens > 0 ? { contextWindowTokens } : {}), + }) + } + return entries +} + +/** + * Parse `opencode auth list`, whose body lists one credential per line as + * " " between box-drawing rules: + * + * ┌ Credentials ~/.local/share/opencode/auth.json + * │ + * ● Anthropic api + * │ + * └ 1 credentials + */ +export function parseOpenCodeAuthList(output: string): { providers: string[] } { + const providers: string[] = [] + for (const rawLine of stripBoxDrawing(output).split("\n")) { + const line = rawLine.trim() + if (!line || /^credentials\b/i.test(line) || /^\d+\s+credentials?$/i.test(line)) continue + // "Anthropic api" -> "Anthropic"; the trailing word is the credential type. + const name = line.replace(/\s+(api|oauth|wellknown)$/i, "").trim() + if (name) providers.push(name) + } + return { providers } +} + +/** Drop the CLI's ANSI colors and box-drawing gutter so lines parse plainly. */ +function stripBoxDrawing(output: string): string { + return output + .replace(/\[[0-9;?]*[A-Za-z]/g, "") + .replace(/^[┌│└●◆◇▲○\s]+/gm, "") +} + +export function parseOpenCodeVersion(output: string): string | null { + return /(\d+\.\d+\.\d+)/.exec(output)?.[1] ?? null +} + +function deriveOpenCodeModelLabel(id: string): string { + const name = id.slice(id.indexOf("/") + 1) + return name + .split("-") + .map((word) => (/^\d/.test(word) ? word : word.charAt(0).toUpperCase() + word.slice(1))) + .join(" ") +} + +/** + * Translate an opencode tool into the Claude-style tool name + snake_case + * argument keys `normalizeToolCall` understands, so tools render natively. + * Mirrors translateCursorTool. The ACP `kind` ("read"/"edit"/"execute") is the + * fallback when the name is unrecognized — e.g. MCP-provided tools. + */ +export function translateOpenCodeTool( + rawName: string, + kind: string | undefined, + args: Record +): { toolName: string; input: Record } { + switch (rawName.toLowerCase().replace(/[^a-z]/g, "")) { + case "bash": + return { toolName: "Bash", input: { command: args.command ?? "", description: args.description } } + case "read": + return { toolName: "Read", input: { file_path: args.filePath ?? "" } } + case "write": + return { toolName: "Write", input: { file_path: args.filePath ?? "", content: args.content ?? "" } } + case "edit": + return { + toolName: "Edit", + input: { + file_path: args.filePath ?? "", + old_string: args.oldString ?? "", + new_string: args.newString ?? "", + }, + } + case "glob": + return { toolName: "Glob", input: { pattern: args.pattern ?? "" } } + case "grep": + return { toolName: "Grep", input: { pattern: args.pattern ?? "" } } + case "todowrite": + return { toolName: "TodoWrite", input: { todos: Array.isArray(args.todos) ? args.todos : [] } } + case "webfetch": + return { toolName: "WebFetch", input: { url: args.url ?? "" } } + case "task": + return { + toolName: "Task", + input: { subagent_type: args.subagent_type ?? args.subagentType ?? "agent", ...args }, + } + default: + break + } + + // Unknown name: lean on the ACP kind so at least the shape renders right. + switch (kind) { + case "read": + return { toolName: "Read", input: { file_path: args.filePath ?? args.path ?? "" } } + case "execute": + return { toolName: "Bash", input: { command: args.command ?? "", description: args.description } } + default: + return { toolName: rawName, input: args } + } +} + +/** Flatten ACP tool-result content blocks into something the transcript can show. */ +function flattenToolContent(update: AcpToolCallUpdate): unknown { + const text = (update.content ?? []) + .map((block) => (block.type === "content" ? block.content?.text ?? "" : "")) + .join("") + if (text) return text + const rawOutput = asRecord(update.rawOutput) + return rawOutput?.output ?? update.rawOutput ?? "" +} + +function normalizeAcpUsage(update: AcpUsageUpdate): ContextWindowUsageSnapshot | null { + const usedTokens = update.used ?? 0 + if (usedTokens <= 0) return null + const maxTokens = update.size + return { + usedTokens, + inputTokens: usedTokens, + lastUsedTokens: usedTokens, + lastInputTokens: usedTokens, + ...(maxTokens && maxTokens > 0 ? { maxTokens } : {}), + compactsAutomatically: true, + } +} + +interface TrackedToolCall { + /** Latched from the first frame that names the tool; later frames relabel it. */ + name: string + kind?: string + /** Merged across frames — a `pending` frame may carry only part of the args. */ + input: Record + emitted: boolean +} + +/** + * Per-turn translation state. + * + * Two things force this to be stateful rather than a pure per-line function + * like parseCursorLine: + * + * - Text arrives as many tiny chunks sharing a `messageId`. Kanna's transcript + * stores whole assistant messages, so chunks accumulate and flush when the + * message id changes or the turn ends. + * - A tool call's name arrives on the `pending` frame with an empty + * `rawInput`; the arguments land on a later `in_progress` frame. The + * `tool_call` entry is emitted on the first frame that actually has + * arguments, using the latched name. + */ +export class AcpTurnTranslator { + private readonly tools = new Map() + private textBuffer = "" + private textMessageId: string | null = null + + handleUpdate(update: AcpSessionUpdate): HarnessEvent[] { + switch (update.sessionUpdate) { + case "agent_message_chunk": + return this.handleMessageChunk(update as AcpMessageChunkUpdate) + + // Reasoning has no transcript kind in Kanna (cursor drops it too), and + // the user's own prompt is already recorded before the turn starts. + case "agent_thought_chunk": + case "user_message_chunk": + return [] + + case "tool_call": + case "tool_call_update": + return this.handleToolCall(update as AcpToolCallUpdate) + + case "usage_update": { + const usage = normalizeAcpUsage(update as AcpUsageUpdate) + if (!usage) return [] + return [{ type: "transcript", entry: timestamped({ kind: "context_window_updated", usage }) }] + } + + case "plan": + return this.handlePlan(update as AcpPlanUpdate) + + // Consumed by the session (for the "/" menu), not the transcript. + case "available_commands_update": + default: + return [] + } + } + + /** Emit whatever text is still buffered. Called before results and at turn end. */ + flushText(): HarnessEvent[] { + const text = this.textBuffer + this.textBuffer = "" + this.textMessageId = null + if (!text.trim()) return [] + return [{ type: "transcript", entry: timestamped({ kind: "assistant_text", text }) }] + } + + private handleMessageChunk(update: AcpMessageChunkUpdate): HarnessEvent[] { + const text = update.content?.text ?? "" + if (!text) return [] + const messageId = update.messageId ?? null + // A new message id closes the previous one. + const events = messageId !== this.textMessageId && this.textBuffer ? this.flushText() : [] + this.textMessageId = messageId + this.textBuffer += text + return events + } + + private handleToolCall(update: AcpToolCallUpdate): HarnessEvent[] { + const toolCallId = update.toolCallId + if (!toolCallId) return [] + + const tracked = this.tools.get(toolCallId) ?? { + name: update.title ?? update.kind ?? "unknown", + kind: update.kind, + input: {}, + emitted: false, + } + if (!this.tools.has(toolCallId)) this.tools.set(toolCallId, tracked) + if (update.kind && !tracked.kind) tracked.kind = update.kind + Object.assign(tracked.input, update.rawInput ?? {}) + + const events: HarnessEvent[] = [] + + // Emit only once the call leaves `pending`. A pending frame's rawInput is + // partial — opencode's bash call, for instance, announces `{cwd}` and only + // adds `{command}` on the in_progress frame — and the transcript is + // append-only, so emitting early would freeze half-built arguments. + if (!tracked.emitted && update.status && update.status !== "pending") { + tracked.emitted = true + // Text streamed before a tool call belongs above it in the transcript. + events.push(...this.flushText()) + const { toolName, input } = translateOpenCodeTool(tracked.name, tracked.kind, tracked.input) + events.push({ + type: "transcript", + entry: timestamped({ + kind: "tool_call", + tool: normalizeToolCall({ toolName, toolId: toolCallId, input }), + }), + }) + } + + if (update.status === "completed" || update.status === "failed") { + events.push({ + type: "transcript", + entry: timestamped({ + kind: "tool_result", + toolId: toolCallId, + content: flattenToolContent(update), + isError: update.status === "failed", + }), + }) + } + + return events + } + + /** + * ACP plans map onto Kanna's todo rendering, the same way codex's plan + * updates become a synthetic TodoWrite call. + */ + private handlePlan(update: AcpPlanUpdate): HarnessEvent[] { + const entries = update.entries ?? [] + if (entries.length === 0) return [] + const toolId = `acp-plan-${randomUUID()}` + const todos = entries.map((entry) => ({ + content: entry.content ?? "", + status: entry.status === "in_progress" ? "in_progress" : entry.status === "completed" ? "completed" : "pending", + activeForm: entry.content ?? "", + })) + return [ + ...this.flushText(), + { + type: "transcript", + entry: timestamped({ + kind: "tool_call", + tool: normalizeToolCall({ toolName: "TodoWrite", toolId, input: { todos } }), + }), + }, + { type: "transcript", entry: timestamped({ kind: "tool_result", toolId, content: "" }) }, + ] + } +} + +function openCodeSystemInitEntry(model: string, slashCommands: string[]): TranscriptEntry { + return timestamped({ + kind: "system_init", + provider: "opencode", + model, + tools: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebFetch", "TodoWrite", "Task"], + agents: [], + slashCommands, + mcpServers: [], + }) +} + +/** Turn a non-success stop reason into the message Kanna shows on the result. */ +function stopReasonMessage(stopReason: string): string { + switch (stopReason) { + case "max_tokens": + return "opencode stopped: token limit reached" + case "max_turn_requests": + return "opencode stopped: too many model requests in one turn" + case "refusal": + return "opencode declined to continue" + default: + return "" + } +} + +export class OpenCodeAcpManager { + private readonly sessions = new Map() + private readonly spawnProcess: SpawnOpenCodeAcp + + constructor(args: { spawnProcess?: SpawnOpenCodeAcp } = {}) { + this.spawnProcess = + args.spawnProcess ?? + ((cwd) => + spawn("opencode", ["acp"], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }) as unknown as AcpChildProcess) + } + + /** + * Start (or reuse) the chat's `opencode acp` process and bind it to a + * session. Returns the session id plus whether a requested resume silently + * fell back to a fresh session, which the caller surfaces as a + * "Conversation Restored" boundary (same contract as codex). + */ + async startSession(args: StartOpenCodeSessionArgs): Promise<{ sessionToken: string; resumeFellBack: boolean }> { + const existing = this.sessions.get(args.chatId) + if (existing && !existing.closed && existing.cwd === args.cwd && existing.sessionId) { + await this.applyConfig(existing, args.model, args.planMode) + return { sessionToken: existing.sessionId, resumeFellBack: false } + } + if (existing) this.stopSession(args.chatId) + + const child = this.spawnProcess(args.cwd) + const context: SessionContext = { + chatId: args.chatId, + cwd: args.cwd, + child, + pendingRequests: new Map(), + pendingTurn: null, + sessionId: null, + availableCommands: [], + configOptions: [], + model: null, + stderrLines: [], + closed: false, + } + this.sessions.set(args.chatId, context) + this.attachListeners(context) + + await this.sendRequest(context, "initialize", { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "kanna", title: "Kanna", version: "0.1.0" }, + } satisfies AcpInitializeParams) + + let resumeFellBack = false + let result: AcpSessionResult | null = null + if (args.sessionToken) { + try { + // session/resume, not session/load: load replays the whole history back + // as session/update notifications, which would duplicate Kanna's own + // transcript. resume rebinds the session silently. + result = await this.sendRequest(context, "session/resume", { + sessionId: args.sessionToken, + cwd: args.cwd, + mcpServers: [], + }) + context.sessionId = args.sessionToken + } catch { + resumeFellBack = true + } + } + + if (!context.sessionId) { + result = await this.sendRequest(context, "session/new", { + cwd: args.cwd, + mcpServers: [], + }) + context.sessionId = result?.sessionId ?? null + } + + if (!context.sessionId) { + this.stopSession(args.chatId) + throw new Error("opencode acp did not return a session id") + } + + context.configOptions = result?.configOptions ?? [] + await this.applyConfig(context, args.model, args.planMode) + return { sessionToken: context.sessionId, resumeFellBack } + } + + /** + * Push the chat's model and plan-mode choice onto the session. Both are ACP + * config options; opencode names them "model" and "mode" (build|plan). A + * rejected value is non-fatal — the session keeps its current one. + */ + private async applyConfig(context: SessionContext, model: string, planMode: boolean) { + if (!context.sessionId) return + const set = async (configId: string, value: string) => { + try { + const result = await this.sendRequest(context, "session/set_config_option", { + sessionId: context.sessionId, + configId, + value, + }) + if (result?.configOptions) context.configOptions = result.configOptions + } catch { + // Unknown option id or value: leave the session as configured. + } + } + if (model) await set("model", model) + if (context.configOptions.some((option) => option.id === "mode")) { + await set("mode", planMode ? "plan" : "build") + } + + // Trust the session's reported value over the requested one: a model the + // agent rejected (credential removed, id renamed) leaves the session on + // its previous choice, and the transcript should say which model actually + // ran rather than which one Kanna asked for. + context.model = context.configOptions.find((option) => option.id === "model")?.currentValue ?? model ?? null + } + + /** The model the chat's session is actually configured with, if it has one. */ + getSessionModel(chatId: string): string | null { + return this.sessions.get(chatId)?.model ?? null + } + + /** + * Read the account's model list (`opencode models --verbose`). Rejects when + * the binary is missing or errors — callers fall back to the static catalog. + */ + async listModels(timeoutMs = 30_000): Promise { + const proc = Bun.spawn(["opencode", "models", "--verbose"], { + stdout: "pipe", + stderr: "pipe", + env: process.env, + }) + const timer = setTimeout(() => { + try { + proc.kill() + } catch { + // already exited + } + }, timeoutMs) + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + const models = parseOpenCodeModelList(stdout) + if (code !== 0 || models.length === 0) { + throw new Error(stderr.trim() || `opencode models exited with code ${code ?? "unknown"}`) + } + return models + } finally { + clearTimeout(timer) + } + } + + /** The session's live slash commands, for the composer's "/" menu. */ + listSkills(chatId: string): HarnessSkill[] | null { + const context = this.sessions.get(chatId) + if (!context || context.closed || context.availableCommands.length === 0) return null + return context.availableCommands + } + + async startTurn(args: StartOpenCodeTurnArgs): Promise { + const context = this.sessions.get(args.chatId) + if (!context || context.closed || !context.sessionId) { + throw new Error("opencode session not started") + } + if (context.pendingTurn) { + throw new Error("opencode turn is already running") + } + + const queue = new AsyncQueue() + queue.push({ type: "session_token", sessionToken: context.sessionId }) + queue.push({ + type: "transcript", + entry: openCodeSystemInitEntry( + context.model ?? args.model, + context.availableCommands.map((skill) => skill.name) + ), + }) + + const pendingTurn: PendingTurn = { queue, translator: new AcpTurnTranslator(), resolved: false } + context.pendingTurn = pendingTurn + + const startedAt = Date.now() + void this.sendRequest(context, "session/prompt", { + sessionId: context.sessionId, + prompt: [{ type: "text", text: args.content }], + }) + .then((result) => { + if (pendingTurn.resolved) return + pendingTurn.resolved = true + context.pendingTurn = null + + for (const event of pendingTurn.translator.flushText()) queue.push(event) + + const stopReason = result?.stopReason ?? "end_turn" + const message = stopReasonMessage(stopReason) + queue.push({ + type: "transcript", + entry: timestamped({ + kind: "result", + subtype: stopReason === "end_turn" ? "success" : stopReason === "cancelled" ? "cancelled" : "error", + isError: Boolean(message), + durationMs: Date.now() - startedAt, + result: message, + }), + }) + queue.finish() + }) + .catch((error: Error) => { + if (pendingTurn.resolved) return + pendingTurn.resolved = true + context.pendingTurn = null + queue.push({ + type: "transcript", + entry: timestamped({ + kind: "result", + subtype: "error", + isError: true, + durationMs: Date.now() - startedAt, + result: context.stderrLines.at(-1) || error.message, + }), + }) + queue.finish() + }) + + return { + provider: "opencode", + stream: queue, + interrupt: async () => { + const turn = context.pendingTurn + if (!turn) return + context.pendingTurn = null + turn.resolved = true + for (const event of turn.translator.flushText()) turn.queue.push(event) + turn.queue.finish() + // Notification, not a request — the in-flight prompt answers with + // stopReason "cancelled", which the resolved turn then ignores. + this.writeMessage(context, { + method: "session/cancel", + params: { sessionId: context.sessionId }, + }) + }, + close: () => {}, + } + } + + stopSession(chatId: string) { + const context = this.sessions.get(chatId) + if (!context) return + this.sessions.delete(chatId) + context.closed = true + try { + context.child.kill("SIGKILL") + } catch { + // already gone + } + } + + stopAll() { + for (const chatId of [...this.sessions.keys()]) this.stopSession(chatId) + } + + private attachListeners(context: SessionContext) { + const lines = createInterface({ input: context.child.stdout }) + void (async () => { + for await (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + let parsed: Record | null + try { + parsed = asRecord(JSON.parse(trimmed)) + } catch { + continue + } + if (!parsed) continue + + if (isAcpResponse(parsed)) { + this.handleResponse(context, parsed as AcpJsonRpcResponse) + continue + } + if (isAcpServerRequest(parsed)) { + this.handleServerRequest(context, parsed.id, parsed.method, asRecord(parsed.params) ?? {}) + continue + } + if (isAcpNotification(parsed)) { + this.handleNotification(context, parsed.method, asRecord(parsed.params) ?? {}) + } + } + })() + + const stderr = createInterface({ input: context.child.stderr }) + void (async () => { + for await (const line of stderr) { + if (line.trim()) context.stderrLines.push(line.trim()) + } + })() + + context.child.on("error", (error) => this.failContext(context, error.message)) + context.child.on("close", (code) => { + if (context.closed) return + this.failContext(context, context.stderrLines.at(-1) || `opencode acp exited with code ${code ?? 1}`) + }) + } + + private handleResponse(context: SessionContext, response: AcpJsonRpcResponse) { + const pending = context.pendingRequests.get(response.id) + if (!pending) return + context.pendingRequests.delete(response.id) + if (response.error) { + pending.reject(new Error(`${pending.method} failed: ${response.error.message ?? "Unknown error"}`)) + return + } + pending.resolve(response.result) + } + + private handleNotification(context: SessionContext, method: string, params: Record) { + if (method !== "session/update") return + const update = asRecord(params.update) as AcpSessionUpdate | null + if (!update) return + + if (update.sessionUpdate === "available_commands_update") { + const commands = (update as { availableCommands?: Array> }).availableCommands ?? [] + context.availableCommands = commands.flatMap((command) => { + const name = asString(command.name) + if (!name) return [] + return [{ name, description: asString(command.description) ?? "", source: "command" as const }] + }) + return + } + + const turn = context.pendingTurn + if (!turn || turn.resolved) return + for (const event of turn.translator.handleUpdate(update)) turn.queue.push(event) + } + + /** + * Answer the agent's client-side requests. Kanna advertises no fs/terminal + * capabilities, so in practice only permission prompts arrive — and only for + * tools opencode's own permission config gates. Kanna's model is that the + * harness owns approvals (codex runs with approvalPolicy "never"), so the + * broadest offered option is selected. + */ + private handleServerRequest( + context: SessionContext, + id: AcpRequestId, + method: string, + params: Record + ) { + if (method === "session/request_permission") { + const options = (params as unknown as AcpRequestPermissionParams).options ?? [] + const choice = + options.find((option) => option.kind === "allow_always") + ?? options.find((option) => option.kind === "allow_once") + ?? options[0] + this.writeMessage(context, { + id, + result: choice + ? { outcome: { outcome: "selected", optionId: choice.optionId } } + : { outcome: { outcome: "cancelled" } }, + }) + return + } + this.writeMessage(context, { + id, + error: { code: -32601, message: `Kanna does not implement ${method}` }, + }) + } + + private failContext(context: SessionContext, message: string) { + const turn = context.pendingTurn + if (turn && !turn.resolved) { + turn.resolved = true + context.pendingTurn = null + turn.queue.push({ + type: "transcript", + entry: timestamped({ + kind: "result", + subtype: "error", + isError: true, + durationMs: 0, + result: message, + }), + }) + turn.queue.finish() + } + for (const pending of context.pendingRequests.values()) pending.reject(new Error(message)) + context.pendingRequests.clear() + context.closed = true + } + + private async sendRequest( + context: SessionContext, + method: string, + params: unknown + ): Promise { + const id = randomUUID() + const promise = new Promise((resolve, reject) => { + context.pendingRequests.set(id, { + method, + resolve: resolve as (value: unknown) => void, + reject, + }) + }) + this.writeMessage(context, { jsonrpc: "2.0", id, method, params }) + return await promise + } + + private writeMessage(context: SessionContext, message: Record) { + context.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`) + } +} diff --git a/src/server/provider-auth.ts b/src/server/provider-auth.ts index 7dc755752..aeeffc618 100644 --- a/src/server/provider-auth.ts +++ b/src/server/provider-auth.ts @@ -11,6 +11,7 @@ import { type ProviderAuthSnapshot, } from "../shared/types" import { compareVersions } from "./cli-runtime" +import { parseOpenCodeAuthList, parseOpenCodeVersion } from "./opencode-acp" import { resolveCommandPath as defaultResolveCommandPath } from "./process-utils" // --------------------------------------------------------------------------- @@ -41,12 +42,14 @@ const CLI_BINARIES: Record, string> = { claude: "claude", codex: "codex", cursor: "cursor-agent", + opencode: "opencode", gh: "gh", } const NPM_PACKAGES: Partial> = { claude: "@anthropic-ai/claude-code", codex: "@openai/codex", + opencode: "opencode-ai", } const CODEX_DEVICE_AUTH_HINT = @@ -422,6 +425,7 @@ export class ProviderAuthManager { service === "claude" ? parseClaudeVersion(versionOutput) : service === "codex" ? parseCodexVersion(versionOutput) : service === "cursor" ? parseCursorVersion(versionResult.stdout) + : service === "opencode" ? parseOpenCodeVersion(versionOutput) : parseGhVersion(versionOutput) let authStatus: AuthServiceSnapshot["authStatus"] = "signed_out" @@ -456,6 +460,21 @@ export class ProviderAuthManager { } else { authStatus = "signed_out" } + } else if (service === "opencode") { + // opencode stores per-provider credentials rather than one account, so + // "signed in" means at least one credential exists. The account line + // lists which providers are connected (e.g. "Anthropic, OpenCode Zen"). + const result = await this.deps.exec([binaryPath, "auth", "list"], { timeoutMs: 20_000 }) + const parsed = parseOpenCodeAuthList(`${result.stdout}\n${result.stderr}`) + if (result.code !== 0) { + authStatus = "error" + statusDetail = truncateOutput(result.stderr || result.stdout) + } else if (parsed.providers.length > 0) { + authStatus = "signed_in" + account = parsed.providers.join(", ") + } else { + authStatus = "signed_out" + } } else if (service === "cursor") { const result = await this.deps.exec([binaryPath, "status"], { timeoutMs: 20_000 }) const parsed = parseCursorStatus(`${result.stdout}\n${result.stderr}`) @@ -575,6 +594,13 @@ export class ProviderAuthManager { if (this.resolvePath("bun")) return `bun add -g ${pkg}` throw new Error("Neither npm nor bun is available to install the package.") } + if (service === "opencode") { + // Native installer (per-user, no root) matches how claude/cursor are + // handled; an existing install self-updates via `opencode upgrade`. + const existing = this.resolvePath(CLI_BINARIES.opencode) + if (existing) return `${shellQuote(existing)} upgrade` + return "curl -fsSL https://opencode.ai/install | bash" + } if (service === "cursor") { const existing = this.resolvePath(CLI_BINARIES.cursor) if (existing) return `${shellQuote(existing)} update` @@ -611,6 +637,13 @@ export class ProviderAuthManager { if (service === "openrouter") { throw new Error("Use the OpenRouter OAuth flow (auth.openrouter.start).") } + if (service === "opencode") { + // `opencode auth login` is an interactive picker over ~100 providers, + // several of which finish in a browser. The client runs the real CLI in + // a terminal dialog and polls `auth.refresh` for the credential instead + // (see OpenCodeSignInDialog), so there is no server-driven flow here. + throw new Error("opencode signs in through its terminal dialog.") + } this.teardownFlow(service) const current = this.services.get(service)! diff --git a/src/server/provider-catalog.ts b/src/server/provider-catalog.ts index 3d71da29a..ab2acb9ed 100644 --- a/src/server/provider-catalog.ts +++ b/src/server/provider-catalog.ts @@ -236,6 +236,51 @@ export function applyCursorModels(models: ReadonlyArray): bo return true } +export interface OpenCodeModelInfo { + id: string + label: string + contextWindowTokens?: number +} + +/** + * Replace the opencode provider's model list with whatever `opencode models` + * reports for the user's configured providers. Grouped by the "provider/" half + * of the id so the picker doesn't interleave vendors. Returns true when the + * catalog changed (callers should broadcast). + */ +export function applyOpenCodeModels(models: ReadonlyArray): boolean { + const openCodeIndex = SERVER_PROVIDERS.findIndex((provider) => provider.id === "opencode") + const openCodeProvider = SERVER_PROVIDERS[openCodeIndex] + if (!openCodeProvider) return false + + const nextModels: ProviderModelOption[] = models.map((model) => ({ + id: model.id, + label: model.label, + supportsEffort: false, + // `opencode models --verbose` reports each model's real context limit, so + // the picker can show it without a hard-coded table. + ...(model.contextWindowTokens ? { contextWindowTokens: model.contextWindowTokens } : {}), + })) + if (nextModels.length === 0) return false + + // Stable sort by vendor prefix keeps each vendor's own ordering intact. + nextModels.sort((a, b) => a.id.slice(0, a.id.indexOf("/")).localeCompare(b.id.slice(0, b.id.indexOf("/")))) + + const defaultModel = nextModels.some((model) => model.id === openCodeProvider.defaultModel) + ? openCodeProvider.defaultModel + : nextModels[0]!.id + + if ( + defaultModel === openCodeProvider.defaultModel + && JSON.stringify(nextModels) === JSON.stringify(openCodeProvider.models) + ) { + return false + } + + SERVER_PROVIDERS.splice(openCodeIndex, 1, { ...openCodeProvider, defaultModel, models: nextModels }) + return true +} + export function getServerProviderCatalog(provider: AgentProvider): ProviderCatalogEntry { const entry = SERVER_PROVIDERS.find((candidate) => candidate.id === provider) if (!entry) { @@ -251,7 +296,12 @@ export function normalizeServerModel(provider: AgentProvider, model?: string): s // are whatever the harness reports at runtime (applyCursorModels / // applyClaudeSdkModels) — for all three, the catalog is only a picker, so // unknown ids pass through for the provider to validate. - if (provider === "pi" || provider === "cursor" || provider === "claude") { + if ( + provider === "pi" + || provider === "cursor" + || provider === "claude" + || provider === "opencode" + ) { return normalizedModel } if (catalog.models.some((candidate) => candidate.id === normalizedModel)) { diff --git a/src/server/server.ts b/src/server/server.ts index 44836cacc..0eb12f6fb 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -241,6 +241,21 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) { }, }) + // opencode's model list IS its credentialed providers, so adding a + // credential changes the catalog. This watches the snapshot rather than + // using onSignedIn, because opencode signs in through a terminal dialog on + // the client — no server-driven flow fires that hook. Comparing the account + // string (the connected-provider list) also catches a *second* provider + // being added while already signed in. + let lastOpenCodeAccount: string | null = null + providerAuth.onChange((snapshot) => { + const opencode = snapshot.services.find((service) => service.service === "opencode") + const account = opencode?.authStatus === "signed_in" ? opencode.account ?? "" : null + if (account === lastOpenCodeAccount) return + lastOpenCodeAccount = account + void agent.refreshOpenCodeModelCatalog({ force: true }).catch(() => undefined) + }) + router = createWsRouter({ store, diffStore, diff --git a/src/server/ws-router.test.ts b/src/server/ws-router.test.ts index 35624ca19..85e904a8a 100644 --- a/src/server/ws-router.test.ts +++ b/src/server/ws-router.test.ts @@ -128,6 +128,12 @@ const DEFAULT_APP_SETTINGS_SNAPSHOT: AppSettingsSnapshot = { planMode: false, autoPlan: false, }, + opencode: { + model: "opencode/north-mini-code-free", + modelOptions: {}, + planMode: false, + autoPlan: false, + }, }, newSidebarEnabled: false, newProjectsDirectory: "~/Kanna", diff --git a/src/server/ws-router.ts b/src/server/ws-router.ts index 994e6fba6..20c4aad40 100644 --- a/src/server/ws-router.ts +++ b/src/server/ws-router.ts @@ -924,7 +924,11 @@ export function createWsRouter({ } case "auth.refresh": { if (providerAuth) { - await providerAuth.refresh({ force: command.force ?? false }) + if (command.service) { + await providerAuth.probeService(command.service) + } else { + await providerAuth.refresh({ force: command.force ?? false }) + } send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: providerAuth.getSnapshot() }) } else { send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { services: [] } }) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ebf07f40e..80401f2bf 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -115,7 +115,9 @@ export type ClientCommand = | { type: "settings.writeAppSettingsPatch"; patch: AppSettingsPatch } | { type: "settings.readLlmProvider" } | { type: "usage.refresh"; force?: boolean } - | { type: "auth.refresh"; force?: boolean } + // `service` narrows the probe to one CLI — a full refresh spawns every + // harness's status command, which is far too heavy to poll. + | { type: "auth.refresh"; force?: boolean; service?: AuthServiceId } /** Install (or update to the latest version of) a service's CLI. */ | { type: "auth.install"; service: AuthServiceId } | { type: "auth.login.start"; service: AuthServiceId } diff --git a/src/shared/provider-preferences.ts b/src/shared/provider-preferences.ts index b3c9eccdd..c642ab242 100644 --- a/src/shared/provider-preferences.ts +++ b/src/shared/provider-preferences.ts @@ -11,6 +11,7 @@ import { normalizeCodexModelId, normalizeCodexReasoningEffort, normalizeCursorModelId, + normalizeOpenCodeModelId, normalizePiModelId, normalizePiReasoningEffort, supportsClaudeMaxReasoningEffort, @@ -20,6 +21,7 @@ import { type ClaudeModelOptions, type CodexModelOptions, type CursorModelOptions, + type OpenCodeModelOptions, type PiModelOptions, type ProviderPreference, } from "./types" @@ -113,6 +115,17 @@ export function normalizeCursorPreference(value?: ProviderPreferenceInput): Prov } } +export function normalizeOpenCodePreference( + value?: ProviderPreferenceInput +): ProviderPreference { + return { + model: normalizeOpenCodeModelId(modelIdFromInput(value)), + modelOptions: {}, + planMode: value?.planMode === true, + autoPlan: false, + } +} + export function normalizePiPreference(value?: ProviderPreferenceInput): ProviderPreference { const reasoningEffort = value?.modelOptions?.reasoningEffort return { @@ -137,6 +150,7 @@ export const PROVIDER_NORMALIZERS: { codex: normalizeCodexPreference, cursor: normalizeCursorPreference, pi: normalizePiPreference, + opencode: normalizeOpenCodePreference, } export function normalizeProviderPreference( @@ -154,6 +168,7 @@ export function normalizeProviderDefaults( codex: normalizeCodexPreference(value?.codex), cursor: normalizeCursorPreference(value?.cursor), pi: normalizePiPreference(value?.pi), + opencode: normalizeOpenCodePreference(value?.opencode), } } @@ -204,5 +219,13 @@ export function mergeProviderDefaultsPatch( ...patch?.pi?.modelOptions, }, }, + opencode: { + ...current.opencode, + ...patch?.opencode, + modelOptions: { + ...current.opencode.modelOptions, + ...patch?.opencode?.modelOptions, + }, + }, } } diff --git a/src/shared/types.ts b/src/shared/types.ts index a90a74792..fd83e9851 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,7 +1,7 @@ export const STORE_VERSION = 2 as const export const PROTOCOL_VERSION = 1 as const -export type AgentProvider = "claude" | "codex" | "cursor" | "pi" +export type AgentProvider = "claude" | "codex" | "cursor" | "pi" | "opencode" export type LlmProviderKind = "openai" | "openrouter" | "custom" export type AppThemePreference = "light" | "dark" | "system" export type ChatSoundPreference = "never" | "unfocused" | "always" @@ -270,11 +270,17 @@ export interface PiModelOptions { reasoningEffort: PiReasoningEffort } +// opencode selects a model per session over ACP (session/set_config_option), +// and exposes neither a reasoning-effort dial nor a fast tier of its own — the +// chosen "provider/model" id carries everything. +export interface OpenCodeModelOptions {} + export interface ProviderModelOptionsByProvider { claude: ClaudeModelOptions codex: CodexModelOptions cursor: CursorModelOptions pi: PiModelOptions + opencode: OpenCodeModelOptions } export interface ProviderPreference { @@ -326,6 +332,7 @@ export type ChatProviderPreferences = { codex: ProviderPreference cursor: ProviderPreference pi: ProviderPreference + opencode: ProviderPreference } export type ModelOptions = Partial<{ @@ -347,6 +354,11 @@ export const DEFAULT_CURSOR_MODEL_OPTIONS = { fastMode: false, } as const satisfies CursorModelOptions +export const DEFAULT_OPENCODE_MODEL_OPTIONS = {} as const satisfies OpenCodeModelOptions + +/** Free on OpenCode Zen, so a fresh install can run a turn without auth. */ +export const DEFAULT_OPENCODE_MODEL = "opencode/north-mini-code-free" + export const DEFAULT_PI_MODEL = "~anthropic/claude-fable-latest" export const DEFAULT_PI_MODEL_OPTIONS = { @@ -657,6 +669,20 @@ export const PROVIDERS: ProviderCatalogEntry[] = [ models: piModelOptionsFromFaves(DEFAULT_PI_FAVE_MODELS), efforts: [...PI_REASONING_OPTIONS], }, + { + // opencode speaks ACP (`opencode acp`). Plan mode is the session's + // build/plan config option. Static fallback only — the real list comes from + // `opencode models` (see applyOpenCodeModels in provider-catalog). + id: "opencode", + label: "opencode", + defaultModel: DEFAULT_OPENCODE_MODEL, + supportsPlanMode: true, + supportsAutoPlanMode: false, + models: [ + { id: DEFAULT_OPENCODE_MODEL, label: "North Mini Code Free", supportsEffort: false }, + ], + efforts: [], + }, ] export function getProviderCatalog(provider: AgentProvider): ProviderCatalogEntry { @@ -694,6 +720,9 @@ export function normalizeProviderModelId( if (provider === "pi") { return normalizePiModelId(modelId, fallbackModelId ?? getProviderCatalog(provider).defaultModel) } + if (provider === "opencode") { + return normalizeOpenCodeModelId(modelId, fallbackModelId ?? getProviderCatalog(provider).defaultModel) + } if (provider === "cursor") { return normalizeCursorModelId(modelId, fallbackModelId ?? getProviderCatalog(provider).defaultModel) } @@ -734,6 +763,14 @@ export function normalizeCursorModelId(modelId?: string, fallbackModelId = "comp return base || fallbackModelId } +// opencode's model list is the account's configured providers, discovered at +// runtime (`opencode models` -> applyOpenCodeModels), so unknown ids pass +// through instead of clamping to the static catalog. +export function normalizeOpenCodeModelId(modelId?: string, fallbackModelId = DEFAULT_OPENCODE_MODEL): string { + const trimmed = typeof modelId === "string" ? modelId.trim() : "" + return trimmed || fallbackModelId +} + export function getProviderModelOption(provider: AgentProvider, modelId: string): ProviderModelOption | undefined { const normalizedModelId = normalizeProviderModelId(provider, modelId) return getProviderCatalog(provider).models.find((candidate) => candidate.id === normalizedModelId) @@ -1088,6 +1125,7 @@ export interface AppSettingsPatch { pi?: Partial, "modelOptions">> & { modelOptions?: Partial } + opencode?: Partial> } } @@ -1172,14 +1210,15 @@ export interface UsageLimitsSnapshot { // the coding-agent CLIs (claude, codex, cursor-agent), gh, and OpenRouter. // --------------------------------------------------------------------------- -export type AuthServiceId = "claude" | "codex" | "cursor" | "gh" | "openrouter" +export type AuthServiceId = "claude" | "codex" | "cursor" | "opencode" | "gh" | "openrouter" -export const AUTH_SERVICE_ORDER: AuthServiceId[] = ["claude", "codex", "cursor", "gh", "openrouter"] +export const AUTH_SERVICE_ORDER: AuthServiceId[] = ["claude", "codex", "cursor", "opencode", "gh", "openrouter"] export const AUTH_SERVICE_LABELS: Record = { claude: "Claude Code", codex: "Codex", cursor: "Cursor", + opencode: "opencode", gh: "GitHub", openrouter: "OpenRouter", } @@ -1243,7 +1282,14 @@ export interface ProviderAuthSnapshot { * OpenAI-compatible endpoint — don't conflate it with the OpenRouter card). */ export function authServiceForProvider(provider: AgentProvider): AuthServiceId | null { - if (provider === "claude" || provider === "codex" || provider === "cursor") return provider + if ( + provider === "claude" + || provider === "codex" + || provider === "cursor" + || provider === "opencode" + ) { + return provider + } return null }