From 6a101e0da11868b4771cb73e2d6dc181407a9338 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 15:24:20 +0100 Subject: [PATCH 01/11] 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"], }, }); From 69c453b27470147bf6c6c0a6d189a15de5211d40 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 15:52:59 +0100 Subject: [PATCH 02/11] Address PR feedback for OpenCode integration --- .../opencode-local/src/server/execute.ts | 8 +++--- .../opencode-local/src/server/models.ts | 26 +++++++++++++++++-- .../opencode-local/src/server/test.ts | 25 +++++++++++------- .../opencode-local/src/ui/build-config.ts | 2 ++ server/src/adapters/registry.ts | 3 +-- server/src/routes/agents.ts | 7 ++++- ui/src/api/agents.ts | 4 ++- ui/src/components/AgentConfigForm.tsx | 14 +--------- ui/src/components/NewAgentDialog.tsx | 6 +++++ ui/src/components/NewIssueDialog.tsx | 13 +++------- ui/src/components/OnboardingWizard.tsx | 18 +++---------- ui/src/lib/model-utils.ts | 16 ++++++++++++ 12 files changed, 87 insertions(+), 55 deletions(-) create mode 100644 ui/src/lib/model-utils.ts diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 0d454bfc..2b50c23f 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -115,7 +115,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise(); +const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const; +const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]); function dedupeModels(models: AdapterModel[]): AdapterModel[] { const seen = new Set(); @@ -61,14 +65,30 @@ function normalizeEnv(input: unknown): Record { return env; } +function isVolatileEnvKey(key: string): boolean { + if (VOLATILE_ENV_KEY_EXACT.has(key)) return true; + return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)); +} + +function hashValue(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + function discoveryCacheKey(command: string, cwd: string, env: Record) { const envKey = Object.entries(env) + .filter(([key]) => !isVolatileEnvKey(key)) .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}=${value}`) + .map(([key, value]) => `${key}=${hashValue(value)}`) .join("\n"); return `${command}\n${cwd}\n${envKey}`; } +function pruneExpiredDiscoveryCache(now: number) { + for (const [key, value] of discoveryCache.entries()) { + if (value.expiresAt <= now) discoveryCache.delete(key); + } +} + export async function discoverOpenCodeModels(input: { command?: unknown; cwd?: unknown; @@ -83,6 +103,7 @@ export async function discoverOpenCodeModels(input: { ); const cwd = asString(input.cwd, process.cwd()); const env = normalizeEnv(input.env); + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); const result = await runChildProcess( `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -90,7 +111,7 @@ export async function discoverOpenCodeModels(input: { ["models"], { cwd, - env, + env: runtimeEnv, timeoutSec: 20, graceSec: 3, onLog: async () => {}, @@ -124,6 +145,7 @@ export async function discoverOpenCodeModelsCached(input: { const env = normalizeEnv(input.env); const key = discoveryCacheKey(command, cwd, env); const now = Date.now(); + pruneExpiredDiscoveryCache(now); const cached = discoveryCache.get(key); if (cached && cached.expiresAt > now) return cached.models; diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index f45dd6b1..6e1ac0a3 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -38,6 +38,15 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean; } +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + 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; @@ -50,7 +59,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); checks.push({ code: "opencode_cwd_valid", level: "info", @@ -70,7 +79,7 @@ export async function testEnvironment( for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } - const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); try { await ensureCommandResolvable(command, cwd, runtimeEnv); @@ -91,17 +100,15 @@ export async function testEnvironment( 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) { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { checks.push({ code: "opencode_models_discovered", level: "info", - message: `Discovered ${discoveredModels.length} model(s) from OpenCode providers.`, + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, }); } else { checks.push({ @@ -135,7 +142,7 @@ export async function testEnvironment( model: configuredModel, command, cwd, - env, + env: runtimeEnv, }); checks.push({ code: "opencode_model_configured", @@ -173,7 +180,7 @@ export async function testEnvironment( args, { cwd, - env, + env: runtimeEnv, timeoutSec: 60, graceSec: 5, stdin: "Respond with hello.", diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index f656585e..3abfd6cd 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -57,6 +57,8 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), adapterModels: (companyId: string, type: string) => - api.get(`/companies/${companyId}/adapters/${type}/models`), + api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, + ), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 3d36febc..f9b08d20 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -23,6 +23,7 @@ import { import { Button } from "@/components/ui/button"; import { FolderOpen, Heart, ChevronDown, X } from "lucide-react"; import { cn } from "../lib/utils"; +import { extractModelName, extractProviderId } from "../lib/model-utils"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { @@ -123,19 +124,6 @@ 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" }, diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 236eb1ec..a5392716 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -58,6 +58,8 @@ export function NewAgentDialog() { const { data: adapterModels, error: adapterModelsError, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, } = useQuery({ queryKey: selectedCompanyId @@ -126,6 +128,10 @@ export function NewAgentDialog() { ); return; } + if (adapterModelsLoading || adapterModelsFetching) { + setFormError("OpenCode models are still loading. Please wait and try again."); + return; + } const discovered = adapterModels ?? []; if (!discovered.some((entry) => entry.id === selectedModel)) { setFormError( diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 811bf156..8653cb1b 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -36,6 +36,7 @@ import { Paperclip, } from "lucide-react"; import { cn } from "../lib/utils"; +import { extractProviderIdWithFallback } from "../lib/model-utils"; import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { AgentIcon } from "./AgentIconPicker"; @@ -54,12 +55,6 @@ function getContrastTextColor(hexColor: string): string { return luminance > 0.5 ? "#000000" : "#ffffff"; } -function extractProviderId(modelId: string): string { - const trimmed = modelId.trim(); - if (!trimmed.includes("/")) return "other"; - return trimmed.slice(0, trimmed.indexOf("/")).trim() || "other"; -} - interface IssueDraft { title: string; description: string; @@ -505,8 +500,8 @@ export function NewIssueDialog() { () => { return [...(assigneeAdapterModels ?? [])] .sort((a, b) => { - const providerA = extractProviderId(a.id); - const providerB = extractProviderId(b.id); + const providerA = extractProviderIdWithFallback(a.id); + const providerB = extractProviderIdWithFallback(b.id); const byProvider = providerA.localeCompare(providerB); if (byProvider !== 0) return byProvider; return a.id.localeCompare(b.id); @@ -514,7 +509,7 @@ export function NewIssueDialog() { .map((model) => ({ id: model.id, label: model.label, - searchText: `${model.id} ${extractProviderId(model.id)}`, + searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`, })); }, [assigneeAdapterModels], diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 1df783a2..50b7a6a3 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { cn } from "../lib/utils"; +import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils"; import { getUIAdapter } from "../adapters"; import { defaultCreateValues } from "./agent-config-defaults"; import { @@ -61,19 +62,6 @@ Ensure you have a folder agents/ceo and then download this AGENTS.md as well as And after you've finished that, hire yourself a Founding Engineer agent`; -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); -} - export function OnboardingWizard() { const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany(); @@ -185,7 +173,7 @@ export function OnboardingWizard() { const query = modelSearch.trim().toLowerCase(); return (adapterModels ?? []).filter((entry) => { if (!query) return true; - const provider = extractProviderId(entry.id) ?? ""; + const provider = extractProviderIdWithFallback(entry.id, ""); return ( entry.id.toLowerCase().includes(query) || entry.label.toLowerCase().includes(query) || @@ -204,7 +192,7 @@ export function OnboardingWizard() { } const groups = new Map>(); for (const entry of filteredModels) { - const provider = extractProviderId(entry.id) ?? "other"; + const provider = extractProviderIdWithFallback(entry.id); const bucket = groups.get(provider) ?? []; bucket.push(entry); groups.set(provider, bucket); diff --git a/ui/src/lib/model-utils.ts b/ui/src/lib/model-utils.ts new file mode 100644 index 00000000..b3753e61 --- /dev/null +++ b/ui/src/lib/model-utils.ts @@ -0,0 +1,16 @@ +export 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; +} + +export function extractProviderIdWithFallback(modelId: string, fallback = "other"): string { + return extractProviderId(modelId) ?? fallback; +} + +export function extractModelName(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return trimmed; + return trimmed.slice(trimmed.indexOf("/") + 1); +} From f4f9d6fd3f3c4d84b2550b4f979c97883f3faaa1 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 16:07:12 +0100 Subject: [PATCH 03/11] Fix remaining OpenCode review comments --- .../opencode-local/src/server/execute.ts | 21 +-- .../opencode-local/src/server/test.ts | 140 ++++++++++-------- ui/src/components/OnboardingWizard.tsx | 6 + ui/src/lib/model-utils.ts | 2 +- 4 files changed, 99 insertions(+), 70 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 2b50c23f..c3d5d591 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -141,38 +141,41 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - if (!instructionsFilePath) return [] as string[]; + if (!resolvedInstructionsFilePath) return [] as string[]; if (instructionsPrefix.length > 0) { return [ - `Loaded agent instructions from ${instructionsFilePath}`, + `Loaded agent instructions from ${resolvedInstructionsFilePath}`, `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.`, + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, ]; })(); diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 6e1ac0a3..569f0d75 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -81,20 +81,30 @@ export async function testEnvironment( } const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); - try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); + if (cwdInvalid) { 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", + code: "opencode_command_skipped", + level: "warn", + message: "Skipped command check because working directory validation failed.", detail: command, }); + } else { + 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 = @@ -174,61 +184,71 @@ export async function testEnvironment( 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: runtimeEnv, - timeoutSec: 60, - graceSec: 5, - stdin: "Respond with hello.", - onLog: async () => {}, - }, - ); + try { + const probe = await runChildProcess( + `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env: runtimeEnv, + 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(); + 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 { + 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.", + }); + } + } catch (err) { checks.push({ code: "opencode_hello_probe_failed", level: "error", message: "OpenCode hello probe failed.", - ...(detail ? { detail } : {}), + detail: err instanceof Error ? err.message : String(err), hint: "Run `opencode run --format json` manually in this working directory to debug.", }); } diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 50b7a6a3..dd426e53 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -149,6 +149,8 @@ export function OnboardingWizard() { const { data: adapterModels, error: adapterModelsError, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, } = useQuery({ queryKey: createdCompanyId @@ -332,6 +334,10 @@ export function OnboardingWizard() { ); return; } + if (adapterModelsLoading || adapterModelsFetching) { + setError("OpenCode models are still loading. Please wait and try again."); + return; + } const discoveredModels = adapterModels ?? []; if (!discoveredModels.some((entry) => entry.id === selectedModelId)) { setError( diff --git a/ui/src/lib/model-utils.ts b/ui/src/lib/model-utils.ts index b3753e61..baa721f5 100644 --- a/ui/src/lib/model-utils.ts +++ b/ui/src/lib/model-utils.ts @@ -12,5 +12,5 @@ export function extractProviderIdWithFallback(modelId: string, fallback = "other export function extractModelName(modelId: string): string { const trimmed = modelId.trim(); if (!trimmed.includes("/")) return trimmed; - return trimmed.slice(trimmed.indexOf("/") + 1); + return trimmed.slice(trimmed.indexOf("/") + 1).trim(); } From 0078fa66a34a5de541c432bda9f3791c5d07c879 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 16:11:11 +0100 Subject: [PATCH 04/11] Use precomputed runtime env in OpenCode execute --- packages/adapters/opencode-local/src/server/execute.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index c3d5d591..06f6478e 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -108,7 +108,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), + ); await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureOpenCodeModelConfiguredAndAvailable({ @@ -216,7 +220,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise Date: Thu, 5 Mar 2026 16:12:17 -0300 Subject: [PATCH 05/11] `onboard` now derives defaults from env vars before writing config --- cli/src/commands/onboard.ts | 181 +++++++++++++++++++++++++++++++----- cli/src/prompts/server.ts | 9 +- 2 files changed, 167 insertions(+), 23 deletions(-) diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 050925e4..9fee5f84 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -1,5 +1,18 @@ import * as p from "@clack/prompts"; +import path from "node:path"; import pc from "picocolors"; +import { + AUTH_BASE_URL_MODES, + DEPLOYMENT_EXPOSURES, + DEPLOYMENT_MODES, + SECRET_PROVIDERS, + STORAGE_PROVIDERS, + type AuthBaseUrlMode, + type DeploymentExposure, + type DeploymentMode, + type SecretProvider, + type StorageProvider, +} from "@paperclipai/shared"; import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import type { PaperclipConfig } from "../config/schema.js"; import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js"; @@ -12,6 +25,7 @@ import { defaultStorageConfig, promptStorage } from "../prompts/storage.js"; import { promptServer } from "../prompts/server.js"; import { describeLocalInstancePaths, + expandHomePrefix, resolveDefaultBackupDir, resolveDefaultEmbeddedPostgresDir, resolveDefaultLogsDir, @@ -29,18 +43,109 @@ type OnboardOptions = { invokedByRun?: boolean; }; -function quickstartDefaults(): Pick { +type OnboardDefaults = Pick; + +const ONBOARD_ENV_KEYS = [ + "DATABASE_URL", + "PAPERCLIP_DB_BACKUP_ENABLED", + "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", + "PAPERCLIP_DB_BACKUP_RETENTION_DAYS", + "PAPERCLIP_DB_BACKUP_DIR", + "PAPERCLIP_DEPLOYMENT_MODE", + "PAPERCLIP_DEPLOYMENT_EXPOSURE", + "HOST", + "PORT", + "SERVE_UI", + "PAPERCLIP_ALLOWED_HOSTNAMES", + "PAPERCLIP_AUTH_BASE_URL_MODE", + "PAPERCLIP_AUTH_PUBLIC_BASE_URL", + "BETTER_AUTH_URL", + "PAPERCLIP_STORAGE_PROVIDER", + "PAPERCLIP_STORAGE_LOCAL_DIR", + "PAPERCLIP_STORAGE_S3_BUCKET", + "PAPERCLIP_STORAGE_S3_REGION", + "PAPERCLIP_STORAGE_S3_ENDPOINT", + "PAPERCLIP_STORAGE_S3_PREFIX", + "PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", + "PAPERCLIP_SECRETS_PROVIDER", + "PAPERCLIP_SECRETS_STRICT_MODE", + "PAPERCLIP_SECRETS_MASTER_KEY_FILE", +] as const; + +function parseBooleanFromEnv(rawValue: string | undefined): boolean | null { + if (rawValue === undefined) return null; + return rawValue === "true"; +} + +function parseNumberFromEnv(rawValue: string | undefined): number | null { + if (!rawValue) return null; + const parsed = Number(rawValue); + if (!Number.isFinite(parsed)) return null; + return parsed; +} + +function parseEnumFromEnv(rawValue: string | undefined, allowedValues: readonly T[]): T | null { + if (!rawValue) return null; + return allowedValues.includes(rawValue as T) ? (rawValue as T) : null; +} + +function resolvePathFromEnv(rawValue: string | undefined): string | null { + if (!rawValue || rawValue.trim().length === 0) return null; + return path.resolve(expandHomePrefix(rawValue.trim())); +} + +function quickstartDefaultsFromEnv(): { defaults: OnboardDefaults; usedEnvKeys: string[] } { const instanceId = resolvePaperclipInstanceId(); - return { + const defaultStorage = defaultStorageConfig(); + const defaultSecrets = defaultSecretsConfig(); + const databaseUrl = process.env.DATABASE_URL?.trim() || undefined; + const deploymentMode = + parseEnumFromEnv(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted"; + const deploymentExposureFromEnv = parseEnumFromEnv( + process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE, + DEPLOYMENT_EXPOSURES, + ); + const deploymentExposure = + deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private"); + const authPublicBaseUrl = + (process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? process.env.BETTER_AUTH_URL)?.trim() || undefined; + const authBaseUrlModeFromEnv = parseEnumFromEnv( + process.env.PAPERCLIP_AUTH_BASE_URL_MODE, + AUTH_BASE_URL_MODES, + ); + const authBaseUrlMode = authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto"); + const allowedHostnamesFromEnv = process.env.PAPERCLIP_ALLOWED_HOSTNAMES + ? process.env.PAPERCLIP_ALLOWED_HOSTNAMES + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0) + : []; + const storageProvider = + parseEnumFromEnv(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ?? + defaultStorage.provider; + const secretsProvider = + parseEnumFromEnv(process.env.PAPERCLIP_SECRETS_PROVIDER, SECRET_PROVIDERS) ?? + defaultSecrets.provider; + const databaseBackupEnabled = parseBooleanFromEnv(process.env.PAPERCLIP_DB_BACKUP_ENABLED) ?? true; + const databaseBackupIntervalMinutes = Math.max( + 1, + parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES) ?? 60, + ); + const databaseBackupRetentionDays = Math.max( + 1, + parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ?? 30, + ); + const defaults: OnboardDefaults = { database: { - mode: "embedded-postgres", + mode: databaseUrl ? "postgres" : "embedded-postgres", + ...(databaseUrl ? { connectionString: databaseUrl } : {}), embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId), embeddedPostgresPort: 54329, backup: { - enabled: true, - intervalMinutes: 60, - retentionDays: 30, - dir: resolveDefaultBackupDir(instanceId), + enabled: databaseBackupEnabled, + intervalMinutes: databaseBackupIntervalMinutes, + retentionDays: databaseBackupRetentionDays, + dir: resolvePathFromEnv(process.env.PAPERCLIP_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId), }, }, logging: { @@ -48,19 +153,45 @@ function quickstartDefaults(): Pick process.env[key] !== undefined); + return { defaults, usedEnvKeys }; } export async function onboard(opts: OnboardOptions): Promise { @@ -116,6 +247,7 @@ export async function onboard(opts: OnboardOptions): Promise { } let llm: PaperclipConfig["llm"] | undefined; + const { defaults: derivedDefaults, usedEnvKeys } = quickstartDefaultsFromEnv(); let { database, logging, @@ -123,7 +255,7 @@ export async function onboard(opts: OnboardOptions): Promise { auth, storage, secrets, - } = quickstartDefaults(); + } = derivedDefaults; if (setupMode === "advanced") { p.log.step(pc.bold("Database")); @@ -191,10 +323,10 @@ export async function onboard(opts: OnboardOptions): Promise { logging = await promptLogging(); p.log.step(pc.bold("Server")); - ({ server, auth } = await promptServer()); + ({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth })); p.log.step(pc.bold("Storage")); - storage = await promptStorage(defaultStorageConfig()); + storage = await promptStorage(storage); p.log.step(pc.bold("Secrets")); secrets = defaultSecretsConfig(); @@ -205,9 +337,14 @@ export async function onboard(opts: OnboardOptions): Promise { ); } else { p.log.step(pc.bold("Quickstart")); - p.log.message( - pc.dim("Using local defaults: embedded database, no LLM provider, file storage, and local encrypted secrets."), - ); + p.log.message(pc.dim("Using quickstart defaults.")); + if (usedEnvKeys.length > 0) { + p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`)); + } else { + p.log.message( + pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."), + ); + } } const jwtSecret = ensureAgentJwtSecret(configPath); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index 1b271316..c2ab4218 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -149,7 +149,14 @@ export async function promptServer(opts?: { } return { - server: { deploymentMode, exposure, host: hostStr.trim(), port, allowedHostnames, serveUi: true }, + server: { + deploymentMode, + exposure, + host: hostStr.trim(), + port, + allowedHostnames, + serveUi: currentServer?.serveUi ?? true, + }, auth, }; } From 9184cf92ddd81822eb3be579d3e4a7fc40f70ee9 Mon Sep 17 00:00:00 2001 From: zvictor Date: Fri, 6 Mar 2026 11:28:31 -0300 Subject: [PATCH 06/11] fix: parseBooleanFromEnv silently treats common truthy values as false --- cli/src/commands/onboard.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 9fee5f84..79353514 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -74,7 +74,10 @@ const ONBOARD_ENV_KEYS = [ function parseBooleanFromEnv(rawValue: string | undefined): boolean | null { if (rawValue === undefined) return null; - return rawValue === "true"; + const lower = rawValue.trim().toLowerCase(); + if (lower === "true" || lower === "1" || lower === "yes") return true; + if (lower === "false" || lower === "0" || lower === "no") return false; + return null; } function parseNumberFromEnv(rawValue: string | undefined): number | null { From 9cacf4a98119fab4f7910a9e9356f0348ff89dd1 Mon Sep 17 00:00:00 2001 From: zvictor Date: Fri, 6 Mar 2026 11:29:28 -0300 Subject: [PATCH 07/11] fix(onboard): preserve env-derived secrets defaults and report ignored exposure env in local_trusted mode --- cli/src/commands/onboard.ts | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 79353514..fbc4db74 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -97,7 +97,11 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null { return path.resolve(expandHomePrefix(rawValue.trim())); } -function quickstartDefaultsFromEnv(): { defaults: OnboardDefaults; usedEnvKeys: string[] } { +function quickstartDefaultsFromEnv(): { + defaults: OnboardDefaults; + usedEnvKeys: string[]; + ignoredEnvKeys: Array<{ key: string; reason: string }>; +} { const instanceId = resolvePaperclipInstanceId(); const defaultStorage = defaultStorageConfig(); const defaultSecrets = defaultSecretsConfig(); @@ -193,8 +197,19 @@ function quickstartDefaultsFromEnv(): { defaults: OnboardDefaults; usedEnvKeys: }, }, }; - const usedEnvKeys = ONBOARD_ENV_KEYS.filter((key) => process.env[key] !== undefined); - return { defaults, usedEnvKeys }; + const ignoredEnvKeys: Array<{ key: string; reason: string }> = []; + if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) { + ignoredEnvKeys.push({ + key: "PAPERCLIP_DEPLOYMENT_EXPOSURE", + reason: "Ignored because deployment mode local_trusted always forces private exposure", + }); + } + + const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key)); + const usedEnvKeys = ONBOARD_ENV_KEYS.filter( + (key) => process.env[key] !== undefined && !ignoredKeySet.has(key), + ); + return { defaults, usedEnvKeys, ignoredEnvKeys }; } export async function onboard(opts: OnboardOptions): Promise { @@ -250,7 +265,7 @@ export async function onboard(opts: OnboardOptions): Promise { } let llm: PaperclipConfig["llm"] | undefined; - const { defaults: derivedDefaults, usedEnvKeys } = quickstartDefaultsFromEnv(); + const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv(); let { database, logging, @@ -332,7 +347,14 @@ export async function onboard(opts: OnboardOptions): Promise { storage = await promptStorage(storage); p.log.step(pc.bold("Secrets")); - secrets = defaultSecretsConfig(); + const secretsDefaults = defaultSecretsConfig(); + secrets = { + provider: secrets.provider ?? secretsDefaults.provider, + strictMode: secrets.strictMode ?? secretsDefaults.strictMode, + localEncrypted: { + keyFilePath: secrets.localEncrypted?.keyFilePath ?? secretsDefaults.localEncrypted.keyFilePath, + }, + }; p.log.message( pc.dim( `Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`, @@ -348,6 +370,9 @@ export async function onboard(opts: OnboardOptions): Promise { pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."), ); } + for (const ignored of ignoredEnvKeys) { + p.log.message(pc.dim(`Ignored ${ignored.key}: ${ignored.reason}`)); + } } const jwtSecret = ensureAgentJwtSecret(configPath); From de60519ef676089a42578d491c0fbe7a5cca1888 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 6 Mar 2026 15:28:32 +0000 Subject: [PATCH 08/11] Regenerate pnpm-lock.yaml after PR #62 merge The lockfile was out of sync with the merged package.json files (adapter-utils @types/node bumped to ^24.6.0 by PR #62). Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 143 +++++++++++++++++++++++++++---------------------- 1 file changed, 79 insertions(+), 64 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae7e8285..492cd35a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@paperclipai/adapter-codex-local': specifier: workspace:* version: link:../packages/adapters/codex-local + '@paperclipai/adapter-cursor-local': + specifier: workspace:* + version: link:../packages/adapters/cursor-local '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw @@ -76,8 +79,8 @@ importers: packages/adapter-utils: devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -92,8 +95,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -108,8 +111,21 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/adapters/cursor-local: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: typescript: specifier: ^5.7.3 version: 5.9.3 @@ -124,8 +140,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -159,8 +175,8 @@ importers: version: 3.4.8 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 drizzle-kit: specifier: ^0.31.9 version: 0.31.9 @@ -172,7 +188,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - 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) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) packages/shared: dependencies: @@ -195,6 +211,9 @@ importers: '@paperclipai/adapter-codex-local': specifier: workspace:* version: link:../packages/adapters/codex-local + '@paperclipai/adapter-cursor-local': + specifier: workspace:* + version: link:../packages/adapters/cursor-local '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw @@ -211,8 +230,8 @@ importers: specifier: workspace:* 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@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + specifier: 1.4.18 + 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@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) detect-port: specifier: ^2.1.0 version: 2.1.0 @@ -222,6 +241,9 @@ importers: drizzle-orm: specifier: ^0.38.4 version: 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) + embedded-postgres: + specifier: ^18.1.0-beta.16 + version: 18.1.0-beta.16 express: specifier: ^5.1.0 version: 5.2.1 @@ -246,10 +268,6 @@ importers: zod: specifier: ^3.24.2 version: 3.25.76 - optionalDependencies: - embedded-postgres: - specifier: ^18.1.0-beta.16 - version: 18.1.0-beta.16 devDependencies: '@types/express': specifier: ^5.0.0 @@ -261,13 +279,13 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 '@types/ws': - specifier: ^8.5.14 + specifier: ^8.18.1 version: 8.18.1 supertest: specifier: ^7.0.0 @@ -280,10 +298,10 @@ importers: version: 5.9.3 vite: specifier: ^6.1.0 - version: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 6.4.1(@types/node@24.12.0)(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@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -305,6 +323,9 @@ importers: '@paperclipai/adapter-codex-local': specifier: workspace:* version: link:../packages/adapters/codex-local + '@paperclipai/adapter-cursor-local': + specifier: workspace:* + version: link:../packages/adapters/cursor-local '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw @@ -2819,6 +2840,9 @@ packages: '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -8138,7 +8162,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.2.3 + '@types/node': 24.12.0 '@types/chai@5.2.3': dependencies: @@ -8147,7 +8171,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.2.3 + '@types/node': 24.12.0 '@types/cookiejar@2.1.5': {} @@ -8165,7 +8189,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.2.3 + '@types/node': 24.12.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -8200,6 +8224,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -8218,18 +8246,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.2.3 + '@types/node': 24.12.0 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.2.3 + '@types/node': 24.12.0 '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 25.2.3 + '@types/node': 24.12.0 form-data: 4.0.5 '@types/supertest@6.0.3': @@ -8243,7 +8271,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.2.3 + '@types/node': 24.12.0 '@ungap/structured-clone@1.3.0': {} @@ -8267,13 +8295,13 @@ 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))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0)(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) + vite: 7.3.1(@types/node@24.12.0)(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: @@ -8346,8 +8374,7 @@ snapshots: assertion-error@2.0.1: {} - async-exit-hook@2.0.1: - optional: true + async-exit-hook@2.0.1: {} asynckit@0.4.0: {} @@ -8359,7 +8386,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@22.19.11)(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@24.12.0)(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)) @@ -8379,7 +8406,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@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) better-call@1.1.8(zod@4.3.6): dependencies: @@ -8676,7 +8703,6 @@ snapshots: '@embedded-postgres/windows-x64': 18.1.0-beta.16 transitivePeerDependencies: - pg-native - optional: true encodeurl@2.0.0: {} @@ -9869,19 +9895,15 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.11.0: - optional: true + pg-connection-string@2.11.0: {} - pg-int8@1.0.1: - optional: true + pg-int8@1.0.1: {} pg-pool@3.11.0(pg@8.18.0): dependencies: pg: 8.18.0 - optional: true - pg-protocol@1.11.0: - optional: true + pg-protocol@1.11.0: {} pg-types@2.2.0: dependencies: @@ -9890,7 +9912,6 @@ snapshots: postgres-bytea: 1.0.1 postgres-date: 1.0.7 postgres-interval: 1.2.0 - optional: true pg@8.18.0: dependencies: @@ -9901,12 +9922,10 @@ snapshots: pgpass: 1.0.5 optionalDependencies: pg-cloudflare: 1.3.0 - optional: true pgpass@1.0.5: dependencies: split2: 4.2.0 - optional: true picocolors@1.1.1: {} @@ -9974,19 +9993,15 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: - optional: true + postgres-array@2.0.0: {} - postgres-bytea@1.0.1: - optional: true + postgres-bytea@1.0.1: {} - postgres-date@1.0.7: - optional: true + postgres-date@1.0.7: {} postgres-interval@1.2.0: dependencies: xtend: 4.0.2 - optional: true postgres@3.4.8: {} @@ -10632,13 +10647,13 @@ 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): + vite-node@3.2.4(@types/node@24.12.0)(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) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -10674,7 +10689,7 @@ snapshots: - tsx - yaml - vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vite@6.4.1(@types/node@24.12.0)(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) @@ -10683,7 +10698,7 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 22.19.11 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -10704,7 +10719,7 @@ 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): + vite@7.3.1(@types/node@24.12.0)(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) @@ -10713,7 +10728,7 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 22.19.11 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -10734,11 +10749,11 @@ 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): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(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/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(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 @@ -10756,12 +10771,12 @@ snapshots: 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) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@24.12.0)(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 + '@types/node': 24.12.0 transitivePeerDependencies: - jiti - less From ae6087950777fc91c57de090d016256e5bf5e44f Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 6 Mar 2026 15:30:13 +0000 Subject: [PATCH 09/11] Fix TS errors: remove DEFAULT_OPENCODE_LOCAL_MODEL references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #62 uses strict model selection with no default — the merge conflict resolution incorrectly kept HEAD's references to this removed constant. Also remove dead opencode_local branch in OnboardingWizard (already handled by prior condition). Co-Authored-By: Claude Opus 4.6 --- ui/src/components/AgentConfigForm.tsx | 7 ++----- ui/src/components/OnboardingWizard.tsx | 9 --------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 861c69f6..2ea08954 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -16,7 +16,6 @@ import { DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { Popover, PopoverContent, @@ -491,7 +490,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } else if (t === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (t === "opencode_local") { - nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL; + nextValues.model = ""; } set!(nextValues); } else { @@ -506,9 +505,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? DEFAULT_CODEX_LOCAL_MODEL : t === "cursor" ? DEFAULT_CURSOR_LOCAL_MODEL - : t === "opencode_local" - ? DEFAULT_OPENCODE_LOCAL_MODEL - : "", + : "", effort: "", modelReasoningEffort: "", variant: "", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 9d0e9638..b4ab034e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -25,7 +25,6 @@ import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { ChoosePathButton } from "./PathInstructionsModal"; import { HintIcon } from "./agent-config-primitives"; @@ -271,8 +270,6 @@ export function OnboardingWizard() { ? model || DEFAULT_CODEX_LOCAL_MODEL : adapterType === "cursor" ? model || DEFAULT_CURSOR_LOCAL_MODEL - : adapterType === "opencode_local" - ? model || DEFAULT_OPENCODE_LOCAL_MODEL : model, command, args, @@ -944,12 +941,6 @@ export function OnboardingWizard() { : "opencode auth login"} .

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

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

) : (

If login is required, run{" "} From 7bcf9940645b4d0a58c6a600e2731b194c9deb09 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 6 Mar 2026 15:48:31 +0000 Subject: [PATCH 10/11] Fix server: remove DEFAULT_OPENCODE_LOCAL_MODEL from agents route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same issue as the UI fix — merge conflict resolution kept HEAD's reference to the removed constant. OpenCode uses strict model selection with no default. Co-Authored-By: Claude Opus 4.6 --- server/src/routes/agents.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index e91f00f9..2358a7b4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -37,7 +37,6 @@ import { DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; export function agentRoutes(db: Db) { @@ -196,9 +195,7 @@ export function agentRoutes(db: Db) { } return next; } - if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) { - next.model = DEFAULT_OPENCODE_LOCAL_MODEL; - } + // OpenCode requires explicit model selection — no default if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; } From b06e41bed208d3e16c5c536b931c38ee4977f16d Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 6 Mar 2026 16:53:50 +0000 Subject: [PATCH 11/11] Fix review feedback: duplicate wizard entry, command resolution, @types/node - Remove duplicate opencode_local adapter entry in OnboardingWizard (old Code-icon version), keeping only the OpenCodeLogoIcon entry - Extract resolveOpenCodeCommand() helper to deduplicate the PAPERCLIP_OPENCODE_COMMAND env-var fallback logic in models.ts - Bump @types/node from ^22.12.0 to ^24.6.0 to match the monorepo Co-Authored-By: Claude Opus 4.6 --- packages/adapters/opencode-local/package.json | 2 +- .../opencode-local/src/server/models.ts | 25 ++++++++----------- ui/src/components/OnboardingWizard.tsx | 6 ----- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json index 1013eb50..7c6b48a3 100644 --- a/packages/adapters/opencode-local/package.json +++ b/packages/adapters/opencode-local/package.json @@ -45,7 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { - "@types/node": "^22.12.0", + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index 99f850a1..dd2eb2c6 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -8,6 +8,15 @@ import { const MODELS_CACHE_TTL_MS = 60_000; +function resolveOpenCodeCommand(input: unknown): string { + const envOverride = + typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" && + process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0 + ? process.env.PAPERCLIP_OPENCODE_COMMAND.trim() + : "opencode"; + return asString(input, envOverride); +} + const discoveryCache = new Map(); const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const; const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]); @@ -94,13 +103,7 @@ export async function discoverOpenCodeModels(input: { 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 command = resolveOpenCodeCommand(input.command); const cwd = asString(input.cwd, process.cwd()); const env = normalizeEnv(input.env); const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); @@ -134,13 +137,7 @@ export async function discoverOpenCodeModelsCached(input: { 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 command = resolveOpenCodeCommand(input.command); const cwd = asString(input.cwd, process.cwd()); const env = normalizeEnv(input.env); const key = discoveryCacheKey(command, cwd, env); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index b4ab034e..77fb4db8 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -659,12 +659,6 @@ export function OnboardingWizard() { desc: "Local Codex agent", recommended: true }, - { - value: "opencode_local" as const, - label: "OpenCode", - icon: Code, - desc: "Local OpenCode agent" - }, { value: "opencode_local" as const, label: "OpenCode",