Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions projects/electron/src/modules/code/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -85,7 +85,7 @@ const activeProbes = new Map<string, Promise<SdkCapabilities | null>>()
* 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<SdkCapabilities | null> {
async function runProbe(cwd: string, harnessId: HarnessId = DEFAULT_HARNESS_ID): Promise<SdkCapabilities | null> {
const key = cacheKey(harnessId, cwd)

// Deduplicate concurrent probes for the same (harnessId, cwd)
Expand Down Expand Up @@ -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)
Expand All @@ -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 }
})
Expand Down
9 changes: 6 additions & 3 deletions projects/electron/src/modules/code/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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]`

Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 19 additions & 1 deletion projects/harness/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down Expand Up @@ -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"
108 changes: 108 additions & 0 deletions projects/harness/src/harnesses/opencode/args.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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("<system-instructions>")
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()
})
})
Loading