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}
+ )}
+
+ )}
+
+ {groupedModels.map((group) => (
+
+ {adapterType === "opencode_local" && (
+
+ {group.provider} ({group.entries.length})
+
+ )}
+ {group.entries.map((m) => (
+
{
+ setModel(m.id);
+ setModelOpen(false);
+ }}
+ >
+
+ {adapterType === "opencode_local" ? extractModelName(m.id) : m.label}
+
+
+ ))}
+
+ ))}
+
+ {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 (
+ <>
+
+
+ >
+ );
+}
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"],
},
});