diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index ba8f93a8..818bc6e6 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -17,7 +17,7 @@ const codexLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCodexStreamEvent, }; -const opencodeLocalCLIAdapter: CLIAdapterModule = { +const openCodeLocalCLIAdapter: CLIAdapterModule = { type: "opencode_local", formatStdoutEvent: printOpenCodeStreamEvent, }; @@ -33,7 +33,7 @@ const openclawCLIAdapter: CLIAdapterModule = { }; const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, opencodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, cursorLocalCLIAdapter, 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/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md index e52dfab9..ef07f9bf 100644 --- a/packages/adapters/opencode-local/CHANGELOG.md +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -4,4 +4,4 @@ ### Patch Changes -- Added initial `opencode_local` adapter package for local OpenCode execution +- 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 index f53722fb..1013eb50 100644 --- a/packages/adapters/opencode-local/package.json +++ b/packages/adapters/opencode-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/opencode-local/src/cli/format-event.ts b/packages/adapters/opencode-local/src/cli/format-event.ts index 37b34250..00d0ec76 100644 --- a/packages/adapters/opencode-local/src/cli/format-event.ts +++ b/packages/adapters/opencode-local/src/cli/format-event.ts @@ -1,5 +1,13 @@ 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; @@ -13,42 +21,21 @@ function asNumber(value: unknown, fallback = 0): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } -function printToolEvent(part: Record): void { - const tool = asString(part.tool, "tool"); - const callId = asString(part.callID, asString(part.id, "")); - const state = asRecord(part.state); - const status = asString(state?.status); - const input = state?.input; - const output = asString(state?.output).replace(/\s+$/, ""); - const metadata = asRecord(state?.metadata); - const exit = asNumber(metadata?.exit, NaN); - const isError = - status === "failed" || - status === "error" || - status === "cancelled" || - (Number.isFinite(exit) && exit !== 0); - - console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`)); - if (input !== undefined) { - try { - console.log(pc.gray(JSON.stringify(input, null, 2))); - } catch { - console.log(pc.gray(String(input))); - } - } - - if (status || output) { - const summary = [ - "tool_result", - status ? `status=${status}` : "", - Number.isFinite(exit) ? `exit=${exit}` : "", - ] - .filter(Boolean) - .join(" "); - console.log((isError ? pc.red : pc.cyan)(summary)); - if (output) { - console.log((isError ? pc.red : pc.gray)(output)); - } +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 ""; } } @@ -56,10 +43,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { const line = raw.trim(); if (!line) return; - let parsed: Record | null = null; - try { - parsed = JSON.parse(line) as Record; - } catch { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { console.log(line); return; } @@ -74,18 +59,36 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { if (type === "text") { const part = asRecord(parsed.part); - const text = asString(part?.text); + 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); - if (part) { - printToolEvent(part); - } else { - console.log(pc.yellow("tool_use")); + 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; } @@ -93,20 +96,18 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { const part = asRecord(parsed.part); const tokens = asRecord(part?.tokens); const cache = asRecord(tokens?.cache); - const reason = asString(part?.reason, "step_finish"); - const input = asNumber(tokens?.input); - const output = asNumber(tokens?.output); - const cached = asNumber(cache?.read); - const cost = asNumber(part?.cost); - console.log(pc.blue(`step finished: reason=${reason}`)); - console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); + 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 part = asRecord(parsed.part); - const message = asString(parsed.message) || asString(part?.message) || line; - console.log(pc.red(`error: ${message}`)); + const message = errorText(parsed.error ?? parsed.message); + if (message) console.log(pc.red(`error: ${message}`)); return; } diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 2688a0f2..1661a85b 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -1,13 +1,7 @@ export const type = "opencode_local"; export const label = "OpenCode (local)"; -export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex"; -export const models = [ - { id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL }, - { id: "openai/gpt-5.2", label: "openai/gpt-5.2" }, - { id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" }, - { id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" }, -]; +export const models: Array<{ id: string; label: string }> = []; export const agentConfigurationDoc = `# opencode_local agent configuration @@ -26,8 +20,8 @@ Don't use when: Core fields: - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt -- model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.2-codex) -- variant (string, optional): provider-specific reasoning/profile variant passed as --variant +- 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 @@ -38,7 +32,9 @@ Operational fields: - 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. - Runs are executed with: opencode run --format json ... -- Prompts are passed as the final positional message argument. - Sessions are resumed with --session when stored session cwd matches current cwd. `; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index d0070dca..338646b3 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -16,8 +16,8 @@ import { renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; -import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; +import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const PAPERCLIP_SKILLS_CANDIDATES = [ @@ -34,81 +34,11 @@ function firstNonEmptyLine(text: string): string { ); } -function getEffectiveEnvValue(envOverrides: Record, key: string): string { - if (Object.prototype.hasOwnProperty.call(envOverrides, key)) { - const raw = envOverrides[key]; - return typeof raw === "string" ? raw : ""; - } - const raw = process.env[key]; - return typeof raw === "string" ? raw : ""; -} - -function hasEffectiveEnvValue(envOverrides: Record, key: string): boolean { - return getEffectiveEnvValue(envOverrides, key).trim().length > 0; -} - -function resolveOpenCodeBillingType(env: Record): "api" | "subscription" { - return hasEffectiveEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; -} - -function resolveProviderFromModel(model: string): string | null { +function parseModelProvider(model: string | null): string | null { + if (!model) return null; const trimmed = model.trim(); - if (!trimmed) return null; - const slash = trimmed.indexOf("/"); - if (slash <= 0) return null; - return trimmed.slice(0, slash).toLowerCase(); -} - -function isProviderModelNotFoundFailure(stdout: string, stderr: string): boolean { - const haystack = `${stdout}\n${stderr}`; - return /ProviderModelNotFoundError|provider model not found/i.test(haystack); -} - -type ProviderModelNotFoundDetails = { - providerId: string | null; - modelId: string | null; - suggestions: string[]; -}; - -function parseProviderModelNotFoundDetails( - stdout: string, - stderr: string, -): ProviderModelNotFoundDetails | null { - if (!isProviderModelNotFoundFailure(stdout, stderr)) return null; - const haystack = `${stdout}\n${stderr}`; - - const providerMatch = haystack.match(/providerID:\s*"([^"]+)"/i); - const modelMatch = haystack.match(/modelID:\s*"([^"]+)"/i); - const suggestionsMatch = haystack.match(/suggestions:\s*\[([^\]]*)\]/i); - const suggestions = suggestionsMatch - ? Array.from( - suggestionsMatch[1].matchAll(/"([^"]+)"/g), - (match) => match[1].trim(), - ).filter((value) => value.length > 0) - : []; - - return { - providerId: providerMatch?.[1]?.trim().toLowerCase() || null, - modelId: modelMatch?.[1]?.trim() || null, - suggestions, - }; -} - -function formatModelNotFoundError( - model: string, - providerFromModel: string | null, - details: ProviderModelNotFoundDetails | null, -): string { - const provider = details?.providerId || providerFromModel || "unknown"; - const missingModel = details?.modelId || model; - const suggestions = details?.suggestions ?? []; - const suggestionText = - suggestions.length > 0 ? ` Suggested models: ${suggestions.map((value) => `\`${value}\``).join(", ")}.` : ""; - return ( - `OpenCode model \`${missingModel}\` is unavailable for provider \`${provider}\`.` + - ` Run \`opencode models ${provider}\` and set adapterConfig.model to a supported value.` + - suggestionText - ); + if (!trimmed.includes("/")) return null; + return trimmed.slice(0, trimmed.indexOf("/")).trim() || null; } function claudeSkillsHome(): string { @@ -160,8 +90,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; - if (wakeTaskId) { - env.PAPERCLIP_TASK_ID = wakeTaskId; - } - if (wakeReason) { - env.PAPERCLIP_WAKE_REASON = wakeReason; - } - if (wakeCommentId) { - env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; - } - if (approvalId) { - env.PAPERCLIP_APPROVAL_ID = approvalId; - } - if (approvalStatus) { - env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; - } - if (linkedIssueIds.length > 0) { - env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); - } - if (effectiveWorkspaceCwd) { - env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; - } - if (workspaceSource) { - env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; - } - if (workspaceId) { - env.PAPERCLIP_WORKSPACE_ID = workspaceId; - } - if (workspaceRepoUrl) { - env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; - } - if (workspaceRepoRef) { - env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; - } - if (workspaceHints.length > 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); - } - for (const [k, v] of Object.entries(envConfig)) { - if (typeof v === "string") env[k] = v; + if (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 billingType = resolveOpenCodeBillingType(env); - const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); await ensureCommandResolvable(command, cwd, runtimeEnv); + await ensureOpenCodeModelConfiguredAndAvailable({ + model, + command, + cwd, + env: runtimeEnv, + }); + const timeoutSec = asNumber(config.timeoutSec, 0); const graceSec = asNumber(config.graceSec, 20); const extraArgs = (() => { @@ -278,37 +195,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}`, - `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + `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.`, ]; })(); @@ -329,7 +250,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); - args.push(prompt); return args; }; @@ -341,10 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - if (idx === args.length - 1) return ``; - return value; - }), + commandArgs: [...args, ``], env: redactEnvForLogs(env), prompt, context, @@ -353,29 +270,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise; }, clearSessionOnMissingSession = false, @@ -390,7 +301,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise) : null; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const modelNotFound = parseProviderModelNotFoundDetails(attempt.proc.stdout, attempt.proc.stderr); - const fallbackErrorMessage = modelNotFound - ? formatModelNotFoundError(model, providerFromModel, modelNotFound) - : parsedError || - stderrLine || - `OpenCode exited with code ${attempt.proc.exitCode ?? -1}`; + 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: attempt.proc.exitCode, + exitCode: synthesizedExitCode, signal: attempt.proc.signal, timedOut: false, - errorMessage: - (attempt.proc.exitCode ?? 0) === 0 - ? null - : fallbackErrorMessage, - usage: attempt.parsed.usage, + 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: providerFromModel, - model, - billingType, - costUsd: attempt.parsed.costUsd, + 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), + clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), }; }; const initial = await runAttempt(sessionId); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); if ( sessionId && - !initial.proc.timedOut && - (initial.proc.exitCode ?? 0) !== 0 && - isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + initialFailed && + isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) ) { await onLog( "stderr", - `[paperclip] OpenCode resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, ); const retry = await runAttempt(null); return toResult(retry, true); diff --git a/packages/adapters/opencode-local/src/server/index.ts b/packages/adapters/opencode-local/src/server/index.ts index 17300e75..a2275d42 100644 --- a/packages/adapters/opencode-local/src/server/index.ts +++ b/packages/adapters/opencode-local/src/server/index.ts @@ -1,6 +1,3 @@ -export { execute } from "./execute.js"; -export { testEnvironment } from "./test.js"; -export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; function readNonEmptyString(value: unknown): string | null { @@ -62,3 +59,13 @@ export const sessionCodec: AdapterSessionCodec = { ); }, }; + +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..99f850a1 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -0,0 +1,198 @@ +import { createHash } from "node:crypto"; +import type { AdapterModel } from "@paperclipai/adapter-utils"; +import { + asString, + ensurePathInEnv, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; + +const MODELS_CACHE_TTL_MS = 60_000; + +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"]); + +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 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}=${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; + 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 runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); + + const result = await runChildProcess( + `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + ["models"], + { + cwd, + env: runtimeEnv, + 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(); + pruneExpiredDiscoveryCache(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 index 2b028566..5cbfa46c 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -1,10 +1,17 @@ -import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils"; +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; -function asErrorText(value: unknown): string { +function errorText(value: unknown): string { if (typeof value === "string") return value; const rec = parseObject(value); - const message = asString(rec.message, "") || asString(rec.error, "") || asString(rec.code, ""); + 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; + const code = asString(rec.code, "").trim(); + if (code) return code; try { return JSON.stringify(rec); } catch { @@ -15,12 +22,12 @@ function asErrorText(value: unknown): string { export function parseOpenCodeJsonl(stdout: string) { let sessionId: string | null = null; const messages: string[] = []; - let errorMessage: string | null = null; - let totalCostUsd = 0; + const errors: string[] = []; const usage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, + costUsd: 0, }; for (const rawLine of stdout.split(/\r?\n/)) { @@ -30,8 +37,8 @@ export function parseOpenCodeJsonl(stdout: string) { const event = parseJson(line); if (!event) continue; - const foundSession = asString(event.sessionID, "").trim(); - if (foundSession) sessionId = foundSession; + const currentSessionId = asString(event.sessionID, "").trim(); + if (currentSessionId) sessionId = currentSessionId; const type = asString(event.type, ""); @@ -48,15 +55,25 @@ export function parseOpenCodeJsonl(stdout: string) { const cache = parseObject(tokens.cache); usage.inputTokens += asNumber(tokens.input, 0); usage.cachedInputTokens += asNumber(cache.read, 0); - usage.outputTokens += asNumber(tokens.output, 0); - totalCostUsd += asNumber(part.cost, 0); + 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 part = parseObject(event.part); - const msg = asErrorText(event.message ?? part.message ?? event.error ?? part.error).trim(); - if (msg) errorMessage = msg; + const text = errorText(event.error ?? event.message).trim(); + if (text) errors.push(text); + continue; } } @@ -64,8 +81,7 @@ export function parseOpenCodeJsonl(stdout: string) { sessionId, summary: messages.join("\n\n").trim(), usage, - costUsd: totalCostUsd > 0 ? totalCostUsd : null, - errorMessage, + errorMessage: errors.length > 0 ? errors.join("\n") : null, }; } @@ -76,7 +92,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b .filter(Boolean) .join("\n"); - return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror/i.test( + return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test( haystack, ); } diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 99c0606e..569f0d75 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -12,8 +12,7 @@ import { ensurePathInEnv, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; -import path from "node:path"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; +import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { parseOpenCodeJsonl } from "./parse.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { @@ -22,19 +21,6 @@ function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentT return "pass"; } -function isNonEmpty(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function getEffectiveEnvValue(envOverrides: Record, key: string): string { - if (Object.prototype.hasOwnProperty.call(envOverrides, key)) { - const raw = envOverrides[key]; - return typeof raw === "string" ? raw : ""; - } - const raw = process.env[key]; - return typeof raw === "string" ? raw : ""; -} - function firstNonEmptyLine(text: string): string { return ( text @@ -44,22 +30,25 @@ function firstNonEmptyLine(text: string): string { ); } -function commandLooksLike(command: string, expected: string): boolean { - const base = path.basename(command).toLowerCase(); - return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; -} - function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); if (!raw) return null; const clean = raw.replace(/\s+/g, " ").trim(); const max = 240; - return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; + 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 = - /(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|login\s+required)/i; -const OPENCODE_MODEL_NOT_FOUND_RE = /ProviderModelNotFoundError|provider\s+model\s+not\s+found/i; + /(?: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, @@ -70,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", @@ -90,100 +79,138 @@ 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 }); - try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); + + 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, }); - } - - const configDefinesOpenAiKey = Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY"); - const effectiveOpenAiKey = getEffectiveEnvValue(env, "OPENAI_API_KEY"); - if (isNonEmpty(effectiveOpenAiKey)) { - const source = configDefinesOpenAiKey ? "adapter config env" : "server environment"; - checks.push({ - code: "opencode_openai_api_key_present", - level: "info", - message: "OPENAI_API_KEY is set for OpenCode authentication.", - detail: `Detected in ${source}.`, - }); } else { - checks.push({ - code: "opencode_openai_api_key_missing", - level: "warn", - message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.", - hint: configDefinesOpenAiKey - ? "adapterConfig.env defines OPENAI_API_KEY but it is empty. Set a non-empty value or remove the override." - : "Set OPENAI_API_KEY in adapter env/shell, or authenticate with `opencode auth login`.", - }); + 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 modelValidationPassed = false; if (canRunProbe) { - if (!commandLooksLike(command, "opencode")) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.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_hello_probe_skipped_custom_command", - level: "info", - message: "Skipped hello probe because command is not `opencode`.", - detail: command, - hint: "Use the `opencode` CLI command to run the automatic installation and auth probe.", + 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.", }); - } else { - const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL).trim(); - const variant = asString(config.variant, asString(config.effort, "")).trim(); - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); + } + } - const args = ["run", "--format", "json"]; - if (model) args.push("--model", model); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - args.push("Respond with hello."); + const 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: runtimeEnv, + }); + 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); + + try { const probe = await runChildProcess( `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, command, args, { cwd, - env, - timeoutSec: 45, + 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 modelNotFound = OPENCODE_MODEL_NOT_FOUND_RE.test(authEvidence); - const modelProvider = (() => { - const slash = model.indexOf("/"); - if (slash <= 0) return "openai"; - return model.slice(0, slash).toLowerCase(); - })(); if (probe.timedOut) { checks.push({ code: "opencode_hello_probe_timed_out", level: "warn", message: "OpenCode hello probe timed out.", - hint: "Retry the probe. If this persists, verify `opencode run --format json \"Respond with hello\"` manually.", + hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", }); - } else if ((probe.exitCode ?? 1) === 0) { + } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { const summary = parsed.summary.trim(); const hasHello = /\bhello\b/i.test(summary); checks.push({ @@ -196,24 +223,16 @@ export async function testEnvironment( ...(hasHello ? {} : { - hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.", + hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", }), }); - } else if (modelNotFound) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: `OpenCode could not run model \`${model}\`.`, - ...(detail ? { detail } : {}), - hint: `Run \`opencode models ${modelProvider}\` and set adapterConfig.model to one of the available models.`, - }); } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { checks.push({ code: "opencode_hello_probe_auth_required", level: "warn", - message: "OpenCode CLI is installed, but authentication is not ready.", + message: "OpenCode is installed, but provider authentication is not ready.", ...(detail ? { detail } : {}), - hint: "Configure OPENAI_API_KEY in adapter env/shell, then retry the probe.", + hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", }); } else { checks.push({ @@ -221,9 +240,17 @@ export async function testEnvironment( level: "error", message: "OpenCode hello probe failed.", ...(detail ? { detail } : {}), - hint: "Run `opencode run --format json \"Respond with hello\"` manually in this working directory to debug.", + 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: err instanceof Error ? err.message : String(err), + hint: "Run `opencode run --format json` manually in this working directory to debug.", + }); } } diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 88b3c0ac..3abfd6cd 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -1,5 +1,4 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; function parseCommaArgs(value: string): string[] { return value @@ -56,10 +55,12 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): 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[] { @@ -51,6 +82,24 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt 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") { const sessionId = asString(parsed.sessionID); return [ @@ -62,93 +111,31 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt ]; } - if (type === "text") { - const part = asRecord(parsed.part); - const text = asString(part?.text).trim(); - if (!text) return []; - return [{ kind: "assistant", ts, text }]; - } - - if (type === "tool_use") { - const part = asRecord(parsed.part); - const toolUseId = asString(part?.callID, asString(part?.id, "tool_use")); - const toolName = asString(part?.tool, "tool"); - const state = asRecord(part?.state); - const input = state?.input ?? {}; - const output = asString(state?.output).trim(); - const status = asString(state?.status).trim(); - const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN); - const isError = - status === "failed" || - status === "error" || - status === "cancelled" || - (Number.isFinite(exitCode) && exitCode !== 0); - - const entries: TranscriptEntry[] = [ - { - kind: "tool_call", - ts, - name: toolName, - input, - }, - ]; - - if (status || output) { - const lines: string[] = []; - if (status) lines.push(`status: ${status}`); - if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`); - if (output) { - if (lines.length > 0) lines.push(""); - if (isJsonLike(output)) { - try { - lines.push(JSON.stringify(JSON.parse(output), null, 2)); - } catch { - lines.push(output); - } - } else { - lines.push(output); - } - } - entries.push({ - kind: "tool_result", - ts, - toolUseId, - content: lines.join("\n").trim() || "tool completed", - isError, - }); - } - - return entries; - } - if (type === "step_finish") { const part = asRecord(parsed.part); const tokens = asRecord(part?.tokens); const cache = asRecord(tokens?.cache); - const reason = asString(part?.reason); + 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), - outputTokens: asNumber(tokens?.output), - cachedTokens: asNumber(cache?.read), - costUsd: asNumber(part?.cost), - subtype: reason || "step_finish", - isError: reason === "error" || reason === "failed", + 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 message = - asString(parsed.message) || - asString(asRecord(parsed.part)?.message) || - stringifyUnknown(parsed.error ?? asRecord(parsed.part)?.error) || - line; - return [{ kind: "stderr", ts, text: message }]; + 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/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/shared/src/constants.ts b/packages/shared/src/constants.ts index 02e83817..4f6b75b9 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -21,7 +21,15 @@ export const AGENT_STATUSES = [ ] as const; export type AgentStatus = (typeof AGENT_STATUSES)[number]; -export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "opencode_local", "cursor", "openclaw"] as const; +export const AGENT_ADAPTER_TYPES = [ + "process", + "http", + "claude_local", + "codex_local", + "opencode_local", + "cursor", + "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 fcfb7e78..ae7e8285 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,6 @@ 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 @@ -79,8 +76,8 @@ importers: packages/adapter-utils: devDependencies: '@types/node': - specifier: ^24.6.0 - version: 24.11.0 + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -95,8 +92,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^24.6.0 - version: 24.11.0 + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -111,21 +108,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^24.6.0 - version: 24.11.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: + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -140,8 +124,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^24.6.0 - version: 24.11.0 + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -155,6 +139,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 @@ -172,8 +159,8 @@ importers: version: 3.4.8 devDependencies: '@types/node': - specifier: ^24.6.0 - version: 24.11.0 + specifier: ^22.12.0 + version: 22.19.11 drizzle-kit: specifier: ^0.31.9 version: 0.31.9 @@ -185,7 +172,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(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: @@ -208,9 +195,6 @@ 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 @@ -227,8 +211,8 @@ importers: specifier: workspace:* version: link:../packages/shared better-auth: - 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.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + 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)) detect-port: specifier: ^2.1.0 version: 2.1.0 @@ -238,9 +222,6 @@ 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 @@ -265,6 +246,10 @@ 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 @@ -276,13 +261,13 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^24.6.0 - version: 24.11.0 + specifier: ^22.12.0 + version: 22.19.11 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 '@types/ws': - specifier: ^8.18.1 + specifier: ^8.5.14 version: 8.18.1 supertest: specifier: ^7.0.0 @@ -295,10 +280,10 @@ importers: version: 5.9.3 vite: specifier: ^6.1.0 - version: 6.4.1(@types/node@24.11.0)(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@24.11.0)(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: @@ -320,9 +305,6 @@ 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 @@ -2837,9 +2819,6 @@ packages: '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} - '@types/node@24.11.0': - resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} - '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -8221,10 +8200,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.11.0': - dependencies: - undici-types: 7.16.0 - '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -8292,13 +8267,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.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@24.11.0)(jiti@2.6.1)(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) '@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: @@ -8371,7 +8346,8 @@ snapshots: assertion-error@2.0.1: {} - async-exit-hook@2.0.1: {} + async-exit-hook@2.0.1: + optional: true asynckit@0.4.0: {} @@ -8383,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@24.11.0)(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)) @@ -8403,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@24.11.0)(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: @@ -8700,6 +8676,7 @@ snapshots: '@embedded-postgres/windows-x64': 18.1.0-beta.16 transitivePeerDependencies: - pg-native + optional: true encodeurl@2.0.0: {} @@ -9892,15 +9869,19 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.11.0: {} + pg-connection-string@2.11.0: + optional: true - pg-int8@1.0.1: {} + pg-int8@1.0.1: + optional: true pg-pool@3.11.0(pg@8.18.0): dependencies: pg: 8.18.0 + optional: true - pg-protocol@1.11.0: {} + pg-protocol@1.11.0: + optional: true pg-types@2.2.0: dependencies: @@ -9909,6 +9890,7 @@ snapshots: postgres-bytea: 1.0.1 postgres-date: 1.0.7 postgres-interval: 1.2.0 + optional: true pg@8.18.0: dependencies: @@ -9919,10 +9901,12 @@ 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: {} @@ -9990,15 +9974,19 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} + postgres-array@2.0.0: + optional: true - postgres-bytea@1.0.1: {} + postgres-bytea@1.0.1: + optional: true - postgres-date@1.0.7: {} + postgres-date@1.0.7: + optional: true postgres-interval@1.2.0: dependencies: xtend: 4.0.2 + optional: true postgres@3.4.8: {} @@ -10644,13 +10632,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@24.11.0)(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): 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@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + 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 @@ -10686,7 +10674,7 @@ snapshots: - tsx - yaml - vite@6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + 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) @@ -10695,7 +10683,7 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 22.19.11 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -10716,7 +10704,7 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 - vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(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) @@ -10725,7 +10713,7 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 22.19.11 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -10746,11 +10734,11 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(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): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.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/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -10768,12 +10756,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) - vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.6.1)(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-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': 24.11.0 + '@types/node': 22.19.11 transitivePeerDependencies: - jiti - less 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/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index b03166b6..7ec3f965 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"; import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local"; +import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server"; import { listAdapterModels } from "../adapters/index.js"; import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js"; import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js"; @@ -8,9 +9,11 @@ import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from ". describe("adapter model listing", () => { beforeEach(() => { delete process.env.OPENAI_API_KEY; + delete process.env.PAPERCLIP_OPENCODE_COMMAND; resetCodexModelsCacheForTests(); resetCursorModelsCacheForTests(); setCursorModelsRunnerForTests(null); + resetOpenCodeModelsCacheForTests(); vi.restoreAllMocks(); }); @@ -60,6 +63,7 @@ describe("adapter model listing", () => { expect(models).toEqual(codexFallbackModels); }); + it("returns cursor fallback models when CLI discovery is unavailable", async () => { setCursorModelsRunnerForTests(() => ({ status: null, @@ -90,4 +94,11 @@ describe("adapter model listing", () => { expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true); expect(first.some((model) => model.id === "composer-1")).toBe(true); }); + + 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 57b057d1..c353603a 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -18,11 +18,14 @@ import { } from "@paperclipai/adapter-cursor-local/server"; import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local"; import { - execute as opencodeExecute, - testEnvironment as opencodeTestEnvironment, - sessionCodec as opencodeSessionCodec, + 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 { + agentConfigurationDoc as openCodeAgentConfigurationDoc, +} from "@paperclipai/adapter-opencode-local"; import { execute as openclawExecute, testEnvironment as openclawTestEnvironment, @@ -57,16 +60,6 @@ const codexLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: codexAgentConfigurationDoc, }; -const opencodeLocalAdapter: ServerAdapterModule = { - type: "opencode_local", - execute: opencodeExecute, - testEnvironment: opencodeTestEnvironment, - sessionCodec: opencodeSessionCodec, - models: opencodeModels, - supportsLocalAgentJwt: true, - agentConfigurationDoc: opencodeAgentConfigurationDoc, -}; - const cursorLocalAdapter: ServerAdapterModule = { type: "cursor", execute: cursorExecute, @@ -87,8 +80,19 @@ const openclawAdapter: ServerAdapterModule = { agentConfigurationDoc: openclawAgentConfigurationDoc, }; +const openCodeLocalAdapter: ServerAdapterModule = { + type: "opencode_local", + execute: openCodeExecute, + testEnvironment: openCodeTestEnvironment, + sessionCodec: openCodeSessionCodec, + models: [], + listModels: listOpenCodeModels, + supportsLocalAgentJwt: true, + agentConfigurationDoc: openCodeAgentConfigurationDoc, +}; + const adaptersByType = new Map( - [claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), + [claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, 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 607c451a..e91f00f9 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -38,6 +38,7 @@ import { } 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) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { @@ -204,6 +205,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; @@ -335,7 +357,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); @@ -589,6 +613,11 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, { strictMode: strictSecretsMode }, ); + await assertAdapterConfigConstraints( + companyId, + hireInput.adapterType, + normalizedAdapterConfig, + ); const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, @@ -724,6 +753,11 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, { strictMode: strictSecretsMode }, ); + await assertAdapterConfigConstraints( + companyId, + req.body.adapterType, + normalizedAdapterConfig, + ); const agent = await svc.create(companyId, { ...req.body, @@ -903,6 +937,27 @@ 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 rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + ? (asRecord(patchData.adapterConfig) ?? {}) + : (asRecord(existing.adapterConfig) ?? {}); + const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + rawEffectiveAdapterConfig, + { strictMode: strictSecretsMode }, + ); + await assertAdapterConfigConstraints( + existing.companyId, + requestedAdapterType, + effectiveAdapterConfig, + ); + } + const actor = getActorInfo(req); const agent = await svc.update(id, patchData, { recordRevision: { 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 index e408c113..043e91c1 100644 --- a/ui/src/adapters/opencode-local/config-fields.tsx +++ b/ui/src/adapters/opencode-local/config-fields.tsx @@ -8,7 +8,7 @@ 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 prompt at runtime."; + "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, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 0b91f694..85486af9 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -117,7 +117,10 @@ 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/${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 a99fb25b..861c69f6 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -25,6 +25,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 { @@ -42,6 +43,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 ---- */ @@ -132,7 +134,7 @@ const codexThinkingEffortOptions = [ { id: "high", label: "High" }, ] as const; -const opencodeVariantOptions = [ +const openCodeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, { id: "low", label: "Low" }, @@ -279,9 +281,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) { 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 ?? []; @@ -339,17 +347,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "modelReasoningEffort" : adapterType === "cursor" ? "mode" - : adapterType === "opencode_local" - ? "variant" - : "effort"; + : adapterType === "opencode_local" + ? "variant" + : "effort"; const thinkingEffortOptions = adapterType === "codex_local" ? codexThinkingEffortOptions : adapterType === "cursor" ? cursorModeOptions - : adapterType === "opencode_local" - ? opencodeVariantOptions - : claudeThinkingEffortOptions; + : adapterType === "opencode_local" + ? openCodeThinkingEffortOptions + : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" @@ -360,8 +368,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ) : adapterType === "cursor" ? eff("adapterConfig", "mode", String(config.mode ?? "")) - : adapterType === "opencode_local" - ? eff("adapterConfig", "variant", String(config.variant ?? "")) + : 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))) @@ -605,9 +613,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "codex" : adapterType === "cursor" ? "agent" - : adapterType === "opencode_local" - ? "opencode" - : "claude" + : adapterType === "opencode_local" + ? "opencode" + : "claude" } /> @@ -622,7 +630,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."} +

+ )} @@ -918,7 +939,10 @@ function AdapterTypeDropdown({ if (!item.comingSoon) onChange(item.value); }} > - {item.label} + + {item.value === "opencode_local" ? : null} + {item.label} + {item.comingSoon && ( Coming soon )} @@ -1184,20 +1208,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 ( @@ -1211,7 +1271,9 @@ function ModelDropdown({ @@ -1225,33 +1287,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/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 14a902cb..a5392716 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -55,14 +55,23 @@ 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, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, + } = 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 +91,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 +103,7 @@ export function NewAgentDialog() { setReportsTo(""); setConfigValues(defaultCreateValues); setExpanded(true); + setFormError(null); } function buildAdapterConfig() { @@ -100,6 +113,35 @@ 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; + } + 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( + 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 +323,11 @@ export function NewAgentDialog() { {isFirstAgent ? "This will be the CEO" : ""} +
+ {formError && ( +
{formError}
+ )} +
@@ -707,36 +802,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. +

+ )}
@@ -802,7 +921,7 @@ export function OnboardingWizard() { : adapterType === "codex_local" ? `${effectiveAdapterCommand} exec --json -` : adapterType === "opencode_local" - ? `${effectiveAdapterCommand} run --format json \"Respond with hello.\"` + ? `${effectiveAdapterCommand} run --format json "Respond with hello."` : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}

@@ -825,6 +944,12 @@ 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{" "} 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 5dab7744..5d1a3539 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.", diff --git a/ui/src/lib/model-utils.ts b/ui/src/lib/model-utils.ts new file mode 100644 index 00000000..baa721f5 --- /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).trim(); +} 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 7c8c6b19..5ed0cf27 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/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"], }, });