paperclip/server/src/adapters/registry.ts

1135 lines
41 KiB
TypeScript

import type { AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js";
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
import {
buildSandboxNpmInstallCommand,
getAdapterSessionManagement,
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
} from "@paperclipai/adapter-utils";
import type { AdapterLoginCapability } from "@paperclipai/adapter-utils";
import {
execute as claudeExecute,
listClaudeSkills,
syncClaudeSkills,
listClaudeModels,
refreshClaudeModels,
testEnvironment as claudeTestEnvironment,
sessionCodec as claudeSessionCodec,
getQuotaWindows as claudeGetQuotaWindows,
getConfigSchema as getClaudeConfigSchema,
CLAUDE_SETUP_TOKEN_COMMAND,
parseSetupTokenPrompt,
parseSetupTokenCredential,
} from "@paperclipai/adapter-claude-local/server";
import {
agentConfigurationDoc as claudeAgentConfigurationDoc,
models as claudeModels,
} from "@paperclipai/adapter-claude-local";
import {
execute as codexExecute,
listCodexSkills,
syncCodexSkills,
testEnvironment as codexTestEnvironment,
sessionCodec as codexSessionCodec,
getQuotaWindows as codexGetQuotaWindows,
getConfigSchema as getCodexConfigSchema,
CODEX_DEVICE_LOGIN_COMMAND,
parseDeviceLoginPrompt,
} from "@paperclipai/adapter-codex-local/server";
import {
agentConfigurationDoc as codexAgentConfigurationDoc,
models as codexModels,
} from "@paperclipai/adapter-codex-local";
import {
execute as cursorExecute,
listCursorSkills,
syncCursorSkills,
testEnvironment as cursorTestEnvironment,
sessionCodec as cursorSessionCodec,
} from "@paperclipai/adapter-cursor-local/server";
import {
agentConfigurationDoc as cursorAgentConfigurationDoc,
models as cursorModels,
} from "@paperclipai/adapter-cursor-local";
import {
execute as cursorCloudExecute,
getConfigSchema as getCursorCloudConfigSchema,
sessionCodec as cursorCloudSessionCodec,
testEnvironment as cursorCloudTestEnvironment,
} from "@paperclipai/adapter-cursor-cloud/server";
import { agentConfigurationDoc as cursorCloudAgentConfigurationDoc } from "@paperclipai/adapter-cursor-cloud";
import {
execute as geminiExecute,
listGeminiSkills,
syncGeminiSkills,
testEnvironment as geminiTestEnvironment,
sessionCodec as geminiSessionCodec,
getConfigSchema as getGeminiConfigSchema,
} from "@paperclipai/adapter-gemini-local/server";
import {
agentConfigurationDoc as geminiAgentConfigurationDoc,
models as geminiModels,
} from "@paperclipai/adapter-gemini-local";
import {
execute as grokExecute,
listGrokSkills,
syncGrokSkills,
testEnvironment as grokTestEnvironment,
sessionCodec as grokSessionCodec,
GROK_DEVICE_LOGIN_COMMAND,
parseGrokDeviceLoginPrompt,
} from "@paperclipai/adapter-grok-local/server";
import {
agentConfigurationDoc as grokAgentConfigurationDoc,
models as grokModels,
} from "@paperclipai/adapter-grok-local";
import {
execute as kimiExecute,
listKimiSkills,
syncKimiSkills,
testEnvironment as kimiTestEnvironment,
sessionCodec as kimiSessionCodec,
} from "@paperclipai/adapter-kimi-local/server";
import {
agentConfigurationDoc as kimiAgentConfigurationDoc,
models as kimiModels,
} from "@paperclipai/adapter-kimi-local";
import {
createHermesGatewayServerAdapter,
createHermesLocalServerAdapter,
} from "@paperclipai/hermes-paperclip-adapter";
import {
execute as openCodeExecute,
listOpenCodeSkills,
syncOpenCodeSkills,
testEnvironment as openCodeTestEnvironment,
sessionCodec as openCodeSessionCodec,
listOpenCodeModels,
} from "@paperclipai/adapter-opencode-local/server";
import {
agentConfigurationDoc as openCodeAgentConfigurationDoc,
models as openCodeModels,
} from "@paperclipai/adapter-opencode-local";
import {
execute as openclawGatewayExecute,
testEnvironment as openclawGatewayTestEnvironment,
} from "@paperclipai/adapter-openclaw-gateway/server";
import {
agentConfigurationDoc as openclawGatewayAgentConfigurationDoc,
models as openclawGatewayModels,
} from "@paperclipai/adapter-openclaw-gateway";
import { listCodexModels, refreshCodexModels } from "./codex-models.js";
import { listCursorModels } from "./cursor-models.js";
import {
execute as piExecute,
listPiSkills,
syncPiSkills,
testEnvironment as piTestEnvironment,
sessionCodec as piSessionCodec,
listPiModels,
} from "@paperclipai/adapter-pi-local/server";
import { agentConfigurationDoc as piAgentConfigurationDoc } from "@paperclipai/adapter-pi-local";
import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js";
import { buildExternalAdapters } from "./plugin-loader.js";
import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
import {
DEFAULT_OPENCODE_RUNNER_MODEL,
PaperclipRunnerProviderProfileError,
QUALIFIED_ACPX_RUNNER_MODELS,
QUALIFIED_OPENCODE_RUNNER_VERSION,
resolvePaperclipRunnerProviderProfile,
} from "../services/native-runtime/provider-profile.js";
function readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
const value = typeof config.command === "string" ? config.command.trim() : "";
return value.length > 0 ? value : fallback;
}
function hasPathSeparator(command: string): boolean {
return command.includes("/") || command.includes("\\");
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function buildNpmRuntimeCommandSpec(
config: Record<string, unknown>,
fallbackCommand: string,
packageName: string,
): AdapterRuntimeCommandSpec {
const command = readConfiguredCommand(config, fallbackCommand);
const canSelfInstall = !hasPathSeparator(command) && command === fallbackCommand;
const installLine = buildSandboxNpmInstallCommand(packageName);
return {
command,
detectCommand: command,
installCommand: canSelfInstall
? `if ! command -v ${shellQuote(command)} >/dev/null 2>&1; then ${installLine}; fi`
: null,
};
}
function buildCursorRuntimeCommandSpec(config: Record<string, unknown>): AdapterRuntimeCommandSpec {
const command = readConfiguredCommand(config, "agent");
return {
command,
detectCommand: command,
installCommand: null,
};
}
const retiredAcpxMessage =
"The acpx_local adapter has been retired. Existing Claude and Codex ACPX agents should be migrated to claude_local or codex_local with adapterConfig.engine=\"acp\".";
const retiredAcpxAgentConfigurationDoc = `# acpx_local retired
Adapter: acpx_local
The standalone ACPX adapter has been retired. Use:
- claude_local with adapterConfig.engine="acp" for Claude ACP execution.
- codex_local with adapterConfig.engine="acp" for Codex ACP execution.
Paperclip keeps this tombstone registered so stale acpx_local rows fail clearly instead of falling back to the process adapter.
`;
// The Claude interactive login capability. Claude runs `claude setup-token` on a
// real pseudo-terminal. The user pastes a browser code back into the flow. The
// flow uses a fixed host-side timeout and records a stored session identifier on
// success. The capability data holds no secret; the callbacks return runtime
// values only.
const claudeLoginCapability: AdapterLoginCapability = {
panelMode: "submitted_browser_code",
timeoutPolicy: "fixed",
getCommand: () => CLAUDE_SETUP_TOKEN_COMMAND,
parsePrompt: (output) => {
const prompt = parseSetupTokenPrompt(output);
return prompt ? { url: prompt.url } : null;
},
captureCredential: (output) => {
const token = parseSetupTokenCredential(output);
return token === null ? null : Buffer.from(token, "utf8");
},
completionClaim: "storedSessionId",
};
// The Codex interactive login capability. Codex runs `codex login --device-auth`
// on a real pseudo-terminal, because a pipe emits no login prompt. The flow shows
// a one-time code that the user enters in the browser. The caller sets the
// host-side timeout. The device-login flow writes its credential inside the
// sandbox, so the capability declares no terminal credential capture and no
// completion claim.
const codexLoginCapability: AdapterLoginCapability = {
panelMode: "displayed_code",
timeoutPolicy: "caller_bounded",
getCommand: () => CODEX_DEVICE_LOGIN_COMMAND,
parsePrompt: (output) => {
const prompt = parseDeviceLoginPrompt(output);
return prompt ? { url: prompt.url, code: prompt.code } : null;
},
};
// The Grok interactive login capability. Grok runs `grok login --device-auth`
// on a real pseudo-terminal, the same way Codex does. The flow shows a
// one-time code that the user enters in the browser. The caller sets the
// host-side timeout. The device-login flow writes its credential inside the
// sandbox, so the capability declares no terminal credential capture and no
// completion claim. `getCommand` is descriptive only: the login path selects
// the real command from the closed key map in `login-command.ts`, never from
// this member.
const grokLoginCapability: AdapterLoginCapability = {
panelMode: "displayed_code",
timeoutPolicy: "caller_bounded",
getCommand: () => GROK_DEVICE_LOGIN_COMMAND,
parsePrompt: (output) => {
const prompt = parseGrokDeviceLoginPrompt(output);
return prompt ? { url: prompt.url, code: prompt.code } : null;
},
};
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
runtimeToolDelivery: "native_mcp",
execute: stampClaudeAgentIdHeader(claudeExecute),
testEnvironment: claudeTestEnvironment,
acp: {
agentId: "claude",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=24.11.0",
packages: ["@agentclientprotocol/claude-agent-acp"],
},
},
listSkills: listClaudeSkills,
syncSkills: syncClaudeSkills,
sessionCodec: claudeSessionCodec,
sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined,
models: claudeModels,
listModels: listClaudeModels,
refreshModels: refreshClaudeModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "claude", "@anthropic-ai/claude-code"),
agentConfigurationDoc: claudeAgentConfigurationDoc,
getConfigSchema: getClaudeConfigSchema,
getQuotaWindows: claudeGetQuotaWindows,
loginCapability: claudeLoginCapability,
};
const acpxLocalAdapter: ServerAdapterModule = {
type: "acpx_local",
runtimeToolDelivery: "environment",
async execute(ctx) {
await ctx.onLog("stderr", `${retiredAcpxMessage}\n`);
await ctx.onMeta?.({
adapterType: "acpx_local",
command: "acpx_local-retired",
commandNotes: [retiredAcpxMessage],
});
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: retiredAcpxMessage,
errorCode: "acpx_local_retired",
provider: "acpx",
summary: retiredAcpxMessage,
};
},
async testEnvironment() {
return {
adapterType: "acpx_local",
status: "fail",
testedAt: new Date().toISOString(),
checks: [
{
code: "acpx_local_retired",
level: "error",
message: retiredAcpxMessage,
hint: "Set the agent adapter to claude_local or codex_local and set adapterConfig.engine to acp.",
},
],
};
},
models: [],
supportsLocalAgentJwt: false,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc: retiredAcpxAgentConfigurationDoc,
getConfigSchema: () => ({ fields: [] }),
};
const codexLocalAdapter: ServerAdapterModule = {
type: "codex_local",
runtimeToolDelivery: "native_mcp",
execute: codexExecute,
testEnvironment: codexTestEnvironment,
acp: {
agentId: "codex",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=24.11.0",
packages: ["@agentclientprotocol/codex-acp"],
},
},
listSkills: listCodexSkills,
syncSkills: syncCodexSkills,
sessionCodec: codexSessionCodec,
sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined,
models: codexModels,
listModels: listCodexModels,
refreshModels: refreshCodexModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: codexAgentConfigurationDoc,
getConfigSchema: getCodexConfigSchema,
getQuotaWindows: codexGetQuotaWindows,
loginCapability: codexLoginCapability,
};
const paperclipRunnerAdapter: ServerAdapterModule = {
type: "paperclip_runner",
runtimeToolDelivery: "environment",
async execute(ctx) {
const message = "paperclip_runner requires the native runner coordinator";
await ctx.onLog("stderr", `${message}\n`);
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: message,
errorCode: "paperclip_runner_coordinator_required",
provider: ctx.config.provider === "opencode"
? "opencode"
: ctx.config.provider === "claude_managed"
? "anthropic"
: ctx.config.provider === "aws_agentcore"
? "amazon-bedrock"
: ctx.config.provider === "acpx"
? "acpx"
: "codex",
summary: message,
};
},
async testEnvironment(context) {
let profile: ReturnType<typeof resolvePaperclipRunnerProviderProfile>;
try {
profile = resolvePaperclipRunnerProviderProfile(context.config);
} catch (error) {
const profileError = error instanceof PaperclipRunnerProviderProfileError
? error
: new PaperclipRunnerProviderProfileError(
"paperclip_runner_provider_unsupported",
"Paperclip Runner provider configuration is invalid.",
);
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt: new Date().toISOString(),
checks: [{
code: profileError.code,
level: "error" as const,
message: profileError.message,
}],
};
}
if (profile.provider === "acpx") {
return {
adapterType: "paperclip_runner",
status: "pass" as const,
testedAt: new Date().toISOString(),
checks: [{
code: "acpx_profile_qualified",
level: "info" as const,
message: `ACPX ${profile.acpxAgent} is pinned to the qualified ${profile.model} profile; process readiness is verified by runnerd before the first turn.`,
}],
};
}
if (profile.provider === "claude_managed") {
return {
adapterType: "paperclip_runner",
status: "warn" as const,
testedAt: new Date().toISOString(),
checks: [{
code: "claude_managed_profile_selected",
level: "info" as const,
message: `Claude Managed profile ${profile.managedProfileId} is selected with retention acknowledged. Its stored qualification, API-key binding, and spend ceiling are verified before the first turn.`,
}, {
code: "claude_managed_retention_notice",
level: "warn" as const,
message: "Claude Managed is a stateful beta service and is not eligible for ZDR or HIPAA modes.",
}],
};
}
if (profile.provider === "aws_agentcore") {
return {
adapterType: "paperclip_runner",
status: "warn" as const,
testedAt: new Date().toISOString(),
checks: [{
code: "aws_agentcore_profile_selected",
level: "info" as const,
message: `AWS AgentCore profile ${profile.agentCoreProfileId} is selected with retention acknowledged. Its stored qualification, invocation limits, and estimated spend ceiling are verified before the first turn.`,
}, {
code: "aws_agentcore_retention_notice",
level: "warn" as const,
message: "AgentCore Memory retains short-term events for 90 days; the spend ceiling is an estimate, not an AWS currency hard stop.",
}],
};
}
const result = profile.provider === "opencode"
? await openCodeTestEnvironment(context)
: await codexTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
},
listSkills: listCodexSkills,
syncSkills: syncCodexSkills,
sessionCodec: codexSessionCodec,
models: [
...codexModels,
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: QUALIFIED_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
],
listModels: async () => [
...await listCodexModels(),
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: QUALIFIED_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
],
refreshModels: async () => [
...await refreshCodexModels(),
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: QUALIFIED_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
],
supportsLocalAgentJwt: false,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) => config.provider === "claude_managed"
|| config.provider === "aws_agentcore"
|| config.provider === "acpx"
? { command: "paperclip-runnerd", detectCommand: null, installCommand: null }
: config.provider === "opencode"
? buildNpmRuntimeCommandSpec(
config,
"opencode",
`opencode-ai@${QUALIFIED_OPENCODE_RUNNER_VERSION}`,
)
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.148.0"),
agentConfigurationDoc:
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or a qualified Claude/Codex ACP agent through the Rust Paperclip runner and authenticated PRP transport. Pi is not available through the qualified ACPX profile. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n",
getConfigSchema: () => ({
fields: [
{
key: "provider",
label: "Provider",
type: "select" as const,
default: "codex",
options: [
{ value: "codex", label: "Codex" },
{ value: "opencode", label: `OpenCode ${QUALIFIED_OPENCODE_RUNNER_VERSION}` },
{ value: "claude_managed", label: "Claude Managed" },
{ value: "aws_agentcore", label: "AWS AgentCore" },
{ value: "acpx", label: "ACPX" },
],
hint: "Select a local provider, company-qualified managed provider, or qualified Claude/Codex ACPX profile.",
},
{
key: "codexPermissionMode",
label: "Codex permission mode",
type: "select" as const,
default: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode,
options: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.options.map(
({ value, label }) => ({ value, label }),
),
hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.description,
meta: { visibleWhen: { key: "provider", value: "codex" } },
},
{
key: "opencodePermissionMode",
label: "OpenCode permission mode",
type: "select" as const,
default: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.opencode.defaultMode,
options: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.opencode.options.map(
({ value, label }) => ({ value, label }),
),
hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.opencode.description,
meta: { visibleWhen: { key: "provider", value: "opencode" } },
},
{
key: "acpxPermissionMode",
label: "ACPX permission mode",
type: "select" as const,
default: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.acpx.defaultMode,
options: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.acpx.options.map(
({ value, label }) => ({ value, label }),
),
hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.acpx.description,
meta: { visibleWhen: { key: "provider", value: "acpx" } },
},
{
key: "acpxAgent",
label: "ACP agent",
type: "select" as const,
default: "claude",
options: [
{ value: "claude", label: "Claude via ACPX" },
{ value: "codex", label: "Codex via ACPX" },
],
hint: "Only the pinned Claude and Codex profiles are qualified; Pi is unavailable.",
meta: { visibleWhen: { key: "provider", value: "acpx" } },
},
{
key: "model",
label: "Provider model",
type: "text" as const,
default: "",
placeholder: DEFAULT_OPENCODE_RUNNER_MODEL,
hint: "OpenCode uses provider/model form. ACPX models are pinned by the selected qualified agent profile.",
meta: { visibleWhen: { key: "provider", value: "opencode" } },
},
{
key: "managedProfileId",
label: "Managed Agent profile",
type: "text" as const,
required: true,
hint: "Company-scoped qualified profile ID or key.",
meta: { visibleWhen: { key: "provider", value: "claude_managed" } },
},
{
key: "maxSessionListCostUsd",
label: "Session spend ceiling (USD)",
type: "number" as const,
default: 1,
hint: "Hard ceiling for one Claude Managed session.",
meta: { visibleWhen: { key: "provider", value: "claude_managed" } },
},
{
key: "managedAgentsRetentionAcknowledged",
label: "Acknowledge managed retention",
type: "toggle" as const,
default: false,
hint: "Claude Managed is stateful beta and is not eligible for ZDR or HIPAA modes.",
meta: { visibleWhen: { key: "provider", value: "claude_managed" } },
},
{
key: "agentCoreProfileId",
label: "AgentCore profile",
type: "text" as const,
required: true,
hint: "Company-scoped qualified AgentCore profile ID or key.",
meta: { visibleWhen: { key: "provider", value: "aws_agentcore" } },
},
{
key: "maxEstimatedSessionCostUsd",
label: "Estimated session ceiling (USD)",
type: "number" as const,
default: 1,
hint: "Paperclip estimate; AWS does not provide a per-session currency hard stop.",
meta: { visibleWhen: { key: "provider", value: "aws_agentcore" } },
},
{
key: "maxIterations",
label: "Maximum iterations",
type: "number" as const,
default: 8,
hint: "Must be between 1 and the qualified maximum of 8.",
meta: { visibleWhen: { key: "provider", value: "aws_agentcore" } },
},
{
key: "maxOutputTokens",
label: "Maximum output tokens",
type: "number" as const,
default: 4_096,
hint: "Must be between 1 and the qualified maximum of 4096.",
meta: { visibleWhen: { key: "provider", value: "aws_agentcore" } },
},
{
key: "timeoutSeconds",
label: "Invocation timeout (seconds)",
type: "number" as const,
default: 300,
hint: "Must be between 1 and the qualified maximum of 300 seconds.",
meta: { visibleWhen: { key: "provider", value: "aws_agentcore" } },
},
{
key: "agentCoreRetentionAcknowledged",
label: "Acknowledge 90-day Memory retention",
type: "toggle" as const,
default: false,
hint: "The qualified AgentCore profile retains short-term Memory events for 90 days.",
meta: { visibleWhen: { key: "provider", value: "aws_agentcore" } },
},
{
key: "lifecycleMode",
label: "Runner lifecycle",
type: "select" as const,
default: "per_turn",
options: [
{ value: "per_turn", label: "Turn by turn" },
{ value: "warm", label: "Warm session" },
],
hint: "Warm sessions retain runnerd and Codex between governed runs.",
},
{
key: "idleTimeoutMs",
label: "Warm idle timeout (ms)",
type: "number" as const,
default: 300_000,
hint: "Warm sessions suspend after this much inactivity.",
meta: { visibleWhen: { key: "lifecycleMode", value: "warm" } },
},
],
}),
loginCapability: codexLoginCapability,
};
const cursorLocalAdapter: ServerAdapterModule = {
type: "cursor",
runtimeToolDelivery: "environment",
execute: cursorExecute,
testEnvironment: cursorTestEnvironment,
listSkills: listCursorSkills,
syncSkills: syncCursorSkills,
sessionCodec: cursorSessionCodec,
sessionManagement: getAdapterSessionManagement("cursor") ?? undefined,
models: cursorModels,
listModels: listCursorModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: buildCursorRuntimeCommandSpec,
agentConfigurationDoc: cursorAgentConfigurationDoc,
};
const cursorCloudAdapter: ServerAdapterModule = {
type: "cursor_cloud",
runtimeToolDelivery: "invocation_context",
execute: cursorCloudExecute,
testEnvironment: cursorCloudTestEnvironment,
sessionCodec: cursorCloudSessionCodec,
sessionManagement: getAdapterSessionManagement("cursor_cloud") ?? undefined,
models: [],
supportsLocalAgentJwt: false,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc: cursorCloudAgentConfigurationDoc,
getConfigSchema: getCursorCloudConfigSchema,
};
const geminiLocalAdapter: ServerAdapterModule = {
type: "gemini_local",
runtimeToolDelivery: "environment",
execute: geminiExecute,
testEnvironment: geminiTestEnvironment,
acp: {
agentId: "gemini",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=24.11.0",
packages: ["@google/gemini-cli"],
},
},
listSkills: listGeminiSkills,
syncSkills: syncGeminiSkills,
sessionCodec: geminiSessionCodec,
sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined,
models: geminiModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "gemini", "@google/gemini-cli"),
agentConfigurationDoc: geminiAgentConfigurationDoc,
getConfigSchema: getGeminiConfigSchema,
};
const grokLocalAdapter: ServerAdapterModule = {
type: "grok_local",
runtimeToolDelivery: "environment",
execute: grokExecute,
testEnvironment: grokTestEnvironment,
listSkills: listGrokSkills,
syncSkills: syncGrokSkills,
sessionCodec: grokSessionCodec,
sessionManagement: getAdapterSessionManagement("grok_local") ?? undefined,
models: grokModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) => ({
command: readConfiguredCommand(config, "grok"),
detectCommand: readConfiguredCommand(config, "grok"),
installCommand: null,
}),
agentConfigurationDoc: grokAgentConfigurationDoc,
loginCapability: grokLoginCapability,
};
const kimiLocalAdapter: ServerAdapterModule = {
type: "kimi_local",
runtimeToolDelivery: "environment",
execute: kimiExecute,
testEnvironment: kimiTestEnvironment,
acp: {
agentId: "kimi",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=20.0.0",
packages: ["@moonshot-ai/kimi-code"],
},
},
listSkills: listKimiSkills,
syncSkills: syncKimiSkills,
sessionCodec: kimiSessionCodec,
sessionManagement: getAdapterSessionManagement("kimi_local") ?? undefined,
models: kimiModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "kimi", "@moonshot-ai/kimi-code"),
agentConfigurationDoc: kimiAgentConfigurationDoc,
};
const hermesGatewayAdapter: ServerAdapterModule = {
...createHermesGatewayServerAdapter(),
runtimeToolDelivery: "invocation_context",
};
const hermesLocalAdapter: ServerAdapterModule = {
...createHermesLocalServerAdapter(),
runtimeToolDelivery: "environment",
};
const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway",
runtimeToolDelivery: "invocation_context",
execute: openclawGatewayExecute,
testEnvironment: openclawGatewayTestEnvironment,
models: openclawGatewayModels,
supportsLocalAgentJwt: false,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc: openclawGatewayAgentConfigurationDoc,
};
const openCodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
runtimeToolDelivery: "environment",
execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment,
listSkills: listOpenCodeSkills,
syncSkills: syncOpenCodeSkills,
sessionCodec: openCodeSessionCodec,
models: openCodeModels,
sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined,
listModels: listOpenCodeModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai"),
agentConfigurationDoc: openCodeAgentConfigurationDoc,
};
const piLocalAdapter: ServerAdapterModule = {
type: "pi_local",
runtimeToolDelivery: "environment",
execute: piExecute,
testEnvironment: piTestEnvironment,
listSkills: listPiSkills,
syncSkills: syncPiSkills,
sessionCodec: piSessionCodec,
sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined,
models: [],
listModels: listPiModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "pi", "@mariozechner/pi-coding-agent"),
agentConfigurationDoc: piAgentConfigurationDoc,
};
const adaptersByType = new Map<string, ServerAdapterModule>();
// For builtin types that are overridden by an external adapter, we keep the
// original builtin so it can be restored when the override is deactivated.
const builtinFallbacks = new Map<string, ServerAdapterModule>();
// Tracks which override types are currently deactivated (paused). When
// paused, `getServerAdapter()` returns the builtin fallback instead of the
// external. Persisted across reloads via the same disabled-adapters store.
const pausedOverrides = new Set<string>();
function registerBuiltInAdapters() {
for (const adapter of [
acpxLocalAdapter,
claudeLocalAdapter,
codexLocalAdapter,
paperclipRunnerAdapter,
openCodeLocalAdapter,
piLocalAdapter,
cursorCloudAdapter,
cursorLocalAdapter,
geminiLocalAdapter,
grokLocalAdapter,
kimiLocalAdapter,
hermesGatewayAdapter,
hermesLocalAdapter,
openclawGatewayAdapter,
processAdapter,
httpAdapter,
]) {
adaptersByType.set(adapter.type, adapter);
}
}
registerBuiltInAdapters();
// ---------------------------------------------------------------------------
// Load external adapter plugins (e.g. droid_local)
//
// External adapter packages export createServerAdapter() which returns a
// ServerAdapterModule. When the module provides its own sessionManagement
// it is preserved; otherwise the host falls back to the built-in registry
// lookup (so externals that override a built-in type inherit the builtin's
// policy). This brings init-time registration to at-least-as-good behavior
// as the hot-install path (routes/adapters.ts:179 -> registerServerAdapter):
// both preserve module-provided sessionManagement, and init-time additionally
// applies the registry fallback for externals overriding a built-in type.
// ---------------------------------------------------------------------------
/** Cached sync wrapper — the store is a simple JSON file read, safe to call frequently. */
function getDisabledAdapterTypesFromStore(): string[] {
return getDisabledAdapterTypes();
}
/**
* Merge an external adapter module with host-provided session management.
*
* Module-provided `sessionManagement` takes precedence. When absent, fall
* back to the hardcoded registry keyed by adapter type (so externals that
* override a built-in — same `type` — inherit the builtin's policy). If
* neither is available, `sessionManagement` remains `undefined`.
*
* Used by both the init-time IIFE below (external-adapter load pass on
* server start) and the hot-install path in `routes/adapters.ts`
* (`registerWithSessionManagement`), so the two load paths resolve
* `sessionManagement` identically.
*/
export function resolveExternalAdapterRegistration(
externalAdapter: ServerAdapterModule,
): ServerAdapterModule {
return {
...externalAdapter,
sessionManagement:
externalAdapter.sessionManagement
?? getAdapterSessionManagement(externalAdapter.type)
?? undefined,
};
}
/**
* 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) {
const overriding = BUILTIN_ADAPTER_TYPES.has(externalAdapter.type);
if (overriding) {
console.log(
`[paperclip] External adapter "${externalAdapter.type}" overrides built-in adapter`,
);
// Save the original builtin for later restoration.
const existing = adaptersByType.get(externalAdapter.type);
if (existing && !builtinFallbacks.has(externalAdapter.type)) {
builtinFallbacks.set(externalAdapter.type, existing);
}
}
adaptersByType.set(
externalAdapter.type,
resolveExternalAdapterRegistration(externalAdapter),
);
}
} 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 {
if (BUILTIN_ADAPTER_TYPES.has(adapter.type) && !builtinFallbacks.has(adapter.type)) {
const existing = adaptersByType.get(adapter.type);
if (existing) {
builtinFallbacks.set(adapter.type, existing);
}
}
adaptersByType.set(adapter.type, adapter);
}
export function unregisterServerAdapter(type: string): void {
if (type === processAdapter.type || type === httpAdapter.type) return;
if (builtinFallbacks.has(type)) {
pausedOverrides.delete(type);
const fallback = builtinFallbacks.get(type);
if (fallback) {
adaptersByType.set(type, fallback);
}
return;
}
if (BUILTIN_ADAPTER_TYPES.has(type)) {
return;
}
adaptersByType.delete(type);
}
export function requireServerAdapter(type: string): ServerAdapterModule {
const adapter = findActiveServerAdapter(type);
if (!adapter) {
throw new Error(`Unknown adapter type: ${type}`);
}
return adapter;
}
export function getServerAdapter(type: string): ServerAdapterModule {
return findActiveServerAdapter(type) ?? processAdapter;
}
/**
* Memoized view of PAPERCLIP_ADAPTER_MODELS, keyed by the raw env string so
* tests (and live env mutation) that change the variable are still observed.
* Parsing happens at most once per distinct raw value instead of per
* `listAdapterModels` request, and malformed values fail SOFT here: we log the
* parse error once (per distinct raw value) and fall back to adapter-discovered
* models rather than throwing at request time.
*/
let adapterModelsEnvCache: {
raw: string | undefined;
value: ReturnType<typeof parseAdapterModelsEnv>;
} | null = null;
function getDeclaredAdapterModels(): ReturnType<typeof parseAdapterModelsEnv> {
const raw = process.env.PAPERCLIP_ADAPTER_MODELS;
if (adapterModelsEnvCache && adapterModelsEnvCache.raw === raw) {
return adapterModelsEnvCache.value;
}
let value: ReturnType<typeof parseAdapterModelsEnv> = null;
try {
value = parseAdapterModelsEnv(process.env);
} catch (err) {
console.error(
"[paperclip] Invalid PAPERCLIP_ADAPTER_MODELS; ignoring declared model lists:",
err,
);
}
adapterModelsEnvCache = { raw, value };
return value;
}
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
const declaredModels = getDeclaredAdapterModels();
if (declaredModels && declaredModels[type]?.length) {
return declaredModels[type].map((m) => ({ id: m.id, label: m.label ?? m.id }));
}
const adapter = findActiveServerAdapter(type);
if (!adapter) return [];
if (adapter.listModels) {
const discovered = await adapter.listModels();
if (discovered.length > 0) return discovered;
}
return adapter.models ?? [];
}
export async function refreshAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
const adapter = findActiveServerAdapter(type);
if (!adapter) return [];
if (adapter.refreshModels) {
const refreshed = await adapter.refreshModels();
if (refreshed.length > 0) return refreshed;
}
if (adapter.listModels) {
const discovered = await adapter.listModels();
if (discovered.length > 0) return discovered;
}
return adapter.models ?? [];
}
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; candidates?: string[] } | null> {
const adapter = findActiveServerAdapter(type);
if (!adapter?.detectModel) return null;
const detected = await adapter.detectModel();
if (!detected) return null;
return {
model: detected.model,
provider: detected.provider,
source: detected.source,
...(detected.candidates?.length ? { candidates: detected.candidates } : {}),
};
}
// ---------------------------------------------------------------------------
// Override pause / resume
// ---------------------------------------------------------------------------
/**
* Pause or resume an external override for a builtin adapter type.
*
* - `paused = true` → subsequent calls to `getServerAdapter(type)` return
* the builtin fallback instead of the external adapter. Already-running
* agent sessions are unaffected (they hold a reference to the module they
* started with).
*
* - `paused = false` → the external adapter is active again.
*
* Returns `true` if the state actually changed, `false` if the type is not
* an override or was already in the requested state.
*/
export function setOverridePaused(type: string, paused: boolean): boolean {
if (!builtinFallbacks.has(type)) return false;
const wasPaused = pausedOverrides.has(type);
if (paused && !wasPaused) {
pausedOverrides.add(type);
console.log(`[paperclip] Override paused for "${type}" — builtin adapter restored`);
return true;
}
if (!paused && wasPaused) {
pausedOverrides.delete(type);
console.log(`[paperclip] Override resumed for "${type}" — external adapter active`);
return true;
}
return false;
}
/** Check whether the external override for a builtin type is currently paused. */
export function isOverridePaused(type: string): boolean {
return pausedOverrides.has(type);
}
/** Get the set of types whose overrides are currently paused. */
export function getPausedOverrides(): Set<string> {
return pausedOverrides;
}
export function findServerAdapter(type: string): ServerAdapterModule | null {
return adaptersByType.get(type) ?? null;
}
export function findActiveServerAdapter(type: string): ServerAdapterModule | null {
if (pausedOverrides.has(type)) {
const fallback = builtinFallbacks.get(type);
if (fallback) return fallback;
}
return adaptersByType.get(type) ?? null;
}