From 6a101e0da11868b4771cb73e2d6dc181407a9338 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 15:24:20 +0100 Subject: [PATCH] Add OpenCode provider integration and strict model selection --- cli/package.json | 1 + cli/src/adapters/registry.ts | 8 +- docs/adapters/overview.md | 4 +- docs/api/agents.md | 12 + docs/guides/board-operator/managing-agents.md | 8 + packages/adapter-utils/package.json | 1 + packages/adapters/claude-local/package.json | 1 + packages/adapters/codex-local/package.json | 1 + packages/adapters/openclaw/package.json | 1 + packages/adapters/opencode-local/CHANGELOG.md | 7 + packages/adapters/opencode-local/package.json | 50 +++ .../opencode-local/src/cli/format-event.ts | 109 ++++++ .../adapters/opencode-local/src/cli/index.ts | 1 + packages/adapters/opencode-local/src/index.ts | 28 ++ .../opencode-local/src/server/execute.ts | 311 ++++++++++++++++++ .../opencode-local/src/server/index.ts | 61 ++++ .../opencode-local/src/server/models.test.ts | 33 ++ .../opencode-local/src/server/models.ts | 176 ++++++++++ .../opencode-local/src/server/parse.test.ts | 50 +++ .../opencode-local/src/server/parse.ts | 93 ++++++ .../opencode-local/src/server/test.ts | 236 +++++++++++++ .../opencode-local/src/ui/build-config.ts | 73 ++++ .../adapters/opencode-local/src/ui/index.ts | 2 + .../opencode-local/src/ui/parse-stdout.ts | 135 ++++++++ .../adapters/opencode-local/tsconfig.json | 8 + .../adapters/opencode-local/vitest.config.ts | 7 + packages/db/package.json | 1 + packages/shared/src/constants.ts | 9 +- pnpm-lock.yaml | 166 +++++++++- scripts/generate-npm-package-json.mjs | 1 + scripts/release.sh | 5 +- server/package.json | 3 + server/src/__tests__/adapter-models.test.ts | 10 + server/src/adapters/registry.ts | 25 +- server/src/routes/agents.ts | 53 ++- ui/package.json | 1 + .../brands/opencode-logo-dark-square.svg | 18 + .../brands/opencode-logo-light-square.svg | 18 + .../adapters/opencode-local/config-fields.tsx | 47 +++ ui/src/adapters/opencode-local/index.ts | 12 + ui/src/adapters/registry.ts | 3 +- ui/src/api/agents.ts | 3 +- ui/src/components/AgentConfigForm.tsx | 183 +++++++++-- ui/src/components/AgentProperties.tsx | 1 + ui/src/components/NewAgentDialog.tsx | 49 ++- ui/src/components/NewIssueDialog.tsx | 56 +++- ui/src/components/OnboardingWizard.tsx | 204 ++++++++++-- ui/src/components/OpenCodeLogoIcon.tsx | 22 ++ ui/src/components/agent-config-primitives.tsx | 5 +- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/AgentDetail.tsx | 8 +- ui/src/pages/Agents.tsx | 1 + ui/src/pages/InviteLanding.tsx | 3 +- ui/src/pages/OrgChart.tsx | 1 + vitest.config.ts | 2 +- 55 files changed, 2225 insertions(+), 104 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/models.test.ts create mode 100644 packages/adapters/opencode-local/src/server/models.ts create mode 100644 packages/adapters/opencode-local/src/server/parse.test.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 packages/adapters/opencode-local/vitest.config.ts create mode 100644 ui/public/brands/opencode-logo-dark-square.svg create mode 100644 ui/public/brands/opencode-logo-light-square.svg create mode 100644 ui/src/adapters/opencode-local/config-fields.tsx create mode 100644 ui/src/adapters/opencode-local/index.ts create mode 100644 ui/src/components/OpenCodeLogoIcon.tsx diff --git a/cli/package.json b/cli/package.json index 3493ed17..f42751b0 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..a0cd553d 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/docs/adapters/overview.md b/docs/adapters/overview.md index 0d3ccabf..4237f87f 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip: |---------|----------|-------------| | [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | +| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | @@ -52,7 +54,7 @@ Three registries consume these modules: ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local` or `codex_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local` - **Need to run a script or command?** Use `process` - **Need to call an external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) diff --git a/docs/api/agents.md b/docs/api/agents.md index 371d2563..143cbc5f 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org Returns the full organizational tree for the company. +## List Adapter Models + +``` +GET /api/companies/{companyId}/adapters/{adapterType}/models +``` + +Returns selectable models for an adapter type. + +- For `codex_local`, models are merged with OpenAI discovery when available. +- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format. +- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty. + ## Config Revisions ``` diff --git a/docs/guides/board-operator/managing-agents.md b/docs/guides/board-operator/managing-agents.md index 4154ee3a..453b967f 100644 --- a/docs/guides/board-operator/managing-agents.md +++ b/docs/guides/board-operator/managing-agents.md @@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires: - **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.) - **Capabilities** — short description of what this agent does +Common adapter choices: +- `claude_local` / `codex_local` / `opencode_local` for local coding agents +- `openclaw` / `http` for webhook-based external agents +- `process` for generic local command execution + +For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`). +Paperclip validates the selected model against live `opencode models` output. + ## Agent Hiring via Governance Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject. diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 20c71067..80ab2874 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -30,6 +30,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@types/node": "^22.12.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index e7531acf..f511e76f 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^22.12.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index dfad0336..194e346f 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^22.12.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/openclaw/package.json b/packages/adapters/openclaw/package.json index 27f0f1b6..84e5ae9c 100644 --- a/packages/adapters/openclaw/package.json +++ b/packages/adapters/openclaw/package.json @@ -44,6 +44,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^22.12.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md new file mode 100644 index 00000000..7661493b --- /dev/null +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -0,0 +1,7 @@ +# @paperclipai/adapter-opencode-local + +## 0.2.5 + +### Patch Changes + +- Add local OpenCode adapter package with server/UI/CLI modules. diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json new file mode 100644 index 00000000..398acf4b --- /dev/null +++ b/packages/adapters/opencode-local/package.json @@ -0,0 +1,50 @@ +{ + "name": "@paperclipai/adapter-opencode-local", + "version": "0.2.5", + "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" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "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..1af47367 --- /dev/null +++ b/packages/adapters/opencode-local/src/cli/format-event.ts @@ -0,0 +1,109 @@ +import pc from "picocolors"; + +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 errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const data = asRecord(rec.data); + const message = + asString(rec.message) || + asString(data?.message) || + asString(rec.name) || + ""; + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + console.log(line); + return; + } + + const type = asString(parsed.type); + + if (type === "text") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (text) console.log(pc.green(`assistant: ${text}`)); + return; + } + + if (type === "reasoning") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "tool_use") { + const part = asRecord(parsed.part); + const tool = asString(part?.tool, "tool"); + const state = asRecord(part?.state); + const status = asString(state?.status); + const summary = `tool_${status || "event"}: ${tool}`; + const isError = status === "error"; + console.log((isError ? pc.red : pc.yellow)(summary)); + const input = state?.input; + if (input !== undefined) { + try { + console.log(pc.gray(JSON.stringify(input, null, 2))); + } catch { + console.log(pc.gray(String(input))); + } + } + const output = asString(state?.output) || asString(state?.error); + if (output) console.log((isError ? pc.red : pc.gray)(output)); + return; + } + + if (type === "step_finish") { + const part = asRecord(parsed.part); + const tokens = asRecord(part?.tokens); + const cache = asRecord(tokens?.cache); + const input = asNumber(tokens?.input, 0); + const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0); + const cached = asNumber(cache?.read, 0); + const cost = asNumber(part?.cost, 0); + const reason = asString(part?.reason, "step"); + console.log(pc.blue(`step finished (${reason}) tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); + return; + } + + if (type === "error") { + const message = errorText(parsed.error ?? parsed.message); + if (message) 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..7a21a455 --- /dev/null +++ b/packages/adapters/opencode-local/src/index.ts @@ -0,0 +1,28 @@ +export const type = "opencode_local"; +export const label = "OpenCode (local)"; + +export const models: Array<{ id: string; label: string }> = []; + +export const agentConfigurationDoc = `# opencode_local agent configuration + +Adapter: opencode_local + +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, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5) +- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max) +- 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: +- OpenCode supports multiple providers and models. Use \ + \`opencode models\` to list available options in provider/model format. +- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents. +`; 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..0d454bfc --- /dev/null +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -0,0 +1,311 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +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 { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function parseModelProvider(model: string | null): string | null { + if (!model) return null; + const trimmed = model.trim(); + if (!trimmed.includes("/")) return null; + return trimmed.slice(0, trimmed.indexOf("/")).trim() || null; +} + +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, "").trim(); + const variant = asString(config.variant, "").trim(); + + 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 }); + + 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 [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + await ensureCommandResolvable(command, cwd, runtimeEnv); + + await ensureOpenCodeModelConfiguredAndAvailable({ + model, + command, + cwd, + env, + }); + + 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 stdin 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); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "opencode_local", + command, + cwd, + commandNotes, + commandArgs: [...args, ``], + env: redactEnvForLogs(env), + prompt, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env, + stdin: prompt, + timeoutSec, + graceSec, + onLog, + }); + return { + proc, + rawStderr: proc.stderr, + parsed: parseOpenCodeJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; + rawStderr: 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 rawExitCode = attempt.proc.exitCode; + const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; + const fallbackErrorMessage = + parsedError || + stderrLine || + `OpenCode exited with code ${synthesizedExitCode ?? -1}`; + const modelId = model || null; + + return { + exitCode: synthesizedExitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + usage: { + inputTokens: attempt.parsed.usage.inputTokens, + outputTokens: attempt.parsed.usage.outputTokens, + cachedInputTokens: attempt.parsed.usage.cachedInputTokens, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: parseModelProvider(modelId), + model: modelId, + billingType: "unknown", + costUsd: attempt.parsed.usage.costUsd, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); + if ( + sessionId && + initialFailed && + isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stderr", + `[paperclip] OpenCode 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..3fc48655 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/index.ts @@ -0,0 +1,61 @@ +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); + 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); + 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); + }, +}; + +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { + listOpenCodeModels, + discoverOpenCodeModels, + ensureOpenCodeModelConfiguredAndAvailable, + resetOpenCodeModelsCacheForTests, +} from "./models.js"; +export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts new file mode 100644 index 00000000..cd49e4a2 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/models.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureOpenCodeModelConfiguredAndAvailable, + listOpenCodeModels, + resetOpenCodeModelsCacheForTests, +} from "./models.js"; + +describe("openCode models", () => { + afterEach(() => { + delete process.env.PAPERCLIP_OPENCODE_COMMAND; + resetOpenCodeModelsCacheForTests(); + }); + + it("returns an empty list when discovery command is unavailable", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + await expect(listOpenCodeModels()).resolves.toEqual([]); + }); + + it("rejects when model is missing", async () => { + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ model: "" }), + ).rejects.toThrow("OpenCode requires `adapterConfig.model`"); + }); + + it("rejects when discovery cannot run for configured model", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "openai/gpt-5", + }), + ).rejects.toThrow("Failed to start command"); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts new file mode 100644 index 00000000..297527c4 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -0,0 +1,176 @@ +import type { AdapterModel } from "@paperclipai/adapter-utils"; +import { + asString, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; + +const MODELS_CACHE_TTL_MS = 60_000; + +const discoveryCache = new Map(); + +function dedupeModels(models: AdapterModel[]): AdapterModel[] { + const seen = new Set(); + const deduped: AdapterModel[] = []; + for (const model of models) { + const id = model.id.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push({ id, label: model.label.trim() || id }); + } + return deduped; +} + +function sortModels(models: AdapterModel[]): AdapterModel[] { + return [...models].sort((a, b) => + a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }), + ); +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function parseModelsOutput(stdout: string): AdapterModel[] { + const parsed: AdapterModel[] = []; + for (const raw of stdout.split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + const firstToken = line.split(/\s+/)[0]?.trim() ?? ""; + if (!firstToken.includes("/")) continue; + const provider = firstToken.slice(0, firstToken.indexOf("/")).trim(); + const model = firstToken.slice(firstToken.indexOf("/") + 1).trim(); + if (!provider || !model) continue; + parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` }); + } + return dedupeModels(parsed); +} + +function normalizeEnv(input: unknown): Record { + const envInput = typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Record) + : {}; + const env: Record = {}; + for (const [key, value] of Object.entries(envInput)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +function discoveryCacheKey(command: string, cwd: string, env: Record) { + const envKey = Object.entries(env) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + return `${command}\n${cwd}\n${envKey}`; +} + +export async function discoverOpenCodeModels(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +} = {}): Promise { + const command = asString( + input.command, + (typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" && + process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0 + ? process.env.PAPERCLIP_OPENCODE_COMMAND.trim() + : "opencode"), + ); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + + const result = await runChildProcess( + `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + ["models"], + { + cwd, + env, + timeoutSec: 20, + graceSec: 3, + onLog: async () => {}, + }, + ); + + if (result.timedOut) { + throw new Error("`opencode models` timed out."); + } + if ((result.exitCode ?? 1) !== 0) { + const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); + throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed."); + } + + return sortModels(parseModelsOutput(result.stdout)); +} + +export async function discoverOpenCodeModelsCached(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +} = {}): Promise { + const command = asString( + input.command, + (typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" && + process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0 + ? process.env.PAPERCLIP_OPENCODE_COMMAND.trim() + : "opencode"), + ); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + const key = discoveryCacheKey(command, cwd, env); + const now = Date.now(); + const cached = discoveryCache.get(key); + if (cached && cached.expiresAt > now) return cached.models; + + const models = await discoverOpenCodeModels({ command, cwd, env }); + discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models }); + return models; +} + +export async function ensureOpenCodeModelConfiguredAndAvailable(input: { + model?: unknown; + command?: unknown; + cwd?: unknown; + env?: unknown; +}): Promise { + const model = asString(input.model, "").trim(); + if (!model) { + throw new Error("OpenCode requires `adapterConfig.model` in provider/model format."); + } + + const models = await discoverOpenCodeModelsCached({ + command: input.command, + cwd: input.cwd, + env: input.env, + }); + + if (models.length === 0) { + throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth."); + } + + if (!models.some((entry) => entry.id === model)) { + const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); + throw new Error( + `Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, + ); + } + + return models; +} + +export async function listOpenCodeModels(): Promise { + try { + return await discoverOpenCodeModelsCached(); + } catch { + return []; + } +} + +export function resetOpenCodeModelsCacheForTests() { + discoveryCache.clear(); +} diff --git a/packages/adapters/opencode-local/src/server/parse.test.ts b/packages/adapters/opencode-local/src/server/parse.test.ts new file mode 100644 index 00000000..af0e264d --- /dev/null +++ b/packages/adapters/opencode-local/src/server/parse.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; + +describe("parseOpenCodeJsonl", () => { + it("parses assistant text, usage, cost, and errors", () => { + const stdout = [ + JSON.stringify({ + type: "text", + sessionID: "session_123", + part: { text: "Hello from OpenCode" }, + }), + JSON.stringify({ + type: "step_finish", + sessionID: "session_123", + part: { + reason: "done", + cost: 0.0025, + tokens: { + input: 120, + output: 40, + reasoning: 10, + cache: { read: 20, write: 0 }, + }, + }, + }), + JSON.stringify({ + type: "error", + sessionID: "session_123", + error: { message: "model unavailable" }, + }), + ].join("\n"); + + const parsed = parseOpenCodeJsonl(stdout); + expect(parsed.sessionId).toBe("session_123"); + expect(parsed.summary).toBe("Hello from OpenCode"); + expect(parsed.usage).toEqual({ + inputTokens: 120, + cachedInputTokens: 20, + outputTokens: 50, + costUsd: 0.0025, + }); + expect(parsed.errorMessage).toContain("model unavailable"); + }); + + it("detects unknown session errors", () => { + expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true); + expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true); + expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false); + }); +}); 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..09cc05ff --- /dev/null +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -0,0 +1,93 @@ +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = asString(rec.message, "").trim(); + if (message) return message; + const data = parseObject(rec.data); + const nestedMessage = asString(data.message, "").trim(); + if (nestedMessage) return nestedMessage; + const name = asString(rec.name, "").trim(); + if (name) return name; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function parseOpenCodeJsonl(stdout: string) { + let sessionId: string | null = null; + const messages: string[] = []; + const errors: string[] = []; + const usage = { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + costUsd: 0, + }; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const currentSessionId = asString(event.sessionID, "").trim(); + if (currentSessionId) sessionId = currentSessionId; + + 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) + asNumber(tokens.reasoning, 0); + usage.costUsd += asNumber(part.cost, 0); + continue; + } + + if (type === "tool_use") { + const part = parseObject(event.part); + const state = parseObject(part.state); + if (asString(state.status, "") === "error") { + const text = asString(state.error, "").trim(); + if (text) errors.push(text); + } + continue; + } + + if (type === "error") { + const text = errorText(event.error ?? event.message).trim(); + if (text) errors.push(text); + continue; + } + } + + return { + sessionId, + summary: messages.join("\n\n").trim(), + usage, + errorMessage: errors.length > 0 ? errors.join("\n") : null, + }; +} + +export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}`.toLowerCase(); + return ( + haystack.includes("session not found") || + haystack.includes("unknown session") || + haystack.includes("no session") + ); +} 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..f45dd6b1 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -0,0 +1,236 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asString, + asStringArray, + parseObject, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.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 firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +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 = + /(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/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 canRunProbe = + checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); + + let discoveredModels: string[] = []; + let modelValidationPassed = false; + if (canRunProbe) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env }); + discoveredModels = discovered.map((item) => item.id); + if (discoveredModels.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discoveredModels.length} model(s) from OpenCode providers.`, + }); + } else { + checks.push({ + code: "opencode_models_empty", + level: "error", + message: "OpenCode returned no models.", + hint: "Run `opencode models` and verify provider authentication.", + }); + } + } catch (err) { + checks.push({ + code: "opencode_models_discovery_failed", + level: "error", + message: err instanceof Error ? err.message : "OpenCode model discovery failed.", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } + } + + const configuredModel = asString(config.model, "").trim(); + if (!configuredModel) { + checks.push({ + code: "opencode_model_required", + level: "error", + message: "OpenCode requires a configured model in provider/model format.", + hint: "Set adapterConfig.model using an ID from `opencode models`.", + }); + } else if (canRunProbe) { + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: configuredModel, + command, + cwd, + env, + }); + checks.push({ + code: "opencode_model_configured", + level: "info", + message: `Configured model: ${configuredModel}`, + }); + modelValidationPassed = true; + } catch (err) { + checks.push({ + code: "opencode_model_invalid", + level: "error", + message: err instanceof Error ? err.message : "Configured model is unavailable.", + hint: "Run `opencode models` and choose a currently available provider/model ID.", + }); + } + } + + if (canRunProbe && modelValidationPassed) { + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + const variant = asString(config.variant, "").trim(); + const probeModel = configuredModel; + + const args = ["run", "--format", "json"]; + args.push("--model", probeModel); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + + const probe = await runChildProcess( + `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 60, + graceSec: 5, + stdin: "Respond with hello.", + 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, run OpenCode manually in this working directory.", + }); + } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { + 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: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", + }), + }); + } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_auth_required", + level: "warn", + message: "OpenCode is installed, but provider authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `opencode auth login` or set provider credentials, 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` 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..f656585e --- /dev/null +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -0,0 +1,73 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +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; + if (v.model) ac.model = v.model; + if (v.thinkingEffort) ac.variant = v.thinkingEffort; + ac.timeoutSec = 0; + ac.graceSec = 20; + 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..d8d2f03f --- /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 errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const data = asRecord(rec.data); + const msg = + asString(rec.message) || + asString(data?.message) || + asString(rec.name) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function parseToolUse(parsed: Record, ts: string): TranscriptEntry[] { + const part = asRecord(parsed.part); + if (!part) return [{ kind: "system", ts, text: "tool event" }]; + + const toolName = asString(part.tool, "tool"); + const state = asRecord(part.state); + const input = state?.input ?? {}; + const callEntry: TranscriptEntry = { + kind: "tool_call", + ts, + name: toolName, + input, + }; + + const status = asString(state?.status); + if (status !== "completed" && status !== "error") return [callEntry]; + + const output = + asString(state?.output) || + asString(state?.error) || + asString(part.title) || + `${toolName} ${status}`; + + return [ + callEntry, + { + kind: "tool_result", + ts, + toolUseId: asString(part.id, toolName), + content: output, + isError: status === "error", + }, + ]; +} + +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 === "text") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (!text) return []; + return [{ kind: "assistant", ts, text }]; + } + + if (type === "reasoning") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (!text) return []; + return [{ kind: "thinking", ts, text }]; + } + + if (type === "tool_use") { + return parseToolUse(parsed, ts); + } + + if (type === "step_start") { + return [{ kind: "system", ts, text: "step started" }]; + } + + 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"); + const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0); + return [ + { + kind: "result", + ts, + text: reason, + inputTokens: asNumber(tokens?.input, 0), + outputTokens: output, + cachedTokens: asNumber(cache?.read, 0), + costUsd: asNumber(part?.cost, 0), + subtype: reason, + isError: false, + errors: [], + }, + ]; + } + + if (type === "error") { + const text = errorText(parsed.error ?? parsed.message); + return [{ kind: "stderr", ts, text: text || line }]; + } + + 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/adapters/opencode-local/vitest.config.ts b/packages/adapters/opencode-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/opencode-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/db/package.json b/packages/db/package.json index 6cf2dc30..1d38bc05 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -38,6 +38,7 @@ "postgres": "^3.4.5" }, "devDependencies": { + "@types/node": "^22.12.0", "drizzle-kit": "^0.31.9", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0b3490ab..7a6abd12 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -21,7 +21,14 @@ 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 304bb691..ae7e8285 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 @@ -72,6 +75,9 @@ importers: packages/adapter-utils: devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -85,6 +91,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -98,6 +107,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -111,6 +123,25 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 + typescript: + 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: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -127,6 +158,9 @@ importers: specifier: ^3.4.5 version: 3.4.8 devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 drizzle-kit: specifier: ^0.31.9 version: 0.31.9 @@ -138,7 +172,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) packages/shared: dependencies: @@ -164,6 +198,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 @@ -175,7 +212,7 @@ importers: version: link:../packages/shared better-auth: specifier: ^1.3.8 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) detect-port: specifier: ^2.1.0 version: 2.1.0 @@ -223,9 +260,15 @@ importers: '@types/multer': specifier: ^2.0.0 version: 2.0.0 + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + '@types/ws': + specifier: ^8.5.14 + version: 8.18.1 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -237,10 +280,10 @@ importers: version: 5.9.3 vite: specifier: ^6.1.0 - version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -265,6 +308,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 @@ -2808,6 +2854,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -8192,6 +8241,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.2.3 + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': @@ -8214,6 +8267,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 @@ -8298,7 +8359,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -8318,7 +8379,7 @@ snapshots: pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) better-call@1.1.8(zod@4.3.6): dependencies: @@ -10571,6 +10632,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: cac: 6.7.14 @@ -10592,6 +10674,21 @@ snapshots: - tsx - yaml + vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.25.12 @@ -10607,6 +10704,21 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 + vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.3 @@ -10622,6 +10734,48 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.19.11 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index 705c58d1..635a3e15 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -32,6 +32,7 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", + "packages/adapters/opencode-local", "packages/adapters/openclaw", ]; diff --git a/scripts/release.sh b/scripts/release.sh index ab4d8fe2..1da537e7 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -74,7 +74,7 @@ const { resolve } = require('path'); const root = '$REPO_ROOT'; const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -131,6 +131,7 @@ pnpm --filter @paperclipai/adapter-utils build pnpm --filter @paperclipai/db build pnpm --filter @paperclipai/adapter-claude-local build pnpm --filter @paperclipai/adapter-codex-local build +pnpm --filter @paperclipai/adapter-opencode-local build pnpm --filter @paperclipai/adapter-openclaw build pnpm --filter @paperclipai/server build @@ -162,7 +163,7 @@ if [ "$dry_run" = true ]; then echo "" echo " Preview what would be published:" for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/openclaw \ + packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \ server cli; do echo " --- $dir ---" cd "$REPO_ROOT/$dir" diff --git a/server/package.json b/server/package.json index df87aca2..31c55dc4 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:*", @@ -57,7 +58,9 @@ "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.0.0", "@types/multer": "^2.0.0", + "@types/node": "^22.12.0", "@types/supertest": "^6.0.2", + "@types/ws": "^8.5.14", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index f97fc48d..56500a59 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -1,12 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"; +import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server"; import { listAdapterModels } from "../adapters/index.js"; import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js"; describe("adapter model listing", () => { beforeEach(() => { delete process.env.OPENAI_API_KEY; + delete process.env.PAPERCLIP_OPENCODE_COMMAND; resetCodexModelsCacheForTests(); + resetOpenCodeModelsCacheForTests(); vi.restoreAllMocks(); }); @@ -55,4 +58,11 @@ describe("adapter model listing", () => { const models = await listAdapterModels("codex_local"); expect(models).toEqual(codexFallbackModels); }); + + it("returns no opencode models when opencode command is unavailable", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + + const models = await listAdapterModels("opencode_local"); + expect(models).toEqual([]); + }); }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 33359b14..b722000c 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -19,6 +19,16 @@ import { agentConfigurationDoc as openclawAgentConfigurationDoc, models as openclawModels, } from "@paperclipai/adapter-openclaw"; +import { + execute as openCodeExecute, + testEnvironment as openCodeTestEnvironment, + sessionCodec as openCodeSessionCodec, + listOpenCodeModels, +} from "@paperclipai/adapter-opencode-local/server"; +import { + agentConfigurationDoc as openCodeAgentConfigurationDoc, + models as openCodeModels, +} from "@paperclipai/adapter-opencode-local"; import { listCodexModels } from "./codex-models.js"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -53,8 +63,21 @@ const openclawAdapter: ServerAdapterModule = { agentConfigurationDoc: openclawAgentConfigurationDoc, }; +const openCodeLocalAdapter: ServerAdapterModule = { + type: "opencode_local", + execute: openCodeExecute, + testEnvironment: openCodeTestEnvironment, + sessionCodec: openCodeSessionCodec, + models: openCodeModels, + listModels: listOpenCodeModels, + supportsLocalAgentJwt: true, + agentConfigurationDoc: openCodeAgentConfigurationDoc, +}; + 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..73fb7be2 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 { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; 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"]); @@ -193,6 +195,27 @@ export function agentRoutes(db: Db) { return next; } + async function assertAdapterConfigConstraints( + companyId: string, + adapterType: string | null | undefined, + adapterConfig: Record, + ) { + if (adapterType !== "opencode_local") return; + const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig); + const runtimeEnv = asRecord(runtimeConfig.env) ?? {}; + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: runtimeConfig.model, + command: runtimeConfig.command, + cwd: runtimeConfig.cwd, + env: runtimeEnv, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); + } + } + function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record) { const trimmed = candidatePath.trim(); if (path.isAbsolute(trimmed)) return trimmed; @@ -324,7 +347,9 @@ export function agentRoutes(db: Db) { } }); - router.get("/adapters/:type/models", async (req, res) => { + router.get("/companies/:companyId/adapters/:type/models", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const type = req.params.type as string; const models = await listAdapterModels(type); res.json(models); @@ -578,6 +603,11 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, { strictMode: strictSecretsMode }, ); + await assertAdapterConfigConstraints( + companyId, + hireInput.adapterType, + normalizedAdapterConfig, + ); const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, @@ -713,6 +743,11 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, { strictMode: strictSecretsMode }, ); + await assertAdapterConfigConstraints( + companyId, + req.body.adapterType, + normalizedAdapterConfig, + ); const agent = await svc.create(companyId, { ...req.body, @@ -892,6 +927,22 @@ export function agentRoutes(db: Db) { ); } + const requestedAdapterType = + typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType; + const touchesAdapterConfiguration = + Object.prototype.hasOwnProperty.call(patchData, "adapterType") || + Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); + if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + const effectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + ? (asRecord(patchData.adapterConfig) ?? {}) + : (asRecord(existing.adapterConfig) ?? {}); + await assertAdapterConfigConstraints( + existing.companyId, + requestedAdapterType, + effectiveAdapterConfig, + ); + } + const actor = getActorInfo(req); const agent = await svc.update(id, patchData, { recordRevision: { diff --git a/ui/package.json b/ui/package.json index 586d37cc..1f5302ac 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,6 +16,7 @@ "@mdxeditor/editor": "^3.52.4", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/ui/public/brands/opencode-logo-dark-square.svg b/ui/public/brands/opencode-logo-dark-square.svg new file mode 100644 index 00000000..6a67f627 --- /dev/null +++ b/ui/public/brands/opencode-logo-dark-square.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/public/brands/opencode-logo-light-square.svg b/ui/public/brands/opencode-logo-light-square.svg new file mode 100644 index 00000000..a738ad87 --- /dev/null +++ b/ui/public/brands/opencode-logo-light-square.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/src/adapters/opencode-local/config-fields.tsx b/ui/src/adapters/opencode-local/config-fields.tsx new file mode 100644 index 00000000..043e91c1 --- /dev/null +++ b/ui/src/adapters/opencode-local/config-fields.tsx @@ -0,0 +1,47 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + +export function OpenCodeLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + return ( + +
+ + 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/api/agents.ts b/ui/src/api/agents.ts index 0b91f694..4bcfc305 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -117,7 +117,8 @@ export const agentsApi = { api.get(agentPath(id, companyId, "/task-sessions")), resetSession: (id: string, taskKey?: string | null, companyId?: string) => api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), - adapterModels: (type: string) => api.get(`/adapters/${type}/models`), + adapterModels: (companyId: string, type: string) => + api.get(`/companies/${companyId}/adapters/${type}/models`), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 2ddc539b..3d36febc 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -40,6 +40,7 @@ import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; +import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; /* ---- Create mode values ---- */ @@ -122,6 +123,19 @@ function formatArgList(value: unknown): string { return typeof value === "string" ? value : ""; } +function extractProviderId(modelId: string): string | null { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return null; + const provider = trimmed.slice(0, trimmed.indexOf("/")).trim(); + return provider || null; +} + +function extractModelName(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return trimmed; + return trimmed.slice(trimmed.indexOf("/") + 1); +} + const codexThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, @@ -130,6 +144,15 @@ const codexThinkingEffortOptions = [ { id: "high", label: "High" }, ] as const; +const openCodeThinkingEffortOptions = [ + { 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,13 +277,20 @@ 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 - const { data: fetchedModels } = useQuery({ - queryKey: ["adapter-models", adapterType], - queryFn: () => agentsApi.adapterModels(adapterType), + const { + data: fetchedModels, + error: fetchedModelsError, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType) + : ["agents", "none", "adapter-models", adapterType], + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType), + enabled: Boolean(selectedCompanyId), }); const models = fetchedModels ?? externalModels ?? []; @@ -313,9 +343,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" + ? openCodeThinkingEffortOptions + : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" @@ -324,6 +363,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))) @@ -549,7 +590,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" + } /> @@ -563,7 +610,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} + allowDefault={adapterType !== "opencode_local"} + required={adapterType === "opencode_local"} + groupByProvider={adapterType === "opencode_local"} /> + {fetchedModelsError && ( +

