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

@ -46,7 +46,12 @@ import {
} from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
import {
detectAdapterModel,
findServerAdapter,
listAdapterModels,
requireServerAdapter,
} from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
@ -69,6 +74,7 @@ export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
claude_local: "instructionsFilePath",
codex_local: "instructionsFilePath",
droid_local: "instructionsFilePath",
gemini_local: "instructionsFilePath",
opencode_local: "instructionsFilePath",
cursor: "instructionsFilePath",
@ -322,6 +328,21 @@ export function agentRoutes(db: Db) {
}
}
function assertKnownAdapterType(type: string | null | undefined): string {
const adapterType = typeof type === "string" ? type.trim() : "";
if (!adapterType) {
throw unprocessable("Adapter type is required");
}
if (!findServerAdapter(adapterType)) {
throw unprocessable(`Unknown adapter type: ${adapterType}`);
}
return adapterType;
}
function hasOwn(value: object, key: string): boolean {
return Object.hasOwn(value, key);
}
async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
const companyIdQuery = req.query.companyId;
const requestedCompanyId =
@ -743,7 +764,7 @@ export function agentRoutes(db: Db) {
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 type = assertKnownAdapterType(req.params.type as string);
const models = await listAdapterModels(type);
res.json(models);
});
@ -751,7 +772,7 @@ export function agentRoutes(db: Db) {
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const type = req.params.type as string;
const type = assertKnownAdapterType(req.params.type as string);
const detected = await detectAdapterModel(type);
res.json(detected);
@ -762,14 +783,10 @@ export function agentRoutes(db: Db) {
validate(testAdapterEnvironmentSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
const type = assertKnownAdapterType(req.params.type as string);
await assertCanReadConfigurations(req, companyId);
const adapter = findServerAdapter(type);
if (!adapter) {
res.status(404).json({ error: `Unknown adapter type: ${type}` });
return;
}
const adapter = requireServerAdapter(type);
const inputAdapterConfig =
(req.body?.adapterConfig ?? {}) as Record<string, unknown>;
@ -1265,6 +1282,7 @@ export function agentRoutes(db: Db) {
sourceIssueIds: _sourceIssueIds,
...hireInput
} = req.body;
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
hireInput.adapterType,
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
@ -1429,6 +1447,7 @@ export function agentRoutes(db: Db) {
desiredSkills: requestedDesiredSkills,
...createInput
} = req.body;
createInput.adapterType = assertKnownAdapterType(createInput.adapterType);
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
createInput.adapterType,
((createInput.adapterConfig ?? {}) as Record<string, unknown>),
@ -1807,7 +1826,7 @@ export function agentRoutes(db: Db) {
}
await assertCanUpdateAgent(req, existing);
if (Object.prototype.hasOwnProperty.call(req.body, "permissions")) {
if (hasOwn(req.body as object, "permissions")) {
res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" });
return;
}
@ -1815,7 +1834,7 @@ export function agentRoutes(db: Db) {
const patchData = { ...(req.body as Record<string, unknown>) };
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
delete patchData.replaceAdapterConfig;
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
if (hasOwn(patchData, "adapterConfig")) {
const adapterConfig = asRecord(patchData.adapterConfig);
if (!adapterConfig) {
res.status(422).json({ error: "adapterConfig must be an object" });
@ -1830,16 +1849,17 @@ export function agentRoutes(db: Db) {
patchData.adapterConfig = adapterConfig;
}
const requestedAdapterType =
typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType;
const requestedAdapterType = hasOwn(patchData, "adapterType")
? assertKnownAdapterType(patchData.adapterType as string | null | undefined)
: existing.adapterType;
const touchesAdapterConfiguration =
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
hasOwn(patchData, "adapterType") ||
hasOwn(patchData, "adapterConfig");
if (touchesAdapterConfiguration) {
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
const changingAdapterType =
typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType;
const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
const requestedAdapterConfig = hasOwn(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {})
: null;
if (