feat(adapters): declarative config-schema API and UI for plugin adapters

Cherry-picked from feat/externalize-hermes-adapter.
Resolved conflicts: kept Hermes as built-in on phase1 branch.
This commit is contained in:
HenkDz
2026-04-01 13:58:02 +01:00
parent f884cbab78
commit 69a1593ff8
8 changed files with 392 additions and 10 deletions
+41
View File
@@ -146,3 +146,44 @@ A change is done when all are true:
2. Typecheck, tests, and build pass
3. Contracts are synced across db/shared/server/ui
4. Docs updated when behavior or commands change
## 11. Fork-Specific: HenkDz/paperclip
This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)).
### Branch Strategy
- `feat/externalize-hermes-adapter` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path).
- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch.
### Hermes (plugin only)
- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded.
- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source.
- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo.
### Local Dev
- Fork runs on port 3101+ (auto-detects if 3100 is taken by upstream instance)
- `npx vite build` hangs on NTFS — use `node node_modules/vite/bin/vite.js build` instead
- Server startup from NTFS takes 30-60s — don't assume failure immediately
- Kill ALL paperclip processes before starting: `pkill -f "paperclip"; pkill -f "tsx.*index.ts"`
- Vite cache survives `rm -rf dist` — delete both: `rm -rf ui/dist ui/node_modules/.vite`
### Fork QoL Patches (not in upstream)
These are local modifications in the fork's UI. If re-copying source, these must be re-applied:
1. **stderr_group** — amber accordion for MCP init noise in `RunTranscriptView.tsx`
2. **tool_group** — accordion for consecutive non-terminal tools (write, read, search, browser)
3. **Dashboard excerpt**`LatestRunCard` strips markdown, shows first 3 lines/280 chars
### Plugin System
PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See root `AGENTS.md` for full details.
- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json`
- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading
- `createServerAdapter()` must include ALL optional fields (especially `detectModel`)
- Built-in UI adapters can shadow external plugin parsers — remove built-in when fully externalizing
- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm)
+3
View File
@@ -22,6 +22,9 @@ export type {
AdapterModel,
HireApprovedPayload,
HireApprovedHookResult,
ConfigFieldOption,
ConfigFieldSchema,
AdapterConfigSchema,
ServerAdapterModule,
QuotaWindow,
ProviderQuotaResult,
+33
View File
@@ -261,6 +261,30 @@ export interface ProviderQuotaResult {
windows: QuotaWindow[];
}
// ---------------------------------------------------------------------------
// Adapter config schema — declarative UI config for external adapters
// ---------------------------------------------------------------------------
export interface ConfigFieldOption {
label: string;
value: string;
}
export interface ConfigFieldSchema {
key: string;
label: string;
type: "text" | "select" | "toggle" | "number" | "textarea";
options?: ConfigFieldOption[];
default?: unknown;
hint?: string;
required?: boolean;
group?: string;
}
export interface AdapterConfigSchema {
fields: ConfigFieldSchema[];
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
@@ -293,6 +317,13 @@ export interface ServerAdapterModule {
* the adapter does not support detection or no config is found.
*/
detectModel?: () => Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null>;
/**
* Optional: return a declarative config schema so the UI can render
* adapter-specific form fields without shipping React components.
* Dynamic options (e.g. scanning a profiles directory) should be
* resolved inside this method — the caller receives a fully hydrated schema.
*/
getConfigSchema?: () => Promise<AdapterConfigSchema> | AdapterConfigSchema;
}
// ---------------------------------------------------------------------------
@@ -353,4 +384,6 @@ export interface CreateConfigValues {
maxTurnsPerRun: number;
heartbeatEnabled: boolean;
intervalSec: number;
/** Arbitrary key-value pairs populated by schema-driven config fields. */
adapterSchemaValues?: Record<string, unknown>;
}
+3
View File
@@ -25,5 +25,8 @@ export type {
NativeContextManagement,
ResolvedSessionCompactionPolicy,
SessionCompactionPolicy,
ConfigFieldOption,
ConfigFieldSchema,
AdapterConfigSchema,
ServerAdapterModule,
} from "@paperclipai/adapter-utils";
+41 -1
View File
@@ -35,7 +35,7 @@ import {
setAdapterDisabled,
} from "../services/adapter-plugin-store.js";
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
import type { ServerAdapterModule } from "../adapters/types.js";
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
import { logger } from "../middleware/logger.js";
import { assertBoard } from "./authz.js";
@@ -453,6 +453,7 @@ export function adapterRoutes() {
// Swap in the reloaded module
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
configSchemaCache.delete(type);
// Sync store.version from package.json (store may be missing version for local installs).
const record = getAdapterPluginByType(type);
@@ -520,6 +521,7 @@ export function adapterRoutes() {
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
configSchemaCache.delete(type);
// Sync store version from disk
let newVersion: string | undefined;
@@ -541,6 +543,44 @@ export function adapterRoutes() {
}
});
// ── GET /api/adapters/:type/config-schema ────────────────────────────────
// Serve a declarative config schema for an adapter's UI form fields.
// The adapter's getConfigSchema() resolves all options (static and dynamic)
// so the UI receives a fully hydrated schema in a single fetch.
const configSchemaCache = new Map<string, { schema: AdapterConfigSchema; fetchedAt: number }>();
const CONFIG_SCHEMA_TTL_MS = 30_000;
router.get("/adapters/:type/config-schema", async (req, res) => {
assertBoard(req);
const { type } = req.params;
const adapter = findServerAdapter(type);
if (!adapter) {
res.status(404).json({ error: `Adapter "${type}" is not registered.` });
return;
}
if (!adapter.getConfigSchema) {
res.status(404).json({ error: `Adapter "${type}" does not provide a config schema.` });
return;
}
const cached = configSchemaCache.get(type);
if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
res.json(cached.schema);
return;
}
try {
const schema = await adapter.getConfigSchema();
configSchemaCache.set(type, { schema, fetchedAt: Date.now() });
res.json(schema);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to resolve config schema");
res.status(500).json({ error: `Failed to resolve config schema: ${message}` });
}
});
// ── GET /api/adapters/:type/ui-parser.js ─────────────────────────────────
// Serve the self-contained UI parser JS for an adapter type.
// This allows external adapters to provide custom run-log parsing
+9 -9
View File
@@ -10,6 +10,7 @@ import { hermesLocalUIAdapter } from "./hermes-local";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
import { loadDynamicParser } from "./dynamic-loader";
import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields";
const uiAdapters: UIAdapterModule[] = [];
const adaptersByType = new Map<string, UIAdapterModule>();
@@ -60,7 +61,6 @@ export function getUIAdapter(type: string): UIAdapterModule {
const builtIn = adaptersByType.get(type);
if (!builtIn) {
// No built-in adapter — fall through to the external-only path.
let loadStarted = false;
return {
type,
@@ -74,16 +74,16 @@ export function getUIAdapter(type: string): UIAdapterModule {
type,
label: type,
parseStdoutLine: parser,
ConfigFields: processUIAdapter.ConfigFields,
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
});
}
});
}
return processUIAdapter.parseStdoutLine(line, ts);
},
ConfigFields: processUIAdapter.ConfigFields,
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
};
}
@@ -117,16 +117,16 @@ export function syncExternalAdapters(
type,
label,
parseStdoutLine: parser,
ConfigFields: processUIAdapter.ConfigFields,
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
});
}
});
}
return processUIAdapter.parseStdoutLine(line, ts);
},
ConfigFields: processUIAdapter.ConfigFields,
buildAdapterConfig: processUIAdapter.buildAdapterConfig,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
});
}
}
+259
View File
@@ -0,0 +1,259 @@
import { useState, useEffect } from "react";
import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils";
import type { AdapterConfigFieldsProps } from "./types";
import {
Field,
DraftInput,
DraftNumberInput,
DraftTextarea,
ToggleField,
} from "../components/agent-config-primitives";
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 selectClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono";
// ---------------------------------------------------------------------------
// Schema cache (module-level, survives re-renders)
// ---------------------------------------------------------------------------
const schemaCache = new Map<string, AdapterConfigSchema | null>();
const schemaFetchInflight = new Map<string, Promise<AdapterConfigSchema | null>>();
const failedSchemaTypes = new Set<string>();
async function fetchConfigSchema(adapterType: string): Promise<AdapterConfigSchema | null> {
const cached = schemaCache.get(adapterType);
if (cached !== undefined) return cached;
if (failedSchemaTypes.has(adapterType)) return null;
const inflight = schemaFetchInflight.get(adapterType);
if (inflight) return inflight;
const promise = (async () => {
try {
const res = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/config-schema`);
if (!res.ok) {
failedSchemaTypes.add(adapterType);
return null;
}
const schema = (await res.json()) as AdapterConfigSchema;
schemaCache.set(adapterType, schema);
return schema;
} catch {
failedSchemaTypes.add(adapterType);
return null;
} finally {
schemaFetchInflight.delete(adapterType);
}
})();
schemaFetchInflight.set(adapterType, promise);
return promise;
}
export function invalidateConfigSchemaCache(adapterType: string): void {
schemaCache.delete(adapterType);
failedSchemaTypes.delete(adapterType);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
function useConfigSchema(adapterType: string): AdapterConfigSchema | null {
const [schema, setSchema] = useState<AdapterConfigSchema | null>(
schemaCache.get(adapterType) ?? null,
);
useEffect(() => {
let cancelled = false;
fetchConfigSchema(adapterType).then((s) => {
if (!cancelled) setSchema(s);
});
return () => {
cancelled = true;
};
}, [adapterType]);
return schema;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getDefaultValue(field: ConfigFieldSchema): unknown {
if (field.default !== undefined) return field.default;
switch (field.type) {
case "toggle":
return false;
case "number":
return 0;
case "text":
case "textarea":
return "";
case "select":
return field.options?.[0]?.value ?? "";
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SchemaConfigFields({
adapterType,
isCreate,
values,
set,
config,
eff,
mark,
}: AdapterConfigFieldsProps) {
const schema = useConfigSchema(adapterType);
const [defaultsApplied, setDefaultsApplied] = useState(false);
useEffect(() => {
if (!schema || !isCreate || defaultsApplied) return;
const defaults: Record<string, unknown> = {};
for (const field of schema.fields) {
const def = getDefaultValue(field);
if (def !== undefined && def !== "") {
defaults[field.key] = def;
}
}
if (Object.keys(defaults).length > 0) {
set?.({
adapterSchemaValues: { ...values?.adapterSchemaValues, ...defaults },
});
}
setDefaultsApplied(true);
}, [schema, isCreate, defaultsApplied, set, values?.adapterSchemaValues]);
if (!schema || schema.fields.length === 0) return null;
function readValue(field: ConfigFieldSchema): unknown {
if (isCreate) {
return values?.adapterSchemaValues?.[field.key] ?? getDefaultValue(field);
}
const stored = config[field.key];
return eff("adapterConfig", field.key, (stored ?? getDefaultValue(field)) as string);
}
function writeValue(field: ConfigFieldSchema, value: unknown): void {
if (isCreate) {
set?.({
adapterSchemaValues: {
...values?.adapterSchemaValues,
[field.key]: value,
},
});
} else {
mark("adapterConfig", field.key, value);
}
}
return (
<>
{schema.fields.map((field) => {
switch (field.type) {
case "select":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<select
className={selectClass}
value={String(readValue(field) ?? "")}
onChange={(e) => writeValue(field, e.target.value)}
>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
);
case "toggle":
return (
<ToggleField
key={field.key}
label={field.label}
hint={field.hint}
checked={readValue(field) === true}
onChange={(v) => writeValue(field, v)}
/>
);
case "number":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftNumberInput
value={Number(readValue(field) ?? 0)}
onCommit={(v) => writeValue(field, v)}
immediate
className={inputClass}
/>
</Field>
);
case "textarea":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftTextarea
value={String(readValue(field) ?? "")}
onCommit={(v) => writeValue(field, v || undefined)}
immediate
/>
</Field>
);
case "text":
default:
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftInput
value={String(readValue(field) ?? "")}
onCommit={(v) => writeValue(field, v || undefined)}
immediate
className={inputClass}
/>
</Field>
);
}
})}
</>
);
}
// ---------------------------------------------------------------------------
// Build adapter config from schema values + standard CreateConfigValues fields
// ---------------------------------------------------------------------------
export function buildSchemaAdapterConfig(
values: CreateConfigValues,
): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (values.model?.trim()) ac.model = values.model.trim();
if (values.cwd) ac.cwd = values.cwd;
if (values.command) ac.command = values.command;
if (values.instructionsFilePath) ac.instructionsFilePath = values.instructionsFilePath;
if (values.thinkingEffort) ac.thinkingEffort = values.thinkingEffort;
if (values.extraArgs) {
ac.extraArgs = values.extraArgs
.split(/\s+/)
.filter(Boolean);
}
if (values.adapterSchemaValues) {
Object.assign(ac, values.adapterSchemaValues);
}
return ac;
}
+3
View File
@@ -31,6 +31,7 @@ import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { ChoosePathButton } from "@/components/PathInstructionsModal";
import { invalidateDynamicParser } from "@/adapters/dynamic-loader";
import { invalidateConfigSchemaCache } from "@/adapters/schema-config-fields";
function AdapterRow({
adapter,
@@ -215,6 +216,7 @@ export function AdapterManager() {
onSuccess: (result) => {
invalidate();
invalidateDynamicParser(result.type);
invalidateConfigSchemaCache(result.type);
pushToast({
title: "Adapter reloaded",
body: `Type "${result.type}" reloaded.${result.version ? ` (v${result.version})` : ""}`,
@@ -231,6 +233,7 @@ export function AdapterManager() {
onSuccess: (result) => {
invalidate();
invalidateDynamicParser(result.type);
invalidateConfigSchemaCache(result.type);
pushToast({
title: "Adapter reinstalled",
body: `Type "${result.type}" updated from npm.${result.version ? ` (v${result.version})` : ""}`,