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

@ -292,7 +292,7 @@ export interface ServerAdapterModule {
* Returns the detected model/provider and the config source, or null if
* the adapter does not support detection or no config is found.
*/
detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>;
detectModel?: () => Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null>;
}
// ---------------------------------------------------------------------------

View file

@ -21,7 +21,7 @@ Core fields:
- chrome (boolean, optional): pass --chrome when running Claude
- promptTemplate (string, optional): run prompt template
- maxTurnsPerRun (number, optional): max turns for one run
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
- dangerouslySkipPermissions (boolean, optional, default true): pass --dangerously-skip-permissions to claude; defaults to true because Paperclip runs Claude in headless --print mode where interactive permission prompts cannot be answered
- command (string, optional): defaults to "claude"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables

View file

@ -317,7 +317,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const effort = asString(config.effort, "");
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
const commandNotes = instructionsFilePath

View file

@ -131,7 +131,7 @@ export async function testEnvironment(
const effort = asString(config.effort, "").trim();
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;

View file

@ -606,7 +606,7 @@ export interface WorkerToHostMethods {
result: IssueComment[],
];
"issues.createComment": [
params: { issueId: string; body: string; companyId: string },
params: { issueId: string; body: string; companyId: string; authorAgentId?: string },
result: IssueComment,
];

View file

@ -405,7 +405,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (!isInCompany(issues.get(issueId), companyId)) return [];
return issueComments.get(issueId) ?? [];
},
async createComment(issueId, body, companyId) {
async createComment(issueId, body, companyId, options) {
requireCapability(manifest, capabilitySet, "issue.comments.create");
const parentIssue = issues.get(issueId);
if (!isInCompany(parentIssue, companyId)) {
@ -416,7 +416,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
id: randomUUID(),
companyId: parentIssue.companyId,
issueId,
authorAgentId: null,
authorAgentId: options?.authorAgentId ?? null,
authorUserId: null,
body,
createdAt: now,

View file

@ -909,7 +909,12 @@ export interface PluginIssuesClient {
companyId: string,
): Promise<Issue>;
listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
createComment(issueId: string, body: string, companyId: string): Promise<IssueComment>;
createComment(
issueId: string,
body: string,
companyId: string,
options?: { authorAgentId?: string },
): Promise<IssueComment>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient;
}

View file

@ -610,8 +610,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
return callHost("issues.listComments", { issueId, companyId });
},
async createComment(issueId: string, body: string, companyId: string) {
return callHost("issues.createComment", { issueId, body, companyId });
async createComment(issueId: string, body: string, companyId: string, options?: { authorAgentId?: string }) {
return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId });
},
documents: {

View file

@ -0,0 +1,15 @@
import { z } from "zod";
import { AGENT_ADAPTER_TYPES } from "./constants.js";
export const agentAdapterTypeSchema = z
.string()
.trim()
.min(1)
.default("process")
.describe(`Known built-in adapters: ${AGENT_ADAPTER_TYPES.join(", ")}. External adapters may register additional non-empty string types at runtime.`);
export const optionalAgentAdapterTypeSchema = z
.string()
.trim()
.min(1)
.optional();

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { acceptInviteSchema, createAgentSchema, updateAgentSchema } from "./index.js";
describe("dynamic adapter type validation schemas", () => {
it("accepts external adapter types in create/update agent schemas", () => {
expect(
createAgentSchema.parse({
name: "External Agent",
adapterType: "external_adapter",
}).adapterType,
).toBe("external_adapter");
expect(
updateAgentSchema.parse({
adapterType: "external_adapter",
}).adapterType,
).toBe("external_adapter");
});
it("still rejects blank adapter types", () => {
expect(() =>
createAgentSchema.parse({
name: "Blank Adapter",
adapterType: " ",
}),
).toThrow();
});
it("accepts external adapter types in invite acceptance schema", () => {
expect(
acceptInviteSchema.parse({
requestType: "agent",
agentName: "External Joiner",
adapterType: "external_adapter",
}).adapterType,
).toBe("external_adapter");
});
});

View file

@ -31,9 +31,8 @@ export const AGENT_ADAPTER_TYPES = [
"pi_local",
"cursor",
"openclaw_gateway",
"hermes_local",
] as const;
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number] | (string & {});
export const AGENT_ROLES = [
"ceo",

View file

@ -1,3 +1,4 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export {
COMPANY_STATUSES,
DEPLOYMENT_MODES,

View file

@ -1,11 +1,11 @@
import { z } from "zod";
import {
AGENT_ADAPTER_TYPES,
INVITE_JOIN_TYPES,
JOIN_REQUEST_STATUSES,
JOIN_REQUEST_TYPES,
PERMISSION_KEYS,
} from "../constants.js";
import { optionalAgentAdapterTypeSchema } from "../adapter-type.js";
export const createCompanyInviteSchema = z.object({
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
@ -26,7 +26,7 @@ export type CreateOpenClawInvitePrompt = z.infer<
export const acceptInviteSchema = z.object({
requestType: z.enum(JOIN_REQUEST_TYPES),
agentName: z.string().min(1).max(120).optional(),
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(),
adapterType: optionalAgentAdapterTypeSchema,
capabilities: z.string().max(4000).optional().nullable(),
agentDefaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
// OpenClaw join compatibility fields accepted at top level.

View file

@ -1,11 +1,11 @@
import { z } from "zod";
import {
AGENT_ADAPTER_TYPES,
AGENT_ICON_NAMES,
AGENT_ROLES,
AGENT_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
} from "../constants.js";
import { agentAdapterTypeSchema } from "../adapter-type.js";
import { envConfigSchema } from "./secret.js";
export const agentPermissionsSchema = z.object({
@ -52,7 +52,7 @@ export const createAgentSchema = z.object({
reportsTo: z.string().uuid().optional().nullable(),
capabilities: z.string().optional().nullable(),
desiredSkills: z.array(z.string().min(1)).optional(),
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"),
adapterType: agentAdapterTypeSchema,
adapterConfig: adapterConfigSchema.optional().default({}),
runtimeConfig: z.record(z.unknown()).optional().default({}),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),