+ {fetchedModelsError instanceof Error + ? fetchedModelsError.message + : "Failed to load adapter models."} +

+ )} @@ -860,7 +920,10 @@ function AdapterTypeDropdown({ if (!item.comingSoon) onChange(item.value); }} > - {item.label} + + {item.value === "opencode_local" ? : null} + {item.label} + {item.comingSoon && ( Coming soon )} @@ -1126,20 +1189,56 @@ function ModelDropdown({ onChange, open, onOpenChange, + allowDefault, + required, + groupByProvider, }: { models: AdapterModel[]; value: string; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; + allowDefault: boolean; + required: boolean; + groupByProvider: boolean; }) { const [modelSearch, setModelSearch] = useState(""); const selected = models.find((m) => m.id === value); - const filteredModels = models.filter((m) => { - if (!modelSearch.trim()) return true; - const q = modelSearch.toLowerCase(); - return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q); - }); + const filteredModels = useMemo(() => { + return models.filter((m) => { + if (!modelSearch.trim()) return true; + const q = modelSearch.toLowerCase(); + const provider = extractProviderId(m.id) ?? ""; + return ( + m.id.toLowerCase().includes(q) || + m.label.toLowerCase().includes(q) || + provider.toLowerCase().includes(q) + ); + }); + }, [models, modelSearch]); + const groupedModels = useMemo(() => { + if (!groupByProvider) { + return [ + { + provider: "models", + entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), + }, + ]; + } + const map = new Map(); + for (const model of filteredModels) { + const provider = extractProviderId(model.id) ?? "other"; + const group = map.get(provider) ?? []; + group.push(model); + map.set(provider, group); + } + return Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, entries]) => ({ + provider, + entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), + })); + }, [filteredModels, groupByProvider]); return ( @@ -1153,7 +1252,9 @@ function ModelDropdown({ @@ -1167,33 +1268,45 @@ function ModelDropdown({ autoFocus />
- - {filteredModels.map((m) => ( + {allowDefault && ( + )} + {groupedModels.map((group) => ( +
+ {groupByProvider && ( +
+ {group.provider} ({group.entries.length}) +
+ )} + {group.entries.map((m) => ( + + ))} +
))} {filteredModels.length === 0 && (

No models found.

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/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 14a902cb..236eb1ec 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -55,14 +55,21 @@ export function NewAgentDialog() { enabled: !!selectedCompanyId && newAgentOpen, }); - const { data: adapterModels } = useQuery({ - queryKey: ["adapter-models", configValues.adapterType], - queryFn: () => agentsApi.adapterModels(configValues.adapterType), - enabled: newAgentOpen, + const { + data: adapterModels, + error: adapterModelsError, + } = useQuery({ + queryKey: + selectedCompanyId + ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) + : ["agents", "none", "adapter-models", configValues.adapterType], + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), + enabled: Boolean(selectedCompanyId) && newAgentOpen, }); const isFirstAgent = !agents || agents.length === 0; const effectiveRole = isFirstAgent ? "ceo" : role; + const [formError, setFormError] = useState(null); // Auto-fill for CEO useEffect(() => { @@ -82,6 +89,9 @@ export function NewAgentDialog() { closeNewAgent(); navigate(agentUrl(result.agent)); }, + onError: (error) => { + setFormError(error instanceof Error ? error.message : "Failed to create agent"); + }, }); function reset() { @@ -91,6 +101,7 @@ export function NewAgentDialog() { setReportsTo(""); setConfigValues(defaultCreateValues); setExpanded(true); + setFormError(null); } function buildAdapterConfig() { @@ -100,6 +111,31 @@ export function NewAgentDialog() { function handleSubmit() { if (!selectedCompanyId || !name.trim()) return; + setFormError(null); + if (configValues.adapterType === "opencode_local") { + const selectedModel = configValues.model.trim(); + if (!selectedModel) { + setFormError("OpenCode requires an explicit model in provider/model format."); + return; + } + if (adapterModelsError) { + setFormError( + adapterModelsError instanceof Error + ? adapterModelsError.message + : "Failed to load OpenCode models.", + ); + return; + } + const discovered = adapterModels ?? []; + if (!discovered.some((entry) => entry.id === selectedModel)) { + setFormError( + discovered.length === 0 + ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." + : `Configured OpenCode model is unavailable: ${selectedModel}`, + ); + return; + } + } createAgent.mutate({ name: name.trim(), role: effectiveRole, @@ -281,6 +317,11 @@ export function NewAgentDialog() { {isFirstAgent ? "This will be the CEO" : ""} +
+ {formError && ( +
{formError}
+ )} +
@@ -604,36 +710,60 @@ export function OnboardingWizard() { className="w-[var(--radix-popover-trigger-width)] p-1" align="start" > - - {(adapterModels ?? []).map((m) => ( + setModelSearch(e.target.value)} + autoFocus + /> + {adapterType !== "opencode_local" && ( - ))} + Default + + )} +
+ {groupedModels.map((group) => ( +
+ {adapterType === "opencode_local" && ( +
+ {group.provider} ({group.entries.length}) +
+ )} + {group.entries.map((m) => ( + + ))} +
+ ))} +
+ {filteredModels.length === 0 && ( +

+ No models discovered. +

+ )}
@@ -678,6 +808,8 @@ export function OnboardingWizard() {

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

@@ -691,6 +823,12 @@ export function OnboardingWizard() { env or run{" "} codex login.

+ ) : adapterType === "opencode_local" ? ( +

+ If providers are unavailable, run{" "} + opencode models and{" "} + opencode auth login. +

) : (

If login is required, run{" "} diff --git a/ui/src/components/OpenCodeLogoIcon.tsx b/ui/src/components/OpenCodeLogoIcon.tsx new file mode 100644 index 00000000..fb3c1d78 --- /dev/null +++ b/ui/src/components/OpenCodeLogoIcon.tsx @@ -0,0 +1,22 @@ +import { cn } from "../lib/utils"; + +interface OpenCodeLogoIconProps { + className?: string; +} + +export function OpenCodeLogoIcon({ className }: OpenCodeLogoIconProps) { + return ( + <> + OpenCode + OpenCode + + ); +} diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index c56ebf7a..74d46e58 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -23,7 +23,7 @@ export const help: Record = { role: "Organizational role. Determines position and capabilities.", reportsTo: "The agent this one reports to in the org hierarchy.", capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", - adapterType: "How this agent runs: local CLI (Claude/Codex), OpenClaw webhook, spawned process, or generic HTTP webhook.", + adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.", cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.", promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", model: "Override the default model used by the adapter.", @@ -34,7 +34,7 @@ export const help: Record = { search: "Enable Codex web search capability during runs.", maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.", command: "The command to execute (e.g. node, python).", - localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).", + localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).", args: "Command-line arguments, comma-separated.", extraArgs: "Extra CLI arguments for local adapters, comma-separated.", envVars: "Environment variables injected into the adapter process. Use plain values or secret references.", @@ -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/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 1a7722d3..83504a12 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -11,6 +11,8 @@ export const queryKeys = { taskSessions: (id: string) => ["agents", "task-sessions", id] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, + adapterModels: (companyId: string, adapterType: string) => + ["agents", companyId, "adapter-models", adapterType] as const, }, issues: { list: (companyId: string) => ["issues", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 9243bc2b..e14243e1 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1154,8 +1154,12 @@ function ConfigurationTab({ const queryClient = useQueryClient(); const { data: adapterModels } = useQuery({ - queryKey: ["adapter-models", agent.adapterType], - queryFn: () => agentsApi.adapterModels(agent.adapterType), + queryKey: + companyId + ? queryKeys.agents.adapterModels(companyId, agent.adapterType) + : ["agents", "none", "adapter-models", agent.adapterType], + queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType), + enabled: Boolean(companyId), }); const updateAgent = useMutation({ 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 07df8fe5..2ae2950a 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", diff --git a/vitest.config.ts b/vitest.config.ts index 9bcf27c4..9bf83928 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - projects: ["packages/db", "server", "ui", "cli"], + projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"], }, });