diff --git a/AGENTS.md b/AGENTS.md index bdfa3e5d..44e5c2fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,3 +146,44 @@ A change is done when all are true: 2. Typecheck, tests, and build pass 3. Contracts are synced across db/shared/server/ui 4. Docs updated when behavior or commands change + +## 11. Fork-Specific: HenkDz/paperclip + +This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)). + +### Branch Strategy + +- `feat/externalize-hermes-adapter` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path). +- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch. + +### Hermes (plugin only) + +- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded. +- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source. +- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo. + +### Local Dev + +- Fork runs on port 3101+ (auto-detects if 3100 is taken by upstream instance) +- `npx vite build` hangs on NTFS — use `node node_modules/vite/bin/vite.js build` instead +- Server startup from NTFS takes 30-60s — don't assume failure immediately +- Kill ALL paperclip processes before starting: `pkill -f "paperclip"; pkill -f "tsx.*index.ts"` +- Vite cache survives `rm -rf dist` — delete both: `rm -rf ui/dist ui/node_modules/.vite` + +### Fork QoL Patches (not in upstream) + +These are local modifications in the fork's UI. If re-copying source, these must be re-applied: + +1. **stderr_group** — amber accordion for MCP init noise in `RunTranscriptView.tsx` +2. **tool_group** — accordion for consecutive non-terminal tools (write, read, search, browser) +3. **Dashboard excerpt** — `LatestRunCard` strips markdown, shows first 3 lines/280 chars + +### Plugin System + +PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See root `AGENTS.md` for full details. + +- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json` +- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading +- `createServerAdapter()` must include ALL optional fields (especially `detectModel`) +- Built-in UI adapters can shadow external plugin parsers — remove built-in when fully externalizing +- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm) diff --git a/adapter-plugin.md b/adapter-plugin.md new file mode 100644 index 00000000..13994ba3 --- /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 00000000..a9390001 --- /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 d3a0b68b..fc64fcf8 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 fae0e4b3..ae5e4ccb 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 00000000..3c814fc9 --- /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 3216b5e5..dfb4b21f 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 | +| Hermes Local | `hermes_local` | Runs Hermes CLI locally (`hermes-paperclip-adapter`) | | 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 | + +## 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`, `hermes_local`, or install `droid_local` as an external plugin - **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 f3672723..cafc39e9 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 +- `hermes_local`: runs your local `hermes` CLI (`hermes-paperclip-adapter`) - `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`) + +For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `droid_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`, `hermes_local`, `cursor`, or `openclaw_gateway`). External plugins like `droid_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 f87809af..be48cc8e 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/index.ts b/packages/adapter-utils/src/index.ts index 943db253..6770ae51 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -22,6 +22,9 @@ export type { AdapterModel, HireApprovedPayload, HireApprovedHookResult, + ConfigFieldOption, + ConfigFieldSchema, + AdapterConfigSchema, ServerAdapterModule, QuotaWindow, ProviderQuotaResult, diff --git a/packages/adapter-utils/src/log-redaction.ts b/packages/adapter-utils/src/log-redaction.ts index 6c5554e1..96cfba6d 100644 --- a/packages/adapter-utils/src/log-redaction.ts +++ b/packages/adapter-utils/src/log-redaction.ts @@ -68,6 +68,7 @@ export function redactTranscriptEntryPaths(entry: TranscriptEntry, opts?: HomePa case "stderr": case "system": case "stdout": + case "diff": return { ...entry, text: redactHomePathUserSegments(entry.text, opts) }; case "tool_call": return { diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 308b54a3..90fe544b 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -41,6 +41,7 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ "codex_local", "cursor", "gemini_local", + "hermes_local", "opencode_local", "pi_local", ]); @@ -76,6 +77,11 @@ export const ADAPTER_SESSION_MANAGEMENT: Record { diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 9337fad0..429143d5 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -261,6 +261,34 @@ export interface ProviderQuotaResult { windows: QuotaWindow[]; } +// --------------------------------------------------------------------------- +// Adapter config schema — declarative UI config for external adapters +// --------------------------------------------------------------------------- + +export interface ConfigFieldOption { + label: string; + value: string; + /** Optional group key for categorizing options (e.g. provider name) */ + group?: string; +} + +export interface ConfigFieldSchema { + key: string; + label: string; + type: "text" | "select" | "toggle" | "number" | "textarea" | "combobox"; + options?: ConfigFieldOption[]; + default?: unknown; + hint?: string; + required?: boolean; + group?: string; + /** Optional metadata — not rendered, but available to custom UI logic */ + meta?: Record; +} + +export interface AdapterConfigSchema { + fields: ConfigFieldSchema[]; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; @@ -292,7 +320,14 @@ 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>; + /** + * Optional: return a declarative config schema so the UI can render + * adapter-specific form fields without shipping React components. + * Dynamic options (e.g. scanning a profiles directory) should be + * resolved inside this method — the caller receives a fully hydrated schema. + */ + getConfigSchema?: () => Promise | AdapterConfigSchema; } // --------------------------------------------------------------------------- @@ -309,7 +344,8 @@ export type TranscriptEntry = | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } | { kind: "stderr"; ts: string; text: string } | { kind: "system"; ts: string; text: string } - | { kind: "stdout"; ts: string; text: string }; + | { kind: "stdout"; ts: string; text: string } + | { kind: "diff"; ts: string; changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; text: string }; export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[]; @@ -353,4 +389,6 @@ export interface CreateConfigValues { maxTurnsPerRun: number; heartbeatEnabled: boolean; intervalSec: number; + /** Arbitrary key-value pairs populated by schema-driven config fields. */ + adapterSchemaValues?: Record; } diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 41c0693f..b2f85732 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 c7d6c6a8..a44d0957 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 b77a0e75..9d81eb8a 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 83fbfb5b..41e91d54 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 4b707e28..f8a6ca4f 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 a64d225a..483dbc70 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 00000000..5af29dfc --- /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 00000000..29fb6eec --- /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 1e82a5ce..59d58441 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 0f936bc2..b0fd87f2 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 126a0843..6da95c12 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 288ae683..7b462db7 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/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts new file mode 100644 index 00000000..6f7b0973 --- /dev/null +++ b/server/src/__tests__/adapter-registry.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import type { ServerAdapterModule } from "../adapters/index.js"; +import { + detectAdapterModel, + findActiveServerAdapter, + findServerAdapter, + listAdapterModels, + registerServerAdapter, + requireServerAdapter, + unregisterServerAdapter, +} from "../adapters/index.js"; +import { setOverridePaused } from "../adapters/registry.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"); + unregisterServerAdapter("claude_local"); + setOverridePaused("claude_local", false); + }); + + afterEach(() => { + unregisterServerAdapter("external_test"); + unregisterServerAdapter("claude_local"); + setOverridePaused("claude_local", false); + }); + + 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", + ); + }); + + it("allows external plugin to override a built-in adapter type", () => { + // claude_local is always built-in + const builtIn = findServerAdapter("claude_local"); + expect(builtIn).not.toBeNull(); + + const plugin: ServerAdapterModule = { + type: "claude_local", + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + }), + testEnvironment: async () => ({ + adapterType: "claude_local", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + models: [{ id: "plugin-model", label: "Plugin Override" }], + supportsLocalAgentJwt: false, + }; + + registerServerAdapter(plugin); + + // Plugin wins + const resolved = requireServerAdapter("claude_local"); + expect(resolved).toBe(plugin); + expect(resolved.models).toEqual([ + { id: "plugin-model", label: "Plugin Override" }, + ]); + }); + + it("switches active adapter behavior back to the builtin when an override is paused", async () => { + const builtIn = findServerAdapter("claude_local"); + expect(builtIn).not.toBeNull(); + + const detectModel = vi.fn(async () => ({ + model: "plugin-model", + provider: "plugin-provider", + source: "plugin-source", + })); + const plugin: ServerAdapterModule = { + type: "claude_local", + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + }), + testEnvironment: async () => ({ + adapterType: "claude_local", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + models: [{ id: "plugin-model", label: "Plugin Override" }], + detectModel, + supportsLocalAgentJwt: false, + }; + + registerServerAdapter(plugin); + + expect(findActiveServerAdapter("claude_local")).toBe(plugin); + expect(await listAdapterModels("claude_local")).toEqual([ + { id: "plugin-model", label: "Plugin Override" }, + ]); + expect(await detectAdapterModel("claude_local")).toMatchObject({ + model: "plugin-model", + provider: "plugin-provider", + }); + + expect(setOverridePaused("claude_local", true)).toBe(true); + + expect(findActiveServerAdapter("claude_local")).not.toBe(plugin); + expect(await listAdapterModels("claude_local")).toEqual(builtIn?.models ?? []); + expect(await detectAdapterModel("claude_local")).toBeNull(); + expect(detectModel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts new file mode 100644 index 00000000..c1ce6c3a --- /dev/null +++ b/server/src/__tests__/adapter-routes.test.ts @@ -0,0 +1,78 @@ +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ServerAdapterModule } from "../adapters/index.js"; +import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; +import { setOverridePaused } from "../adapters/registry.js"; +import { adapterRoutes } from "../routes/adapters.js"; +import { errorHandler } from "../middleware/index.js"; + +const overridingConfigSchemaAdapter: ServerAdapterModule = { + type: "claude_local", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "claude_local", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + getConfigSchema: async () => ({ + version: 1, + fields: [ + { + key: "mode", + type: "text", + label: "Mode", + }, + ], + }), +}; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: [], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", adapterRoutes()); + app.use(errorHandler); + return app; +} + +describe("adapter routes", () => { + beforeEach(() => { + setOverridePaused("claude_local", false); + registerServerAdapter(overridingConfigSchemaAdapter); + }); + + afterEach(() => { + setOverridePaused("claude_local", false); + unregisterServerAdapter("claude_local"); + }); + + it("uses the active adapter when resolving config schema for a paused builtin override", async () => { + const app = createApp(); + + const active = await request(app).get("/api/adapters/claude_local/config-schema"); + expect(active.status, JSON.stringify(active.body)).toBe(200); + expect(active.body).toMatchObject({ + fields: [{ key: "mode" }], + }); + + const paused = await request(app) + .patch("/api/adapters/claude_local/override") + .send({ paused: true }); + expect(paused.status, JSON.stringify(paused.body)).toBe(200); + + const builtin = await request(app).get("/api/adapters/claude_local/config-schema"); + expect(builtin.status, JSON.stringify(builtin.body)).toBe(404); + expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema"); + }); +}); 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 00000000..55b9b85b --- /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 16b16ca3..1f65c26d 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 eeec658e..5523323f 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -61,6 +61,7 @@ const mockAdapter = vi.hoisted(() => ({ vi.mock("@paperclipai/shared/telemetry", () => ({ trackAgentCreated: mockTrackAgentCreated, + trackErrorHandlerCrash: vi.fn(), })); vi.mock("../telemetry.js", () => ({ @@ -85,7 +86,9 @@ vi.mock("../services/index.js", () => ({ vi.mock("../adapters/index.js", () => ({ findServerAdapter: vi.fn(() => mockAdapter), + findActiveServerAdapter: 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 ec6bc2d9..79efdabe 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/__tests__/hire-hook.test.ts b/server/src/__tests__/hire-hook.test.ts index 0a2cbbfd..b08e04bc 100644 --- a/server/src/__tests__/hire-hook.test.ts +++ b/server/src/__tests__/hire-hook.test.ts @@ -4,14 +4,14 @@ import { notifyHireApproved } from "../services/hire-hook.js"; // Mock the registry so we control whether the adapter has onHireApproved and what it does. vi.mock("../adapters/registry.js", () => ({ - findServerAdapter: vi.fn(), + findActiveServerAdapter: vi.fn(), })); vi.mock("../services/activity-log.js", () => ({ logActivity: vi.fn().mockResolvedValue(undefined), })); -const { findServerAdapter } = await import("../adapters/registry.js"); +const { findActiveServerAdapter } = await import("../adapters/registry.js"); const { logActivity } = await import("../services/activity-log.js"); function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record }): Db { @@ -39,7 +39,7 @@ afterEach(() => { describe("notifyHireApproved", () => { it("writes success activity when adapter hook returns ok", async () => { - vi.mocked(findServerAdapter).mockReturnValue({ + vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: true }), } as any); @@ -88,11 +88,11 @@ describe("notifyHireApproved", () => { }), ).resolves.toBeUndefined(); - expect(findServerAdapter).not.toHaveBeenCalled(); + expect(findActiveServerAdapter).not.toHaveBeenCalled(); }); it("does nothing when adapter has no onHireApproved", async () => { - vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any); + vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "process" } as any); const db = mockDbWithAgent({ id: "a1", @@ -110,12 +110,12 @@ describe("notifyHireApproved", () => { }), ).resolves.toBeUndefined(); - expect(findServerAdapter).toHaveBeenCalledWith("process"); + expect(findActiveServerAdapter).toHaveBeenCalledWith("process"); expect(logActivity).not.toHaveBeenCalled(); }); it("logs failed result when adapter onHireApproved returns ok=false", async () => { - vi.mocked(findServerAdapter).mockReturnValue({ + vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }), } as any); @@ -147,7 +147,7 @@ describe("notifyHireApproved", () => { }); it("does not throw when adapter onHireApproved throws (non-fatal)", async () => { - vi.mocked(findServerAdapter).mockReturnValue({ + vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "openclaw_gateway", onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")), } as any); diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts new file mode 100644 index 00000000..463a5694 --- /dev/null +++ b/server/src/adapters/builtin-adapter-types.ts @@ -0,0 +1,15 @@ +/** + * Adapter types shipped with Paperclip. External plugins must not replace these. + */ +export const BUILTIN_ADAPTER_TYPES = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "openclaw_gateway", + "opencode_local", + "pi_local", + "hermes_local", + "process", + "http", +]); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 8be40a51..49530dc7 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -1,4 +1,14 @@ -export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js"; +export { + getServerAdapter, + listAdapterModels, + listServerAdapters, + findServerAdapter, + findActiveServerAdapter, + 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 00000000..a9f70463 --- /dev/null +++ b/server/src/adapters/plugin-loader.ts @@ -0,0 +1,277 @@ +/** + * 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 fileUrl = `file://${modulePath}`; + + // Bust ESM module cache so re-import loads fresh code from disk. + // Query-string trick (?t=...) works in Node; Bun may need the file:// URL + // to be evicted from its internal registry first. + try { + // @ts-expect-error -- Bun internal module cache + const bunCache = globalThis.Bun?.__moduleCache as Map | undefined; + if (bunCache) { + bunCache.delete(fileUrl); + bunCache.delete(modulePath); + } + } catch { + // Ignore — query-string fallback still works in Node + } + + const cacheBustUrl = `${fileUrl}?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 1f195f86..8a30a9c8 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -79,6 +79,9 @@ import { agentConfigurationDoc as hermesAgentConfigurationDoc, models as hermesModels, } from "hermes-paperclip-adapter"; +import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; +import { buildExternalAdapters } from "./plugin-loader.js"; +import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -188,8 +191,19 @@ const hermesLocalAdapter: ServerAdapterModule = { detectModel: () => detectModelFromHermes(), }; -const adaptersByType = new Map( - [ +const adaptersByType = new Map(); + +// For builtin types that are overridden by an external adapter, we keep the +// original builtin so it can be restored when the override is deactivated. +const builtinFallbacks = new Map(); + +// Tracks which override types are currently deactivated (paused). When +// paused, `getServerAdapter()` returns the builtin fallback instead of the +// external. Persisted across reloads via the same disabled-adapters store. +const pausedOverrides = new Set(); + +function registerBuiltInAdapters() { + for (const adapter of [ claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, @@ -200,20 +214,109 @@ const adaptersByType = new Map( hermesLocalAdapter, processAdapter, httpAdapter, - ].map((a) => [a.type, a]), -); + ]) { + adaptersByType.set(adapter.type, adapter); + } +} -export function getServerAdapter(type: string): ServerAdapterModule { - const adapter = adaptersByType.get(type); +registerBuiltInAdapters(); + +// --------------------------------------------------------------------------- +// Load external adapter plugins (e.g. droid_local) +// +// External adapter packages export createServerAdapter() which returns a +// ServerAdapterModule. The host fills in sessionManagement. +// --------------------------------------------------------------------------- + +/** 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) { + const overriding = BUILTIN_ADAPTER_TYPES.has(externalAdapter.type); + if (overriding) { + console.log( + `[paperclip] External adapter "${externalAdapter.type}" overrides built-in adapter`, + ); + // Save the original builtin for later restoration. + const existing = adaptersByType.get(externalAdapter.type); + if (existing && !builtinFallbacks.has(externalAdapter.type)) { + builtinFallbacks.set(externalAdapter.type, existing); + } + } + adaptersByType.set( + externalAdapter.type, + { + ...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 { + if (BUILTIN_ADAPTER_TYPES.has(adapter.type) && !builtinFallbacks.has(adapter.type)) { + const existing = adaptersByType.get(adapter.type); + if (existing) { + builtinFallbacks.set(adapter.type, existing); + } + } + adaptersByType.set(adapter.type, adapter); +} + +export function unregisterServerAdapter(type: string): void { + if (type === processAdapter.type || type === httpAdapter.type) return; + if (builtinFallbacks.has(type)) { + pausedOverrides.delete(type); + const fallback = builtinFallbacks.get(type); + if (fallback) { + adaptersByType.set(type, fallback); + } + return; + } + if (BUILTIN_ADAPTER_TYPES.has(type)) { + return; + } + adaptersByType.delete(type); +} + +export function requireServerAdapter(type: string): ServerAdapterModule { + const adapter = findActiveServerAdapter(type); if (!adapter) { - // 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 findActiveServerAdapter(type) ?? processAdapter; +} + export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> { - const adapter = adaptersByType.get(type); + const adapter = findActiveServerAdapter(type); if (!adapter) return []; if (adapter.listModels) { const discovered = await adapter.listModels(); @@ -226,15 +329,85 @@ 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> { - const adapter = adaptersByType.get(type); +): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> { + const adapter = findActiveServerAdapter(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 } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Override pause / resume +// --------------------------------------------------------------------------- + +/** + * Pause or resume an external override for a builtin adapter type. + * + * - `paused = true` → subsequent calls to `getServerAdapter(type)` return + * the builtin fallback instead of the external adapter. Already-running + * agent sessions are unaffected (they hold a reference to the module they + * started with). + * + * - `paused = false` → the external adapter is active again. + * + * Returns `true` if the state actually changed, `false` if the type is not + * an override or was already in the requested state. + */ +export function setOverridePaused(type: string, paused: boolean): boolean { + if (!builtinFallbacks.has(type)) return false; + const wasPaused = pausedOverrides.has(type); + if (paused && !wasPaused) { + pausedOverrides.add(type); + console.log(`[paperclip] Override paused for "${type}" — builtin adapter restored`); + return true; + } + if (!paused && wasPaused) { + pausedOverrides.delete(type); + console.log(`[paperclip] Override resumed for "${type}" — external adapter active`); + return true; + } + return false; +} + +/** Check whether the external override for a builtin type is currently paused. */ +export function isOverridePaused(type: string): boolean { + return pausedOverrides.has(type); +} + +/** Get the set of types whose overrides are currently paused. */ +export function getPausedOverrides(): Set { + return pausedOverrides; } export function findServerAdapter(type: string): ServerAdapterModule | null { return adaptersByType.get(type) ?? null; } + +export function findActiveServerAdapter(type: string): ServerAdapterModule | null { + if (pausedOverrides.has(type)) { + const fallback = builtinFallbacks.get(type); + if (fallback) return fallback; + } + return adaptersByType.get(type) ?? null; +} diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 7df54741..b8f32568 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -25,5 +25,8 @@ export type { NativeContextManagement, ResolvedSessionCompactionPolicy, SessionCompactionPolicy, + ConfigFieldOption, + ConfigFieldSchema, + AdapterConfigSchema, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/app.ts b/server/src/app.ts index 0aa099bf..686ecfde 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"; @@ -229,6 +230,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 cd618f73..4fe3769d 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 d5d9c805..b417f14c 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 00000000..27e32a06 --- /dev/null +++ b/server/src/routes/adapters.ts @@ -0,0 +1,643 @@ +/** + * @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, + findActiveServerAdapter, + listEnabledServerAdapters, + registerServerAdapter, + unregisterServerAdapter, + isOverridePaused, + setOverridePaused, +} 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, AdapterConfigSchema } 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"; +import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// 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; + /** True when an external plugin has replaced a built-in adapter of the same type. */ + overriddenBuiltin?: boolean; + /** True when the external override for a builtin type is currently paused. */ + overridePaused?: 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), + overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, + overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, + // 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), + ).sort((a, b) => a.type.localeCompare(b.type)); + + 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 }); + }); + + /** + * PATCH /api/adapters/:type/override + * + * Pause or resume an external adapter's override of a builtin type. + * When paused, the server returns the builtin adapter for all new requests + * (execute, listModels, config schema, etc.). Already-running sessions + * keep the adapter they started with. + */ + router.patch("/adapters/:type/override", async (req, res) => { + assertBoard(req); + + const adapterType = req.params.type; + const { paused } = req.body as { paused?: boolean }; + + if (typeof paused !== "boolean") { + res.status(400).json({ error: "\"paused\" (boolean) is required in request body." }); + return; + } + + if (!BUILTIN_ADAPTER_TYPES.has(adapterType)) { + res.status(400).json({ error: `Type "${adapterType}" is not a builtin adapter.` }); + return; + } + + const changed = setOverridePaused(adapterType, paused); + + logger.info({ type: adapterType, paused, changed }, "Adapter override toggle"); + + res.json({ type: adapterType, paused, 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 unless overridden by an external one + if (BUILTIN_ADAPTER_TYPES.has(type) && !getAdapterPluginByType(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); + configSchemaCache.delete(type); + + // 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) && !getAdapterPluginByType(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); + configSchemaCache.delete(type); + + // 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/config-schema ──────────────────────────────── + // Serve a declarative config schema for an adapter's UI form fields. + // The adapter's getConfigSchema() resolves all options (static and dynamic) + // so the UI receives a fully hydrated schema in a single fetch. + const configSchemaCache = new Map(); + const CONFIG_SCHEMA_TTL_MS = 30_000; + + router.get("/adapters/:type/config-schema", async (req, res) => { + assertBoard(req); + const { type } = req.params; + + const adapter = findActiveServerAdapter(type); + if (!adapter) { + res.status(404).json({ error: `Adapter "${type}" is not registered.` }); + return; + } + if (!adapter.getConfigSchema) { + res.status(404).json({ error: `Adapter "${type}" does not provide a config schema.` }); + return; + } + + const cached = configSchemaCache.get(type); + if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) { + res.json(cached.schema); + return; + } + + try { + const schema = await adapter.getConfigSchema(); + configSchemaCache.set(type, { schema, fetchedAt: Date.now() }); + res.json(schema); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err, type }, "Failed to resolve config schema"); + res.status(500).json({ error: `Failed to resolve config schema: ${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 68084040..f1c15b8d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -46,7 +46,13 @@ 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, + findActiveServerAdapter, + 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,7 +75,9 @@ export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", + droid_local: "instructionsFilePath", gemini_local: "instructionsFilePath", + hermes_local: "instructionsFilePath", opencode_local: "instructionsFilePath", cursor: "instructionsFilePath", pi_local: "instructionsFilePath", @@ -322,6 +330,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 +766,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 +774,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 +785,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; @@ -802,7 +821,7 @@ export function agentRoutes(db: Db) { } await assertCanReadConfigurations(req, agent.companyId); - const adapter = findServerAdapter(agent.adapterType); + const adapter = findActiveServerAdapter(agent.adapterType); if (!adapter?.listSkills) { const preference = readPaperclipSkillSyncPreference( agent.adapterConfig as Record, @@ -880,7 +899,7 @@ export function agentRoutes(db: Db) { return; } - const adapter = findServerAdapter(updated.adapterType); + const adapter = findActiveServerAdapter(updated.adapterType); const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( updated.companyId, updated.adapterConfig, @@ -1265,6 +1284,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 +1449,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 +1828,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 +1836,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 +1851,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 00000000..8c26abe8 --- /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/company-skills.ts b/server/src/services/company-skills.ts index fae77e5f..faea351c 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -27,7 +27,7 @@ import type { CompanySkillUsageAgent, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; -import { findServerAdapter } from "../adapters/index.js"; +import { findActiveServerAdapter } from "../adapters/index.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; @@ -1575,7 +1575,7 @@ export function companySkillService(db: Db) { return Promise.all( desiredAgents.map(async (agent) => { - const adapter = findServerAdapter(agent.adapterType); + const adapter = findActiveServerAdapter(agent.adapterType); let actualState: string | null = null; if (!adapter?.listSkills) { diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 4ef07047..441b0882 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 356783de..dc14bc99 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/hire-hook.ts b/server/src/services/hire-hook.ts index 6b6e22ce..79a38177 100644 --- a/server/src/services/hire-hook.ts +++ b/server/src/services/hire-hook.ts @@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents } from "@paperclipai/db"; import type { HireApprovedPayload } from "@paperclipai/adapter-utils"; -import { findServerAdapter } from "../adapters/registry.js"; +import { findActiveServerAdapter } from "../adapters/registry.js"; import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; @@ -40,7 +40,7 @@ export async function notifyHireApproved( } const adapterType = row.adapterType ?? "process"; - const adapter = findServerAdapter(adapterType); + const adapter = findActiveServerAdapter(adapterType); const onHireApproved = adapter?.onHireApproved; if (!onHireApproved) { return; diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 4d487552..22ccb017 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 3a989f38..9a5267db 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 f240defc..0bc4721b 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 00000000..fe809273 --- /dev/null +++ b/ui/src/adapters/adapter-display-registry.ts @@ -0,0 +1,157 @@ +/** + * 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"; +import { HermesIcon } from "@/components/HermesIcon"; + +// --------------------------------------------------------------------------- +// 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, + }, + hermes_local: { + label: "Hermes Agent", + description: "Local Hermes CLI agent", + icon: HermesIcon, + }, + 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 00000000..66de3e71 --- /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 00000000..aec2efa8 --- /dev/null +++ b/ui/src/adapters/dynamic-loader.ts @@ -0,0 +1,122 @@ +/** + * 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 { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types"; + +interface DynamicParserModule { + parseStdoutLine: StdoutLineParser; + createStdoutParser?: StdoutParserFactory; +} + +// 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 parserModule: DynamicParserModule; + + 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") { + const createStdoutParser = mod.createStdoutParser as StdoutParserFactory; + parserModule = { + createStdoutParser, + // Fallback for callers that only know about parseStdoutLine. + parseStdoutLine: + typeof mod.parseStdoutLine === "function" + ? (mod.parseStdoutLine as StdoutLineParser) + : ((line: string, ts: string) => { + const parser = createStdoutParser() as StatefulStdoutParser; + const entries = parser.parseLine(line, ts); + parser.reset(); + return entries; + }), + }; + } else if (typeof mod.parseStdoutLine === "function") { + parserModule = { + parseStdoutLine: 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, parserModule); + console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`); + return parserModule; + } 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 index 62b85fea..4b807043 100644 --- a/ui/src/adapters/hermes-local/config-fields.tsx +++ b/ui/src/adapters/hermes-local/config-fields.tsx @@ -1,49 +1,49 @@ -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" - /> - -
-
- ); -} +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 index 97c064f8..c037a747 100644 --- a/ui/src/adapters/hermes-local/index.ts +++ b/ui/src/adapters/hermes-local/index.ts @@ -1,12 +1,12 @@ -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, -}; +import type { UIAdapterModule } from "../types"; +import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; +import { SchemaConfigFields, buildSchemaAdapterConfig } from "../schema-config-fields"; +import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; + +export const hermesLocalUIAdapter: UIAdapterModule = { + type: "hermes_local", + label: "Hermes Agent", + parseStdoutLine: parseHermesStdoutLine, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, +}; diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index feb04511..5623cf8a 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -1,4 +1,12 @@ -export { getUIAdapter, listUIAdapters } from "./registry"; +export { + getUIAdapter, + listUIAdapters, + findUIAdapter, + registerUIAdapter, + unregisterUIAdapter, + syncExternalAdapters, + onAdapterChange, +} 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 00000000..70b7ef3c --- /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 00000000..297a7237 --- /dev/null +++ b/ui/src/adapters/metadata.ts @@ -0,0 +1,75 @@ +/** + * 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 { + // Check display registry first — built-in adapters like process/http are + // intentionally withheld even though they're registered as UI adapters. + if (getAdapterDisplay(type).comingSoon) return false; + // All other types (registered or external) are enabled. + return true; +} + +/** + * Check whether an adapter type is a valid choice for new agent creation. + * Includes all registered UI adapters (built-in + external) and + * any non-"coming soon" adapter from the display registry. + */ +export function isValidAdapterType(type: string): boolean { + if (getAdapterDisplay(type).comingSoon) return false; + return true; +} + +/** + * 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 00000000..6d30f6b0 --- /dev/null +++ b/ui/src/adapters/registry.test.ts @@ -0,0 +1,51 @@ +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"; +import { SchemaConfigFields } from "./schema-config-fields"; + +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 schema-based config fields for external adapter forms. + expect(fallback.ConfigFields).toBe(SchemaConfigFields); + }); +}); diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 67d89ada..e418457e 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,32 +3,256 @@ 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 { hermesLocalUIAdapter } from "./hermes-local"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; +import { loadDynamicParser, invalidateDynamicParser } from "./dynamic-loader"; +import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields"; -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]), -); +// Types registered at module load time — allowed to be overridden by +// external adapters that ship their own ui-parser.js via the server. +const builtinTypes = new Set(); + +// Original builtin adapters stored for restoration when external overrides +// are deactivated or removed. +const builtinAdaptersByType = new Map(); + +// Tracks which builtin types currently have an active external override. +const activeExternalOverrides = new Set(); + +// Generation counter to discard stale dynamic parser loads. When an override +// is deactivated while a load is in-flight, the generation is bumped and the +// stale result is discarded in its .then() handler. +const overrideGeneration = new Map(); + +// Subscriber list — components can register to be notified when adapters change +// (e.g., when a dynamic parser replaces a placeholder). +const adapterChangeListeners = new Set<() => void>(); + +/** Subscribe to adapter registry changes. Returns unsubscribe function. */ +export function onAdapterChange(fn: () => void): () => void { + adapterChangeListeners.add(fn); + return () => adapterChangeListeners.delete(fn); +} + +function notifyAdapterChange(): void { + for (const fn of adapterChangeListeners) fn(); +} + +function registerBuiltInUIAdapters() { + for (const adapter of [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + geminiLocalUIAdapter, + hermesLocalUIAdapter, + openCodeLocalUIAdapter, + piLocalUIAdapter, + cursorLocalUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, + ]) { + builtinTypes.add(adapter.type); + builtinAdaptersByType.set(adapter.type, adapter); + registerUIAdapter(adapter); + } +} + +export function registerUIAdapter(adapter: UIAdapterModule): void { + const existingIndex = uiAdapters.findIndex((entry) => entry.type === adapter.type); + if (existingIndex >= 0) { + uiAdapters.splice(existingIndex, 1, adapter); + } else { + uiAdapters.push(adapter); + } + adaptersByType.set(adapter.type, adapter); + notifyAdapterChange(); +} + +export function unregisterUIAdapter(type: string): void { + if (type === processUIAdapter.type || type === httpUIAdapter.type) return; + const existingIndex = uiAdapters.findIndex((entry) => entry.type === type); + if (existingIndex >= 0) { + uiAdapters.splice(existingIndex, 1); + } + adaptersByType.delete(type); +} + +export function findUIAdapter(type: string): UIAdapterModule | null { + return adaptersByType.get(type) ?? null; +} + +registerBuiltInUIAdapters(); export function getUIAdapter(type: string): UIAdapterModule { - return adaptersByType.get(type) ?? processUIAdapter; + const builtIn = adaptersByType.get(type); + + if (!builtIn) { + let loadStarted = false; + return { + type, + label: type, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(type).then((parserModule) => { + if (parserModule) { + registerUIAdapter({ + type, + label: type, + parseStdoutLine: parserModule.parseStdoutLine, + createStdoutParser: parserModule.createStdoutParser, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, + }); + } + }); + } + return processUIAdapter.parseStdoutLine(line, ts); + }, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, + }; + } + + return builtIn; +} + +/** + * Keep the UI adapter registry in sync with the server's adapter list. + * + * Two concerns: + * + * 1. **Builtin overrides** — when an external adapter ships a ui-parser.js for a + * builtin type, the external parser takes priority. When the external is + * disabled or removed the original builtin parser is restored transparently. + * A generation counter guards against stale loads that resolve after the + * override has been torn down. + * + * 2. **Non-builtin externals** — register a bridge adapter that lazily loads the + * dynamic parser on first stdout line, falling back to the generic process + * adapter. Once the parser resolves the bridge is replaced. + */ +export function syncExternalAdapters( + serverAdapters: { + type: string; + label: string; + disabled?: boolean; + /** When true, the external override for a builtin type is client-side paused. */ + overrideDisabled?: boolean; + }[], +): void { + const enabledExternalTypes = new Set( + serverAdapters.filter((a) => !a.disabled && !a.overrideDisabled).map((a) => a.type), + ); + const allExternalTypes = new Set( + serverAdapters.map((a) => a.type), + ); + + // ── Builtin override lifecycle ────────────────────────────────────────── + + for (const builtinType of builtinTypes) { + const originalBuiltin = builtinAdaptersByType.get(builtinType); + if (!originalBuiltin) continue; + + const hasExternal = allExternalTypes.has(builtinType); + const externalEnabled = enabledExternalTypes.has(builtinType); + const wasOverridden = activeExternalOverrides.has(builtinType); + + if (hasExternal && externalEnabled && !wasOverridden) { + // Activate: external just became active → replace builtin with bridge. + activeExternalOverrides.add(builtinType); + + const gen = (overrideGeneration.get(builtinType) ?? 0) + 1; + overrideGeneration.set(builtinType, gen); + + let loadStarted = false; + const fallbackParser = originalBuiltin.parseStdoutLine; + const externalEntry = serverAdapters.find((a) => a.type === builtinType); + const label = externalEntry?.label ?? builtinType; + + registerUIAdapter({ + type: builtinType, + label, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(builtinType).then((parserModule) => { + // Discard if the override was torn down while the load was in-flight. + if (parserModule && overrideGeneration.get(builtinType) === gen) { + registerUIAdapter({ + type: builtinType, + label, + parseStdoutLine: parserModule.parseStdoutLine, + createStdoutParser: parserModule.createStdoutParser, + ConfigFields: originalBuiltin.ConfigFields, + buildAdapterConfig: originalBuiltin.buildAdapterConfig, + }); + } + }); + } + return fallbackParser(line, ts); + }, + ConfigFields: originalBuiltin.ConfigFields, + buildAdapterConfig: originalBuiltin.buildAdapterConfig, + }); + } else if ((!hasExternal || !externalEnabled) && wasOverridden) { + // Deactivate: external disabled or removed → restore builtin. + activeExternalOverrides.delete(builtinType); + overrideGeneration.delete(builtinType); + invalidateDynamicParser(builtinType); + registerUIAdapter(originalBuiltin); + } + } + + // ── Non-builtin externals ─────────────────────────────────────────────── + + for (const { type, label } of serverAdapters) { + if (builtinTypes.has(type)) continue; // handled above + + const existing = adaptersByType.get(type); + + // If this type already has an externally-loaded dynamic parser, skip — + // it was loaded from disk on a previous sync. Only re-trigger loading + // when the server returns a new external adapter that hasn't been loaded yet. + if (existing && existing !== processUIAdapter) continue; + + let loadStarted = false; + // Use the existing built-in parser as fallback (if any) so we don't + // regress to the generic process parser while the dynamic one loads. + const fallbackParser = existing?.parseStdoutLine ?? processUIAdapter.parseStdoutLine; + + registerUIAdapter({ + type, + label, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(type).then((parserModule) => { + if (parserModule) { + registerUIAdapter({ + type, + label, + parseStdoutLine: parserModule.parseStdoutLine, + createStdoutParser: parserModule.createStdoutParser, + ConfigFields: existing?.ConfigFields ?? SchemaConfigFields, + buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig, + }); + } + }); + } + return fallbackParser(line, ts); + }, + ConfigFields: existing?.ConfigFields ?? SchemaConfigFields, + buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig, + }); + } } export function listUIAdapters(): UIAdapterModule[] { diff --git a/ui/src/adapters/schema-config-fields.tsx b/ui/src/adapters/schema-config-fields.tsx new file mode 100644 index 00000000..7161f9e0 --- /dev/null +++ b/ui/src/adapters/schema-config-fields.tsx @@ -0,0 +1,507 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils"; + +import type { AdapterConfigFieldsProps } from "./types"; +import { + Field, + DraftInput, + DraftNumberInput, + DraftTextarea, + ToggleField, +} from "../components/agent-config-primitives"; +import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover"; +import { ChevronDown } from "lucide-react"; + +// ── Select field (extracted to keep hooks at component top level) ────── +function SelectField({ + value, + options, + onChange, +}: { + value: string; + options: Array<{ value: string; label: string }>; + onChange: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const selectedOpt = options.find((o) => o.value === value); + return ( + + + + + + {options.map((opt) => ( + + ))} + + + ); +} +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"; + + +// --------------------------------------------------------------------------- +// Combobox: type-to-filter dropdown with free text fallback +// --------------------------------------------------------------------------- + +function ComboboxField({ + value, + options, + onChange, + placeholder, +}: { + value: string; + options: { label: string; value: string; group?: string }[]; + onChange: (val: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const inputRef = useRef(null); + + // Sync filter with external value when it changes (e.g. provider switch resets model) + useEffect(() => { + setFilter(""); + }, [value]); + + const filtered = options.filter((opt) => { + if (!filter) return true; + const q = filter.toLowerCase(); + return ( + opt.value.toLowerCase().includes(q) || + opt.label.toLowerCase().includes(q) || + (opt.group && opt.group.toLowerCase().includes(q)) + ); + }); + + const selectedOpt = options.find((o) => o.value === value); + const displayValue = filter || selectedOpt?.value || value || ""; + + // Group filtered options by `group` field if present + const grouped = new Map(); + for (const opt of filtered) { + const g = opt.group ?? ""; + if (!grouped.has(g)) grouped.set(g, []); + grouped.get(g)!.push(opt); + } + + const select = useCallback( + (val: string) => { + onChange(val); + setOpen(false); + setFilter(""); + inputRef.current?.blur(); + }, + [onChange], + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + // If exactly one match, select it. Otherwise commit the typed value. + if (filtered.length === 1) { + select(filtered[0].value); + } else if (filter) { + select(filter); + } + } else if (e.key === "Escape") { + setOpen(false); + setFilter(""); + } else if (e.key === "ArrowDown" && !open) { + e.preventDefault(); + setOpen(true); + } + }; + + return ( +
+
+ { + setFilter(e.target.value); + if (!open) setOpen(true); + }} + onFocus={() => { + if (!open) setOpen(true); + }} + onBlur={() => { + // Delay close to allow click on option to register + setTimeout(() => setOpen(false), 150); + }} + onKeyDown={handleKeyDown} + /> + 0} onOpenChange={setOpen}> + + + + e.preventDefault()} + > + {Array.from(grouped.entries()).map(([group, opts]) => ( +
+ {group && ( +
+ {group} +
+ )} + {opts.map((opt) => ( + + ))} +
+ ))} + {filter && filtered.length === 0 && ( +
+ Use "{filter}" as custom value (press Enter) +
+ )} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// SchemaConfigFields component +// --------------------------------------------------------------------------- + +const schemaCache = new Map(); +const schemaFetchInflight = new Map>(); +const failedSchemaTypes = new Set(); + +async function fetchConfigSchema(adapterType: string): Promise { + const cached = schemaCache.get(adapterType); + if (cached !== undefined) return cached; + if (failedSchemaTypes.has(adapterType)) return null; + + const inflight = schemaFetchInflight.get(adapterType); + if (inflight) return inflight; + + const promise = (async () => { + try { + const res = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/config-schema`); + if (!res.ok) { + failedSchemaTypes.add(adapterType); + return null; + } + const schema = (await res.json()) as AdapterConfigSchema; + schemaCache.set(adapterType, schema); + return schema; + } catch { + failedSchemaTypes.add(adapterType); + return null; + } finally { + schemaFetchInflight.delete(adapterType); + } + })(); + + schemaFetchInflight.set(adapterType, promise); + return promise; +} + +export function invalidateConfigSchemaCache(adapterType: string): void { + schemaCache.delete(adapterType); + failedSchemaTypes.delete(adapterType); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +function useConfigSchema(adapterType: string): AdapterConfigSchema | null { + const [schema, setSchema] = useState( + schemaCache.get(adapterType) ?? null, + ); + + useEffect(() => { + let cancelled = false; + fetchConfigSchema(adapterType).then((s) => { + if (!cancelled) setSchema(s); + }); + return () => { + cancelled = true; + }; + }, [adapterType]); + + return schema; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getDefaultValue(field: ConfigFieldSchema): unknown { + if (field.default !== undefined) return field.default; + switch (field.type) { + case "toggle": + return false; + case "number": + return 0; + case "text": + case "textarea": + return ""; + case "select": + return field.options?.[0]?.value ?? ""; + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function SchemaConfigFields({ + adapterType, + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + const schema = useConfigSchema(adapterType); + + const [defaultsApplied, setDefaultsApplied] = useState(false); + useEffect(() => { + if (!schema || !isCreate || defaultsApplied) return; + const defaults: Record = {}; + for (const field of schema.fields) { + const def = getDefaultValue(field); + if (def !== undefined && def !== "") { + defaults[field.key] = def; + } + } + if (Object.keys(defaults).length > 0) { + set?.({ + adapterSchemaValues: { ...values?.adapterSchemaValues, ...defaults }, + }); + } + setDefaultsApplied(true); + }, [schema, isCreate, defaultsApplied, set, values?.adapterSchemaValues]); + + if (!schema || schema.fields.length === 0) return null; + + function readValue(field: ConfigFieldSchema): unknown { + if (isCreate) { + return values?.adapterSchemaValues?.[field.key] ?? getDefaultValue(field); + } + const stored = config[field.key]; + return eff("adapterConfig", field.key, (stored ?? getDefaultValue(field)) as string); + } + + function writeValue(field: ConfigFieldSchema, value: unknown): void { + if (isCreate) { + const next = { + adapterSchemaValues: { + ...values?.adapterSchemaValues, + [field.key]: value, + }, + }; + + // When provider changes, auto-clear model if it's not in the new provider's list + if (field.key === "provider" && schema) { + const modelField = schema.fields.find((f) => f.key === "model"); + if (modelField?.meta?.providerModels) { + const modelsByProvider = modelField.meta.providerModels as Record; + const providerModels = modelsByProvider[String(value)] ?? []; + const currentModel = values?.adapterSchemaValues?.model; + if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) { + next.adapterSchemaValues.model = ""; + } + } + } + + set?.(next); + } else { + mark("adapterConfig", field.key, value); + + // Same logic for edit mode + if (field.key === "provider" && schema) { + const modelField = schema.fields.find((f) => f.key === "model"); + if (modelField?.meta?.providerModels) { + const modelsByProvider = modelField.meta.providerModels as Record; + const providerModels = modelsByProvider[String(value)] ?? []; + const currentModel = eff("adapterConfig", "model", ""); + if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) { + mark("adapterConfig", "model", ""); + } + } + } + } + } + + return ( + <> + {schema.fields.map((field) => { + switch (field.type) { + case "select": { + const currentVal = String(readValue(field) ?? ""); + return ( + + writeValue(field, v)} + /> + + ); + } + + case "toggle": + return ( + writeValue(field, v)} + /> + ); + + case "number": + return ( + + writeValue(field, v)} + immediate + className={inputClass} + /> + + ); + + case "textarea": + return ( + + writeValue(field, v || undefined)} + immediate + /> + + ); + + case "combobox": { + const currentVal = String(readValue(field) ?? ""); + // Dynamic options: if meta.providerModels exists, compute options + // based on the current provider value + let comboboxOptions = field.options ?? []; + if (field.meta?.providerModels) { + const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto"); + const modelsByProvider = field.meta.providerModels as Record; + if (providerVal === "auto") { + // Auto: show all models from all providers, grouped by provider + const providerLabel = schema.fields.find((f) => f.key === "provider"); + const providerOptions = providerLabel?.options ?? []; + comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) => + models.map((m) => ({ + label: m, + value: m, + group: providerOptions.find((p) => p.value === prov)?.label ?? prov, + })), + ); + } else { + const providerModels = modelsByProvider[providerVal] ?? []; + const providerLabel = schema.fields.find((f) => f.key === "provider"); + const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal; + comboboxOptions = providerModels.map((m) => ({ + label: m, + value: m, + group: provName, + })); + } + } + return ( + + writeValue(field, v || undefined)} + placeholder={field.hint} + /> + + ); + } + + case "text": + default: + return ( + + writeValue(field, v || undefined)} + immediate + className={inputClass} + /> + + ); + } + })} + + ); +} + +// --------------------------------------------------------------------------- +// Build adapter config from schema values + standard CreateConfigValues fields +// --------------------------------------------------------------------------- + +export function buildSchemaAdapterConfig( + values: CreateConfigValues, +): Record { + const ac: Record = {}; + + if (values.model?.trim()) ac.model = values.model.trim(); + if (values.cwd) ac.cwd = values.cwd; + if (values.command) ac.command = values.command; + if (values.instructionsFilePath) ac.instructionsFilePath = values.instructionsFilePath; + if (values.thinkingEffort) ac.thinkingEffort = values.thinkingEffort; + + if (values.extraArgs) { + ac.extraArgs = values.extraArgs + .split(/\s+/) + .filter(Boolean); + } + + if (values.adapterSchemaValues) { + Object.assign(ac, values.adapterSchemaValues); + } + + return ac; +} diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index 8b56163e..c33c9008 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildTranscript, type RunLogChunk } from "./transcript"; +import type { UIAdapterModule } from "./types"; describe("buildTranscript", () => { const ts = "2026-03-20T13:00:00.000Z"; @@ -27,4 +28,46 @@ describe("buildTranscript", () => { { kind: "stderr", ts, text: "stderr /Users/d****/project" }, ]); }); + + it("creates a fresh stateful parser for each transcript build", () => { + const statefulAdapter: UIAdapterModule = { + type: "stateful_test", + label: "Stateful Test", + parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }], + createStdoutParser: () => { + let pending: string | null = null; + return { + parseLine: (line, entryTs) => { + if (line.startsWith("begin:")) { + pending = line.slice("begin:".length); + return []; + } + if (line === "finish" && pending) { + const text = `completed:${pending}`; + pending = null; + return [{ kind: "stdout", ts: entryTs, text }]; + } + return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }]; + }, + reset: () => { + pending = null; + }, + }; + }, + ConfigFields: () => null, + buildAdapterConfig: () => ({}), + }; + + const first = buildTranscript( + [{ ts, stream: "stdout", chunk: "begin:task-a\n" }], + statefulAdapter, + ); + const second = buildTranscript( + [{ ts, stream: "stdout", chunk: "finish\n" }], + statefulAdapter, + ); + + expect(first).toEqual([]); + expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]); + }); }); diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 98b19454..307aa5ae 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -1,9 +1,20 @@ import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils"; -import type { TranscriptEntry, StdoutLineParser } from "./types"; +import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types"; export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; type TranscriptBuildOptions = { censorUsernameInLogs?: boolean }; +function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) { + if (typeof source === "function") { + return { parseLine: source, reset: null as (() => void) | null }; + } + if (source.createStdoutParser) { + const parser = source.createStdoutParser(); + return { parseLine: parser.parseLine, reset: parser.reset }; + } + return { parseLine: source.parseStdoutLine, reset: null as (() => void) | null }; +} + export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) { const last = entries[entries.length - 1]; @@ -24,12 +35,13 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr export function buildTranscript( chunks: RunLogChunk[], - parser: StdoutLineParser, + parserSource: StdoutLineParser | TranscriptParserSource, opts?: TranscriptBuildOptions, ): TranscriptEntry[] { const entries: TranscriptEntry[] = []; let stdoutBuffer = ""; const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false }; + const { parseLine, reset } = resolveStdoutParser(parserSource); for (const chunk of chunks) { if (chunk.stream === "stderr") { @@ -47,15 +59,17 @@ export function buildTranscript( for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); + appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); } } const trailing = stdoutBuffer.trim(); if (trailing) { const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); - appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); + appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); } + reset?.(); + return entries; } diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index 6a7ae48a..74bd48e5 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -4,6 +4,18 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils"; // Re-export shared types so local consumers don't need to change imports export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils"; +export interface StatefulStdoutParser { + parseLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[]; + reset: () => void; +} + +export type StdoutParserFactory = () => StatefulStdoutParser; + +export interface TranscriptParserSource { + parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[]; + createStdoutParser?: StdoutParserFactory; +} + export interface AdapterConfigFieldsProps { mode: "create" | "edit"; isCreate: boolean; @@ -24,10 +36,9 @@ export interface AdapterConfigFieldsProps { hideInstructionsFile?: boolean; } -export interface UIAdapterModule { +export interface UIAdapterModule extends TranscriptParserSource { type: string; label: string; - parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[]; ConfigFields: ComponentType; buildAdapterConfig: (values: CreateConfigValues) => Record; } diff --git a/ui/src/adapters/use-disabled-adapters.ts b/ui/src/adapters/use-disabled-adapters.ts new file mode 100644 index 00000000..ebc63946 --- /dev/null +++ b/ui/src/adapters/use-disabled-adapters.ts @@ -0,0 +1,54 @@ +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, + disabled: a.disabled, + overrideDisabled: a.overridePaused, + })), + ); + } + + // 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 00000000..86705bd4 --- /dev/null +++ b/ui/src/api/adapters.ts @@ -0,0 +1,59 @@ +/** + * @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; + /** True when an external plugin has replaced a built-in adapter of the same type. */ + overriddenBuiltin?: boolean; + /** True when the external override for a builtin type is currently paused. */ + overridePaused?: 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 }), + + /** Pause or resume an external override of a builtin type. */ + setOverridePaused: (type: string, paused: boolean) => + api.patch<{ type: string; paused: boolean; changed: boolean }>(`/adapters/${type}/override`, { paused }), + + /** 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 bda8bf7a..fcd38604 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 c3c9bdfa..6b4f761b 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 @@ -692,8 +693,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {/* Adapter-specific fields */} - + {/* Adapter-specific fields are rendered inside Permissions & Configuration */} @@ -716,24 +716,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 +743,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 && (

