forked from farhoodlabs/paperclip
Merge pull request #2218 from HenkDz/feat/external-adapter-phase1
feat(adapters): external adapter plugin system with dynamic UI parser
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
</Tip>
|
||||
|
||||
## 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/<name>/
|
||||
packages/adapters/<name>/ # 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<string, unknown> | null };
|
||||
config: Record<string, unknown>; // agent's adapterConfig
|
||||
context: Record<string, unknown>; // task, wake reason, etc.
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### AdapterExecutionResult
|
||||
|
||||
```ts
|
||||
interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
errorMessage?: string | null;
|
||||
usage?: { inputTokens: number; outputTokens: number };
|
||||
sessionParams?: Record<string, unknown> | 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<AdapterEnvironmentTestResult> {
|
||||
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/<name>/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
|
||||
|
||||
@@ -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<AdapterExecutionResult> {
|
||||
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<AdapterEnvironmentTestResult> {
|
||||
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 <token>" \
|
||||
-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 <token>" \
|
||||
-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<string, unknown>;
|
||||
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
|
||||
+39
-15
@@ -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/<name>/
|
||||
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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"adapters/codex-local",
|
||||
"adapters/process",
|
||||
"adapters/http",
|
||||
"adapters/external-adapters",
|
||||
"adapters/adapter-ui-parser",
|
||||
"adapters/creating-an-adapter"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export type {
|
||||
AdapterModel,
|
||||
HireApprovedPayload,
|
||||
HireApprovedHookResult,
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
ServerAdapterModule,
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, AdapterSessionManagement
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
hermes_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
},
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdapterConfigSchema {
|
||||
fields: ConfigFieldSchema[];
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
@@ -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> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -317,7 +317,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const effort = asString(config.effort, "");
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
const commandNotes = instructionsFilePath
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function testEnvironment(
|
||||
const effort = asString(config.effort, "").trim();
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -909,7 +909,12 @@ export interface PluginIssuesClient {
|
||||
companyId: string,
|
||||
): Promise<Issue>;
|
||||
listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
|
||||
createComment(issueId: string, body: string, companyId: string): Promise<IssueComment>;
|
||||
createComment(
|
||||
issueId: string,
|
||||
body: string,
|
||||
companyId: string,
|
||||
options?: { authorAgentId?: string },
|
||||
): Promise<IssueComment>;
|
||||
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
|
||||
documents: PluginIssueDocumentsClient;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export {
|
||||
COMPANY_STATUSES,
|
||||
DEPLOYMENT_MODES,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
setPrincipalPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanySkillService = vi.hoisted(() => ({
|
||||
listRuntimeSkillEntries: vi.fn(),
|
||||
resolveRequestedSkillKeys: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
|
||||
}));
|
||||
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
materializeManagedBundle: vi.fn(),
|
||||
getBundle: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
updateBundle: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
exportFiles: vi.fn(),
|
||||
ensureManagedBundle: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
cancelActiveForAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
linkManyForApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
|
||||
const externalAdapter: ServerAdapterModule = {
|
||||
type: "external_test",
|
||||
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "external_test",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
};
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("agent routes adapter validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
unregisterServerAdapter("external_test");
|
||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
name: String(input.name ?? "Agent"),
|
||||
urlKey: "agent",
|
||||
role: String(input.role ?? "general"),
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: String(input.adapterType ?? "process"),
|
||||
adapterConfig: (input.adapterConfig as Record<string, unknown> | undefined) ?? {},
|
||||
runtimeConfig: (input.runtimeConfig as Record<string, unknown> | undefined) ?? {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterServerAdapter("external_test");
|
||||
});
|
||||
|
||||
it("creates agents for dynamically registered external adapter types", async () => {
|
||||
registerServerAdapter(externalAdapter);
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "External Agent",
|
||||
adapterType: "external_test",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body.adapterType).toBe("external_test");
|
||||
});
|
||||
|
||||
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Missing Adapter",
|
||||
adapterType: "missing_adapter",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> }): 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);
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
export function getUiParserSource(adapterType: string): string | undefined {
|
||||
return uiParserCache.get(adapterType);
|
||||
}
|
||||
|
||||
/**
|
||||
* On cache miss, attempt on-demand extraction from the plugin store.
|
||||
* Makes the ui-parser.js endpoint self-healing.
|
||||
*/
|
||||
export function getOrExtractUiParserSource(adapterType: string): string | undefined {
|
||||
const cached = uiParserCache.get(adapterType);
|
||||
if (cached) return cached;
|
||||
|
||||
const record = getAdapterPluginByType(adapterType);
|
||||
if (!record) return undefined;
|
||||
|
||||
const packageDir = resolvePackageDir(record);
|
||||
const source = extractUiParserSource(packageDir, record.packageName);
|
||||
if (source) {
|
||||
uiParserCache.set(adapterType, source);
|
||||
logger.info(
|
||||
{ type: adapterType, packageName: record.packageName, origin: "lazy" },
|
||||
"UI parser extracted on-demand (cache miss)",
|
||||
);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolvePackageDir(record: Pick<AdapterPluginRecord, "localPath" | "packageName">): string {
|
||||
return record.localPath
|
||||
? path.resolve(record.localPath)
|
||||
: path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName);
|
||||
}
|
||||
|
||||
function resolvePackageEntryPoint(packageDir: string): string {
|
||||
const pkgJsonPath = path.join(packageDir, "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
||||
|
||||
if (pkg.exports && typeof pkg.exports === "object" && pkg.exports["."]) {
|
||||
const exp = pkg.exports["."];
|
||||
return typeof exp === "string" ? exp : (exp.import ?? exp.default ?? "index.js");
|
||||
}
|
||||
return pkg.main ?? "index.js";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI parser extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUPPORTED_PARSER_CONTRACT = "1";
|
||||
|
||||
function extractUiParserSource(
|
||||
packageDir: string,
|
||||
packageName: string,
|
||||
): string | undefined {
|
||||
const pkgJsonPath = path.join(packageDir, "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
||||
|
||||
if (!pkg.exports || typeof pkg.exports !== "object" || !pkg.exports["./ui-parser"]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contractVersion = pkg.paperclip?.adapterUiParser;
|
||||
if (contractVersion) {
|
||||
const major = contractVersion.split(".")[0];
|
||||
if (major !== SUPPORTED_PARSER_CONTRACT) {
|
||||
logger.warn(
|
||||
{ packageName, contractVersion, supported: `${SUPPORTED_PARSER_CONTRACT}.x` },
|
||||
"Adapter declares unsupported UI parser contract version — skipping UI parser",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
{ packageName },
|
||||
"Adapter has ./ui-parser export but no paperclip.adapterUiParser version — loading anyway (future versions may require it)",
|
||||
);
|
||||
}
|
||||
|
||||
const uiParserExp = pkg.exports["./ui-parser"];
|
||||
const uiParserFile = typeof uiParserExp === "string"
|
||||
? uiParserExp
|
||||
: (uiParserExp.import ?? uiParserExp.default);
|
||||
const uiParserPath = path.resolve(packageDir, uiParserFile);
|
||||
|
||||
if (!uiParserPath.startsWith(packageDir + path.sep) && uiParserPath !== packageDir) {
|
||||
logger.warn(
|
||||
{ packageName, uiParserFile },
|
||||
"UI parser path escapes package directory — skipping",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(uiParserPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = fs.readFileSync(uiParserPath, "utf-8");
|
||||
logger.info(
|
||||
{ packageName, uiParserFile, size: source.length },
|
||||
`Loaded UI parser from adapter package${contractVersion ? "" : " (no version declared)"}`,
|
||||
);
|
||||
return source;
|
||||
} catch (err) {
|
||||
logger.warn({ err, packageName, uiParserFile }, "Failed to read UI parser from adapter package");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load / reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule {
|
||||
const m = mod as Record<string, unknown>;
|
||||
const createServerAdapter = m.createServerAdapter;
|
||||
if (typeof createServerAdapter !== "function") {
|
||||
throw new Error(
|
||||
`Package "${packageName}" does not export createServerAdapter(). ` +
|
||||
`Ensure the package's main entry exports a createServerAdapter function.`,
|
||||
);
|
||||
}
|
||||
|
||||
const adapterModule = createServerAdapter() as ServerAdapterModule;
|
||||
if (!adapterModule || !adapterModule.type) {
|
||||
throw new Error(
|
||||
`createServerAdapter() from "${packageName}" returned an invalid module (missing "type").`,
|
||||
);
|
||||
}
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
export async function loadExternalAdapterPackage(
|
||||
packageName: string,
|
||||
localPath?: string,
|
||||
): Promise<ServerAdapterModule> {
|
||||
const packageDir = localPath
|
||||
? path.resolve(localPath)
|
||||
: path.resolve(getAdapterPluginsDir(), "node_modules", packageName);
|
||||
|
||||
const entryPoint = resolvePackageEntryPoint(packageDir);
|
||||
const modulePath = path.resolve(packageDir, entryPoint);
|
||||
const uiParserSource = extractUiParserSource(packageDir, packageName);
|
||||
|
||||
logger.info({ packageName, packageDir, entryPoint, modulePath, hasUiParser: !!uiParserSource }, "Loading external adapter package");
|
||||
|
||||
const mod = await import(modulePath);
|
||||
const adapterModule = validateAdapterModule(mod, packageName);
|
||||
|
||||
if (uiParserSource) {
|
||||
uiParserCache.set(adapterModule.type, uiParserSource);
|
||||
}
|
||||
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
async function loadFromRecord(record: AdapterPluginRecord): Promise<ServerAdapterModule | null> {
|
||||
try {
|
||||
return await loadExternalAdapterPackage(record.packageName, record.localPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, packageName: record.packageName, type: record.type },
|
||||
"Failed to dynamically load external adapter; skipping",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload an external adapter at runtime (dev iteration without server restart).
|
||||
* Busts the ESM module cache via a cache-busting query string.
|
||||
*/
|
||||
export async function reloadExternalAdapter(
|
||||
type: string,
|
||||
): Promise<ServerAdapterModule | null> {
|
||||
const record = getAdapterPluginByType(type);
|
||||
if (!record) return null;
|
||||
|
||||
const packageDir = resolvePackageDir(record);
|
||||
const entryPoint = resolvePackageEntryPoint(packageDir);
|
||||
const modulePath = path.resolve(packageDir, entryPoint);
|
||||
const 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<string, unknown> | 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<ServerAdapterModule[]> {
|
||||
const results: ServerAdapterModule[] = [];
|
||||
|
||||
const storeRecords = listAdapterPlugins();
|
||||
for (const record of storeRecords) {
|
||||
const adapter = await loadFromRecord(record);
|
||||
if (adapter) {
|
||||
results.push(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
logger.info(
|
||||
{ count: results.length, adapters: results.map((a) => a.type) },
|
||||
"Loaded external adapters from plugin store",
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
+185
-12
@@ -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<string, ServerAdapterModule>(
|
||||
[
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>();
|
||||
|
||||
// For builtin types that are overridden by an external adapter, we keep the
|
||||
// original builtin so it can be restored when the override is deactivated.
|
||||
const builtinFallbacks = new Map<string, ServerAdapterModule>();
|
||||
|
||||
// Tracks which override types are currently deactivated (paused). When
|
||||
// paused, `getServerAdapter()` returns the builtin fallback instead of the
|
||||
// external. Persisted across reloads via the same disabled-adapters store.
|
||||
const pausedOverrides = new Set<string>();
|
||||
|
||||
function registerBuiltInAdapters() {
|
||||
for (const adapter of [
|
||||
claudeLocalAdapter,
|
||||
codexLocalAdapter,
|
||||
openCodeLocalAdapter,
|
||||
@@ -200,20 +214,109 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
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<void> = (async () => {
|
||||
try {
|
||||
const externalAdapters = await buildExternalAdapters();
|
||||
for (const externalAdapter of externalAdapters) {
|
||||
const overriding = BUILTIN_ADAPTER_TYPES.has(externalAdapter.type);
|
||||
if (overriding) {
|
||||
console.log(
|
||||
`[paperclip] External adapter "${externalAdapter.type}" overrides built-in adapter`,
|
||||
);
|
||||
// Save the original builtin for later restoration.
|
||||
const existing = adaptersByType.get(externalAdapter.type);
|
||||
if (existing && !builtinFallbacks.has(externalAdapter.type)) {
|
||||
builtinFallbacks.set(externalAdapter.type, existing);
|
||||
}
|
||||
}
|
||||
adaptersByType.set(
|
||||
externalAdapter.type,
|
||||
{
|
||||
...externalAdapter,
|
||||
sessionManagement: getAdapterSessionManagement(externalAdapter.type) ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[paperclip] Failed to load external adapters:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Await this before validating adapter types to avoid race conditions
|
||||
* during server startup. External adapters are loaded asynchronously;
|
||||
* calling assertKnownAdapterType before this resolves will reject
|
||||
* valid external adapter types.
|
||||
*/
|
||||
export function waitForExternalAdapters(): Promise<void> {
|
||||
return externalAdaptersReady;
|
||||
}
|
||||
|
||||
export function registerServerAdapter(adapter: ServerAdapterModule): void {
|
||||
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<string> {
|
||||
return pausedOverrides;
|
||||
}
|
||||
|
||||
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
||||
return adaptersByType.get(type) ?? null;
|
||||
}
|
||||
|
||||
export function findActiveServerAdapter(type: string): ServerAdapterModule | null {
|
||||
if (pausedOverrides.has(type)) {
|
||||
const fallback = builtinFallbacks.get(type);
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
return adaptersByType.get(type) ?? null;
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@ export type {
|
||||
NativeContextManagement,
|
||||
ResolvedSessionCompactionPolicy,
|
||||
SessionCompactionPolicy,
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
ServerAdapterModule,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -668,6 +668,12 @@ export async function startServer(): Promise<StartedServer> {
|
||||
}, backupIntervalMs);
|
||||
}
|
||||
|
||||
// Wait for external adapters to finish loading before accepting requests.
|
||||
// Without this, adapter type validation (assertKnownAdapterType) would
|
||||
// reject valid external adapter types during the startup loading window.
|
||||
const { waitForExternalAdapters } = await import("./adapters/registry.js");
|
||||
await waitForExternalAdapters();
|
||||
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
const onError = (err: Error) => {
|
||||
server.off("error", onError);
|
||||
|
||||
@@ -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<string>): AdapterInfo {
|
||||
const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined;
|
||||
return {
|
||||
type: adapter.type,
|
||||
label: adapter.type, // ServerAdapterModule doesn't have a separate "label" field; type serves as label
|
||||
source: externalRecord ? "external" : "builtin",
|
||||
modelsCount: (adapter.models ?? []).length,
|
||||
loaded: true, // If it's in the registry, it's loaded
|
||||
disabled: disabledSet.has(adapter.type),
|
||||
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<string> {
|
||||
// Already a POSIX path (WSL or native Linux)
|
||||
if (rawPath.startsWith("/")) {
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
// Windows path detection: C:\ or C:/ pattern
|
||||
if (/^[A-Za-z]:[\\/]/.test(rawPath)) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("wslpath", ["-u", rawPath]);
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
logger.warn({ err, rawPath }, "wslpath conversion failed; using path as-is");
|
||||
return rawPath;
|
||||
}
|
||||
}
|
||||
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an adapter module into the server registry, filling in
|
||||
* sessionManagement from the host.
|
||||
*/
|
||||
function registerWithSessionManagement(adapter: ServerAdapterModule): void {
|
||||
const wrapped: ServerAdapterModule = {
|
||||
...adapter,
|
||||
sessionManagement: getAdapterSessionManagement(adapter.type) ?? undefined,
|
||||
};
|
||||
registerServerAdapter(wrapped);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function adapterRoutes() {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/adapters
|
||||
*
|
||||
* List all registered adapters (built-in + external).
|
||||
* Each entry includes whether the adapter is built-in or external,
|
||||
* its model count, and load status.
|
||||
*/
|
||||
router.get("/adapters", async (_req, res) => {
|
||||
assertBoard(_req);
|
||||
|
||||
const registeredAdapters = listServerAdapters();
|
||||
const externalRecords = new Map(
|
||||
listAdapterPlugins().map((r) => [r.type, r]),
|
||||
);
|
||||
const disabledSet = new Set(getDisabledAdapterTypes());
|
||||
|
||||
const result: AdapterInfo[] = registeredAdapters.map((adapter) =>
|
||||
buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet),
|
||||
).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<string, { schema: AdapterConfigSchema; fetchedAt: number }>();
|
||||
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;
|
||||
}
|
||||
+40
-18
@@ -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<string, string> = {
|
||||
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<string | null> {
|
||||
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<string, unknown>;
|
||||
@@ -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<string, unknown>,
|
||||
@@ -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<string, unknown>),
|
||||
@@ -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<string, unknown>),
|
||||
@@ -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<string, unknown>) };
|
||||
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
|
||||
delete patchData.replaceAdapterConfig;
|
||||
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
|
||||
if (hasOwn(patchData, "adapterConfig")) {
|
||||
const adapterConfig = asRecord(patchData.adapterConfig);
|
||||
if (!adapterConfig) {
|
||||
res.status(422).json({ error: "adapterConfig must be an object" });
|
||||
@@ -1830,16 +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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,6 +7,12 @@ function readNumericField(record: Record<string, unknown>, key: string) {
|
||||
return key in record ? record[key] ?? null : undefined;
|
||||
}
|
||||
|
||||
function readCommentText(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function summarizeHeartbeatRunResultJson(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
@@ -33,3 +39,18 @@ export function summarizeHeartbeatRunResultJson(
|
||||
|
||||
return Object.keys(summary).length > 0 ? summary : null;
|
||||
}
|
||||
|
||||
export function buildHeartbeatRunIssueComment(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): string | null {
|
||||
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
readCommentText(resultJson.summary)
|
||||
?? readCommentText(resultJson.result)
|
||||
?? readCommentText(resultJson.message)
|
||||
?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -807,7 +807,7 @@ export function buildHostServices(
|
||||
return (await issues.addComment(
|
||||
params.issueId,
|
||||
params.body,
|
||||
{},
|
||||
{ agentId: params.authorAgentId },
|
||||
)) as IssueComment;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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() {
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||
</>
|
||||
@@ -321,6 +323,7 @@ export function App() {
|
||||
<Route path="experimental" element={<InstanceExperimentalSettings />} />
|
||||
<Route path="plugins" element={<PluginManager />} />
|
||||
<Route path="plugins/:pluginId" element={<PluginSettings />} />
|
||||
<Route path="adapters" element={<AdapterManager />} />
|
||||
</Route>
|
||||
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||
|
||||
@@ -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<string, string> = {
|
||||
_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<string, AdapterDisplayInfo> = {
|
||||
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<string, string> {
|
||||
const suffixed: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
/** 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<string> {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, DynamicParserModule>();
|
||||
|
||||
// Track which types we've already attempted to load (to avoid repeat 404s).
|
||||
const failedLoads = new Set<string>();
|
||||
|
||||
/**
|
||||
* 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<DynamicParserModule | null> {
|
||||
// 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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+241
-17
@@ -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<string, UIAdapterModule>();
|
||||
|
||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
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<string>();
|
||||
|
||||
// Original builtin adapters stored for restoration when external overrides
|
||||
// are deactivated or removed.
|
||||
const builtinAdaptersByType = new Map<string, UIAdapterModule>();
|
||||
|
||||
// Tracks which builtin types currently have an active external override.
|
||||
const activeExternalOverrides = new Set<string>();
|
||||
|
||||
// Generation counter to discard stale dynamic parser loads. When an override
|
||||
// is deactivated while a load is in-flight, the generation is bumped and the
|
||||
// stale result is discarded in its .then() handler.
|
||||
const overrideGeneration = new Map<string, number>();
|
||||
|
||||
// Subscriber list — components can register to be notified when adapters change
|
||||
// (e.g., when a dynamic parser replaces a placeholder).
|
||||
const adapterChangeListeners = new Set<() => void>();
|
||||
|
||||
/** Subscribe to adapter registry changes. Returns unsubscribe function. */
|
||||
export function onAdapterChange(fn: () => void): () => void {
|
||||
adapterChangeListeners.add(fn);
|
||||
return () => adapterChangeListeners.delete(fn);
|
||||
}
|
||||
|
||||
function notifyAdapterChange(): void {
|
||||
for (const fn of adapterChangeListeners) fn();
|
||||
}
|
||||
|
||||
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[] {
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className={!value ? "text-muted-foreground" : ""}>
|
||||
{selectedOpt?.label ?? value ?? "Select..."}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50 ${opt.value === value ? "bg-accent" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
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<HTMLInputElement>(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<string, typeof filtered>();
|
||||
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 (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="flex-1 rounded-l-md border border-r-0 border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 focus:z-10"
|
||||
value={displayValue}
|
||||
placeholder={placeholder ?? "Type or select..."}
|
||||
onChange={(e) => {
|
||||
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}
|
||||
/>
|
||||
<Popover open={open && filtered.length > 0} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="rounded-r-md border border-border px-2 py-1.5 hover:bg-accent/50 transition-colors">
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-1 max-h-60 overflow-y-auto"
|
||||
style={{ minWidth: 280 }}
|
||||
align="start"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{Array.from(grouped.entries()).map(([group, opts]) => (
|
||||
<div key={group || "_ungrouped"}>
|
||||
{group && (
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
{opts.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50 ${
|
||||
opt.value === value ? "bg-accent" : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent input blur
|
||||
select(opt.value);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{filter && filtered.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
Use "{filter}" as custom value (press Enter)
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaConfigFields component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const schemaCache = new Map<string, AdapterConfigSchema | null>();
|
||||
const schemaFetchInflight = new Map<string, Promise<AdapterConfigSchema | null>>();
|
||||
const failedSchemaTypes = new Set<string>();
|
||||
|
||||
async function fetchConfigSchema(adapterType: string): Promise<AdapterConfigSchema | null> {
|
||||
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<AdapterConfigSchema | null>(
|
||||
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<string, unknown> = {};
|
||||
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<string, string[]>;
|
||||
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<string, string[]>;
|
||||
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 (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<SelectField
|
||||
value={currentVal}
|
||||
options={field.options ?? []}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
hint={field.hint}
|
||||
checked={readValue(field) === true}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftNumberInput
|
||||
value={Number(readValue(field) ?? 0)}
|
||||
onCommit={(v) => writeValue(field, v)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftTextarea
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
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<string, string[]>;
|
||||
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 (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<ComboboxField
|
||||
value={currentVal}
|
||||
options={comboboxOptions}
|
||||
onChange={(v) => writeValue(field, v || undefined)}
|
||||
placeholder={field.hint}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftInput
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build adapter config from schema values + standard CreateConfigValues fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildSchemaAdapterConfig(
|
||||
values: CreateConfigValues,
|
||||
): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<AdapterConfigFieldsProps>;
|
||||
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -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<AdapterInfo[]>("/adapters"),
|
||||
|
||||
/** Install an external adapter from npm or a local path. */
|
||||
install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
|
||||
api.post<AdapterInstallResult>("/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`, {}),
|
||||
};
|
||||
@@ -32,6 +32,7 @@ export interface DetectedAdapterModel {
|
||||
model: string;
|
||||
provider: string;
|
||||
source: string;
|
||||
candidates?: string[];
|
||||
}
|
||||
|
||||
export interface ClaudeLoginResult {
|
||||
|
||||
@@ -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) {
|
||||
<Field label="Adapter type" hint={help.adapterType}>
|
||||
<AdapterTypeDropdown
|
||||
value={adapterType}
|
||||
disabledTypes={disabledTypes}
|
||||
onChange={(t) => {
|
||||
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 */}
|
||||
<uiAdapter.ConfigFields {...adapterFieldProps} />
|
||||
{/* Adapter-specific fields are rendered inside Permissions & Configuration */}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -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<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
@@ -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 && (
|
||||
<p className="text-xs text-destructive">
|
||||
@@ -820,6 +815,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
{adapterType === "claude_local" && (
|
||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||
)}
|
||||
<uiAdapter.ConfigFields {...adapterFieldProps} />
|
||||
|
||||
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
|
||||
<DraftInput
|
||||
@@ -831,7 +827,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ extraArgs: v })
|
||||
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined)
|
||||
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : null)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
@@ -1024,37 +1020,37 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
||||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||
...AGENT_ADAPTER_TYPES.map((t) => ({
|
||||
value: t,
|
||||
label: adapterLabels[t] ?? t,
|
||||
comingSoon: !ENABLED_ADAPTER_TYPES.has(t),
|
||||
})),
|
||||
];
|
||||
|
||||
function AdapterTypeDropdown({
|
||||
value,
|
||||
onChange,
|
||||
disabledTypes,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (type: string) => void;
|
||||
disabledTypes: Set<string>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const adapterList = useMemo(
|
||||
() =>
|
||||
listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter(
|
||||
(item) => !disabledTypes.has(item.value),
|
||||
),
|
||||
[disabledTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||
<span>{adapterLabels[value] ?? value}</span>
|
||||
<span>{adapterLabels[value] ?? getAdapterLabel(value)}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
{ADAPTER_DISPLAY_LIST.map((item) => (
|
||||
{adapterList.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
disabled={item.comingSoon}
|
||||
@@ -1066,7 +1062,10 @@ function AdapterTypeDropdown({
|
||||
item.value === value && !item.comingSoon && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!item.comingSoon) onChange(item.value);
|
||||
if (!item.comingSoon) {
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
@@ -1357,8 +1356,10 @@ function ModelDropdown({
|
||||
groupByProvider,
|
||||
creatable,
|
||||
detectedModel,
|
||||
detectedModelCandidates,
|
||||
onDetectModel,
|
||||
detectModelLabel,
|
||||
emptyDetectHint,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
@@ -1370,8 +1371,10 @@ function ModelDropdown({
|
||||
groupByProvider: boolean;
|
||||
creatable?: boolean;
|
||||
detectedModel?: string | null;
|
||||
detectedModelCandidates?: string[];
|
||||
onDetectModel?: () => Promise<string | null>;
|
||||
detectModelLabel?: string;
|
||||
emptyDetectHint?: string;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const [detectingModel, setDetectingModel] = useState(false);
|
||||
@@ -1382,8 +1385,19 @@ function ModelDropdown({
|
||||
manualModel &&
|
||||
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
|
||||
);
|
||||
// Model IDs already shown as detected/candidate badges — exclude from regular list
|
||||
const promotedModelIds = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
if (detectedModel) set.add(detectedModel);
|
||||
for (const c of detectedModelCandidates ?? []) {
|
||||
if (c) set.add(c);
|
||||
}
|
||||
return set;
|
||||
}, [detectedModel, detectedModelCandidates]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (promotedModelIds.has(m.id)) return false;
|
||||
if (!modelSearch.trim()) return true;
|
||||
const q = modelSearch.toLowerCase();
|
||||
const provider = extractProviderId(m.id) ?? "";
|
||||
@@ -1393,7 +1407,7 @@ function ModelDropdown({
|
||||
provider.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [models, modelSearch]);
|
||||
}, [models, modelSearch, promotedModelIds]);
|
||||
const groupedModels = useMemo(() => {
|
||||
if (!groupByProvider) {
|
||||
return [
|
||||
@@ -1474,7 +1488,7 @@ function ModelDropdown({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDetectModel && !detectedModel && !modelSearch.trim() && (
|
||||
{onDetectModel && !modelSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||
@@ -1487,10 +1501,10 @@ function ModelDropdown({
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
|
||||
{detectingModel ? "Detecting..." : detectedModel ? (detectModelLabel?.replace(/^Detect\b/, "Re-detect") ?? "Re-detect from config") : (detectModelLabel ?? "Detect from config")}
|
||||
</button>
|
||||
)}
|
||||
{value && !models.some((m) => m.id === value) && (
|
||||
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -1501,7 +1515,7 @@ function ModelDropdown({
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
|
||||
{value}
|
||||
{models.find((m) => m.id === value)?.label ?? value}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
|
||||
current
|
||||
@@ -1520,13 +1534,38 @@ function ModelDropdown({
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
|
||||
{detectedModel}
|
||||
{models.find((m) => m.id === detectedModel)?.label ?? detectedModel}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
|
||||
detected
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{detectedModelCandidates
|
||||
?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value)
|
||||
.map((candidate) => {
|
||||
const entry = models.find((m) => m.id === candidate);
|
||||
return (
|
||||
<button
|
||||
key={`detected-${candidate}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(candidate);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={candidate}>
|
||||
{entry?.label ?? candidate}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-sky-500/15 text-sky-400 border border-sky-500/20">
|
||||
config
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{allowDefault && (
|
||||
<button
|
||||
@@ -1584,11 +1623,11 @@ function ModelDropdown({
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||
{filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{onDetectModel
|
||||
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
|
||||
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
|
||||
: "No models found."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from "@/lib/router";
|
||||
import { AGENT_ROLE_LABELS, type Agent, type AgentRuntimeState } from "@paperclipai/shared";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { Identity } from "./Identity";
|
||||
@@ -14,17 +15,6 @@ interface AgentPropertiesProps {
|
||||
runtimeState?: AgentRuntimeState;
|
||||
}
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
gemini_local: "Gemini CLI (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
@@ -62,7 +52,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Adapter">
|
||||
<span className="text-sm font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
|
||||
<span className="text-sm font-mono">{getAdapterLabel(agent.adapterType)}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
|
||||
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
|
||||
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
|
||||
<SidebarNavItem to="/instance/settings/adapters" label="Adapters" icon={Cpu} />
|
||||
{(plugins ?? []).length > 0 ? (
|
||||
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
|
||||
{(plugins ?? []).map((plugin) => (
|
||||
|
||||
@@ -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 */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||
<Sparkles className="h-6 w-6 text-foreground" />
|
||||
<Bot className="h-6 w-6 text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We recommend letting your CEO handle agent setup — they know the
|
||||
@@ -201,13 +175,18 @@ export function NewAgentDialog() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ADVANCED_ADAPTER_OPTIONS.map((opt) => (
|
||||
{adapterGrid.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative"
|
||||
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative",
|
||||
opt.comingSoon && "opacity-40 cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => handleAdvancedAdapterPick(opt.value)}
|
||||
disabled={!!opt.comingSoon}
|
||||
title={opt.comingSoon ? opt.disabledLabel : undefined}
|
||||
onClick={() => {
|
||||
if (!opt.comingSoon) handleAdvancedAdapterPick(opt.value);
|
||||
}}
|
||||
>
|
||||
{opt.recommended && (
|
||||
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
extractProviderIdWithFallback
|
||||
} from "../lib/model-utils";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { listUIAdapters } from "../adapters";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
|
||||
import {
|
||||
@@ -38,37 +41,22 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import {
|
||||
Building2,
|
||||
Bot,
|
||||
Code,
|
||||
Gem,
|
||||
ListTodo,
|
||||
Rocket,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Terminal,
|
||||
Sparkles,
|
||||
MousePointer2,
|
||||
Check,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
type AdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "gemini_local"
|
||||
| "hermes_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "http"
|
||||
| "openclaw_gateway";
|
||||
type AdapterType = string;
|
||||
|
||||
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
|
||||
|
||||
@@ -85,6 +73,9 @@ export function OnboardingWizard() {
|
||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||
const [routeDismissed, setRouteDismissed] = useState(false);
|
||||
|
||||
// Sync disabled adapter types from server so adapter grid filters them out
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
|
||||
const routeOnboardingOptions =
|
||||
companyPrefix && companiesLoading
|
||||
? null
|
||||
@@ -206,29 +197,33 @@ export function OnboardingWizard() {
|
||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
|
||||
});
|
||||
const isLocalAdapter =
|
||||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
|
||||
const isLocalAdapter = !NONLOCAL_TYPES.has(adapterType);
|
||||
|
||||
// Build adapter grids dynamically from the UI registry + display metadata.
|
||||
// External/plugin adapters automatically appear with generic defaults.
|
||||
const { recommendedAdapters, moreAdapters } = useMemo(() => {
|
||||
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
|
||||
const all = listUIAdapters()
|
||||
.filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && !disabledTypes.has(a.type))
|
||||
.map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }));
|
||||
|
||||
return {
|
||||
recommendedAdapters: all.filter((a) => a.recommended),
|
||||
moreAdapters: all.filter((a) => !a.recommended),
|
||||
};
|
||||
}, [disabledTypes]);
|
||||
const COMMAND_PLACEHOLDERS: Record<string, string> = {
|
||||
claude_local: "claude",
|
||||
codex_local: "codex",
|
||||
gemini_local: "gemini",
|
||||
pi_local: "pi",
|
||||
cursor: "agent",
|
||||
opencode_local: "opencode",
|
||||
};
|
||||
const effectiveAdapterCommand =
|
||||
command.trim() ||
|
||||
(adapterType === "codex_local"
|
||||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude");
|
||||
(COMMAND_PLACEHOLDERS[adapterType] ?? adapterType.replace(/_local$/, ""));
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 2) return;
|
||||
@@ -759,32 +754,17 @@ export function OnboardingWizard() {
|
||||
Adapter type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{
|
||||
value: "claude_local" as const,
|
||||
label: "Claude Code",
|
||||
icon: Sparkles,
|
||||
desc: "Local Claude agent",
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
value: "codex_local" as const,
|
||||
label: "Codex",
|
||||
icon: Code,
|
||||
desc: "Local Codex agent",
|
||||
recommended: true
|
||||
}
|
||||
].map((opt) => (
|
||||
{recommendedAdapters.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
key={opt.type}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
|
||||
adapterType === opt.value
|
||||
adapterType === opt.type
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
const nextType = opt.value as AdapterType;
|
||||
const nextType = opt.type;
|
||||
setAdapterType(nextType);
|
||||
if (nextType === "codex_local" && !model) {
|
||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||
@@ -802,7 +782,7 @@ export function OnboardingWizard() {
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.desc}
|
||||
{opt.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -823,60 +803,21 @@ export function OnboardingWizard() {
|
||||
|
||||
{showMoreAdapters && (
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
disabled={!!opt.comingSoon}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
|
||||
opt.comingSoon
|
||||
? "border-border opacity-40 cursor-not-allowed"
|
||||
: adapterType === opt.value
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (opt.comingSoon) return;
|
||||
const nextType = opt.value as AdapterType;
|
||||
{moreAdapters.map((opt) => (
|
||||
<button
|
||||
key={opt.type}
|
||||
disabled={!!opt.comingSoon}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
|
||||
opt.comingSoon
|
||||
? "border-border opacity-40 cursor-not-allowed"
|
||||
: adapterType === opt.type
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (opt.comingSoon) return;
|
||||
const nextType = opt.type;
|
||||
setAdapterType(nextType);
|
||||
if (nextType === "gemini_local" && !model) {
|
||||
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
|
||||
@@ -899,9 +840,8 @@ export function OnboardingWizard() {
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.comingSoon
|
||||
? (opt as { disabledLabel?: string })
|
||||
.disabledLabel ?? "Coming soon"
|
||||
: opt.desc}
|
||||
? opt.disabledLabel ?? "Coming soon"
|
||||
: opt.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -910,13 +850,7 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
|
||||
{/* Conditional adapter fields */}
|
||||
{(adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor") && (
|
||||
{isLocalAdapter && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { RunInvocationCard } from "../pages/AgentDetail";
|
||||
|
||||
describe("RunInvocationCard", () => {
|
||||
it("keeps verbose invocation details collapsed by default", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<RunInvocationCard
|
||||
payload={{
|
||||
adapterType: "claude_local",
|
||||
cwd: "/tmp/workspace",
|
||||
command: "claude",
|
||||
commandArgs: ["--dangerously-skip-permissions"],
|
||||
commandNotes: ["Prompt is piped to claude via stdin."],
|
||||
prompt: "very long prompt body",
|
||||
context: { triggeredBy: "board" },
|
||||
env: { ANTHROPIC_API_KEY: "***REDACTED***" },
|
||||
}}
|
||||
censorUsernameInLogs={false}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Invocation");
|
||||
expect(html).toContain("Adapter:");
|
||||
expect(html).toContain("Working dir:");
|
||||
expect(html).toContain("Details");
|
||||
expect(html).not.toContain("Command:");
|
||||
expect(html).not.toContain("Prompt is piped to claude via stdin.");
|
||||
expect(html).not.toContain("very long prompt body");
|
||||
expect(html).not.toContain("ANTHROPIC_API_KEY");
|
||||
expect(html).not.toContain("triggeredBy");
|
||||
});
|
||||
});
|
||||
@@ -57,17 +57,9 @@ export const help: Record<string, string> = {
|
||||
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
|
||||
};
|
||||
|
||||
export const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
gemini_local: "Gemini CLI (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
import { getAdapterLabels } from "../adapters/adapter-display-registry";
|
||||
|
||||
export const adapterLabels = getAdapterLabels();
|
||||
|
||||
export const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
|
||||
@@ -81,4 +81,33 @@ describe("RunTranscriptView", () => {
|
||||
text: "Working on the task.",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders successful result summaries as markdown in nice mode", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<RunTranscriptView
|
||||
density="compact"
|
||||
entries={[
|
||||
{
|
||||
kind: "result",
|
||||
ts: "2026-03-12T00:00:02.000Z",
|
||||
text: "## Summary\n\n- fixed deploy config\n- posted issue update",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "success",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("<h2>Summary</h2>");
|
||||
expect(html).toContain("<li>fixed deploy config</li>");
|
||||
expect(html).toContain("<li>posted issue update</li>");
|
||||
expect(html).not.toContain("result");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
GitCompare,
|
||||
TerminalSquare,
|
||||
User,
|
||||
Wrench,
|
||||
@@ -92,6 +93,12 @@ type TranscriptBlock =
|
||||
endTs?: string;
|
||||
lines: Array<{ ts: string; text: string }>;
|
||||
}
|
||||
| {
|
||||
type: "system_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
lines: Array<{ ts: string; text: string }>;
|
||||
}
|
||||
| {
|
||||
type: "stdout";
|
||||
ts: string;
|
||||
@@ -104,6 +111,16 @@ type TranscriptBlock =
|
||||
tone: "info" | "warn" | "error" | "neutral";
|
||||
text: string;
|
||||
detail?: string;
|
||||
}
|
||||
| {
|
||||
type: "diff_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
filePath?: string;
|
||||
hunks: Array<{
|
||||
changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
@@ -491,6 +508,10 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
label: "result",
|
||||
tone: entry.isError ? "error" : "info",
|
||||
text: entry.text.trim() || entry.errors[0] || (entry.isError ? "Run failed" : "Completed"),
|
||||
detail:
|
||||
!entry.isError && entry.text.trim().length > 0
|
||||
? `${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`
|
||||
: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -543,13 +564,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: "event",
|
||||
ts: entry.ts,
|
||||
label: "system",
|
||||
tone: "warn",
|
||||
text: entry.text,
|
||||
});
|
||||
// Batch consecutive system events into a single collapsible group
|
||||
const prev = blocks[blocks.length - 1];
|
||||
if (prev && prev.type === "system_group") {
|
||||
prev.lines.push({ ts: entry.ts, text: entry.text });
|
||||
prev.endTs = entry.ts;
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "system_group",
|
||||
ts: entry.ts,
|
||||
endTs: entry.ts,
|
||||
lines: [{ ts: entry.ts, text: entry.text }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -564,6 +591,28 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Diff entries — accumulate into diff_group blocks ──────────
|
||||
if (entry.kind === "diff") {
|
||||
const prev = blocks[blocks.length - 1];
|
||||
if (prev && prev.type === "diff_group") {
|
||||
if (entry.changeType === "file_header") {
|
||||
// New file in the same diff block — update filePath
|
||||
prev.filePath = entry.text;
|
||||
}
|
||||
prev.hunks.push({ changeType: entry.changeType, text: entry.text });
|
||||
prev.endTs = entry.ts;
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "diff_group",
|
||||
ts: entry.ts,
|
||||
endTs: entry.ts,
|
||||
filePath: entry.changeType === "file_header" ? entry.text : undefined,
|
||||
hunks: [{ changeType: entry.changeType, text: entry.text }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous?.type === "stdout") {
|
||||
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
|
||||
previous.ts = entry.ts;
|
||||
@@ -1062,9 +1111,14 @@ function TranscriptEventRow({
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{block.label === "result" && block.tone !== "error" ? (
|
||||
<div className={cn("whitespace-pre-wrap break-words text-sky-700 dark:text-sky-300", compact ? "text-[11px]" : "text-xs")}>
|
||||
<MarkdownBody
|
||||
className={cn(
|
||||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 text-sky-700 dark:text-sky-300",
|
||||
compact ? "text-[11px] leading-5" : "text-xs leading-5",
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</div>
|
||||
</MarkdownBody>
|
||||
) : (
|
||||
<div className={cn("whitespace-pre-wrap break-words", compact ? "text-[11px]" : "text-xs")}>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/70">
|
||||
@@ -1084,6 +1138,103 @@ function TranscriptEventRow({
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptDiffGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "diff_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
|
||||
// Count add/remove lines (exclude context, hunk, file_header, truncation)
|
||||
const addCount = block.hunks.filter((h) => h.changeType === "add").length;
|
||||
const removeCount = block.hunks.filter((h) => h.changeType === "remove").length;
|
||||
const hasChanges = addCount > 0 || removeCount > 0;
|
||||
|
||||
// Extract a short file name from the path
|
||||
const shortFile = block.filePath
|
||||
? block.filePath.split("/").pop() ?? block.filePath
|
||||
: "diff";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.04] p-2">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<GitCompare className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
|
||||
<span className={cn("text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-700 dark:text-blue-300")}>
|
||||
{shortFile}
|
||||
</span>
|
||||
{hasChanges && (
|
||||
<span className="text-[10px] tabular-nums">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">+{addCount}</span>
|
||||
{" "}
|
||||
<span className="text-red-600 dark:text-red-400">-{removeCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
{open && (
|
||||
<pre className={cn(
|
||||
"mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono pl-5",
|
||||
compact ? "text-[11px]" : "text-xs",
|
||||
)}>
|
||||
{block.hunks.map((hunk, i) => {
|
||||
const key = `${i}-${hunk.changeType}`;
|
||||
switch (hunk.changeType) {
|
||||
case "remove":
|
||||
return (
|
||||
<span key={key} className="block bg-red-500/[0.10] text-red-700 dark:text-red-300 -mx-2 px-2">
|
||||
<span className="select-none mr-2 text-red-500/60 dark:text-red-400/50">-</span>
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
case "add":
|
||||
return (
|
||||
<span key={key} className="block bg-emerald-500/[0.10] text-emerald-700 dark:text-emerald-300 -mx-2 px-2">
|
||||
<span className="select-none mr-2 text-emerald-500/60 dark:text-emerald-400/50">+</span>
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
case "file_header":
|
||||
return (
|
||||
<span key={key} className="block font-semibold text-blue-600 dark:text-blue-300 mt-2 first:mt-0">
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
case "truncation":
|
||||
return (
|
||||
<span key={key} className="block text-muted-foreground italic mt-1">
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
case "context":
|
||||
default:
|
||||
return (
|
||||
<span key={key} className="block text-muted-foreground/70">
|
||||
{" "}
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptStderrGroup({
|
||||
block,
|
||||
density,
|
||||
@@ -1121,6 +1272,43 @@ function TranscriptStderrGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptSystemGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "system_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.04] p-2 text-blue-700 dark:text-blue-300">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.14em]">
|
||||
{block.lines.length} system {block.lines.length === 1 ? "message" : "messages"}
|
||||
</span>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
{open && (
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-blue-700/80 dark:text-blue-300/80 pl-5">
|
||||
{block.lines.map((line, i) => (
|
||||
<span key={`${line.ts}-${i}`}>
|
||||
<span className="select-none text-blue-500/40 dark:text-blue-400/30">{i > 0 ? "\n" : ""}</span>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptStdoutRow({
|
||||
block,
|
||||
density,
|
||||
@@ -1242,7 +1430,9 @@ export function RunTranscriptView({
|
||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
|
||||
{block.type === "diff_group" && <TranscriptDiffGroup block={block} density={density} />}
|
||||
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
|
||||
{block.type === "system_group" && <TranscriptSystemGroup block={block} density={density} />}
|
||||
{block.type === "stdout" && (
|
||||
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import type { LiveEvent } from "@paperclipai/shared";
|
||||
import { instanceSettingsApi } from "../../api/instanceSettings";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
|
||||
import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
|
||||
const LOG_POLL_INTERVAL_MS = 2000;
|
||||
@@ -68,6 +68,11 @@ export function useLiveRunTranscripts({
|
||||
const seenChunkKeysRef = useRef(new Set<string>());
|
||||
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
||||
const logOffsetByRunRef = useRef(new Map<string, number>());
|
||||
// Tick counter to force transcript recomputation when dynamic parser loads
|
||||
const [parserTick, setParserTick] = useState(0);
|
||||
useEffect(() => {
|
||||
return onAdapterChange(() => setParserTick((t) => t + 1));
|
||||
}, []);
|
||||
const { data: generalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.generalSettings,
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
@@ -279,13 +284,13 @@ export function useLiveRunTranscripts({
|
||||
const adapter = getUIAdapter(run.adapterType);
|
||||
next.set(
|
||||
run.id,
|
||||
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
|
||||
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
|
||||
censorUsernameInLogs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]);
|
||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]);
|
||||
|
||||
return {
|
||||
transcriptByRun,
|
||||
|
||||
@@ -144,4 +144,7 @@ export const queryKeys = {
|
||||
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
||||
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
|
||||
},
|
||||
adapters: {
|
||||
all: ["adapters"] as const,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* @fileoverview Adapter Manager page — install, view, and manage external adapters.
|
||||
*
|
||||
* Adapters are simpler than plugins: no workers, no events, no manifests.
|
||||
* They just register a ServerAdapterModule that provides model discovery and execution.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Cpu, Plus, Power, Trash2, FolderOpen, Package, RefreshCw, Download } from "lucide-react";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { adaptersApi } from "@/api/adapters";
|
||||
import type { AdapterInfo } from "@/api/adapters";
|
||||
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChoosePathButton } from "@/components/PathInstructionsModal";
|
||||
import { invalidateDynamicParser } from "@/adapters/dynamic-loader";
|
||||
import { invalidateConfigSchemaCache } from "@/adapters/schema-config-fields";
|
||||
|
||||
function AdapterRow({
|
||||
adapter,
|
||||
canRemove,
|
||||
onToggle,
|
||||
onRemove,
|
||||
onReload,
|
||||
onReinstall,
|
||||
isToggling,
|
||||
isReloading,
|
||||
isReinstalling,
|
||||
overriddenBy,
|
||||
/** Custom tooltip for the power button when adapter is enabled. */
|
||||
toggleTitleEnabled,
|
||||
/** Custom tooltip for the power button when adapter is disabled. */
|
||||
toggleTitleDisabled,
|
||||
/** Custom label for the disabled badge (defaults to "Hidden from menus"). */
|
||||
disabledBadgeLabel,
|
||||
}: {
|
||||
adapter: AdapterInfo;
|
||||
canRemove: boolean;
|
||||
onToggle: (type: string, disabled: boolean) => void;
|
||||
onRemove: (type: string) => void;
|
||||
onReload?: (type: string) => void;
|
||||
onReinstall?: (type: string) => void;
|
||||
isToggling: boolean;
|
||||
isReloading?: boolean;
|
||||
isReinstalling?: boolean;
|
||||
/** When set, shows an "Overridden by …" badge (used for builtin entries). */
|
||||
overriddenBy?: string;
|
||||
toggleTitleEnabled?: string;
|
||||
toggleTitleDisabled?: string;
|
||||
disabledBadgeLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn("font-medium", adapter.disabled && "text-muted-foreground line-through")}>
|
||||
{adapter.label || getAdapterLabel(adapter.type)}
|
||||
</span>
|
||||
<Badge variant="outline">{adapter.source === "external" ? "External" : "Built-in"}</Badge>
|
||||
{adapter.source === "external" && (
|
||||
adapter.isLocalPath
|
||||
? <span title="Installed from local path"><FolderOpen className="h-4 w-4 text-amber-500" /></span>
|
||||
: <span title="Installed from npm"><Package className="h-4 w-4 text-red-500" /></span>
|
||||
)}
|
||||
{adapter.version && (
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
v{adapter.version}
|
||||
</Badge>
|
||||
)}
|
||||
{adapter.overriddenBuiltin && (
|
||||
<Badge variant="secondary" className="text-blue-600 border-blue-400">
|
||||
Overrides built-in
|
||||
</Badge>
|
||||
)}
|
||||
{overriddenBy && (
|
||||
<Badge variant="secondary" className="text-blue-600 border-blue-400">
|
||||
Overridden by {overriddenBy}
|
||||
</Badge>
|
||||
)}
|
||||
{adapter.disabled && (
|
||||
<Badge variant="secondary" className="text-amber-600 border-amber-400">
|
||||
{disabledBadgeLabel ?? "Hidden from menus"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{adapter.type}
|
||||
{adapter.packageName && adapter.packageName !== adapter.type && (
|
||||
<> · {adapter.packageName}</>
|
||||
)}
|
||||
{" · "}{adapter.modelsCount} models
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{onReinstall && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="h-8 w-8"
|
||||
title="Reinstall adapter (pull latest from npm)"
|
||||
disabled={isReinstalling}
|
||||
onClick={() => onReinstall(adapter.type)}
|
||||
>
|
||||
<Download className={cn("h-4 w-4", isReinstalling && "animate-bounce")} />
|
||||
</Button>
|
||||
)}
|
||||
{onReload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="h-8 w-8"
|
||||
title="Reload adapter (hot-swap)"
|
||||
disabled={isReloading}
|
||||
onClick={() => onReload(adapter.type)}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isReloading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="h-8 w-8"
|
||||
title={adapter.disabled
|
||||
? (toggleTitleEnabled ?? "Show in agent menus")
|
||||
: (toggleTitleDisabled ?? "Hide from agent menus")}
|
||||
disabled={isToggling}
|
||||
onClick={() => onToggle(adapter.type, !adapter.disabled)}
|
||||
>
|
||||
<Power className={cn("h-4 w-4", !adapter.disabled ? "text-green-600" : "text-muted-foreground")} />
|
||||
</Button>
|
||||
{canRemove && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="Remove adapter"
|
||||
onClick={() => onRemove(adapter.type)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function fetchNpmLatestVersion(packageName: string): Promise<string | null> {
|
||||
return fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => (typeof data?.version === "string" ? (data.version as string) : null))
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
function ReinstallDialog({
|
||||
adapter,
|
||||
open,
|
||||
isReinstalling,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
adapter: AdapterInfo | null;
|
||||
open: boolean;
|
||||
isReinstalling: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { data: latestVersion, isLoading: isFetchingVersion } = useQuery({
|
||||
queryKey: ["npm-latest-version", adapter?.packageName],
|
||||
queryFn: () => {
|
||||
if (!adapter?.packageName) return null;
|
||||
return fetchNpmLatestVersion(adapter.packageName);
|
||||
},
|
||||
enabled: open && !!adapter?.packageName,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const isUpToDate = adapter?.version && latestVersion && adapter.version === latestVersion;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reinstall Adapter</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will pull the latest version of{" "}
|
||||
<strong>{adapter?.packageName}</strong> from npm and hot-swap
|
||||
the running adapter module. Existing agents will use the new
|
||||
version on their next run.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-md border bg-muted/50 px-4 py-3 text-sm space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-mono">{adapter?.packageName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Current</span>
|
||||
<span className="font-mono">
|
||||
{adapter?.version ? `v${adapter.version}` : "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Latest on npm</span>
|
||||
<span className="font-mono">
|
||||
{isFetchingVersion
|
||||
? "checking..."
|
||||
: latestVersion
|
||||
? `v${latestVersion}`
|
||||
: "unavailable"}
|
||||
</span>
|
||||
</div>
|
||||
{isUpToDate && (
|
||||
<p className="text-xs text-muted-foreground pt-1">
|
||||
Already on the latest version.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isReinstalling}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isReinstalling} onClick={onConfirm}>
|
||||
{isReinstalling ? "Reinstalling..." : "Reinstall"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdapterManager() {
|
||||
const { selectedCompany } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
|
||||
const [installPackage, setInstallPackage] = useState("");
|
||||
const [installVersion, setInstallVersion] = useState("");
|
||||
const [isLocalPath, setIsLocalPath] = useState(false);
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [removeType, setRemoveType] = useState<string | null>(null);
|
||||
const [reinstallTarget, setReinstallTarget] = useState<AdapterInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/instance/settings/general" },
|
||||
{ label: "Adapters" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
const { data: adapters, isLoading } = useQuery({
|
||||
queryKey: queryKeys.adapters.all,
|
||||
queryFn: () => adaptersApi.list(),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adapters.all });
|
||||
};
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
|
||||
adaptersApi.install(params),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
setInstallDialogOpen(false);
|
||||
setInstallPackage("");
|
||||
setInstallVersion("");
|
||||
setIsLocalPath(false);
|
||||
pushToast({
|
||||
title: "Adapter installed",
|
||||
body: `Type "${result.type}" registered successfully.${result.version ? ` (v${result.version})` : ""}`,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Install failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (type: string) => adaptersApi.remove(type),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
pushToast({ title: "Adapter removed", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Removal failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ type, disabled }: { type: string; disabled: boolean }) =>
|
||||
adaptersApi.setDisabled(type, disabled),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Toggle failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const overrideMutation = useMutation({
|
||||
mutationFn: ({ type, paused }: { type: string; paused: boolean }) =>
|
||||
adaptersApi.setOverridePaused(type, paused),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Override toggle failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const reloadMutation = useMutation({
|
||||
mutationFn: (type: string) => adaptersApi.reload(type),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
invalidateDynamicParser(result.type);
|
||||
invalidateConfigSchemaCache(result.type);
|
||||
pushToast({
|
||||
title: "Adapter reloaded",
|
||||
body: `Type "${result.type}" reloaded.${result.version ? ` (v${result.version})` : ""}`,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Reload failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const reinstallMutation = useMutation({
|
||||
mutationFn: (type: string) => adaptersApi.reinstall(type),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
invalidateDynamicParser(result.type);
|
||||
invalidateConfigSchemaCache(result.type);
|
||||
pushToast({
|
||||
title: "Adapter reinstalled",
|
||||
body: `Type "${result.type}" updated from npm.${result.version ? ` (v${result.version})` : ""}`,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Reinstall failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const builtinAdapters = (adapters ?? []).filter((a) => a.source === "builtin");
|
||||
const externalAdapters = (adapters ?? []).filter((a) => a.source === "external");
|
||||
|
||||
// External adapters that override a builtin type. The server only returns
|
||||
// one entry per type (the external), so we synthesize a builtin row for
|
||||
// the builtins section so users can see which builtins are affected.
|
||||
const overriddenBuiltins = (adapters ?? [])
|
||||
.filter((a) => a.source === "external" && a.overriddenBuiltin)
|
||||
.filter((a) => !builtinAdapters.some((b) => b.type === a.type))
|
||||
.map((a) => ({
|
||||
type: a.type,
|
||||
label: getAdapterLabel(a.type),
|
||||
overriddenBy: [
|
||||
a.packageName,
|
||||
a.version ? `v${a.version}` : undefined,
|
||||
].filter(Boolean).join(" "),
|
||||
overridePaused: !!a.overridePaused,
|
||||
menuDisabled: !!a.disabled,
|
||||
}));
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading adapters...</div>;
|
||||
|
||||
const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || overrideMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-6 w-6 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">Adapters</h1>
|
||||
<Badge variant="outline" className="text-amber-600 border-amber-400">
|
||||
Alpha
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Install Adapter
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install External Adapter</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add an adapter from npm or a local path. The adapter package must export <code className="text-xs bg-muted px-1 py-0.5 rounded">createServerAdapter()</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Source toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors",
|
||||
!isLocalPath
|
||||
? "border-foreground bg-accent text-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setIsLocalPath(false)}
|
||||
>
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
npm package
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors",
|
||||
isLocalPath
|
||||
? "border-foreground bg-accent text-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setIsLocalPath(true)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
Local path
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLocalPath ? (
|
||||
/* Local path input */
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="adapterLocalPath">Path to adapter package</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="adapterLocalPath"
|
||||
className="flex-1 font-mono text-xs"
|
||||
placeholder="/mnt/e/Projects/my-adapter or E:\Projects\my-adapter"
|
||||
value={installPackage}
|
||||
onChange={(e) => setInstallPackage(e.target.value)}
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Accepts Linux, WSL, and Windows paths. Windows paths are auto-converted.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* npm package input */
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="adapterPackageName">Package Name</Label>
|
||||
<Input
|
||||
id="adapterPackageName"
|
||||
placeholder="my-paperclip-adapter"
|
||||
value={installPackage}
|
||||
onChange={(e) => setInstallPackage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="adapterVersion">Version (optional)</Label>
|
||||
<Input
|
||||
id="adapterVersion"
|
||||
placeholder="latest"
|
||||
value={installVersion}
|
||||
onChange={(e) => setInstallVersion(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
installMutation.mutate({
|
||||
packageName: installPackage,
|
||||
version: installVersion || undefined,
|
||||
isLocalPath,
|
||||
})
|
||||
}
|
||||
disabled={!installPackage || installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Alpha notice */}
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">External adapters are alpha.</p>
|
||||
<p className="text-muted-foreground">
|
||||
The adapter plugin system is under active development. APIs and storage format may change.
|
||||
Use the power icon to hide adapters from agent menus without removing them.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* External adapters */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">External Adapters</h2>
|
||||
</div>
|
||||
|
||||
{externalAdapters.length === 0 ? (
|
||||
<Card className="bg-muted/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||
<Cpu className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm font-medium">No external adapters installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Install an adapter package to extend model support.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border bg-card">
|
||||
{externalAdapters.map((adapter) => {
|
||||
const isBuiltinOverride = adapter.overriddenBuiltin;
|
||||
const overridePaused = isBuiltinOverride && !!adapter.overridePaused;
|
||||
|
||||
// For overridden builtins, the power button controls the
|
||||
// override pause state (not server menu visibility).
|
||||
const effectiveAdapter: AdapterInfo = isBuiltinOverride
|
||||
? { ...adapter, disabled: overridePaused ?? false }
|
||||
: adapter;
|
||||
|
||||
return (
|
||||
<AdapterRow
|
||||
key={adapter.type}
|
||||
adapter={effectiveAdapter}
|
||||
canRemove={true}
|
||||
onToggle={
|
||||
isBuiltinOverride
|
||||
? (type, disabled) => overrideMutation.mutate({ type, paused: disabled })
|
||||
: (type, disabled) => toggleMutation.mutate({ type, disabled })
|
||||
}
|
||||
onRemove={(type) => setRemoveType(type)}
|
||||
onReload={(type) => reloadMutation.mutate(type)}
|
||||
onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined}
|
||||
isToggling={isBuiltinOverride ? overrideMutation.isPending : toggleMutation.isPending}
|
||||
isReloading={reloadMutation.isPending}
|
||||
isReinstalling={reinstallMutation.isPending}
|
||||
toggleTitleDisabled={isBuiltinOverride ? "Pause external override" : undefined}
|
||||
toggleTitleEnabled={isBuiltinOverride ? "Resume external override" : undefined}
|
||||
disabledBadgeLabel={isBuiltinOverride ? "Override paused" : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Built-in adapters */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Built-in Adapters</h2>
|
||||
</div>
|
||||
|
||||
{builtinAdapters.length === 0 && overriddenBuiltins.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No built-in adapters found.</div>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border bg-card">
|
||||
{builtinAdapters.map((adapter) => (
|
||||
<AdapterRow
|
||||
key={adapter.type}
|
||||
adapter={adapter}
|
||||
canRemove={false}
|
||||
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
|
||||
onRemove={() => {}}
|
||||
isToggling={isMutating}
|
||||
/>
|
||||
))}
|
||||
{overriddenBuiltins.map((virtual) => (
|
||||
<AdapterRow
|
||||
key={virtual.type}
|
||||
adapter={{
|
||||
type: virtual.type,
|
||||
label: virtual.label,
|
||||
source: "builtin",
|
||||
modelsCount: 0,
|
||||
loaded: true,
|
||||
disabled: virtual.menuDisabled,
|
||||
}}
|
||||
canRemove={false}
|
||||
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
|
||||
onRemove={() => {}}
|
||||
isToggling={isMutating}
|
||||
overriddenBy={virtual.overridePaused ? undefined : virtual.overriddenBy}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Remove confirmation */}
|
||||
<Dialog
|
||||
open={removeType !== null}
|
||||
onOpenChange={(open) => { if (!open) setRemoveType(null); }}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Adapter</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove the <strong>{removeType}</strong> adapter?
|
||||
It will be unregistered and removed from the adapter store.
|
||||
{removeType && adapters?.find((a) => a.type === removeType)?.packageName && (
|
||||
<> npm packages will be cleaned up from disk.</>
|
||||
)}
|
||||
{" "}This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemoveType(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={removeMutation.isPending}
|
||||
onClick={() => {
|
||||
if (removeType) {
|
||||
removeMutation.mutate(removeType, {
|
||||
onSettled: () => setRemoveType(null),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removeMutation.isPending ? "Removing..." : "Remove"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Reinstall confirmation */}
|
||||
<ReinstallDialog
|
||||
adapter={reinstallTarget}
|
||||
open={reinstallTarget !== null}
|
||||
isReinstalling={reinstallMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (reinstallTarget) {
|
||||
reinstallMutation.mutate(reinstallTarget.type, {
|
||||
onSettled: () => setReinstallTarget(null),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={() => setReinstallTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+138
-69
@@ -27,7 +27,7 @@ import { PageTabBar } from "../components/PageTabBar";
|
||||
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
|
||||
import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { getUIAdapter, buildTranscript } from "../adapters";
|
||||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
@@ -263,12 +263,16 @@ function runMetrics(run: HeartbeatRun) {
|
||||
);
|
||||
const cost =
|
||||
visibleRunCostUsd(usage, result);
|
||||
const provider = asNonEmptyString(usage?.provider) ?? null;
|
||||
const model = asNonEmptyString(usage?.model) ?? null;
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cached,
|
||||
cost,
|
||||
totalTokens: input + output,
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,6 +289,98 @@ function asNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function RunInvocationCard({
|
||||
payload,
|
||||
censorUsernameInLogs,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
censorUsernameInLogs: boolean;
|
||||
}) {
|
||||
const commandLine = [
|
||||
typeof payload.command === "string" ? payload.command : null,
|
||||
...(Array.isArray(payload.commandArgs)
|
||||
? payload.commandArgs.filter((value): value is string => typeof value === "string")
|
||||
: []),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" ");
|
||||
|
||||
const hasAdvancedDetails =
|
||||
commandLine.length > 0
|
||||
|| (Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0)
|
||||
|| payload.prompt !== undefined
|
||||
|| payload.context !== undefined
|
||||
|| payload.env !== undefined;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
|
||||
{typeof payload.adapterType === "string" && (
|
||||
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{payload.adapterType}</div>
|
||||
)}
|
||||
{typeof payload.cwd === "string" && (
|
||||
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{payload.cwd}</span></div>
|
||||
)}
|
||||
{hasAdvancedDetails && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
|
||||
Details
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-2">
|
||||
{commandLine && (
|
||||
<div className="text-xs break-all">
|
||||
<span className="text-muted-foreground">Command: </span>
|
||||
<span className="font-mono">{commandLine}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{payload.commandNotes
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.map((note, idx) => (
|
||||
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{payload.prompt !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{typeof payload.prompt === "string"
|
||||
? redactPathText(payload.prompt, censorUsernameInLogs)
|
||||
: JSON.stringify(redactPathValue(payload.prompt, censorUsernameInLogs), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{payload.context !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Context</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(redactPathValue(payload.context, censorUsernameInLogs), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{payload.env !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Environment</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{formatEnvForDisplay(payload.env, censorUsernameInLogs)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseStoredLogContent(content: string): RunLogChunk[] {
|
||||
const parsed: RunLogChunk[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
@@ -1035,6 +1131,7 @@ export function AgentDetail() {
|
||||
agentRouteId={canonicalAgentRef}
|
||||
selectedRunId={urlRunId ?? null}
|
||||
adapterType={agent.adapterType}
|
||||
adapterConfig={agent.adapterConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2813,6 +2910,7 @@ function RunsTab({
|
||||
agentRouteId,
|
||||
selectedRunId,
|
||||
adapterType,
|
||||
adapterConfig,
|
||||
}: {
|
||||
runs: HeartbeatRun[];
|
||||
companyId: string;
|
||||
@@ -2820,6 +2918,7 @@ function RunsTab({
|
||||
agentRouteId: string;
|
||||
selectedRunId: string | null;
|
||||
adapterType: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
@@ -2848,7 +2947,7 @@ function RunsTab({
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to runs
|
||||
</Link>
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} adapterConfig={adapterConfig} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2879,7 +2978,7 @@ function RunsTab({
|
||||
{/* Right: run detail — natural height, page scrolls */}
|
||||
{selectedRun && (
|
||||
<div className="flex-1 min-w-0 pl-4">
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} adapterConfig={adapterConfig} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2888,7 +2987,7 @@ function RunsTab({
|
||||
|
||||
/* ---- Run Detail (expanded) ---- */
|
||||
|
||||
function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
|
||||
function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }: { run: HeartbeatRun; agentRouteId: string; adapterType: string; adapterConfig: Record<string, unknown> }) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { data: hydratedRun } = useQuery({
|
||||
@@ -3082,6 +3181,27 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 (
|
||||
<div className="text-[11px] text-muted-foreground font-mono flex items-center gap-1.5 flex-wrap">
|
||||
{adapterType && (
|
||||
<span className="bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide">{adapterType.replace(/_/g, " ")}</span>
|
||||
)}
|
||||
{displayProvider && displayModel && (
|
||||
<span>{displayProvider}/{displayModel}</span>
|
||||
)}
|
||||
{!displayProvider && displayModel && (
|
||||
<span>{displayModel}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{resumeRun.isError && (
|
||||
<div className="text-xs text-destructive">
|
||||
{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 && (
|
||||
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
|
||||
{typeof adapterInvokePayload.adapterType === "string" && (
|
||||
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{adapterInvokePayload.adapterType}</div>
|
||||
)}
|
||||
{typeof adapterInvokePayload.cwd === "string" && (
|
||||
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{adapterInvokePayload.cwd}</span></div>
|
||||
)}
|
||||
{typeof adapterInvokePayload.command === "string" && (
|
||||
<div className="text-xs break-all">
|
||||
<span className="text-muted-foreground">Command: </span>
|
||||
<span className="font-mono">
|
||||
{[
|
||||
adapterInvokePayload.command,
|
||||
...(Array.isArray(adapterInvokePayload.commandArgs)
|
||||
? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string")
|
||||
: []),
|
||||
].join(" ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{adapterInvokePayload.commandNotes
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.map((note, idx) => (
|
||||
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{adapterInvokePayload.prompt !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{typeof adapterInvokePayload.prompt === "string"
|
||||
? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
|
||||
: JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{adapterInvokePayload.context !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Context</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{adapterInvokePayload.env !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Environment</div>
|
||||
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RunInvocationCard payload={adapterInvokePayload} censorUsernameInLogs={censorUsernameInLogs} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
+3
-13
@@ -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<string, string> = {
|
||||
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<string, string>;
|
||||
|
||||
@@ -263,7 +253,7 @@ export function Agents() {
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
@@ -364,7 +354,7 @@ function OrgTreeNode({
|
||||
{agent && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
|
||||
@@ -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 ───────────────────────────────
|
||||
|
||||
@@ -12,20 +12,9 @@ import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
type JoinType = "human" | "agent";
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
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) => (
|
||||
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
|
||||
{adapterLabels[type]}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
||||
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -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<CreateConfigValues["adapterType"]>([
|
||||
"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"]);
|
||||
|
||||
@@ -116,17 +116,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
|
||||
|
||||
// ── Status dot colors (raw hex for SVG) ─────────────────────────────────
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
running: "#22d3ee",
|
||||
@@ -426,7 +416,7 @@ export function OrgChart() {
|
||||
</span>
|
||||
{agent && (
|
||||
<span className="text-[10px] text-muted-foreground/60 font-mono leading-tight mt-1">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
)}
|
||||
{agent && agent.capabilities && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user