Add OpenCode provider integration and strict model selection

This commit is contained in:
Konan69
2026-03-05 15:24:20 +01:00
parent c7c96feef7
commit 6a101e0da1
55 changed files with 2225 additions and 104 deletions

View File

@@ -0,0 +1,47 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function OpenCodeLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
}: AdapterConfigFieldsProps) {
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}

View File

@@ -0,0 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parseOpenCodeStdoutLine } from "@paperclipai/adapter-opencode-local/ui";
import { OpenCodeLocalConfigFields } from "./config-fields";
import { buildOpenCodeLocalConfig } from "@paperclipai/adapter-opencode-local/ui";
export const openCodeLocalUIAdapter: UIAdapterModule = {
type: "opencode_local",
label: "OpenCode (local)",
parseStdoutLine: parseOpenCodeStdoutLine,
ConfigFields: OpenCodeLocalConfigFields,
buildAdapterConfig: buildOpenCodeLocalConfig,
};

View File

@@ -1,12 +1,13 @@
import type { UIAdapterModule } from "./types";
import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { openClawUIAdapter } from "./openclaw";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
const adaptersByType = new Map<string, UIAdapterModule>(
[claudeLocalUIAdapter, codexLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
);
export function getUIAdapter(type: string): UIAdapterModule {

View File

@@ -117,7 +117,8 @@ export const agentsApi = {
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
adapterModels: (companyId: string, type: string) =>
api.get<AdapterModel[]>(`/companies/${companyId}/adapters/${type}/models`),
testEnvironment: (
companyId: string,
type: string,

View File

@@ -40,6 +40,7 @@ import { getUIAdapter } from "../adapters";
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
/* ---- Create mode values ---- */
@@ -122,6 +123,19 @@ function formatArgList(value: unknown): string {
return typeof value === "string" ? value : "";
}
function extractProviderId(modelId: string): string | null {
const trimmed = modelId.trim();
if (!trimmed.includes("/")) return null;
const provider = trimmed.slice(0, trimmed.indexOf("/")).trim();
return provider || null;
}
function extractModelName(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed.includes("/")) return trimmed;
return trimmed.slice(trimmed.indexOf("/") + 1);
}
const codexThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },
@@ -130,6 +144,15 @@ const codexThinkingEffortOptions = [
{ id: "high", label: "High" },
] as const;
const openCodeThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "max", label: "Max" },
] as const;
const claudeThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "low", label: "Low" },
@@ -254,13 +277,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const adapterType = isCreate
? props.values.adapterType
: overlay.adapterType ?? props.agent.adapterType;
const isLocal = adapterType === "claude_local" || adapterType === "codex_local";
const isLocal =
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local";
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
// Fetch adapter models for the effective adapter type
const { data: fetchedModels } = useQuery({
queryKey: ["adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(adapterType),
const {
data: fetchedModels,
error: fetchedModelsError,
} = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType),
enabled: Boolean(selectedCompanyId),
});
const models = fetchedModels ?? externalModels ?? [];
@@ -313,9 +343,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? val!.model
: eff("adapterConfig", "model", String(config.model ?? ""));
const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : "effort";
const thinkingEffortKey =
adapterType === "codex_local"
? "modelReasoningEffort"
: adapterType === "opencode_local"
? "variant"
: "effort";
const thinkingEffortOptions =
adapterType === "codex_local" ? codexThinkingEffortOptions : claudeThinkingEffortOptions;
adapterType === "codex_local"
? codexThinkingEffortOptions
: adapterType === "opencode_local"
? openCodeThinkingEffortOptions
: claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: adapterType === "codex_local"
@@ -324,6 +363,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
)
: adapterType === "opencode_local"
? eff("adapterConfig", "variant", String(config.variant ?? ""))
: eff("adapterConfig", "effort", String(config.effort ?? ""));
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
@@ -549,7 +590,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
immediate
className={inputClass}
placeholder={adapterType === "codex_local" ? "codex" : "claude"}
placeholder={
adapterType === "codex_local"
? "codex"
: adapterType === "opencode_local"
? "opencode"
: "claude"
}
/>
</Field>
@@ -563,7 +610,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}
required={adapterType === "opencode_local"}
groupByProvider={adapterType === "opencode_local"}
/>
{fetchedModelsError && (
<p className="text-xs text-destructive">
{fetchedModelsError instanceof Error
? fetchedModelsError.message
: "Failed to load adapter models."}
</p>
)}
<ThinkingEffortDropdown
value={currentThinkingEffort}
@@ -817,7 +874,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
/* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]);
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
@@ -840,7 +897,10 @@ function AdapterTypeDropdown({
<Popover>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span>{adapterLabels[value] ?? value}</span>
<span className="inline-flex items-center gap-1.5">
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span>{adapterLabels[value] ?? value}</span>
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
@@ -860,7 +920,10 @@ function AdapterTypeDropdown({
if (!item.comingSoon) onChange(item.value);
}}
>
<span>{item.label}</span>
<span className="inline-flex items-center gap-1.5">
{item.value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span>{item.label}</span>
</span>
{item.comingSoon && (
<span className="text-[10px] text-muted-foreground">Coming soon</span>
)}
@@ -1126,20 +1189,56 @@ function ModelDropdown({
onChange,
open,
onOpenChange,
allowDefault,
required,
groupByProvider,
}: {
models: AdapterModel[];
value: string;
onChange: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
allowDefault: boolean;
required: boolean;
groupByProvider: boolean;
}) {
const [modelSearch, setModelSearch] = useState("");
const selected = models.find((m) => m.id === value);
const filteredModels = models.filter((m) => {
if (!modelSearch.trim()) return true;
const q = modelSearch.toLowerCase();
return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q);
});
const filteredModels = useMemo(() => {
return models.filter((m) => {
if (!modelSearch.trim()) return true;
const q = modelSearch.toLowerCase();
const provider = extractProviderId(m.id) ?? "";
return (
m.id.toLowerCase().includes(q) ||
m.label.toLowerCase().includes(q) ||
provider.toLowerCase().includes(q)
);
});
}, [models, modelSearch]);
const groupedModels = useMemo(() => {
if (!groupByProvider) {
return [
{
provider: "models",
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
},
];
}
const map = new Map<string, AdapterModel[]>();
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 (
<Field label="Model" hint={help.model}>
@@ -1153,7 +1252,9 @@ function ModelDropdown({
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
{selected ? selected.label : value || "Default"}
{selected
? selected.label
: value || (allowDefault ? "Default" : required ? "Select model (required)" : "Select model")}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
@@ -1167,33 +1268,45 @@ function ModelDropdown({
autoFocus
/>
<div className="max-h-[240px] overflow-y-auto">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
)}
onClick={() => {
onChange("");
onOpenChange(false);
}}
>
Default
</button>
{filteredModels.map((m) => (
{allowDefault && (
<button
key={m.id}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
)}
onClick={() => {
onChange(m.id);
onChange("");
onOpenChange(false);
}}
>
<span>{m.label}</span>
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
Default
</button>
)}
{groupedModels.map((group) => (
<div key={group.provider} className="mb-1 last:mb-0">
{groupByProvider && (
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{group.provider} ({group.entries.length})
</div>
)}
{group.entries.map((m) => (
<button
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
)}
onClick={() => {
onChange(m.id);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate" title={m.id}>
{groupByProvider ? extractModelName(m.id) : m.label}
</span>
</button>
))}
</div>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>

View File

@@ -17,6 +17,7 @@ interface AgentPropertiesProps {
const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
cursor: "Cursor",
process: "Process",

View File

@@ -55,14 +55,21 @@ export function NewAgentDialog() {
enabled: !!selectedCompanyId && newAgentOpen,
});
const { data: adapterModels } = useQuery({
queryKey: ["adapter-models", configValues.adapterType],
queryFn: () => agentsApi.adapterModels(configValues.adapterType),
enabled: newAgentOpen,
const {
data: adapterModels,
error: adapterModelsError,
} = useQuery({
queryKey:
selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
: ["agents", "none", "adapter-models", configValues.adapterType],
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
enabled: Boolean(selectedCompanyId) && newAgentOpen,
});
const isFirstAgent = !agents || agents.length === 0;
const effectiveRole = isFirstAgent ? "ceo" : role;
const [formError, setFormError] = useState<string | null>(null);
// Auto-fill for CEO
useEffect(() => {
@@ -82,6 +89,9 @@ export function NewAgentDialog() {
closeNewAgent();
navigate(agentUrl(result.agent));
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Failed to create agent");
},
});
function reset() {
@@ -91,6 +101,7 @@ export function NewAgentDialog() {
setReportsTo("");
setConfigValues(defaultCreateValues);
setExpanded(true);
setFormError(null);
}
function buildAdapterConfig() {
@@ -100,6 +111,31 @@ export function NewAgentDialog() {
function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
setFormError(null);
if (configValues.adapterType === "opencode_local") {
const selectedModel = configValues.model.trim();
if (!selectedModel) {
setFormError("OpenCode requires an explicit model in provider/model format.");
return;
}
if (adapterModelsError) {
setFormError(
adapterModelsError instanceof Error
? adapterModelsError.message
: "Failed to load OpenCode models.",
);
return;
}
const discovered = adapterModels ?? [];
if (!discovered.some((entry) => entry.id === selectedModel)) {
setFormError(
discovered.length === 0
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
: `Configured OpenCode model is unavailable: ${selectedModel}`,
);
return;
}
}
createAgent.mutate({
name: name.trim(),
role: effectiveRole,
@@ -281,6 +317,11 @@ export function NewAgentDialog() {
<span className="text-xs text-muted-foreground">
{isFirstAgent ? "This will be the CEO" : ""}
</span>
</div>
{formError && (
<div className="px-4 pb-2 text-xs text-destructive">{formError}</div>
)}
<div className="flex items-center justify-end px-4 pb-3">
<Button
size="sm"
disabled={!name.trim() || createAgent.isPending}

View File

@@ -54,6 +54,12 @@ function getContrastTextColor(hexColor: string): string {
return luminance > 0.5 ? "#000000" : "#ffffff";
}
function extractProviderId(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed.includes("/")) return "other";
return trimmed.slice(0, trimmed.indexOf("/")).trim() || "other";
}
interface IssueDraft {
title: string;
description: string;
@@ -67,7 +73,7 @@ interface IssueDraft {
assigneeUseProjectWorkspace: boolean;
}
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]);
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
const ISSUE_THINKING_EFFORT_OPTIONS = {
claude_local: [
@@ -83,6 +89,14 @@ const ISSUE_THINKING_EFFORT_OPTIONS = {
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
opencode_local: [
{ value: "", label: "Default" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "max", label: "Max" },
],
} as const;
function buildAssigneeAdapterOverrides(input: {
@@ -104,6 +118,8 @@ function buildAssigneeAdapterOverrides(input: {
adapterConfig.modelReasoningEffort = input.thinkingEffortOverride;
} else if (adapterType === "claude_local") {
adapterConfig.effort = input.thinkingEffortOverride;
} else if (adapterType === "opencode_local") {
adapterConfig.variant = input.thinkingEffortOverride;
}
}
if (adapterType === "claude_local" && input.chrome) {
@@ -237,9 +253,12 @@ export function NewIssueDialog() {
}, [agents, orderedProjects]);
const { data: assigneeAdapterModels } = useQuery({
queryKey: ["adapter-models", assigneeAdapterType],
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
queryKey:
effectiveCompanyId && assigneeAdapterType
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType)
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!),
enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides,
});
const createIssue = useMutation({
@@ -351,7 +370,9 @@ export function NewIssueDialog() {
const validThinkingValues =
assigneeAdapterType === "codex_local"
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
: assigneeAdapterType === "opencode_local"
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
setAssigneeThinkingEffort("");
}
@@ -451,10 +472,14 @@ export function NewIssueDialog() {
? "Claude options"
: assigneeAdapterType === "codex_local"
? "Codex options"
: assigneeAdapterType === "opencode_local"
? "OpenCode options"
: "Agent options";
const thinkingEffortOptions =
assigneeAdapterType === "codex_local"
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
: assigneeAdapterType === "opencode_local"
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
@@ -477,12 +502,21 @@ export function NewIssueDialog() {
[orderedProjects],
);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() =>
(assigneeAdapterModels ?? []).map((model) => ({
id: model.id,
label: model.label,
searchText: model.id,
})),
() => {
return [...(assigneeAdapterModels ?? [])]
.sort((a, b) => {
const providerA = extractProviderId(a.id);
const providerB = extractProviderId(b.id);
const byProvider = providerA.localeCompare(providerB);
if (byProvider !== 0) return byProvider;
return a.id.localeCompare(b.id);
})
.map((model) => ({
id: model.id,
label: model.label,
searchText: `${model.id} ${extractProviderId(model.id)}`,
}));
},
[assigneeAdapterModels],
);

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
@@ -26,6 +26,7 @@ import {
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import {
Building2,
Bot,
@@ -49,6 +50,7 @@ type Step = 1 | 2 | 3 | 4;
type AdapterType =
| "claude_local"
| "codex_local"
| "opencode_local"
| "process"
| "http"
| "openclaw";
@@ -59,6 +61,19 @@ Ensure you have a folder agents/ceo and then download this AGENTS.md as well as
And after you've finished that, hire yourself a Founding Engineer agent`;
function extractProviderId(modelId: string): string | null {
const trimmed = modelId.trim();
if (!trimmed.includes("/")) return null;
const provider = trimmed.slice(0, trimmed.indexOf("/")).trim();
return provider || null;
}
function extractModelName(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed.includes("/")) return trimmed;
return trimmed.slice(trimmed.indexOf("/") + 1);
}
export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
@@ -72,6 +87,7 @@ export function OnboardingWizard() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modelOpen, setModelOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
// Step 1
const [companyName, setCompanyName] = useState("");
@@ -142,15 +158,21 @@ export function OnboardingWizard() {
if (step === 3) autoResizeTextarea();
}, [step, taskDescription, autoResizeTextarea]);
const { data: adapterModels } = useQuery({
queryKey: ["adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(adapterType),
enabled: onboardingOpen && step === 2
const {
data: adapterModels,
error: adapterModelsError,
} = useQuery({
queryKey:
createdCompanyId
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
});
const isLocalAdapter =
adapterType === "claude_local" || adapterType === "codex_local";
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local";
const effectiveAdapterCommand =
command.trim() || (adapterType === "codex_local" ? "codex" : "claude");
command.trim() || (adapterType === "codex_local" ? "codex" : adapterType === "opencode_local" ? "opencode" : "claude");
useEffect(() => {
if (step !== 2) return;
@@ -159,6 +181,41 @@ export function OnboardingWizard() {
}, [step, adapterType, cwd, model, command, args, url]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const filteredModels = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
return (adapterModels ?? []).filter((entry) => {
if (!query) return true;
const provider = extractProviderId(entry.id) ?? "";
return (
entry.id.toLowerCase().includes(query) ||
entry.label.toLowerCase().includes(query) ||
provider.toLowerCase().includes(query)
);
});
}, [adapterModels, modelSearch]);
const groupedModels = useMemo(() => {
if (adapterType !== "opencode_local") {
return [
{
provider: "models",
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
},
];
}
const groups = new Map<string, Array<{ id: string; label: string }>>();
for (const entry of filteredModels) {
const provider = extractProviderId(entry.id) ?? "other";
const bucket = groups.get(provider) ?? [];
bucket.push(entry);
groups.set(provider, bucket);
}
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, entries]) => ({
provider,
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
}));
}, [filteredModels, adapterType]);
function reset() {
setStep(1);
@@ -273,6 +330,31 @@ export function OnboardingWizard() {
setLoading(true);
setError(null);
try {
if (adapterType === "opencode_local") {
const selectedModelId = model.trim();
if (!selectedModelId) {
setError("OpenCode requires an explicit model in provider/model format.");
return;
}
if (adapterModelsError) {
setError(
adapterModelsError instanceof Error
? adapterModelsError.message
: "Failed to load OpenCode models.",
);
return;
}
const discoveredModels = adapterModels ?? [];
if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
setError(
discoveredModels.length === 0
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
: `Configured OpenCode model is unavailable: ${selectedModelId}`,
);
return;
}
}
if (isLocalAdapter) {
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
if (!result) return;
@@ -500,6 +582,12 @@ export function OnboardingWizard() {
icon: Code,
desc: "Local Codex agent"
},
{
value: "opencode_local" as const,
label: "OpenCode",
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent"
},
{
value: "openclaw" as const,
label: "OpenClaw",
@@ -546,7 +634,15 @@ export function OnboardingWizard() {
setAdapterType(nextType);
if (nextType === "codex_local" && !model) {
setModel(DEFAULT_CODEX_LOCAL_MODEL);
return;
}
if (nextType === "opencode_local") {
if (!model.includes("/")) {
setModel("");
}
return;
}
setModel("");
}}
>
<opt.icon className="h-4 w-4" />
@@ -561,7 +657,8 @@ export function OnboardingWizard() {
{/* Conditional adapter fields */}
{(adapterType === "claude_local" ||
adapterType === "codex_local") && (
adapterType === "codex_local" ||
adapterType === "opencode_local") && (
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1">
@@ -585,7 +682,13 @@ export function OnboardingWizard() {
<label className="text-xs text-muted-foreground mb-1 block">
Model
</label>
<Popover open={modelOpen} onOpenChange={setModelOpen}>
<Popover
open={modelOpen}
onOpenChange={(next) => {
setModelOpen(next);
if (!next) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span
@@ -595,7 +698,10 @@ export function OnboardingWizard() {
>
{selectedModel
? selectedModel.label
: model || "Default"}
: model ||
(adapterType === "opencode_local"
? "Select model (required)"
: "Default")}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
@@ -604,36 +710,60 @@ export function OnboardingWizard() {
className="w-[var(--radix-popover-trigger-width)] p-1"
align="start"
>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!model && "bg-accent"
)}
onClick={() => {
setModel("");
setModelOpen(false);
}}
>
Default
</button>
{(adapterModels ?? []).map((m) => (
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
{adapterType !== "opencode_local" && (
<button
key={m.id}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === model && "bg-accent"
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!model && "bg-accent"
)}
onClick={() => {
setModel(m.id);
setModel("");
setModelOpen(false);
}}
>
<span>{m.label}</span>
<span className="text-xs text-muted-foreground font-mono">
{m.id}
</span>
</button>
))}
Default
</button>
)}
<div className="max-h-[240px] overflow-y-auto">
{groupedModels.map((group) => (
<div key={group.provider} className="mb-1 last:mb-0">
{adapterType === "opencode_local" && (
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{group.provider} ({group.entries.length})
</div>
)}
{group.entries.map((m) => (
<button
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === model && "bg-accent"
)}
onClick={() => {
setModel(m.id);
setModelOpen(false);
}}
>
<span className="block w-full text-left truncate" title={m.id}>
{adapterType === "opencode_local" ? extractModelName(m.id) : m.label}
</span>
</button>
))}
</div>
))}
</div>
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">
No models discovered.
</p>
)}
</PopoverContent>
</Popover>
</div>
@@ -678,6 +808,8 @@ export function OnboardingWizard() {
<p className="text-muted-foreground font-mono break-all">
{adapterType === "codex_local"
? `${effectiveAdapterCommand} exec --json -`
: adapterType === "opencode_local"
? `${effectiveAdapterCommand} run --format json`
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
</p>
<p className="text-muted-foreground">
@@ -691,6 +823,12 @@ export function OnboardingWizard() {
env or run{" "}
<span className="font-mono">codex login</span>.
</p>
) : adapterType === "opencode_local" ? (
<p className="text-muted-foreground">
If providers are unavailable, run{" "}
<span className="font-mono">opencode models</span> and{" "}
<span className="font-mono">opencode auth login</span>.
</p>
) : (
<p className="text-muted-foreground">
If login is required, run{" "}

View File

@@ -0,0 +1,22 @@
import { cn } from "../lib/utils";
interface OpenCodeLogoIconProps {
className?: string;
}
export function OpenCodeLogoIcon({ className }: OpenCodeLogoIconProps) {
return (
<>
<img
src="/brands/opencode-logo-light-square.svg"
alt="OpenCode"
className={cn("dark:hidden", className)}
/>
<img
src="/brands/opencode-logo-dark-square.svg"
alt="OpenCode"
className={cn("hidden dark:block", className)}
/>
</>
);
}

View File

@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
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<string, string> = {
search: "Enable Codex web search capability during runs.",
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
command: "The command to execute (e.g. node, python).",
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).",
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
@@ -52,6 +52,7 @@ export const help: Record<string, string> = {
export const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
cursor: "Cursor",
process: "Process",

View File

@@ -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,

View File

@@ -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({

View File

@@ -23,6 +23,7 @@ import type { Agent } from "@paperclipai/shared";
const adapterLabels: Record<string, string> = {
claude_local: "Claude",
codex_local: "Codex",
opencode_local: "OpenCode",
openclaw: "OpenClaw",
process: "Process",
http: "HTTP",

View File

@@ -18,13 +18,14 @@ const joinAdapterOptions: AgentAdapterType[] = [
const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
cursor: "Cursor",
process: "Process",
http: "HTTP",
};
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local"]);
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local"]);
function dateTime(value: string) {
return new Date(value).toLocaleString();

View File

@@ -118,6 +118,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
const adapterLabels: Record<string, string> = {
claude_local: "Claude",
codex_local: "Codex",
opencode_local: "OpenCode",
openclaw: "OpenClaw",
process: "Process",
http: "HTTP",