Extract adapter registry across CLI, server, and UI

Refactor monolithic heartbeat service, AgentConfigForm, and CLI
heartbeat-run into a proper adapter registry pattern. Each adapter
type (process, claude-local, codex-local, http) gets its own module
with server-side execution logic, CLI invocation, and UI config form.
Significantly reduces file sizes and enables adding new adapters
without touching core code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-18 13:53:03 -06:00
parent 3a91ecbae3
commit 47ccd946b6
52 changed files with 1961 additions and 1361 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclip/shared";
import type { Agent } from "@paperclip/shared";
@@ -24,6 +24,8 @@ import {
help,
adapterLabels,
} from "./agent-config-primitives";
import { getUIAdapter } from "../adapters";
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
/* ---- Create mode values ---- */
@@ -251,6 +253,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? props.values.adapterType
: overlay.adapterType ?? props.agent.adapterType;
const isLocal = adapterType === "claude_local" || adapterType === "codex_local";
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
// Fetch adapter models for the effective adapter type
const { data: fetchedModels } = useQuery({
@@ -259,6 +262,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
});
const models = fetchedModels ?? externalModels ?? [];
/** Props passed to adapter-specific config field components */
const adapterFieldProps = {
mode,
isCreate,
adapterType,
values: isCreate ? props.values : null,
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
config,
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
models,
};
// Section toggle state — advanced always starts collapsed
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate);
@@ -443,130 +459,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
)}
{/* Claude-specific: Skip permissions */}
{adapterType === "claude_local" && (
<ToggleField
label="Skip permissions"
hint={help.dangerouslySkipPermissions}
checked={
isCreate
? val!.dangerouslySkipPermissions
: eff(
"adapterConfig",
"dangerouslySkipPermissions",
config.dangerouslySkipPermissions !== false,
)
}
onChange={(v) =>
isCreate
? set!({ dangerouslySkipPermissions: v })
: mark("adapterConfig", "dangerouslySkipPermissions", v)
}
/>
)}
{/* Codex-specific: Bypass sandbox + Search */}
{adapterType === "codex_local" && (
<>
<ToggleField
label="Bypass sandbox"
hint={help.dangerouslyBypassSandbox}
checked={
isCreate
? val!.dangerouslyBypassSandbox
: eff(
"adapterConfig",
"dangerouslyBypassApprovalsAndSandbox",
config.dangerouslyBypassApprovalsAndSandbox !== false,
)
}
onChange={(v) =>
isCreate
? set!({ dangerouslyBypassSandbox: v })
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
}
/>
<ToggleField
label="Enable search"
hint={help.search}
checked={
isCreate
? val!.search
: eff("adapterConfig", "search", !!config.search)
}
onChange={(v) =>
isCreate
? set!({ search: v })
: mark("adapterConfig", "search", v)
}
/>
</>
)}
{/* Process-specific */}
{adapterType === "process" && (
<>
<Field label="Command" hint={help.command}>
<DraftInput
value={
isCreate
? val!.command
: eff("adapterConfig", "command", String(config.command ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ command: v })
: mark("adapterConfig", "command", v || undefined)
}
immediate
className={inputClass}
placeholder="e.g. node, python"
/>
</Field>
<Field label="Args (comma-separated)" hint={help.args}>
<DraftInput
value={
isCreate
? val!.args
: eff("adapterConfig", "args", formatArgList(config.args))
}
onCommit={(v) =>
isCreate
? set!({ args: v })
: mark(
"adapterConfig",
"args",
v ? parseCommaArgs(v) : undefined,
)
}
immediate
className={inputClass}
placeholder="e.g. script.js, --flag"
/>
</Field>
</>
)}
{/* HTTP-specific */}
{adapterType === "http" && (
<Field label="Webhook URL" hint={help.webhookUrl}>
<DraftInput
value={
isCreate
? val!.url
: eff("adapterConfig", "url", String(config.url ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ url: v })
: mark("adapterConfig", "url", v || undefined)
}
immediate
className={inputClass}
placeholder="https://..."
/>
</Field>
)}
{/* Adapter-specific fields */}
<uiAdapter.ConfigFields {...adapterFieldProps} />
{/* Advanced adapter section — collapsible in both modes */}
{isLocal && (
@@ -630,27 +524,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
</Field>
{adapterType === "claude_local" && (
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
{isCreate ? (
<input
type="number"
className={inputClass}
value={val!.maxTurnsPerRun}
onChange={(e) => set!({ maxTurnsPerRun: Number(e.target.value) })}
/>
) : (
<DraftNumberInput
value={eff(
"adapterConfig",
"maxTurnsPerRun",
Number(config.maxTurnsPerRun ?? 80),
)}
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
immediate
className={inputClass}
/>
)}
</Field>
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>

View File

@@ -29,28 +29,7 @@ import {
defaultCreateValues,
type CreateConfigValues,
} from "./AgentConfigForm";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const valueAtKey = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = valueAtKey;
}
return env;
}
import { getUIAdapter } from "../adapters";
export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent } = useDialog();
@@ -116,34 +95,8 @@ export function NewAgentDialog() {
}
function buildAdapterConfig() {
const v = configValues;
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvVars(v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
if (v.adapterType === "claude_local") {
ac.maxTurnsPerRun = v.maxTurnsPerRun;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
} else if (v.adapterType === "codex_local") {
ac.search = v.search;
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
} else if (v.adapterType === "process") {
if (v.command) ac.command = v.command;
if (v.args) ac.args = parseCommaArgs(v.args);
} else if (v.adapterType === "http") {
if (v.url) ac.url = v.url;
}
return ac;
const adapter = getUIAdapter(configValues.adapterType);
return adapter.buildAdapterConfig(configValues);
}
function handleSubmit() {

View File

@@ -16,6 +16,8 @@ import {
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
import { getUIAdapter } from "../adapters";
import { defaultCreateValues } from "./AgentConfigForm";
import {
Building2,
Bot,
@@ -97,33 +99,17 @@ export function OnboardingWizard() {
}
function buildAdapterConfig(): Record<string, unknown> {
if (adapterType === "claude_local") {
return {
...(cwd ? { cwd } : {}),
...(model ? { model } : {}),
timeoutSec: 900,
graceSec: 15,
maxTurnsPerRun: 80,
dangerouslySkipPermissions: true,
};
}
if (adapterType === "process") {
return {
...(command ? { command } : {}),
args: args
.split(",")
.map((s) => s.trim())
.filter(Boolean),
timeoutSec: 900,
graceSec: 15,
};
}
// http
return {
...(url ? { url } : {}),
method: "POST",
timeoutMs: 15000,
};
const adapter = getUIAdapter(adapterType);
return adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
cwd,
model,
command,
args,
url,
dangerouslySkipPermissions: adapterType === "claude_local",
});
}
async function handleStep1Next() {
@@ -594,11 +580,7 @@ export function OnboardingWizard() {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{agentName}</p>
<p className="text-xs text-muted-foreground">
{adapterType === "claude_local"
? "Claude Code"
: adapterType === "process"
? "Shell Command"
: "HTTP Webhook"}
{getUIAdapter(adapterType).label}
</p>
</div>
<Check className="h-4 w-4 text-green-500 shrink-0" />