feat(adapters): external adapter plugin system with dynamic UI parser

- Plugin loader: install/reload/remove/reinstall external adapters
  from npm packages or local directories
- Plugin store persisted at ~/.paperclip/adapter-plugins.json
- Self-healing UI parser resolution with version caching
- UI: Adapter Manager page, dynamic loader, display registry
  with humanized names for unknown adapter types
- Dev watch: exclude adapter-plugins dir from tsx watcher
  to prevent mid-request server restarts during reinstall
- All consumer fallbacks use getAdapterLabel() for consistent display
- AdapterTypeDropdown uses controlled open state for proper close behavior
- Remove hermes-local from built-in UI (externalized to plugin)
- Add docs for external adapters and UI parser contract
This commit is contained in:
HenkDz 2026-03-31 20:21:13 +01:00
parent f8452a4520
commit 14d59da316
72 changed files with 4102 additions and 585 deletions

View file

@ -0,0 +1,483 @@
/**
* @fileoverview Adapter Manager page install, view, and manage external adapters.
*
* Adapters are simpler than plugins: no workers, no events, no manifests.
* They just register a ServerAdapterModule that provides model discovery and execution.
*/
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Cpu, Plus, Power, Trash2, FolderOpen, Package, RefreshCw, Download } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { adaptersApi } from "@/api/adapters";
import type { AdapterInfo } from "@/api/adapters";
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { ChoosePathButton } from "@/components/PathInstructionsModal";
import { invalidateDynamicParser } from "@/adapters/dynamic-loader";
function AdapterRow({
adapter,
canRemove,
onToggle,
onRemove,
onReload,
onReinstall,
isToggling,
isReloading,
isReinstalling,
}: {
adapter: AdapterInfo;
canRemove: boolean;
onToggle: (type: string, disabled: boolean) => void;
onRemove: (type: string) => void;
onReload?: (type: string) => void;
onReinstall?: (type: string) => void;
isToggling: boolean;
isReloading?: boolean;
isReinstalling?: boolean;
}) {
return (
<li>
<div className="flex items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("font-medium", adapter.disabled && "text-muted-foreground line-through")}>
{adapter.label || getAdapterLabel(adapter.type)}
</span>
<Badge variant="outline">{adapter.source === "external" ? "External" : "Built-in"}</Badge>
<Badge
variant="default"
className={adapter.loaded ? "bg-green-600 hover:bg-green-700" : ""}
>
{adapter.loaded ? "loaded" : "error"}
</Badge>
{adapter.version && (
<Badge variant="secondary" className="font-mono text-[10px]">
v{adapter.version}
</Badge>
)}
{adapter.disabled && (
<Badge variant="secondary" className="text-amber-600 border-amber-400">
Hidden from menus
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{adapter.type}
{adapter.packageName && adapter.packageName !== adapter.type && (
<> · {adapter.packageName}</>
)}
{" · "}{adapter.modelsCount} models
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title={adapter.disabled ? "Show in agent menus" : "Hide from agent menus"}
disabled={isToggling}
onClick={() => onToggle(adapter.type, !adapter.disabled)}
>
<Power className={cn("h-4 w-4", !adapter.disabled ? "text-green-600" : "text-muted-foreground")} />
</Button>
{onReload && (
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title="Reload adapter (hot-swap)"
disabled={isReloading}
onClick={() => onReload(adapter.type)}
>
<RefreshCw className={cn("h-4 w-4", isReloading && "animate-spin")} />
</Button>
)}
{onReinstall && (
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title="Reinstall adapter (pull latest from npm)"
disabled={isReinstalling}
onClick={() => onReinstall(adapter.type)}
>
<Download className={cn("h-4 w-4", isReinstalling && "animate-bounce")} />
</Button>
)}
{canRemove && (
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Remove adapter"
onClick={() => onRemove(adapter.type)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</li>
);
}
export function AdapterManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [installPackage, setInstallPackage] = useState("");
const [installVersion, setInstallVersion] = useState("");
const [isLocalPath, setIsLocalPath] = useState(false);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [removeType, setRemoveType] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/instance/settings/general" },
{ label: "Adapters" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const { data: adapters, isLoading } = useQuery({
queryKey: queryKeys.adapters.all,
queryFn: () => adaptersApi.list(),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.adapters.all });
};
const installMutation = useMutation({
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
adaptersApi.install(params),
onSuccess: (result) => {
invalidate();
setInstallDialogOpen(false);
setInstallPackage("");
setInstallVersion("");
setIsLocalPath(false);
pushToast({
title: "Adapter installed",
body: `Type "${result.type}" registered successfully.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Install failed", body: err.message, tone: "error" });
},
});
const removeMutation = useMutation({
mutationFn: (type: string) => adaptersApi.remove(type),
onSuccess: () => {
invalidate();
pushToast({ title: "Adapter removed", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Removal failed", body: err.message, tone: "error" });
},
});
const toggleMutation = useMutation({
mutationFn: ({ type, disabled }: { type: string; disabled: boolean }) =>
adaptersApi.setDisabled(type, disabled),
onSuccess: () => {
invalidate();
},
onError: (err: Error) => {
pushToast({ title: "Toggle failed", body: err.message, tone: "error" });
},
});
const reloadMutation = useMutation({
mutationFn: (type: string) => adaptersApi.reload(type),
onSuccess: (result) => {
invalidate();
invalidateDynamicParser(result.type);
pushToast({
title: "Adapter reloaded",
body: `Type "${result.type}" reloaded.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Reload failed", body: err.message, tone: "error" });
},
});
const reinstallMutation = useMutation({
mutationFn: (type: string) => adaptersApi.reinstall(type),
onSuccess: (result) => {
invalidate();
invalidateDynamicParser(result.type);
pushToast({
title: "Adapter reinstalled",
body: `Type "${result.type}" updated from npm.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Reinstall failed", body: err.message, tone: "error" });
},
});
const builtinAdapters = (adapters ?? []).filter((a) => a.source === "builtin");
const externalAdapters = (adapters ?? []).filter((a) => a.source === "external");
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading adapters...</div>;
const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending;
return (
<div className="space-y-6 max-w-5xl">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="h-6 w-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Adapters</h1>
<Badge variant="outline" className="text-amber-600 border-amber-400">
Alpha
</Badge>
</div>
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" />
Install Adapter
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Install External Adapter</DialogTitle>
<DialogDescription>
Add an adapter from npm or a local path. The adapter package must export <code className="text-xs bg-muted px-1 py-0.5 rounded">createServerAdapter()</code>.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Source toggle */}
<div className="flex items-center gap-2">
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors",
!isLocalPath
? "border-foreground bg-accent text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
)}
onClick={() => setIsLocalPath(false)}
>
<Package className="h-3.5 w-3.5" />
npm package
</button>
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors",
isLocalPath
? "border-foreground bg-accent text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
)}
onClick={() => setIsLocalPath(true)}
>
<FolderOpen className="h-3.5 w-3.5" />
Local path
</button>
</div>
{isLocalPath ? (
/* Local path input */
<div className="grid gap-2">
<Label htmlFor="adapterLocalPath">Path to adapter package</Label>
<div className="flex gap-2">
<Input
id="adapterLocalPath"
className="flex-1 font-mono text-xs"
placeholder="/mnt/e/Projects/my-adapter or E:\Projects\my-adapter"
value={installPackage}
onChange={(e) => setInstallPackage(e.target.value)}
/>
<ChoosePathButton />
</div>
<p className="text-xs text-muted-foreground">
Accepts Linux, WSL, and Windows paths. Windows paths are auto-converted.
</p>
</div>
) : (
/* npm package input */
<>
<div className="grid gap-2">
<Label htmlFor="adapterPackageName">Package Name</Label>
<Input
id="adapterPackageName"
placeholder="my-paperclip-adapter"
value={installPackage}
onChange={(e) => setInstallPackage(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="adapterVersion">Version (optional)</Label>
<Input
id="adapterVersion"
placeholder="latest"
value={installVersion}
onChange={(e) => setInstallVersion(e.target.value)}
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
<Button
onClick={() =>
installMutation.mutate({
packageName: installPackage,
version: installVersion || undefined,
isLocalPath,
})
}
disabled={!installPackage || installMutation.isPending}
>
{installMutation.isPending ? "Installing..." : "Install"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Alpha notice */}
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">External adapters are alpha.</p>
<p className="text-muted-foreground">
The adapter plugin system is under active development. APIs and storage format may change.
Use the power icon to hide adapters from agent menus without removing them.
</p>
</div>
</div>
</div>
{/* External adapters */}
<section className="space-y-3">
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">External Adapters</h2>
</div>
{externalAdapters.length === 0 ? (
<Card className="bg-muted/30">
<CardContent className="flex flex-col items-center justify-center py-10">
<Cpu className="h-10 w-10 text-muted-foreground mb-4" />
<p className="text-sm font-medium">No external adapters installed</p>
<p className="text-xs text-muted-foreground mt-1">
Install an adapter package to extend model support.
</p>
</CardContent>
</Card>
) : (
<ul className="divide-y rounded-md border bg-card">
{externalAdapters.map((adapter) => (
<AdapterRow
key={adapter.type}
adapter={adapter}
canRemove={true}
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
onRemove={(type) => setRemoveType(type)}
onReload={(type) => reloadMutation.mutate(type)}
onReinstall={!adapter.isLocalPath ? (type) => reinstallMutation.mutate(type) : undefined}
isToggling={toggleMutation.isPending}
isReloading={reloadMutation.isPending}
isReinstalling={reinstallMutation.isPending}
/>
))}
</ul>
)}
</section>
{/* Built-in adapters */}
<section className="space-y-3">
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Built-in Adapters</h2>
</div>
{builtinAdapters.length === 0 ? (
<div className="text-sm text-muted-foreground">No built-in adapters found.</div>
) : (
<ul className="divide-y rounded-md border bg-card">
{builtinAdapters.map((adapter) => (
<AdapterRow
key={adapter.type}
adapter={adapter}
canRemove={false}
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
onRemove={() => {}}
isToggling={isMutating}
/>
))}
</ul>
)}
</section>
{/* Remove confirmation */}
<Dialog
open={removeType !== null}
onOpenChange={(open) => { if (!open) setRemoveType(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Adapter</DialogTitle>
<DialogDescription>
Are you sure you want to remove the <strong>{removeType}</strong> adapter?
It will be unregistered and removed from the adapter store.
{removeType && adapters?.find((a) => a.type === removeType)?.packageName && (
<> npm packages will be cleaned up from disk.</>
)}
{" "}This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveType(null)}>Cancel</Button>
<Button
variant="destructive"
disabled={removeMutation.isPending}
onClick={() => {
if (removeType) {
removeMutation.mutate(removeType, {
onSettled: () => setRemoveType(null),
});
}
}}
>
{removeMutation.isPending ? "Removing..." : "Remove"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -285,6 +285,98 @@ function asNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
export function RunInvocationCard({
payload,
censorUsernameInLogs,
}: {
payload: Record<string, unknown>;
censorUsernameInLogs: boolean;
}) {
const commandLine = [
typeof payload.command === "string" ? payload.command : null,
...(Array.isArray(payload.commandArgs)
? payload.commandArgs.filter((value): value is string => typeof value === "string")
: []),
]
.filter((value): value is string => Boolean(value))
.join(" ");
const hasAdvancedDetails =
commandLine.length > 0
|| (Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0)
|| payload.prompt !== undefined
|| payload.context !== undefined
|| payload.env !== undefined;
return (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
{typeof payload.adapterType === "string" && (
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{payload.adapterType}</div>
)}
{typeof payload.cwd === "string" && (
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{payload.cwd}</span></div>
)}
{hasAdvancedDetails && (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
Details
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-2">
{commandLine && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Command: </span>
<span className="font-mono">{commandLine}</span>
</div>
)}
{Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
<ul className="list-disc pl-5 space-y-1">
{payload.commandNotes
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.map((note, idx) => (
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
{note}
</li>
))}
</ul>
</div>
)}
{payload.prompt !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof payload.prompt === "string"
? redactPathText(payload.prompt, censorUsernameInLogs)
: JSON.stringify(redactPathValue(payload.prompt, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
{payload.context !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(redactPathValue(payload.context, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
{payload.env !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(payload.env, censorUsernameInLogs)}
</pre>
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
function parseStoredLogContent(content: string): RunLogChunk[] {
const parsed: RunLogChunk[] = [];
for (const line of content.split("\n")) {
@ -3707,68 +3799,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
censorUsernameInLogs={censorUsernameInLogs}
/>
{adapterInvokePayload && (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
{typeof adapterInvokePayload.adapterType === "string" && (
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{adapterInvokePayload.adapterType}</div>
)}
{typeof adapterInvokePayload.cwd === "string" && (
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{adapterInvokePayload.cwd}</span></div>
)}
{typeof adapterInvokePayload.command === "string" && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Command: </span>
<span className="font-mono">
{[
adapterInvokePayload.command,
...(Array.isArray(adapterInvokePayload.commandArgs)
? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string")
: []),
].join(" ")}
</span>
</div>
)}
{Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
<ul className="list-disc pl-5 space-y-1">
{adapterInvokePayload.commandNotes
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.map((note, idx) => (
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
{note}
</li>
))}
</ul>
</div>
)}
{adapterInvokePayload.prompt !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string"
? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
: JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
{adapterInvokePayload.context !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
{adapterInvokePayload.env !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
</pre>
</div>
)}
</div>
<RunInvocationCard payload={adapterInvokePayload} censorUsernameInLogs={censorUsernameInLogs} />
)}
<div className="flex items-center justify-between">

View file

@ -20,17 +20,7 @@ import { Button } from "@/components/ui/button";
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
const adapterLabels: Record<string, string> = {
claude_local: "Claude",
codex_local: "Codex",
gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",
};
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
@ -263,7 +253,7 @@ export function Agents() {
/>
)}
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
@ -364,7 +354,7 @@ function OrgTreeNode({
{agent && (
<>
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}

View file

@ -31,6 +31,7 @@ import {
Upload,
} from "lucide-react";
import { Field, adapterLabels } from "../components/agent-config-primitives";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter, listUIAdapters } from "../adapters";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
@ -514,7 +515,7 @@ function ConflictResolutionList({
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapters().map((adapter) => ({
value: adapter.type,
label: adapterLabels[adapter.type] ?? adapter.label,
label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type),
}));
// ── Adapter picker for imported agents ───────────────────────────────

View file

@ -12,20 +12,9 @@ import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
type JoinType = "human" | "agent";
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
pi_local: "Pi (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process",
http: "HTTP",
};
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
function dateTime(value: string) {
return new Date(value).toLocaleString();
@ -279,7 +268,7 @@ export function InviteLandingPage() {
>
{joinAdapterOptions.map((type) => (
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
{adapterLabels[type]}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
</option>
))}
</select>

View file

@ -19,7 +19,8 @@ import { cn, agentUrl } from "../lib/utils";
import { roleLabels } from "../components/agent-config-primitives";
import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm";
import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { getUIAdapter, listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { ReportsToPicker } from "../components/ReportsToPicker";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
@ -28,16 +29,9 @@ import {
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
"claude_local",
"codex_local",
"gemini_local",
"opencode_local",
"pi_local",
"cursor",
"hermes_local",
"openclaw_gateway",
]);
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>(
listUIAdapters().map((adapter) => adapter.type as CreateConfigValues["adapterType"]),
);
function createValuesForAdapterType(
adapterType: CreateConfigValues["adapterType"],

View file

@ -116,17 +116,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
// ── Status dot colors (raw hex for SVG) ─────────────────────────────────
const adapterLabels: Record<string, string> = {
claude_local: "Claude",
codex_local: "Codex",
gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",
};
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const statusDotColor: Record<string, string> = {
running: "#22d3ee",
@ -426,7 +416,7 @@ export function OrgChart() {
</span>
{agent && (
<span className="text-[10px] text-muted-foreground/60 font-mono leading-tight mt-1">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
{getAdapterLabel(agent.adapterType)}
</span>
)}
{agent && agent.capabilities && (