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,58 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
import {
findServerAdapter,
listAdapterModels,
registerServerAdapter,
requireServerAdapter,
unregisterServerAdapter,
} from "../adapters/index.js";
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
}),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
models: [{ id: "external-model", label: "External Model" }],
supportsLocalAgentJwt: false,
};
describe("server adapter registry", () => {
beforeEach(() => {
unregisterServerAdapter("external_test");
});
afterEach(() => {
unregisterServerAdapter("external_test");
});
it("registers external adapters and exposes them through lookup helpers", async () => {
expect(findServerAdapter("external_test")).toBeNull();
registerServerAdapter(externalAdapter);
expect(requireServerAdapter("external_test")).toBe(externalAdapter);
expect(await listAdapterModels("external_test")).toEqual([
{ id: "external-model", label: "External Model" },
]);
});
it("removes external adapters when unregistered", () => {
registerServerAdapter(externalAdapter);
unregisterServerAdapter("external_test");
expect(findServerAdapter("external_test")).toBeNull();
expect(() => requireServerAdapter("external_test")).toThrow(
"Unknown adapter type: external_test",
);
});
});

View file

@ -0,0 +1,180 @@
import express from "express";
import request from "supertest";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
import type { ServerAdapterModule } from "../adapters/index.js";
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
const mockAgentService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
getBundle: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
exportFiles: vi.fn(),
ensureManagedBundle: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
cancelActiveForAgent: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
const externalAdapter: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
}),
};
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", agentRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("agent routes adapter validation", () => {
beforeEach(() => {
vi.clearAllMocks();
unregisterServerAdapter("external_test");
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
name: String(input.name ?? "Agent"),
urlKey: "agent",
role: String(input.role ?? "general"),
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: String(input.adapterType ?? "process"),
adapterConfig: (input.adapterConfig as Record<string, unknown> | undefined) ?? {},
runtimeConfig: (input.runtimeConfig as Record<string, unknown> | undefined) ?? {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
}));
});
afterEach(() => {
unregisterServerAdapter("external_test");
});
it("creates agents for dynamically registered external adapter types", async () => {
registerServerAdapter(externalAdapter);
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "External Agent",
adapterType: "external_test",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.adapterType).toBe("external_test");
});
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "Missing Adapter",
adapterType: "missing_adapter",
});
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter");
});
});

View file

@ -50,7 +50,7 @@ vi.mock("../services/index.js", () => ({
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
findServerAdapter: vi.fn((_type: string) => ({ type: _type })),
listAdapterModels: vi.fn(),
}));

View file