@@ -820,6 +815,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {adapterType === "claude_local" && ( )} + 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 dbd8381b..076b9702 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 aaaf7c6d..4ff672c6 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 && (
+ {/* Adapter type · provider · model */} + {(() => { + const displayProvider = metrics.provider + ?? asNonEmptyString(adapterConfig?.provider); + const displayModel = metrics.model + ?? asNonEmptyString(adapterConfig?.model); + if (!adapterType && !displayProvider && !displayModel) return null; + return ( +
+ {adapterType && ( + {adapterType.replace(/_/g, " ")} + )} + {displayProvider && displayModel && ( + {displayProvider}/{displayModel} + )} + {!displayProvider && displayModel && ( + {displayModel} + )} +
+ ); + })()} {resumeRun.isError && (
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"} @@ -3670,10 +3790,20 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs); }, [censorUsernameInLogs, events]); - const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); + // NOTE: adapter is NOT memoized because external adapters replace their + // parseStdoutLine asynchronously after dynamic parser loading. Memoizing + // on adapterType alone would stale the transcript with the fallback parser. + // We subscribe to adapter registry changes to force transcript recomputation. + const [parserTick, setParserTick] = useState(0); + const adapter = getUIAdapter(adapterType); + + useEffect(() => { + return onAdapterChange(() => setParserTick((t) => t + 1)); + }, []); + const transcript = useMemo( - () => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }), - [adapter, censorUsernameInLogs, logLines], + () => buildTranscript(logLines, adapter, { censorUsernameInLogs }), + [adapter, censorUsernameInLogs, logLines, parserTick], ); useEffect(() => { @@ -3707,68 +3837,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin censorUsernameInLogs={censorUsernameInLogs} /> {adapterInvokePayload && ( -
-
Invocation
- {typeof adapterInvokePayload.adapterType === "string" && ( -
Adapter: {adapterInvokePayload.adapterType}
- )} - {typeof adapterInvokePayload.cwd === "string" && ( -
Working dir: {adapterInvokePayload.cwd}
- )} - {typeof adapterInvokePayload.command === "string" && ( -
- Command: - - {[ - adapterInvokePayload.command, - ...(Array.isArray(adapterInvokePayload.commandArgs) - ? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string") - : []), - ].join(" ")} - -
- )} - {Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && ( -
-
Command notes
-
    - {adapterInvokePayload.commandNotes - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) - .map((note, idx) => ( -
  • - {note} -
  • - ))} -
-
- )} - {adapterInvokePayload.prompt !== undefined && ( -
-
Prompt
-
-                {typeof adapterInvokePayload.prompt === "string"
-                  ? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
-                  : JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
-              
-
- )} - {adapterInvokePayload.context !== undefined && ( -
-
Context
-
-                {JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
-              
-
- )} - {adapterInvokePayload.env !== undefined && ( -
-
Environment
-
-                {formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
-              
-
- )} -
+ )}
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index a157f777..fc75cbd8 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -20,17 +20,7 @@ import { Button } from "@/components/ui/button"; import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; -const adapterLabels: Record = { - claude_local: "Claude", - codex_local: "Codex", - gemini_local: "Gemini", - opencode_local: "OpenCode", - cursor: "Cursor", - hermes_local: "Hermes", - openclaw_gateway: "OpenClaw Gateway", - process: "Process", - http: "HTTP", -}; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; const roleLabels = AGENT_ROLE_LABELS as Record; @@ -263,7 +253,7 @@ export function Agents() { /> )} - {adapterLabels[agent.adapterType] ?? agent.adapterType} + {getAdapterLabel(agent.adapterType)} {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} @@ -364,7 +354,7 @@ function OrgTreeNode({ {agent && ( <> - {adapterLabels[agent.adapterType] ?? agent.adapterType} + {getAdapterLabel(agent.adapterType)} {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 60765138..a4ab82b5 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -31,6 +31,7 @@ import { Upload, } from "lucide-react"; import { Field, adapterLabels } from "../components/agent-config-primitives"; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { defaultCreateValues } from "../components/agent-config-defaults"; import { getUIAdapter, listUIAdapters } from "../adapters"; import type { CreateConfigValues } from "@paperclipai/adapter-utils"; @@ -514,7 +515,7 @@ function ConflictResolutionList({ const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapters().map((adapter) => ({ value: adapter.type, - label: adapterLabels[adapter.type] ?? adapter.label, + label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type), })); // ── Adapter picker for imported agents ─────────────────────────────── diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 6d412aa8..c2b0fe02 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -12,20 +12,9 @@ import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared"; type JoinType = "human" | "agent"; const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES]; -const adapterLabels: Record = { - claude_local: "Claude (local)", - codex_local: "Codex (local)", - gemini_local: "Gemini CLI (local)", - opencode_local: "OpenCode (local)", - pi_local: "Pi (local)", - openclaw_gateway: "OpenClaw Gateway", - cursor: "Cursor (local)", - hermes_local: "Hermes Agent", - process: "Process", - http: "HTTP", -}; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; -const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); +const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); function dateTime(value: string) { return new Date(value).toLocaleString(); @@ -279,7 +268,7 @@ export function InviteLandingPage() { > {joinAdapterOptions.map((type) => ( ))} diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index 69415db6..9b1dd12c 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -19,7 +19,9 @@ import { cn, agentUrl } from "../lib/utils"; import { roleLabels } from "../components/agent-config-primitives"; import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm"; import { defaultCreateValues } from "../components/agent-config-defaults"; -import { getUIAdapter } from "../adapters"; +import { getUIAdapter, listUIAdapters } from "../adapters"; +import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; +import { isValidAdapterType } from "../adapters/metadata"; import { ReportsToPicker } from "../components/ReportsToPicker"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, @@ -28,17 +30,6 @@ import { import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; -const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set([ - "claude_local", - "codex_local", - "gemini_local", - "opencode_local", - "pi_local", - "cursor", - "hermes_local", - "openclaw_gateway", -]); - function createValuesForAdapterType( adapterType: CreateConfigValues["adapterType"], ): CreateConfigValues { @@ -120,9 +111,7 @@ export function NewAgent() { useEffect(() => { const requested = presetAdapterType; if (!requested) return; - if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) { - return; - } + if (!isValidAdapterType(requested)) return; setConfigValues((prev) => { if (prev.adapterType === requested) return prev; return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 88d0caab..fdfd6b90 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -116,17 +116,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L // ── Status dot colors (raw hex for SVG) ───────────────────────────────── -const adapterLabels: Record = { - claude_local: "Claude", - codex_local: "Codex", - gemini_local: "Gemini", - opencode_local: "OpenCode", - cursor: "Cursor", - hermes_local: "Hermes", - openclaw_gateway: "OpenClaw Gateway", - process: "Process", - http: "HTTP", -}; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; const statusDotColor: Record = { running: "#22d3ee", @@ -426,7 +416,7 @@ export function OrgChart() { {agent && ( - {adapterLabels[agent.adapterType] ?? agent.adapterType} + {getAdapterLabel(agent.adapterType)} )} {agent && agent.capabilities && ( diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 56d8a4db..395eebb9 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -13,6 +13,8 @@ export default defineConfig({ }, server: { port: 5173, + // WSL2 /mnt/ drives don't support inotify — fall back to polling so HMR works + watch: process.cwd().startsWith("/mnt/") ? { usePolling: true, interval: 1000 } : undefined, proxy: { "/api": { target: "http://localhost:3100",