diff --git a/adapter-plugin.md b/adapter-plugin.md new file mode 100644 index 0000000000..13994ba3b3 --- /dev/null +++ b/adapter-plugin.md @@ -0,0 +1,143 @@ +- Created branch: feat/external-adapter-phase1 + + I started phase 1 in the most merge-friendly way I could: small central changes, reusing existing registry patterns instead of inventing a whole new plugin system up front. + + What I changed + + 1. Server adapter registry is now mutable + Files: + - server/src/adapters/registry.ts + - server/src/adapters/index.ts + + Added: + - registerServerAdapter(adapter) + - unregisterServerAdapter(type) + - requireServerAdapter(type) + + Kept the existing built-in registry shape, but changed initialization so built-ins are registered into a mutable map on startup. + + Why this is merge-friendly: + - existing built-in adapter definitions stay where they already are + - existing lookup helpers still exist + - no big architectural rewrite yet + + 1. Runtime adapter validation moved to server routes + File: + - server/src/routes/agents.ts + + Added: + - assertKnownAdapterType(...) + + Used it in: + - /companies/:companyId/adapters/:type/models + - /companies/:companyId/adapters/:type/detect-model + - /companies/:companyId/adapters/:type/test-environment + - POST /companies/:companyId/agents + - POST /companies/:companyId/agent-hires + - PATCH /agents/:id when adapterType is touched + + Why: + - shared schemas can now allow external adapter strings + - server becomes the real source of truth for β€œis this adapter actually registered?” + + 1. Shared adapterType validation is now open-ended for inputs + Files: + - packages/shared/src/adapter-type.ts + - packages/shared/src/validators/agent.ts + - packages/shared/src/validators/access.ts + - packages/shared/src/index.ts + + Changed input validation from hardcoded z.enum(AGENT_ADAPTER_TYPES) to: + - agentAdapterTypeSchema + - optionalAgentAdapterTypeSchema + + These accept any non-empty string. + + Important: + - I did not remove AGENT_ADAPTER_TYPES from constants + - I did not change the built-in type unions yet + - this keeps the change minimal and limits blast radius + + So: + - input payloads can carry external adapter types + - actual acceptance still depends on server registry validation + + 1. UI adapter registry is now mutable too + Files: + - ui/src/adapters/registry.ts + - ui/src/adapters/index.ts + + Added: + - registerUIAdapter(adapter) + - unregisterUIAdapter(type) + - findUIAdapter(type) + + Built-ins are still registered from the same file, same pattern as server. + + 1. Began switching UI adapter lists toward the registry + Files: + - ui/src/adapters/metadata.ts + - ui/src/components/AgentConfigForm.tsx + - ui/src/pages/NewAgent.tsx + + Changed: + - AgentConfigForm adapter list now comes from registered UI adapters via metadata helper + - NewAgent supported adapter set now derives from listUIAdapters() + + Why: + - phase 1 should start reducing hardcoded UI lists + - but without rewriting every page at once + + 1. Tests added first, then implementation + New tests: + - server/src/__tests__/adapter-registry.test.ts + - server/src/__tests__/agent-adapter-validation-routes.test.ts + - ui/src/adapters/registry.test.ts + - packages/shared/src/adapter-types.test.ts + + Small test mock fix: + - server/src/__tests__/agent-skills-routes.test.ts + + Current status + + The branch is created and the phase-1 code is started. + + I also followed TDD in spirit: + - wrote failing tests first for missing register/unregister APIs + - then implemented the registry changes + + What is not finished yet + + I have not finished the green/stabilization pass yet. + + I still need to: + - run the targeted tests again after the implementation changes + - fix any TS / mocking fallout + - decide how far to take phase-1 UI wiring in this branch before stopping + + Also, I intentionally did not yet touch the bigger hardcoded surfaces like: + - NewAgentDialog + - OnboardingWizard + - InviteLanding + - duplicated adapterLabels maps + - plugin manifest / plugin loader adapter contributions + + That is deliberate. + For merge safety, I started with the smallest useful seam: + - dynamic server registry + - dynamic UI registry + - runtime validation based on registry + - first couple of UI consumers + + One note + There was already an unrelated dirty change in: + - server/scripts/dev-watch.ts + + I left that alone and did not make phase-1 changes there. + + Recommended next move + I should keep going on this same branch and do the stabilization pass: + 1. rerun server/ui/shared targeted tests + 2. fix failures + 3. run typechecks + 4. then extend the same registry-driven approach to the next safest UI surfaces diff --git a/docs/adapters/adapter-ui-parser.md b/docs/adapters/adapter-ui-parser.md new file mode 100644 index 0000000000..a9390001f6 --- /dev/null +++ b/docs/adapters/adapter-ui-parser.md @@ -0,0 +1,287 @@ +--- +title: Adapter UI Parser Contract +summary: Ship a custom run-log parser so the Paperclip UI renders your adapter's output correctly +--- + +When Paperclip runs an agent, stdout is streamed to the UI in real time. The UI needs a **parser** to convert raw stdout lines into structured transcript entries (tool calls, tool results, assistant messages, system events). Without a custom parser, the UI falls back to a generic shell parser that treats every non-system line as `assistant` output β€” tool commands leak as plain text, durations are lost, and errors are invisible. + +## The Problem + +Most agent CLIs emit structured stdout with tool calls, progress indicators, and multi-line output. For example: + +``` +[hermes] Session resumed: abc123 +β”Š πŸ’¬ Thinking about how to approach this... +β”Š $ ls /home/user/project +β”Š [done] $ ls /home/user/project β€” /src /README.md 0.3s +β”Š πŸ’¬ I see the project structure. Let me read the README. +β”Š read /home/user/project/README.md +β”Š [done] read β€” Project Overview: A CLI tool for... 1.2s +The project is a CLI tool. Here's what I found: +- It uses TypeScript +- Tests are in /tests +``` + +Without a parser, the UI shows all of this as raw `assistant` text β€” the tool calls and results are indistinguishable from the agent's actual response. + +With a parser, the UI renders: + +- `Thinking about how to approach this...` as a collapsible thinking block +- `$ ls /home/user/project` as a tool call card (collapsed) +- `0.3s` duration as a tool result card +- `The project is a CLI tool...` as the assistant's response + +## How It Works + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” package.json β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Adapter Package │─── exports["./ui-parser"] ──→│ dist/ui-parser.js β”‚ +β”‚ (npm / local) β”‚ β”‚ (zero imports) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ plugin-loader reads at startup + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” GET /api/:type/ui-parser.js β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Paperclip Server │◄────────────────────────────────│ uiParserCache β”‚ +β”‚ (in-memory) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ serves JS to browser + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” fetch() + eval β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Paperclip UI │─────────────────────→│ parseStdoutLine β”‚ +β”‚ (dynamic loader) β”‚ registers parser β”‚ (per-adapter) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +1. **Build time** β€” You compile `src/ui-parser.ts` to `dist/ui-parser.js` (zero runtime imports) +2. **Server startup** β€” Plugin loader reads the file and caches it in memory +3. **UI load** β€” When the user opens a run, the UI fetches the parser from `GET /api/:type/ui-parser.js` +4. **Runtime** β€” The fetched module is eval'd and registered. All subsequent lines use the real parser + +## Contract: package.json + +### 1. `paperclip.adapterUiParser` β€” contract version + +```json +{ + "paperclip": { + "adapterUiParser": "1.0.0" + } +} +``` + +The Paperclip host checks this field. If the major version is unsupported, the host logs a warning and falls back to the generic parser instead of executing potentially incompatible code. + +| Host expects | Adapter declares | Result | +|---|---|---| +| `1.x` | `1.0.0` | Parser loaded | +| `1.x` | `2.0.0` | Warning logged, generic parser used | +| `1.x` | (missing) | Parser loaded (grace period β€” future versions may require it) | + +### 2. `exports["./ui-parser"]` β€” file path + +```json +{ + "exports": { + ".": "./dist/server/index.js", + "./ui-parser": "./dist/ui-parser.js" + } +} +``` + +## Contract: Module Exports + +Your `dist/ui-parser.js` must export **at least one** of: + +### `parseStdoutLine(line: string, ts: string): TranscriptEntry[]` + +Static parser. Called for each line of adapter stdout. + +```ts +export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] { + if (line.startsWith("[my-agent]")) { + return [{ kind: "system", ts, text: line }]; + } + return [{ kind: "assistant", ts, text: line }]; +} +``` + +### `createStdoutParser(): { parseLine(line, ts): TranscriptEntry[]; reset(): void }` + +Stateful parser factory. Preferred if your parser needs to track multi-line continuation, command nesting, or other cross-call state. + +```ts +let counter = 0; + +export function createStdoutParser() { + let suppressContinuation = false; + + function parseLine(line: string, ts: string): TranscriptEntry[] { + const trimmed = line.trim(); + if (!trimmed) return []; + + if (suppressContinuation) { + if (/^[\d.]+s$/.test(trimmed)) { + suppressContinuation = false; + return []; + } + return []; // swallow continuation lines + } + + if (trimmed.startsWith("[tool-done]")) { + const id = `tool-${++counter}`; + suppressContinuation = true; + return [ + { kind: "tool_call", ts, name: "shell", input: {}, toolUseId: id }, + { kind: "tool_result", ts, toolUseId: id, content: trimmed, isError: false }, + ]; + } + + return [{ kind: "assistant", ts, text: trimmed }]; + } + + function reset() { + suppressContinuation = false; + } + + return { parseLine, reset }; +} +``` + +If both are exported, `createStdoutParser` takes priority. + +## Contract: TranscriptEntry + +Each entry must match one of these discriminated union shapes: + +```ts +// Assistant message +{ kind: "assistant"; ts: string; text: string; delta?: boolean } + +// Thinking / reasoning +{ kind: "thinking"; ts: string; text: string; delta?: boolean } + +// User message (rare β€” usually from agent-initiated prompts) +{ kind: "user"; ts: string; text: string } + +// Tool invocation +{ kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string } + +// Tool result +{ kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } + +// System / adapter messages +{ kind: "system"; ts: string; text: string } + +// Stderr / errors +{ kind: "stderr"; ts: string; text: string } + +// Raw stdout (fallback) +{ kind: "stdout"; ts: string; text: string } +``` + +### Linking tool calls to results + +Use `toolUseId` to pair `tool_call` and `tool_result` entries. The UI renders them as collapsible cards. + +```ts +const id = `my-tool-${++counter}`; +return [ + { kind: "tool_call", ts, name: "read", input: { path: "/src/main.ts" }, toolUseId: id }, + { kind: "tool_result", ts, toolUseId: id, content: "const main = () => {...}", isError: false }, +]; +``` + +### Error handling + +Set `isError: true` on tool results to show a red indicator: + +```ts +{ kind: "tool_result", ts, toolUseId: id, content: "ENOENT: no such file", isError: true } +``` + +## Constraints + +1. **Zero runtime imports.** Your file is loaded via `URL.createObjectURL` + dynamic `import()` in the browser. No `import`, no `require`, no top-level `await`. + +2. **No DOM / Node.js APIs.** Runs in a browser sandbox. Use only vanilla JS (ES2020+). + +3. **No side effects.** Module-level code must not modify globals, access `window`, or perform I/O. Only declare and export functions. + +4. **Deterministic.** Given the same `(line, ts)` input, the same output must be produced. This matters for log replay. + +5. **Error-tolerant.** Never throw. Return `[{ kind: "stdout", ts, text: line }]` for any line you can't parse, rather than crashing the transcript. + +6. **File size.** Keep under 50 KB. This is served per-request and eval'd in the browser. + +## Lifecycle + +| Event | What happens | +|---|---| +| Server starts | Plugin loader reads `exports["./ui-parser"]`, reads the file, caches in memory | +| UI opens run | `getUIAdapter(type)` called. If no built-in parser, kicks off async `fetch(/api/:type/ui-parser.js)` | +| First lines arrive | Generic process parser handles them immediately (no blocking). Dynamic parser loads in background | +| Parser loads | `registerUIAdapter()` called. All subsequent line parsing uses the real parser | +| Parser fails (404, eval error) | Warning logged to console. Generic parser continues. Failed type is cached β€” no retries | +| Server restart | In-memory cache is repopulated from adapter packages | + +## Error Behavior + +| Failure | What happens | +|---|---| +| Module syntax error (import fails) | Caught, logged, falls back to generic parser. No retries. | +| Returns wrong shape | Individual entries with missing fields are silently ignored by the transcript builder. | +| Throws at runtime | Caught per-line. That line falls back to generic. Parser stays registered for future lines. | +| 404 (no ui-parser export) | Type added to failed-loads set. Generic parser from first call onward. | +| Contract version mismatch | Server logs warning, skips loading. Generic parser used. | + +## Building + +```sh +# Compile TypeScript to JavaScript +tsc src/ui-parser.ts --outDir dist --target ES2020 --module ES2020 --declaration false +``` + +Your `tsconfig.json` can handle this automatically β€” just make sure `ui-parser.ts` is included in the build and outputs to `dist/ui-parser.js`. + +## Testing + +Test your parser locally by running it against sample stdout: + +```ts +// test-parser.ts +import { createStdoutParser } from "./dist/ui-parser.js"; + +const parser = createStdoutParser(); +const sampleLines = [ + "[my-agent] Starting session abc123", + "Thinking about the task...", + "$ ls /home/user/project", + "[done] $ ls β€” /src /README.md 0.3s", + "I'll read the README now.", + "Error: file not found", +]; + +for (const line of sampleLines) { + const entries = parser.parseLine(line, new Date().toISOString()); + for (const entry of entries) { + console.log(` ${entry.kind}:`, entry.text ?? entry.name ?? entry.content); + } +} +``` + +Run with: `npx tsx test-parser.ts` + +## Skipping the UI Parser + +If your adapter's stdout is simple (no tool markers, no special formatting), you can skip the UI parser entirely. The generic `process` parser will handle it β€” every non-system line becomes `assistant` output. This is fine for: + +- Agents that output plain text responses +- Custom scripts that just print results +- Simple CLIs without structured output + +To skip it, simply don't include `exports["./ui-parser"]` in your `package.json`. + +## Next Steps + +- [External Adapters](/adapters/external-adapters) β€” full guide to building adapter packages +- [Creating an Adapter](/adapters/creating-an-adapter) β€” adapter internals and built-in integration diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index d3a0b68b6e..fc64fcf8d8 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -20,8 +20,8 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | | `graceSec` | number | No | Grace period before force-kill | -| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `1000`) | -| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) | +| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | +| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible | ## Prompt Templates diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index fae0e4b33b..ae5e4ccbe5 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -9,23 +9,40 @@ Build a custom adapter to connect Paperclip to any agent runtime. If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step. +## Two Paths + +| | Built-in | External Plugin | +|---|---|---| +| Source | Inside `paperclip-fork` | Separate npm package | +| Distribution | Ships with Paperclip | Independent npm publish | +| UI parser | Static import | Dynamic load from API | +| Registration | Edit 3 registries | Auto-loaded at startup | +| Best for | Core adapters, contributors | Third-party adapters, internal tools | + +For most cases, **build an external adapter plugin**. It's cleaner, independently versioned, and doesn't require modifying Paperclip's source. See [External Adapters](/adapters/external-adapters) for the full guide. + +The rest of this page covers the shared internals that both paths use. + ## Package Structure ``` -packages/adapters// +packages/adapters// # built-in + ── or ── +my-adapter/ # external plugin package.json tsconfig.json src/ index.ts # Shared metadata server/ - index.ts # Server exports + index.ts # Server exports (createServerAdapter) execute.ts # Core execution logic parse.ts # Output parsing test.ts # Environment diagnostics ui/ - index.ts # UI exports - parse-stdout.ts # Transcript parser + index.ts # UI exports (built-in only) + parse-stdout.ts # Transcript parser (built-in only) build-config.ts # Config builder + ui-parser.ts # Self-contained UI parser (external β€” see [UI Parser Contract](/adapters/adapter-ui-parser)) cli/ index.ts # CLI exports format-event.ts # Terminal formatter @@ -46,6 +63,9 @@ Use when: ... Don't use when: ... Core fields: ... `; + +// Required for external adapters (plugin-loader convention) +export { createServerAdapter } from "./server/index.js"; ``` ## Step 2: Server Execute @@ -54,7 +74,7 @@ Core fields: ... Key responsibilities: -1. Read config using safe helpers (`asString`, `asNumber`, etc.) +1. Read config using safe helpers (`asString`, `asNumber`, etc.) from `@paperclipai/adapter-utils/server-utils` 2. Build environment with `buildPaperclipEnv(agent)` plus context vars 3. Resolve session state from `runtime.sessionParams` 4. Render prompt with `renderTemplate(template, data)` @@ -62,27 +82,102 @@ Key responsibilities: 6. Parse output for usage, costs, session state, errors 7. Handle unknown session errors (retry fresh, set `clearSession: true`) +### Available Helpers + +| Helper | Source | Purpose | +|--------|--------|---------| +| `runChildProcess(cmd, opts)` | `@paperclipai/adapter-utils/server-utils` | Spawn with timeout, grace, streaming | +| `buildPaperclipEnv(agent)` | `@paperclipai/adapter-utils/server-utils` | Inject `PAPERCLIP_*` env vars | +| `renderTemplate(tpl, data)` | `@paperclipai/adapter-utils/server-utils` | `{{variable}}` substitution | +| `asString(v)` | `@paperclipai/adapter-utils` | Safe config value extraction | +| `asNumber(v)` | `@paperclipai/adapter-utils` | Safe number extraction | + +### AdapterExecutionContext + +```ts +interface AdapterExecutionContext { + runId: string; + agent: { id: string; companyId: string; name: string; adapterConfig: unknown }; + runtime: { sessionId: string | null; sessionParams: Record | null }; + config: Record; // agent's adapterConfig + context: Record; // task, wake reason, etc. + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; + onMeta?: (meta: AdapterInvocationMeta) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; +} +``` + +### AdapterExecutionResult + +```ts +interface AdapterExecutionResult { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + errorMessage?: string | null; + usage?: { inputTokens: number; outputTokens: number }; + sessionParams?: Record | null; // persist across heartbeats + sessionDisplayId?: string | null; + provider?: string | null; + model?: string | null; + costUsd?: number | null; + clearSession?: boolean; // set true to force fresh session on next wake +} +``` + ## Step 3: Environment Test `src/server/test.ts` validates the adapter config before running. Return structured diagnostics: -- `error` for invalid/unusable setup -- `warn` for non-blocking issues -- `info` for successful checks +| Level | Meaning | Effect | +|-------|---------|--------| +| `error` | Invalid or unusable setup | Blocks execution | +| `warn` | Non-blocking issue | Shown with yellow indicator | +| `info` | Successful check | Shown in test results | -## Step 4: UI Module +```ts +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + return { + adapterType: ctx.adapterType, + status: "pass", // "pass" | "warn" | "fail" + checks: [ + { level: "info", message: "CLI v1.2.0 detected", code: "cli_detected" }, + { level: "warn", message: "No API key found", hint: "Set ANTHROPIC_API_KEY", code: "no_key" }, + ], + testedAt: new Date().toISOString(), + }; +} +``` + +## Step 4: UI Module (Built-in Only) + +For built-in adapters registered in Paperclip's source: - `parse-stdout.ts` β€” converts stdout lines to `TranscriptEntry[]` for the run viewer - `build-config.ts` β€” converts form values to `adapterConfig` JSON - Config fields React component in `ui/src/adapters//config-fields.tsx` +For external adapters, use a self-contained `ui-parser.ts` instead. See the [UI Parser Contract](/adapters/adapter-ui-parser). + ## Step 5: CLI Module `format-event.ts` β€” pretty-prints stdout for `paperclipai run --watch` using `picocolors`. -## Step 6: Register +```ts +export function formatStdoutEvent(line: string, debug: boolean): void { + if (line.startsWith("[tool-done]")) { + console.log(chalk.green(` βœ“ ${line}`)); + } else { + console.log(` ${line}`); + } +} +``` + +## Step 6: Register (Built-in Only) Add the adapter to all three registries: @@ -90,6 +185,24 @@ Add the adapter to all three registries: 2. `ui/src/adapters/registry.ts` 3. `cli/src/adapters/registry.ts` +For external adapters, registration is automatic β€” the plugin loader handles it. + +## Session Persistence + +If your agent runtime supports conversation continuity across heartbeats: + +1. Return `sessionParams` from `execute()` (e.g., `{ sessionId: "abc123" }`) +2. Read `runtime.sessionParams` on the next wake to resume +3. Optionally implement a `sessionCodec` for validation and display + +```ts +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw) { /* validate raw session data */ }, + serialize(params) { /* serialize for storage */ }, + getDisplayId(params) { /* human-readable session label */ }, +}; +``` + ## Skills Injection Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory: @@ -105,3 +218,10 @@ Make Paperclip skills discoverable to your agent runtime without writing to the - Inject secrets via environment variables, not prompts - Configure network access controls if the runtime supports them - Always enforce timeout and grace period +- The UI parser module runs in a browser sandbox β€” zero runtime imports, no side effects + +## Next Steps + +- [External Adapters](/adapters/external-adapters) β€” build a standalone adapter plugin +- [UI Parser Contract](/adapters/adapter-ui-parser) β€” ship a custom run-log parser +- [How Agents Work](/guides/agent-developer/how-agents-work) β€” the heartbeat lifecycle diff --git a/docs/adapters/external-adapters.md b/docs/adapters/external-adapters.md new file mode 100644 index 0000000000..3c814fc9ab --- /dev/null +++ b/docs/adapters/external-adapters.md @@ -0,0 +1,392 @@ +--- +title: External Adapters +summary: Build, package, and distribute adapters as plugins without modifying Paperclip source +--- + +Paperclip supports external adapter plugins that can be installed from npm packages or local directories. External adapters work exactly like built-in adapters β€” they execute agents, parse output, and render transcripts β€” but they live in their own package and don't require changes to Paperclip's source code. + +## Built-in vs External + +| | Built-in | External | +|---|---|---| +| Source location | Inside `paperclip-fork/packages/adapters/` | Separate npm package or local directory | +| Registration | Hardcoded in three registries | Loaded at startup via plugin system | +| UI parser | Static import at build time | Dynamically loaded from API (see [UI Parser](/adapters/adapter-ui-parser)) | +| Distribution | Ships with Paperclip | Published to npm or linked via `file:` | +| Updates | Requires Paperclip release | Independent versioning | + +## Quick Start + +### Minimal Package Structure + +``` +my-adapter/ + package.json + tsconfig.json + src/ + index.ts # Shared metadata (type, label, models) + server/ + index.ts # createServerAdapter() factory + execute.ts # Core execution logic + parse.ts # Output parsing + test.ts # Environment diagnostics + ui-parser.ts # Self-contained UI transcript parser +``` + +### package.json + +```json +{ + "name": "my-paperclip-adapter", + "version": "1.0.0", + "type": "module", + "license": "MIT", + "paperclip": { + "adapterUiParser": "1.0.0" + }, + "exports": { + ".": "./dist/index.js", + "./server": "./dist/server/index.js", + "./ui-parser": "./dist/ui-parser.js" + }, + "files": ["dist"], + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@paperclipai/adapter-utils": "^2026.325.0", + "picocolors": "^1.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } +} +``` + +Key fields: + +| Field | Purpose | +|-------|---------| +| `exports["."]` | Entry point β€” must export `createServerAdapter` | +| `exports["./ui-parser"]` | Self-contained UI parser module (optional but recommended) | +| `paperclip.adapterUiParser` | Contract version for the UI parser (`"1.0.0"`) | +| `files` | Limits what gets published β€” only `dist/` | + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} +``` + +## Server Module + +The plugin loader calls `createServerAdapter()` from your package root. This function must return a `ServerAdapterModule`. + +### src/index.ts + +```ts +export const type = "my_adapter"; // snake_case, globally unique +export const label = "My Agent (local)"; + +export const models = [ + { id: "model-a", label: "Model A" }, +]; + +export const agentConfigurationDoc = `# my_adapter configuration +Use when: ... +Don't use when: ... +`; + +// Required by plugin-loader convention +export { createServerAdapter } from "./server/index.js"; +``` + +### src/server/index.ts + +```ts +import type { ServerAdapterModule } from "@paperclipai/adapter-utils"; +import { type, models, agentConfigurationDoc } from "../index.js"; +import { execute } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +export function createServerAdapter(): ServerAdapterModule { + return { + type, + execute, + testEnvironment, + models, + agentConfigurationDoc, + }; +} +``` + +### src/server/execute.ts + +The core execution function. Receives an `AdapterExecutionContext` and returns an `AdapterExecutionResult`. + +```ts +import type { + AdapterExecutionContext, + AdapterExecutionResult, +} from "@paperclipai/adapter-utils"; + +import { + runChildProcess, + buildPaperclipEnv, + renderTemplate, +} from "@paperclipai/adapter-utils/server-utils"; + +export async function execute( + ctx: AdapterExecutionContext, +): Promise { + const { config, agent, runtime, context, onLog, onMeta } = ctx; + + // 1. Read config with safe helpers + const cwd = String(config.cwd ?? "/tmp"); + const command = String(config.command ?? "my-agent"); + const timeoutSec = Number(config.timeoutSec ?? 300); + + // 2. Build environment with Paperclip vars injected + const env = buildPaperclipEnv(agent); + + // 3. Render prompt template + const prompt = config.promptTemplate + ? renderTemplate(String(config.promptTemplate), { + agentId: agent.id, + agentName: agent.name, + companyId: agent.companyId, + runId: ctx.runId, + taskId: context.taskId ?? "", + taskTitle: context.taskTitle ?? "", + }) + : "Continue your work."; + + // 4. Spawn process + const result = await runChildProcess(command, { + args: [prompt], + cwd, + env, + timeout: timeoutSec * 1000, + graceMs: 10_000, + onStdout: (chunk) => onLog("stdout", chunk), + onStderr: (chunk) => onLog("stderr", chunk), + }); + + // 5. Return structured result + return { + exitCode: result.exitCode, + timedOut: result.timedOut, + // Include session state for persistence + sessionParams: { /* ... */ }, + }; +} +``` + +#### Available Helpers from `@paperclipai/adapter-utils` + +| Helper | Purpose | +|--------|---------| +| `runChildProcess(command, opts)` | Spawn a child process with timeout, grace period, and streaming callbacks | +| `buildPaperclipEnv(agent)` | Inject `PAPERCLIP_*` environment variables | +| `renderTemplate(template, data)` | `{{variable}}` substitution in prompt templates | +| `asString(v)`, `asNumber(v)`, `asBoolean(v)` | Safe config value extraction | + +### src/server/test.ts + +Validates the adapter configuration before running. Returns structured diagnostics. + +```ts +import type { + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks = []; + + // Example: check CLI is installed + checks.push({ + level: "info", + message: "My Agent CLI v1.2.0 detected", + code: "cli_detected", + }); + + // Example: check working directory + const cwd = String(ctx.config.cwd ?? ""); + if (!cwd.startsWith("/")) { + checks.push({ + level: "error", + message: `Working directory must be absolute: "${cwd}"`, + hint: "Use /home/user/project or /workspace", + code: "invalid_cwd", + }); + } + + return { + adapterType: ctx.adapterType, + status: checks.some(c => c.level === "error") ? "fail" : "pass", + checks, + testedAt: new Date().toISOString(), + }; +} +``` + +Check levels: + +| Level | Meaning | Effect | +|-------|---------|--------| +| `info` | Informational | Shown in test results | +| `warn` | Non-blocking issue | Shown with yellow indicator | +| `error` | Blocks execution | Prevents agent from running | + +## Installation + +### From npm + +```sh +# Via the Paperclip UI +# Settings β†’ Adapters β†’ Install from npm β†’ "my-paperclip-adapter" + +# Or via API +curl -X POST http://localhost:3102/api/adapters \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"packageName": "my-paperclip-adapter"}' +``` + +### From local directory + +```sh +curl -X POST http://localhost:3102/api/adapters \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"localPath": "/home/user/my-adapter"}' +``` + +Local adapters are symlinked into Paperclip's adapter directory. Changes to the source are picked up on server restart. + +### Via adapter-plugins.json + +For development, you can also edit `~/.paperclip/adapter-plugins.json` directly: + +```json +[ + { + "packageName": "my-paperclip-adapter", + "localPath": "/home/user/my-adapter", + "type": "my_adapter", + "installedAt": "2026-03-30T12:00:00.000Z" + } +] +``` + +## Optional: Session Persistence + +If your agent runtime supports sessions (conversation continuity across heartbeats), implement a session codec: + +```ts +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw) { + if (typeof raw !== "object" || raw === null) return null; + const r = raw as Record; + return r.sessionId ? { sessionId: String(r.sessionId) } : null; + }, + serialize(params) { + return params?.sessionId ? { sessionId: String(params.sessionId) } : null; + }, + getDisplayId(params) { + return params?.sessionId ? String(params.sessionId) : null; + }, +}; +``` + +Include it in `createServerAdapter()`: + +```ts +return { type, execute, testEnvironment, sessionCodec, /* ... */ }; +``` + +## Optional: Skills Sync + +If your agent runtime supports skills/plugins, implement `listSkills` and `syncSkills`: + +```ts +return { + type, + execute, + testEnvironment, + async listSkills(ctx) { + return { + adapterType: ctx.adapterType, + supported: true, + mode: "ephemeral", + desiredSkills: [], + entries: [], + warnings: [], + }; + }, + async syncSkills(ctx, desiredSkills) { + // Install desired skills into the runtime + return { /* same shape as listSkills */ }; + }, +}; +``` + +## Optional: Model Detection + +If your runtime has a local config file that specifies the default model: + +```ts +async function detectModel() { + // Read ~/.my-agent/config.yaml or similar + return { + model: "anthropic/claude-sonnet-4", + provider: "anthropic", + source: "~/.my-agent/config.yaml", + candidates: ["anthropic/claude-sonnet-4", "openai/gpt-4o"], + }; +} + +return { type, execute, testEnvironment, detectModel: () => detectModel() }; +``` + +## Publishing + +```sh +npm run build +npm publish +``` + +Other Paperclip users can then install your adapter by package name from the UI or API. + +## Security + +- Treat agent output as untrusted β€” parse defensively, never `eval()` agent output +- Inject secrets via environment variables, not in prompts +- Configure network access controls if the runtime supports them +- Always enforce timeout and grace period β€” don't let agents run forever +- The UI parser module runs in a browser sandbox β€” it must have zero runtime imports and no side effects + +## Next Steps + +- [UI Parser Contract](/adapters/adapter-ui-parser) β€” add a custom run-log parser so the UI renders your adapter's output correctly +- [Creating an Adapter](/adapters/creating-an-adapter) β€” full walkthrough of adapter internals +- [How Agents Work](/guides/agent-developer/how-agents-work) β€” understand the heartbeat lifecycle your adapter serves diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 3216b5e5c7..55fd4d4e9d 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -22,43 +22,67 @@ When a heartbeat fires, Paperclip: | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | | [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental β€” adapter package exists, not yet in stable type enum) | | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | -| Hermes Local | `hermes_local` | Runs Hermes CLI locally | | Cursor | `cursor` | Runs Cursor in background mode | | Pi Local | `pi_local` | Runs an embedded Pi agent locally | | OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | +### External (plugin) adapters + +These adapters ship as standalone npm packages and are installed via the plugin system: + +| Adapter | Package | Type Key | Description | +|---------|---------|----------|-------------| +| Droid Local | `@henkey/droid-paperclip-adapter` | `droid_local` | Runs Factory Droid locally | +| Hermes Local | `@henkey/hermes-paperclip-adapter` | `hermes_local` | Runs Hermes CLI locally | + +## External Adapters + +You can build and distribute adapters as standalone packages β€” no changes to Paperclip's source code required. External adapters are loaded at startup via the plugin system. + +```sh +# Install from npm via API +curl -X POST http://localhost:3102/api/adapters \ + -d '{"packageName": "my-paperclip-adapter"}' + +# Or link from a local directory +curl -X POST http://localhost:3102/api/adapters \ + -d '{"localPath": "/home/user/my-adapter"}' +``` + +See [External Adapters](/adapters/external-adapters) for the full guide. + ## Adapter Architecture -Each adapter is a package with three modules: +Each adapter is a package with modules consumed by three registries: ``` -packages/adapters// +my-adapter/ src/ index.ts # Shared metadata (type, label, models) server/ execute.ts # Core execution logic parse.ts # Output parsing test.ts # Environment diagnostics - ui/ - parse-stdout.ts # Stdout -> transcript entries for run viewer - build-config.ts # Form values -> adapterConfig JSON + ui-parser.ts # Self-contained UI transcript parser (for external adapters) cli/ format-event.ts # Terminal output for `paperclipai run --watch` ``` -Three registries consume these modules: - -| Registry | What it does | -|----------|-------------| -| **Server** | Executes agents, captures results | -| **UI** | Renders run transcripts, provides config forms | -| **CLI** | Formats terminal output for live watching | +| Registry | What it does | Source | +|----------|-------------|--------| +| **Server** | Executes agents, captures results | `createServerAdapter()` from package root | +| **UI** | Renders run transcripts, provides config forms | `ui-parser.js` (dynamic) or static import (built-in) | +| **CLI** | Formats terminal output for live watching | Static import | ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or `hermes_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or install `droid_local` / `hermes_local` as external plugins - **Need to run a script or command?** Use `process` - **Need to call an external service?** Use `http` -- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) +- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters) + +## UI Parser Contract + +External adapters can ship a self-contained UI parser that tells the Paperclip web UI how to render their stdout. Without it, the UI uses a generic shell parser. See the [UI Parser Contract](/adapters/adapter-ui-parser) for details. diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index f367272395..dffb6fe29a 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -37,14 +37,18 @@ Built-in adapters: - `claude_local`: runs your local `claude` CLI - `codex_local`: runs your local `codex` CLI - `opencode_local`: runs your local `opencode` CLI -- `hermes_local`: runs your local `hermes` CLI - `cursor`: runs Cursor in background mode - `pi_local`: runs an embedded Pi agent locally - `openclaw_gateway`: connects to an OpenClaw gateway endpoint - `process`: generic shell command adapter - `http`: calls an external HTTP endpoint -For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. +External plugin adapters (install via the adapter manager or API): + +- `droid_local`: runs your local Factory Droid CLI (`@henkey/droid-paperclip-adapter`) +- `hermes_local`: runs your local `hermes` CLI (`@henkey/hermes-paperclip-adapter`) + +For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `droid_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. ## 3.2 Runtime behavior @@ -173,7 +177,7 @@ Start with least privilege where possible, and avoid exposing secrets in broad r ## 10. Minimal setup checklist -1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). +1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `cursor`, or `openclaw_gateway`). External plugins like `droid_local` and `hermes_local` are also available via the adapter manager. 2. Set `cwd` to the target workspace (for local adapters). 3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle. 4. Configure heartbeat policy (timer and/or assignment wakeups). diff --git a/docs/docs.json b/docs/docs.json index f87809af07..be48cc8e9e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -98,6 +98,8 @@ "adapters/codex-local", "adapters/process", "adapters/http", + "adapters/external-adapters", + "adapters/adapter-ui-parser", "adapters/creating-an-adapter" ] } diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 9337fad0b8..42f91fda70 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -292,7 +292,7 @@ export interface ServerAdapterModule { * Returns the detected model/provider and the config source, or null if * the adapter does not support detection or no config is found. */ - detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>; + detectModel?: () => Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null>; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 41c0693f8d..b2f85732fb 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -21,7 +21,7 @@ Core fields: - chrome (boolean, optional): pass --chrome when running Claude - promptTemplate (string, optional): run prompt template - maxTurnsPerRun (number, optional): max turns for one run -- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude +- dangerouslySkipPermissions (boolean, optional, default true): pass --dangerously-skip-permissions to claude; defaults to true because Paperclip runs Claude in headless --print mode where interactive permission prompts cannot be answered - command (string, optional): defaults to "claude" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index c7d6c6a8b2..a44d0957bf 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -317,7 +317,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index b77a0e75a2..9d81eb8af7 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -606,7 +606,7 @@ export interface WorkerToHostMethods { result: IssueComment[], ]; "issues.createComment": [ - params: { issueId: string; body: string; companyId: string }, + params: { issueId: string; body: string; companyId: string; authorAgentId?: string }, result: IssueComment, ]; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 83fbfb5b09..41e91d542f 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -405,7 +405,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!isInCompany(issues.get(issueId), companyId)) return []; return issueComments.get(issueId) ?? []; }, - async createComment(issueId, body, companyId) { + async createComment(issueId, body, companyId, options) { requireCapability(manifest, capabilitySet, "issue.comments.create"); const parentIssue = issues.get(issueId); if (!isInCompany(parentIssue, companyId)) { @@ -416,7 +416,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { id: randomUUID(), companyId: parentIssue.companyId, issueId, - authorAgentId: null, + authorAgentId: options?.authorAgentId ?? null, authorUserId: null, body, createdAt: now, diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 4b707e281e..f8a6ca4f9f 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -909,7 +909,12 @@ export interface PluginIssuesClient { companyId: string, ): Promise; listComments(issueId: string, companyId: string): Promise; - createComment(issueId: string, body: string, companyId: string): Promise; + createComment( + issueId: string, + body: string, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ documents: PluginIssueDocumentsClient; } diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index a64d225a87..483dbc7082 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -610,8 +610,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return callHost("issues.listComments", { issueId, companyId }); }, - async createComment(issueId: string, body: string, companyId: string) { - return callHost("issues.createComment", { issueId, body, companyId }); + async createComment(issueId: string, body: string, companyId: string, options?: { authorAgentId?: string }) { + return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId }); }, documents: { diff --git a/packages/shared/src/adapter-type.ts b/packages/shared/src/adapter-type.ts new file mode 100644 index 0000000000..5af29dfc58 --- /dev/null +++ b/packages/shared/src/adapter-type.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { AGENT_ADAPTER_TYPES } from "./constants.js"; + +export const agentAdapterTypeSchema = z + .string() + .trim() + .min(1) + .default("process") + .describe(`Known built-in adapters: ${AGENT_ADAPTER_TYPES.join(", ")}. External adapters may register additional non-empty string types at runtime.`); + +export const optionalAgentAdapterTypeSchema = z + .string() + .trim() + .min(1) + .optional(); diff --git a/packages/shared/src/adapter-types.test.ts b/packages/shared/src/adapter-types.test.ts new file mode 100644 index 0000000000..29fb6eec9d --- /dev/null +++ b/packages/shared/src/adapter-types.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { acceptInviteSchema, createAgentSchema, updateAgentSchema } from "./index.js"; + +describe("dynamic adapter type validation schemas", () => { + it("accepts external adapter types in create/update agent schemas", () => { + expect( + createAgentSchema.parse({ + name: "External Agent", + adapterType: "external_adapter", + }).adapterType, + ).toBe("external_adapter"); + + expect( + updateAgentSchema.parse({ + adapterType: "external_adapter", + }).adapterType, + ).toBe("external_adapter"); + }); + + it("still rejects blank adapter types", () => { + expect(() => + createAgentSchema.parse({ + name: "Blank Adapter", + adapterType: " ", + }), + ).toThrow(); + }); + + it("accepts external adapter types in invite acceptance schema", () => { + expect( + acceptInviteSchema.parse({ + requestType: "agent", + agentName: "External Joiner", + adapterType: "external_adapter", + }).adapterType, + ).toBe("external_adapter"); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 1e82a5ce77..59d584415c 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -31,9 +31,8 @@ export const AGENT_ADAPTER_TYPES = [ "pi_local", "cursor", "openclaw_gateway", - "hermes_local", ] as const; -export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; +export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number] | (string & {}); export const AGENT_ROLES = [ "ceo", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0f936bc2cb..b0fd87f2bf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; export { COMPANY_STATUSES, DEPLOYMENT_MODES, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 126a084386..6da95c126e 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { - AGENT_ADAPTER_TYPES, INVITE_JOIN_TYPES, JOIN_REQUEST_STATUSES, JOIN_REQUEST_TYPES, PERMISSION_KEYS, } from "../constants.js"; +import { optionalAgentAdapterTypeSchema } from "../adapter-type.js"; export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), @@ -26,7 +26,7 @@ export type CreateOpenClawInvitePrompt = z.infer< export const acceptInviteSchema = z.object({ requestType: z.enum(JOIN_REQUEST_TYPES), agentName: z.string().min(1).max(120).optional(), - adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(), + adapterType: optionalAgentAdapterTypeSchema, capabilities: z.string().max(4000).optional().nullable(), agentDefaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), // OpenClaw join compatibility fields accepted at top level. diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 288ae683fd..7b462db7fd 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { - AGENT_ADAPTER_TYPES, AGENT_ICON_NAMES, AGENT_ROLES, AGENT_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, } from "../constants.js"; +import { agentAdapterTypeSchema } from "../adapter-type.js"; import { envConfigSchema } from "./secret.js"; export const agentPermissionsSchema = z.object({ @@ -52,7 +52,7 @@ export const createAgentSchema = z.object({ reportsTo: z.string().uuid().optional().nullable(), capabilities: z.string().optional().nullable(), desiredSkills: z.array(z.string().min(1)).optional(), - adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"), + adapterType: agentAdapterTypeSchema, adapterConfig: adapterConfigSchema.optional().default({}), runtimeConfig: z.record(z.unknown()).optional().default({}), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), diff --git a/server/package.json b/server/package.json index b2d17ad3f1..b519ed1a7f 100644 --- a/server/package.json +++ b/server/package.json @@ -66,7 +66,6 @@ "drizzle-orm": "^0.38.4", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", - "hermes-paperclip-adapter": "^0.2.0", "jsdom": "^28.1.0", "multer": "^2.0.2", "open": "^11.0.0", @@ -93,4 +92,4 @@ "vite": "^6.1.0", "vitest": "^3.0.5" } -} +} \ No newline at end of file diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts new file mode 100644 index 0000000000..d121a3741d --- /dev/null +++ b/server/src/__tests__/adapter-registry.test.ts @@ -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", + ); + }); +}); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts new file mode 100644 index 0000000000..55b9b85b9f --- /dev/null +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -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) => config), + resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ 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) => ({ + 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 | undefined) ?? {}, + runtimeConfig: (input.runtimeConfig as Record | 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"); + }); +}); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 16b16ca38a..1f65c26d70 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -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(), })); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index eeec658e36..153365581c 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -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) { diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index ec6bc2d921..79efdabe70 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -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(); + }); +}); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 8be40a51e0..84bcaa3a51 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -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, diff --git a/server/src/adapters/plugin-loader.ts b/server/src/adapters/plugin-loader.ts new file mode 100644 index 0000000000..e9faf312d5 --- /dev/null +++ b/server/src/adapters/plugin-loader.ts @@ -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(); + +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): 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; + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 1f195f862e..35ae45c57d 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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(); -const adaptersByType = new Map( - [ +function registerBuiltInAdapters() { + for (const adapter of [ claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, @@ -197,21 +174,84 @@ const adaptersByType = new Map( 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 = (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 { + 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 { diff --git a/server/src/app.ts b/server/src/app.ts index b9faee2f3d..1ed1079908 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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, diff --git a/server/src/dev-watch-ignore.ts b/server/src/dev-watch-ignore.ts index cd618f7391..4fe3769d16 100644 --- a/server/src/dev-watch-ignore.ts +++ b/server/src/dev-watch-ignore.ts @@ -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)); } diff --git a/server/src/index.ts b/server/src/index.ts index 37318245be..8e20fe1f3f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -668,6 +668,12 @@ export async function startServer(): Promise { }, 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((resolveListen, rejectListen) => { const onError = (err: Error) => { server.off("error", onError); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts new file mode 100644 index 0000000000..c50d558122 --- /dev/null +++ b/server/src/routes/adapters.ts @@ -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): 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 { + // 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; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 68084040f1..36a87d6338 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 = { 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 { 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; @@ -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), @@ -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), @@ -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) }; 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 ( diff --git a/server/src/services/adapter-plugin-store.ts b/server/src/services/adapter-plugin-store.ts new file mode 100644 index 0000000000..8c26abe841 --- /dev/null +++ b/server/src/services/adapter-plugin-store.ts @@ -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; +} diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 4ef070471d..441b08826c 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -7,6 +7,12 @@ function readNumericField(record: Record, 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 | null | undefined, ): Record | null { @@ -33,3 +39,18 @@ export function summarizeHeartbeatRunResultJson( return Object.keys(summary).length > 0 ? summary : null; } + +export function buildHeartbeatRunIssueComment( + resultJson: Record | 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 + ); +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 356783de0b..dc14bc9955 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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); } diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 4d48755268..22ccb01708 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -807,7 +807,7 @@ export function buildHostServices( return (await issues.addComment( params.issueId, params.body, - {}, + { agentId: params.authorAgentId }, )) as IssueComment; }, }, diff --git a/tsconfig.json b/tsconfig.json index 3a989f389d..9a5267db6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "./packages/adapters/claude-local" }, { "path": "./packages/adapters/codex-local" }, { "path": "./packages/adapters/cursor-local" }, + { "path": "./packages/adapters/droid-local" }, { "path": "./packages/adapters/openclaw-gateway" }, { "path": "./packages/adapters/opencode-local" }, { "path": "./packages/adapters/pi-local" }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f240defcf7..0bc4721b46 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -34,6 +34,7 @@ import { InstanceSettings } from "./pages/InstanceSettings"; import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; +import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; @@ -175,6 +176,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> @@ -321,6 +323,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts new file mode 100644 index 0000000000..cbaae0119d --- /dev/null +++ b/ui/src/adapters/adapter-display-registry.ts @@ -0,0 +1,151 @@ +/** + * Single source of truth for adapter display metadata. + * + * Built-in adapters have entries in `adapterDisplayMap`. External (plugin) + * adapters get sensible defaults derived from their type string via + * `getAdapterDisplay()`. + */ +import type { ComponentType } from "react"; +import { + Bot, + Code, + Gem, + MousePointer2, + Sparkles, + Terminal, + Cpu, +} from "lucide-react"; +import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon"; + +// --------------------------------------------------------------------------- +// Type suffix parsing +// --------------------------------------------------------------------------- + +const TYPE_SUFFIXES: Record = { + _local: "local", + _gateway: "gateway", +}; + +function getTypeSuffix(type: string): string | null { + for (const [suffix, mode] of Object.entries(TYPE_SUFFIXES)) { + if (type.endsWith(suffix)) return mode; + } + return null; +} + +function withSuffix(label: string, suffix: string | null): string { + return suffix ? `${label} (${suffix})` : label; +} + +// --------------------------------------------------------------------------- +// Display metadata per adapter type +// --------------------------------------------------------------------------- + +export interface AdapterDisplayInfo { + label: string; + description: string; + icon: ComponentType<{ className?: string }>; + recommended?: boolean; + comingSoon?: boolean; + disabledLabel?: string; +} + +const adapterDisplayMap: Record = { + claude_local: { + label: "Claude Code", + description: "Local Claude agent", + icon: Sparkles, + recommended: true, + }, + codex_local: { + label: "Codex", + description: "Local Codex agent", + icon: Code, + recommended: true, + }, + gemini_local: { + label: "Gemini CLI", + description: "Local Gemini agent", + icon: Gem, + }, + opencode_local: { + label: "OpenCode", + description: "Local multi-provider agent", + icon: OpenCodeLogoIcon, + }, + pi_local: { + label: "Pi", + description: "Local Pi agent", + icon: Terminal, + }, + cursor: { + label: "Cursor", + description: "Local Cursor agent", + icon: MousePointer2, + }, + openclaw_gateway: { + label: "OpenClaw Gateway", + description: "Invoke OpenClaw via gateway protocol", + icon: Bot, + comingSoon: true, + disabledLabel: "Configure OpenClaw within the App", + }, + process: { + label: "Process", + description: "Internal process adapter", + icon: Cpu, + comingSoon: true, + }, + http: { + label: "HTTP", + description: "Internal HTTP adapter", + icon: Cpu, + comingSoon: true, + }, +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +function humanizeType(type: string): string { + // Strip known type suffixes so "droid_local" β†’ "Droid", not "Droid Local" + let base = type; + for (const suffix of Object.keys(TYPE_SUFFIXES)) { + if (base.endsWith(suffix)) { + base = base.slice(0, -suffix.length); + break; + } + } + return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export function getAdapterLabel(type: string): string { + const base = adapterDisplayMap[type]?.label ?? humanizeType(type); + return withSuffix(base, getTypeSuffix(type)); +} + +export function getAdapterLabels(): Record { + const suffixed: Record = {}; + for (const [type, info] of Object.entries(adapterDisplayMap)) { + suffixed[type] = withSuffix(info.label, getTypeSuffix(type)); + } + return suffixed; +} + +export function getAdapterDisplay(type: string): AdapterDisplayInfo { + const known = adapterDisplayMap[type]; + if (known) return known; + + const suffix = getTypeSuffix(type); + const label = withSuffix(humanizeType(type), suffix); + return { + label, + description: suffix ? `External ${suffix} adapter` : "External adapter", + icon: Cpu, + }; +} + +export function isKnownAdapterType(type: string): boolean { + return type in adapterDisplayMap; +} diff --git a/ui/src/adapters/disabled-store.ts b/ui/src/adapters/disabled-store.ts new file mode 100644 index 0000000000..66de3e7120 --- /dev/null +++ b/ui/src/adapters/disabled-store.ts @@ -0,0 +1,33 @@ +/** + * Client-side store for disabled adapter types. + * + * Hydrated from the server's GET /api/adapters response. + * Provides synchronous reads so module-level constants can filter against it. + * Falls back to "nothing disabled" before the first hydration. + * + * Usage in components: + * useQuery + adaptersApi.list() populates the store automatically. + * + * Usage in non-React code: + * import { isAdapterTypeHidden } from "@/adapters/disabled-store"; + */ + +let disabledTypes = new Set(); + +/** Check if an adapter type is hidden from menus (sync read). */ +export function isAdapterTypeHidden(type: string): boolean { + return disabledTypes.has(type); +} + +/** Get all hidden adapter types (sync read). */ +export function getHiddenAdapterTypes(): Set { + return disabledTypes; +} + +/** + * Hydrate the store from a server response. + * Called by components that fetch the adapters list. + */ +export function setDisabledAdapterTypes(types: string[]): void { + disabledTypes = new Set(types); +} diff --git a/ui/src/adapters/dynamic-loader.ts b/ui/src/adapters/dynamic-loader.ts new file mode 100644 index 0000000000..23d5f443f9 --- /dev/null +++ b/ui/src/adapters/dynamic-loader.ts @@ -0,0 +1,106 @@ +/** + * Dynamic UI parser loading for external adapters. + * + * When the Paperclip UI encounters an adapter type that doesn't have a + * built-in parser (e.g., an external adapter loaded via the plugin system), + * it fetches the parser JS from `/api/adapters/:type/ui-parser.js` and + * evaluates it to create a `parseStdoutLine` function. + * + * The parser module must export: + * - `parseStdoutLine(line: string, ts: string): TranscriptEntry[]` + * - optionally `createStdoutParser(): { parseLine, reset }` for stateful parsers + * + * This is the bridge between the server-side plugin system and the client-side + * UI rendering. Adapter developers ship a `dist/ui-parser.js` with zero + * runtime dependencies, and Paperclip's UI loads it on demand. + */ + +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import type { StdoutLineParser } from "./types"; + +// Cache of dynamically loaded parsers by adapter type. +// Once loaded, the parser is reused for all runs of that adapter type. +const dynamicParserCache = new Map(); + +// Track which types we've already attempted to load (to avoid repeat 404s). +const failedLoads = new Set(); + +/** + * Dynamically load a UI parser for an adapter type from the server API. + * + * Fetches `/api/adapters/:type/ui-parser.js`, evaluates the module source + * in a scoped context, and extracts the `parseStdoutLine` export. + * + * @returns A StdoutLineParser function, or null if unavailable. + */ +export async function loadDynamicParser(adapterType: string): Promise { + // Return cached parser if already loaded + const cached = dynamicParserCache.get(adapterType); + if (cached) return cached; + + // Don't retry types that previously 404'd + if (failedLoads.has(adapterType)) return null; + + try { + const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`); + if (!response.ok) { + failedLoads.add(adapterType); + return null; + } + + const source = await response.text(); + + // Evaluate the module source using URL.createObjectURL + dynamic import(). + // This properly supports ESM modules with `export` statements. + // (new Function("exports", source) would fail with SyntaxError on `export` keywords.) + const blob = new Blob([source], { type: "application/javascript" }); + const blobUrl = URL.createObjectURL(blob); + + let parseFn: StdoutLineParser; + + try { + const mod = await import(/* @vite-ignore */ blobUrl); + + // Prefer the factory function (stateful parser) if available, + // fall back to the static parseStdoutLine function. + if (typeof mod.createStdoutParser === "function") { + // Stateful parser β€” create one instance for the UI session. + // Each run creates its own transcript builder, so a single + // parser instance is sufficient per adapter type. + const parser = (mod.createStdoutParser as () => { parseLine: StdoutLineParser; reset: () => void })(); + parseFn = parser.parseLine.bind(parser); + } else if (typeof mod.parseStdoutLine === "function") { + parseFn = mod.parseStdoutLine as StdoutLineParser; + } else { + console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`); + failedLoads.add(adapterType); + return null; + } + } finally { + URL.revokeObjectURL(blobUrl); + } + + // Cache for reuse + dynamicParserCache.set(adapterType, parseFn); + console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`); + return parseFn; + } catch (err) { + console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err); + failedLoads.add(adapterType); + return null; + } +} + +/** + * Invalidate a cached dynamic parser, removing it from both the parser cache + * and the failed-loads set so that the next load attempt will try again. + */ +export function invalidateDynamicParser(adapterType: string): boolean { + const wasCached = dynamicParserCache.has(adapterType); + dynamicParserCache.delete(adapterType); + failedLoads.delete(adapterType); + if (wasCached) { + console.info(`[adapter-ui-loader] Invalidated dynamic UI parser for "${adapterType}"`); + } + return wasCached; +} diff --git a/ui/src/adapters/hermes-local/config-fields.tsx b/ui/src/adapters/hermes-local/config-fields.tsx deleted file mode 100644 index 62b85fea88..0000000000 --- a/ui/src/adapters/hermes-local/config-fields.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { AdapterConfigFieldsProps } from "../types"; -import { - Field, - DraftInput, -} from "../../components/agent-config-primitives"; -import { ChoosePathButton } from "../../components/PathInstructionsModal"; - -const inputClass = - "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; -const instructionsFileHint = - "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; - -export function HermesLocalConfigFields({ - isCreate, - values, - set, - config, - eff, - mark, - hideInstructionsFile, -}: AdapterConfigFieldsProps) { - if (hideInstructionsFile) return null; - return ( - -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
-
- ); -} diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts deleted file mode 100644 index 97c064f8d6..0000000000 --- a/ui/src/adapters/hermes-local/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { UIAdapterModule } from "../types"; -import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; -import { HermesLocalConfigFields } from "./config-fields"; -import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; - -export const hermesLocalUIAdapter: UIAdapterModule = { - type: "hermes_local", - label: "Hermes Agent", - parseStdoutLine: parseHermesStdoutLine, - ConfigFields: HermesLocalConfigFields, - buildAdapterConfig: buildHermesConfig, -}; diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index feb04511ab..ec4f196b16 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -1,4 +1,11 @@ -export { getUIAdapter, listUIAdapters } from "./registry"; +export { + getUIAdapter, + listUIAdapters, + findUIAdapter, + registerUIAdapter, + unregisterUIAdapter, + syncExternalAdapters, +} from "./registry"; export { buildTranscript } from "./transcript"; export type { TranscriptEntry, diff --git a/ui/src/adapters/metadata.test.ts b/ui/src/adapters/metadata.test.ts new file mode 100644 index 0000000000..70b7ef3c96 --- /dev/null +++ b/ui/src/adapters/metadata.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isEnabledAdapterType, listAdapterOptions } from "./metadata"; +import type { UIAdapterModule } from "./types"; + +const externalAdapter: UIAdapterModule = { + type: "external_test", + label: "External Test", + parseStdoutLine: () => [], + ConfigFields: () => null, + buildAdapterConfig: () => ({}), +}; + +describe("adapter metadata", () => { + it("treats registered external adapters as enabled by default", () => { + expect(isEnabledAdapterType("external_test")).toBe(true); + + expect( + listAdapterOptions((type) => type, [externalAdapter]), + ).toEqual([ + { + value: "external_test", + label: "external_test", + comingSoon: false, + hidden: false, + }, + ]); + }); + + it("keeps intentionally withheld built-in adapters marked as coming soon", () => { + expect(isEnabledAdapterType("process")).toBe(false); + expect(isEnabledAdapterType("http")).toBe(false); + }); +}); \ No newline at end of file diff --git a/ui/src/adapters/metadata.ts b/ui/src/adapters/metadata.ts new file mode 100644 index 0000000000..d8a9e1f1d3 --- /dev/null +++ b/ui/src/adapters/metadata.ts @@ -0,0 +1,61 @@ +/** + * Adapter metadata utilities β€” built on top of the display registry and UI adapter list. + * + * This module bridges the static display metadata with the dynamic adapter registry. + * "Coming soon" status is derived from the display registry's `comingSoon` flag. + * "Hidden" status comes from the disabled-adapter store (server-side toggle). + */ +import type { UIAdapterModule } from "./types"; +import { listUIAdapters } from "./registry"; +import { isAdapterTypeHidden } from "./disabled-store"; +import { getAdapterLabel, getAdapterDisplay } from "./adapter-display-registry"; + +export interface AdapterOptionMetadata { + value: string; + label: string; + comingSoon: boolean; + hidden: boolean; +} + +export function listKnownAdapterTypes(): string[] { + return listUIAdapters().map((adapter) => adapter.type); +} + +/** + * Check whether an adapter type is enabled (not "coming soon"). + * Unknown types (external adapters) are always considered enabled. + */ +export function isEnabledAdapterType(type: string): boolean { + return !getAdapterDisplay(type).comingSoon; +} + +/** + * Build option metadata for a list of adapters (for dropdowns). + * `labelFor` callback allows callers to override labels; defaults to display registry. + */ +export function listAdapterOptions( + labelFor?: (type: string) => string, + adapters: UIAdapterModule[] = listUIAdapters(), +): AdapterOptionMetadata[] { + const getLabel = labelFor ?? getAdapterLabel; + return adapters.map((adapter) => ({ + value: adapter.type, + label: getLabel(adapter.type), + comingSoon: !!getAdapterDisplay(adapter.type).comingSoon, + hidden: isAdapterTypeHidden(adapter.type), + })); +} + +/** + * List UI adapters excluding those hidden via the Adapters settings page. + */ +export function listVisibleUIAdapters(): UIAdapterModule[] { + return listUIAdapters().filter((a) => !isAdapterTypeHidden(a.type)); +} + +/** + * List visible adapter types (for non-React contexts like module-level constants). + */ +export function listVisibleAdapterTypes(): string[] { + return listVisibleUIAdapters().map((a) => a.type); +} diff --git a/ui/src/adapters/registry.test.ts b/ui/src/adapters/registry.test.ts new file mode 100644 index 0000000000..b80dcc2868 --- /dev/null +++ b/ui/src/adapters/registry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import type { UIAdapterModule } from "./types"; +import { + findUIAdapter, + getUIAdapter, + listUIAdapters, + registerUIAdapter, + unregisterUIAdapter, +} from "./registry"; +import { processUIAdapter } from "./process"; + +const externalUIAdapter: UIAdapterModule = { + type: "external_test", + label: "External Test", + parseStdoutLine: () => [], + ConfigFields: () => null, + buildAdapterConfig: () => ({}), +}; + +describe("ui adapter registry", () => { + beforeEach(() => { + unregisterUIAdapter("external_test"); + }); + + afterEach(() => { + unregisterUIAdapter("external_test"); + }); + + it("registers adapters for lookup and listing", () => { + registerUIAdapter(externalUIAdapter); + + expect(findUIAdapter("external_test")).toBe(externalUIAdapter); + expect(getUIAdapter("external_test")).toBe(externalUIAdapter); + expect(listUIAdapters().some((adapter) => adapter.type === "external_test")).toBe(true); + }); + + it("falls back to the process parser for unknown types after unregistering", () => { + registerUIAdapter(externalUIAdapter); + + unregisterUIAdapter("external_test"); + + expect(findUIAdapter("external_test")).toBeNull(); + const fallback = getUIAdapter("external_test"); + // Unknown types return a lazy-loading wrapper (for external adapters), + // not the process adapter directly. The type is preserved. + expect(fallback.type).toBe("external_test"); + // But it uses the process parser under the hood. + expect(fallback.ConfigFields).toBe(processUIAdapter.ConfigFields); + }); +}); diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 67d89adae5..a27e7316fb 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,32 +3,130 @@ import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; -import { hermesLocalUIAdapter } from "./hermes-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; +import { loadDynamicParser } from "./dynamic-loader"; -const uiAdapters: UIAdapterModule[] = [ - claudeLocalUIAdapter, - codexLocalUIAdapter, - geminiLocalUIAdapter, - hermesLocalUIAdapter, - openCodeLocalUIAdapter, - piLocalUIAdapter, - cursorLocalUIAdapter, - openClawGatewayUIAdapter, - processUIAdapter, - httpUIAdapter, -]; +const uiAdapters: UIAdapterModule[] = []; +const adaptersByType = new Map(); -const adaptersByType = new Map( - uiAdapters.map((a) => [a.type, a]), -); +function registerBuiltInUIAdapters() { + for (const adapter of [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + geminiLocalUIAdapter, + openCodeLocalUIAdapter, + piLocalUIAdapter, + cursorLocalUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, + ]) { + 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); +} + +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 { - return adaptersByType.get(type) ?? processUIAdapter; + const builtIn = adaptersByType.get(type); + + if (!builtIn) { + // No built-in adapter β€” fall through to the external-only path. + let loadStarted = false; + return { + type, + label: type, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(type).then((parser) => { + if (parser) { + registerUIAdapter({ + type, + label: type, + parseStdoutLine: parser, + ConfigFields: processUIAdapter.ConfigFields, + buildAdapterConfig: processUIAdapter.buildAdapterConfig, + }); + } + }); + } + return processUIAdapter.parseStdoutLine(line, ts); + }, + ConfigFields: processUIAdapter.ConfigFields, + buildAdapterConfig: processUIAdapter.buildAdapterConfig, + }; + } + + return builtIn; +} + +/** + * Ensure external adapter types (from the server's /api/adapters response) + * are registered in the UI adapter list so they appear in dropdowns. + * + * For each type not already registered, creates a placeholder module that + * uses the process adapter defaults and kicks off dynamic parser loading. + * Once the parser resolves, the placeholder is replaced with the real one. + */ +export function syncExternalAdapters( + serverAdapters: { type: string; label: string }[], +): void { + for (const { type, label } of serverAdapters) { + if (adaptersByType.has(type)) continue; + + let loadStarted = false; + registerUIAdapter({ + type, + label, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(type).then((parser) => { + if (parser) { + registerUIAdapter({ + type, + label, + parseStdoutLine: parser, + ConfigFields: processUIAdapter.ConfigFields, + buildAdapterConfig: processUIAdapter.buildAdapterConfig, + }); + } + }); + } + return processUIAdapter.parseStdoutLine(line, ts); + }, + ConfigFields: processUIAdapter.ConfigFields, + buildAdapterConfig: processUIAdapter.buildAdapterConfig, + }); + } } export function listUIAdapters(): UIAdapterModule[] { diff --git a/ui/src/adapters/use-disabled-adapters.ts b/ui/src/adapters/use-disabled-adapters.ts new file mode 100644 index 0000000000..9810d094bd --- /dev/null +++ b/ui/src/adapters/use-disabled-adapters.ts @@ -0,0 +1,49 @@ +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { adaptersApi } from "@/api/adapters"; +import { setDisabledAdapterTypes } from "@/adapters/disabled-store"; +import { syncExternalAdapters } from "@/adapters/registry"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * Fetch adapters and keep the disabled-adapter store + UI adapter registry + * in sync with the server. + * + * - Registers external adapter types in the UI registry so they appear in + * dropdowns (done eagerly during render β€” idempotent, no React state). + * - Syncs the disabled-adapter store for non-React consumers (useEffect). + * + * Returns a reactive Set of disabled types for use as useMemo dependencies. + * Call this at the top of any component that renders adapter menus. + */ +export function useDisabledAdaptersSync(): Set { + const { data: adapters } = useQuery({ + queryKey: queryKeys.adapters.all, + queryFn: () => adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + + // Eagerly register external adapter types in the UI registry so that + // consumers calling listUIAdapters() in the same render cycle see them. + // This is idempotent β€” already-registered types are skipped. + if (adapters) { + syncExternalAdapters( + adapters + .filter((a) => a.source === "external") + .map((a) => ({ type: a.type, label: a.label })), + ); + } + + // Sync the disabled set to the global store for non-React code + useEffect(() => { + if (!adapters) return; + setDisabledAdapterTypes( + adapters.filter((a) => a.disabled).map((a) => a.type), + ); + }, [adapters]); + + return useMemo( + () => new Set(adapters?.filter((a) => a.disabled).map((a) => a.type) ?? []), + [adapters], + ); +} diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts new file mode 100644 index 0000000000..dea603946d --- /dev/null +++ b/ui/src/api/adapters.ts @@ -0,0 +1,51 @@ +/** + * @fileoverview Frontend API client for external adapter management. + */ + +import { api } from "./client"; + +export interface AdapterInfo { + type: string; + label: string; + source: "builtin" | "external"; + modelsCount: number; + loaded: boolean; + disabled: boolean; + /** Installed version (for external npm adapters) */ + version?: string; + /** Package name (for external adapters) */ + packageName?: string; + /** Whether the adapter was installed from a local path (vs npm). */ + isLocalPath?: boolean; +} + +export interface AdapterInstallResult { + type: string; + packageName: string; + version?: string; + installedAt: string; +} + +export const adaptersApi = { + /** List all registered adapters (built-in + external). */ + list: () => api.get("/adapters"), + + /** Install an external adapter from npm or a local path. */ + install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) => + api.post("/adapters/install", params), + + /** Remove an external adapter by type. */ + remove: (type: string) => api.delete<{ type: string; removed: boolean }>(`/adapters/${type}`), + + /** Enable or disable an adapter (disabled adapters hidden from agent menus). */ + setDisabled: (type: string, disabled: boolean) => + api.patch<{ type: string; disabled: boolean; changed: boolean }>(`/adapters/${type}`, { disabled }), + + /** Reload an external adapter (bust server + client caches). */ + reload: (type: string) => + api.post<{ type: string; version?: string; reloaded: boolean }>(`/adapters/${type}/reload`, {}), + + /** Reinstall an npm-sourced adapter (pulls latest from registry, then reloads). */ + reinstall: (type: string) => + api.post<{ type: string; version?: string; reinstalled: boolean }>(`/adapters/${type}/reinstall`, {}), +}; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index bda8bf7ac8..fcd3860420 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -32,6 +32,7 @@ export interface DetectedAdapterModel { model: string; provider: string; source: string; + candidates?: string[]; } export interface ClaudeLoginResult { diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c3c9bdfa6f..bb3ac083f1 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import type { Agent, AdapterEnvironmentTestResult, @@ -46,6 +45,9 @@ import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { ReportsToPicker } from "./ReportsToPicker"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; +import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; +import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; /* ---- Create mode values ---- */ @@ -180,6 +182,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); + // Sync disabled adapter types from server so dropdown filters them out + const disabledTypes = useDisabledAdaptersSync(); + const { data: availableSecrets = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], queryFn: () => secretsApi.list(selectedCompanyId!), @@ -311,15 +316,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const isLocal = - adapterType === "claude_local" || - adapterType === "codex_local" || - adapterType === "gemini_local" || - adapterType === "hermes_local" || - adapterType === "opencode_local" || - adapterType === "pi_local" || - adapterType === "cursor"; - const isHermesLocal = adapterType === "hermes_local"; + const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); + const isLocal = !NONLOCAL_TYPES.has(adapterType); + const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); @@ -345,13 +344,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : ["agents", "none", "detect-model", adapterType], queryFn: () => { if (!selectedCompanyId) { - throw new Error("Select a company to detect the Hermes model"); + throw new Error("Select a company to detect the model"); } return agentsApi.detectModel(selectedCompanyId, adapterType); }, - enabled: Boolean(selectedCompanyId && isHermesLocal), + enabled: Boolean(selectedCompanyId && isLocal), }); const detectedModel = detectedModelData?.model ?? null; + const detectedModelCandidates = detectedModelData?.candidates ?? []; const { data: companyAgents = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], @@ -583,6 +583,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { { if (isCreate) { // Reset all adapter-specific fields to defaults when switching adapter type @@ -716,24 +717,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onCommit={(v) => isCreate ? set!({ command: v }) - : mark("adapterConfig", "command", v || undefined) + : mark("adapterConfig", "command", v || null) } immediate className={inputClass} placeholder={ - adapterType === "codex_local" - ? "codex" - : adapterType === "gemini_local" - ? "gemini" - : adapterType === "hermes_local" - ? "hermes" - : adapterType === "pi_local" - ? "pi" - : adapterType === "cursor" - ? "agent" - : adapterType === "opencode_local" - ? "opencode" - : "claude" + ({ + claude_local: "claude", + codex_local: "codex", + gemini_local: "gemini", + pi_local: "pi", + cursor: "agent", + opencode_local: "opencode", + } as Record)[adapterType] ?? adapterType.replace(/_local$/, "") } /> @@ -748,18 +744,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} - allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"} - required={adapterType === "opencode_local" || adapterType === "hermes_local"} + allowDefault={adapterType !== "opencode_local"} + required={adapterType === "opencode_local"} groupByProvider={adapterType === "opencode_local"} - creatable={adapterType === "hermes_local"} - detectedModel={adapterType === "hermes_local" ? detectedModel : null} - onDetectModel={adapterType === "hermes_local" - ? async () => { - const result = await refetchDetectedModel(); - return result.data?.model ?? null; - } - : undefined} - detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined} + creatable + detectedModel={detectedModel} + detectedModelCandidates={[]} + onDetectModel={async () => { + const result = await refetchDetectedModel(); + return result.data?.model ?? null; + }} + detectModelLabel="Detect model" + emptyDetectHint="No model detected. Select or enter one manually." /> {fetchedModelsError && (

@@ -831,7 +827,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onCommit={(v) => isCreate ? set!({ extraArgs: v }) - : mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined) + : mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : null) } immediate className={inputClass} @@ -1024,37 +1020,37 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe /* ---- Internal sub-components ---- */ -const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); - -/** Display list includes all real adapter types plus UI-only coming-soon entries. */ -const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ - ...AGENT_ADAPTER_TYPES.map((t) => ({ - value: t, - label: adapterLabels[t] ?? t, - comingSoon: !ENABLED_ADAPTER_TYPES.has(t), - })), -]; - function AdapterTypeDropdown({ value, onChange, + disabledTypes, }: { value: string; onChange: (type: string) => void; + disabledTypes: Set; }) { + const [open, setOpen] = useState(false); + const adapterList = useMemo( + () => + listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter( + (item) => !disabledTypes.has(item.value), + ), + [disabledTypes], + ); + return ( - + - {ADAPTER_DISPLAY_LIST.map((item) => ( + {adapterList.map((item) => ( )} - {onDetectModel && !detectedModel && !modelSearch.trim() && ( + {onDetectModel && !modelSearch.trim() && ( )} - {value && !models.some((m) => m.id === value) && ( + {value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && ( )} + {detectedModelCandidates + ?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value) + .map((candidate) => { + const entry = models.find((m) => m.id === candidate); + return ( + + ); + })}

{allowDefault && (
diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index dbd8381b39..076b970265 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react"; +import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react"; import { NavLink } from "@/lib/router"; import { pluginsApi } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; @@ -26,6 +26,7 @@ export function InstanceSidebar() { + {(plugins ?? []).length > 0 ? (
{(plugins ?? []).map((plugin) => ( diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index aaaf7c6d19..4ff672c660 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,10 +1,11 @@ -import { useState, type ComponentType } from "react"; +import { useState, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { agentsApi } from "../api/agents"; -import { queryKeys } from "../lib/queryKeys"; +import { adaptersApi } from "../api/adapters"; +import { queryKeys } from "@/lib/queryKeys"; import { Dialog, DialogContent, @@ -13,91 +14,37 @@ import { Button } from "@/components/ui/button"; import { ArrowLeft, Bot, - Code, - Gem, - MousePointer2, - Sparkles, - Terminal, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; -import { HermesIcon } from "./HermesIcon"; +import { listUIAdapters } from "../adapters"; +import { getAdapterDisplay } from "../adapters/adapter-display-registry"; +import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; -type AdvancedAdapterType = - | "claude_local" - | "codex_local" - | "gemini_local" - | "opencode_local" - | "pi_local" - | "cursor" - | "openclaw_gateway" - | "hermes_local"; +/** + * Adapter types that are suitable for agent creation (excludes internal + * system adapters like "process" and "http"). + */ +const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]); -const ADVANCED_ADAPTER_OPTIONS: Array<{ - value: AdvancedAdapterType; - label: string; - desc: string; - icon: ComponentType<{ className?: string }>; - recommended?: boolean; -}> = [ - { - value: "claude_local", - label: "Claude Code", - icon: Sparkles, - desc: "Local Claude agent", - recommended: true, - }, - { - value: "codex_local", - label: "Codex", - icon: Code, - desc: "Local Codex agent", - recommended: true, - }, - { - value: "gemini_local", - label: "Gemini CLI", - icon: Gem, - desc: "Local Gemini agent", - }, - { - value: "opencode_local", - label: "OpenCode", - icon: OpenCodeLogoIcon, - desc: "Local multi-provider agent", - }, - { - value: "hermes_local", - label: "Hermes Agent", - icon: HermesIcon, - desc: "Local multi-provider agent", - }, - { - value: "pi_local", - label: "Pi", - icon: Terminal, - desc: "Local Pi agent", - }, - { - value: "cursor", - label: "Cursor", - icon: MousePointer2, - desc: "Local Cursor agent", - }, - { - value: "openclaw_gateway", - label: "OpenClaw Gateway", - icon: Bot, - desc: "Invoke OpenClaw via gateway protocol", - }, -]; +function isAgentAdapterType(type: string): boolean { + return !SYSTEM_ADAPTER_TYPES.has(type); +} export function NewAgentDialog() { const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); const [showAdvancedCards, setShowAdvancedCards] = useState(false); + const disabledTypes = useDisabledAdaptersSync(); + // Fetch registered adapters from server (syncs disabled store + provides data) + const { data: serverAdapters } = useQuery({ + queryKey: queryKeys.adapters.all, + queryFn: () => adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + + // Fetch existing agents for the "Ask CEO" flow const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), @@ -106,6 +53,33 @@ export function NewAgentDialog() { const ceoAgent = (agents ?? []).find((a) => a.role === "ceo"); + // Build the adapter grid from the UI registry merged with display metadata. + // This automatically includes external/plugin adapters. + const adapterGrid = useMemo(() => { + const registered = listUIAdapters() + .filter((a) => isAgentAdapterType(a.type) && !disabledTypes.has(a.type)); + + // Sort: recommended first, then alphabetical + return registered + .map((a) => { + const display = getAdapterDisplay(a.type); + return { + value: a.type, + label: display.label, + desc: display.description, + icon: display.icon, + recommended: display.recommended, + comingSoon: display.comingSoon, + disabledLabel: display.disabledLabel, + }; + }) + .sort((a, b) => { + if (a.recommended && !b.recommended) return -1; + if (!a.recommended && b.recommended) return 1; + return a.label.localeCompare(b.label); + }); + }, [disabledTypes, serverAdapters]); + function handleAskCeo() { closeNewAgent(); openNewIssue({ @@ -119,7 +93,7 @@ export function NewAgentDialog() { setShowAdvancedCards(true); } - function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) { + function handleAdvancedAdapterPick(adapterType: string) { closeNewAgent(); setShowAdvancedCards(false); navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); @@ -161,7 +135,7 @@ export function NewAgentDialog() { {/* Recommendation */}
- +

We recommend letting your CEO handle agent setup β€” they know the @@ -201,13 +175,18 @@ export function NewAgentDialog() {

- {ADVANCED_ADAPTER_OPTIONS.map((opt) => ( + {adapterGrid.map((opt) => ( ))} @@ -823,60 +803,21 @@ export function OnboardingWizard() { {showMoreAdapters && (
- {[ - { - value: "gemini_local" as const, - label: "Gemini CLI", - icon: Gem, - desc: "Local Gemini agent" - }, - { - value: "opencode_local" as const, - label: "OpenCode", - icon: OpenCodeLogoIcon, - desc: "Local multi-provider agent" - }, - { - value: "pi_local" as const, - label: "Pi", - icon: Terminal, - desc: "Local Pi agent" - }, - { - value: "cursor" as const, - label: "Cursor", - icon: MousePointer2, - desc: "Local Cursor agent" - }, - { - value: "hermes_local" as const, - label: "Hermes Agent", - icon: HermesIcon, - desc: "Local multi-provider agent" - }, - { - value: "openclaw_gateway" as const, - label: "OpenClaw Gateway", - icon: Bot, - desc: "Invoke OpenClaw via gateway protocol", - comingSoon: true, - disabledLabel: "Configure OpenClaw within the App" - } - ].map((opt) => ( - ))} @@ -910,13 +850,7 @@ export function OnboardingWizard() {
{/* Conditional adapter fields */} - {(adapterType === "claude_local" || - adapterType === "codex_local" || - adapterType === "gemini_local" || - adapterType === "hermes_local" || - adapterType === "opencode_local" || - adapterType === "pi_local" || - adapterType === "cursor") && ( + {isLocalAdapter && (