@ -86,6 +86,7 @@ vi.mock("../services/index.js", () => ({
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
}));
function createDb(requireBoardApprovalForNewAgents = false) {

View file

@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js";
import {
summarizeHeartbeatRunResultJson,
buildHeartbeatRunIssueComment,
} from "../services/heartbeat-run-summary.js";
describe("summarizeHeartbeatRunResultJson", () => {
it("truncates text fields and preserves cost aliases", () => {
@ -31,3 +34,24 @@ describe("summarizeHeartbeatRunResultJson", () => {
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
});
});
describe("buildHeartbeatRunIssueComment", () => {
it("uses the final summary text for issue comments on successful runs", () => {
const comment = buildHeartbeatRunIssueComment({
summary: "## Summary\n\n- fixed deploy config\n- posted issue update",
});
expect(comment).toContain("## Summary");
expect(comment).toContain("- fixed deploy config");
expect(comment).not.toContain("Run summary");
});
it("falls back to result or message when summary is missing", () => {
expect(buildHeartbeatRunIssueComment({ result: "done" })).toBe("done");
expect(buildHeartbeatRunIssueComment({ message: "completed" })).toBe("completed");
});
it("returns null when there is no usable final text", () => {
expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull();
});
});

View file

@ -1,4 +1,13 @@
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js";
export {
getServerAdapter,
listAdapterModels,
listServerAdapters,
findServerAdapter,
detectAdapterModel,
registerServerAdapter,
unregisterServerAdapter,
requireServerAdapter,
} from "./registry.js";
export type {
ServerAdapterModule,
AdapterExecutionContext,

View file

@ -0,0 +1,262 @@
/**
* External adapter plugin loader.
*
* Loads external adapter packages from the adapter-plugin-store and returns
* their ServerAdapterModule instances. The caller (registry.ts) is
* responsible for registering them.
*
* This avoids circular initialization: plugin-loader imports only
* adapter-utils, never registry.ts.
*/
import fs from "node:fs";
import path from "node:path";
import type { ServerAdapterModule } from "./types.js";
import { logger } from "../middleware/logger.js";
import {
listAdapterPlugins,
getAdapterPluginsDir,
getAdapterPluginByType,
} from "../services/adapter-plugin-store.js";
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
// ---------------------------------------------------------------------------
// In-memory UI parser cache
// ---------------------------------------------------------------------------
const uiParserCache = new Map<string, string>();
export function getUiParserSource(adapterType: string): string | undefined {
return uiParserCache.get(adapterType);
}
/**
* On cache miss, attempt on-demand extraction from the plugin store.
* Makes the ui-parser.js endpoint self-healing.
*/
export function getOrExtractUiParserSource(adapterType: string): string | undefined {
const cached = uiParserCache.get(adapterType);
if (cached) return cached;
const record = getAdapterPluginByType(adapterType);
if (!record) return undefined;
const packageDir = resolvePackageDir(record);
const source = extractUiParserSource(packageDir, record.packageName);
if (source) {
uiParserCache.set(adapterType, source);
logger.info(
{ type: adapterType, packageName: record.packageName, origin: "lazy" },
"UI parser extracted on-demand (cache miss)",
);
}
return source;
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function resolvePackageDir(record: Pick<AdapterPluginRecord, "localPath" | "packageName">): string {
return record.localPath
? path.resolve(record.localPath)
: path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName);
}
function resolvePackageEntryPoint(packageDir: string): string {
const pkgJsonPath = path.join(packageDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
if (pkg.exports && typeof pkg.exports === "object" && pkg.exports["."]) {
const exp = pkg.exports["."];
return typeof exp === "string" ? exp : (exp.import ?? exp.default ?? "index.js");
}
return pkg.main ?? "index.js";
}
// ---------------------------------------------------------------------------
// UI parser extraction
// ---------------------------------------------------------------------------
const SUPPORTED_PARSER_CONTRACT = "1";
function extractUiParserSource(
packageDir: string,
packageName: string,
): string | undefined {
const pkgJsonPath = path.join(packageDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
if (!pkg.exports || typeof pkg.exports !== "object" || !pkg.exports["./ui-parser"]) {
return undefined;
}
const contractVersion = pkg.paperclip?.adapterUiParser;
if (contractVersion) {
const major = contractVersion.split(".")[0];
if (major !== SUPPORTED_PARSER_CONTRACT) {
logger.warn(
{ packageName, contractVersion, supported: `${SUPPORTED_PARSER_CONTRACT}.x` },
"Adapter declares unsupported UI parser contract version — skipping UI parser",
);
return undefined;
}
} else {
logger.info(
{ packageName },
"Adapter has ./ui-parser export but no paperclip.adapterUiParser version — loading anyway (future versions may require it)",
);
}
const uiParserExp = pkg.exports["./ui-parser"];
const uiParserFile = typeof uiParserExp === "string"
? uiParserExp
: (uiParserExp.import ?? uiParserExp.default);
const uiParserPath = path.resolve(packageDir, uiParserFile);
if (!uiParserPath.startsWith(packageDir + path.sep) && uiParserPath !== packageDir) {
logger.warn(
{ packageName, uiParserFile },
"UI parser path escapes package directory — skipping",
);
return undefined;
}
if (!fs.existsSync(uiParserPath)) {
return undefined;
}
try {
const source = fs.readFileSync(uiParserPath, "utf-8");
logger.info(
{ packageName, uiParserFile, size: source.length },
`Loaded UI parser from adapter package${contractVersion ? "" : " (no version declared)"}`,
);
return source;
} catch (err) {
logger.warn({ err, packageName, uiParserFile }, "Failed to read UI parser from adapter package");
return undefined;
}
}
// ---------------------------------------------------------------------------
// Load / reload
// ---------------------------------------------------------------------------
function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule {
const m = mod as Record<string, unknown>;
const createServerAdapter = m.createServerAdapter;
if (typeof createServerAdapter !== "function") {
throw new Error(
`Package "${packageName}" does not export createServerAdapter(). ` +
`Ensure the package's main entry exports a createServerAdapter function.`,
);
}
const adapterModule = createServerAdapter() as ServerAdapterModule;
if (!adapterModule || !adapterModule.type) {
throw new Error(
`createServerAdapter() from "${packageName}" returned an invalid module (missing "type").`,
);
}
return adapterModule;
}
export async function loadExternalAdapterPackage(
packageName: string,
localPath?: string,
): Promise<ServerAdapterModule> {
const packageDir = localPath
? path.resolve(localPath)
: path.resolve(getAdapterPluginsDir(), "node_modules", packageName);
const entryPoint = resolvePackageEntryPoint(packageDir);
const modulePath = path.resolve(packageDir, entryPoint);
const uiParserSource = extractUiParserSource(packageDir, packageName);
logger.info({ packageName, packageDir, entryPoint, modulePath, hasUiParser: !!uiParserSource }, "Loading external adapter package");
const mod = await import(modulePath);
const adapterModule = validateAdapterModule(mod, packageName);
if (uiParserSource) {
uiParserCache.set(adapterModule.type, uiParserSource);
}
return adapterModule;
}
async function loadFromRecord(record: AdapterPluginRecord): Promise<ServerAdapterModule | null> {
try {
return await loadExternalAdapterPackage(record.packageName, record.localPath);
} catch (err) {
logger.warn(
{ err, packageName: record.packageName, type: record.type },
"Failed to dynamically load external adapter; skipping",
);
return null;
}
}
/**
* Reload an external adapter at runtime (dev iteration without server restart).
* Busts the ESM module cache via a cache-busting query string.
*/
export async function reloadExternalAdapter(
type: string,
): Promise<ServerAdapterModule | null> {
const record = getAdapterPluginByType(type);
if (!record) return null;
const packageDir = resolvePackageDir(record);
const entryPoint = resolvePackageEntryPoint(packageDir);
const modulePath = path.resolve(packageDir, entryPoint);
const cacheBustUrl = `file://${modulePath}?t=${Date.now()}`;
logger.info(
{ type, packageName: record.packageName, modulePath, cacheBustUrl },
"Reloading external adapter (cache bust)",
);
const mod = await import(cacheBustUrl);
const adapterModule = validateAdapterModule(mod, record.packageName);
uiParserCache.delete(type);
const uiParserSource = extractUiParserSource(packageDir, record.packageName);
if (uiParserSource) {
uiParserCache.set(adapterModule.type, uiParserSource);
}
logger.info(
{ type, packageName: record.packageName, hasUiParser: !!uiParserSource },
"Successfully reloaded external adapter",
);
return adapterModule;
}
/**
* Build all external adapter modules from the plugin store.
*/
export async function buildExternalAdapters(): Promise<ServerAdapterModule[]> {
const results: ServerAdapterModule[] = [];
const storeRecords = listAdapterPlugins();
for (const record of storeRecords) {
const adapter = await loadFromRecord(record);
if (adapter) {
results.push(adapter);
}
}
if (results.length > 0) {
logger.info(
{ count: results.length, adapters: results.map((a) => a.type) },
"Loaded external adapters from plugin store",
);
}
return results;
}

View file

@ -67,18 +67,6 @@ import {
import {
agentConfigurationDoc as piAgentConfigurationDoc,
} from "@paperclipai/adapter-pi-local";
import {
execute as hermesExecute,
testEnvironment as hermesTestEnvironment,
sessionCodec as hermesSessionCodec,
listSkills as hermesListSkills,
syncSkills as hermesSyncSkills,
detectModel as detectModelFromHermes,
} from "hermes-paperclip-adapter/server";
import {
agentConfigurationDoc as hermesAgentConfigurationDoc,
models as hermesModels,
} from "hermes-paperclip-adapter";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
@ -175,21 +163,10 @@ const piLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: piAgentConfigurationDoc,
};
const hermesLocalAdapter: ServerAdapterModule = {
type: "hermes_local",
execute: hermesExecute,
testEnvironment: hermesTestEnvironment,
sessionCodec: hermesSessionCodec,
listSkills: hermesListSkills,
syncSkills: hermesSyncSkills,
models: hermesModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: hermesAgentConfigurationDoc,
detectModel: () => detectModelFromHermes(),
};
const adaptersByType = new Map<string, ServerAdapterModule>();
const adaptersByType = new Map<string, ServerAdapterModule>(
[
function registerBuiltInAdapters() {
for (const adapter of [
claudeLocalAdapter,
codexLocalAdapter,
openCodeLocalAdapter,
@ -197,21 +174,84 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
cursorLocalAdapter,
geminiLocalAdapter,
openclawGatewayAdapter,
hermesLocalAdapter,
processAdapter,
httpAdapter,
].map((a) => [a.type, a]),
);
]) {
adaptersByType.set(adapter.type, adapter);
}
}
export function getServerAdapter(type: string): ServerAdapterModule {
registerBuiltInAdapters();
// ---------------------------------------------------------------------------
// Load external adapter plugins (droid, hermes, etc.)
//
// External adapter packages export createServerAdapter() which returns a
// ServerAdapterModule. The host fills in sessionManagement.
// ---------------------------------------------------------------------------
import { buildExternalAdapters } from "./plugin-loader.js";
import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
/** Cached sync wrapper — the store is a simple JSON file read, safe to call frequently. */
function getDisabledAdapterTypesFromStore(): string[] {
return getDisabledAdapterTypes();
}
/**
* Load external adapters from the plugin store and hardcoded sources.
* Called once at module initialization. The promise is exported so that
* callers (e.g. assertKnownAdapterType, app startup) can await completion
* and avoid racing against the loading window.
*/
const externalAdaptersReady: Promise<void> = (async () => {
try {
const externalAdapters = await buildExternalAdapters();
for (const externalAdapter of externalAdapters) {
adaptersByType.set(
externalAdapter.type,
{
...externalAdapter,
sessionManagement: getAdapterSessionManagement(externalAdapter.type) ?? undefined,
},
);
}
} catch (err) {
console.error("[paperclip] Failed to load external adapters:", err);
}
})();
/**
* Await this before validating adapter types to avoid race conditions
* during server startup. External adapters are loaded asynchronously;
* calling assertKnownAdapterType before this resolves will reject
* valid external adapter types.
*/
export function waitForExternalAdapters(): Promise<void> {
return externalAdaptersReady;
}
export function registerServerAdapter(adapter: ServerAdapterModule): void {
adaptersByType.set(adapter.type, adapter);
}
export function unregisterServerAdapter(type: string): void {
if (type === processAdapter.type || type === httpAdapter.type) return;
adaptersByType.delete(type);
}
export function requireServerAdapter(type: string): ServerAdapterModule {
const adapter = adaptersByType.get(type);
if (!adapter) {
// Fall back to process adapter for unknown types
return processAdapter;
throw new Error(`Unknown adapter type: ${type}`);
}
return adapter;
}
export function getServerAdapter(type: string): ServerAdapterModule {
return adaptersByType.get(type) ?? processAdapter;
}
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
const adapter = adaptersByType.get(type);
if (!adapter) return [];
@ -226,13 +266,32 @@ export function listServerAdapters(): ServerAdapterModule[] {
return Array.from(adaptersByType.values());
}
/**
* List adapters excluding those that are disabled in settings.
* Used for menus and agent creation flows disabled adapters remain
* functional for existing agents but hidden from selection.
*/
export function listEnabledServerAdapters(): ServerAdapterModule[] {
const disabled = getDisabledAdapterTypesFromStore();
const disabledSet = disabled.length > 0 ? new Set(disabled) : null;
return disabledSet
? Array.from(adaptersByType.values()).filter((a) => !disabledSet.has(a.type))
: Array.from(adaptersByType.values());
}
export async function detectAdapterModel(
type: string,
): Promise<{ model: string; provider: string; source: string } | null> {
): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> {
const adapter = adaptersByType.get(type);
if (!adapter?.detectModel) return null;
const detected = await adapter.detectModel();
return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null;
if (!detected) return null;
return {
model: detected.model,
provider: detected.provider,
source: detected.source,
...(detected.candidates?.length ? { candidates: detected.candidates } : {}),
};
}
export function findServerAdapter(type: string): ServerAdapterModule | null {

View file

@ -29,6 +29,7 @@ import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
import { pluginRoutes } from "./routes/plugins.js";
import { adapterRoutes } from "./routes/adapters.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
import { applyUiBranding } from "./ui-branding.js";
import { logger } from "./middleware/logger.js";
@ -226,6 +227,7 @@ export async function createApp(
{ workerManager },
),
);
api.use(adapterRoutes());
api.use(
accessRoutes(db, {
deploymentMode: opts.deploymentMode,

View file

@ -28,6 +28,9 @@ export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] {
"../ui/node_modules/.vite-temp",
"../ui/.vite",
"../ui/dist",
// npm install during reinstall would trigger a restart mid-request
// if tsx watch sees the new files. Exclude the managed plugins dir.
process.env.HOME + "/.paperclip/adapter-plugins",
]) {
addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath));
}

View file

@ -668,6 +668,12 @@ export async function startServer(): Promise<StartedServer> {
}, backupIntervalMs);
}
// Wait for external adapters to finish loading before accepting requests.
// Without this, adapter type validation (assertKnownAdapterType) would
// reject valid external adapter types during the startup loading window.
const { waitForExternalAdapters } = await import("./adapters/registry.js");
await waitForExternalAdapters();
await new Promise<void>((resolveListen, rejectListen) => {
const onError = (err: Error) => {
server.off("error", onError);

View file

@ -0,0 +1,578 @@
/**
* @fileoverview Adapter management REST API routes
*
* This module provides Express routes for managing external adapter plugins:
* - Listing all registered adapters (built-in + external)
* - Installing external adapters from npm packages or local paths
* - Unregistering external adapters
*
* All routes require board-level authentication (assertBoard middleware).
*
* @module server/routes/adapters
*/
import { execFile } from "node:child_process";
import fs from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { Router } from "express";
import {
listServerAdapters,
findServerAdapter,
listEnabledServerAdapters,
registerServerAdapter,
unregisterServerAdapter,
} from "../adapters/registry.js";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import {
listAdapterPlugins,
addAdapterPlugin,
removeAdapterPlugin,
getAdapterPluginByType,
getAdapterPluginsDir,
getDisabledAdapterTypes,
setAdapterDisabled,
} from "../services/adapter-plugin-store.js";
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
import type { ServerAdapterModule } 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";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Known built-in adapter types (cannot be removed via the API)
// ---------------------------------------------------------------------------
const BUILTIN_ADAPTER_TYPES = new Set([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"openclaw_gateway",
"opencode_local",
"pi_local",
"process",
"http",
]);
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
interface AdapterInstallRequest {
/** npm package name (e.g., "droid-paperclip-adapter") or local path */
packageName: string;
/** True if packageName is a local filesystem path */
isLocalPath?: boolean;
/** Target version for npm packages (optional, defaults to latest) */
version?: string;
}
interface AdapterInfo {
type: string;
label: string;
source: "builtin" | "external";
modelsCount: number;
loaded: boolean;
disabled: boolean;
version?: string;
packageName?: string;
isLocalPath?: boolean;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Resolve the adapter package directory (same rules as plugin-loader).
*/
function resolveAdapterPackageDir(record: AdapterPluginRecord): string {
return record.localPath
? path.resolve(record.localPath)
: path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName);
}
/**
* Read `version` from the adapter's package.json on disk.
* This is the source of truth for what is actually installed (npm or local path).
*/
function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string | undefined {
try {
const pkgDir = resolveAdapterPackageDir(record);
const raw = fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8");
const v = JSON.parse(raw).version;
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
} catch {
return undefined;
}
}
function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set<string>): AdapterInfo {
const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined;
return {
type: adapter.type,
label: adapter.type, // ServerAdapterModule doesn't have a separate "label" field; type serves as label
source: externalRecord ? "external" : "builtin",
modelsCount: (adapter.models ?? []).length,
loaded: true, // If it's in the registry, it's loaded
disabled: disabledSet.has(adapter.type),
// Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields.
version: fromDisk ?? externalRecord?.version,
packageName: externalRecord?.packageName,
isLocalPath: externalRecord?.localPath ? true : undefined,
};
}
/**
* Normalize a local path that may be a Windows path into a WSL-compatible path.
*
* - Windows paths (e.g., "C:\\Users\\...") are converted via `wslpath -u`.
* - Paths already starting with `/mnt/` or `/` are returned as-is.
*/
async function normalizeLocalPath(rawPath: string): Promise<string> {
// Already a POSIX path (WSL or native Linux)
if (rawPath.startsWith("/")) {
return rawPath;
}
// Windows path detection: C:\ or C:/ pattern
if (/^[A-Za-z]:[\\/]/.test(rawPath)) {
try {
const { stdout } = await execFileAsync("wslpath", ["-u", rawPath]);
return stdout.trim();
} catch (err) {
logger.warn({ err, rawPath }, "wslpath conversion failed; using path as-is");
return rawPath;
}
}
return rawPath;
}
/**
* Register an adapter module into the server registry, filling in
* sessionManagement from the host.
*/
function registerWithSessionManagement(adapter: ServerAdapterModule): void {
const wrapped: ServerAdapterModule = {
...adapter,
sessionManagement: getAdapterSessionManagement(adapter.type) ?? undefined,
};
registerServerAdapter(wrapped);
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
export function adapterRoutes() {
const router = Router();
/**
* GET /api/adapters
*
* List all registered adapters (built-in + external).
* Each entry includes whether the adapter is built-in or external,
* its model count, and load status.
*/
router.get("/adapters", async (_req, res) => {
assertBoard(_req);
const registeredAdapters = listServerAdapters();
const externalRecords = new Map(
listAdapterPlugins().map((r) => [r.type, r]),
);
const disabledSet = new Set(getDisabledAdapterTypes());
const result: AdapterInfo[] = registeredAdapters.map((adapter) =>
buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet),
);
res.json(result);
});
/**
* POST /api/adapters/install
*
* Install an external adapter from an npm package or local path.
*
* Request body:
* - packageName: string (required) npm package name or local path
* - isLocalPath?: boolean (default false)
* - version?: string target version for npm packages
*/
router.post("/adapters/install", async (req, res) => {
assertBoard(req);
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
if (!packageName || typeof packageName !== "string") {
res.status(400).json({ error: "packageName is required and must be a string." });
return;
}
// Strip version suffix if the UI sends "pkg@1.2.3" instead of separating it
// e.g. "@henkey/hermes-paperclip-adapter@0.3.0" → packageName + version
let canonicalName = packageName;
let explicitVersion = version;
const versionSuffix = packageName.match(/@(\d+\.\d+\.\d+.*)$/);
if (versionSuffix) {
// For scoped packages: "@scope/name@1.2.3" → "@scope/name" + "1.2.3"
// For unscoped: "name@1.2.3" → "name" + "1.2.3"
const lastAtIndex = packageName.lastIndexOf("@");
if (lastAtIndex > 0 && !explicitVersion) {
canonicalName = packageName.slice(0, lastAtIndex);
explicitVersion = versionSuffix[1];
}
}
try {
let installedVersion: string | undefined;
let moduleLocalPath: string | undefined;
if (!isLocalPath) {
// npm install into the managed directory
const pluginsDir = getAdapterPluginsDir();
const spec = explicitVersion ? `${canonicalName}@${explicitVersion}` : canonicalName;
logger.info({ spec, pluginsDir }, "Installing adapter package via npm");
await execFileAsync("npm", ["install", "--no-save", spec], {
cwd: pluginsDir,
timeout: 120_000,
});
// Read installed version from package.json
try {
const pkgJsonPath = path.join(pluginsDir, "node_modules", canonicalName, "package.json");
const pkgContent = await import("node:fs/promises");
const pkgRaw = await pkgContent.readFile(pkgJsonPath, "utf-8");
const pkg = JSON.parse(pkgRaw);
const v = pkg.version;
installedVersion =
typeof v === "string" && v.trim().length > 0 ? v.trim() : explicitVersion;
} catch {
installedVersion = explicitVersion;
}
} else {
// Local path — normalize (e.g., Windows → WSL) and use the resolved path
moduleLocalPath = path.resolve(await normalizeLocalPath(packageName));
try {
const pkgRaw = await readFile(path.join(moduleLocalPath, "package.json"), "utf-8");
const v = JSON.parse(pkgRaw).version;
if (typeof v === "string" && v.trim().length > 0) {
installedVersion = v.trim();
}
} catch {
// leave installedVersion undefined if package.json is missing
}
}
// Load and register the adapter (use canonicalName for path resolution)
const adapterModule = await loadExternalAdapterPackage(canonicalName, moduleLocalPath);
// Check if this type conflicts with a built-in adapter
if (BUILTIN_ADAPTER_TYPES.has(adapterModule.type)) {
res.status(409).json({
error: `Adapter type "${adapterModule.type}" is a built-in adapter and cannot be overwritten.`,
});
return;
}
// Check if already registered (indicates a reinstall/update)
const existing = findServerAdapter(adapterModule.type);
const isReinstall = existing !== null;
if (existing) {
unregisterServerAdapter(adapterModule.type);
logger.info({ type: adapterModule.type }, "Unregistered existing adapter for replacement");
}
// Register the new adapter
registerWithSessionManagement(adapterModule);
// Persist the record (use canonicalName without version suffix)
const record: AdapterPluginRecord = {
packageName: canonicalName,
localPath: moduleLocalPath,
version: installedVersion ?? explicitVersion,
type: adapterModule.type,
installedAt: new Date().toISOString(),
};
addAdapterPlugin(record);
logger.info(
{ type: adapterModule.type, packageName: canonicalName },
"External adapter installed and registered",
);
res.status(201).json({
type: adapterModule.type,
packageName: canonicalName,
version: installedVersion ?? explicitVersion,
installedAt: record.installedAt,
requiresRestart: isReinstall,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, packageName }, "Failed to install external adapter");
// Distinguish npm errors from load errors
if (message.includes("npm") || message.includes("ERR!")) {
res.status(500).json({ error: `npm install failed: ${message}` });
} else {
res.status(500).json({ error: `Failed to install adapter: ${message}` });
}
}
});
/**
* PATCH /api/adapters/:type
*
* Enable or disable an adapter. Disabled adapters are hidden from agent
* creation menus but remain functional for existing agents.
*
* Request body: { "disabled": boolean }
*/
router.patch("/adapters/:type", async (req, res) => {
assertBoard(req);
const adapterType = req.params.type;
const { disabled } = req.body as { disabled?: boolean };
if (typeof disabled !== "boolean") {
res.status(400).json({ error: "Request body must include { \"disabled\": true|false }." });
return;
}
// Check that the adapter exists in the registry
const existing = findServerAdapter(adapterType);
if (!existing) {
res.status(404).json({ error: `Adapter "${adapterType}" is not registered.` });
return;
}
const changed = setAdapterDisabled(adapterType, disabled);
if (changed) {
logger.info({ type: adapterType, disabled }, "Adapter enabled/disabled");
}
res.json({ type: adapterType, disabled, changed });
});
/**
* DELETE /api/adapters/:type
*
* Unregister an external adapter. Built-in adapters cannot be removed.
*/
router.delete("/adapters/:type", async (req, res) => {
assertBoard(req);
const adapterType = req.params.type;
if (!adapterType) {
res.status(400).json({ error: "Adapter type is required." });
return;
}
// Prevent removal of built-in adapters
if (BUILTIN_ADAPTER_TYPES.has(adapterType)) {
res.status(403).json({
error: `Cannot remove built-in adapter "${adapterType}".`,
});
return;
}
// Check that the adapter exists in the registry
const existing = findServerAdapter(adapterType);
if (!existing) {
res.status(404).json({
error: `Adapter "${adapterType}" is not registered.`,
});
return;
}
// Check that it's an external adapter
const externalRecord = getAdapterPluginByType(adapterType);
if (!externalRecord) {
res.status(404).json({
error: `Adapter "${adapterType}" is not an externally installed adapter.`,
});
return;
}
// If installed via npm (has packageName but no localPath), run npm uninstall
if (externalRecord.packageName && !externalRecord.localPath) {
try {
const pluginsDir = getAdapterPluginsDir();
await execFileAsync("npm", ["uninstall", externalRecord.packageName], {
cwd: pluginsDir,
timeout: 60_000,
});
logger.info(
{ type: adapterType, packageName: externalRecord.packageName },
"npm uninstall completed for external adapter",
);
} catch (err) {
logger.warn(
{ err, type: adapterType, packageName: externalRecord.packageName },
"npm uninstall failed for external adapter; continuing with unregister",
);
}
}
// Unregister from the runtime registry
unregisterServerAdapter(adapterType);
// Remove from the persistent store
removeAdapterPlugin(adapterType);
logger.info({ type: adapterType }, "External adapter unregistered and removed");
res.json({ type: adapterType, removed: true });
});
/**
* POST /api/adapters/:type/reload
*
* Reload an external adapter at runtime (for dev iteration without server restart).
* Busts the ESM module cache, re-imports the adapter, and re-registers it.
*
* Cannot be used on built-in adapter types.
*/
router.post("/adapters/:type/reload", async (req, res) => {
assertBoard(req);
const type = req.params.type;
// Built-in adapters cannot be reloaded
if (BUILTIN_ADAPTER_TYPES.has(type)) {
res.status(400).json({ error: "Cannot reload built-in adapter." });
return;
}
// Reload the adapter module (busts ESM cache, re-imports)
try {
const newModule = await reloadExternalAdapter(type);
// Not found in the external adapter store
if (!newModule) {
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
return;
}
// Swap in the reloaded module
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
// Sync store.version from package.json (store may be missing version for local installs).
const record = getAdapterPluginByType(type);
let newVersion: string | undefined;
if (record) {
newVersion = readAdapterPackageVersionFromDisk(record);
if (newVersion) {
addAdapterPlugin({ ...record, version: newVersion });
}
}
logger.info({ type, version: newVersion }, "External adapter reloaded at runtime");
res.json({ type, version: newVersion, reloaded: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to reload external adapter");
res.status(500).json({ error: `Failed to reload adapter: ${message}` });
}
});
// ── POST /api/adapters/:type/reinstall ──────────────────────────────────
// Reinstall an npm-sourced external adapter (pulls latest from registry).
// Local-path adapters cannot be reinstalled — use Reload instead.
//
// This is a convenience shortcut for remove + install with the same
// package name, but without the risk of losing the store record.
router.post("/adapters/:type/reinstall", async (req, res) => {
assertBoard(req);
const type = req.params.type;
if (BUILTIN_ADAPTER_TYPES.has(type)) {
res.status(400).json({ error: "Cannot reinstall built-in adapter." });
return;
}
const record = getAdapterPluginByType(type);
if (!record) {
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
return;
}
if (record.localPath) {
res.status(400).json({ error: "Local-path adapters cannot be reinstalled. Use Reload instead." });
return;
}
try {
const pluginsDir = getAdapterPluginsDir();
logger.info({ type, packageName: record.packageName }, "Reinstalling adapter package via npm");
await execFileAsync("npm", ["install", "--no-save", record.packageName], {
cwd: pluginsDir,
timeout: 120_000,
});
// Reload the freshly installed adapter
const newModule = await reloadExternalAdapter(type);
if (!newModule) {
res.status(500).json({ error: "npm install succeeded but adapter reload failed." });
return;
}
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
// Sync store version from disk
let newVersion: string | undefined;
const updatedRecord = getAdapterPluginByType(type);
if (updatedRecord) {
newVersion = readAdapterPackageVersionFromDisk(updatedRecord);
if (newVersion) {
addAdapterPlugin({ ...updatedRecord, version: newVersion });
}
}
logger.info({ type, version: newVersion }, "Adapter reinstalled from npm");
res.json({ type, version: newVersion, reinstalled: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to reinstall adapter");
res.status(500).json({ error: `Reinstall failed: ${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
// without modifying Paperclip's source code.
//
// The adapter package must export a "./ui-parser" entry in package.json
// pointing to a self-contained ESM module with zero runtime dependencies.
router.get("/adapters/:type/ui-parser.js", (req, res) => {
assertBoard(req);
const { type } = req.params;
const source = getOrExtractUiParserSource(type);
if (!source) {
res.status(404).json({ error: `No UI parser available for adapter "${type}".` });
return;
}
res.type("application/javascript").send(source);
});
return router;
}

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 (

View file

@ -0,0 +1,177 @@
/**
* JSON-file-backed store for external adapter registrations.
*
* Stores metadata about externally installed adapter packages at
* ~/.paperclip/adapter-plugins.json. This is the source of truth for which
* external adapters should be loaded at startup.
*
* Both the plugin store and the settings store are cached in memory after
* the first read. Writes invalidate the cache so the next read picks up
* the new state without a redundant disk round-trip.
*
* @module server/services/adapter-plugin-store
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AdapterPluginRecord {
/** npm package name (e.g., "droid-paperclip-adapter") */
packageName: string;
/** Absolute local filesystem path (for locally linked adapters) */
localPath?: string;
/** Installed version string (for npm packages) */
version?: string;
/** Adapter type identifier (matches ServerAdapterModule.type) */
type: string;
/** ISO 8601 timestamp of when the adapter was installed */
installedAt: string;
/** Whether this adapter is disabled (hidden from menus but still functional) */
disabled?: boolean;
}
interface AdapterSettings {
disabledTypes: string[];
}
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const PAPERCLIP_DIR = path.join(os.homedir(), ".paperclip");
const ADAPTER_PLUGINS_DIR = path.join(PAPERCLIP_DIR, "adapter-plugins");
const ADAPTER_PLUGINS_STORE_PATH = path.join(PAPERCLIP_DIR, "adapter-plugins.json");
const ADAPTER_SETTINGS_PATH = path.join(PAPERCLIP_DIR, "adapter-settings.json");
// ---------------------------------------------------------------------------
// In-memory caches (invalidated on write)
// ---------------------------------------------------------------------------
let storeCache: AdapterPluginRecord[] | null = null;
let settingsCache: AdapterSettings | null = null;
// ---------------------------------------------------------------------------
// Store functions
// ---------------------------------------------------------------------------
function ensureDirs(): void {
fs.mkdirSync(ADAPTER_PLUGINS_DIR, { recursive: true });
const pkgJsonPath = path.join(ADAPTER_PLUGINS_DIR, "package.json");
if (!fs.existsSync(pkgJsonPath)) {
fs.writeFileSync(pkgJsonPath, JSON.stringify({
name: "paperclip-adapter-plugins",
version: "0.0.0",
private: true,
description: "Managed directory for Paperclip external adapter plugins. Do not edit manually.",
}, null, 2) + "\n");
}
}
function readStore(): AdapterPluginRecord[] {
if (storeCache) return storeCache;
try {
const raw = fs.readFileSync(ADAPTER_PLUGINS_STORE_PATH, "utf-8");
const parsed = JSON.parse(raw);
storeCache = Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : [];
} catch {
storeCache = [];
}
return storeCache;
}
function writeStore(records: AdapterPluginRecord[]): void {
ensureDirs();
fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8");
storeCache = records;
}
function readSettings(): AdapterSettings {
if (settingsCache) return settingsCache;
try {
const raw = fs.readFileSync(ADAPTER_SETTINGS_PATH, "utf-8");
const parsed = JSON.parse(raw);
settingsCache = parsed && Array.isArray(parsed.disabledTypes)
? (parsed as AdapterSettings)
: { disabledTypes: [] };
} catch {
settingsCache = { disabledTypes: [] };
}
return settingsCache;
}
function writeSettings(settings: AdapterSettings): void {
ensureDirs();
fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
settingsCache = settings;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function listAdapterPlugins(): AdapterPluginRecord[] {
return readStore();
}
export function addAdapterPlugin(record: AdapterPluginRecord): void {
const store = [...readStore()];
const idx = store.findIndex((r) => r.type === record.type);
if (idx >= 0) {
store[idx] = record;
} else {
store.push(record);
}
writeStore(store);
}
export function removeAdapterPlugin(type: string): boolean {
const store = [...readStore()];
const idx = store.findIndex((r) => r.type === type);
if (idx < 0) return false;
store.splice(idx, 1);
writeStore(store);
return true;
}
export function getAdapterPluginByType(type: string): AdapterPluginRecord | undefined {
return readStore().find((r) => r.type === type);
}
export function getAdapterPluginsDir(): string {
ensureDirs();
return ADAPTER_PLUGINS_DIR;
}
// ---------------------------------------------------------------------------
// Adapter enable/disable (settings)
// ---------------------------------------------------------------------------
export function getDisabledAdapterTypes(): string[] {
return readSettings().disabledTypes;
}
export function isAdapterDisabled(type: string): boolean {
return readSettings().disabledTypes.includes(type);
}
export function setAdapterDisabled(type: string, disabled: boolean): boolean {
const settings = { ...readSettings(), disabledTypes: [...readSettings().disabledTypes] };
const idx = settings.disabledTypes.indexOf(type);
if (disabled && idx < 0) {
settings.disabledTypes.push(type);
writeSettings(settings);
return true;
}
if (!disabled && idx >= 0) {
settings.disabledTypes.splice(idx, 1);
writeSettings(settings);
return true;
}
return false;
}

View file

@ -7,6 +7,12 @@ function readNumericField(record: Record<string, unknown>, key: string) {
return key in record ? record[key] ?? null : undefined;
}
function readCommentText(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function summarizeHeartbeatRunResultJson(
resultJson: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
@ -33,3 +39,18 @@ export function summarizeHeartbeatRunResultJson(
return Object.keys(summary).length > 0 ? summary : null;
}
export function buildHeartbeatRunIssueComment(
resultJson: Record<string, unknown> | null | undefined,
): string | null {
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
return null;
}
return (
readCommentText(resultJson.summary)
?? readCommentText(resultJson.result)
?? readCommentText(resultJson.message)
?? null
);
}

View file

@ -31,7 +31,7 @@ import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
import { buildHeartbeatRunIssueComment, summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
import {
buildWorkspaceReadyComment,
cleanupExecutionWorkspaceArtifacts,
@ -2838,6 +2838,19 @@ export function heartbeatService(db: Db) {
exitCode: adapterResult.exitCode,
},
});
if (issueId && outcome === "succeeded") {
try {
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
if (issueComment) {
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id });
}
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to post run summary comment: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
await releaseIssueExecutionAndPromote(finalizedRun);
}

View file

@ -807,7 +807,7 @@ export function buildHostServices(
return (await issues.addComment(
params.issueId,
params.body,
{},
{ agentId: params.authorAgentId },
)) as IssueComment;
},
},