diff --git a/projects/electron/src/modules/code/capabilities.ts b/projects/electron/src/modules/code/capabilities.ts index 0add833c..206be6ee 100644 --- a/projects/electron/src/modules/code/capabilities.ts +++ b/projects/electron/src/modules/code/capabilities.ts @@ -14,7 +14,7 @@ import { ipcMain, type IpcMainInvokeEvent } from "electron" import logger from "electron-log" import { isDev } from "../../config" import { registry } from "./harness" -import type { HarnessId } from "@openade/harness" +import { DEFAULT_HARNESS_ID, type HarnessId } from "@openade/harness" // ============================================================================ // Type Definitions @@ -63,12 +63,12 @@ function cacheKey(harnessId: HarnessId, cwd: string): string { } /** Get cached SDK capabilities for a (harnessId, cwd) pair */ -function getSdkCache(cwd: string, harnessId: HarnessId = "claude-code"): SdkCapabilities | null { +function getSdkCache(cwd: string, harnessId: HarnessId = DEFAULT_HARNESS_ID): SdkCapabilities | null { return sdkCapabilitiesCache.get(cacheKey(harnessId, cwd)) ?? null } -/** Update cached SDK capabilities for a working directory (backward compat: default to claude-code) */ -export function setSdkCache(cwd: string, data: SdkCapabilities, harnessId: HarnessId = "claude-code"): void { +/** Update cached SDK capabilities for a working directory. */ +export function setSdkCache(cwd: string, data: SdkCapabilities, harnessId: HarnessId = DEFAULT_HARNESS_ID): void { sdkCapabilitiesCache.set(cacheKey(harnessId, cwd), data) logger.info("[Capabilities] SDK cache updated for", harnessId, cwd, JSON.stringify({ slash_commands: data.slash_commands.length, @@ -85,7 +85,7 @@ const activeProbes = new Map>() * Uses harness.discoverSlashCommands() which runs a short-lived CLI invocation * and aborts after receiving initial config. No API tokens are consumed. */ -async function runProbe(cwd: string, harnessId: HarnessId = "claude-code"): Promise { +async function runProbe(cwd: string, harnessId: HarnessId = DEFAULT_HARNESS_ID): Promise { const key = cacheKey(harnessId, cwd) // Deduplicate concurrent probes for the same (harnessId, cwd) @@ -153,7 +153,7 @@ export const load = () => { ipcMain.handle("code:sdk-capabilities", async (event, args: { cwd: string; harnessId?: HarnessId }) => { if (!checkAllowed(event)) throw new Error("not allowed") - const { cwd, harnessId = "claude-code" } = args + const { cwd, harnessId = DEFAULT_HARNESS_ID } = args // Return cached if available const cached = getSdkCache(cwd, harnessId) @@ -165,7 +165,7 @@ export const load = () => { ipcMain.handle("code:invalidate-sdk-capabilities", async (event, args: { cwd: string; harnessId?: HarnessId }) => { if (!checkAllowed(event)) throw new Error("not allowed") - const harnessId = args.harnessId ?? "claude-code" + const harnessId = args.harnessId ?? DEFAULT_HARNESS_ID sdkCapabilitiesCache.delete(cacheKey(harnessId, args.cwd)) return { ok: true } }) diff --git a/projects/electron/src/modules/code/harness.ts b/projects/electron/src/modules/code/harness.ts index 35d1f7ce..ffdf8dd0 100644 --- a/projects/electron/src/modules/code/harness.ts +++ b/projects/electron/src/modules/code/harness.ts @@ -20,12 +20,14 @@ import { HarnessRegistry, ClaudeCodeHarness, CodexHarness, + OpencodeHarness, type HarnessEvent, type HarnessQuery, type HarnessId, type McpServerConfig, type ClientToolDefinition, type ClientToolResult, + DEFAULT_HARNESS_ID, } from "@openade/harness" import { isDev } from "../../config.js" import { setSdkCache } from "./capabilities.js" @@ -39,9 +41,10 @@ export const registry = new HarnessRegistry() // Register harnesses at module level. // Binary resolution is handled by the harness internally via resolveExecutable(). // The managed binaries (bun, rg) are on PATH via binaries.ts enhancePath(), -// but claude/codex CLI resolution is done by each harness. +// but agent CLI resolution is done by each harness. registry.register(new ClaudeCodeHarness()) registry.register(new CodexHarness()) +registry.register(new OpencodeHarness()) // ============================================================================ // Shared Types (mirrors claudeEventTypes.ts in dashboard) @@ -432,7 +435,7 @@ async function handleStartQuery( command: HarnessCommandEvent & { type: "start_query" } ): Promise<{ ok: boolean; error?: string }> { const { executionId, prompt, options } = command - const harnessId = options.harnessId || "claude-code" + const harnessId = options.harnessId || DEFAULT_HARNESS_ID const promptPreview = typeof prompt === "string" ? prompt.slice(0, 100) : `[${prompt.length} content blocks]` @@ -613,7 +616,7 @@ async function handleStructuredQuery( error?: string }> { const { prompt, options, outputSchema } = command - const harnessId = options.harnessId || "claude-code" + const harnessId = options.harnessId || DEFAULT_HARNESS_ID const harness = registry.get(harnessId) if (!harness) { diff --git a/projects/harness/src/browser.ts b/projects/harness/src/browser.ts index 2ad65baf..418795db 100644 --- a/projects/harness/src/browser.ts +++ b/projects/harness/src/browser.ts @@ -2,7 +2,7 @@ // This entry point re-exports only pure-data modules (types + model catalog) // and is safe to bundle with Vite/Rollup for renderer / web contexts. // -// IMPORTANT: Never import from harness index files (claude-code/index, codex/index) +// IMPORTANT: Never import from harness index files (claude-code/index, codex/index, opencode/index) // as those pull in Node built-ins (child_process, fs, os, etc.). // ── Core types ── @@ -125,3 +125,21 @@ export type { export { parseCodexEvent } from "./harnesses/codex/types.js" export { calculateCodexCostUsd } from "./harnesses/codex/pricing.js" + +// ── opencode config & event types (from leaf modules, NOT index.ts) ── +export type { OpencodeHarnessConfig } from "./harnesses/opencode/args.js" + +export type { + OpencodeEvent, + OpencodeStepStartEvent, + OpencodeTextEvent, + OpencodeToolUseEvent, + OpencodeStepFinishEvent, + OpencodeErrorEvent, + OpencodeRawJsonEvent, + OpencodePart, + OpencodeToolState, + OpencodeTokens, +} from "./harnesses/opencode/types.js" + +export { parseOpencodeEvent } from "./harnesses/opencode/types.js" diff --git a/projects/harness/src/harnesses/opencode/args.test.ts b/projects/harness/src/harnesses/opencode/args.test.ts new file mode 100644 index 00000000..34265a10 --- /dev/null +++ b/projects/harness/src/harnesses/opencode/args.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest" +import { buildOpencodeArgs } from "./args.js" +import type { HarnessQuery } from "../../types.js" + +function makeQuery(overrides: Partial = {}): HarnessQuery { + return { + prompt: "test prompt", + cwd: "/tmp/test", + mode: "yolo", + signal: new AbortController().signal, + ...overrides, + } +} + +describe("buildOpencodeArgs", () => { + it("uses run with JSON output", async () => { + const result = await buildOpencodeArgs(makeQuery(), {}) + expect(result.args.slice(0, 3)).toEqual(["run", "--format", "json"]) + }) + + it("mode: 'yolo' auto-approves permissions", async () => { + const result = await buildOpencodeArgs(makeQuery({ mode: "yolo" }), {}) + expect(result.args).toContain("--dangerously-skip-permissions") + }) + + it("mode: 'read-only' overlays deny edit/bash permissions", async () => { + const result = await buildOpencodeArgs(makeQuery({ mode: "read-only" }), {}) + expect(result.args).not.toContain("--dangerously-skip-permissions") + const config = JSON.parse(result.env.OPENCODE_CONFIG_CONTENT) + expect(config.permission.edit).toBe("deny") + expect(config.permission.bash).toBe("deny") + }) + + it("read-only config includes additional directory permissions", async () => { + const result = await buildOpencodeArgs(makeQuery({ mode: "read-only", additionalDirectories: ["/tmp/extra"] }), {}) + const config = JSON.parse(result.env.OPENCODE_CONFIG_CONTENT) + expect(config.permission.external_directory).toEqual({ + "/tmp/extra": "allow", + "/tmp/extra/**": "allow", + }) + }) + + it("model produces -m provider/model", async () => { + const result = await buildOpencodeArgs(makeQuery({ model: "anthropic/claude-sonnet-4-5" }), {}) + const modelIdx = result.args.indexOf("-m") + expect(result.args[modelIdx + 1]).toBe("anthropic/claude-sonnet-4-5") + }) + + it("thinking maps to --variant", async () => { + const result = await buildOpencodeArgs(makeQuery({ thinking: "med" }), {}) + const variantIdx = result.args.indexOf("--variant") + expect(result.args[variantIdx + 1]).toBe("medium") + }) + + it("resume uses --session and supports --fork", async () => { + const result = await buildOpencodeArgs(makeQuery({ resumeSessionId: "ses_123", forkSession: true }), {}) + expect(result.args).toContain("--session") + expect(result.args[result.args.indexOf("--session") + 1]).toBe("ses_123") + expect(result.args).toContain("--fork") + }) + + it("passes cwd through --dir", async () => { + const result = await buildOpencodeArgs(makeQuery({ cwd: "/home/user/project" }), {}) + const dirIdx = result.args.indexOf("--dir") + expect(result.args[dirIdx + 1]).toBe("/home/user/project") + }) + + it("system prompt is prepended to positional message", async () => { + const result = await buildOpencodeArgs(makeQuery({ prompt: "do something", systemPrompt: "Be careful" }), {}) + const dashDashIdx = result.args.indexOf("--") + const prompt = result.args[dashDashIdx + 1] + expect(prompt).toContain("") + expect(prompt).toContain("Be careful") + expect(prompt).toContain("do something") + }) + + it("outputSchema appends structured output instruction", async () => { + const schema = { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + } + const result = await buildOpencodeArgs(makeQuery({ outputSchema: schema }), {}) + const prompt = result.args[result.args.indexOf("--") + 1] + expect(prompt).toContain("Return only valid JSON") + expect(prompt).toContain('"answer"') + }) + + it("prompt as PromptPart[] joins text parts", async () => { + const result = await buildOpencodeArgs( + makeQuery({ + prompt: [ + { type: "text", text: "part 1" }, + { type: "text", text: "part 2" }, + ], + }), + {} + ) + expect(result.args[result.args.indexOf("--") + 1]).toBe("part 1\npart 2") + }) + + it("forkSession without resume logs a warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + await buildOpencodeArgs(makeQuery({ forkSession: true }), {}) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("forkSession requires")) + warnSpy.mockRestore() + }) +}) diff --git a/projects/harness/src/harnesses/opencode/args.ts b/projects/harness/src/harnesses/opencode/args.ts new file mode 100644 index 00000000..e951d0b5 --- /dev/null +++ b/projects/harness/src/harnesses/opencode/args.ts @@ -0,0 +1,189 @@ +import { tmpdir } from "node:os" +import { join } from "node:path" +import { randomUUID } from "node:crypto" +import { writeFile } from "node:fs/promises" + +import type { HarnessQuery, PromptPart } from "../../types.js" + +export interface OpencodeHarnessConfig { + binaryPath?: string + tempDir?: string +} + +export interface OpencodeArgBuildResult { + command: string + args: string[] + env: Record + cwd?: string + cleanup: Array<{ path: string; type: "file" | "dir" }> +} + +const THINKING_VARIANT_MAP: Record = { + low: "low", + med: "medium", + high: "high", + max: "max", +} + +/** + * Builds CLI arguments for the `opencode` binary from a HarnessQuery. + */ +export async function buildOpencodeArgs(query: HarnessQuery, config: OpencodeHarnessConfig): Promise { + const args: string[] = ["run", "--format", "json"] + const env: Record = { ...(query.env ?? {}) } + const cleanup: Array<{ path: string; type: "file" | "dir" }> = [] + + env.OPENCODE_DISABLE_AUTOUPDATE ??= "true" + + if (query.mode === "yolo") { + args.push("--dangerously-skip-permissions") + } else if (query.mode === "read-only") { + env.OPENCODE_CONFIG_CONTENT = buildReadOnlyConfigContent(query.additionalDirectories, env.OPENCODE_CONFIG_CONTENT) + } + + if (query.cwd) { + args.push("--dir", query.cwd) + } + + if (query.model) { + args.push("-m", query.model) + } + + if (query.thinking) { + const variant = THINKING_VARIANT_MAP[query.thinking] + if (variant) { + args.push("--variant", variant) + } + } + + if (query.fastMode) { + console.warn("[opencode-harness] fastMode is not supported by opencode. Ignoring.") + } + + if (query.resumeSessionId) { + args.push("--session", query.resumeSessionId) + if (query.forkSession) { + args.push("--fork") + } + } else if (query.forkSession) { + console.warn("[opencode-harness] forkSession requires a resumeSessionId. Ignoring.") + } + + if (query.additionalDirectories && query.additionalDirectories.length > 0) { + console.warn("[opencode-harness] additionalDirectories are only reflected in read-only permission config; opencode has no add-dir flag.") + } + + const { promptText: rawPromptText, filePaths, fileCleanup } = await resolveOpencodePrompt(query.prompt, config) + cleanup.push(...fileCleanup) + + for (const filePath of filePaths) { + args.push("-f", filePath) + } + + let promptText = rawPromptText + const systemPrompt = query.systemPrompt ?? query.appendSystemPrompt + if (systemPrompt) { + promptText = `\n${systemPrompt}\n\n\n${promptText}` + } + + if (query.outputSchema) { + promptText = [ + promptText, + "Return only valid JSON matching this JSON Schema. Do not wrap the JSON in Markdown fences.", + JSON.stringify(query.outputSchema), + ].join("\n\n") + } + + args.push("--", promptText) + + return { + command: "opencode", + args, + env, + cwd: query.cwd, + cleanup, + } +} + +interface OpencodeResolvedPrompt { + promptText: string + filePaths: string[] + fileCleanup: Array<{ path: string; type: "file" }> +} + +async function resolveOpencodePrompt(prompt: string | PromptPart[], config: OpencodeHarnessConfig): Promise { + if (typeof prompt === "string") { + return { promptText: prompt, filePaths: [], fileCleanup: [] } + } + + const textParts: string[] = [] + const filePaths: string[] = [] + const fileCleanup: Array<{ path: string; type: "file" }> = [] + + for (const part of prompt) { + if (part.type === "text") { + textParts.push(part.text) + } else if (part.type === "image") { + if (part.source.kind === "path") { + filePaths.push(part.source.path) + } else if (part.source.kind === "base64") { + const ext = part.source.mediaType.split("/")[1] || "png" + const filename = `harness-img-${randomUUID()}.${ext}` + const filepath = join(config.tempDir ?? tmpdir(), filename) + await writeFile(filepath, Buffer.from(part.source.data, "base64")) + filePaths.push(filepath) + fileCleanup.push({ path: filepath, type: "file" }) + } + } + } + + return { + promptText: textParts.join("\n"), + filePaths, + fileCleanup, + } +} + +function buildReadOnlyConfigContent(additionalDirectories: string[] | undefined, existingConfigContent: string | undefined): string { + const base = parseConfigContent(existingConfigContent) + const permission = isRecord(base.permission) ? base.permission : {} + const externalDirectory = buildExternalDirectoryRules(additionalDirectories) + + return JSON.stringify({ + ...base, + permission: { + ...permission, + edit: "deny", + bash: "deny", + ...(externalDirectory ? { external_directory: externalDirectory } : {}), + }, + }) +} + +function parseConfigContent(raw: string | undefined): Record { + if (!raw) return {} + try { + const parsed = JSON.parse(raw) + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +function buildExternalDirectoryRules(additionalDirectories: string[] | undefined): Record | undefined { + if (!additionalDirectories || additionalDirectories.length === 0) return undefined + + const rules: Record = {} + for (const dir of additionalDirectories) { + const trimmed = dir.replace(/\/+$/, "") + if (!trimmed) continue + rules[trimmed] = "allow" + rules[`${trimmed}/**`] = "allow" + } + + return Object.keys(rules).length > 0 ? rules : undefined +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} diff --git a/projects/harness/src/harnesses/opencode/index.ts b/projects/harness/src/harnesses/opencode/index.ts new file mode 100644 index 00000000..e7d2c73e --- /dev/null +++ b/projects/harness/src/harnesses/opencode/index.ts @@ -0,0 +1,520 @@ +import { rm } from "node:fs/promises" +import { execFileSync, spawnSync } from "node:child_process" + +import type { Harness } from "../../harness.js" +import type { + DeleteSessionOptions, + HarnessCapabilities, + HarnessEvent, + HarnessInstallStatus, + HarnessMeta, + HarnessModelConfig, + HarnessQuery, + HarnessUsage, + ListSessionsOptions, + SessionMeta, + SlashCommand, + StructuredQueryInput, + StructuredQueryResult, + WriteSessionEventsOptions, + GetSessionEventsOptions, +} from "../../types.js" +import { HarnessNotInstalledError } from "../../errors.js" +import { OPENCODE_MODEL_CONFIG } from "../../models.js" +import { runStructuredQuery } from "../../structured.js" +import { spawnJsonl } from "../../util/spawn.js" +import { resolveExecutable } from "../../util/which.js" +import { buildOpencodeArgs, type OpencodeHarnessConfig } from "./args.js" +import { parseOpencodeEvent, type OpencodeEvent } from "./types.js" + +export type { OpencodeHarnessConfig } from "./args.js" +export type { OpencodeEvent } from "./types.js" + +export class OpencodeHarness implements Harness { + readonly id = "opencode" + private config: OpencodeHarnessConfig + + constructor(config?: OpencodeHarnessConfig) { + this.config = config ?? {} + } + + meta(): HarnessMeta { + return { + id: "opencode", + name: "opencode", + vendor: "sst", + website: "https://opencode.ai/", + } + } + + capabilities(): HarnessCapabilities { + return { + supportsSystemPrompt: false, + supportsAppendSystemPrompt: false, + supportsReadOnly: true, + supportsMcp: false, + supportsResume: true, + supportsFork: true, + supportsClientTools: false, + supportsStreamingTokens: false, + supportsCostTracking: true, + supportsFastMode: false, + supportsNamedTools: false, + supportsImages: true, + supportsSessionReplay: false, + } + } + + models(): HarnessModelConfig { + return OPENCODE_MODEL_CONFIG + } + + async checkInstallStatus(): Promise { + const binaryPath = await this.resolveBinary() + + if (!binaryPath) { + return { + installed: false, + authType: "account", + authenticated: false, + authInstructions: "Install opencode: curl -fsSL https://opencode.ai/install | bash", + } + } + + let version: string | undefined + try { + version = execFileSync(binaryPath, ["--version"], { + encoding: "utf-8", + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + } catch { + // Version check failed + } + + let authenticated = false + try { + const result = spawnSync(binaryPath, ["auth", "list"], { + encoding: "utf-8", + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }) + authenticated = hasAuthListEntries(`${result.stdout ?? ""}\n${result.stderr ?? ""}`) + } catch { + // auth list failed or returned non-zero — not authenticated + } + + return { + installed: true, + version, + authType: "account", + authenticated, + authInstructions: authenticated ? undefined : "Run `opencode auth login` to authenticate a provider", + } + } + + async discoverSlashCommands(_cwd: string): Promise { + return [] + } + + async *query(q: HarnessQuery): AsyncGenerator> { + const binaryPath = await this.resolveBinary() + if (!binaryPath) { + throw new HarnessNotInstalledError("opencode", "Install opencode: curl -fsSL https://opencode.ai/install | bash") + } + + if (q.clientTools && q.clientTools.length > 0) { + console.warn("[opencode-harness] client tools are not supported by opencode. Ignoring.") + } + if (q.userPromptHandler) { + console.warn("[opencode-harness] user prompts are not supported by opencode. Ignoring.") + } + if (q.mcpServers && Object.keys(q.mcpServers).length > 0) { + console.warn("[opencode-harness] per-query MCP server injection is not supported by opencode. Ignoring.") + } + + const buildResult = await buildOpencodeArgs(q, this.config) + const startTime = Date.now() + let sessionStarted = false + let lastError: string | undefined + const textParts: string[] = [] + const textDeltaPartIds = new Set() + const usageAccumulator = createUsageAccumulator() + + try { + yield* spawnJsonl({ + command: binaryPath, + args: buildResult.args, + cwd: buildResult.cwd, + env: buildResult.env, + signal: q.signal, + argv0: q.processLabel, + parseLine: (line) => { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + return null + } + + const event = parseOpencodeEvent(parsed) + if (!event) return null + + const events: HarnessEvent[] = [] + const sessionId = getOpencodeSessionId(event) + if (sessionId && !sessionStarted) { + sessionStarted = true + events.push({ type: "session_started", sessionId }) + } + + const text = getOpencodeText(event, textDeltaPartIds) + if (text) textParts.push(text) + + usageAccumulator.add(event) + + if (event.type === "error" || event.type === "session.error") { + lastError = getOpencodeErrorMessage(event) + events.push({ type: "error", error: lastError, code: "unknown" }) + } + + events.push({ type: "message", message: event }) + return events + }, + onExit: (code, stderr) => { + if (q.signal.aborted) return null + + if (lastError) { + return null + } + + const durationMs = Date.now() - startTime + let structuredOutput: unknown + + if (q.outputSchema) { + const rawStructured = textParts.join("").trim() + if (!rawStructured) { + return { + type: "error", + error: "opencode completed without structured output", + code: "unknown", + } + } + + try { + structuredOutput = parseStructuredJson(rawStructured) + } catch (error) { + return { + type: "error", + error: `Failed to parse opencode structured output: ${error instanceof Error ? error.message : String(error)}`, + code: "unknown", + } + } + } + + if (code === 0) { + return { + type: "complete", + usage: usageAccumulator.toUsage(durationMs), + structuredOutput, + } + } + + if (code !== null && code !== 0) { + return { + type: "error", + error: stderr.trim() || `opencode process exited with code ${code}`, + code: "process_crashed", + } + } + + return null + }, + }) + } finally { + for (const item of buildResult.cleanup) { + try { + await rm(item.path, { recursive: item.type === "dir", force: true }) + } catch { + // Ignore cleanup errors + } + } + } + } + + async structuredQuery(q: StructuredQueryInput): Promise> { + return runStructuredQuery(this, q) + } + + async listSessions(options?: ListSessionsOptions): Promise { + const binaryPath = await this.resolveBinary() + if (!binaryPath) return [] + + const args = ["session", "list", "--format", "json"] + if (options?.limit != null) { + args.push("--max-count", String(options.limit)) + } + + const result = spawnSync(binaryPath, args, { + cwd: options?.cwd, + encoding: "utf-8", + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }) + + if (result.status !== 0) return [] + + return parseSessionList(result.stdout, options?.cwd) + } + + async getSessionEvents(_sessionId: string, _options?: GetSessionEventsOptions): Promise[] | null> { + return null + } + + async writeSessionEvents(_sessionId: string, _events: HarnessEvent[], _options: WriteSessionEventsOptions): Promise { + throw new Error("opencode session replay writes are not supported") + } + + async deleteSession(sessionId: string, options?: DeleteSessionOptions): Promise { + const binaryPath = await this.resolveBinary() + if (!binaryPath) return false + + const result = spawnSync(binaryPath, ["session", "delete", sessionId], { + cwd: options?.cwd, + encoding: "utf-8", + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }) + + return result.status === 0 + } + + async isSessionActive(_sessionId: string): Promise { + return false + } + + private async resolveBinary(): Promise { + if (this.config.binaryPath) return this.config.binaryPath + return resolveExecutable("opencode") + } +} + +function stripAnsi(value: string): string { + return value.replace(/\x1b\[[0-9;]*m/g, "") +} + +function hasAuthListEntries(output: string): boolean { + const clean = stripAnsi(output) + return /\b[1-9]\d*\s+credentials?\b/i.test(clean) || /\b[1-9]\d*\s+environment variables?\b/i.test(clean) || /^●\s+/m.test(clean) +} + +function getOpencodeSessionId(event: OpencodeEvent): string | undefined { + const record = getEventRecord(event) + + const direct = pickString(record, ["sessionID", "sessionId"]) + if (direct) return direct + + const part = isRecord(record.part) ? record.part : undefined + const partSessionId = part ? pickString(part, ["sessionID", "sessionId"]) : undefined + if (partSessionId) return partSessionId + + const properties = getProperties(event) + const propertySessionId = properties ? pickString(properties, ["sessionID", "sessionId"]) : undefined + if (propertySessionId) return propertySessionId + + const propertyPart = properties && isRecord(properties.part) ? properties.part : undefined + const propertyPartSessionId = propertyPart ? pickString(propertyPart, ["sessionID", "sessionId"]) : undefined + if (propertyPartSessionId) return propertyPartSessionId + + const info = properties && isRecord(properties.info) ? properties.info : undefined + return info ? pickString(info, ["sessionID", "sessionId"]) : undefined +} + +function getOpencodeText(event: OpencodeEvent, textDeltaPartIds?: Set): string | undefined { + if (event.type === "text") { + const partText = event.part?.text + if (typeof partText === "string") return partText + + const rawText = (event as unknown as { text?: unknown }).text + return typeof rawText === "string" ? rawText : undefined + } + + const properties = getProperties(event) + if (!properties) return undefined + + if (event.type === "message.part.delta") { + if (properties.field !== "text" || typeof properties.delta !== "string") return undefined + const partId = getOpencodePartId(properties) + if (partId) textDeltaPartIds?.add(partId) + return properties.delta + } + + if (event.type === "message.part.updated") { + const part = isRecord(properties.part) ? properties.part : undefined + if (!part || part.type !== "text") return undefined + const partId = getOpencodePartId(properties) ?? getOpencodePartId(part) + if (partId && textDeltaPartIds?.has(partId)) return undefined + const text = part.text ?? part.snapshot + return typeof text === "string" ? text : undefined + } + + return undefined +} + +function getOpencodeErrorMessage(event: OpencodeEvent): string { + if (event.type === "error") { + return event.error?.data?.message ?? event.error?.message ?? event.message ?? event.error?.name ?? "opencode error" + } + + if (event.type === "session.error") { + const properties = getProperties(event) + const propertyError = properties?.error + if (isRecord(propertyError)) { + const data = isRecord(propertyError.data) ? propertyError.data : undefined + const dataMessage = data ? pickString(data, ["message"]) : undefined + return dataMessage ?? pickString(propertyError, ["message", "name"]) ?? "opencode error" + } + if (typeof propertyError === "string") return propertyError + return event.message ?? (typeof properties?.message === "string" ? properties.message : undefined) ?? "opencode error" + } + + return "opencode error" +} + +function createUsageAccumulator() { + let inputTokens = 0 + let outputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + let costUsd = 0 + + return { + add(event: OpencodeEvent) { + const usage = getOpencodeUsage(event) + if (!usage) return + + if (usage.mode === "snapshot") { + inputTokens = Math.max(inputTokens, asNumber(usage.tokens?.input)) + outputTokens = Math.max(outputTokens, asNumber(usage.tokens?.output)) + cacheReadTokens = Math.max(cacheReadTokens, asNumber(usage.tokens?.cache?.read)) + cacheWriteTokens = Math.max(cacheWriteTokens, asNumber(usage.tokens?.cache?.write)) + costUsd = Math.max(costUsd, asNumber(usage.cost)) + return + } + + inputTokens += asNumber(usage.tokens?.input) + outputTokens += asNumber(usage.tokens?.output) + cacheReadTokens += asNumber(usage.tokens?.cache?.read) + cacheWriteTokens += asNumber(usage.tokens?.cache?.write) + costUsd += asNumber(usage.cost) + }, + toUsage(durationMs: number): HarnessUsage { + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + costUsd, + durationMs, + } + }, + } +} + +function getOpencodeUsage(event: OpencodeEvent): { mode: "increment"; tokens?: { input?: number; output?: number; cache?: { read?: number; write?: number } }; cost?: number } | { mode: "snapshot"; tokens?: { input?: number; output?: number; cache?: { read?: number; write?: number } }; cost?: number } | null { + if (event.type === "step_finish") { + return { + mode: "increment", + tokens: event.part?.tokens, + cost: event.part?.cost, + } + } + + if (event.type === "message.updated") { + const properties = getProperties(event) + const info = properties && isRecord(properties.info) ? properties.info : undefined + const tokens = info && isRecord(info.tokens) ? info.tokens : undefined + return { + mode: "snapshot", + tokens: tokens as { input?: number; output?: number; cache?: { read?: number; write?: number } } | undefined, + cost: typeof info?.cost === "number" ? info.cost : undefined, + } + } + + return null +} + +function getEventRecord(event: OpencodeEvent): Record { + return event.type === "raw_json" ? event.raw : (event as unknown as Record) +} + +function getProperties(event: OpencodeEvent): Record | undefined { + const properties = getEventRecord(event).properties + return isRecord(properties) ? properties : undefined +} + +function getOpencodePartId(record: Record): string | undefined { + return pickString(record, ["partID", "partId", "id"]) +} + +function asNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0 +} + +function parseStructuredJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + // Continue with extraction fallbacks + } + + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i) + if (fenced) { + return JSON.parse(fenced[1].trim()) + } + + const start = text.indexOf("{") + const end = text.lastIndexOf("}") + if (start >= 0 && end > start) { + return JSON.parse(text.slice(start, end + 1)) + } + + throw new Error("No JSON object found in opencode output") +} + +function parseSessionList(raw: string, cwd: string | undefined): SessionMeta[] { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + + const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.sessions) ? parsed.sessions : [] + + return entries.flatMap((entry): SessionMeta[] => { + if (!isRecord(entry)) return [] + const sessionId = pickString(entry, ["id", "sessionID", "sessionId"]) + if (!sessionId) return [] + return [ + { + sessionId, + harnessId: "opencode", + cwd: pickString(entry, ["cwd", "directory", "path"]) ?? cwd, + model: pickString(entry, ["model"]), + startedAt: pickString(entry, ["created", "createdAt", "time", "updated", "updatedAt"]), + }, + ] + }) +} + +function pickString(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key] + if (typeof value === "string" && value.length > 0) return value + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} diff --git a/projects/harness/src/harnesses/opencode/types.test.ts b/projects/harness/src/harnesses/opencode/types.test.ts new file mode 100644 index 00000000..7abfda72 --- /dev/null +++ b/projects/harness/src/harnesses/opencode/types.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest" +import { parseOpencodeEvent } from "./types.js" + +describe("parseOpencodeEvent", () => { + it("parses step_start event", () => { + const raw = { type: "step_start", sessionID: "ses_123", part: { id: "prt_1", type: "step-start" } } + const event = parseOpencodeEvent(raw) + expect(event).toEqual(raw) + }) + + it("parses text event", () => { + const raw = { type: "text", sessionID: "ses_123", part: { type: "text", text: "hello" } } + const event = parseOpencodeEvent(raw) + expect(event).toEqual(raw) + }) + + it("parses opencode JSON stream message part events", () => { + const raw = { type: "message.part.delta", properties: { partID: "prt_1", field: "text", delta: "hello" } } + const event = parseOpencodeEvent(raw) + expect(event).toEqual(raw) + }) + + it("parses opencode JSON stream shell events", () => { + const raw = { type: "session.next.shell.ended", properties: { callID: "call_1", command: "pwd", output: "/tmp", exit: 0 } } + const event = parseOpencodeEvent(raw) + expect(event).toEqual(raw) + }) + + it("parses tool_use event", () => { + const raw = { + type: "tool_use", + sessionID: "ses_123", + part: { + id: "prt_1", + tool: "bash", + state: { status: "completed", input: { command: "pwd" }, output: "/tmp", metadata: { exit: 0 } }, + }, + } + const event = parseOpencodeEvent(raw) + expect(event).toEqual(raw) + }) + + it("parses step_finish event with usage", () => { + const raw = { + type: "step_finish", + sessionID: "ses_123", + part: { + reason: "stop", + cost: 0.01, + tokens: { input: 10, output: 20, cache: { read: 3, write: 4 } }, + }, + } + const event = parseOpencodeEvent(raw) + expect(event).toEqual(raw) + }) + + it("parses error event", () => { + const raw = { type: "error", error: { name: "APIError", data: { message: "rate limited" } } } + const event = parseOpencodeEvent(raw) + expect(event).toEqual(raw) + }) + + it("preserves unknown event types as raw_json", () => { + const raw = { type: "future_event", data: "value" } + expect(parseOpencodeEvent(raw)).toEqual({ + type: "raw_json", + original_type: "future_event", + raw, + }) + }) + + it("returns null for invalid input", () => { + expect(parseOpencodeEvent(null)).toBeNull() + expect(parseOpencodeEvent("nope")).toBeNull() + expect(parseOpencodeEvent({ data: "no type" })).toBeNull() + }) +}) diff --git a/projects/harness/src/harnesses/opencode/types.ts b/projects/harness/src/harnesses/opencode/types.ts new file mode 100644 index 00000000..6cfccd1b --- /dev/null +++ b/projects/harness/src/harnesses/opencode/types.ts @@ -0,0 +1,238 @@ +// ============================================================================ +// OpencodeEvent — types for `opencode run --format json` JSONL output +// ============================================================================ + +export type OpencodeEvent = + | OpencodeStepStartEvent + | OpencodeTextEvent + | OpencodeToolUseEvent + | OpencodeStepFinishEvent + | OpencodeErrorEvent + | OpencodeMessagePartDeltaEvent + | OpencodeMessagePartUpdatedEvent + | OpencodeMessageUpdatedEvent + | OpencodeShellStartedEvent + | OpencodeShellEndedEvent + | OpencodeSessionErrorEvent + | OpencodeGenericKnownEvent + | OpencodeRawJsonEvent + +export interface OpencodeBaseEvent { + timestamp?: number + sessionID?: string + part?: OpencodePart + [key: string]: unknown +} + +export interface OpencodeStepStartEvent extends OpencodeBaseEvent { + type: "step_start" +} + +export interface OpencodeTextEvent extends OpencodeBaseEvent { + type: "text" + part?: OpencodePart & { text?: string } +} + +export interface OpencodeToolUseEvent extends OpencodeBaseEvent { + type: "tool_use" + part?: OpencodePart & { + tool?: string + state?: OpencodeToolState + } +} + +export interface OpencodeStepFinishEvent extends OpencodeBaseEvent { + type: "step_finish" + part?: OpencodePart & { + reason?: string + cost?: number + tokens?: OpencodeTokens + } +} + +export interface OpencodeErrorEvent extends OpencodeBaseEvent { + type: "error" + error?: { + name?: string + message?: string + data?: { + message?: string + statusCode?: number + isRetryable?: boolean + [key: string]: unknown + } + [key: string]: unknown + } + message?: string +} + +export interface OpencodeMessagePartDeltaEvent extends OpencodeBaseEvent { + type: "message.part.delta" + properties?: { + field?: string + delta?: string + partID?: string + partId?: string + id?: string + part?: OpencodePart + sessionID?: string + [key: string]: unknown + } +} + +export interface OpencodeMessagePartUpdatedEvent extends OpencodeBaseEvent { + type: "message.part.updated" + properties?: { + part?: OpencodePart + partID?: string + partId?: string + id?: string + sessionID?: string + [key: string]: unknown + } +} + +export interface OpencodeMessageUpdatedEvent extends OpencodeBaseEvent { + type: "message.updated" + properties?: { + info?: { + tokens?: OpencodeTokens + cost?: number + sessionID?: string + [key: string]: unknown + } + sessionID?: string + [key: string]: unknown + } +} + +export interface OpencodeShellStartedEvent extends OpencodeBaseEvent { + type: "session.next.shell.started" + properties?: { + callID?: string + command?: string + cwd?: string + sessionID?: string + [key: string]: unknown + } +} + +export interface OpencodeShellEndedEvent extends OpencodeBaseEvent { + type: "session.next.shell.ended" + properties?: { + callID?: string + command?: string + output?: string + stdout?: string + stderr?: string + exit?: number + code?: number + sessionID?: string + [key: string]: unknown + } +} + +export interface OpencodeSessionErrorEvent extends OpencodeBaseEvent { + type: "session.error" + properties?: { + error?: unknown + message?: string + sessionID?: string + [key: string]: unknown + } + error?: OpencodeErrorEvent["error"] + message?: string +} + +export interface OpencodeGenericKnownEvent extends OpencodeBaseEvent { + type: "permission.asked" | "permission.replied" | "question.asked" | "question.replied" | "question.rejected" + properties?: Record +} + +export interface OpencodeRawJsonEvent { + type: "raw_json" + original_type?: string + raw: Record +} + +export interface OpencodePart { + id?: string + sessionID?: string + messageID?: string + type?: string + text?: string + snapshot?: string + tool?: string + state?: OpencodeToolState + reason?: string + cost?: number + tokens?: OpencodeTokens + [key: string]: unknown +} + +export interface OpencodeToolState { + status?: "pending" | "running" | "completed" | "error" | string + input?: unknown + output?: unknown + title?: string + metadata?: { + exit?: number + [key: string]: unknown + } + [key: string]: unknown +} + +export interface OpencodeTokens { + total?: number + input?: number + output?: number + reasoning?: number + cache?: { + read?: number + write?: number + [key: string]: unknown + } + [key: string]: unknown +} + +const KNOWN_TOP_TYPES = new Set([ + "step_start", + "text", + "tool_use", + "step_finish", + "error", + "message.updated", + "message.part.delta", + "message.part.updated", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", + "session.next.shell.started", + "session.next.shell.ended", + "session.error", +]) + +/** + * Parses a raw JSON object into a typed OpencodeEvent. + * Preserves unknown event types as raw_json so consumers can surface them. + */ +export function parseOpencodeEvent(json: unknown): OpencodeEvent | null { + if (!json || typeof json !== "object") return null + + const obj = json as Record + const type = obj.type as string | undefined + + if (!type) return null + + if (KNOWN_TOP_TYPES.has(type)) { + return obj as unknown as OpencodeEvent + } + + return { + type: "raw_json", + original_type: type, + raw: obj, + } +} diff --git a/projects/harness/src/index.ts b/projects/harness/src/index.ts index 3e610e5a..e3f3b901 100644 --- a/projects/harness/src/index.ts +++ b/projects/harness/src/index.ts @@ -66,6 +66,9 @@ export { CodexHarness } from "./harnesses/codex/index.js" export type { CodexHarnessConfig, CodexEvent } from "./harnesses/codex/index.js" export { calculateCodexCostUsd } from "./harnesses/codex/pricing.js" +export { OpencodeHarness } from "./harnesses/opencode/index.js" +export type { OpencodeHarnessConfig, OpencodeEvent } from "./harnesses/opencode/index.js" + // ── Utilities ── export { startToolServer } from "./util/tool-server.js" export type { ToolServerHandle, ToolServerOptions } from "./util/tool-server.js" @@ -131,3 +134,18 @@ export type { } from "./harnesses/codex/types.js" export { parseCodexEvent } from "./harnesses/codex/types.js" + +// ── opencode sub-types (for consumers that need them) ── +export type { + OpencodeStepStartEvent, + OpencodeTextEvent, + OpencodeToolUseEvent, + OpencodeStepFinishEvent, + OpencodeErrorEvent, + OpencodeRawJsonEvent, + OpencodePart, + OpencodeToolState, + OpencodeTokens, +} from "./harnesses/opencode/types.js" + +export { parseOpencodeEvent } from "./harnesses/opencode/types.js" diff --git a/projects/harness/src/models.test.ts b/projects/harness/src/models.test.ts index b3356239..19ee34e5 100644 --- a/projects/harness/src/models.test.ts +++ b/projects/harness/src/models.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest" import { ClaudeCodeHarness } from "./harnesses/claude-code/index.js" import { CodexHarness } from "./harnesses/codex/index.js" +import { OpencodeHarness } from "./harnesses/opencode/index.js" import { MODEL_REGISTRY, HARNESS_META, @@ -28,8 +29,13 @@ describe("MODEL_REGISTRY sync", () => { expect(harness.models()).toEqual(MODEL_REGISTRY["codex"]) }) + it("matches OpencodeHarness.models()", () => { + const harness = new OpencodeHarness() + expect(harness.models()).toEqual(MODEL_REGISTRY["opencode"]) + }) + it("HARNESS_META matches harness meta()", () => { - for (const harness of [new ClaudeCodeHarness(), new CodexHarness()]) { + for (const harness of [new ClaudeCodeHarness(), new CodexHarness(), new OpencodeHarness()]) { const meta = harness.meta() expect(HARNESS_META[meta.id]).toEqual({ name: meta.name, vendor: meta.vendor }) } @@ -49,7 +55,7 @@ describe("defaults", () => { expect(DEFAULT_HARNESS_ID).toBe("claude-code") }) - it("DEFAULT_MODEL is opus", () => { + it("DEFAULT_MODEL is the Claude Code default", () => { expect(DEFAULT_MODEL).toBe("opus") }) }) @@ -97,6 +103,12 @@ describe("getModelsForHarness", () => { expect(models.map((m) => m.id)).toEqual(["gpt-5.5", "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark"]) }) + it("returns models for opencode", () => { + const models = getModelsForHarness("opencode") + expect(models.length).toBe(4) + expect(models.map((m) => m.id)).toEqual(["claude-sonnet", "claude-opus", "gpt-5.1-codex", "gpt-5"]) + }) + it("returns empty array for unknown harness", () => { expect(getModelsForHarness("unknown" as never)).toEqual([]) }) @@ -115,6 +127,10 @@ describe("getDefaultModelForHarness", () => { expect(getDefaultModelForHarness("codex")).toBe("gpt-5.5") }) + it("returns claude-sonnet for opencode", () => { + expect(getDefaultModelForHarness("opencode")).toBe("claude-sonnet") + }) + it("falls back to DEFAULT_MODEL for unknown harness", () => { expect(getDefaultModelForHarness("unknown" as never)).toBe(DEFAULT_MODEL) }) @@ -133,6 +149,8 @@ describe("resolveModelForHarness", () => { expect(resolveModelForHarness("claude-opus-4-6", "claude-code")).toBe("opus-4-6") expect(resolveModelForHarness("claude-opus-4-7", "claude-code")).toBe("opus-4-7") expect(resolveModelForHarness("gpt-5.3-codex", "codex")).toBe("gpt-5.3-codex") + expect(resolveModelForHarness("anthropic/claude-sonnet-4-5", "opencode")).toBe("claude-sonnet") + expect(resolveModelForHarness("opencode/gpt-5.1-codex", "opencode")).toBe("gpt-5.1-codex") }) it("maps future Claude family full IDs to stable aliases", () => { @@ -178,6 +196,8 @@ describe("normalizeModelClass", () => { expect(normalizeModelClass("gpt-5.3-codex")).toBe("Codex") expect(normalizeModelClass("gpt-5.5")).toBe("Codex") expect(normalizeModelClass("gpt-5.4")).toBe("Codex") + expect(normalizeModelClass("anthropic/claude-sonnet-4-5")).toBe("Sonnet") + expect(normalizeModelClass("opencode/gpt-5.1-codex")).toBe("Codex") }) it("falls back to string matching for legacy model IDs", () => { diff --git a/projects/harness/src/models.ts b/projects/harness/src/models.ts index fea1b0d0..8996aae4 100644 --- a/projects/harness/src/models.ts +++ b/projects/harness/src/models.ts @@ -12,6 +12,7 @@ export interface HarnessMetaEntry { export const HARNESS_META: Record = { "claude-code": { name: "Claude Code", vendor: "Anthropic" }, codex: { name: "Codex", vendor: "OpenAI" }, + opencode: { name: "opencode", vendor: "sst" }, } // ============================================================================ @@ -42,13 +43,24 @@ export const CODEX_MODEL_CONFIG: HarnessModelConfig = { defaultModel: "gpt-5.5", } +export const OPENCODE_MODEL_CONFIG: HarnessModelConfig = { + models: [ + { id: "claude-sonnet", fullId: "anthropic/claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5", displayClass: "Sonnet" }, + { id: "claude-opus", fullId: "anthropic/claude-opus-4-5-20251101", label: "Claude Opus 4.5", displayClass: "Opus" }, + { id: "gpt-5.1-codex", fullId: "opencode/gpt-5.1-codex", label: "GPT-5.1 Codex", displayClass: "Codex" }, + { id: "gpt-5", fullId: "openai/gpt-5", label: "GPT-5", displayClass: "Codex" }, + ], + defaultModel: "claude-sonnet", +} + export const MODEL_REGISTRY: Record = { "claude-code": CLAUDE_CODE_MODEL_CONFIG, codex: CODEX_MODEL_CONFIG, + opencode: OPENCODE_MODEL_CONFIG, } export const DEFAULT_HARNESS_ID: HarnessId = "claude-code" -export const DEFAULT_MODEL = "opus" +export const DEFAULT_MODEL = CLAUDE_CODE_MODEL_CONFIG.defaultModel // ============================================================================ // Helpers @@ -147,5 +159,6 @@ export function normalizeModelClass(modelId: string): string { if (lower.includes("haiku")) return "Haiku" if (lower.includes("codex")) return "Codex" if (lower.startsWith("gpt-")) return "Codex" + if (lower.includes("/gpt-")) return "Codex" return "Other" } diff --git a/projects/harness/src/types.ts b/projects/harness/src/types.ts index 8eeecd66..42ff460e 100644 --- a/projects/harness/src/types.ts +++ b/projects/harness/src/types.ts @@ -2,7 +2,7 @@ // Identifiers // ============================================================================ -export type HarnessId = "claude-code" | "codex" +export type HarnessId = "claude-code" | "codex" | "opencode" // ============================================================================ // Prompt Content diff --git a/projects/web/src/components/events/messageGroups.test.ts b/projects/web/src/components/events/messageGroups.test.ts index cd2dd044..be4ba644 100644 --- a/projects/web/src/components/events/messageGroups.test.ts +++ b/projects/web/src/components/events/messageGroups.test.ts @@ -35,6 +35,28 @@ function claudeMessageEvent(message: Record, id: string = crypt } as unknown as HarnessStreamEvent } +function opencodeMessageEvent(message: Record, id: string = crypto.randomUUID()): HarnessStreamEvent { + return { + id, + type: "raw_message", + executionId: "exec-1", + harnessId: "opencode", + direction: "execution", + message, + } as unknown as HarnessStreamEvent +} + +function opencodeCompleteEvent(usage: { inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number }, id: string = crypto.randomUUID()): HarnessStreamEvent { + return { + id, + type: "complete", + executionId: "exec-1", + harnessId: "opencode", + direction: "execution", + usage, + } as unknown as HarnessStreamEvent +} + describe("groupStreamEvents stderr grouping", () => { it("merges adjacent stderr events into one stderr group", () => { const groups = groupStreamEvents( @@ -84,6 +106,109 @@ describe("groupStreamEvents stderr grouping", () => { }) }) +describe("groupStreamEvents opencode", () => { + it("renders opencode text events as one text group", () => { + const groups = groupStreamEvents( + [ + opencodeMessageEvent({ type: "step_start", sessionID: "ses_123", part: { id: "prt_start", type: "step-start" } }, "msg-1"), + opencodeMessageEvent({ type: "text", sessionID: "ses_123", part: { type: "text", text: "hello " } }, "msg-2"), + opencodeMessageEvent({ type: "text", sessionID: "ses_123", part: { type: "text", text: "world" } }, "msg-3"), + opencodeMessageEvent({ + type: "step_finish", + sessionID: "ses_123", + part: { reason: "stop", cost: 0.01, tokens: { input: 10, output: 2 } }, + }), + ], + "opencode" + ) + + expect(groups).toMatchObject([ + { type: "system", subtype: "init" }, + { type: "text", text: "hello world", messageIndex: 1 }, + { type: "result", subtype: "success", totalCostUsd: 0.01, usage: { inputTokens: 10, outputTokens: 2 } }, + ]) + }) + + it("renders opencode bash tool_use events", () => { + const groups = groupStreamEvents( + [ + opencodeMessageEvent( + { + type: "tool_use", + sessionID: "ses_123", + part: { + id: "tool_1", + tool: "bash", + state: { + status: "completed", + input: { command: "pwd" }, + output: "/tmp/project", + metadata: { exit: 0 }, + }, + }, + }, + "msg-1" + ), + ], + "opencode" + ) + + expect(groups).toEqual([ + { + type: "bash", + toolUseId: "tool_1", + command: "pwd", + description: undefined, + result: "/tmp/project", + isError: false, + isPending: false, + messageIndices: [0, undefined], + }, + ]) + }) + + it("renders opencode JSON stream text and shell events", () => { + const groups = groupStreamEvents( + [ + opencodeMessageEvent({ type: "message.part.delta", properties: { partID: "part_1", field: "text", delta: "hello " } }, "msg-1"), + opencodeMessageEvent({ type: "message.part.delta", properties: { partID: "part_1", field: "text", delta: "world" } }, "msg-2"), + opencodeMessageEvent({ type: "message.part.updated", properties: { part: { id: "part_1", type: "text", text: "hello world" } } }, "msg-3"), + opencodeMessageEvent({ type: "session.next.shell.started", properties: { callID: "call_1", command: "pwd" } }, "msg-4"), + opencodeMessageEvent({ type: "session.next.shell.ended", properties: { callID: "call_1", command: "pwd", output: "/tmp/project", exit: 0 } }, "msg-5"), + opencodeCompleteEvent({ inputTokens: 12, outputTokens: 3, costUsd: 0.02, durationMs: 500 }, "complete-1"), + ], + "opencode" + ) + + expect(groups).toEqual([ + { + type: "text", + text: "hello world", + messageIndex: 0, + }, + { + type: "bash", + toolUseId: "call_1", + command: "pwd", + description: undefined, + result: "/tmp/project", + isError: false, + isPending: false, + messageIndices: [4, undefined], + }, + { + type: "result", + subtype: "success", + durationMs: 500, + totalCostUsd: 0.02, + usage: { inputTokens: 12, outputTokens: 3 }, + isError: false, + messageIndex: 4, + }, + ]) + }) +}) + describe("groupStreamEvents codex file changes", () => { it("renders one fileChange group per Codex file_change entry", () => { const groups = groupStreamEvents( diff --git a/projects/web/src/components/events/messageGroups.ts b/projects/web/src/components/events/messageGroups.ts index 0b528e8c..31a78152 100644 --- a/projects/web/src/components/events/messageGroups.ts +++ b/projects/web/src/components/events/messageGroups.ts @@ -18,6 +18,7 @@ import { extractRawMessageEvents } from "../../electronAPI/harnessEventTypes" import type { ActionEventSource } from "../../types" import { groupClaudeCodeMessages } from "./parsers/claudeCodeParser" import { groupCodexMessages } from "./parsers/codexParser" +import { groupOpencodeMessages } from "./parsers/opencodeParser" // ============================================================================ // Render Mode Types @@ -263,7 +264,7 @@ function collectStderrGroups(events: HarnessStreamEvent[]): StderrGroup[] { function groupRawMessageEvents( events: HarnessRawMessageEvent[], harnessId: HarnessId, - completionUsage?: { costUsd?: number; durationMs?: number } + completionUsage?: { costUsd?: number; durationMs?: number; inputTokens?: number; outputTokens?: number } ): MessageGroup[] { switch (harnessId) { case "claude-code": { @@ -276,6 +277,12 @@ function groupRawMessageEvents( const messages = events.filter((e): e is HarnessRawMessageEvent & { harnessId: "codex" } => e.harnessId === "codex").map((e) => e.message) return groupCodexMessages(messages, completionUsage) } + case "opencode": { + const messages = events + .filter((e): e is HarnessRawMessageEvent & { harnessId: "opencode" } => e.harnessId === "opencode") + .map((e) => e.message) + return groupOpencodeMessages(messages, completionUsage) + } default: { const _exhaustive: never = harnessId return _exhaustive @@ -283,11 +290,11 @@ function groupRawMessageEvents( } } -function extractCompletionUsage(events: HarnessStreamEvent[]): { costUsd?: number; durationMs?: number } | undefined { +function extractCompletionUsage(events: HarnessStreamEvent[]): { costUsd?: number; durationMs?: number; inputTokens?: number; outputTokens?: number } | undefined { for (const e of events) { if (e.direction === "execution" && e.type === "complete" && e.usage) { const usage = e.usage as HarnessUsage - return { costUsd: usage.costUsd, durationMs: usage.durationMs } + return { costUsd: usage.costUsd, durationMs: usage.durationMs, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } } } return undefined diff --git a/projects/web/src/components/events/parsers/opencodeParser.ts b/projects/web/src/components/events/parsers/opencodeParser.ts new file mode 100644 index 00000000..f5c539ff --- /dev/null +++ b/projects/web/src/components/events/parsers/opencodeParser.ts @@ -0,0 +1,406 @@ +/** + * opencode event parser + * + * Converts typed OpencodeEvent[] into MessageGroup[] for rendering. + */ + +import type { OpencodeErrorEvent, OpencodeEvent, OpencodeRawJsonEvent, OpencodeStepFinishEvent, OpencodeToolUseEvent } from "@openade/harness/browser" +import type { BashGroup, MessageGroup, ResultGroup, ToolGroup } from "../messageGroups" + +type CompletionUsage = { costUsd?: number; durationMs?: number; inputTokens?: number; outputTokens?: number } + +export function groupOpencodeMessages(messages: OpencodeEvent[], completionUsage?: CompletionUsage): MessageGroup[] { + const groups: MessageGroup[] = [] + const textDeltaPartIds = new Set() + const thinkingDeltaPartIds = new Set() + const endedShellCallIds = collectEndedShellCallIds(messages) + let textBuffer = "" + let textStartIndex = -1 + let thinkingBuffer = "" + let thinkingStartIndex = -1 + let renderedResult = false + + const flushText = () => { + const text = textBuffer.trim() + if (text.length > 0) { + groups.push({ + type: "text", + text, + messageIndex: textStartIndex, + }) + } + textBuffer = "" + textStartIndex = -1 + } + + const flushThinking = () => { + const text = thinkingBuffer.trim() + if (text.length > 0) { + groups.push({ + type: "thinking", + text, + messageIndex: thinkingStartIndex, + }) + } + thinkingBuffer = "" + thinkingStartIndex = -1 + } + + const flushInlineText = () => { + flushText() + flushThinking() + } + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + + const text = getOpencodeText(msg, textDeltaPartIds) + if (text) { + flushThinking() + if (textStartIndex < 0) textStartIndex = i + textBuffer += text + continue + } + + const thinking = getOpencodeThinking(msg, thinkingDeltaPartIds) + if (thinking) { + flushText() + if (thinkingStartIndex < 0) thinkingStartIndex = i + thinkingBuffer += thinking + continue + } + + flushInlineText() + + if (msg.type === "step_start") { + const { type: _type, ...metadata } = msg as unknown as Record + groups.push({ + type: "system", + subtype: "init", + metadata, + messageIndex: i, + }) + continue + } + + if (msg.type === "tool_use") { + groups.push(buildToolGroup(msg, i)) + continue + } + + if (msg.type === "message.part.updated") { + const toolGroup = buildPartUpdatedToolGroup(msg, i) + if (toolGroup) { + groups.push(toolGroup) + } + continue + } + + if (msg.type === "session.next.shell.started") { + const callId = getShellCallId(msg) + if (!callId || !endedShellCallIds.has(callId)) { + groups.push(buildShellGroup(msg, i, true)) + } + continue + } + + if (msg.type === "session.next.shell.ended") { + groups.push(buildShellGroup(msg, i, false)) + continue + } + + if (msg.type === "step_finish") { + const result = buildResultGroup(msg, i, completionUsage) + if (result) { + groups.push(result) + renderedResult = true + } + continue + } + + if (msg.type === "error" || msg.type === "session.error") { + groups.push(buildErrorResultGroup(msg, i)) + continue + } + + if (msg.type === "raw_json") { + pushOpencodeUnknownEventGroup(groups, msg, i) + } + } + + flushInlineText() + + if (!renderedResult && completionUsage) { + groups.push(buildCompletionResultGroup(completionUsage, messages.length - 1)) + } + + return groups +} + +function getOpencodeText(msg: OpencodeEvent, textDeltaPartIds: Set): string | undefined { + if (msg.type === "text") { + const partText = msg.part?.text + if (typeof partText === "string") return partText + + const rawText = (msg as unknown as { text?: unknown }).text + return typeof rawText === "string" ? rawText : undefined + } + + const properties = getProperties(msg) + if (!properties) return undefined + + if (msg.type === "message.part.delta") { + if (properties.field !== "text" || typeof properties.delta !== "string") return undefined + const partId = getOpencodePartId(properties) + if (partId) textDeltaPartIds.add(partId) + return properties.delta + } + + if (msg.type === "message.part.updated") { + const part = isRecord(properties.part) ? properties.part : undefined + if (!part || part.type !== "text") return undefined + const partId = getOpencodePartId(properties) ?? getOpencodePartId(part) + if (partId && textDeltaPartIds.has(partId)) return undefined + const text = part.text ?? part.snapshot + return typeof text === "string" ? text : undefined + } + + return undefined +} + +function getOpencodeThinking(msg: OpencodeEvent, thinkingDeltaPartIds: Set): string | undefined { + const properties = getProperties(msg) + if (!properties) return undefined + + if (msg.type === "message.part.delta") { + const part = isRecord(properties.part) ? properties.part : undefined + if (part?.type !== "reasoning" || properties.field !== "text" || typeof properties.delta !== "string") return undefined + const partId = getOpencodePartId(properties) ?? getOpencodePartId(part) + if (partId) thinkingDeltaPartIds.add(partId) + return properties.delta + } + + if (msg.type === "message.part.updated") { + const part = isRecord(properties.part) ? properties.part : undefined + if (!part || part.type !== "reasoning") return undefined + const partId = getOpencodePartId(properties) ?? getOpencodePartId(part) + if (partId && thinkingDeltaPartIds.has(partId)) return undefined + const text = part.text ?? part.snapshot + return typeof text === "string" ? text : undefined + } + + return undefined +} + +function buildToolGroup(msg: OpencodeToolUseEvent, messageIndex: number): ToolGroup | BashGroup { + return buildToolGroupFromPart(msg.part ?? {}, messageIndex) +} + +function buildPartUpdatedToolGroup(msg: OpencodeEvent, messageIndex: number): ToolGroup | BashGroup | null { + const properties = getProperties(msg) + const part = properties && isRecord(properties.part) ? properties.part : undefined + if (!part || part.type !== "tool") return null + return buildToolGroupFromPart(part, messageIndex) +} + +function buildToolGroupFromPart(part: Record, messageIndex: number): ToolGroup | BashGroup { + const state = isRecord(part.state) ? part.state : undefined + const toolName = normalizeToolName(getToolName(part.tool)) + const input = state?.input ?? part.input + const output = state?.output ?? part.output + const metadata = state && isRecord(state.metadata) ? state.metadata : undefined + const exitCode = pickNumber(metadata, ["exit", "code"]) ?? pickNumber(state, ["exit", "code"]) ?? pickNumber(part, ["exit", "code"]) + const status = pickString(state, ["status"]) ?? pickString(part, ["status"]) + const isError = status === "error" || status === "failed" || (exitCode !== undefined && exitCode !== 0) + const toolUseId = pickString(part, ["id", "callID", "callId"]) ?? `opencode-tool-${messageIndex}` + + if (toolName === "Bash") { + const inputRecord = isRecord(input) ? input : {} + return { + type: "bash", + toolUseId, + command: pickString(inputRecord, ["command", "cmd", "script"]) ?? stringifyUnknown(input) ?? "", + description: pickString(inputRecord, ["description"]), + result: stringifyUnknown(output), + isError, + isPending: status === "pending" || status === "running", + messageIndices: [messageIndex, undefined], + } + } + + return { + type: "tool", + toolUseId, + toolName, + input, + result: stringifyUnknown(output), + isError, + messageIndices: [messageIndex, undefined], + } +} + +function buildShellGroup(msg: OpencodeEvent, messageIndex: number, isPending: boolean): BashGroup { + const properties = getProperties(msg) ?? {} + const exitCode = pickNumber(properties, ["exit", "exitCode", "code"]) + const status = pickString(properties, ["status"]) + const output = pickString(properties, ["output"]) ?? joinOutputParts(properties) + + return { + type: "bash", + toolUseId: getShellCallId(msg) ?? `opencode-shell-${messageIndex}`, + command: pickString(properties, ["command", "cmd", "script"]) ?? "", + description: pickString(properties, ["description"]), + result: isPending ? undefined : output, + isError: status === "error" || status === "failed" || (exitCode !== undefined && exitCode !== 0), + isPending, + messageIndices: [messageIndex, undefined], + } +} + +function buildResultGroup(msg: OpencodeStepFinishEvent, messageIndex: number, completionUsage?: CompletionUsage): ResultGroup | null { + if (msg.part?.reason === "tool-calls") return null + + return { + type: "result", + subtype: "success", + durationMs: completionUsage?.durationMs ?? 0, + totalCostUsd: completionUsage?.costUsd ?? msg.part?.cost ?? 0, + usage: { + inputTokens: completionUsage?.inputTokens ?? msg.part?.tokens?.input ?? 0, + outputTokens: completionUsage?.outputTokens ?? msg.part?.tokens?.output ?? 0, + }, + isError: false, + messageIndex, + } +} + +function buildCompletionResultGroup(completionUsage: CompletionUsage, messageIndex: number): ResultGroup { + return { + type: "result", + subtype: "success", + durationMs: completionUsage.durationMs ?? 0, + totalCostUsd: completionUsage.costUsd ?? 0, + usage: { + inputTokens: completionUsage.inputTokens ?? 0, + outputTokens: completionUsage.outputTokens ?? 0, + }, + isError: false, + messageIndex, + } +} + +function buildErrorResultGroup(msg: OpencodeErrorEvent | Extract, messageIndex: number): ResultGroup { + return { + type: "result", + subtype: "error_during_execution", + durationMs: 0, + totalCostUsd: 0, + usage: { inputTokens: 0, outputTokens: 0 }, + isError: true, + errors: [getOpencodeErrorMessage(msg)], + messageIndex, + } +} + +function getOpencodeErrorMessage(msg: OpencodeErrorEvent | Extract): string { + if (msg.type === "error") { + return msg.error?.data?.message ?? msg.error?.message ?? msg.message ?? msg.error?.name ?? "opencode error" + } + + const properties = getProperties(msg) + const propertyError = properties?.error + if (isRecord(propertyError)) { + const data = isRecord(propertyError.data) ? propertyError.data : undefined + return (data && pickString(data, ["message"])) ?? pickString(propertyError, ["message", "name"]) ?? "opencode error" + } + if (typeof propertyError === "string") return propertyError + return msg.message ?? pickString(properties, ["message"]) ?? "opencode error" +} + +function pushOpencodeUnknownEventGroup(groups: MessageGroup[], event: OpencodeRawJsonEvent, messageIndex: number): void { + groups.push({ + type: "unknown", + harnessId: "opencode", + label: `Unknown opencode event: ${event.original_type ?? "event"}`, + originalType: event.original_type, + raw: event.raw, + messageIndex, + }) +} + +function collectEndedShellCallIds(messages: OpencodeEvent[]): Set { + const ids = new Set() + for (const message of messages) { + if (message.type !== "session.next.shell.ended") continue + const callId = getShellCallId(message) + if (callId) ids.add(callId) + } + return ids +} + +function getShellCallId(msg: OpencodeEvent): string | undefined { + const properties = getProperties(msg) + return properties ? pickString(properties, ["callID", "callId", "id"]) : undefined +} + +function getProperties(msg: OpencodeEvent): Record | undefined { + const properties = (msg as unknown as { properties?: unknown }).properties + return isRecord(properties) ? properties : undefined +} + +function getOpencodePartId(record: Record): string | undefined { + return pickString(record, ["partID", "partId", "id"]) +} + +function getToolName(value: unknown): string | undefined { + if (typeof value === "string") return value + if (isRecord(value)) return pickString(value, ["name", "id", "tool"]) + return undefined +} + +function normalizeToolName(value: string | undefined): string { + const lower = value?.toLowerCase() + if (lower === "bash" || lower === "shell") return "Bash" + if (lower === "websearch" || lower === "web_search") return "WebSearch" + if (lower === "webfetch" || lower === "web_fetch") return "WebFetch" + if (lower === "todowrite" || lower === "todo_write") return "TodoWrite" + if (!value) return "opencode tool" + return value +} + +function joinOutputParts(record: Record): string | undefined { + const parts = [pickString(record, ["stdout"]), pickString(record, ["stderr"])].filter((part): part is string => typeof part === "string" && part.length > 0) + return parts.length > 0 ? parts.join("\n") : undefined +} + +function stringifyUnknown(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined + if (typeof value === "string") return value + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function pickString(record: Record | undefined, keys: string[]): string | undefined { + if (!record) return undefined + for (const key of keys) { + const value = record[key] + if (typeof value === "string") return value + } + return undefined +} + +function pickNumber(record: Record | undefined, keys: string[]): number | undefined { + if (!record) return undefined + for (const key of keys) { + const value = record[key] + if (typeof value === "number" && Number.isFinite(value)) return value + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} diff --git a/projects/web/src/components/hyperplan/StrategyPicker.tsx b/projects/web/src/components/hyperplan/StrategyPicker.tsx index 22dd98b8..18edc677 100644 --- a/projects/web/src/components/hyperplan/StrategyPicker.tsx +++ b/projects/web/src/components/hyperplan/StrategyPicker.tsx @@ -11,7 +11,7 @@ import cx from "classnames" import { AlertTriangle, Check, FileText, Play, Star, X, Zap } from "lucide-react" import { observer } from "mobx-react" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { MODEL_REGISTRY } from "../../constants" import type { HarnessId } from "../../electronAPI/harnessEventTypes" import { type HarnessInstallStatus, type HarnessStatusMap, getHarnessStatuses } from "../../electronAPI/harnessStatus" @@ -136,6 +136,14 @@ export const StrategyPicker = observer(function StrategyPicker({ onClose, onRun const store = useCodeStore() const settings = store.personalSettingsStore?.settings.get() const showKeyboardHints = useShortcutHintsVisible() + const registryEntries = useMemo(() => { + const entries = Object.entries(MODEL_REGISTRY) as Array<[HarnessId, (typeof MODEL_REGISTRY)[HarnessId]]> + return entries.sort(([a], [b]) => { + if (a === store.defaultHarnessId) return -1 + if (b === store.defaultHarnessId) return 1 + return 0 + }) + }, [store.defaultHarnessId]) // Local state const [selectedStrategyId, setSelectedStrategyId] = useState(settings?.hyperplanStrategyId ?? "ensemble") @@ -145,8 +153,8 @@ export const StrategyPicker = observer(function StrategyPicker({ onClose, onRun } // Default: one default model per harness const defaults: AgentCouplet[] = [] - for (const [harnessId, config] of Object.entries(MODEL_REGISTRY)) { - defaults.push({ harnessId: harnessId as HarnessId, modelId: config.defaultModel }) + for (const [harnessId, config] of registryEntries) { + defaults.push({ harnessId, modelId: config.defaultModel }) } return defaults.length > 0 ? defaults : [{ harnessId: store.defaultHarnessId, modelId: store.defaultModel }] }) @@ -173,7 +181,7 @@ export const StrategyPicker = observer(function StrategyPicker({ onClose, onRun // Build couplet list const allCouplets: Array = [] - for (const [harnessId, config] of Object.entries(MODEL_REGISTRY)) { + for (const [harnessId, config] of registryEntries) { const status = harnessStatuses[harnessId] as HarnessInstallStatus | undefined const isAvailable = !!status?.installed && !!status?.authenticated const harnessLabel = getHarnessDisplayName(harnessId) diff --git a/projects/web/src/components/settings/SystemConfigTab.tsx b/projects/web/src/components/settings/SystemConfigTab.tsx index 7f90ca1a..a4b1f490 100644 --- a/projects/web/src/components/settings/SystemConfigTab.tsx +++ b/projects/web/src/components/settings/SystemConfigTab.tsx @@ -7,10 +7,13 @@ import { AlertTriangle, CheckCircle, Eye, EyeOff, Loader2, Minus, Plus, RefreshCw, RotateCcw, XCircle } from "lucide-react" import { observer } from "mobx-react" import { useEffect, useState } from "react" +import { MODEL_REGISTRY } from "../../constants" import { type ManagedBinaryStatus, ensureBinary, getStatuses } from "../../electronAPI/binaries" +import type { HarnessId } from "../../electronAPI/harnessEventTypes" import { type HarnessStatusMap, getHarnessStatuses, isHarnessStatusApiAvailable } from "../../electronAPI/harnessStatus" import { isSystemApiAvailable } from "../../electronAPI/system" import type { CodeStore } from "../../store/store" +import { Select } from "../ui/Select" import { getHarnessAuthTypeLabel, getHarnessDisplayName, toHarnessStatusView } from "./harnessStatusUtils" interface KeyValuePair { @@ -107,7 +110,7 @@ const KeyValueEditor = ({ ) } -const HarnessStatusSection = () => { +const HarnessStatusSection = observer(({ store }: { store: CodeStore }) => { const [statuses, setStatuses] = useState({}) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -135,6 +138,10 @@ const HarnessStatusSection = () => { if (!isSystemApiAvailable()) return null const entries = Object.entries(statuses).sort((a, b) => getHarnessDisplayName(a[0]).localeCompare(getHarnessDisplayName(b[0]))) + const defaultHarnessEntries = (Object.keys(MODEL_REGISTRY) as HarnessId[]).map((harnessId) => ({ + id: harnessId, + content: getHarnessDisplayName(harnessId), + })) return (
@@ -150,7 +157,23 @@ const HarnessStatusSection = () => { -

Install and authentication status for each configured harness CLI.

+

Install and authentication status for each configured harness CLI. The default engine is used for new tasks.

+ +
+
+

Default Engine

+

Used for new tasks and plan generation.

+
+