Files
paperclip/ui/src/adapters/registry.ts
T
Dotta 4272c1604d Add ACPX local adapter runtime (#4893)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through a control plane
that can start, supervise, and recover agent runs.
> - Local adapters are the bridge between Paperclip issues and concrete
agent runtimes such as Claude, Codex, and other ACP-compatible tools.
> - The roadmap calls out broader “bring your own agent” and claw-style
agent support, and ACPX gives Paperclip one path to normalize multiple
ACP agents behind a single adapter.
> - The branch needed to become one reviewable PR against current
`paperclipai/paperclip:master`, without carrying stale base conflicts or
generated lockfile churn.
> - This pull request adds an experimental built-in `acpx_local`
adapter, integrates it through the server/CLI/UI adapter surfaces, and
adds regression coverage for runtime execution, skill sync, stream
parsing, diagnostics, and log redaction.
> - The benefit is that Paperclip can run Claude/Codex/custom ACP agents
through ACPX while keeping operator configuration, skills, logging, and
transcript rendering inside the existing adapter model.

## What Changed

- Added `@paperclipai/adapter-acpx-local` with server execution, config
schema, ACPX session handling, CLI formatting, UI config helpers, and
stdout parsing.
- Registered `acpx_local` across CLI, server, shared constants, UI
adapter metadata, adapter capabilities, and agent creation/editing
surfaces.
- Added ACPX runtime execution support with persistent sessions,
local-agent JWT environment handling, skill snapshots, runtime skill
materialization, and isolation/security regressions.
- Added ACPX adapter diagnostics and marked the adapter experimental in
the UI.
- Added command/env secret redaction for resolved command metadata in
adapter-utils, server event storage, and the Agent Detail invocation UI.
- Added Storybook coverage for ACPX config, transcript rendering, and
skill states, plus PR screenshots under `docs/pr-screenshots/pap-2944/`.
- Rebased the branch onto current `public-gh/master`; `pnpm-lock.yaml`
is intentionally not included and there are no migration/schema changes.

## Verification

- `pnpm exec vitest run
packages/adapters/acpx-local/src/server/execute.test.ts
packages/adapters/acpx-local/src/server/test.test.ts
packages/adapters/acpx-local/src/cli/format-event.test.ts
packages/adapters/acpx-local/src/ui/parse-stdout.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/redaction.test.ts
server/src/__tests__/acpx-local-execute.test.ts
server/src/__tests__/acpx-local-skill-sync.test.ts
server/src/__tests__/acpx-local-adapter-environment.test.ts
server/src/__tests__/adapter-routes.test.ts
server/src/__tests__/agent-skills-routes.test.ts
ui/src/adapters/metadata.test.ts` — 12 files, 87 tests passed.
- `pnpm --filter @paperclipai/adapter-acpx-local typecheck` — passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- Confirmed PR diff does not include `pnpm-lock.yaml`, database schema
files, or migrations.

Screenshots:

![ACPX Claude skills
light](https://github.com/cryppadotta/paperclip-1/blob/PAP-2944-acpx-make-a-claude_local-adapter-that-uses-acpx-instead/docs/pr-screenshots/pap-2944/skills-claude-light.png?raw=true)
![ACPX Claude skills
dark](https://github.com/cryppadotta/paperclip-1/blob/PAP-2944-acpx-make-a-claude_local-adapter-that-uses-acpx-instead/docs/pr-screenshots/pap-2944/skills-claude-dark.png?raw=true)
![ACPX custom skills
light](https://github.com/cryppadotta/paperclip-1/blob/PAP-2944-acpx-make-a-claude_local-adapter-that-uses-acpx-instead/docs/pr-screenshots/pap-2944/skills-custom-light.png?raw=true)

## Risks

- Medium risk: this introduces a new built-in adapter package and
touches runtime execution, adapter registration, agent config, skills,
and transcript rendering.
- ACPX and ACP agent behavior can vary by installed tool versions; the
adapter is marked experimental to set operator expectations.
- `pnpm-lock.yaml` is excluded per repository PR policy, so dependency
lock refresh must be handled by the repo’s automation or maintainers.
- No database migration risk: no schema or migration files changed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with repository tool use,
shell execution, git operations, and local verification. Exact hosted
context window was not exposed in this environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 19:57:05 -05:00

265 lines
9.7 KiB
TypeScript

import type { UIAdapterModule } from "./types";
import { acpxLocalUIAdapter } from "./acpx-local";
import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor";
import { geminiLocalUIAdapter } from "./gemini-local";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
import { hermesLocalUIAdapter } from "./hermes-local";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
import { loadDynamicParser, invalidateDynamicParser, setDynamicParserResultNotifier } from "./dynamic-loader";
import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields";
const uiAdapters: UIAdapterModule[] = [];
const adaptersByType = new Map<string, UIAdapterModule>();
// Types registered at module load time — allowed to be overridden by
// external adapters that ship their own ui-parser.js via the server.
const builtinTypes = new Set<string>();
// Original builtin adapters stored for restoration when external overrides
// are deactivated or removed.
const builtinAdaptersByType = new Map<string, UIAdapterModule>();
// Tracks which builtin types currently have an active external override.
const activeExternalOverrides = new Set<string>();
// Generation counter to discard stale dynamic parser loads. When an override
// is deactivated while a load is in-flight, the generation is bumped and the
// stale result is discarded in its .then() handler.
const overrideGeneration = new Map<string, number>();
// Subscriber list — components can register to be notified when adapters change
// (e.g., when a dynamic parser replaces a placeholder).
const adapterChangeListeners = new Set<() => void>();
/** Subscribe to adapter registry changes. Returns unsubscribe function. */
export function onAdapterChange(fn: () => void): () => void {
adapterChangeListeners.add(fn);
return () => adapterChangeListeners.delete(fn);
}
function notifyAdapterChange(): void {
for (const fn of adapterChangeListeners) fn();
}
setDynamicParserResultNotifier(notifyAdapterChange);
function registerBuiltInUIAdapters() {
for (const adapter of [
acpxLocalUIAdapter,
claudeLocalUIAdapter,
codexLocalUIAdapter,
geminiLocalUIAdapter,
hermesLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
openClawGatewayUIAdapter,
processUIAdapter,
httpUIAdapter,
]) {
builtinTypes.add(adapter.type);
builtinAdaptersByType.set(adapter.type, adapter);
registerUIAdapter(adapter);
}
}
export function registerUIAdapter(adapter: UIAdapterModule): void {
const existingIndex = uiAdapters.findIndex((entry) => entry.type === adapter.type);
if (existingIndex >= 0) {
uiAdapters.splice(existingIndex, 1, adapter);
} else {
uiAdapters.push(adapter);
}
adaptersByType.set(adapter.type, adapter);
notifyAdapterChange();
}
export function unregisterUIAdapter(type: string): void {
if (type === processUIAdapter.type || type === httpUIAdapter.type) return;
const existingIndex = uiAdapters.findIndex((entry) => entry.type === type);
if (existingIndex >= 0) {
uiAdapters.splice(existingIndex, 1);
}
adaptersByType.delete(type);
}
export function findUIAdapter(type: string): UIAdapterModule | null {
return adaptersByType.get(type) ?? null;
}
registerBuiltInUIAdapters();
export function getUIAdapter(type: string): UIAdapterModule {
const builtIn = adaptersByType.get(type);
if (!builtIn) {
let loadStarted = false;
return {
type,
label: type,
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(type).then((parserModule) => {
if (parserModule) {
registerUIAdapter({
type,
label: type,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
});
}
});
}
return processUIAdapter.parseStdoutLine(line, ts);
},
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
};
}
return builtIn;
}
/**
* Keep the UI adapter registry in sync with the server's adapter list.
*
* Two concerns:
*
* 1. **Builtin overrides** — when an external adapter ships a ui-parser.js for a
* builtin type, the external parser takes priority. When the external is
* disabled or removed the original builtin parser is restored transparently.
* A generation counter guards against stale loads that resolve after the
* override has been torn down.
*
* 2. **Non-builtin externals** — register a bridge adapter that lazily loads the
* dynamic parser on first stdout line, falling back to the generic process
* adapter. Once the parser resolves the bridge is replaced.
*/
export function syncExternalAdapters(
serverAdapters: {
type: string;
label: string;
disabled?: boolean;
/** When true, the external override for a builtin type is client-side paused. */
overrideDisabled?: boolean;
}[],
): void {
const enabledExternalTypes = new Set(
serverAdapters.filter((a) => !a.disabled && !a.overrideDisabled).map((a) => a.type),
);
const allExternalTypes = new Set(
serverAdapters.map((a) => a.type),
);
// ── Builtin override lifecycle ──────────────────────────────────────────
for (const builtinType of builtinTypes) {
const originalBuiltin = builtinAdaptersByType.get(builtinType);
if (!originalBuiltin) continue;
const hasExternal = allExternalTypes.has(builtinType);
const externalEnabled = enabledExternalTypes.has(builtinType);
const wasOverridden = activeExternalOverrides.has(builtinType);
if (hasExternal && externalEnabled && !wasOverridden) {
// Activate: external just became active → replace builtin with bridge.
activeExternalOverrides.add(builtinType);
const gen = (overrideGeneration.get(builtinType) ?? 0) + 1;
overrideGeneration.set(builtinType, gen);
let loadStarted = false;
const fallbackParser = originalBuiltin.parseStdoutLine;
const externalEntry = serverAdapters.find((a) => a.type === builtinType);
const label = externalEntry?.label ?? builtinType;
registerUIAdapter({
type: builtinType,
label,
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(builtinType).then((parserModule) => {
// Discard if the override was torn down while the load was in-flight.
if (parserModule && overrideGeneration.get(builtinType) === gen) {
registerUIAdapter({
type: builtinType,
label,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: originalBuiltin.ConfigFields,
buildAdapterConfig: originalBuiltin.buildAdapterConfig,
});
}
});
}
return fallbackParser(line, ts);
},
ConfigFields: originalBuiltin.ConfigFields,
buildAdapterConfig: originalBuiltin.buildAdapterConfig,
});
} else if ((!hasExternal || !externalEnabled) && wasOverridden) {
// Deactivate: external disabled or removed → restore builtin.
activeExternalOverrides.delete(builtinType);
overrideGeneration.delete(builtinType);
invalidateDynamicParser(builtinType);
registerUIAdapter(originalBuiltin);
}
}
// ── Non-builtin externals ───────────────────────────────────────────────
for (const { type, label } of serverAdapters) {
if (builtinTypes.has(type)) continue; // handled above
const existing = adaptersByType.get(type);
// If this type already has an externally-loaded dynamic parser, skip —
// it was loaded from disk on a previous sync. Only re-trigger loading
// when the server returns a new external adapter that hasn't been loaded yet.
if (existing && existing !== processUIAdapter) continue;
let loadStarted = false;
// Use the existing built-in parser as fallback (if any) so we don't
// regress to the generic process parser while the dynamic one loads.
const fallbackParser = existing?.parseStdoutLine ?? processUIAdapter.parseStdoutLine;
registerUIAdapter({
type,
label,
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(type).then((parserModule) => {
if (parserModule) {
registerUIAdapter({
type,
label,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
});
}
});
}
return fallbackParser(line, ts);
},
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
});
}
}
export function listUIAdapters(): UIAdapterModule[] {
return [...uiAdapters];
}