From f6a09bcbeae68b233e6a2dba93bd9e2af9278683 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 4 Mar 2026 16:48:54 -0600 Subject: [PATCH] feat: add opencode local adapter support --- cli/package.json | 1 + cli/src/adapters/registry.ts | 8 +- packages/adapters/openclaw/src/index.ts | 2 +- packages/adapters/opencode-local/CHANGELOG.md | 7 + packages/adapters/opencode-local/package.json | 50 +++ .../opencode-local/src/cli/format-event.ts | 114 +++++ .../adapters/opencode-local/src/cli/index.ts | 1 + packages/adapters/opencode-local/src/index.ts | 49 +++ .../opencode-local/src/server/execute.ts | 391 ++++++++++++++++++ .../opencode-local/src/server/index.ts | 64 +++ .../opencode-local/src/server/parse.ts | 82 ++++ .../opencode-local/src/server/test.ts | 210 ++++++++++ .../opencode-local/src/ui/build-config.ts | 74 ++++ .../adapters/opencode-local/src/ui/index.ts | 2 + .../opencode-local/src/ui/parse-stdout.ts | 135 ++++++ .../adapters/opencode-local/tsconfig.json | 8 + packages/shared/src/constants.ts | 2 +- pnpm-lock.yaml | 22 + server/package.json | 1 + .../__tests__/adapter-session-codecs.test.ts | 39 ++ ...opencode-local-adapter-environment.test.ts | 32 ++ .../__tests__/opencode-local-adapter.test.ts | 224 ++++++++++ server/src/adapters/registry.ts | 18 +- server/src/routes/agents.ts | 24 +- server/src/services/company-portability.ts | 4 + ui/package.json | 1 + .../adapters/opencode-local/config-fields.tsx | 47 +++ ui/src/adapters/opencode-local/index.ts | 12 + ui/src/adapters/registry.ts | 3 +- ui/src/components/AgentConfigForm.tsx | 50 ++- ui/src/components/AgentProperties.tsx | 1 + ui/src/components/NewIssueDialog.tsx | 18 +- ui/src/components/OnboardingWizard.tsx | 32 +- ui/src/components/agent-config-primitives.tsx | 1 + ui/src/pages/Agents.tsx | 1 + ui/src/pages/InviteLanding.tsx | 3 +- ui/src/pages/OrgChart.tsx | 1 + 37 files changed, 1707 insertions(+), 27 deletions(-) create mode 100644 packages/adapters/opencode-local/CHANGELOG.md create mode 100644 packages/adapters/opencode-local/package.json create mode 100644 packages/adapters/opencode-local/src/cli/format-event.ts create mode 100644 packages/adapters/opencode-local/src/cli/index.ts create mode 100644 packages/adapters/opencode-local/src/index.ts create mode 100644 packages/adapters/opencode-local/src/server/execute.ts create mode 100644 packages/adapters/opencode-local/src/server/index.ts create mode 100644 packages/adapters/opencode-local/src/server/parse.ts create mode 100644 packages/adapters/opencode-local/src/server/test.ts create mode 100644 packages/adapters/opencode-local/src/ui/build-config.ts create mode 100644 packages/adapters/opencode-local/src/ui/index.ts create mode 100644 packages/adapters/opencode-local/src/ui/parse-stdout.ts create mode 100644 packages/adapters/opencode-local/tsconfig.json create mode 100644 server/src/__tests__/opencode-local-adapter-environment.test.ts create mode 100644 server/src/__tests__/opencode-local-adapter.test.ts create mode 100644 ui/src/adapters/opencode-local/config-fields.tsx create mode 100644 ui/src/adapters/opencode-local/index.ts diff --git a/cli/package.json b/cli/package.json index dd940833..a9f939e8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,6 +36,7 @@ "@clack/prompts": "^0.10.0", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index b97dd5df..f51d8091 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -1,6 +1,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; +import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -15,13 +16,18 @@ const codexLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCodexStreamEvent, }; +const opencodeLocalCLIAdapter: CLIAdapterModule = { + type: "opencode_local", + formatStdoutEvent: printOpenCodeStreamEvent, +}; + const openclawCLIAdapter: CLIAdapterModule = { type: "openclaw", formatStdoutEvent: printOpenClawStreamEvent, }; const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [claudeLocalCLIAdapter, codexLocalCLIAdapter, opencodeLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), ); export function getCLIAdapter(type: string): CLIAdapterModule { diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts index 835d2ebf..d7399505 100644 --- a/packages/adapters/openclaw/src/index.ts +++ b/packages/adapters/openclaw/src/index.ts @@ -12,7 +12,7 @@ Use when: - You want Paperclip heartbeat/task events delivered over HTTP. Don't use when: -- You need local CLI execution inside Paperclip (use claude_local/codex_local/process). +- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process). - The OpenClaw endpoint is not reachable from the Paperclip server. Core fields: diff --git a/packages/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md new file mode 100644 index 00000000..e52dfab9 --- /dev/null +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -0,0 +1,7 @@ +# @paperclipai/adapter-opencode-local + +## 0.2.7 + +### Patch Changes + +- Added initial `opencode_local` adapter package for local OpenCode execution diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json new file mode 100644 index 00000000..f53722fb --- /dev/null +++ b/packages/adapters/opencode-local/package.json @@ -0,0 +1,50 @@ +{ + "name": "@paperclipai/adapter-opencode-local", + "version": "0.2.7", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist", + "skills" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/opencode-local/src/cli/format-event.ts b/packages/adapters/opencode-local/src/cli/format-event.ts new file mode 100644 index 00000000..37b34250 --- /dev/null +++ b/packages/adapters/opencode-local/src/cli/format-event.ts @@ -0,0 +1,114 @@ +import pc from "picocolors"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function printToolEvent(part: Record): void { + const tool = asString(part.tool, "tool"); + const callId = asString(part.callID, asString(part.id, "")); + const state = asRecord(part.state); + const status = asString(state?.status); + const input = state?.input; + const output = asString(state?.output).replace(/\s+$/, ""); + const metadata = asRecord(state?.metadata); + const exit = asNumber(metadata?.exit, NaN); + const isError = + status === "failed" || + status === "error" || + status === "cancelled" || + (Number.isFinite(exit) && exit !== 0); + + console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`)); + if (input !== undefined) { + try { + console.log(pc.gray(JSON.stringify(input, null, 2))); + } catch { + console.log(pc.gray(String(input))); + } + } + + if (status || output) { + const summary = [ + "tool_result", + status ? `status=${status}` : "", + Number.isFinite(exit) ? `exit=${exit}` : "", + ] + .filter(Boolean) + .join(" "); + console.log((isError ? pc.red : pc.cyan)(summary)); + if (output) { + console.log((isError ? pc.red : pc.gray)(output)); + } + } +} + +export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + const type = asString(parsed.type); + + if (type === "step_start") { + const sessionId = asString(parsed.sessionID); + console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`)); + return; + } + + if (type === "text") { + const part = asRecord(parsed.part); + const text = asString(part?.text); + if (text) console.log(pc.green(`assistant: ${text}`)); + return; + } + + if (type === "tool_use") { + const part = asRecord(parsed.part); + if (part) { + printToolEvent(part); + } else { + console.log(pc.yellow("tool_use")); + } + return; + } + + if (type === "step_finish") { + const part = asRecord(parsed.part); + const tokens = asRecord(part?.tokens); + const cache = asRecord(tokens?.cache); + const reason = asString(part?.reason, "step_finish"); + const input = asNumber(tokens?.input); + const output = asNumber(tokens?.output); + const cached = asNumber(cache?.read); + const cost = asNumber(part?.cost); + console.log(pc.blue(`step finished: reason=${reason}`)); + console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); + return; + } + + if (type === "error") { + const part = asRecord(parsed.part); + const message = asString(parsed.message) || asString(part?.message) || line; + console.log(pc.red(`error: ${message}`)); + return; + } + + console.log(line); +} diff --git a/packages/adapters/opencode-local/src/cli/index.ts b/packages/adapters/opencode-local/src/cli/index.ts new file mode 100644 index 00000000..93c29b69 --- /dev/null +++ b/packages/adapters/opencode-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printOpenCodeStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts new file mode 100644 index 00000000..6844391c --- /dev/null +++ b/packages/adapters/opencode-local/src/index.ts @@ -0,0 +1,49 @@ +export const type = "opencode_local"; +export const label = "OpenCode (local)"; +export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.3-codex"; + +export const models = [ + { id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL }, + { id: "openai/gpt-5.3-codex-spark", label: "openai/gpt-5.3-codex-spark" }, + { id: "openai/gpt-5.2-codex", label: "openai/gpt-5.2-codex" }, + { id: "openai/gpt-5.1-codex", label: "openai/gpt-5.1-codex" }, + { id: "openai/gpt-5-codex", label: "openai/gpt-5-codex" }, + { id: "openai/codex-mini-latest", label: "openai/codex-mini-latest" }, + { id: "openai/gpt-5", label: "openai/gpt-5" }, + { id: "openai/o3", label: "openai/o3" }, + { id: "openai/o4-mini", label: "openai/o4-mini" }, +]; + +export const agentConfigurationDoc = `# opencode_local agent configuration + +Adapter: opencode_local + +Use when: +- You want Paperclip to run OpenCode locally as the agent runtime +- You want provider/model routing in OpenCode format (provider/model) +- You want OpenCode session resume across heartbeats via --session + +Don't use when: +- You need webhook-style external invocation (use openclaw or http) +- You only need one-shot shell commands (use process) +- OpenCode CLI is not installed on the machine + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt +- model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.3-codex) +- variant (string, optional): provider-specific reasoning/profile variant passed as --variant +- promptTemplate (string, optional): run prompt template +- command (string, optional): defaults to "opencode" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- Runs are executed with: opencode run --format json ... +- Prompts are passed as the final positional message argument. +- Sessions are resumed with --session when stored session cwd matches current cwd. +`; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts new file mode 100644 index 00000000..24cbcefe --- /dev/null +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -0,0 +1,391 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + asString, + asNumber, + asStringArray, + parseObject, + buildPaperclipEnv, + redactEnvForLogs, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + renderTemplate, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; +import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const PAPERCLIP_SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../skills"), + path.resolve(__moduleDir, "../../../../../skills"), +]; + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function resolveOpenCodeBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; +} + +function resolveProviderFromModel(model: string): string | null { + const trimmed = model.trim(); + if (!trimmed) return null; + const slash = trimmed.indexOf("/"); + if (slash <= 0) return null; + return trimmed.slice(0, slash).toLowerCase(); +} + +function claudeSkillsHome(): string { + return path.join(os.homedir(), ".claude", "skills"); +} + +async function resolvePaperclipSkillsDir(): Promise { + for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +async function ensureOpenCodeSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { + const skillsDir = await resolvePaperclipSkillsDir(); + if (!skillsDir) return; + + const skillsHome = claudeSkillsHome(); + await fs.mkdir(skillsHome, { recursive: true }); + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(skillsDir, entry.name); + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) continue; + + try { + await fs.symlink(source, target); + await onLog( + "stderr", + `[paperclip] Injected OpenCode skill "${entry.name}" into ${skillsHome}\n`, + ); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to inject OpenCode skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const command = asString(config.command, "opencode"); + const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL); + const variant = asString(config.variant, asString(config.effort, "")); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureOpenCodeSkillsInjected(onLog); + + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + if (wakeTaskId) { + env.PAPERCLIP_TASK_ID = wakeTaskId; + } + if (wakeReason) { + env.PAPERCLIP_WAKE_REASON = wakeReason; + } + if (wakeCommentId) { + env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + } + if (approvalId) { + env.PAPERCLIP_APPROVAL_ID = approvalId; + } + if (approvalStatus) { + env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + } + if (linkedIssueIds.length > 0) { + env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + } + if (effectiveWorkspaceCwd) { + env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; + } + if (workspaceSource) { + env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; + } + if (workspaceId) { + env.PAPERCLIP_WORKSPACE_ID = workspaceId; + } + if (workspaceRepoUrl) { + env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; + } + if (workspaceRepoRef) { + env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; + } + if (workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + } + for (const [k, v] of Object.entries(envConfig)) { + if (typeof v === "string") env[k] = v; + } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + const billingType = resolveOpenCodeBillingType(env); + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + await ensureCommandResolvable(command, cwd, runtimeEnv); + + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stderr", + `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } + + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (instructionsFilePath) { + try { + const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + await onLog( + "stderr", + `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + } + } + const commandNotes = (() => { + if (!instructionsFilePath) return [] as string[]; + if (instructionsPrefix.length > 0) { + return [ + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + ]; + } + return [ + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ]; + })(); + + const renderedPrompt = renderTemplate(promptTemplate, { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }); + const prompt = `${instructionsPrefix}${renderedPrompt}`; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["run", "--format", "json"]; + if (resumeSessionId) args.push("--session", resumeSessionId); + if (model) args.push("--model", model); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + args.push(prompt); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "opencode_local", + command, + cwd, + commandNotes, + commandArgs: args.map((value, idx) => { + if (idx === args.length - 1) return ``; + return value; + }), + env: redactEnvForLogs(env), + prompt, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + + return { + proc, + parsed: parseOpenCodeJsonl(proc.stdout), + }; + }; + + const providerFromModel = resolveProviderFromModel(model); + + const toResult = ( + attempt: { + proc: { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + }; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null; + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : null; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const fallbackErrorMessage = + parsedError || + stderrLine || + `OpenCode exited with code ${attempt.proc.exitCode ?? -1}`; + + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: + (attempt.proc.exitCode ?? 0) === 0 + ? null + : fallbackErrorMessage, + usage: attempt.parsed.usage, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: providerFromModel, + model, + billingType, + costUsd: attempt.parsed.costUsd, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stderr", + `[paperclip] OpenCode resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true); + } + + return toResult(initial); +} diff --git a/packages/adapters/opencode-local/src/server/index.ts b/packages/adapters/opencode-local/src/server/index.ts new file mode 100644 index 00000000..17300e75 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/index.ts @@ -0,0 +1,64 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) + ); + }, +}; diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts new file mode 100644 index 00000000..2b028566 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -0,0 +1,82 @@ +import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils"; + +function asErrorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = asString(rec.message, "") || asString(rec.error, "") || asString(rec.code, ""); + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function parseOpenCodeJsonl(stdout: string) { + let sessionId: string | null = null; + const messages: string[] = []; + let errorMessage: string | null = null; + let totalCostUsd = 0; + const usage = { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const foundSession = asString(event.sessionID, "").trim(); + if (foundSession) sessionId = foundSession; + + const type = asString(event.type, ""); + + if (type === "text") { + const part = parseObject(event.part); + const text = asString(part.text, "").trim(); + if (text) messages.push(text); + continue; + } + + if (type === "step_finish") { + const part = parseObject(event.part); + const tokens = parseObject(part.tokens); + const cache = parseObject(tokens.cache); + usage.inputTokens += asNumber(tokens.input, 0); + usage.cachedInputTokens += asNumber(cache.read, 0); + usage.outputTokens += asNumber(tokens.output, 0); + totalCostUsd += asNumber(part.cost, 0); + continue; + } + + if (type === "error") { + const part = parseObject(event.part); + const msg = asErrorText(event.message ?? part.message ?? event.error ?? part.error).trim(); + if (msg) errorMessage = msg; + } + } + + return { + sessionId, + summary: messages.join("\n\n").trim(), + usage, + costUsd: totalCostUsd > 0 ? totalCostUsd : null, + errorMessage, + }; +} + +export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror/i.test( + haystack, + ); +} diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts new file mode 100644 index 00000000..ab2e47ee --- /dev/null +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -0,0 +1,210 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asString, + asStringArray, + parseObject, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import path from "node:path"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; +import { parseOpenCodeJsonl } from "./parse.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function commandLooksLike(command: string, expected: string): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +} + +const OPENCODE_AUTH_REQUIRED_RE = + /(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|login\s+required)/i; + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "opencode"); + const cwd = asString(config.cwd, process.cwd()); + + try { + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + checks.push({ + code: "opencode_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "opencode_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const envConfig = parseObject(config.env); + const env: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); + checks.push({ + code: "opencode_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "opencode_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const configOpenAiKey = env.OPENAI_API_KEY; + const hostOpenAiKey = process.env.OPENAI_API_KEY; + if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) { + const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment"; + checks.push({ + code: "opencode_openai_api_key_present", + level: "info", + message: "OPENAI_API_KEY is set for OpenCode authentication.", + detail: `Detected in ${source}.`, + }); + } else { + checks.push({ + code: "opencode_openai_api_key_missing", + level: "warn", + message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.", + hint: "Set OPENAI_API_KEY in adapter env or shell environment.", + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); + if (canRunProbe) { + if (!commandLooksLike(command, "opencode")) { + checks.push({ + code: "opencode_hello_probe_skipped_custom_command", + level: "info", + message: "Skipped hello probe because command is not `opencode`.", + detail: command, + hint: "Use the `opencode` CLI command to run the automatic installation and auth probe.", + }); + } else { + const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL).trim(); + const variant = asString(config.variant, asString(config.effort, "")).trim(); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const args = ["run", "--format", "json"]; + if (model) args.push("--model", model); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("Respond with hello."); + + const probe = await runChildProcess( + `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 45, + graceSec: 5, + onLog: async () => {}, + }, + ); + const parsed = parseOpenCodeJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + + if (probe.timedOut) { + checks.push({ + code: "opencode_hello_probe_timed_out", + level: "warn", + message: "OpenCode hello probe timed out.", + hint: "Retry the probe. If this persists, verify `opencode run --format json \"Respond with hello\"` manually.", + }); + } else if ((probe.exitCode ?? 1) === 0) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "OpenCode hello probe succeeded." + : "OpenCode probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.", + }), + }); + } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_auth_required", + level: "warn", + message: "OpenCode CLI is installed, but authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Configure OPENAI_API_KEY in adapter env/shell, then retry the probe.", + }); + } else { + checks.push({ + code: "opencode_hello_probe_failed", + level: "error", + message: "OpenCode hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `opencode run --format json \"Respond with hello\"` manually in this working directory to debug.", + }); + } + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts new file mode 100644 index 00000000..88b3c0ac --- /dev/null +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -0,0 +1,74 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + ac.model = v.model || DEFAULT_OPENCODE_LOCAL_MODEL; + if (v.thinkingEffort) ac.variant = v.thinkingEffort; + ac.timeoutSec = 0; + ac.graceSec = 15; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/opencode-local/src/ui/index.ts b/packages/adapters/opencode-local/src/ui/index.ts new file mode 100644 index 00000000..a06f826d --- /dev/null +++ b/packages/adapters/opencode-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseOpenCodeStdoutLine } from "./parse-stdout.js"; +export { buildOpenCodeLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/opencode-local/src/ui/parse-stdout.ts b/packages/adapters/opencode-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..7acc1ff9 --- /dev/null +++ b/packages/adapters/opencode-local/src/ui/parse-stdout.ts @@ -0,0 +1,135 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type); + + if (type === "step_start") { + const sessionId = asString(parsed.sessionID); + return [ + { + kind: "system", + ts, + text: `step started${sessionId ? ` (${sessionId})` : ""}`, + }, + ]; + } + + if (type === "text") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (!text) return []; + return [{ kind: "assistant", ts, text }]; + } + + if (type === "tool_use") { + const part = asRecord(parsed.part); + const toolUseId = asString(part?.callID, asString(part?.id, "tool_use")); + const toolName = asString(part?.tool, "tool"); + const state = asRecord(part?.state); + const input = state?.input ?? {}; + const output = asString(state?.output).trim(); + const status = asString(state?.status).trim(); + const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN); + const isError = + status === "failed" || + status === "error" || + status === "cancelled" || + (Number.isFinite(exitCode) && exitCode !== 0); + + const entries: TranscriptEntry[] = [ + { + kind: "tool_call", + ts, + name: toolName, + input, + }, + ]; + + if (status || output) { + const lines: string[] = []; + if (status) lines.push(`status: ${status}`); + if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`); + if (output) { + if (lines.length > 0) lines.push(""); + lines.push(output); + } + entries.push({ + kind: "tool_result", + ts, + toolUseId, + content: lines.join("\n").trim() || "tool completed", + isError, + }); + } + + return entries; + } + + if (type === "step_finish") { + const part = asRecord(parsed.part); + const tokens = asRecord(part?.tokens); + const cache = asRecord(tokens?.cache); + const reason = asString(part?.reason); + return [ + { + kind: "result", + ts, + text: reason, + inputTokens: asNumber(tokens?.input), + outputTokens: asNumber(tokens?.output), + cachedTokens: asNumber(cache?.read), + costUsd: asNumber(part?.cost), + subtype: reason || "step_finish", + isError: reason === "error" || reason === "failed", + errors: [], + }, + ]; + } + + if (type === "error") { + const message = + asString(parsed.message) || + asString(asRecord(parsed.part)?.message) || + stringifyUnknown(parsed.error ?? asRecord(parsed.part)?.error) || + line; + return [{ kind: "stderr", ts, text: message }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/opencode-local/tsconfig.json b/packages/adapters/opencode-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/opencode-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0b3490ab..ff377fb6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -21,7 +21,7 @@ export const AGENT_STATUSES = [ ] as const; export type AgentStatus = (typeof AGENT_STATUSES)[number]; -export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "openclaw"] as const; +export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "opencode_local", "openclaw"] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; export const AGENT_ROLES = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 978dd572..9a9824e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-opencode-local': + specifier: workspace:* + version: link:../packages/adapters/opencode-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils @@ -115,6 +118,19 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapters/opencode-local: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/db: dependencies: '@paperclipai/shared': @@ -164,6 +180,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-opencode-local': + specifier: workspace:* + version: link:../packages/adapters/opencode-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils @@ -264,6 +283,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-opencode-local': + specifier: workspace:* + version: link:../packages/adapters/opencode-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils diff --git a/server/package.json b/server/package.json index 13de3755..e3bc8cf0 100644 --- a/server/package.json +++ b/server/package.json @@ -33,6 +33,7 @@ "@aws-sdk/client-s3": "^3.888.0", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index 21d3dda6..593147ae 100644 --- a/server/src/__tests__/adapter-session-codecs.test.ts +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { sessionCodec as claudeSessionCodec } from "@paperclipai/adapter-claude-local/server"; import { sessionCodec as codexSessionCodec, isCodexUnknownSessionError } from "@paperclipai/adapter-codex-local/server"; +import { + sessionCodec as opencodeSessionCodec, + isOpenCodeUnknownSessionError, +} from "@paperclipai/adapter-opencode-local/server"; describe("adapter session codecs", () => { it("normalizes claude session params with cwd", () => { @@ -38,6 +42,24 @@ describe("adapter session codecs", () => { }); expect(codexSessionCodec.getDisplayId?.(serialized ?? null)).toBe("codex-session-1"); }); + + it("normalizes opencode session params with cwd", () => { + const parsed = opencodeSessionCodec.deserialize({ + sessionID: "opencode-session-1", + cwd: "/tmp/opencode", + }); + expect(parsed).toEqual({ + sessionId: "opencode-session-1", + cwd: "/tmp/opencode", + }); + + const serialized = opencodeSessionCodec.serialize(parsed); + expect(serialized).toEqual({ + sessionId: "opencode-session-1", + cwd: "/tmp/opencode", + }); + expect(opencodeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("opencode-session-1"); + }); }); describe("codex resume recovery detection", () => { @@ -62,3 +84,20 @@ describe("codex resume recovery detection", () => { ).toBe(false); }); }); + +describe("opencode resume recovery detection", () => { + it("detects unknown session errors from opencode output", () => { + expect( + isOpenCodeUnknownSessionError( + "", + "NotFoundError: Resource not found: /Users/test/.local/share/opencode/storage/session/proj/ses_missing.json", + ), + ).toBe(true); + expect( + isOpenCodeUnknownSessionError( + "{\"type\":\"step_finish\",\"part\":{\"reason\":\"stop\"}}", + "", + ), + ).toBe(false); + }); +}); diff --git a/server/src/__tests__/opencode-local-adapter-environment.test.ts b/server/src/__tests__/opencode-local-adapter-environment.test.ts new file mode 100644 index 00000000..f92de1de --- /dev/null +++ b/server/src/__tests__/opencode-local-adapter-environment.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { testEnvironment } from "@paperclipai/adapter-opencode-local/server"; + +describe("opencode_local environment diagnostics", () => { + it("creates a missing working directory when cwd is absolute", async () => { + const cwd = path.join( + os.tmpdir(), + `paperclip-opencode-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`, + "workspace", + ); + + await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "opencode_local", + config: { + command: process.execPath, + cwd, + }, + }); + + expect(result.checks.some((check) => check.code === "opencode_cwd_valid")).toBe(true); + expect(result.checks.some((check) => check.level === "error")).toBe(false); + const stats = await fs.stat(cwd); + expect(stats.isDirectory()).toBe(true); + await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + }); +}); diff --git a/server/src/__tests__/opencode-local-adapter.test.ts b/server/src/__tests__/opencode-local-adapter.test.ts new file mode 100644 index 00000000..e37bc1ec --- /dev/null +++ b/server/src/__tests__/opencode-local-adapter.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from "vitest"; +import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "@paperclipai/adapter-opencode-local/server"; +import { parseOpenCodeStdoutLine } from "@paperclipai/adapter-opencode-local/ui"; +import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; + +describe("opencode_local parser", () => { + it("extracts session, summary, usage, cost, and terminal error message", () => { + const stdout = [ + JSON.stringify({ type: "step_start", sessionID: "ses_123" }), + JSON.stringify({ type: "text", part: { type: "text", text: "hello" } }), + JSON.stringify({ + type: "step_finish", + part: { + reason: "tool-calls", + cost: 0.001, + tokens: { + input: 100, + output: 40, + cache: { read: 20, write: 0 }, + }, + }, + }), + JSON.stringify({ + type: "step_finish", + part: { + reason: "stop", + cost: 0.002, + tokens: { + input: 50, + output: 25, + cache: { read: 10, write: 0 }, + }, + }, + }), + JSON.stringify({ type: "error", message: "model access denied" }), + ].join("\n"); + + const parsed = parseOpenCodeJsonl(stdout); + expect(parsed.sessionId).toBe("ses_123"); + expect(parsed.summary).toBe("hello"); + expect(parsed.usage).toEqual({ + inputTokens: 150, + cachedInputTokens: 30, + outputTokens: 65, + }); + expect(parsed.costUsd).toBeCloseTo(0.003, 6); + expect(parsed.errorMessage).toBe("model access denied"); + }); +}); + +describe("opencode_local stale session detection", () => { + it("treats missing persisted session file as an unknown session error", () => { + const stderr = + "NotFoundError: Resource not found: /Users/test/.local/share/opencode/storage/session/project/ses_missing.json"; + + expect(isOpenCodeUnknownSessionError("", stderr)).toBe(true); + }); +}); + +describe("opencode_local ui stdout parser", () => { + it("parses assistant and tool lifecycle events", () => { + const ts = "2026-03-04T00:00:00.000Z"; + + expect( + parseOpenCodeStdoutLine( + JSON.stringify({ + type: "text", + part: { + type: "text", + text: "I will run a command.", + }, + }), + ts, + ), + ).toEqual([ + { + kind: "assistant", + ts, + text: "I will run a command.", + }, + ]); + + expect( + parseOpenCodeStdoutLine( + JSON.stringify({ + type: "tool_use", + part: { + id: "prt_tool_1", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: { command: "ls -1" }, + output: "AGENTS.md\nDockerfile\n", + metadata: { exit: 0 }, + }, + }, + }), + ts, + ), + ).toEqual([ + { + kind: "tool_call", + ts, + name: "bash", + input: { command: "ls -1" }, + }, + { + kind: "tool_result", + ts, + toolUseId: "call_1", + content: "status: completed\nexit: 0\n\nAGENTS.md\nDockerfile", + isError: false, + }, + ]); + }); + + it("parses finished steps into usage-aware results", () => { + const ts = "2026-03-04T00:00:00.000Z"; + expect( + parseOpenCodeStdoutLine( + JSON.stringify({ + type: "step_finish", + part: { + reason: "stop", + cost: 0.00042, + tokens: { + input: 10, + output: 5, + cache: { read: 2, write: 0 }, + }, + }, + }), + ts, + ), + ).toEqual([ + { + kind: "result", + ts, + text: "stop", + inputTokens: 10, + outputTokens: 5, + cachedTokens: 2, + costUsd: 0.00042, + subtype: "stop", + isError: false, + errors: [], + }, + ]); + }); +}); + +function stripAnsi(value: string): string { + return value.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("opencode_local cli formatter", () => { + it("prints step, assistant, tool, and result events", () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + printOpenCodeStreamEvent( + JSON.stringify({ type: "step_start", sessionID: "ses_abc" }), + false, + ); + printOpenCodeStreamEvent( + JSON.stringify({ + type: "text", + part: { type: "text", text: "hello" }, + }), + false, + ); + printOpenCodeStreamEvent( + JSON.stringify({ + type: "tool_use", + part: { + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: { command: "ls -1" }, + output: "AGENTS.md\n", + metadata: { exit: 0 }, + }, + }, + }), + false, + ); + printOpenCodeStreamEvent( + JSON.stringify({ + type: "step_finish", + part: { + reason: "stop", + cost: 0.00042, + tokens: { + input: 10, + output: 5, + cache: { read: 2, write: 0 }, + }, + }, + }), + false, + ); + + const lines = spy.mock.calls + .map((call) => call.map((v) => String(v)).join(" ")) + .map(stripAnsi); + + expect(lines).toEqual( + expect.arrayContaining([ + "step started (session: ses_abc)", + "assistant: hello", + "tool_call: bash (call_1)", + "tool_result status=completed exit=0", + "AGENTS.md", + "step finished: reason=stop", + "tokens: in=10 out=5 cached=2 cost=$0.000420", + ]), + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 33359b14..74e18098 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -11,6 +11,12 @@ import { sessionCodec as codexSessionCodec, } from "@paperclipai/adapter-codex-local/server"; import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local"; +import { + execute as opencodeExecute, + testEnvironment as opencodeTestEnvironment, + sessionCodec as opencodeSessionCodec, +} from "@paperclipai/adapter-opencode-local/server"; +import { agentConfigurationDoc as opencodeAgentConfigurationDoc, models as opencodeModels } from "@paperclipai/adapter-opencode-local"; import { execute as openclawExecute, testEnvironment as openclawTestEnvironment, @@ -44,6 +50,16 @@ const codexLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: codexAgentConfigurationDoc, }; +const opencodeLocalAdapter: ServerAdapterModule = { + type: "opencode_local", + execute: opencodeExecute, + testEnvironment: opencodeTestEnvironment, + sessionCodec: opencodeSessionCodec, + models: opencodeModels, + supportsLocalAgentJwt: true, + agentConfigurationDoc: opencodeAgentConfigurationDoc, +}; + const openclawAdapter: ServerAdapterModule = { type: "openclaw", execute: openclawExecute, @@ -54,7 +70,7 @@ const openclawAdapter: ServerAdapterModule = { }; const adaptersByType = new Map( - [claudeLocalAdapter, codexLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), + [claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), ); export function getServerAdapter(type: string): ServerAdapterModule { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 9f4fdade..9ed732b2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -36,11 +36,13 @@ import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", + opencode_local: "instructionsFilePath", }; const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); @@ -178,17 +180,21 @@ export function agentRoutes(db: Db) { adapterType: string | null | undefined, adapterConfig: Record, ): Record { - if (adapterType !== "codex_local") return adapterConfig; - const next = { ...adapterConfig }; - if (!asNonEmptyString(next.model)) { - next.model = DEFAULT_CODEX_LOCAL_MODEL; + if (adapterType === "codex_local") { + if (!asNonEmptyString(next.model)) { + next.model = DEFAULT_CODEX_LOCAL_MODEL; + } + const hasBypassFlag = + typeof next.dangerouslyBypassApprovalsAndSandbox === "boolean" || + typeof next.dangerouslyBypassSandbox === "boolean"; + if (!hasBypassFlag) { + next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + } + return next; } - const hasBypassFlag = - typeof next.dangerouslyBypassApprovalsAndSandbox === "boolean" || - typeof next.dangerouslyBypassSandbox === "boolean"; - if (!hasBypassFlag) { - next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) { + next.model = DEFAULT_OPENCODE_LOCAL_MODEL; } return next; } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 84575881..0e557f41 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -70,6 +70,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+ + ); +} diff --git a/ui/src/adapters/opencode-local/index.ts b/ui/src/adapters/opencode-local/index.ts new file mode 100644 index 00000000..b68f3ace --- /dev/null +++ b/ui/src/adapters/opencode-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseOpenCodeStdoutLine } from "@paperclipai/adapter-opencode-local/ui"; +import { OpenCodeLocalConfigFields } from "./config-fields"; +import { buildOpenCodeLocalConfig } from "@paperclipai/adapter-opencode-local/ui"; + +export const openCodeLocalUIAdapter: UIAdapterModule = { + type: "opencode_local", + label: "OpenCode (local)", + parseStdoutLine: parseOpenCodeStdoutLine, + ConfigFields: OpenCodeLocalConfigFields, + buildAdapterConfig: buildOpenCodeLocalConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 8dbe3637..6477cb72 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -1,12 +1,13 @@ import type { UIAdapterModule } from "./types"; import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; +import { openCodeLocalUIAdapter } from "./opencode-local"; import { openClawUIAdapter } from "./openclaw"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; const adaptersByType = new Map( - [claudeLocalUIAdapter, codexLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), + [claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), ); export function getUIAdapter(type: string): UIAdapterModule { diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 2ddc539b..0b92b190 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -15,6 +15,7 @@ import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { Popover, PopoverContent, @@ -130,6 +131,15 @@ const codexThinkingEffortOptions = [ { id: "high", label: "High" }, ] as const; +const opencodeVariantOptions = [ + { id: "", label: "Auto" }, + { id: "minimal", label: "Minimal" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max", label: "Max" }, +] as const; + const claudeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "low", label: "Low" }, @@ -254,7 +264,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const isLocal = adapterType === "claude_local" || adapterType === "codex_local"; + const isLocal = + adapterType === "claude_local" || + adapterType === "codex_local" || + adapterType === "opencode_local"; const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); // Fetch adapter models for the effective adapter type @@ -313,9 +326,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? val!.model : eff("adapterConfig", "model", String(config.model ?? "")); - const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : "effort"; + const thinkingEffortKey = + adapterType === "codex_local" + ? "modelReasoningEffort" + : adapterType === "opencode_local" + ? "variant" + : "effort"; const thinkingEffortOptions = - adapterType === "codex_local" ? codexThinkingEffortOptions : claudeThinkingEffortOptions; + adapterType === "codex_local" + ? codexThinkingEffortOptions + : adapterType === "opencode_local" + ? opencodeVariantOptions + : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" @@ -324,6 +346,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { "modelReasoningEffort", String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""), ) + : adapterType === "opencode_local" + ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", "effort", String(config.effort ?? "")); const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) @@ -442,6 +466,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; nextValues.dangerouslyBypassSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + } else if (t === "opencode_local") { + nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL; } set!(nextValues); } else { @@ -451,9 +477,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ...prev, adapterType: t, adapterConfig: { - model: t === "codex_local" ? DEFAULT_CODEX_LOCAL_MODEL : "", + model: + t === "codex_local" + ? DEFAULT_CODEX_LOCAL_MODEL + : t === "opencode_local" + ? DEFAULT_OPENCODE_LOCAL_MODEL + : "", effort: "", modelReasoningEffort: "", + variant: "", ...(t === "codex_local" ? { dangerouslyBypassApprovalsAndSandbox: @@ -549,7 +581,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } immediate className={inputClass} - placeholder={adapterType === "codex_local" ? "codex" : "claude"} + placeholder={ + adapterType === "codex_local" + ? "codex" + : adapterType === "opencode_local" + ? "opencode" + : "claude" + } /> @@ -817,7 +855,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe /* ---- Internal sub-components ---- */ -const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]); +const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]); /** Display list includes all real adapter types plus UI-only coming-soon entries. */ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index e4f7f01b..2c6f1a37 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -17,6 +17,7 @@ interface AgentPropertiesProps { const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", + opencode_local: "OpenCode (local)", openclaw: "OpenClaw", cursor: "Cursor", process: "Process", diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 237b7c5f..1b07385f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -67,7 +67,7 @@ interface IssueDraft { assigneeUseProjectWorkspace: boolean; } -const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]); +const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]); const ISSUE_THINKING_EFFORT_OPTIONS = { claude_local: [ @@ -83,6 +83,14 @@ const ISSUE_THINKING_EFFORT_OPTIONS = { { value: "medium", label: "Medium" }, { value: "high", label: "High" }, ], + opencode_local: [ + { value: "", label: "Default" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "max", label: "Max" }, + ], } as const; function buildAssigneeAdapterOverrides(input: { @@ -102,6 +110,8 @@ function buildAssigneeAdapterOverrides(input: { if (input.thinkingEffortOverride) { if (adapterType === "codex_local") { adapterConfig.modelReasoningEffort = input.thinkingEffortOverride; + } else if (adapterType === "opencode_local") { + adapterConfig.variant = input.thinkingEffortOverride; } else if (adapterType === "claude_local") { adapterConfig.effort = input.thinkingEffortOverride; } @@ -351,6 +361,8 @@ export function NewIssueDialog() { const validThinkingValues = assigneeAdapterType === "codex_local" ? ISSUE_THINKING_EFFORT_OPTIONS.codex_local + : assigneeAdapterType === "opencode_local" + ? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) { setAssigneeThinkingEffort(""); @@ -451,10 +463,14 @@ export function NewIssueDialog() { ? "Claude options" : assigneeAdapterType === "codex_local" ? "Codex options" + : assigneeAdapterType === "opencode_local" + ? "OpenCode options" : "Agent options"; const thinkingEffortOptions = assigneeAdapterType === "codex_local" ? ISSUE_THINKING_EFFORT_OPTIONS.codex_local + : assigneeAdapterType === "opencode_local" + ? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; const assigneeOptions = useMemo( () => diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index b12c0739..a299bb9e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -23,6 +23,7 @@ import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { ChoosePathButton } from "./PathInstructionsModal"; import { HintIcon } from "./agent-config-primitives"; @@ -49,6 +50,7 @@ type Step = 1 | 2 | 3 | 4; type AdapterType = | "claude_local" | "codex_local" + | "opencode_local" | "process" | "http" | "openclaw"; @@ -151,9 +153,14 @@ export function OnboardingWizard() { enabled: onboardingOpen && step === 2 }); const isLocalAdapter = - adapterType === "claude_local" || adapterType === "codex_local"; + adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local"; const effectiveAdapterCommand = - command.trim() || (adapterType === "codex_local" ? "codex" : "claude"); + command.trim() || + (adapterType === "codex_local" + ? "codex" + : adapterType === "opencode_local" + ? "opencode" + : "claude"); useEffect(() => { if (step !== 2) return; @@ -212,6 +219,8 @@ export function OnboardingWizard() { model: adapterType === "codex_local" ? model || DEFAULT_CODEX_LOCAL_MODEL + : adapterType === "opencode_local" + ? model || DEFAULT_OPENCODE_LOCAL_MODEL : model, command, args, @@ -570,6 +579,12 @@ export function OnboardingWizard() { icon: Code, desc: "Local Codex agent" }, + { + value: "opencode_local" as const, + label: "OpenCode", + icon: Code, + desc: "Local OpenCode agent" + }, { value: "openclaw" as const, label: "OpenClaw", @@ -616,6 +631,8 @@ export function OnboardingWizard() { setAdapterType(nextType); if (nextType === "codex_local" && !model) { setModel(DEFAULT_CODEX_LOCAL_MODEL); + } else if (nextType === "opencode_local" && !model) { + setModel(DEFAULT_OPENCODE_LOCAL_MODEL); } }} > @@ -631,7 +648,8 @@ export function OnboardingWizard() { {/* Conditional adapter fields */} {(adapterType === "claude_local" || - adapterType === "codex_local") && ( + adapterType === "codex_local" || + adapterType === "opencode_local") && (
@@ -766,18 +784,22 @@ export function OnboardingWizard() {

{adapterType === "codex_local" ? `${effectiveAdapterCommand} exec --json -` + : adapterType === "opencode_local" + ? `${effectiveAdapterCommand} run --format json \"Respond with hello.\"` : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}

Prompt:{" "} Respond with hello.

- {adapterType === "codex_local" ? ( + {adapterType === "codex_local" || adapterType === "opencode_local" ? (

If auth fails, set{" "} OPENAI_API_KEY in env or run{" "} - codex login. + + {adapterType === "codex_local" ? "codex login" : "opencode auth login"} + .

) : (

diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index c56ebf7a..3df4deed 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -52,6 +52,7 @@ export const help: Record = { export const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", + opencode_local: "OpenCode (local)", openclaw: "OpenClaw", cursor: "Cursor", process: "Process", diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 801d4837..d9b98d77 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -23,6 +23,7 @@ import type { Agent } from "@paperclipai/shared"; const adapterLabels: Record = { claude_local: "Claude", codex_local: "Codex", + opencode_local: "OpenCode", openclaw: "OpenClaw", process: "Process", http: "HTTP", diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 10be6787..aac26562 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -18,13 +18,14 @@ const joinAdapterOptions: AgentAdapterType[] = [ const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", + opencode_local: "OpenCode (local)", openclaw: "OpenClaw", cursor: "Cursor", process: "Process", http: "HTTP", }; -const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local"]); +const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local"]); function dateTime(value: string) { return new Date(value).toLocaleString(); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index bc9edb65..77f2aa08 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -118,6 +118,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L const adapterLabels: Record = { claude_local: "Claude", codex_local: "Codex", + opencode_local: "OpenCode", openclaw: "OpenClaw", process: "Process", http: "HTTP",