From 37b6ad42eabf9a755150c1acdbb585bad0e51c6c Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 3 Apr 2026 07:32:54 -0500 Subject: [PATCH 01/49] Add versioned telemetry events Co-Authored-By: Paperclip --- packages/shared/src/telemetry/client.ts | 1 + packages/shared/src/telemetry/events.ts | 42 +++++ packages/shared/src/telemetry/index.ts | 6 + packages/shared/src/telemetry/types.ts | 7 + .../src/__tests__/agent-skills-routes.test.ts | 20 +++ .../__tests__/company-skills-routes.test.ts | 109 ++++++++++++ .../project-goal-telemetry-routes.test.ts | 115 ++++++++++++ .../__tests__/routine-run-telemetry.test.ts | 163 ++++++++++++++++++ server/src/__tests__/routines-routes.test.ts | 18 ++ .../__tests__/telemetry-client-flush.test.ts | 19 ++ server/src/routes/agents.ts | 10 ++ server/src/routes/company-skills.ts | 39 +++++ server/src/routes/goals.ts | 6 + server/src/routes/projects.ts | 6 + server/src/routes/routines.ts | 6 + server/src/services/routines.ts | 10 ++ 16 files changed, 577 insertions(+) create mode 100644 server/src/__tests__/project-goal-telemetry-routes.test.ts create mode 100644 server/src/__tests__/routine-run-telemetry.test.ts diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts index 939a32ed..a8d6aefb 100644 --- a/packages/shared/src/telemetry/client.ts +++ b/packages/shared/src/telemetry/client.ts @@ -58,6 +58,7 @@ export class TelemetryClient { app, schemaVersion, installId: state.installId, + version: this.version, events, }), signal: controller.signal, diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index 1ed96bb6..6b30995e 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -23,6 +23,48 @@ export function trackCompanyImported( }); } +export function trackProjectCreated(client: TelemetryClient): void { + client.track("project.created"); +} + +export function trackRoutineCreated(client: TelemetryClient): void { + client.track("routine.created"); +} + +export function trackRoutineRun( + client: TelemetryClient, + dims: { source: string; status: string }, +): void { + client.track("routine.run", { + source: dims.source, + status: dims.status, + }); +} + +export function trackGoalCreated( + client: TelemetryClient, + dims?: { goalLevel?: string | null }, +): void { + client.track("goal.created", dims?.goalLevel ? { goal_level: dims.goalLevel } : undefined); +} + +export function trackAgentCreated( + client: TelemetryClient, + dims: { agentRole: string }, +): void { + client.track("agent.created", { agent_role: dims.agentRole }); +} + +export function trackSkillImported( + client: TelemetryClient, + dims: { sourceType: string; skillRef?: string | null }, +): void { + client.track("skill.imported", { + source_type: dims.sourceType, + ...(dims.skillRef ? { skill_ref: dims.skillRef } : {}), + }); +} + export function trackAgentFirstHeartbeat( client: TelemetryClient, dims: { agentRole: string }, diff --git a/packages/shared/src/telemetry/index.ts b/packages/shared/src/telemetry/index.ts index 1757276e..f80de29c 100644 --- a/packages/shared/src/telemetry/index.ts +++ b/packages/shared/src/telemetry/index.ts @@ -5,6 +5,12 @@ export { trackInstallStarted, trackInstallCompleted, trackCompanyImported, + trackProjectCreated, + trackRoutineCreated, + trackRoutineRun, + trackGoalCreated, + trackAgentCreated, + trackSkillImported, trackAgentFirstHeartbeat, trackAgentTaskCompleted, trackErrorHandlerCrash, diff --git a/packages/shared/src/telemetry/types.ts b/packages/shared/src/telemetry/types.ts index a8e3d4dc..d3552d0d 100644 --- a/packages/shared/src/telemetry/types.ts +++ b/packages/shared/src/telemetry/types.ts @@ -24,6 +24,7 @@ export interface TelemetryEventEnvelope { app: string; schemaVersion: string; installId: string; + version: string; events: TelemetryEvent[]; } @@ -31,6 +32,12 @@ export type TelemetryEventName = | "install.started" | "install.completed" | "company.imported" + | "project.created" + | "routine.created" + | "routine.run" + | "goal.created" + | "agent.created" + | "skill.imported" | "agent.first_heartbeat" | "agent.task_completed" | "error.handler_crash" diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 8590d988..71be4cc7 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -51,12 +51,28 @@ const mockSecretService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockAdapter = vi.hoisted(() => ({ listSkills: vi.fn(), syncSkills: vi.fn(), })); +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackAgentCreated: mockTrackAgentCreated, + }; +}); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + vi.mock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => mockAgentInstructionsService, @@ -132,6 +148,7 @@ function makeAgent(adapterType: string) { describe("agent skill routes", () => { beforeEach(() => { vi.resetAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent("claude_local"), @@ -330,6 +347,9 @@ describe("agent skill routes", () => { }), }), ); + expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), { + agentRole: "engineer", + }); }); it("materializes a managed AGENTS.md for directly created local agents", async () => { diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 8ac0785d..821dc723 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -18,6 +18,22 @@ const mockCompanySkillService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackSkillImported = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackSkillImported: mockTrackSkillImported, + }; +}); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, @@ -41,6 +57,7 @@ function createApp(actor: Record) { describe("company skill mutation permissions", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockCompanySkillService.importFromSource.mockResolvedValue({ imported: [], warnings: [], @@ -68,6 +85,98 @@ describe("company skill mutation permissions", () => { ); }); + it("tracks public GitHub skill imports with an explicit skill reference", async () => { + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [ + { + id: "skill-1", + companyId: "company-1", + key: "vercel-labs/agent-browser/find-skills", + slug: "find-skills", + name: "Find Skills", + description: null, + markdown: "# Find Skills", + sourceType: "github", + sourceLocator: "https://github.com/vercel-labs/agent-browser", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [], + metadata: { + hostname: "github.com", + owner: "vercel-labs", + repo: "agent-browser", + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + warnings: [], + }); + + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), { + sourceType: "github", + skillRef: "vercel-labs/agent-browser/find-skills", + }); + }); + + it("does not expose a skill reference for non-public skill imports", async () => { + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [ + { + id: "skill-1", + companyId: "company-1", + key: "private-skill", + slug: "private-skill", + name: "Private Skill", + description: null, + markdown: "# Private Skill", + sourceType: "github", + sourceLocator: "https://ghe.example.com/acme/private-skill", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [], + metadata: { + hostname: "ghe.example.com", + owner: "acme", + repo: "private-skill", + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + warnings: [], + }); + + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://ghe.example.com/acme/private-skill" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), { + sourceType: "github", + skillRef: null, + }); + }); + it("blocks same-company agents without management permission from mutating company skills", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", diff --git a/server/src/__tests__/project-goal-telemetry-routes.test.ts b/server/src/__tests__/project-goal-telemetry-routes.test.ts new file mode 100644 index 00000000..ac41af63 --- /dev/null +++ b/server/src/__tests__/project-goal-telemetry-routes.test.ts @@ -0,0 +1,115 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { projectRoutes } from "../routes/projects.js"; +import { goalRoutes } from "../routes/goals.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockProjectService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + createWorkspace: vi.fn(), + resolveByReference: vi.fn(), +})); + +const mockGoalService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackProjectCreated = vi.hoisted(() => vi.fn()); +const mockTrackGoalCreated = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackProjectCreated: mockTrackProjectCreated, + trackGoalCreated: mockTrackGoalCreated, + }; +}); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + goalService: () => mockGoalService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../services/workspace-runtime.js", () => ({ + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), +})); + +function createApp(route: ReturnType | ReturnType) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "board-user", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", route); + app.use(errorHandler); + return app; +} + +describe("project and goal telemetry routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockProjectService.create.mockResolvedValue({ + id: "project-1", + companyId: "company-1", + name: "Telemetry project", + description: null, + status: "backlog", + }); + mockGoalService.create.mockResolvedValue({ + id: "goal-1", + companyId: "company-1", + title: "Telemetry goal", + description: null, + level: "team", + status: "planned", + }); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("emits telemetry when a project is created", async () => { + const res = await request(createApp(projectRoutes({} as any))) + .post("/api/companies/company-1/projects") + .send({ name: "Telemetry project" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackProjectCreated).toHaveBeenCalledWith(expect.anything()); + }); + + it("emits telemetry when a goal is created", async () => { + const res = await request(createApp(goalRoutes({} as any))) + .post("/api/companies/company-1/goals") + .send({ title: "Telemetry goal", level: "team" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackGoalCreated).toHaveBeenCalledWith(expect.anything(), { goalLevel: "team" }); + }); +}); diff --git a/server/src/__tests__/routine-run-telemetry.test.ts b/server/src/__tests__/routine-run-telemetry.test.ts new file mode 100644 index 00000000..513ba6e3 --- /dev/null +++ b/server/src/__tests__/routine-run-telemetry.test.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agents, + companies, + createDb, + executionWorkspaces, + heartbeatRuns, + issues, + projectWorkspaces, + projects, + routineRuns, + routines, + routineTriggers, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() })); +const mockTrackRoutineRun = vi.hoisted(() => vi.fn()); + +vi.mock("../telemetry.ts", () => ({ + getTelemetryClient: () => mockTelemetryClient, +})); + +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackRoutineRun: mockTrackRoutineRun, + }; +}); + +import { routineService } from "../services/routines.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("routine run telemetry", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routine-telemetry-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + vi.clearAllMocks(); + await db.delete(routineRuns); + await db.delete(routineTriggers); + await db.delete(routines); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedFixture() { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Routines", + status: "in_progress", + }); + + const svc = routineService(db, { + heartbeat: { + wakeup: async (wakeupAgentId, wakeupOpts) => { + const issueId = + (typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) + || (typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) + || null; + if (!issueId) return null; + const queuedRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, + companyId, + agentId: wakeupAgentId, + invocationSource: wakeupOpts.source ?? "assignment", + triggerDetail: wakeupOpts.triggerDetail ?? null, + status: "queued", + contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId }, + }); + await db + .update(issues) + .set({ + executionRunId: queuedRunId, + executionLockedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + return { id: queuedRunId }; + }, + }, + }); + + const routine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "Run telemetry test", + description: "Routine body", + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, + {}, + ); + + return { routine, svc }; + } + + it("emits telemetry for routine runs from the service layer", async () => { + const { routine, svc } = await seedFixture(); + + const run = await svc.runRoutine(routine.id, { source: "manual" }); + + expect(run.status).toBe("issue_created"); + expect(mockTrackRoutineRun).toHaveBeenCalledWith(mockTelemetryClient, { + source: "manual", + status: "issue_created", + }); + }); +}); diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 0c3c0b2b..aeb943c0 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -82,6 +82,22 @@ const mockAccessService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackRoutineCreated: mockTrackRoutineCreated, + }; +}); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, @@ -104,6 +120,7 @@ function createApp(actor: Record) { describe("routine routes", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); mockRoutineService.getTrigger.mockResolvedValue(trigger); @@ -267,5 +284,6 @@ describe("routine routes", () => { agentId: null, userId: "board-user", }); + expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything()); }); }); diff --git a/server/src/__tests__/telemetry-client-flush.test.ts b/server/src/__tests__/telemetry-client-flush.test.ts index b057ef9d..2264638c 100644 --- a/server/src/__tests__/telemetry-client-flush.test.ts +++ b/server/src/__tests__/telemetry-client-flush.test.ts @@ -33,6 +33,25 @@ describe("TelemetryClient periodic flush", () => { await vi.advanceTimersByTimeAsync(1000); expect(fetch).toHaveBeenCalledTimes(1); + const lastCall = vi.mocked(fetch).mock.calls.at(-1); + expect(lastCall?.[0]).toBe("http://localhost:9999/ingest"); + const requestInit = lastCall?.[1] as RequestInit | undefined; + expect(requestInit?.method).toBe("POST"); + expect(requestInit?.headers).toEqual({ "Content-Type": "application/json" }); + const body = JSON.parse(String(requestInit?.body ?? "{}")); + expect(body).toMatchObject({ + app: "paperclip", + schemaVersion: "1", + installId: "test-install", + version: "0.0.0-test", + events: [ + { + name: "install.started", + dimensions: {}, + }, + ], + }); + expect(body.events[0]?.occurredAt).toEqual(expect.any(String)); // Second tick with no new events — no additional call await vi.advanceTimersByTimeAsync(1000); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 8ec5ffb1..68084040 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -27,6 +27,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; +import { trackAgentCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { agentService, @@ -62,6 +63,7 @@ import { loadDefaultAgentInstructionsBundle, resolveDefaultAgentInstructionsBundleRole, } from "../services/default-agent-instructions.js"; +import { getTelemetryClient } from "../telemetry.js"; export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { @@ -1387,6 +1389,10 @@ export function agentRoutes(db: Db) { desiredSkills: desiredSkillAssignment.desiredSkills, }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackAgentCreated(telemetryClient, { agentRole: agent.role }); + } await applyDefaultAgentTaskAssignGrant( companyId, @@ -1469,6 +1475,10 @@ export function agentRoutes(db: Db) { desiredSkills: desiredSkillAssignment.desiredSkills, }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackAgentCreated(telemetryClient, { agentRole: agent.role }); + } await applyDefaultAgentTaskAssignGrant( companyId, diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 7b239832..5f2ca739 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -6,10 +6,20 @@ import { companySkillImportSchema, companySkillProjectScanRequestSchema, } from "@paperclipai/shared"; +import { trackSkillImported } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, agentService, companySkillService, logActivity } from "../services/index.js"; import { forbidden } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { getTelemetryClient } from "../telemetry.js"; + +type SkillTelemetryInput = { + key: string; + slug: string; + sourceType: string; + sourceLocator: string | null; + metadata: Record | null; +}; export function companySkillRoutes(db: Db) { const router = Router(); @@ -22,6 +32,26 @@ export function companySkillRoutes(db: Db) { return Boolean((agent.permissions as Record).canCreateAgents); } + function asString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null { + if (skill.sourceType === "skills_sh") { + return skill.key; + } + if (skill.sourceType !== "github") { + return null; + } + const hostname = asString(skill.metadata?.hostname) ?? "github.com"; + if (hostname !== "github.com") { + return null; + } + return skill.key; + } + async function assertCanMutateCompanySkills(req: Request, companyId: string) { assertCompanyAccess(req, companyId); @@ -183,6 +213,15 @@ export function companySkillRoutes(db: Db) { warningCount: result.warnings.length, }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + for (const skill of result.imported) { + trackSkillImported(telemetryClient, { + sourceType: skill.sourceType, + skillRef: deriveTrackedSkillRef(skill), + }); + } + } res.status(201).json(result); }, diff --git a/server/src/routes/goals.ts b/server/src/routes/goals.ts index 450f9467..2f090dad 100644 --- a/server/src/routes/goals.ts +++ b/server/src/routes/goals.ts @@ -1,9 +1,11 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; import { createGoalSchema, updateGoalSchema } from "@paperclipai/shared"; +import { trackGoalCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { goalService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { getTelemetryClient } from "../telemetry.js"; export function goalRoutes(db: Db) { const router = Router(); @@ -42,6 +44,10 @@ export function goalRoutes(db: Db) { entityId: goal.id, details: { title: goal.title }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackGoalCreated(telemetryClient, { goalLevel: goal.level }); + } res.status(201).json(goal); }); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index b200b354..482a6983 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -7,11 +7,13 @@ import { updateProjectSchema, updateProjectWorkspaceSchema, } from "@paperclipai/shared"; +import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; +import { getTelemetryClient } from "../telemetry.js"; export function projectRoutes(db: Db) { const router = Router(); @@ -107,6 +109,10 @@ export function projectRoutes(db: Db) { workspaceId: createdWorkspaceId, }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackProjectCreated(telemetryClient); + } res.status(201).json(hydratedProject ?? project); }); diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index e7887e88..7045a52d 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -8,10 +8,12 @@ import { updateRoutineSchema, updateRoutineTriggerSchema, } from "@paperclipai/shared"; +import { trackRoutineCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, logActivity, routineService } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { forbidden, unauthorized } from "../errors.js"; +import { getTelemetryClient } from "../telemetry.js"; export function routineRoutes(db: Db) { const router = Router(); @@ -76,6 +78,10 @@ export function routineRoutes(db: Db) { entityId: created.id, details: { title: created.title, assigneeAgentId: created.assigneeAgentId }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackRoutineCreated(telemetryClient); + } res.status(201).json(created); }); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 323462e0..f1f9e1ef 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -31,8 +31,10 @@ import { stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, } from "@paperclipai/shared"; +import { trackRoutineRun } from "@paperclipai/shared/telemetry"; import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; import { logger } from "../middleware/logger.js"; +import { getTelemetryClient } from "../telemetry.js"; import { issueService } from "./issues.js"; import { secretService } from "./secrets.js"; import { parseCron, validateCron } from "./cron.js"; @@ -856,6 +858,14 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup } } + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackRoutineRun(telemetryClient, { + source: run.source, + status: run.status, + }); + } + return run; } From 9b3ad6e61672dbc289888409ffd634f2d01026b8 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 3 Apr 2026 09:43:58 -0500 Subject: [PATCH 02/49] Fix telemetry test mocking in agent skill routes Co-Authored-By: Paperclip --- server/src/__tests__/agent-skills-routes.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 71be4cc7..eeec658e 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -59,15 +59,9 @@ const mockAdapter = vi.hoisted(() => ({ syncSkills: vi.fn(), })); -vi.mock("@paperclipai/shared/telemetry", async () => { - const actual = await vi.importActual( - "@paperclipai/shared/telemetry", - ); - return { - ...actual, - trackAgentCreated: mockTrackAgentCreated, - }; -}); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentCreated: mockTrackAgentCreated, +})); vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient, From 728fbdd199839a0a70b825ce603b3a09456e56c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A6=A8=E5=86=89?= Date: Fri, 3 Apr 2026 23:50:48 +0800 Subject: [PATCH 03/49] Fix markdown paste handling in document editor (#2572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #2499. ## Thinking Path 1. **Project context**: Paperclip uses a markdown editor (`MarkdownEditor`) for document editing. Users expect to paste markdown-formatted text from external sources (like code editors, other documents) and have it render correctly. 2. **Problem identification**: When users paste plain text containing markdown syntax (e.g., `# Heading`, `- list item`), the editor was treating it as plain text, resulting in raw markdown syntax being displayed rather than formatted content. 3. **Root cause**: The default browser paste behavior doesn't recognize markdown syntax in plain text. The editor needed to intercept paste events and detect when the clipboard content looks like markdown. 4. **Solution design**: - Create a utility (`markdownPaste.ts`) to detect markdown patterns in plain text - Add a paste capture handler in `MarkdownEditor` that intercepts paste events - When markdown is detected, prevent default paste and use `insertMarkdown` instead - Handle edge cases (code blocks, file pastes, HTML content) ## What - Added `ui/src/lib/markdownPaste.ts`: Utility to detect markdown patterns and normalize line endings - Added `ui/src/lib/markdownPaste.test.ts`: Test coverage for markdown detection - Modified `ui/src/components/MarkdownEditor.tsx`: Added paste capture handler to intercept and handle markdown paste ## Why Users frequently copy markdown content from various sources (GitHub, documentation, notes) and expect it to render correctly when pasted into the editor. Without this fix, users see raw markdown syntax (e.g., `# Title` instead of a formatted heading), which degrades the editing experience. ## How to Verify 1. Open any document in Paperclip 2. Copy markdown text from an external source (e.g., `# Heading\n\n- Item 1\n- Item 2`) 3. Paste into the editor 4. **Expected**: The content should render as formatted markdown (heading + bullet list), not as plain text with markdown syntax ### Test Coverage ```bash cd ui npm test -- markdownPaste.test.ts ``` All tests should pass, including: - Windows line ending normalization (`\r\n` → `\n`) - Old-Mac line ending normalization (`\r` → `\n`) - Markdown block detection (headings, lists, code fences, etc.) - Plain text rejection (non-markdown content) ## Risks 1. **False positives**: Plain text containing markdown-like characters (e.g., a paragraph starting with `#` as a hashtag) may be incorrectly treated as markdown. The detection uses a heuristic that requires block-level markdown patterns, which reduces but doesn't eliminate this risk. 2. **Removed focus guard**: The previous implementation used `isFocusedRef` to prevent `onChange` from firing during programmatic `setMarkdown` calls. This guard was removed as part of refactoring. The assumption is that MDXEditor does not fire `onChange` during `setMarkdown`, but this should be monitored for unexpected parent update loops. 3. **Clipboard compatibility**: The paste handler specifically looks for `text/plain` content and ignores `text/html` (to preserve existing HTML paste behavior). This means pasting from rich text editors that provide both HTML and plain text will continue to use the HTML path, which may or may not be the desired behavior. --------- Co-authored-by: 馨冉 --- .../src/__tests__/workspace-runtime.test.ts | 14 +- ui/src/components/MarkdownEditor.tsx | 130 +++++++++++------- ui/src/lib/markdownPaste.test.ts | 50 +++++++ ui/src/lib/markdownPaste.ts | 23 ++++ 4 files changed, 161 insertions(+), 56 deletions(-) create mode 100644 ui/src/lib/markdownPaste.test.ts create mode 100644 ui/src/lib/markdownPaste.ts diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 8bf008bd..f158a5e9 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { parse as parseEnvContents } from "dotenv"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, @@ -540,13 +541,12 @@ describe("realizeExecutionWorkspace", () => { path.join(expectedInstanceRoot, "secrets", "master.key"), ); expect(envContents).not.toContain("DATABASE_URL="); - expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`); - expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`); - expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`); - expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true"); - expect(envContents).toContain( - `PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`, - ); + const envVars = parseEnvContents(envContents); + expect(envVars.PAPERCLIP_HOME).toBe(isolatedWorktreeHome); + expect(envVars.PAPERCLIP_INSTANCE_ID).toBe(expectedInstanceId); + expect(await fs.realpath(envVars.PAPERCLIP_CONFIG!)).toBe(await fs.realpath(configPath)); + expect(envVars.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(envVars.PAPERCLIP_WORKTREE_NAME).toBe("PAP-885-show-worktree-banner"); process.chdir(workspace.cwd); expect(resolvePaperclipConfigPath()).toBe(configPath); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index fb9b0fff..b77f926c 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1,4 +1,5 @@ import { + type ClipboardEvent, forwardRef, useCallback, useEffect, @@ -32,6 +33,7 @@ import { AgentIcon } from "./AgentIconPicker"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node"; import { mentionDeletionPlugin } from "../lib/mention-deletion"; +import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "../lib/markdownPaste"; import { cn } from "../lib/utils"; /* ---- Mention types ---- */ @@ -167,6 +169,24 @@ function detectMention(container: HTMLElement): MentionState | null { }; } +function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { + if (!node || !container.contains(node)) return false; + const el = node.nodeType === Node.ELEMENT_NODE + ? (node as HTMLElement) + : node.parentElement; + return Boolean(el?.closest("pre, code")); +} + +function isSelectionInsideCodeLikeElement(container: HTMLElement | null) { + if (!container) return false; + const selection = window.getSelection(); + if (!selection) return false; + for (const node of [selection.anchorNode, selection.focusNode]) { + if (nodeInsideCodeLike(container, node)) return true; + } + return false; +} + function mentionMarkdown(option: MentionOption): string { if (option.kind === "project" && option.projectId) { return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `; @@ -199,11 +219,17 @@ export const MarkdownEditor = forwardRef onSubmit, }: MarkdownEditorProps, forwardedRef) { const containerRef = useRef(null); - const editorRef = useRef(null); + const ref = useRef(null); + const valueRef = useRef(value); + valueRef.current = value; const latestValueRef = useRef(value); - const latestPropValueRef = useRef(value); - const pendingExternalValueRef = useRef(null); - const isFocusedRef = useRef(false); + const initialChildOnChangeRef = useRef(true); + /** + * After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange` + * with the same markdown. Skip notifying the parent for that echo so controlled parents that + * normalize or transform values cannot loop. Replaces the older blur/focus gate for the same concern. + */ + const echoIgnoreMarkdownRef = useRef(null); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); @@ -237,9 +263,19 @@ export const MarkdownEditor = forwardRef return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8); }, [mentionState?.query, mentions]); + const setEditorRef = useCallback((instance: MDXEditorMethods | null) => { + ref.current = instance; + if (instance) { + const v = valueRef.current; + echoIgnoreMarkdownRef.current = v; + instance.setMarkdown(v); + latestValueRef.current = v; + } + }, []); + useImperativeHandle(forwardedRef, () => ({ focus: () => { - editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" }); + ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }, }), []); @@ -266,10 +302,11 @@ export const MarkdownEditor = forwardRef ); if (updated !== current) { latestValueRef.current = updated; - editorRef.current?.setMarkdown(updated); + echoIgnoreMarkdownRef.current = updated; + ref.current?.setMarkdown(updated); onChange(updated); requestAnimationFrame(() => { - editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" }); + ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }); } }, 100); @@ -303,29 +340,14 @@ export const MarkdownEditor = forwardRef return all; }, [hasImageUpload]); - const handleEditorRef = useCallback((instance: MDXEditorMethods | null) => { - editorRef.current = instance; - if (!instance) return; - - const pendingValue = pendingExternalValueRef.current; - if (pendingValue !== null && pendingValue !== latestValueRef.current) { - instance.setMarkdown(pendingValue); - latestValueRef.current = pendingValue; - } - pendingExternalValueRef.current = null; - }, []); - - latestPropValueRef.current = value; - useEffect(() => { if (value !== latestValueRef.current) { - if (!editorRef.current) { - pendingExternalValueRef.current = value; - return; + if (ref.current) { + // Pair with onChange echo suppression (echoIgnoreMarkdownRef). + echoIgnoreMarkdownRef.current = value; + ref.current.setMarkdown(value); + latestValueRef.current = value; } - editorRef.current.setMarkdown(value); - latestValueRef.current = value; - pendingExternalValueRef.current = null; } }, [value]); @@ -416,7 +438,8 @@ export const MarkdownEditor = forwardRef const next = applyMention(current, state.query, option); if (next !== current) { latestValueRef.current = next; - editorRef.current?.setMarkdown(next); + echoIgnoreMarkdownRef.current = next; + ref.current?.setMarkdown(next); onChange(next); } @@ -486,6 +509,19 @@ export const MarkdownEditor = forwardRef } const canDropImage = Boolean(imageUploadHandler); + const handlePasteCapture = useCallback((event: ClipboardEvent) => { + const clipboard = event.clipboardData; + if (!clipboard || !ref.current) return; + const types = new Set(Array.from(clipboard.types)); + if (types.has("Files") || types.has("text/html")) return; + if (isSelectionInsideCodeLikeElement(containerRef.current)) return; + + const rawText = clipboard.getData("text/plain"); + if (!looksLikeMarkdownPaste(rawText)) return; + + event.preventDefault(); + ref.current.insertMarkdown(normalizePastedMarkdown(rawText)); + }, []); return (
dragDepthRef.current = 0; setIsDragOver(false); }} - onFocusCapture={() => { - isFocusedRef.current = true; - }} - onBlurCapture={() => { - isFocusedRef.current = false; - }} + onPasteCapture={handlePasteCapture} > { - const externalValue = latestPropValueRef.current; - if (!isFocusedRef.current) { - if (next === externalValue) { - latestValueRef.current = externalValue; - return; - } - - latestValueRef.current = externalValue; - if (editorRef.current) { - editorRef.current.setMarkdown(externalValue); - pendingExternalValueRef.current = null; - } else { - pendingExternalValueRef.current = externalValue; - } + const echo = echoIgnoreMarkdownRef.current; + if (echo !== null && next === echo) { + echoIgnoreMarkdownRef.current = null; + latestValueRef.current = next; return; } + if (echo !== null) { + echoIgnoreMarkdownRef.current = null; + } + if (initialChildOnChangeRef.current) { + initialChildOnChangeRef.current = false; + if (next === "" && value !== "") { + echoIgnoreMarkdownRef.current = value; + ref.current?.setMarkdown(value); + return; + } + } latestValueRef.current = next; onChange(next); }} diff --git a/ui/src/lib/markdownPaste.test.ts b/ui/src/lib/markdownPaste.test.ts new file mode 100644 index 00000000..9a84f903 --- /dev/null +++ b/ui/src/lib/markdownPaste.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "./markdownPaste"; + +describe("markdownPaste", () => { + it("normalizes windows line endings", () => { + expect(normalizePastedMarkdown("a\r\nb\r\n")).toBe("a\nb\n"); + }); + + it("normalizes old mac line endings", () => { + expect(normalizePastedMarkdown("a\rb\r")).toBe("a\nb\n"); + }); + + it("treats markdown blocks as markdown paste", () => { + expect(looksLikeMarkdownPaste("# Title\n\n- item 1\n- item 2")).toBe(true); + }); + + it("treats a fenced code block as markdown paste", () => { + expect(looksLikeMarkdownPaste("```\nconst x = 1;\n```")).toBe(true); + }); + + it("treats a tilde fence as markdown paste", () => { + expect(looksLikeMarkdownPaste("~~~\nraw\n~~~")).toBe(true); + }); + + it("treats a blockquote as markdown paste", () => { + expect(looksLikeMarkdownPaste("> some quoted text")).toBe(true); + }); + + it("treats an ordered list as markdown paste", () => { + expect(looksLikeMarkdownPaste("1. first\n2. second")).toBe(true); + }); + + it("treats a table row as markdown paste", () => { + expect(looksLikeMarkdownPaste("| col1 | col2 |")).toBe(true); + }); + + it("treats horizontal rules as markdown paste", () => { + expect(looksLikeMarkdownPaste("---")).toBe(true); + expect(looksLikeMarkdownPaste("***")).toBe(true); + expect(looksLikeMarkdownPaste("___")).toBe(true); + }); + + it("leaves plain multi-line text on the native paste path", () => { + expect(looksLikeMarkdownPaste("first paragraph\nsecond paragraph")).toBe(false); + }); + + it("leaves single-line plain text on the native paste path", () => { + expect(looksLikeMarkdownPaste("just a sentence")).toBe(false); + }); +}); diff --git a/ui/src/lib/markdownPaste.ts b/ui/src/lib/markdownPaste.ts new file mode 100644 index 00000000..80a8886d --- /dev/null +++ b/ui/src/lib/markdownPaste.ts @@ -0,0 +1,23 @@ +const BLOCK_MARKER_PATTERNS = [ + /^#{1,6}\s+/m, + /^>\s+/m, + /^[-*+]\s+/m, + /^\d+\.\s+/m, + /^```/m, + /^~~~/m, + /^\|.+\|$/m, + /^---$/m, + /^\*\*\*$/m, + /^___$/m, +]; + +export function normalizePastedMarkdown(text: string): string { + return text.replace(/\r\n?/g, "\n"); +} + +export function looksLikeMarkdownPaste(text: string): boolean { + const normalized = normalizePastedMarkdown(text).trim(); + if (!normalized) return false; + + return BLOCK_MARKER_PATTERNS.some((pattern) => pattern.test(normalized)); +} From aa256fee03c97f4c11e2c2c97ca52f633202d20e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 3 Apr 2026 10:51:26 -0700 Subject: [PATCH 04/49] feat: add authenticated screenshot utility (#2622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents sometimes need to capture UI screenshots for visual verification of fixes > - The Paperclip UI requires authentication, so headless browser screenshots fail without auth > - The CLI already stores a board token in `~/.paperclip/auth.json` > - This pull request adds a Playwright-based screenshot script that reads the board token and injects it as a Bearer header > - The benefit is agents can now take authenticated screenshots of any Paperclip UI page without storing email/password credentials ## What Changed - Added `scripts/screenshot.cjs` — a Node.js script that: - Reads the board token from `~/.paperclip/auth.json` - Launches Chromium via Playwright with the token as an `Authorization` header - Navigates to the specified URL and saves a screenshot - Supports `--width`, `--height`, and `--wait` flags - Accepts both full URLs and path-only shortcuts (e.g., `/PAPA/agents/cto/instructions`) ## Verification ```bash node scripts/screenshot.cjs /PAPA/agents/cto/instructions /tmp/test.png --width 1920 ``` Should produce an authenticated screenshot of the agent instructions page. ## Risks - Low risk — standalone utility script with no impact on the main application. Requires Playwright (already a dev dependency) and a valid board token in `~/.paperclip/auth.json`. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [ ] I have run tests locally and they pass - [ ] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- scripts/screenshot.cjs | 92 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 scripts/screenshot.cjs diff --git a/scripts/screenshot.cjs b/scripts/screenshot.cjs new file mode 100644 index 00000000..f267bd06 --- /dev/null +++ b/scripts/screenshot.cjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Screenshot utility for Paperclip UI. + * + * Reads the board token from ~/.paperclip/auth.json and injects it as a + * Bearer header so Playwright can access authenticated pages. + * + * Usage: + * node scripts/screenshot.cjs [output.png] [--width 1280] [--height 800] [--wait 2000] + * + * Examples: + * node scripts/screenshot.cjs /PAPA/agents/cto/instructions /tmp/shot.png + * node scripts/screenshot.cjs http://localhost:5173/PAPA/agents/cto/instructions + */ + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +// --- CLI args ----------------------------------------------------------- +const args = process.argv.slice(2); +function flag(name, fallback) { + const i = args.indexOf(`--${name}`); + if (i === -1) return fallback; + const val = args.splice(i, 2)[1]; + return Number.isNaN(Number(val)) ? fallback : Number(val); +} +const width = flag("width", 1280); +const height = flag("height", 800); +const waitMs = flag("wait", 2000); + +const rawUrl = args[0]; +const outPath = args[1] || "/tmp/paperclip-screenshot.png"; + +if (!rawUrl) { + console.error("Usage: node scripts/screenshot.cjs [output.png]"); + process.exit(1); +} + +// --- Auth ---------------------------------------------------------------- +function loadBoardToken() { + const authPath = path.resolve(os.homedir(), ".paperclip/auth.json"); + try { + const auth = JSON.parse(fs.readFileSync(authPath, "utf-8")); + const creds = auth.credentials || {}; + const entry = Object.values(creds)[0]; + if (entry && entry.token && entry.apiBase) return { token: entry.token, apiBase: entry.apiBase }; + } catch (_) { + // ignore + } + return null; +} + +const cred = loadBoardToken(); +if (!cred) { + console.error("No board token found in ~/.paperclip/auth.json"); + process.exit(1); +} + +// Resolve URL — if it starts with / treat as path relative to apiBase +const url = rawUrl.startsWith("http") ? rawUrl : `${cred.apiBase}${rawUrl}`; + +// Validate URL before launching browser +const origin = new URL(url).origin; + +// --- Screenshot ---------------------------------------------------------- +(async () => { + const { chromium } = require("playwright"); + const browser = await chromium.launch(); + try { + const context = await browser.newContext({ + viewport: { width, height }, + }); + + const page = await context.newPage(); + // Scope the auth header to the Paperclip origin only + await page.route(`${origin}/**`, async (route) => { + await route.continue({ + headers: { ...route.request().headers(), Authorization: `Bearer ${cred.token}` }, + }); + }); + await page.goto(url, { waitUntil: "networkidle", timeout: 20000 }); + await page.waitForTimeout(waitMs); + await page.screenshot({ path: outPath, fullPage: false }); + console.log(`Saved: ${outPath}`); + } catch (err) { + console.error(`Screenshot failed: ${err.message}`); + process.exitCode = 1; + } finally { + await browser.close(); + } +})(); From 68b2fe20bb45eddcf102a892d4b16f441a90bfa9 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 3 Apr 2026 14:11:11 -0500 Subject: [PATCH 05/49] Address Greptile telemetry review comments Co-Authored-By: Paperclip --- .../__tests__/company-skills-routes.test.ts | 42 +++++++++++++++++++ .../__tests__/routine-run-telemetry.test.ts | 2 +- server/src/routes/company-skills.ts | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 821dc723..3814dc08 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -177,6 +177,48 @@ describe("company skill mutation permissions", () => { }); }); + it("does not expose a skill reference when GitHub metadata is missing", async () => { + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [ + { + id: "skill-1", + companyId: "company-1", + key: "unknown/private-skill", + slug: "private-skill", + name: "Private Skill", + description: null, + markdown: "# Private Skill", + sourceType: "github", + sourceLocator: "https://github.com/acme/private-skill", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [], + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + warnings: [], + }); + + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/acme/private-skill" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), { + sourceType: "github", + skillRef: null, + }); + }); + it("blocks same-company agents without management permission from mutating company skills", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", diff --git a/server/src/__tests__/routine-run-telemetry.test.ts b/server/src/__tests__/routine-run-telemetry.test.ts index 513ba6e3..ded45597 100644 --- a/server/src/__tests__/routine-run-telemetry.test.ts +++ b/server/src/__tests__/routine-run-telemetry.test.ts @@ -22,7 +22,7 @@ import { const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() })); const mockTrackRoutineRun = vi.hoisted(() => vi.fn()); -vi.mock("../telemetry.ts", () => ({ +vi.mock("../telemetry.js", () => ({ getTelemetryClient: () => mockTelemetryClient, })); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 5f2ca739..9e91bf26 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -45,7 +45,7 @@ export function companySkillRoutes(db: Db) { if (skill.sourceType !== "github") { return null; } - const hostname = asString(skill.metadata?.hostname) ?? "github.com"; + const hostname = asString(skill.metadata?.hostname); if (hostname !== "github.com") { return null; } From e13c3f7c6c8f6907f316c008581d1d6f8f1be6dd Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 3 Apr 2026 13:04:56 -0700 Subject: [PATCH 06/49] fix: use deterministic UUID in feedback-service test to avoid phone redaction The PII sanitizer's phone regex matches digit pairs like "4880-8614" that span UUID segment boundaries. Random UUIDs occasionally produce these patterns, causing flaky test failures where sourceRun.id gets partially redacted as [REDACTED_PHONE]. Use a fixed hex-letter-heavy UUID for runId so no cross-boundary digit sequence triggers the phone pattern. Co-Authored-By: Paperclip --- server/src/__tests__/feedback-service.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/feedback-service.test.ts b/server/src/__tests__/feedback-service.test.ts index 898ae0aa..03b4f4c6 100644 --- a/server/src/__tests__/feedback-service.test.ts +++ b/server/src/__tests__/feedback-service.test.ts @@ -187,7 +187,11 @@ describe("feedbackService.saveIssueVote", () => { const targetCommentId = randomUUID(); const earlierCommentId = randomUUID(); const laterCommentId = randomUUID(); - const runId = randomUUID(); + // Use a deterministic UUID whose hyphen-separated segments cannot be + // mistaken for a phone number by the PII redactor's phone regex. + // Random UUIDs occasionally produce digit pairs like "4880-8614" that + // cross segment boundaries and match the phone pattern. + const runId = "abcde123-face-beef-cafe-abcdef654321"; const instructionsDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-feedback-instructions-")); tempDirs.push(instructionsDir); const instructionsPath = path.join(instructionsDir, "AGENTS.md"); From 14d59da316324fa4cacf1e3393ac51d64f98115d Mon Sep 17 00:00:00 2001 From: HenkDz Date: Tue, 31 Mar 2026 20:21:13 +0100 Subject: [PATCH 07/49] feat(adapters): external adapter plugin system with dynamic UI parser - Plugin loader: install/reload/remove/reinstall external adapters from npm packages or local directories - Plugin store persisted at ~/.paperclip/adapter-plugins.json - Self-healing UI parser resolution with version caching - UI: Adapter Manager page, dynamic loader, display registry with humanized names for unknown adapter types - Dev watch: exclude adapter-plugins dir from tsx watcher to prevent mid-request server restarts during reinstall - All consumer fallbacks use getAdapterLabel() for consistent display - AdapterTypeDropdown uses controlled open state for proper close behavior - Remove hermes-local from built-in UI (externalized to plugin) - Add docs for external adapters and UI parser contract --- adapter-plugin.md | 143 +++++ docs/adapters/adapter-ui-parser.md | 287 +++++++++ docs/adapters/claude-local.md | 4 +- docs/adapters/creating-an-adapter.md | 140 ++++- docs/adapters/external-adapters.md | 392 ++++++++++++ docs/adapters/overview.md | 54 +- docs/agents-runtime.md | 10 +- docs/docs.json | 2 + packages/adapter-utils/src/types.ts | 2 +- packages/adapters/claude-local/src/index.ts | 2 +- .../claude-local/src/server/execute.ts | 2 +- .../adapters/claude-local/src/server/test.ts | 2 +- packages/plugins/sdk/src/protocol.ts | 2 +- packages/plugins/sdk/src/testing.ts | 4 +- packages/plugins/sdk/src/types.ts | 7 +- packages/plugins/sdk/src/worker-rpc-host.ts | 4 +- packages/shared/src/adapter-type.ts | 15 + packages/shared/src/adapter-types.test.ts | 38 ++ packages/shared/src/constants.ts | 3 +- packages/shared/src/index.ts | 1 + packages/shared/src/validators/access.ts | 4 +- packages/shared/src/validators/agent.ts | 4 +- server/package.json | 3 +- server/src/__tests__/adapter-registry.test.ts | 58 ++ .../agent-adapter-validation-routes.test.ts | 180 ++++++ .../agent-instructions-routes.test.ts | 2 +- .../src/__tests__/agent-skills-routes.test.ts | 1 + .../__tests__/heartbeat-run-summary.test.ts | 26 +- server/src/adapters/index.ts | 11 +- server/src/adapters/plugin-loader.ts | 262 ++++++++ server/src/adapters/registry.ts | 127 ++-- server/src/app.ts | 2 + server/src/dev-watch-ignore.ts | 3 + server/src/index.ts | 6 + server/src/routes/adapters.ts | 578 ++++++++++++++++++ server/src/routes/agents.ts | 52 +- server/src/services/adapter-plugin-store.ts | 177 ++++++ server/src/services/heartbeat-run-summary.ts | 21 + server/src/services/heartbeat.ts | 15 +- server/src/services/plugin-host-services.ts | 2 +- tsconfig.json | 1 + ui/src/App.tsx | 3 + ui/src/adapters/adapter-display-registry.ts | 151 +++++ ui/src/adapters/disabled-store.ts | 33 + ui/src/adapters/dynamic-loader.ts | 106 ++++ .../adapters/hermes-local/config-fields.tsx | 49 -- ui/src/adapters/hermes-local/index.ts | 12 - ui/src/adapters/index.ts | 9 +- ui/src/adapters/metadata.test.ts | 33 + ui/src/adapters/metadata.ts | 61 ++ ui/src/adapters/registry.test.ts | 50 ++ ui/src/adapters/registry.ts | 132 +++- ui/src/adapters/use-disabled-adapters.ts | 49 ++ ui/src/api/adapters.ts | 51 ++ ui/src/api/agents.ts | 1 + ui/src/components/AgentConfigForm.tsx | 161 +++-- ui/src/components/AgentProperties.tsx | 14 +- ui/src/components/InstanceSidebar.tsx | 3 +- ui/src/components/NewAgentDialog.tsx | 141 ++--- ui/src/components/OnboardingWizard.tsx | 178 ++---- ui/src/components/RunInvocationCard.test.tsx | 38 ++ ui/src/components/agent-config-primitives.tsx | 14 +- .../transcript/RunTranscriptView.test.tsx | 29 + .../transcript/RunTranscriptView.tsx | 13 +- ui/src/lib/queryKeys.ts | 3 + ui/src/pages/AdapterManager.tsx | 483 +++++++++++++++ ui/src/pages/AgentDetail.tsx | 155 +++-- ui/src/pages/Agents.tsx | 16 +- ui/src/pages/CompanyImport.tsx | 3 +- ui/src/pages/InviteLanding.tsx | 17 +- ui/src/pages/NewAgent.tsx | 16 +- ui/src/pages/OrgChart.tsx | 14 +- 72 files changed, 4102 insertions(+), 585 deletions(-) create mode 100644 adapter-plugin.md create mode 100644 docs/adapters/adapter-ui-parser.md create mode 100644 docs/adapters/external-adapters.md create mode 100644 packages/shared/src/adapter-type.ts create mode 100644 packages/shared/src/adapter-types.test.ts create mode 100644 server/src/__tests__/adapter-registry.test.ts create mode 100644 server/src/__tests__/agent-adapter-validation-routes.test.ts create mode 100644 server/src/adapters/plugin-loader.ts create mode 100644 server/src/routes/adapters.ts create mode 100644 server/src/services/adapter-plugin-store.ts create mode 100644 ui/src/adapters/adapter-display-registry.ts create mode 100644 ui/src/adapters/disabled-store.ts create mode 100644 ui/src/adapters/dynamic-loader.ts delete mode 100644 ui/src/adapters/hermes-local/config-fields.tsx delete mode 100644 ui/src/adapters/hermes-local/index.ts create mode 100644 ui/src/adapters/metadata.test.ts create mode 100644 ui/src/adapters/metadata.ts create mode 100644 ui/src/adapters/registry.test.ts create mode 100644 ui/src/adapters/use-disabled-adapters.ts create mode 100644 ui/src/api/adapters.ts create mode 100644 ui/src/components/RunInvocationCard.test.tsx create mode 100644 ui/src/pages/AdapterManager.tsx diff --git a/adapter-plugin.md b/adapter-plugin.md new file mode 100644 index 00000000..13994ba3 --- /dev/null +++ b/adapter-plugin.md @@ -0,0 +1,143 @@ +- Created branch: feat/external-adapter-phase1 + + I started phase 1 in the most merge-friendly way I could: small central changes, reusing existing registry patterns instead of inventing a whole new plugin system up front. + + What I changed + + 1. Server adapter registry is now mutable + Files: + - server/src/adapters/registry.ts + - server/src/adapters/index.ts + + Added: + - registerServerAdapter(adapter) + - unregisterServerAdapter(type) + - requireServerAdapter(type) + + Kept the existing built-in registry shape, but changed initialization so built-ins are registered into a mutable map on startup. + + Why this is merge-friendly: + - existing built-in adapter definitions stay where they already are + - existing lookup helpers still exist + - no big architectural rewrite yet + + 1. Runtime adapter validation moved to server routes + File: + - server/src/routes/agents.ts + + Added: + - assertKnownAdapterType(...) + + Used it in: + - /companies/:companyId/adapters/:type/models + - /companies/:companyId/adapters/:type/detect-model + - /companies/:companyId/adapters/:type/test-environment + - POST /companies/:companyId/agents + - POST /companies/:companyId/agent-hires + - PATCH /agents/:id when adapterType is touched + + Why: + - shared schemas can now allow external adapter strings + - server becomes the real source of truth for “is this adapter actually registered?” + + 1. Shared adapterType validation is now open-ended for inputs + Files: + - packages/shared/src/adapter-type.ts + - packages/shared/src/validators/agent.ts + - packages/shared/src/validators/access.ts + - packages/shared/src/index.ts + + Changed input validation from hardcoded z.enum(AGENT_ADAPTER_TYPES) to: + - agentAdapterTypeSchema + - optionalAgentAdapterTypeSchema + + These accept any non-empty string. + + Important: + - I did not remove AGENT_ADAPTER_TYPES from constants + - I did not change the built-in type unions yet + - this keeps the change minimal and limits blast radius + + So: + - input payloads can carry external adapter types + - actual acceptance still depends on server registry validation + + 1. UI adapter registry is now mutable too + Files: + - ui/src/adapters/registry.ts + - ui/src/adapters/index.ts + + Added: + - registerUIAdapter(adapter) + - unregisterUIAdapter(type) + - findUIAdapter(type) + + Built-ins are still registered from the same file, same pattern as server. + + 1. Began switching UI adapter lists toward the registry + Files: + - ui/src/adapters/metadata.ts + - ui/src/components/AgentConfigForm.tsx + - ui/src/pages/NewAgent.tsx + + Changed: + - AgentConfigForm adapter list now comes from registered UI adapters via metadata helper + - NewAgent supported adapter set now derives from listUIAdapters() + + Why: + - phase 1 should start reducing hardcoded UI lists + - but without rewriting every page at once + + 1. Tests added first, then implementation + New tests: + - server/src/__tests__/adapter-registry.test.ts + - server/src/__tests__/agent-adapter-validation-routes.test.ts + - ui/src/adapters/registry.test.ts + - packages/shared/src/adapter-types.test.ts + + Small test mock fix: + - server/src/__tests__/agent-skills-routes.test.ts + + Current status + + The branch is created and the phase-1 code is started. + + I also followed TDD in spirit: + - wrote failing tests first for missing register/unregister APIs + - then implemented the registry changes + + What is not finished yet + + I have not finished the green/stabilization pass yet. + + I still need to: + - run the targeted tests again after the implementation changes + - fix any TS / mocking fallout + - decide how far to take phase-1 UI wiring in this branch before stopping + + Also, I intentionally did not yet touch the bigger hardcoded surfaces like: + - NewAgentDialog + - OnboardingWizard + - InviteLanding + - duplicated adapterLabels maps + - plugin manifest / plugin loader adapter contributions + + That is deliberate. + For merge safety, I started with the smallest useful seam: + - dynamic server registry + - dynamic UI registry + - runtime validation based on registry + - first couple of UI consumers + + One note + There was already an unrelated dirty change in: + - server/scripts/dev-watch.ts + + I left that alone and did not make phase-1 changes there. + + Recommended next move + I should keep going on this same branch and do the stabilization pass: + 1. rerun server/ui/shared targeted tests + 2. fix failures + 3. run typechecks + 4. then extend the same registry-driven approach to the next safest UI surfaces diff --git a/docs/adapters/adapter-ui-parser.md b/docs/adapters/adapter-ui-parser.md new file mode 100644 index 00000000..a9390001 --- /dev/null +++ b/docs/adapters/adapter-ui-parser.md @@ -0,0 +1,287 @@ +--- +title: Adapter UI Parser Contract +summary: Ship a custom run-log parser so the Paperclip UI renders your adapter's output correctly +--- + +When Paperclip runs an agent, stdout is streamed to the UI in real time. The UI needs a **parser** to convert raw stdout lines into structured transcript entries (tool calls, tool results, assistant messages, system events). Without a custom parser, the UI falls back to a generic shell parser that treats every non-system line as `assistant` output — tool commands leak as plain text, durations are lost, and errors are invisible. + +## The Problem + +Most agent CLIs emit structured stdout with tool calls, progress indicators, and multi-line output. For example: + +``` +[hermes] Session resumed: abc123 +┊ 💬 Thinking about how to approach this... +┊ $ ls /home/user/project +┊ [done] $ ls /home/user/project — /src /README.md 0.3s +┊ 💬 I see the project structure. Let me read the README. +┊ read /home/user/project/README.md +┊ [done] read — Project Overview: A CLI tool for... 1.2s +The project is a CLI tool. Here's what I found: +- It uses TypeScript +- Tests are in /tests +``` + +Without a parser, the UI shows all of this as raw `assistant` text — the tool calls and results are indistinguishable from the agent's actual response. + +With a parser, the UI renders: + +- `Thinking about how to approach this...` as a collapsible thinking block +- `$ ls /home/user/project` as a tool call card (collapsed) +- `0.3s` duration as a tool result card +- `The project is a CLI tool...` as the assistant's response + +## How It Works + +``` +┌──────────────────┐ package.json ┌──────────────────┐ +│ Adapter Package │─── exports["./ui-parser"] ──→│ dist/ui-parser.js │ +│ (npm / local) │ │ (zero imports) │ +└──────────────────┘ └────────┬─────────┘ + │ plugin-loader reads at startup + ▼ +┌──────────────────┐ GET /api/:type/ui-parser.js ┌──────────────────┐ +│ Paperclip Server │◄────────────────────────────────│ uiParserCache │ +│ (in-memory) │ └──────────────────┘ +└────────┬─────────┘ + │ serves JS to browser + ▼ +┌──────────────────┐ fetch() + eval ┌──────────────────┐ +│ Paperclip UI │─────────────────────→│ parseStdoutLine │ +│ (dynamic loader) │ registers parser │ (per-adapter) │ +└──────────────────┘ └──────────────────┘ +``` + +1. **Build time** — You compile `src/ui-parser.ts` to `dist/ui-parser.js` (zero runtime imports) +2. **Server startup** — Plugin loader reads the file and caches it in memory +3. **UI load** — When the user opens a run, the UI fetches the parser from `GET /api/:type/ui-parser.js` +4. **Runtime** — The fetched module is eval'd and registered. All subsequent lines use the real parser + +## Contract: package.json + +### 1. `paperclip.adapterUiParser` — contract version + +```json +{ + "paperclip": { + "adapterUiParser": "1.0.0" + } +} +``` + +The Paperclip host checks this field. If the major version is unsupported, the host logs a warning and falls back to the generic parser instead of executing potentially incompatible code. + +| Host expects | Adapter declares | Result | +|---|---|---| +| `1.x` | `1.0.0` | Parser loaded | +| `1.x` | `2.0.0` | Warning logged, generic parser used | +| `1.x` | (missing) | Parser loaded (grace period — future versions may require it) | + +### 2. `exports["./ui-parser"]` — file path + +```json +{ + "exports": { + ".": "./dist/server/index.js", + "./ui-parser": "./dist/ui-parser.js" + } +} +``` + +## Contract: Module Exports + +Your `dist/ui-parser.js` must export **at least one** of: + +### `parseStdoutLine(line: string, ts: string): TranscriptEntry[]` + +Static parser. Called for each line of adapter stdout. + +```ts +export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] { + if (line.startsWith("[my-agent]")) { + return [{ kind: "system", ts, text: line }]; + } + return [{ kind: "assistant", ts, text: line }]; +} +``` + +### `createStdoutParser(): { parseLine(line, ts): TranscriptEntry[]; reset(): void }` + +Stateful parser factory. Preferred if your parser needs to track multi-line continuation, command nesting, or other cross-call state. + +```ts +let counter = 0; + +export function createStdoutParser() { + let suppressContinuation = false; + + function parseLine(line: string, ts: string): TranscriptEntry[] { + const trimmed = line.trim(); + if (!trimmed) return []; + + if (suppressContinuation) { + if (/^[\d.]+s$/.test(trimmed)) { + suppressContinuation = false; + return []; + } + return []; // swallow continuation lines + } + + if (trimmed.startsWith("[tool-done]")) { + const id = `tool-${++counter}`; + suppressContinuation = true; + return [ + { kind: "tool_call", ts, name: "shell", input: {}, toolUseId: id }, + { kind: "tool_result", ts, toolUseId: id, content: trimmed, isError: false }, + ]; + } + + return [{ kind: "assistant", ts, text: trimmed }]; + } + + function reset() { + suppressContinuation = false; + } + + return { parseLine, reset }; +} +``` + +If both are exported, `createStdoutParser` takes priority. + +## Contract: TranscriptEntry + +Each entry must match one of these discriminated union shapes: + +```ts +// Assistant message +{ kind: "assistant"; ts: string; text: string; delta?: boolean } + +// Thinking / reasoning +{ kind: "thinking"; ts: string; text: string; delta?: boolean } + +// User message (rare — usually from agent-initiated prompts) +{ kind: "user"; ts: string; text: string } + +// Tool invocation +{ kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string } + +// Tool result +{ kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } + +// System / adapter messages +{ kind: "system"; ts: string; text: string } + +// Stderr / errors +{ kind: "stderr"; ts: string; text: string } + +// Raw stdout (fallback) +{ kind: "stdout"; ts: string; text: string } +``` + +### Linking tool calls to results + +Use `toolUseId` to pair `tool_call` and `tool_result` entries. The UI renders them as collapsible cards. + +```ts +const id = `my-tool-${++counter}`; +return [ + { kind: "tool_call", ts, name: "read", input: { path: "/src/main.ts" }, toolUseId: id }, + { kind: "tool_result", ts, toolUseId: id, content: "const main = () => {...}", isError: false }, +]; +``` + +### Error handling + +Set `isError: true` on tool results to show a red indicator: + +```ts +{ kind: "tool_result", ts, toolUseId: id, content: "ENOENT: no such file", isError: true } +``` + +## Constraints + +1. **Zero runtime imports.** Your file is loaded via `URL.createObjectURL` + dynamic `import()` in the browser. No `import`, no `require`, no top-level `await`. + +2. **No DOM / Node.js APIs.** Runs in a browser sandbox. Use only vanilla JS (ES2020+). + +3. **No side effects.** Module-level code must not modify globals, access `window`, or perform I/O. Only declare and export functions. + +4. **Deterministic.** Given the same `(line, ts)` input, the same output must be produced. This matters for log replay. + +5. **Error-tolerant.** Never throw. Return `[{ kind: "stdout", ts, text: line }]` for any line you can't parse, rather than crashing the transcript. + +6. **File size.** Keep under 50 KB. This is served per-request and eval'd in the browser. + +## Lifecycle + +| Event | What happens | +|---|---| +| Server starts | Plugin loader reads `exports["./ui-parser"]`, reads the file, caches in memory | +| UI opens run | `getUIAdapter(type)` called. If no built-in parser, kicks off async `fetch(/api/:type/ui-parser.js)` | +| First lines arrive | Generic process parser handles them immediately (no blocking). Dynamic parser loads in background | +| Parser loads | `registerUIAdapter()` called. All subsequent line parsing uses the real parser | +| Parser fails (404, eval error) | Warning logged to console. Generic parser continues. Failed type is cached — no retries | +| Server restart | In-memory cache is repopulated from adapter packages | + +## Error Behavior + +| Failure | What happens | +|---|---| +| Module syntax error (import fails) | Caught, logged, falls back to generic parser. No retries. | +| Returns wrong shape | Individual entries with missing fields are silently ignored by the transcript builder. | +| Throws at runtime | Caught per-line. That line falls back to generic. Parser stays registered for future lines. | +| 404 (no ui-parser export) | Type added to failed-loads set. Generic parser from first call onward. | +| Contract version mismatch | Server logs warning, skips loading. Generic parser used. | + +## Building + +```sh +# Compile TypeScript to JavaScript +tsc src/ui-parser.ts --outDir dist --target ES2020 --module ES2020 --declaration false +``` + +Your `tsconfig.json` can handle this automatically — just make sure `ui-parser.ts` is included in the build and outputs to `dist/ui-parser.js`. + +## Testing + +Test your parser locally by running it against sample stdout: + +```ts +// test-parser.ts +import { createStdoutParser } from "./dist/ui-parser.js"; + +const parser = createStdoutParser(); +const sampleLines = [ + "[my-agent] Starting session abc123", + "Thinking about the task...", + "$ ls /home/user/project", + "[done] $ ls — /src /README.md 0.3s", + "I'll read the README now.", + "Error: file not found", +]; + +for (const line of sampleLines) { + const entries = parser.parseLine(line, new Date().toISOString()); + for (const entry of entries) { + console.log(` ${entry.kind}:`, entry.text ?? entry.name ?? entry.content); + } +} +``` + +Run with: `npx tsx test-parser.ts` + +## Skipping the UI Parser + +If your adapter's stdout is simple (no tool markers, no special formatting), you can skip the UI parser entirely. The generic `process` parser will handle it — every non-system line becomes `assistant` output. This is fine for: + +- Agents that output plain text responses +- Custom scripts that just print results +- Simple CLIs without structured output + +To skip it, simply don't include `exports["./ui-parser"]` in your `package.json`. + +## Next Steps + +- [External Adapters](/adapters/external-adapters) — full guide to building adapter packages +- [Creating an Adapter](/adapters/creating-an-adapter) — adapter internals and built-in integration diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index d3a0b68b..fc64fcf8 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -20,8 +20,8 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | | `graceSec` | number | No | Grace period before force-kill | -| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `1000`) | -| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) | +| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | +| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible | ## Prompt Templates diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index fae0e4b3..ae5e4ccb 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -9,23 +9,40 @@ Build a custom adapter to connect Paperclip to any agent runtime. If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step. +## Two Paths + +| | Built-in | External Plugin | +|---|---|---| +| Source | Inside `paperclip-fork` | Separate npm package | +| Distribution | Ships with Paperclip | Independent npm publish | +| UI parser | Static import | Dynamic load from API | +| Registration | Edit 3 registries | Auto-loaded at startup | +| Best for | Core adapters, contributors | Third-party adapters, internal tools | + +For most cases, **build an external adapter plugin**. It's cleaner, independently versioned, and doesn't require modifying Paperclip's source. See [External Adapters](/adapters/external-adapters) for the full guide. + +The rest of this page covers the shared internals that both paths use. + ## Package Structure ``` -packages/adapters// +packages/adapters// # built-in + ── or ── +my-adapter/ # external plugin package.json tsconfig.json src/ index.ts # Shared metadata server/ - index.ts # Server exports + index.ts # Server exports (createServerAdapter) execute.ts # Core execution logic parse.ts # Output parsing test.ts # Environment diagnostics ui/ - index.ts # UI exports - parse-stdout.ts # Transcript parser + index.ts # UI exports (built-in only) + parse-stdout.ts # Transcript parser (built-in only) build-config.ts # Config builder + ui-parser.ts # Self-contained UI parser (external — see [UI Parser Contract](/adapters/adapter-ui-parser)) cli/ index.ts # CLI exports format-event.ts # Terminal formatter @@ -46,6 +63,9 @@ Use when: ... Don't use when: ... Core fields: ... `; + +// Required for external adapters (plugin-loader convention) +export { createServerAdapter } from "./server/index.js"; ``` ## Step 2: Server Execute @@ -54,7 +74,7 @@ Core fields: ... Key responsibilities: -1. Read config using safe helpers (`asString`, `asNumber`, etc.) +1. Read config using safe helpers (`asString`, `asNumber`, etc.) from `@paperclipai/adapter-utils/server-utils` 2. Build environment with `buildPaperclipEnv(agent)` plus context vars 3. Resolve session state from `runtime.sessionParams` 4. Render prompt with `renderTemplate(template, data)` @@ -62,27 +82,102 @@ Key responsibilities: 6. Parse output for usage, costs, session state, errors 7. Handle unknown session errors (retry fresh, set `clearSession: true`) +### Available Helpers + +| Helper | Source | Purpose | +|--------|--------|---------| +| `runChildProcess(cmd, opts)` | `@paperclipai/adapter-utils/server-utils` | Spawn with timeout, grace, streaming | +| `buildPaperclipEnv(agent)` | `@paperclipai/adapter-utils/server-utils` | Inject `PAPERCLIP_*` env vars | +| `renderTemplate(tpl, data)` | `@paperclipai/adapter-utils/server-utils` | `{{variable}}` substitution | +| `asString(v)` | `@paperclipai/adapter-utils` | Safe config value extraction | +| `asNumber(v)` | `@paperclipai/adapter-utils` | Safe number extraction | + +### AdapterExecutionContext + +```ts +interface AdapterExecutionContext { + runId: string; + agent: { id: string; companyId: string; name: string; adapterConfig: unknown }; + runtime: { sessionId: string | null; sessionParams: Record | null }; + config: Record; // agent's adapterConfig + context: Record; // task, wake reason, etc. + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; + onMeta?: (meta: AdapterInvocationMeta) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; +} +``` + +### AdapterExecutionResult + +```ts +interface AdapterExecutionResult { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + errorMessage?: string | null; + usage?: { inputTokens: number; outputTokens: number }; + sessionParams?: Record | null; // persist across heartbeats + sessionDisplayId?: string | null; + provider?: string | null; + model?: string | null; + costUsd?: number | null; + clearSession?: boolean; // set true to force fresh session on next wake +} +``` + ## Step 3: Environment Test `src/server/test.ts` validates the adapter config before running. Return structured diagnostics: -- `error` for invalid/unusable setup -- `warn` for non-blocking issues -- `info` for successful checks +| Level | Meaning | Effect | +|-------|---------|--------| +| `error` | Invalid or unusable setup | Blocks execution | +| `warn` | Non-blocking issue | Shown with yellow indicator | +| `info` | Successful check | Shown in test results | -## Step 4: UI Module +```ts +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + return { + adapterType: ctx.adapterType, + status: "pass", // "pass" | "warn" | "fail" + checks: [ + { level: "info", message: "CLI v1.2.0 detected", code: "cli_detected" }, + { level: "warn", message: "No API key found", hint: "Set ANTHROPIC_API_KEY", code: "no_key" }, + ], + testedAt: new Date().toISOString(), + }; +} +``` + +## Step 4: UI Module (Built-in Only) + +For built-in adapters registered in Paperclip's source: - `parse-stdout.ts` — converts stdout lines to `TranscriptEntry[]` for the run viewer - `build-config.ts` — converts form values to `adapterConfig` JSON - Config fields React component in `ui/src/adapters//config-fields.tsx` +For external adapters, use a self-contained `ui-parser.ts` instead. See the [UI Parser Contract](/adapters/adapter-ui-parser). + ## Step 5: CLI Module `format-event.ts` — pretty-prints stdout for `paperclipai run --watch` using `picocolors`. -## Step 6: Register +```ts +export function formatStdoutEvent(line: string, debug: boolean): void { + if (line.startsWith("[tool-done]")) { + console.log(chalk.green(` ✓ ${line}`)); + } else { + console.log(` ${line}`); + } +} +``` + +## Step 6: Register (Built-in Only) Add the adapter to all three registries: @@ -90,6 +185,24 @@ Add the adapter to all three registries: 2. `ui/src/adapters/registry.ts` 3. `cli/src/adapters/registry.ts` +For external adapters, registration is automatic — the plugin loader handles it. + +## Session Persistence + +If your agent runtime supports conversation continuity across heartbeats: + +1. Return `sessionParams` from `execute()` (e.g., `{ sessionId: "abc123" }`) +2. Read `runtime.sessionParams` on the next wake to resume +3. Optionally implement a `sessionCodec` for validation and display + +```ts +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw) { /* validate raw session data */ }, + serialize(params) { /* serialize for storage */ }, + getDisplayId(params) { /* human-readable session label */ }, +}; +``` + ## Skills Injection Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory: @@ -105,3 +218,10 @@ Make Paperclip skills discoverable to your agent runtime without writing to the - Inject secrets via environment variables, not prompts - Configure network access controls if the runtime supports them - Always enforce timeout and grace period +- The UI parser module runs in a browser sandbox — zero runtime imports, no side effects + +## Next Steps + +- [External Adapters](/adapters/external-adapters) — build a standalone adapter plugin +- [UI Parser Contract](/adapters/adapter-ui-parser) — ship a custom run-log parser +- [How Agents Work](/guides/agent-developer/how-agents-work) — the heartbeat lifecycle diff --git a/docs/adapters/external-adapters.md b/docs/adapters/external-adapters.md new file mode 100644 index 00000000..3c814fc9 --- /dev/null +++ b/docs/adapters/external-adapters.md @@ -0,0 +1,392 @@ +--- +title: External Adapters +summary: Build, package, and distribute adapters as plugins without modifying Paperclip source +--- + +Paperclip supports external adapter plugins that can be installed from npm packages or local directories. External adapters work exactly like built-in adapters — they execute agents, parse output, and render transcripts — but they live in their own package and don't require changes to Paperclip's source code. + +## Built-in vs External + +| | Built-in | External | +|---|---|---| +| Source location | Inside `paperclip-fork/packages/adapters/` | Separate npm package or local directory | +| Registration | Hardcoded in three registries | Loaded at startup via plugin system | +| UI parser | Static import at build time | Dynamically loaded from API (see [UI Parser](/adapters/adapter-ui-parser)) | +| Distribution | Ships with Paperclip | Published to npm or linked via `file:` | +| Updates | Requires Paperclip release | Independent versioning | + +## Quick Start + +### Minimal Package Structure + +``` +my-adapter/ + package.json + tsconfig.json + src/ + index.ts # Shared metadata (type, label, models) + server/ + index.ts # createServerAdapter() factory + execute.ts # Core execution logic + parse.ts # Output parsing + test.ts # Environment diagnostics + ui-parser.ts # Self-contained UI transcript parser +``` + +### package.json + +```json +{ + "name": "my-paperclip-adapter", + "version": "1.0.0", + "type": "module", + "license": "MIT", + "paperclip": { + "adapterUiParser": "1.0.0" + }, + "exports": { + ".": "./dist/index.js", + "./server": "./dist/server/index.js", + "./ui-parser": "./dist/ui-parser.js" + }, + "files": ["dist"], + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@paperclipai/adapter-utils": "^2026.325.0", + "picocolors": "^1.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } +} +``` + +Key fields: + +| Field | Purpose | +|-------|---------| +| `exports["."]` | Entry point — must export `createServerAdapter` | +| `exports["./ui-parser"]` | Self-contained UI parser module (optional but recommended) | +| `paperclip.adapterUiParser` | Contract version for the UI parser (`"1.0.0"`) | +| `files` | Limits what gets published — only `dist/` | + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} +``` + +## Server Module + +The plugin loader calls `createServerAdapter()` from your package root. This function must return a `ServerAdapterModule`. + +### src/index.ts + +```ts +export const type = "my_adapter"; // snake_case, globally unique +export const label = "My Agent (local)"; + +export const models = [ + { id: "model-a", label: "Model A" }, +]; + +export const agentConfigurationDoc = `# my_adapter configuration +Use when: ... +Don't use when: ... +`; + +// Required by plugin-loader convention +export { createServerAdapter } from "./server/index.js"; +``` + +### src/server/index.ts + +```ts +import type { ServerAdapterModule } from "@paperclipai/adapter-utils"; +import { type, models, agentConfigurationDoc } from "../index.js"; +import { execute } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +export function createServerAdapter(): ServerAdapterModule { + return { + type, + execute, + testEnvironment, + models, + agentConfigurationDoc, + }; +} +``` + +### src/server/execute.ts + +The core execution function. Receives an `AdapterExecutionContext` and returns an `AdapterExecutionResult`. + +```ts +import type { + AdapterExecutionContext, + AdapterExecutionResult, +} from "@paperclipai/adapter-utils"; + +import { + runChildProcess, + buildPaperclipEnv, + renderTemplate, +} from "@paperclipai/adapter-utils/server-utils"; + +export async function execute( + ctx: AdapterExecutionContext, +): Promise { + const { config, agent, runtime, context, onLog, onMeta } = ctx; + + // 1. Read config with safe helpers + const cwd = String(config.cwd ?? "/tmp"); + const command = String(config.command ?? "my-agent"); + const timeoutSec = Number(config.timeoutSec ?? 300); + + // 2. Build environment with Paperclip vars injected + const env = buildPaperclipEnv(agent); + + // 3. Render prompt template + const prompt = config.promptTemplate + ? renderTemplate(String(config.promptTemplate), { + agentId: agent.id, + agentName: agent.name, + companyId: agent.companyId, + runId: ctx.runId, + taskId: context.taskId ?? "", + taskTitle: context.taskTitle ?? "", + }) + : "Continue your work."; + + // 4. Spawn process + const result = await runChildProcess(command, { + args: [prompt], + cwd, + env, + timeout: timeoutSec * 1000, + graceMs: 10_000, + onStdout: (chunk) => onLog("stdout", chunk), + onStderr: (chunk) => onLog("stderr", chunk), + }); + + // 5. Return structured result + return { + exitCode: result.exitCode, + timedOut: result.timedOut, + // Include session state for persistence + sessionParams: { /* ... */ }, + }; +} +``` + +#### Available Helpers from `@paperclipai/adapter-utils` + +| Helper | Purpose | +|--------|---------| +| `runChildProcess(command, opts)` | Spawn a child process with timeout, grace period, and streaming callbacks | +| `buildPaperclipEnv(agent)` | Inject `PAPERCLIP_*` environment variables | +| `renderTemplate(template, data)` | `{{variable}}` substitution in prompt templates | +| `asString(v)`, `asNumber(v)`, `asBoolean(v)` | Safe config value extraction | + +### src/server/test.ts + +Validates the adapter configuration before running. Returns structured diagnostics. + +```ts +import type { + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks = []; + + // Example: check CLI is installed + checks.push({ + level: "info", + message: "My Agent CLI v1.2.0 detected", + code: "cli_detected", + }); + + // Example: check working directory + const cwd = String(ctx.config.cwd ?? ""); + if (!cwd.startsWith("/")) { + checks.push({ + level: "error", + message: `Working directory must be absolute: "${cwd}"`, + hint: "Use /home/user/project or /workspace", + code: "invalid_cwd", + }); + } + + return { + adapterType: ctx.adapterType, + status: checks.some(c => c.level === "error") ? "fail" : "pass", + checks, + testedAt: new Date().toISOString(), + }; +} +``` + +Check levels: + +| Level | Meaning | Effect | +|-------|---------|--------| +| `info` | Informational | Shown in test results | +| `warn` | Non-blocking issue | Shown with yellow indicator | +| `error` | Blocks execution | Prevents agent from running | + +## Installation + +### From npm + +```sh +# Via the Paperclip UI +# Settings → Adapters → Install from npm → "my-paperclip-adapter" + +# Or via API +curl -X POST http://localhost:3102/api/adapters \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"packageName": "my-paperclip-adapter"}' +``` + +### From local directory + +```sh +curl -X POST http://localhost:3102/api/adapters \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"localPath": "/home/user/my-adapter"}' +``` + +Local adapters are symlinked into Paperclip's adapter directory. Changes to the source are picked up on server restart. + +### Via adapter-plugins.json + +For development, you can also edit `~/.paperclip/adapter-plugins.json` directly: + +```json +[ + { + "packageName": "my-paperclip-adapter", + "localPath": "/home/user/my-adapter", + "type": "my_adapter", + "installedAt": "2026-03-30T12:00:00.000Z" + } +] +``` + +## Optional: Session Persistence + +If your agent runtime supports sessions (conversation continuity across heartbeats), implement a session codec: + +```ts +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw) { + if (typeof raw !== "object" || raw === null) return null; + const r = raw as Record; + return r.sessionId ? { sessionId: String(r.sessionId) } : null; + }, + serialize(params) { + return params?.sessionId ? { sessionId: String(params.sessionId) } : null; + }, + getDisplayId(params) { + return params?.sessionId ? String(params.sessionId) : null; + }, +}; +``` + +Include it in `createServerAdapter()`: + +```ts +return { type, execute, testEnvironment, sessionCodec, /* ... */ }; +``` + +## Optional: Skills Sync + +If your agent runtime supports skills/plugins, implement `listSkills` and `syncSkills`: + +```ts +return { + type, + execute, + testEnvironment, + async listSkills(ctx) { + return { + adapterType: ctx.adapterType, + supported: true, + mode: "ephemeral", + desiredSkills: [], + entries: [], + warnings: [], + }; + }, + async syncSkills(ctx, desiredSkills) { + // Install desired skills into the runtime + return { /* same shape as listSkills */ }; + }, +}; +``` + +## Optional: Model Detection + +If your runtime has a local config file that specifies the default model: + +```ts +async function detectModel() { + // Read ~/.my-agent/config.yaml or similar + return { + model: "anthropic/claude-sonnet-4", + provider: "anthropic", + source: "~/.my-agent/config.yaml", + candidates: ["anthropic/claude-sonnet-4", "openai/gpt-4o"], + }; +} + +return { type, execute, testEnvironment, detectModel: () => detectModel() }; +``` + +## Publishing + +```sh +npm run build +npm publish +``` + +Other Paperclip users can then install your adapter by package name from the UI or API. + +## Security + +- Treat agent output as untrusted — parse defensively, never `eval()` agent output +- Inject secrets via environment variables, not in prompts +- Configure network access controls if the runtime supports them +- Always enforce timeout and grace period — don't let agents run forever +- The UI parser module runs in a browser sandbox — it must have zero runtime imports and no side effects + +## Next Steps + +- [UI Parser Contract](/adapters/adapter-ui-parser) — add a custom run-log parser so the UI renders your adapter's output correctly +- [Creating an Adapter](/adapters/creating-an-adapter) — full walkthrough of adapter internals +- [How Agents Work](/guides/agent-developer/how-agents-work) — understand the heartbeat lifecycle your adapter serves diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 3216b5e5..55fd4d4e 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -22,43 +22,67 @@ When a heartbeat fires, Paperclip: | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | | [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | -| Hermes Local | `hermes_local` | Runs Hermes CLI locally | | Cursor | `cursor` | Runs Cursor in background mode | | Pi Local | `pi_local` | Runs an embedded Pi agent locally | | OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | +### External (plugin) adapters + +These adapters ship as standalone npm packages and are installed via the plugin system: + +| Adapter | Package | Type Key | Description | +|---------|---------|----------|-------------| +| Droid Local | `@henkey/droid-paperclip-adapter` | `droid_local` | Runs Factory Droid locally | +| Hermes Local | `@henkey/hermes-paperclip-adapter` | `hermes_local` | Runs Hermes CLI locally | + +## External Adapters + +You can build and distribute adapters as standalone packages — no changes to Paperclip's source code required. External adapters are loaded at startup via the plugin system. + +```sh +# Install from npm via API +curl -X POST http://localhost:3102/api/adapters \ + -d '{"packageName": "my-paperclip-adapter"}' + +# Or link from a local directory +curl -X POST http://localhost:3102/api/adapters \ + -d '{"localPath": "/home/user/my-adapter"}' +``` + +See [External Adapters](/adapters/external-adapters) for the full guide. + ## Adapter Architecture -Each adapter is a package with three modules: +Each adapter is a package with modules consumed by three registries: ``` -packages/adapters// +my-adapter/ src/ index.ts # Shared metadata (type, label, models) server/ execute.ts # Core execution logic parse.ts # Output parsing test.ts # Environment diagnostics - ui/ - parse-stdout.ts # Stdout -> transcript entries for run viewer - build-config.ts # Form values -> adapterConfig JSON + ui-parser.ts # Self-contained UI transcript parser (for external adapters) cli/ format-event.ts # Terminal output for `paperclipai run --watch` ``` -Three registries consume these modules: - -| Registry | What it does | -|----------|-------------| -| **Server** | Executes agents, captures results | -| **UI** | Renders run transcripts, provides config forms | -| **CLI** | Formats terminal output for live watching | +| Registry | What it does | Source | +|----------|-------------|--------| +| **Server** | Executes agents, captures results | `createServerAdapter()` from package root | +| **UI** | Renders run transcripts, provides config forms | `ui-parser.js` (dynamic) or static import (built-in) | +| **CLI** | Formats terminal output for live watching | Static import | ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or `hermes_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or install `droid_local` / `hermes_local` as external plugins - **Need to run a script or command?** Use `process` - **Need to call an external service?** Use `http` -- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) +- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters) + +## UI Parser Contract + +External adapters can ship a self-contained UI parser that tells the Paperclip web UI how to render their stdout. Without it, the UI uses a generic shell parser. See the [UI Parser Contract](/adapters/adapter-ui-parser) for details. diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index f3672723..dffb6fe2 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -37,14 +37,18 @@ Built-in adapters: - `claude_local`: runs your local `claude` CLI - `codex_local`: runs your local `codex` CLI - `opencode_local`: runs your local `opencode` CLI -- `hermes_local`: runs your local `hermes` CLI - `cursor`: runs Cursor in background mode - `pi_local`: runs an embedded Pi agent locally - `openclaw_gateway`: connects to an OpenClaw gateway endpoint - `process`: generic shell command adapter - `http`: calls an external HTTP endpoint -For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. +External plugin adapters (install via the adapter manager or API): + +- `droid_local`: runs your local Factory Droid CLI (`@henkey/droid-paperclip-adapter`) +- `hermes_local`: runs your local `hermes` CLI (`@henkey/hermes-paperclip-adapter`) + +For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `droid_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. ## 3.2 Runtime behavior @@ -173,7 +177,7 @@ Start with least privilege where possible, and avoid exposing secrets in broad r ## 10. Minimal setup checklist -1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). +1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `cursor`, or `openclaw_gateway`). External plugins like `droid_local` and `hermes_local` are also available via the adapter manager. 2. Set `cwd` to the target workspace (for local adapters). 3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle. 4. Configure heartbeat policy (timer and/or assignment wakeups). diff --git a/docs/docs.json b/docs/docs.json index f87809af..be48cc8e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -98,6 +98,8 @@ "adapters/codex-local", "adapters/process", "adapters/http", + "adapters/external-adapters", + "adapters/adapter-ui-parser", "adapters/creating-an-adapter" ] } diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 9337fad0..42f91fda 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -292,7 +292,7 @@ export interface ServerAdapterModule { * Returns the detected model/provider and the config source, or null if * the adapter does not support detection or no config is found. */ - detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>; + detectModel?: () => Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null>; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 41c0693f..b2f85732 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -21,7 +21,7 @@ Core fields: - chrome (boolean, optional): pass --chrome when running Claude - promptTemplate (string, optional): run prompt template - maxTurnsPerRun (number, optional): max turns for one run -- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude +- dangerouslySkipPermissions (boolean, optional, default true): pass --dangerously-skip-permissions to claude; defaults to true because Paperclip runs Claude in headless --print mode where interactive permission prompts cannot be answered - command (string, optional): defaults to "claude" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index c7d6c6a8..a44d0957 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -317,7 +317,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index b77a0e75..9d81eb8a 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -606,7 +606,7 @@ export interface WorkerToHostMethods { result: IssueComment[], ]; "issues.createComment": [ - params: { issueId: string; body: string; companyId: string }, + params: { issueId: string; body: string; companyId: string; authorAgentId?: string }, result: IssueComment, ]; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 83fbfb5b..41e91d54 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -405,7 +405,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!isInCompany(issues.get(issueId), companyId)) return []; return issueComments.get(issueId) ?? []; }, - async createComment(issueId, body, companyId) { + async createComment(issueId, body, companyId, options) { requireCapability(manifest, capabilitySet, "issue.comments.create"); const parentIssue = issues.get(issueId); if (!isInCompany(parentIssue, companyId)) { @@ -416,7 +416,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { id: randomUUID(), companyId: parentIssue.companyId, issueId, - authorAgentId: null, + authorAgentId: options?.authorAgentId ?? null, authorUserId: null, body, createdAt: now, diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 4b707e28..f8a6ca4f 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -909,7 +909,12 @@ export interface PluginIssuesClient { companyId: string, ): Promise; listComments(issueId: string, companyId: string): Promise; - createComment(issueId: string, body: string, companyId: string): Promise; + createComment( + issueId: string, + body: string, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ documents: PluginIssueDocumentsClient; } diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index a64d225a..483dbc70 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -610,8 +610,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return callHost("issues.listComments", { issueId, companyId }); }, - async createComment(issueId: string, body: string, companyId: string) { - return callHost("issues.createComment", { issueId, body, companyId }); + async createComment(issueId: string, body: string, companyId: string, options?: { authorAgentId?: string }) { + return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId }); }, documents: { diff --git a/packages/shared/src/adapter-type.ts b/packages/shared/src/adapter-type.ts new file mode 100644 index 00000000..5af29dfc --- /dev/null +++ b/packages/shared/src/adapter-type.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { AGENT_ADAPTER_TYPES } from "./constants.js"; + +export const agentAdapterTypeSchema = z + .string() + .trim() + .min(1) + .default("process") + .describe(`Known built-in adapters: ${AGENT_ADAPTER_TYPES.join(", ")}. External adapters may register additional non-empty string types at runtime.`); + +export const optionalAgentAdapterTypeSchema = z + .string() + .trim() + .min(1) + .optional(); diff --git a/packages/shared/src/adapter-types.test.ts b/packages/shared/src/adapter-types.test.ts new file mode 100644 index 00000000..29fb6eec --- /dev/null +++ b/packages/shared/src/adapter-types.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { acceptInviteSchema, createAgentSchema, updateAgentSchema } from "./index.js"; + +describe("dynamic adapter type validation schemas", () => { + it("accepts external adapter types in create/update agent schemas", () => { + expect( + createAgentSchema.parse({ + name: "External Agent", + adapterType: "external_adapter", + }).adapterType, + ).toBe("external_adapter"); + + expect( + updateAgentSchema.parse({ + adapterType: "external_adapter", + }).adapterType, + ).toBe("external_adapter"); + }); + + it("still rejects blank adapter types", () => { + expect(() => + createAgentSchema.parse({ + name: "Blank Adapter", + adapterType: " ", + }), + ).toThrow(); + }); + + it("accepts external adapter types in invite acceptance schema", () => { + expect( + acceptInviteSchema.parse({ + requestType: "agent", + agentName: "External Joiner", + adapterType: "external_adapter", + }).adapterType, + ).toBe("external_adapter"); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 1e82a5ce..59d58441 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -31,9 +31,8 @@ export const AGENT_ADAPTER_TYPES = [ "pi_local", "cursor", "openclaw_gateway", - "hermes_local", ] as const; -export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; +export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number] | (string & {}); export const AGENT_ROLES = [ "ceo", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0f936bc2..b0fd87f2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; export { COMPANY_STATUSES, DEPLOYMENT_MODES, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 126a0843..6da95c12 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { - AGENT_ADAPTER_TYPES, INVITE_JOIN_TYPES, JOIN_REQUEST_STATUSES, JOIN_REQUEST_TYPES, PERMISSION_KEYS, } from "../constants.js"; +import { optionalAgentAdapterTypeSchema } from "../adapter-type.js"; export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), @@ -26,7 +26,7 @@ export type CreateOpenClawInvitePrompt = z.infer< export const acceptInviteSchema = z.object({ requestType: z.enum(JOIN_REQUEST_TYPES), agentName: z.string().min(1).max(120).optional(), - adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(), + adapterType: optionalAgentAdapterTypeSchema, capabilities: z.string().max(4000).optional().nullable(), agentDefaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), // OpenClaw join compatibility fields accepted at top level. diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 288ae683..7b462db7 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { - AGENT_ADAPTER_TYPES, AGENT_ICON_NAMES, AGENT_ROLES, AGENT_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, } from "../constants.js"; +import { agentAdapterTypeSchema } from "../adapter-type.js"; import { envConfigSchema } from "./secret.js"; export const agentPermissionsSchema = z.object({ @@ -52,7 +52,7 @@ export const createAgentSchema = z.object({ reportsTo: z.string().uuid().optional().nullable(), capabilities: z.string().optional().nullable(), desiredSkills: z.array(z.string().min(1)).optional(), - adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"), + adapterType: agentAdapterTypeSchema, adapterConfig: adapterConfigSchema.optional().default({}), runtimeConfig: z.record(z.unknown()).optional().default({}), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), diff --git a/server/package.json b/server/package.json index b2d17ad3..b519ed1a 100644 --- a/server/package.json +++ b/server/package.json @@ -66,7 +66,6 @@ "drizzle-orm": "^0.38.4", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", - "hermes-paperclip-adapter": "^0.2.0", "jsdom": "^28.1.0", "multer": "^2.0.2", "open": "^11.0.0", @@ -93,4 +92,4 @@ "vite": "^6.1.0", "vitest": "^3.0.5" } -} +} \ No newline at end of file diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts new file mode 100644 index 00000000..d121a374 --- /dev/null +++ b/server/src/__tests__/adapter-registry.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import type { ServerAdapterModule } from "../adapters/index.js"; +import { + findServerAdapter, + listAdapterModels, + registerServerAdapter, + requireServerAdapter, + unregisterServerAdapter, +} from "../adapters/index.js"; + +const externalAdapter: ServerAdapterModule = { + type: "external_test", + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + }), + testEnvironment: async () => ({ + adapterType: "external_test", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + models: [{ id: "external-model", label: "External Model" }], + supportsLocalAgentJwt: false, +}; + +describe("server adapter registry", () => { + beforeEach(() => { + unregisterServerAdapter("external_test"); + }); + + afterEach(() => { + unregisterServerAdapter("external_test"); + }); + + it("registers external adapters and exposes them through lookup helpers", async () => { + expect(findServerAdapter("external_test")).toBeNull(); + + registerServerAdapter(externalAdapter); + + expect(requireServerAdapter("external_test")).toBe(externalAdapter); + expect(await listAdapterModels("external_test")).toEqual([ + { id: "external-model", label: "External Model" }, + ]); + }); + + it("removes external adapters when unregistered", () => { + registerServerAdapter(externalAdapter); + + unregisterServerAdapter("external_test"); + + expect(findServerAdapter("external_test")).toBeNull(); + expect(() => requireServerAdapter("external_test")).toThrow( + "Unknown adapter type: external_test", + ); + }); +}); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts new file mode 100644 index 00000000..55b9b85b --- /dev/null +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -0,0 +1,180 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { agentRoutes } from "../routes/agents.js"; +import { errorHandler } from "../middleware/index.js"; +import type { ServerAdapterModule } from "../adapters/index.js"; +import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; + +const mockAgentService = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + ensureMembership: vi.fn(), + setPrincipalPermission: vi.fn(), +})); + +const mockCompanySkillService = vi.hoisted(() => ({ + listRuntimeSkillEntries: vi.fn(), + resolveRequestedSkillKeys: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), + resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config })), +})); + +const mockAgentInstructionsService = vi.hoisted(() => ({ + materializeManagedBundle: vi.fn(), + getBundle: vi.fn(), + readFile: vi.fn(), + updateBundle: vi.fn(), + writeFile: vi.fn(), + deleteFile: vi.fn(), + exportFiles: vi.fn(), + ensureManagedBundle: vi.fn(), +})); + +const mockBudgetService = vi.hoisted(() => ({ + upsertPolicy: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + cancelActiveForAgent: vi.fn(), +})); + +const mockIssueApprovalService = vi.hoisted(() => ({ + linkManyForApproval: vi.fn(), +})); + +const mockApprovalService = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => ({}), + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => ({}), +})); + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, +})); + +const externalAdapter: ServerAdapterModule = { + type: "external_test", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "external_test", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), +}; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", agentRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("agent routes adapter validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + unregisterServerAdapter("external_test"); + mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); + mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(true); + mockAccessService.ensureMembership.mockResolvedValue(undefined); + mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); + mockLogActivity.mockResolvedValue(undefined); + mockAgentService.create.mockImplementation(async (_companyId: string, input: Record) => ({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + name: String(input.name ?? "Agent"), + urlKey: "agent", + role: String(input.role ?? "general"), + title: null, + icon: null, + status: "idle", + reportsTo: null, + capabilities: null, + adapterType: String(input.adapterType ?? "process"), + adapterConfig: (input.adapterConfig as Record | undefined) ?? {}, + runtimeConfig: (input.runtimeConfig as Record | undefined) ?? {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + })); + }); + + afterEach(() => { + unregisterServerAdapter("external_test"); + }); + + it("creates agents for dynamically registered external adapter types", async () => { + registerServerAdapter(externalAdapter); + + const res = await request(createApp()) + .post("/api/companies/company-1/agents") + .send({ + name: "External Agent", + adapterType: "external_test", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.adapterType).toBe("external_test"); + }); + + it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/agents") + .send({ + name: "Missing Adapter", + adapterType: "missing_adapter", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter"); + }); +}); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 16b16ca3..1f65c26d 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -50,7 +50,7 @@ vi.mock("../services/index.js", () => ({ })); vi.mock("../adapters/index.js", () => ({ - findServerAdapter: vi.fn(), + findServerAdapter: vi.fn((_type: string) => ({ type: _type })), listAdapterModels: vi.fn(), })); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index eeec658e..15336558 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -86,6 +86,7 @@ vi.mock("../services/index.js", () => ({ vi.mock("../adapters/index.js", () => ({ findServerAdapter: vi.fn(() => mockAdapter), listAdapterModels: vi.fn(), + detectAdapterModel: vi.fn(), })); function createDb(requireBoardApprovalForNewAgents = false) { diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index ec6bc2d9..79efdabe 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js"; +import { + summarizeHeartbeatRunResultJson, + buildHeartbeatRunIssueComment, +} from "../services/heartbeat-run-summary.js"; describe("summarizeHeartbeatRunResultJson", () => { it("truncates text fields and preserves cost aliases", () => { @@ -31,3 +34,24 @@ describe("summarizeHeartbeatRunResultJson", () => { expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull(); }); }); + +describe("buildHeartbeatRunIssueComment", () => { + it("uses the final summary text for issue comments on successful runs", () => { + const comment = buildHeartbeatRunIssueComment({ + summary: "## Summary\n\n- fixed deploy config\n- posted issue update", + }); + + expect(comment).toContain("## Summary"); + expect(comment).toContain("- fixed deploy config"); + expect(comment).not.toContain("Run summary"); + }); + + it("falls back to result or message when summary is missing", () => { + expect(buildHeartbeatRunIssueComment({ result: "done" })).toBe("done"); + expect(buildHeartbeatRunIssueComment({ message: "completed" })).toBe("completed"); + }); + + it("returns null when there is no usable final text", () => { + expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull(); + }); +}); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 8be40a51..84bcaa3a 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -1,4 +1,13 @@ -export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js"; +export { + getServerAdapter, + listAdapterModels, + listServerAdapters, + findServerAdapter, + detectAdapterModel, + registerServerAdapter, + unregisterServerAdapter, + requireServerAdapter, +} from "./registry.js"; export type { ServerAdapterModule, AdapterExecutionContext, diff --git a/server/src/adapters/plugin-loader.ts b/server/src/adapters/plugin-loader.ts new file mode 100644 index 00000000..e9faf312 --- /dev/null +++ b/server/src/adapters/plugin-loader.ts @@ -0,0 +1,262 @@ +/** + * External adapter plugin loader. + * + * Loads external adapter packages from the adapter-plugin-store and returns + * their ServerAdapterModule instances. The caller (registry.ts) is + * responsible for registering them. + * + * This avoids circular initialization: plugin-loader imports only + * adapter-utils, never registry.ts. + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ServerAdapterModule } from "./types.js"; +import { logger } from "../middleware/logger.js"; + +import { + listAdapterPlugins, + getAdapterPluginsDir, + getAdapterPluginByType, +} from "../services/adapter-plugin-store.js"; +import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; + +// --------------------------------------------------------------------------- +// In-memory UI parser cache +// --------------------------------------------------------------------------- + +const uiParserCache = new Map(); + +export function getUiParserSource(adapterType: string): string | undefined { + return uiParserCache.get(adapterType); +} + +/** + * On cache miss, attempt on-demand extraction from the plugin store. + * Makes the ui-parser.js endpoint self-healing. + */ +export function getOrExtractUiParserSource(adapterType: string): string | undefined { + const cached = uiParserCache.get(adapterType); + if (cached) return cached; + + const record = getAdapterPluginByType(adapterType); + if (!record) return undefined; + + const packageDir = resolvePackageDir(record); + const source = extractUiParserSource(packageDir, record.packageName); + if (source) { + uiParserCache.set(adapterType, source); + logger.info( + { type: adapterType, packageName: record.packageName, origin: "lazy" }, + "UI parser extracted on-demand (cache miss)", + ); + } + return source; +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function resolvePackageDir(record: Pick): string { + return record.localPath + ? path.resolve(record.localPath) + : path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName); +} + +function resolvePackageEntryPoint(packageDir: string): string { + const pkgJsonPath = path.join(packageDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); + + if (pkg.exports && typeof pkg.exports === "object" && pkg.exports["."]) { + const exp = pkg.exports["."]; + return typeof exp === "string" ? exp : (exp.import ?? exp.default ?? "index.js"); + } + return pkg.main ?? "index.js"; +} + +// --------------------------------------------------------------------------- +// UI parser extraction +// --------------------------------------------------------------------------- + +const SUPPORTED_PARSER_CONTRACT = "1"; + +function extractUiParserSource( + packageDir: string, + packageName: string, +): string | undefined { + const pkgJsonPath = path.join(packageDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); + + if (!pkg.exports || typeof pkg.exports !== "object" || !pkg.exports["./ui-parser"]) { + return undefined; + } + + const contractVersion = pkg.paperclip?.adapterUiParser; + if (contractVersion) { + const major = contractVersion.split(".")[0]; + if (major !== SUPPORTED_PARSER_CONTRACT) { + logger.warn( + { packageName, contractVersion, supported: `${SUPPORTED_PARSER_CONTRACT}.x` }, + "Adapter declares unsupported UI parser contract version — skipping UI parser", + ); + return undefined; + } + } else { + logger.info( + { packageName }, + "Adapter has ./ui-parser export but no paperclip.adapterUiParser version — loading anyway (future versions may require it)", + ); + } + + const uiParserExp = pkg.exports["./ui-parser"]; + const uiParserFile = typeof uiParserExp === "string" + ? uiParserExp + : (uiParserExp.import ?? uiParserExp.default); + const uiParserPath = path.resolve(packageDir, uiParserFile); + + if (!uiParserPath.startsWith(packageDir + path.sep) && uiParserPath !== packageDir) { + logger.warn( + { packageName, uiParserFile }, + "UI parser path escapes package directory — skipping", + ); + return undefined; + } + + if (!fs.existsSync(uiParserPath)) { + return undefined; + } + + try { + const source = fs.readFileSync(uiParserPath, "utf-8"); + logger.info( + { packageName, uiParserFile, size: source.length }, + `Loaded UI parser from adapter package${contractVersion ? "" : " (no version declared)"}`, + ); + return source; + } catch (err) { + logger.warn({ err, packageName, uiParserFile }, "Failed to read UI parser from adapter package"); + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Load / reload +// --------------------------------------------------------------------------- + +function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule { + const m = mod as Record; + const createServerAdapter = m.createServerAdapter; + if (typeof createServerAdapter !== "function") { + throw new Error( + `Package "${packageName}" does not export createServerAdapter(). ` + + `Ensure the package's main entry exports a createServerAdapter function.`, + ); + } + + const adapterModule = createServerAdapter() as ServerAdapterModule; + if (!adapterModule || !adapterModule.type) { + throw new Error( + `createServerAdapter() from "${packageName}" returned an invalid module (missing "type").`, + ); + } + return adapterModule; +} + +export async function loadExternalAdapterPackage( + packageName: string, + localPath?: string, +): Promise { + const packageDir = localPath + ? path.resolve(localPath) + : path.resolve(getAdapterPluginsDir(), "node_modules", packageName); + + const entryPoint = resolvePackageEntryPoint(packageDir); + const modulePath = path.resolve(packageDir, entryPoint); + const uiParserSource = extractUiParserSource(packageDir, packageName); + + logger.info({ packageName, packageDir, entryPoint, modulePath, hasUiParser: !!uiParserSource }, "Loading external adapter package"); + + const mod = await import(modulePath); + const adapterModule = validateAdapterModule(mod, packageName); + + if (uiParserSource) { + uiParserCache.set(adapterModule.type, uiParserSource); + } + + return adapterModule; +} + +async function loadFromRecord(record: AdapterPluginRecord): Promise { + try { + return await loadExternalAdapterPackage(record.packageName, record.localPath); + } catch (err) { + logger.warn( + { err, packageName: record.packageName, type: record.type }, + "Failed to dynamically load external adapter; skipping", + ); + return null; + } +} + +/** + * Reload an external adapter at runtime (dev iteration without server restart). + * Busts the ESM module cache via a cache-busting query string. + */ +export async function reloadExternalAdapter( + type: string, +): Promise { + const record = getAdapterPluginByType(type); + if (!record) return null; + + const packageDir = resolvePackageDir(record); + const entryPoint = resolvePackageEntryPoint(packageDir); + const modulePath = path.resolve(packageDir, entryPoint); + + const cacheBustUrl = `file://${modulePath}?t=${Date.now()}`; + + logger.info( + { type, packageName: record.packageName, modulePath, cacheBustUrl }, + "Reloading external adapter (cache bust)", + ); + + const mod = await import(cacheBustUrl); + const adapterModule = validateAdapterModule(mod, record.packageName); + + uiParserCache.delete(type); + const uiParserSource = extractUiParserSource(packageDir, record.packageName); + if (uiParserSource) { + uiParserCache.set(adapterModule.type, uiParserSource); + } + + logger.info( + { type, packageName: record.packageName, hasUiParser: !!uiParserSource }, + "Successfully reloaded external adapter", + ); + + return adapterModule; +} + +/** + * Build all external adapter modules from the plugin store. + */ +export async function buildExternalAdapters(): Promise { + const results: ServerAdapterModule[] = []; + + const storeRecords = listAdapterPlugins(); + for (const record of storeRecords) { + const adapter = await loadFromRecord(record); + if (adapter) { + results.push(adapter); + } + } + + if (results.length > 0) { + logger.info( + { count: results.length, adapters: results.map((a) => a.type) }, + "Loaded external adapters from plugin store", + ); + } + + return results; +} diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 1f195f86..35ae45c5 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -67,18 +67,6 @@ import { import { agentConfigurationDoc as piAgentConfigurationDoc, } from "@paperclipai/adapter-pi-local"; -import { - execute as hermesExecute, - testEnvironment as hermesTestEnvironment, - sessionCodec as hermesSessionCodec, - listSkills as hermesListSkills, - syncSkills as hermesSyncSkills, - detectModel as detectModelFromHermes, -} from "hermes-paperclip-adapter/server"; -import { - agentConfigurationDoc as hermesAgentConfigurationDoc, - models as hermesModels, -} from "hermes-paperclip-adapter"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -175,21 +163,10 @@ const piLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: piAgentConfigurationDoc, }; -const hermesLocalAdapter: ServerAdapterModule = { - type: "hermes_local", - execute: hermesExecute, - testEnvironment: hermesTestEnvironment, - sessionCodec: hermesSessionCodec, - listSkills: hermesListSkills, - syncSkills: hermesSyncSkills, - models: hermesModels, - supportsLocalAgentJwt: true, - agentConfigurationDoc: hermesAgentConfigurationDoc, - detectModel: () => detectModelFromHermes(), -}; +const adaptersByType = new Map(); -const adaptersByType = new Map( - [ +function registerBuiltInAdapters() { + for (const adapter of [ claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, @@ -197,21 +174,84 @@ const adaptersByType = new Map( cursorLocalAdapter, geminiLocalAdapter, openclawGatewayAdapter, - hermesLocalAdapter, processAdapter, httpAdapter, - ].map((a) => [a.type, a]), -); + ]) { + adaptersByType.set(adapter.type, adapter); + } +} -export function getServerAdapter(type: string): ServerAdapterModule { +registerBuiltInAdapters(); + +// --------------------------------------------------------------------------- +// Load external adapter plugins (droid, hermes, etc.) +// +// External adapter packages export createServerAdapter() which returns a +// ServerAdapterModule. The host fills in sessionManagement. +// --------------------------------------------------------------------------- + +import { buildExternalAdapters } from "./plugin-loader.js"; +import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; + +/** Cached sync wrapper — the store is a simple JSON file read, safe to call frequently. */ +function getDisabledAdapterTypesFromStore(): string[] { + return getDisabledAdapterTypes(); +} + +/** + * Load external adapters from the plugin store and hardcoded sources. + * Called once at module initialization. The promise is exported so that + * callers (e.g. assertKnownAdapterType, app startup) can await completion + * and avoid racing against the loading window. + */ +const externalAdaptersReady: Promise = (async () => { + try { + const externalAdapters = await buildExternalAdapters(); + for (const externalAdapter of externalAdapters) { + adaptersByType.set( + externalAdapter.type, + { + ...externalAdapter, + sessionManagement: getAdapterSessionManagement(externalAdapter.type) ?? undefined, + }, + ); + } + } catch (err) { + console.error("[paperclip] Failed to load external adapters:", err); + } +})(); + +/** + * Await this before validating adapter types to avoid race conditions + * during server startup. External adapters are loaded asynchronously; + * calling assertKnownAdapterType before this resolves will reject + * valid external adapter types. + */ +export function waitForExternalAdapters(): Promise { + return externalAdaptersReady; +} + +export function registerServerAdapter(adapter: ServerAdapterModule): void { + adaptersByType.set(adapter.type, adapter); +} + +export function unregisterServerAdapter(type: string): void { + if (type === processAdapter.type || type === httpAdapter.type) return; + adaptersByType.delete(type); +} + +export function requireServerAdapter(type: string): ServerAdapterModule { const adapter = adaptersByType.get(type); if (!adapter) { - // Fall back to process adapter for unknown types - return processAdapter; + throw new Error(`Unknown adapter type: ${type}`); } return adapter; } +export function getServerAdapter(type: string): ServerAdapterModule { + return adaptersByType.get(type) ?? processAdapter; +} + export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> { const adapter = adaptersByType.get(type); if (!adapter) return []; @@ -226,13 +266,32 @@ export function listServerAdapters(): ServerAdapterModule[] { return Array.from(adaptersByType.values()); } +/** + * List adapters excluding those that are disabled in settings. + * Used for menus and agent creation flows — disabled adapters remain + * functional for existing agents but hidden from selection. + */ +export function listEnabledServerAdapters(): ServerAdapterModule[] { + const disabled = getDisabledAdapterTypesFromStore(); + const disabledSet = disabled.length > 0 ? new Set(disabled) : null; + return disabledSet + ? Array.from(adaptersByType.values()).filter((a) => !disabledSet.has(a.type)) + : Array.from(adaptersByType.values()); +} + export async function detectAdapterModel( type: string, -): Promise<{ model: string; provider: string; source: string } | null> { +): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> { const adapter = adaptersByType.get(type); if (!adapter?.detectModel) return null; const detected = await adapter.detectModel(); - return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null; + if (!detected) return null; + return { + model: detected.model, + provider: detected.provider, + source: detected.source, + ...(detected.candidates?.length ? { candidates: detected.candidates } : {}), + }; } export function findServerAdapter(type: string): ServerAdapterModule | null { diff --git a/server/src/app.ts b/server/src/app.ts index b9faee2f..1ed10799 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -29,6 +29,7 @@ import { llmRoutes } from "./routes/llms.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; +import { adapterRoutes } from "./routes/adapters.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; @@ -226,6 +227,7 @@ export async function createApp( { workerManager }, ), ); + api.use(adapterRoutes()); api.use( accessRoutes(db, { deploymentMode: opts.deploymentMode, diff --git a/server/src/dev-watch-ignore.ts b/server/src/dev-watch-ignore.ts index cd618f73..4fe3769d 100644 --- a/server/src/dev-watch-ignore.ts +++ b/server/src/dev-watch-ignore.ts @@ -28,6 +28,9 @@ export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] { "../ui/node_modules/.vite-temp", "../ui/.vite", "../ui/dist", + // npm install during reinstall would trigger a restart mid-request + // if tsx watch sees the new files. Exclude the managed plugins dir. + process.env.HOME + "/.paperclip/adapter-plugins", ]) { addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath)); } diff --git a/server/src/index.ts b/server/src/index.ts index 37318245..8e20fe1f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -668,6 +668,12 @@ export async function startServer(): Promise { }, backupIntervalMs); } + // Wait for external adapters to finish loading before accepting requests. + // Without this, adapter type validation (assertKnownAdapterType) would + // reject valid external adapter types during the startup loading window. + const { waitForExternalAdapters } = await import("./adapters/registry.js"); + await waitForExternalAdapters(); + await new Promise((resolveListen, rejectListen) => { const onError = (err: Error) => { server.off("error", onError); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts new file mode 100644 index 00000000..c50d5581 --- /dev/null +++ b/server/src/routes/adapters.ts @@ -0,0 +1,578 @@ +/** + * @fileoverview Adapter management REST API routes + * + * This module provides Express routes for managing external adapter plugins: + * - Listing all registered adapters (built-in + external) + * - Installing external adapters from npm packages or local paths + * - Unregistering external adapters + * + * All routes require board-level authentication (assertBoard middleware). + * + * @module server/routes/adapters + */ + +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { Router } from "express"; +import { + listServerAdapters, + findServerAdapter, + listEnabledServerAdapters, + registerServerAdapter, + unregisterServerAdapter, +} from "../adapters/registry.js"; +import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; +import { + listAdapterPlugins, + addAdapterPlugin, + removeAdapterPlugin, + getAdapterPluginByType, + getAdapterPluginsDir, + getDisabledAdapterTypes, + setAdapterDisabled, +} from "../services/adapter-plugin-store.js"; +import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; +import type { ServerAdapterModule } from "../adapters/types.js"; +import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; +import { logger } from "../middleware/logger.js"; +import { assertBoard } from "./authz.js"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Known built-in adapter types (cannot be removed via the API) +// --------------------------------------------------------------------------- + +const BUILTIN_ADAPTER_TYPES = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "openclaw_gateway", + "opencode_local", + "pi_local", + "process", + "http", +]); + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +interface AdapterInstallRequest { + /** npm package name (e.g., "droid-paperclip-adapter") or local path */ + packageName: string; + /** True if packageName is a local filesystem path */ + isLocalPath?: boolean; + /** Target version for npm packages (optional, defaults to latest) */ + version?: string; +} + +interface AdapterInfo { + type: string; + label: string; + source: "builtin" | "external"; + modelsCount: number; + loaded: boolean; + disabled: boolean; + version?: string; + packageName?: string; + isLocalPath?: boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Resolve the adapter package directory (same rules as plugin-loader). + */ +function resolveAdapterPackageDir(record: AdapterPluginRecord): string { + return record.localPath + ? path.resolve(record.localPath) + : path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName); +} + +/** + * Read `version` from the adapter's package.json on disk. + * This is the source of truth for what is actually installed (npm or local path). + */ +function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string | undefined { + try { + const pkgDir = resolveAdapterPackageDir(record); + const raw = fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"); + const v = JSON.parse(raw).version; + return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined; + } catch { + return undefined; + } +} + +function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { + const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; + return { + type: adapter.type, + label: adapter.type, // ServerAdapterModule doesn't have a separate "label" field; type serves as label + source: externalRecord ? "external" : "builtin", + modelsCount: (adapter.models ?? []).length, + loaded: true, // If it's in the registry, it's loaded + disabled: disabledSet.has(adapter.type), + // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. + version: fromDisk ?? externalRecord?.version, + packageName: externalRecord?.packageName, + isLocalPath: externalRecord?.localPath ? true : undefined, + }; +} + +/** + * Normalize a local path that may be a Windows path into a WSL-compatible path. + * + * - Windows paths (e.g., "C:\\Users\\...") are converted via `wslpath -u`. + * - Paths already starting with `/mnt/` or `/` are returned as-is. + */ +async function normalizeLocalPath(rawPath: string): Promise { + // Already a POSIX path (WSL or native Linux) + if (rawPath.startsWith("/")) { + return rawPath; + } + + // Windows path detection: C:\ or C:/ pattern + if (/^[A-Za-z]:[\\/]/.test(rawPath)) { + try { + const { stdout } = await execFileAsync("wslpath", ["-u", rawPath]); + return stdout.trim(); + } catch (err) { + logger.warn({ err, rawPath }, "wslpath conversion failed; using path as-is"); + return rawPath; + } + } + + return rawPath; +} + +/** + * Register an adapter module into the server registry, filling in + * sessionManagement from the host. + */ +function registerWithSessionManagement(adapter: ServerAdapterModule): void { + const wrapped: ServerAdapterModule = { + ...adapter, + sessionManagement: getAdapterSessionManagement(adapter.type) ?? undefined, + }; + registerServerAdapter(wrapped); +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +export function adapterRoutes() { + const router = Router(); + + /** + * GET /api/adapters + * + * List all registered adapters (built-in + external). + * Each entry includes whether the adapter is built-in or external, + * its model count, and load status. + */ + router.get("/adapters", async (_req, res) => { + assertBoard(_req); + + const registeredAdapters = listServerAdapters(); + const externalRecords = new Map( + listAdapterPlugins().map((r) => [r.type, r]), + ); + const disabledSet = new Set(getDisabledAdapterTypes()); + + const result: AdapterInfo[] = registeredAdapters.map((adapter) => + buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet), + ); + + res.json(result); + }); + + /** + * POST /api/adapters/install + * + * Install an external adapter from an npm package or local path. + * + * Request body: + * - packageName: string (required) — npm package name or local path + * - isLocalPath?: boolean (default false) + * - version?: string — target version for npm packages + */ + router.post("/adapters/install", async (req, res) => { + assertBoard(req); + + const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest; + + if (!packageName || typeof packageName !== "string") { + res.status(400).json({ error: "packageName is required and must be a string." }); + return; + } + + // Strip version suffix if the UI sends "pkg@1.2.3" instead of separating it + // e.g. "@henkey/hermes-paperclip-adapter@0.3.0" → packageName + version + let canonicalName = packageName; + let explicitVersion = version; + const versionSuffix = packageName.match(/@(\d+\.\d+\.\d+.*)$/); + if (versionSuffix) { + // For scoped packages: "@scope/name@1.2.3" → "@scope/name" + "1.2.3" + // For unscoped: "name@1.2.3" → "name" + "1.2.3" + const lastAtIndex = packageName.lastIndexOf("@"); + if (lastAtIndex > 0 && !explicitVersion) { + canonicalName = packageName.slice(0, lastAtIndex); + explicitVersion = versionSuffix[1]; + } + } + + try { + let installedVersion: string | undefined; + let moduleLocalPath: string | undefined; + + if (!isLocalPath) { + // npm install into the managed directory + const pluginsDir = getAdapterPluginsDir(); + const spec = explicitVersion ? `${canonicalName}@${explicitVersion}` : canonicalName; + + logger.info({ spec, pluginsDir }, "Installing adapter package via npm"); + + await execFileAsync("npm", ["install", "--no-save", spec], { + cwd: pluginsDir, + timeout: 120_000, + }); + + // Read installed version from package.json + try { + const pkgJsonPath = path.join(pluginsDir, "node_modules", canonicalName, "package.json"); + const pkgContent = await import("node:fs/promises"); + const pkgRaw = await pkgContent.readFile(pkgJsonPath, "utf-8"); + const pkg = JSON.parse(pkgRaw); + const v = pkg.version; + installedVersion = + typeof v === "string" && v.trim().length > 0 ? v.trim() : explicitVersion; + } catch { + installedVersion = explicitVersion; + } + } else { + // Local path — normalize (e.g., Windows → WSL) and use the resolved path + moduleLocalPath = path.resolve(await normalizeLocalPath(packageName)); + try { + const pkgRaw = await readFile(path.join(moduleLocalPath, "package.json"), "utf-8"); + const v = JSON.parse(pkgRaw).version; + if (typeof v === "string" && v.trim().length > 0) { + installedVersion = v.trim(); + } + } catch { + // leave installedVersion undefined if package.json is missing + } + } + + // Load and register the adapter (use canonicalName for path resolution) + const adapterModule = await loadExternalAdapterPackage(canonicalName, moduleLocalPath); + + // Check if this type conflicts with a built-in adapter + if (BUILTIN_ADAPTER_TYPES.has(adapterModule.type)) { + res.status(409).json({ + error: `Adapter type "${adapterModule.type}" is a built-in adapter and cannot be overwritten.`, + }); + return; + } + + // Check if already registered (indicates a reinstall/update) + const existing = findServerAdapter(adapterModule.type); + const isReinstall = existing !== null; + if (existing) { + unregisterServerAdapter(adapterModule.type); + logger.info({ type: adapterModule.type }, "Unregistered existing adapter for replacement"); + } + + // Register the new adapter + registerWithSessionManagement(adapterModule); + + // Persist the record (use canonicalName without version suffix) + const record: AdapterPluginRecord = { + packageName: canonicalName, + localPath: moduleLocalPath, + version: installedVersion ?? explicitVersion, + type: adapterModule.type, + installedAt: new Date().toISOString(), + }; + addAdapterPlugin(record); + + logger.info( + { type: adapterModule.type, packageName: canonicalName }, + "External adapter installed and registered", + ); + + res.status(201).json({ + type: adapterModule.type, + packageName: canonicalName, + version: installedVersion ?? explicitVersion, + installedAt: record.installedAt, + requiresRestart: isReinstall, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err, packageName }, "Failed to install external adapter"); + + // Distinguish npm errors from load errors + if (message.includes("npm") || message.includes("ERR!")) { + res.status(500).json({ error: `npm install failed: ${message}` }); + } else { + res.status(500).json({ error: `Failed to install adapter: ${message}` }); + } + } + }); + + /** + * PATCH /api/adapters/:type + * + * Enable or disable an adapter. Disabled adapters are hidden from agent + * creation menus but remain functional for existing agents. + * + * Request body: { "disabled": boolean } + */ + router.patch("/adapters/:type", async (req, res) => { + assertBoard(req); + + const adapterType = req.params.type; + const { disabled } = req.body as { disabled?: boolean }; + + if (typeof disabled !== "boolean") { + res.status(400).json({ error: "Request body must include { \"disabled\": true|false }." }); + return; + } + + // Check that the adapter exists in the registry + const existing = findServerAdapter(adapterType); + if (!existing) { + res.status(404).json({ error: `Adapter "${adapterType}" is not registered.` }); + return; + } + + const changed = setAdapterDisabled(adapterType, disabled); + + if (changed) { + logger.info({ type: adapterType, disabled }, "Adapter enabled/disabled"); + } + + res.json({ type: adapterType, disabled, changed }); + }); + + /** + * DELETE /api/adapters/:type + * + * Unregister an external adapter. Built-in adapters cannot be removed. + */ + router.delete("/adapters/:type", async (req, res) => { + assertBoard(req); + + const adapterType = req.params.type; + + if (!adapterType) { + res.status(400).json({ error: "Adapter type is required." }); + return; + } + + // Prevent removal of built-in adapters + if (BUILTIN_ADAPTER_TYPES.has(adapterType)) { + res.status(403).json({ + error: `Cannot remove built-in adapter "${adapterType}".`, + }); + return; + } + + // Check that the adapter exists in the registry + const existing = findServerAdapter(adapterType); + if (!existing) { + res.status(404).json({ + error: `Adapter "${adapterType}" is not registered.`, + }); + return; + } + + // Check that it's an external adapter + const externalRecord = getAdapterPluginByType(adapterType); + if (!externalRecord) { + res.status(404).json({ + error: `Adapter "${adapterType}" is not an externally installed adapter.`, + }); + return; + } + + // If installed via npm (has packageName but no localPath), run npm uninstall + if (externalRecord.packageName && !externalRecord.localPath) { + try { + const pluginsDir = getAdapterPluginsDir(); + await execFileAsync("npm", ["uninstall", externalRecord.packageName], { + cwd: pluginsDir, + timeout: 60_000, + }); + logger.info( + { type: adapterType, packageName: externalRecord.packageName }, + "npm uninstall completed for external adapter", + ); + } catch (err) { + logger.warn( + { err, type: adapterType, packageName: externalRecord.packageName }, + "npm uninstall failed for external adapter; continuing with unregister", + ); + } + } + + // Unregister from the runtime registry + unregisterServerAdapter(adapterType); + + // Remove from the persistent store + removeAdapterPlugin(adapterType); + + logger.info({ type: adapterType }, "External adapter unregistered and removed"); + + res.json({ type: adapterType, removed: true }); + }); + + /** + * POST /api/adapters/:type/reload + * + * Reload an external adapter at runtime (for dev iteration without server restart). + * Busts the ESM module cache, re-imports the adapter, and re-registers it. + * + * Cannot be used on built-in adapter types. + */ + router.post("/adapters/:type/reload", async (req, res) => { + assertBoard(req); + + const type = req.params.type; + + // Built-in adapters cannot be reloaded + if (BUILTIN_ADAPTER_TYPES.has(type)) { + res.status(400).json({ error: "Cannot reload built-in adapter." }); + return; + } + + // Reload the adapter module (busts ESM cache, re-imports) + try { + const newModule = await reloadExternalAdapter(type); + + // Not found in the external adapter store + if (!newModule) { + res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` }); + return; + } + + // Swap in the reloaded module + unregisterServerAdapter(type); + registerWithSessionManagement(newModule); + + // Sync store.version from package.json (store may be missing version for local installs). + const record = getAdapterPluginByType(type); + let newVersion: string | undefined; + if (record) { + newVersion = readAdapterPackageVersionFromDisk(record); + if (newVersion) { + addAdapterPlugin({ ...record, version: newVersion }); + } + } + + logger.info({ type, version: newVersion }, "External adapter reloaded at runtime"); + + res.json({ type, version: newVersion, reloaded: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err, type }, "Failed to reload external adapter"); + res.status(500).json({ error: `Failed to reload adapter: ${message}` }); + } + }); + + // ── POST /api/adapters/:type/reinstall ────────────────────────────────── + // Reinstall an npm-sourced external adapter (pulls latest from registry). + // Local-path adapters cannot be reinstalled — use Reload instead. + // + // This is a convenience shortcut for remove + install with the same + // package name, but without the risk of losing the store record. + router.post("/adapters/:type/reinstall", async (req, res) => { + assertBoard(req); + + const type = req.params.type; + + if (BUILTIN_ADAPTER_TYPES.has(type)) { + res.status(400).json({ error: "Cannot reinstall built-in adapter." }); + return; + } + + const record = getAdapterPluginByType(type); + if (!record) { + res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` }); + return; + } + + if (record.localPath) { + res.status(400).json({ error: "Local-path adapters cannot be reinstalled. Use Reload instead." }); + return; + } + + try { + const pluginsDir = getAdapterPluginsDir(); + + logger.info({ type, packageName: record.packageName }, "Reinstalling adapter package via npm"); + + await execFileAsync("npm", ["install", "--no-save", record.packageName], { + cwd: pluginsDir, + timeout: 120_000, + }); + + // Reload the freshly installed adapter + const newModule = await reloadExternalAdapter(type); + if (!newModule) { + res.status(500).json({ error: "npm install succeeded but adapter reload failed." }); + return; + } + + unregisterServerAdapter(type); + registerWithSessionManagement(newModule); + + // Sync store version from disk + let newVersion: string | undefined; + const updatedRecord = getAdapterPluginByType(type); + if (updatedRecord) { + newVersion = readAdapterPackageVersionFromDisk(updatedRecord); + if (newVersion) { + addAdapterPlugin({ ...updatedRecord, version: newVersion }); + } + } + + logger.info({ type, version: newVersion }, "Adapter reinstalled from npm"); + + res.json({ type, version: newVersion, reinstalled: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err, type }, "Failed to reinstall adapter"); + res.status(500).json({ error: `Reinstall failed: ${message}` }); + } + }); + + // ── GET /api/adapters/:type/ui-parser.js ───────────────────────────────── + // Serve the self-contained UI parser JS for an adapter type. + // This allows external adapters to provide custom run-log parsing + // without modifying Paperclip's source code. + // + // The adapter package must export a "./ui-parser" entry in package.json + // pointing to a self-contained ESM module with zero runtime dependencies. + router.get("/adapters/:type/ui-parser.js", (req, res) => { + assertBoard(req); + const { type } = req.params; + const source = getOrExtractUiParserSource(type); + if (!source) { + res.status(404).json({ error: `No UI parser available for adapter "${type}".` }); + return; + } + res.type("application/javascript").send(source); + }); + + return router; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 68084040..36a87d63 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -46,7 +46,12 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; -import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js"; +import { + detectAdapterModel, + findServerAdapter, + listAdapterModels, + requireServerAdapter, +} from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; @@ -69,6 +74,7 @@ export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", + droid_local: "instructionsFilePath", gemini_local: "instructionsFilePath", opencode_local: "instructionsFilePath", cursor: "instructionsFilePath", @@ -322,6 +328,21 @@ export function agentRoutes(db: Db) { } } + function assertKnownAdapterType(type: string | null | undefined): string { + const adapterType = typeof type === "string" ? type.trim() : ""; + if (!adapterType) { + throw unprocessable("Adapter type is required"); + } + if (!findServerAdapter(adapterType)) { + throw unprocessable(`Unknown adapter type: ${adapterType}`); + } + return adapterType; + } + + function hasOwn(value: object, key: string): boolean { + return Object.hasOwn(value, key); + } + async function resolveCompanyIdForAgentReference(req: Request): Promise { const companyIdQuery = req.query.companyId; const requestedCompanyId = @@ -743,7 +764,7 @@ export function agentRoutes(db: Db) { router.get("/companies/:companyId/adapters/:type/models", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const type = req.params.type as string; + const type = assertKnownAdapterType(req.params.type as string); const models = await listAdapterModels(type); res.json(models); }); @@ -751,7 +772,7 @@ export function agentRoutes(db: Db) { router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const type = req.params.type as string; + const type = assertKnownAdapterType(req.params.type as string); const detected = await detectAdapterModel(type); res.json(detected); @@ -762,14 +783,10 @@ export function agentRoutes(db: Db) { validate(testAdapterEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; - const type = req.params.type as string; + const type = assertKnownAdapterType(req.params.type as string); await assertCanReadConfigurations(req, companyId); - const adapter = findServerAdapter(type); - if (!adapter) { - res.status(404).json({ error: `Unknown adapter type: ${type}` }); - return; - } + const adapter = requireServerAdapter(type); const inputAdapterConfig = (req.body?.adapterConfig ?? {}) as Record; @@ -1265,6 +1282,7 @@ export function agentRoutes(db: Db) { sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body; + hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, ((hireInput.adapterConfig ?? {}) as Record), @@ -1429,6 +1447,7 @@ export function agentRoutes(db: Db) { desiredSkills: requestedDesiredSkills, ...createInput } = req.body; + createInput.adapterType = assertKnownAdapterType(createInput.adapterType); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( createInput.adapterType, ((createInput.adapterConfig ?? {}) as Record), @@ -1807,7 +1826,7 @@ export function agentRoutes(db: Db) { } await assertCanUpdateAgent(req, existing); - if (Object.prototype.hasOwnProperty.call(req.body, "permissions")) { + if (hasOwn(req.body as object, "permissions")) { res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" }); return; } @@ -1815,7 +1834,7 @@ export function agentRoutes(db: Db) { const patchData = { ...(req.body as Record) }; const replaceAdapterConfig = patchData.replaceAdapterConfig === true; delete patchData.replaceAdapterConfig; - if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) { + if (hasOwn(patchData, "adapterConfig")) { const adapterConfig = asRecord(patchData.adapterConfig); if (!adapterConfig) { res.status(422).json({ error: "adapterConfig must be an object" }); @@ -1830,16 +1849,17 @@ export function agentRoutes(db: Db) { patchData.adapterConfig = adapterConfig; } - const requestedAdapterType = - typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType; + const requestedAdapterType = hasOwn(patchData, "adapterType") + ? assertKnownAdapterType(patchData.adapterType as string | null | undefined) + : existing.adapterType; const touchesAdapterConfiguration = - Object.prototype.hasOwnProperty.call(patchData, "adapterType") || - Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); + hasOwn(patchData, "adapterType") || + hasOwn(patchData, "adapterConfig"); if (touchesAdapterConfiguration) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const changingAdapterType = typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType; - const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + const requestedAdapterConfig = hasOwn(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) : null; if ( diff --git a/server/src/services/adapter-plugin-store.ts b/server/src/services/adapter-plugin-store.ts new file mode 100644 index 00000000..8c26abe8 --- /dev/null +++ b/server/src/services/adapter-plugin-store.ts @@ -0,0 +1,177 @@ +/** + * JSON-file-backed store for external adapter registrations. + * + * Stores metadata about externally installed adapter packages at + * ~/.paperclip/adapter-plugins.json. This is the source of truth for which + * external adapters should be loaded at startup. + * + * Both the plugin store and the settings store are cached in memory after + * the first read. Writes invalidate the cache so the next read picks up + * the new state without a redundant disk round-trip. + * + * @module server/services/adapter-plugin-store + */ + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AdapterPluginRecord { + /** npm package name (e.g., "droid-paperclip-adapter") */ + packageName: string; + /** Absolute local filesystem path (for locally linked adapters) */ + localPath?: string; + /** Installed version string (for npm packages) */ + version?: string; + /** Adapter type identifier (matches ServerAdapterModule.type) */ + type: string; + /** ISO 8601 timestamp of when the adapter was installed */ + installedAt: string; + /** Whether this adapter is disabled (hidden from menus but still functional) */ + disabled?: boolean; +} + +interface AdapterSettings { + disabledTypes: string[]; +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const PAPERCLIP_DIR = path.join(os.homedir(), ".paperclip"); +const ADAPTER_PLUGINS_DIR = path.join(PAPERCLIP_DIR, "adapter-plugins"); +const ADAPTER_PLUGINS_STORE_PATH = path.join(PAPERCLIP_DIR, "adapter-plugins.json"); +const ADAPTER_SETTINGS_PATH = path.join(PAPERCLIP_DIR, "adapter-settings.json"); + +// --------------------------------------------------------------------------- +// In-memory caches (invalidated on write) +// --------------------------------------------------------------------------- + +let storeCache: AdapterPluginRecord[] | null = null; +let settingsCache: AdapterSettings | null = null; + +// --------------------------------------------------------------------------- +// Store functions +// --------------------------------------------------------------------------- + +function ensureDirs(): void { + fs.mkdirSync(ADAPTER_PLUGINS_DIR, { recursive: true }); + const pkgJsonPath = path.join(ADAPTER_PLUGINS_DIR, "package.json"); + if (!fs.existsSync(pkgJsonPath)) { + fs.writeFileSync(pkgJsonPath, JSON.stringify({ + name: "paperclip-adapter-plugins", + version: "0.0.0", + private: true, + description: "Managed directory for Paperclip external adapter plugins. Do not edit manually.", + }, null, 2) + "\n"); + } +} + +function readStore(): AdapterPluginRecord[] { + if (storeCache) return storeCache; + try { + const raw = fs.readFileSync(ADAPTER_PLUGINS_STORE_PATH, "utf-8"); + const parsed = JSON.parse(raw); + storeCache = Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : []; + } catch { + storeCache = []; + } + return storeCache; +} + +function writeStore(records: AdapterPluginRecord[]): void { + ensureDirs(); + fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8"); + storeCache = records; +} + +function readSettings(): AdapterSettings { + if (settingsCache) return settingsCache; + try { + const raw = fs.readFileSync(ADAPTER_SETTINGS_PATH, "utf-8"); + const parsed = JSON.parse(raw); + settingsCache = parsed && Array.isArray(parsed.disabledTypes) + ? (parsed as AdapterSettings) + : { disabledTypes: [] }; + } catch { + settingsCache = { disabledTypes: [] }; + } + return settingsCache; +} + +function writeSettings(settings: AdapterSettings): void { + ensureDirs(); + fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8"); + settingsCache = settings; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function listAdapterPlugins(): AdapterPluginRecord[] { + return readStore(); +} + +export function addAdapterPlugin(record: AdapterPluginRecord): void { + const store = [...readStore()]; + const idx = store.findIndex((r) => r.type === record.type); + if (idx >= 0) { + store[idx] = record; + } else { + store.push(record); + } + writeStore(store); +} + +export function removeAdapterPlugin(type: string): boolean { + const store = [...readStore()]; + const idx = store.findIndex((r) => r.type === type); + if (idx < 0) return false; + store.splice(idx, 1); + writeStore(store); + return true; +} + +export function getAdapterPluginByType(type: string): AdapterPluginRecord | undefined { + return readStore().find((r) => r.type === type); +} + +export function getAdapterPluginsDir(): string { + ensureDirs(); + return ADAPTER_PLUGINS_DIR; +} + +// --------------------------------------------------------------------------- +// Adapter enable/disable (settings) +// --------------------------------------------------------------------------- + +export function getDisabledAdapterTypes(): string[] { + return readSettings().disabledTypes; +} + +export function isAdapterDisabled(type: string): boolean { + return readSettings().disabledTypes.includes(type); +} + +export function setAdapterDisabled(type: string, disabled: boolean): boolean { + const settings = { ...readSettings(), disabledTypes: [...readSettings().disabledTypes] }; + const idx = settings.disabledTypes.indexOf(type); + + if (disabled && idx < 0) { + settings.disabledTypes.push(type); + writeSettings(settings); + return true; + } + if (!disabled && idx >= 0) { + settings.disabledTypes.splice(idx, 1); + writeSettings(settings); + return true; + } + return false; +} diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 4ef07047..441b0882 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -7,6 +7,12 @@ function readNumericField(record: Record, key: string) { return key in record ? record[key] ?? null : undefined; } +function readCommentText(value: unknown) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + export function summarizeHeartbeatRunResultJson( resultJson: Record | null | undefined, ): Record | null { @@ -33,3 +39,18 @@ export function summarizeHeartbeatRunResultJson( return Object.keys(summary).length > 0 ? summary : null; } + +export function buildHeartbeatRunIssueComment( + resultJson: Record | null | undefined, +): string | null { + if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) { + return null; + } + + return ( + readCommentText(resultJson.summary) + ?? readCommentText(resultJson.result) + ?? readCommentText(resultJson.message) + ?? null + ); +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 356783de..dc14bc99 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -31,7 +31,7 @@ import { companySkillService } from "./company-skills.js"; import { budgetService, type BudgetEnforcementScope } from "./budgets.js"; import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js"; -import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js"; +import { buildHeartbeatRunIssueComment, summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js"; import { buildWorkspaceReadyComment, cleanupExecutionWorkspaceArtifacts, @@ -2838,6 +2838,19 @@ export function heartbeatService(db: Db) { exitCode: adapterResult.exitCode, }, }); + if (issueId && outcome === "succeeded") { + try { + const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null); + if (issueComment) { + await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id }); + } + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to post run summary comment: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } await releaseIssueExecutionAndPromote(finalizedRun); } diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 4d487552..22ccb017 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -807,7 +807,7 @@ export function buildHostServices( return (await issues.addComment( params.issueId, params.body, - {}, + { agentId: params.authorAgentId }, )) as IssueComment; }, }, diff --git a/tsconfig.json b/tsconfig.json index 3a989f38..9a5267db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "./packages/adapters/claude-local" }, { "path": "./packages/adapters/codex-local" }, { "path": "./packages/adapters/cursor-local" }, + { "path": "./packages/adapters/droid-local" }, { "path": "./packages/adapters/openclaw-gateway" }, { "path": "./packages/adapters/opencode-local" }, { "path": "./packages/adapters/pi-local" }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f240defc..0bc4721b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -34,6 +34,7 @@ import { InstanceSettings } from "./pages/InstanceSettings"; import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; +import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; @@ -175,6 +176,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> @@ -321,6 +323,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts new file mode 100644 index 00000000..cbaae011 --- /dev/null +++ b/ui/src/adapters/adapter-display-registry.ts @@ -0,0 +1,151 @@ +/** + * Single source of truth for adapter display metadata. + * + * Built-in adapters have entries in `adapterDisplayMap`. External (plugin) + * adapters get sensible defaults derived from their type string via + * `getAdapterDisplay()`. + */ +import type { ComponentType } from "react"; +import { + Bot, + Code, + Gem, + MousePointer2, + Sparkles, + Terminal, + Cpu, +} from "lucide-react"; +import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon"; + +// --------------------------------------------------------------------------- +// Type suffix parsing +// --------------------------------------------------------------------------- + +const TYPE_SUFFIXES: Record = { + _local: "local", + _gateway: "gateway", +}; + +function getTypeSuffix(type: string): string | null { + for (const [suffix, mode] of Object.entries(TYPE_SUFFIXES)) { + if (type.endsWith(suffix)) return mode; + } + return null; +} + +function withSuffix(label: string, suffix: string | null): string { + return suffix ? `${label} (${suffix})` : label; +} + +// --------------------------------------------------------------------------- +// Display metadata per adapter type +// --------------------------------------------------------------------------- + +export interface AdapterDisplayInfo { + label: string; + description: string; + icon: ComponentType<{ className?: string }>; + recommended?: boolean; + comingSoon?: boolean; + disabledLabel?: string; +} + +const adapterDisplayMap: Record = { + claude_local: { + label: "Claude Code", + description: "Local Claude agent", + icon: Sparkles, + recommended: true, + }, + codex_local: { + label: "Codex", + description: "Local Codex agent", + icon: Code, + recommended: true, + }, + gemini_local: { + label: "Gemini CLI", + description: "Local Gemini agent", + icon: Gem, + }, + opencode_local: { + label: "OpenCode", + description: "Local multi-provider agent", + icon: OpenCodeLogoIcon, + }, + pi_local: { + label: "Pi", + description: "Local Pi agent", + icon: Terminal, + }, + cursor: { + label: "Cursor", + description: "Local Cursor agent", + icon: MousePointer2, + }, + openclaw_gateway: { + label: "OpenClaw Gateway", + description: "Invoke OpenClaw via gateway protocol", + icon: Bot, + comingSoon: true, + disabledLabel: "Configure OpenClaw within the App", + }, + process: { + label: "Process", + description: "Internal process adapter", + icon: Cpu, + comingSoon: true, + }, + http: { + label: "HTTP", + description: "Internal HTTP adapter", + icon: Cpu, + comingSoon: true, + }, +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +function humanizeType(type: string): string { + // Strip known type suffixes so "droid_local" → "Droid", not "Droid Local" + let base = type; + for (const suffix of Object.keys(TYPE_SUFFIXES)) { + if (base.endsWith(suffix)) { + base = base.slice(0, -suffix.length); + break; + } + } + return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export function getAdapterLabel(type: string): string { + const base = adapterDisplayMap[type]?.label ?? humanizeType(type); + return withSuffix(base, getTypeSuffix(type)); +} + +export function getAdapterLabels(): Record { + const suffixed: Record = {}; + for (const [type, info] of Object.entries(adapterDisplayMap)) { + suffixed[type] = withSuffix(info.label, getTypeSuffix(type)); + } + return suffixed; +} + +export function getAdapterDisplay(type: string): AdapterDisplayInfo { + const known = adapterDisplayMap[type]; + if (known) return known; + + const suffix = getTypeSuffix(type); + const label = withSuffix(humanizeType(type), suffix); + return { + label, + description: suffix ? `External ${suffix} adapter` : "External adapter", + icon: Cpu, + }; +} + +export function isKnownAdapterType(type: string): boolean { + return type in adapterDisplayMap; +} diff --git a/ui/src/adapters/disabled-store.ts b/ui/src/adapters/disabled-store.ts new file mode 100644 index 00000000..66de3e71 --- /dev/null +++ b/ui/src/adapters/disabled-store.ts @@ -0,0 +1,33 @@ +/** + * Client-side store for disabled adapter types. + * + * Hydrated from the server's GET /api/adapters response. + * Provides synchronous reads so module-level constants can filter against it. + * Falls back to "nothing disabled" before the first hydration. + * + * Usage in components: + * useQuery + adaptersApi.list() populates the store automatically. + * + * Usage in non-React code: + * import { isAdapterTypeHidden } from "@/adapters/disabled-store"; + */ + +let disabledTypes = new Set(); + +/** Check if an adapter type is hidden from menus (sync read). */ +export function isAdapterTypeHidden(type: string): boolean { + return disabledTypes.has(type); +} + +/** Get all hidden adapter types (sync read). */ +export function getHiddenAdapterTypes(): Set { + return disabledTypes; +} + +/** + * Hydrate the store from a server response. + * Called by components that fetch the adapters list. + */ +export function setDisabledAdapterTypes(types: string[]): void { + disabledTypes = new Set(types); +} diff --git a/ui/src/adapters/dynamic-loader.ts b/ui/src/adapters/dynamic-loader.ts new file mode 100644 index 00000000..23d5f443 --- /dev/null +++ b/ui/src/adapters/dynamic-loader.ts @@ -0,0 +1,106 @@ +/** + * Dynamic UI parser loading for external adapters. + * + * When the Paperclip UI encounters an adapter type that doesn't have a + * built-in parser (e.g., an external adapter loaded via the plugin system), + * it fetches the parser JS from `/api/adapters/:type/ui-parser.js` and + * evaluates it to create a `parseStdoutLine` function. + * + * The parser module must export: + * - `parseStdoutLine(line: string, ts: string): TranscriptEntry[]` + * - optionally `createStdoutParser(): { parseLine, reset }` for stateful parsers + * + * This is the bridge between the server-side plugin system and the client-side + * UI rendering. Adapter developers ship a `dist/ui-parser.js` with zero + * runtime dependencies, and Paperclip's UI loads it on demand. + */ + +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import type { StdoutLineParser } from "./types"; + +// Cache of dynamically loaded parsers by adapter type. +// Once loaded, the parser is reused for all runs of that adapter type. +const dynamicParserCache = new Map(); + +// Track which types we've already attempted to load (to avoid repeat 404s). +const failedLoads = new Set(); + +/** + * Dynamically load a UI parser for an adapter type from the server API. + * + * Fetches `/api/adapters/:type/ui-parser.js`, evaluates the module source + * in a scoped context, and extracts the `parseStdoutLine` export. + * + * @returns A StdoutLineParser function, or null if unavailable. + */ +export async function loadDynamicParser(adapterType: string): Promise { + // Return cached parser if already loaded + const cached = dynamicParserCache.get(adapterType); + if (cached) return cached; + + // Don't retry types that previously 404'd + if (failedLoads.has(adapterType)) return null; + + try { + const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`); + if (!response.ok) { + failedLoads.add(adapterType); + return null; + } + + const source = await response.text(); + + // Evaluate the module source using URL.createObjectURL + dynamic import(). + // This properly supports ESM modules with `export` statements. + // (new Function("exports", source) would fail with SyntaxError on `export` keywords.) + const blob = new Blob([source], { type: "application/javascript" }); + const blobUrl = URL.createObjectURL(blob); + + let parseFn: StdoutLineParser; + + try { + const mod = await import(/* @vite-ignore */ blobUrl); + + // Prefer the factory function (stateful parser) if available, + // fall back to the static parseStdoutLine function. + if (typeof mod.createStdoutParser === "function") { + // Stateful parser — create one instance for the UI session. + // Each run creates its own transcript builder, so a single + // parser instance is sufficient per adapter type. + const parser = (mod.createStdoutParser as () => { parseLine: StdoutLineParser; reset: () => void })(); + parseFn = parser.parseLine.bind(parser); + } else if (typeof mod.parseStdoutLine === "function") { + parseFn = mod.parseStdoutLine as StdoutLineParser; + } else { + console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`); + failedLoads.add(adapterType); + return null; + } + } finally { + URL.revokeObjectURL(blobUrl); + } + + // Cache for reuse + dynamicParserCache.set(adapterType, parseFn); + console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`); + return parseFn; + } catch (err) { + console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err); + failedLoads.add(adapterType); + return null; + } +} + +/** + * Invalidate a cached dynamic parser, removing it from both the parser cache + * and the failed-loads set so that the next load attempt will try again. + */ +export function invalidateDynamicParser(adapterType: string): boolean { + const wasCached = dynamicParserCache.has(adapterType); + dynamicParserCache.delete(adapterType); + failedLoads.delete(adapterType); + if (wasCached) { + console.info(`[adapter-ui-loader] Invalidated dynamic UI parser for "${adapterType}"`); + } + return wasCached; +} diff --git a/ui/src/adapters/hermes-local/config-fields.tsx b/ui/src/adapters/hermes-local/config-fields.tsx deleted file mode 100644 index 62b85fea..00000000 --- a/ui/src/adapters/hermes-local/config-fields.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { AdapterConfigFieldsProps } from "../types"; -import { - Field, - DraftInput, -} from "../../components/agent-config-primitives"; -import { ChoosePathButton } from "../../components/PathInstructionsModal"; - -const inputClass = - "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; -const instructionsFileHint = - "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; - -export function HermesLocalConfigFields({ - isCreate, - values, - set, - config, - eff, - mark, - hideInstructionsFile, -}: AdapterConfigFieldsProps) { - if (hideInstructionsFile) return null; - return ( - -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
-
- ); -} diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts deleted file mode 100644 index 97c064f8..00000000 --- a/ui/src/adapters/hermes-local/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { UIAdapterModule } from "../types"; -import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; -import { HermesLocalConfigFields } from "./config-fields"; -import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; - -export const hermesLocalUIAdapter: UIAdapterModule = { - type: "hermes_local", - label: "Hermes Agent", - parseStdoutLine: parseHermesStdoutLine, - ConfigFields: HermesLocalConfigFields, - buildAdapterConfig: buildHermesConfig, -}; diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index feb04511..ec4f196b 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -1,4 +1,11 @@ -export { getUIAdapter, listUIAdapters } from "./registry"; +export { + getUIAdapter, + listUIAdapters, + findUIAdapter, + registerUIAdapter, + unregisterUIAdapter, + syncExternalAdapters, +} from "./registry"; export { buildTranscript } from "./transcript"; export type { TranscriptEntry, diff --git a/ui/src/adapters/metadata.test.ts b/ui/src/adapters/metadata.test.ts new file mode 100644 index 00000000..70b7ef3c --- /dev/null +++ b/ui/src/adapters/metadata.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isEnabledAdapterType, listAdapterOptions } from "./metadata"; +import type { UIAdapterModule } from "./types"; + +const externalAdapter: UIAdapterModule = { + type: "external_test", + label: "External Test", + parseStdoutLine: () => [], + ConfigFields: () => null, + buildAdapterConfig: () => ({}), +}; + +describe("adapter metadata", () => { + it("treats registered external adapters as enabled by default", () => { + expect(isEnabledAdapterType("external_test")).toBe(true); + + expect( + listAdapterOptions((type) => type, [externalAdapter]), + ).toEqual([ + { + value: "external_test", + label: "external_test", + comingSoon: false, + hidden: false, + }, + ]); + }); + + it("keeps intentionally withheld built-in adapters marked as coming soon", () => { + expect(isEnabledAdapterType("process")).toBe(false); + expect(isEnabledAdapterType("http")).toBe(false); + }); +}); \ No newline at end of file diff --git a/ui/src/adapters/metadata.ts b/ui/src/adapters/metadata.ts new file mode 100644 index 00000000..d8a9e1f1 --- /dev/null +++ b/ui/src/adapters/metadata.ts @@ -0,0 +1,61 @@ +/** + * Adapter metadata utilities — built on top of the display registry and UI adapter list. + * + * This module bridges the static display metadata with the dynamic adapter registry. + * "Coming soon" status is derived from the display registry's `comingSoon` flag. + * "Hidden" status comes from the disabled-adapter store (server-side toggle). + */ +import type { UIAdapterModule } from "./types"; +import { listUIAdapters } from "./registry"; +import { isAdapterTypeHidden } from "./disabled-store"; +import { getAdapterLabel, getAdapterDisplay } from "./adapter-display-registry"; + +export interface AdapterOptionMetadata { + value: string; + label: string; + comingSoon: boolean; + hidden: boolean; +} + +export function listKnownAdapterTypes(): string[] { + return listUIAdapters().map((adapter) => adapter.type); +} + +/** + * Check whether an adapter type is enabled (not "coming soon"). + * Unknown types (external adapters) are always considered enabled. + */ +export function isEnabledAdapterType(type: string): boolean { + return !getAdapterDisplay(type).comingSoon; +} + +/** + * Build option metadata for a list of adapters (for dropdowns). + * `labelFor` callback allows callers to override labels; defaults to display registry. + */ +export function listAdapterOptions( + labelFor?: (type: string) => string, + adapters: UIAdapterModule[] = listUIAdapters(), +): AdapterOptionMetadata[] { + const getLabel = labelFor ?? getAdapterLabel; + return adapters.map((adapter) => ({ + value: adapter.type, + label: getLabel(adapter.type), + comingSoon: !!getAdapterDisplay(adapter.type).comingSoon, + hidden: isAdapterTypeHidden(adapter.type), + })); +} + +/** + * List UI adapters excluding those hidden via the Adapters settings page. + */ +export function listVisibleUIAdapters(): UIAdapterModule[] { + return listUIAdapters().filter((a) => !isAdapterTypeHidden(a.type)); +} + +/** + * List visible adapter types (for non-React contexts like module-level constants). + */ +export function listVisibleAdapterTypes(): string[] { + return listVisibleUIAdapters().map((a) => a.type); +} diff --git a/ui/src/adapters/registry.test.ts b/ui/src/adapters/registry.test.ts new file mode 100644 index 00000000..b80dcc28 --- /dev/null +++ b/ui/src/adapters/registry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import type { UIAdapterModule } from "./types"; +import { + findUIAdapter, + getUIAdapter, + listUIAdapters, + registerUIAdapter, + unregisterUIAdapter, +} from "./registry"; +import { processUIAdapter } from "./process"; + +const externalUIAdapter: UIAdapterModule = { + type: "external_test", + label: "External Test", + parseStdoutLine: () => [], + ConfigFields: () => null, + buildAdapterConfig: () => ({}), +}; + +describe("ui adapter registry", () => { + beforeEach(() => { + unregisterUIAdapter("external_test"); + }); + + afterEach(() => { + unregisterUIAdapter("external_test"); + }); + + it("registers adapters for lookup and listing", () => { + registerUIAdapter(externalUIAdapter); + + expect(findUIAdapter("external_test")).toBe(externalUIAdapter); + expect(getUIAdapter("external_test")).toBe(externalUIAdapter); + expect(listUIAdapters().some((adapter) => adapter.type === "external_test")).toBe(true); + }); + + it("falls back to the process parser for unknown types after unregistering", () => { + registerUIAdapter(externalUIAdapter); + + unregisterUIAdapter("external_test"); + + expect(findUIAdapter("external_test")).toBeNull(); + const fallback = getUIAdapter("external_test"); + // Unknown types return a lazy-loading wrapper (for external adapters), + // not the process adapter directly. The type is preserved. + expect(fallback.type).toBe("external_test"); + // But it uses the process parser under the hood. + expect(fallback.ConfigFields).toBe(processUIAdapter.ConfigFields); + }); +}); diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 67d89ada..a27e7316 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,32 +3,130 @@ import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; -import { hermesLocalUIAdapter } from "./hermes-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; +import { loadDynamicParser } from "./dynamic-loader"; -const uiAdapters: UIAdapterModule[] = [ - claudeLocalUIAdapter, - codexLocalUIAdapter, - geminiLocalUIAdapter, - hermesLocalUIAdapter, - openCodeLocalUIAdapter, - piLocalUIAdapter, - cursorLocalUIAdapter, - openClawGatewayUIAdapter, - processUIAdapter, - httpUIAdapter, -]; +const uiAdapters: UIAdapterModule[] = []; +const adaptersByType = new Map(); -const adaptersByType = new Map( - uiAdapters.map((a) => [a.type, a]), -); +function registerBuiltInUIAdapters() { + for (const adapter of [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + geminiLocalUIAdapter, + openCodeLocalUIAdapter, + piLocalUIAdapter, + cursorLocalUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, + ]) { + registerUIAdapter(adapter); + } +} + +export function registerUIAdapter(adapter: UIAdapterModule): void { + const existingIndex = uiAdapters.findIndex((entry) => entry.type === adapter.type); + if (existingIndex >= 0) { + uiAdapters.splice(existingIndex, 1, adapter); + } else { + uiAdapters.push(adapter); + } + adaptersByType.set(adapter.type, adapter); +} + +export function unregisterUIAdapter(type: string): void { + if (type === processUIAdapter.type || type === httpUIAdapter.type) return; + const existingIndex = uiAdapters.findIndex((entry) => entry.type === type); + if (existingIndex >= 0) { + uiAdapters.splice(existingIndex, 1); + } + adaptersByType.delete(type); +} + +export function findUIAdapter(type: string): UIAdapterModule | null { + return adaptersByType.get(type) ?? null; +} + +registerBuiltInUIAdapters(); export function getUIAdapter(type: string): UIAdapterModule { - return adaptersByType.get(type) ?? processUIAdapter; + const builtIn = adaptersByType.get(type); + + if (!builtIn) { + // No built-in adapter — fall through to the external-only path. + let loadStarted = false; + return { + type, + label: type, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(type).then((parser) => { + if (parser) { + registerUIAdapter({ + type, + label: type, + parseStdoutLine: parser, + ConfigFields: processUIAdapter.ConfigFields, + buildAdapterConfig: processUIAdapter.buildAdapterConfig, + }); + } + }); + } + return processUIAdapter.parseStdoutLine(line, ts); + }, + ConfigFields: processUIAdapter.ConfigFields, + buildAdapterConfig: processUIAdapter.buildAdapterConfig, + }; + } + + return builtIn; +} + +/** + * Ensure external adapter types (from the server's /api/adapters response) + * are registered in the UI adapter list so they appear in dropdowns. + * + * For each type not already registered, creates a placeholder module that + * uses the process adapter defaults and kicks off dynamic parser loading. + * Once the parser resolves, the placeholder is replaced with the real one. + */ +export function syncExternalAdapters( + serverAdapters: { type: string; label: string }[], +): void { + for (const { type, label } of serverAdapters) { + if (adaptersByType.has(type)) continue; + + let loadStarted = false; + registerUIAdapter({ + type, + label, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(type).then((parser) => { + if (parser) { + registerUIAdapter({ + type, + label, + parseStdoutLine: parser, + ConfigFields: processUIAdapter.ConfigFields, + buildAdapterConfig: processUIAdapter.buildAdapterConfig, + }); + } + }); + } + return processUIAdapter.parseStdoutLine(line, ts); + }, + ConfigFields: processUIAdapter.ConfigFields, + buildAdapterConfig: processUIAdapter.buildAdapterConfig, + }); + } } export function listUIAdapters(): UIAdapterModule[] { diff --git a/ui/src/adapters/use-disabled-adapters.ts b/ui/src/adapters/use-disabled-adapters.ts new file mode 100644 index 00000000..9810d094 --- /dev/null +++ b/ui/src/adapters/use-disabled-adapters.ts @@ -0,0 +1,49 @@ +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { adaptersApi } from "@/api/adapters"; +import { setDisabledAdapterTypes } from "@/adapters/disabled-store"; +import { syncExternalAdapters } from "@/adapters/registry"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * Fetch adapters and keep the disabled-adapter store + UI adapter registry + * in sync with the server. + * + * - Registers external adapter types in the UI registry so they appear in + * dropdowns (done eagerly during render — idempotent, no React state). + * - Syncs the disabled-adapter store for non-React consumers (useEffect). + * + * Returns a reactive Set of disabled types for use as useMemo dependencies. + * Call this at the top of any component that renders adapter menus. + */ +export function useDisabledAdaptersSync(): Set { + const { data: adapters } = useQuery({ + queryKey: queryKeys.adapters.all, + queryFn: () => adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + + // Eagerly register external adapter types in the UI registry so that + // consumers calling listUIAdapters() in the same render cycle see them. + // This is idempotent — already-registered types are skipped. + if (adapters) { + syncExternalAdapters( + adapters + .filter((a) => a.source === "external") + .map((a) => ({ type: a.type, label: a.label })), + ); + } + + // Sync the disabled set to the global store for non-React code + useEffect(() => { + if (!adapters) return; + setDisabledAdapterTypes( + adapters.filter((a) => a.disabled).map((a) => a.type), + ); + }, [adapters]); + + return useMemo( + () => new Set(adapters?.filter((a) => a.disabled).map((a) => a.type) ?? []), + [adapters], + ); +} diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts new file mode 100644 index 00000000..dea60394 --- /dev/null +++ b/ui/src/api/adapters.ts @@ -0,0 +1,51 @@ +/** + * @fileoverview Frontend API client for external adapter management. + */ + +import { api } from "./client"; + +export interface AdapterInfo { + type: string; + label: string; + source: "builtin" | "external"; + modelsCount: number; + loaded: boolean; + disabled: boolean; + /** Installed version (for external npm adapters) */ + version?: string; + /** Package name (for external adapters) */ + packageName?: string; + /** Whether the adapter was installed from a local path (vs npm). */ + isLocalPath?: boolean; +} + +export interface AdapterInstallResult { + type: string; + packageName: string; + version?: string; + installedAt: string; +} + +export const adaptersApi = { + /** List all registered adapters (built-in + external). */ + list: () => api.get("/adapters"), + + /** Install an external adapter from npm or a local path. */ + install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) => + api.post("/adapters/install", params), + + /** Remove an external adapter by type. */ + remove: (type: string) => api.delete<{ type: string; removed: boolean }>(`/adapters/${type}`), + + /** Enable or disable an adapter (disabled adapters hidden from agent menus). */ + setDisabled: (type: string, disabled: boolean) => + api.patch<{ type: string; disabled: boolean; changed: boolean }>(`/adapters/${type}`, { disabled }), + + /** Reload an external adapter (bust server + client caches). */ + reload: (type: string) => + api.post<{ type: string; version?: string; reloaded: boolean }>(`/adapters/${type}/reload`, {}), + + /** Reinstall an npm-sourced adapter (pulls latest from registry, then reloads). */ + reinstall: (type: string) => + api.post<{ type: string; version?: string; reinstalled: boolean }>(`/adapters/${type}/reinstall`, {}), +}; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index bda8bf7a..fcd38604 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -32,6 +32,7 @@ export interface DetectedAdapterModel { model: string; provider: string; source: string; + candidates?: string[]; } export interface ClaudeLoginResult { diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c3c9bdfa..bb3ac083 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import type { Agent, AdapterEnvironmentTestResult, @@ -46,6 +45,9 @@ import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { ReportsToPicker } from "./ReportsToPicker"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; +import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; +import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; /* ---- Create mode values ---- */ @@ -180,6 +182,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); + // Sync disabled adapter types from server so dropdown filters them out + const disabledTypes = useDisabledAdaptersSync(); + const { data: availableSecrets = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], queryFn: () => secretsApi.list(selectedCompanyId!), @@ -311,15 +316,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const isLocal = - adapterType === "claude_local" || - adapterType === "codex_local" || - adapterType === "gemini_local" || - adapterType === "hermes_local" || - adapterType === "opencode_local" || - adapterType === "pi_local" || - adapterType === "cursor"; - const isHermesLocal = adapterType === "hermes_local"; + const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); + const isLocal = !NONLOCAL_TYPES.has(adapterType); + const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); @@ -345,13 +344,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : ["agents", "none", "detect-model", adapterType], queryFn: () => { if (!selectedCompanyId) { - throw new Error("Select a company to detect the Hermes model"); + throw new Error("Select a company to detect the model"); } return agentsApi.detectModel(selectedCompanyId, adapterType); }, - enabled: Boolean(selectedCompanyId && isHermesLocal), + enabled: Boolean(selectedCompanyId && isLocal), }); const detectedModel = detectedModelData?.model ?? null; + const detectedModelCandidates = detectedModelData?.candidates ?? []; const { data: companyAgents = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], @@ -583,6 +583,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { { if (isCreate) { // Reset all adapter-specific fields to defaults when switching adapter type @@ -716,24 +717,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onCommit={(v) => isCreate ? set!({ command: v }) - : mark("adapterConfig", "command", v || undefined) + : mark("adapterConfig", "command", v || null) } immediate className={inputClass} placeholder={ - adapterType === "codex_local" - ? "codex" - : adapterType === "gemini_local" - ? "gemini" - : adapterType === "hermes_local" - ? "hermes" - : adapterType === "pi_local" - ? "pi" - : adapterType === "cursor" - ? "agent" - : adapterType === "opencode_local" - ? "opencode" - : "claude" + ({ + claude_local: "claude", + codex_local: "codex", + gemini_local: "gemini", + pi_local: "pi", + cursor: "agent", + opencode_local: "opencode", + } as Record)[adapterType] ?? adapterType.replace(/_local$/, "") } /> @@ -748,18 +744,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} - allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"} - required={adapterType === "opencode_local" || adapterType === "hermes_local"} + allowDefault={adapterType !== "opencode_local"} + required={adapterType === "opencode_local"} groupByProvider={adapterType === "opencode_local"} - creatable={adapterType === "hermes_local"} - detectedModel={adapterType === "hermes_local" ? detectedModel : null} - onDetectModel={adapterType === "hermes_local" - ? async () => { - const result = await refetchDetectedModel(); - return result.data?.model ?? null; - } - : undefined} - detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined} + creatable + detectedModel={detectedModel} + detectedModelCandidates={[]} + onDetectModel={async () => { + const result = await refetchDetectedModel(); + return result.data?.model ?? null; + }} + detectModelLabel="Detect model" + emptyDetectHint="No model detected. Select or enter one manually." /> {fetchedModelsError && (

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

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

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

- {ADVANCED_ADAPTER_OPTIONS.map((opt) => ( + {adapterGrid.map((opt) => ( ))} @@ -823,60 +803,21 @@ export function OnboardingWizard() { {showMoreAdapters && (
- {[ - { - value: "gemini_local" as const, - label: "Gemini CLI", - icon: Gem, - desc: "Local Gemini agent" - }, - { - value: "opencode_local" as const, - label: "OpenCode", - icon: OpenCodeLogoIcon, - desc: "Local multi-provider agent" - }, - { - value: "pi_local" as const, - label: "Pi", - icon: Terminal, - desc: "Local Pi agent" - }, - { - value: "cursor" as const, - label: "Cursor", - icon: MousePointer2, - desc: "Local Cursor agent" - }, - { - value: "hermes_local" as const, - label: "Hermes Agent", - icon: HermesIcon, - desc: "Local multi-provider agent" - }, - { - value: "openclaw_gateway" as const, - label: "OpenClaw Gateway", - icon: Bot, - desc: "Invoke OpenClaw via gateway protocol", - comingSoon: true, - disabledLabel: "Configure OpenClaw within the App" - } - ].map((opt) => ( - ))} @@ -910,13 +850,7 @@ export function OnboardingWizard() {
{/* Conditional adapter fields */} - {(adapterType === "claude_local" || - adapterType === "codex_local" || - adapterType === "gemini_local" || - adapterType === "hermes_local" || - adapterType === "opencode_local" || - adapterType === "pi_local" || - adapterType === "cursor") && ( + {isLocalAdapter && (
@@ -2980,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 }) { const queryClient = useQueryClient(); const navigate = useNavigate(); const { data: hydratedRun } = useQuery({ @@ -3174,6 +3181,27 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb )}
+ {/* Adapter type · provider · model */} + {(() => { + const displayProvider = metrics.provider + ?? asNonEmptyString(adapterConfig?.provider); + const displayModel = metrics.model + ?? asNonEmptyString(adapterConfig?.model); + if (!adapterType && !displayProvider && !displayModel) return null; + return ( +
+ {adapterType && ( + {adapterType.replace(/_/g, " ")} + )} + {displayProvider && displayModel && ( + {displayProvider}/{displayModel} + )} + {!displayProvider && displayModel && ( + {displayModel} + )} +
+ ); + })()} {resumeRun.isError && (
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"} From c757a07708b14c0431ee3795f2453b8e55ffdf78 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 3 Apr 2026 17:30:45 +0100 Subject: [PATCH 14/49] fix(adapters): stable sort order, npm/local icons, reinstall dialog, HMR polling on WSL - Sort GET /api/adapters alphabetically by type (reload no longer shuffles) - Show red Package icon for npm adapters, amber FolderOpen for local path - Add reinstall confirmation dialog with current vs latest npm version - Enable Vite polling when running on /mnt/ (WSL inotify doesn't work on NTFS) --- server/src/routes/adapters.ts | 2 +- ui/src/pages/AdapterManager.tsx | 110 +++++++++++++++++++++++++++++++- ui/vite.config.ts | 2 + 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 49e76c25..fdeb64d2 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -175,7 +175,7 @@ export function adapterRoutes() { const result: AdapterInfo[] = registeredAdapters.map((adapter) => buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet), - ); + ).sort((a, b) => a.type.localeCompare(b.type)); res.json(result); }); diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index 39df6e75..e41b9ca3 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -63,6 +63,11 @@ function AdapterRow({ {adapter.label || getAdapterLabel(adapter.type)} {adapter.source === "external" ? "External" : "Built-in"} + {adapter.source === "external" && ( + adapter.isLocalPath + ? + : + )} { + 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 ( + { if (!o) onCancel(); }}> + + + Reinstall Adapter + + This will pull the latest version of{" "} + {adapter?.packageName} from npm and hot-swap + the running adapter module. Existing agents will use the new + version on their next run. + + + +
+
+ Package + {adapter?.packageName} +
+
+ Current + + {adapter?.version ? `v${adapter.version}` : "unknown"} + +
+
+ Latest on npm + + {isFetchingVersion + ? "checking..." + : latestVersion + ? `v${latestVersion}` + : "unavailable"} + +
+ {isUpToDate && ( +

+ Already on the latest version. +

+ )} +
+ + + + + +
+
+ ); +} + export function AdapterManager() { const { selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -151,6 +244,7 @@ export function AdapterManager() { const [isLocalPath, setIsLocalPath] = useState(false); const [installDialogOpen, setInstallDialogOpen] = useState(false); const [removeType, setRemoveType] = useState(null); + const [reinstallTarget, setReinstallTarget] = useState(null); useEffect(() => { setBreadcrumbs([ @@ -411,7 +505,7 @@ export function AdapterManager() { onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })} onRemove={(type) => setRemoveType(type)} onReload={(type) => reloadMutation.mutate(type)} - onReinstall={!adapter.isLocalPath ? (type) => reinstallMutation.mutate(type) : undefined} + onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined} isToggling={toggleMutation.isPending} isReloading={reloadMutation.isPending} isReinstalling={reinstallMutation.isPending} @@ -481,6 +575,20 @@ export function AdapterManager() { + {/* Reinstall confirmation */} + { + if (reinstallTarget) { + reinstallMutation.mutate(reinstallTarget.type, { + onSettled: () => setReinstallTarget(null), + }); + } + }} + onCancel={() => setReinstallTarget(null)} + />
); } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 56d8a4db..395eebb9 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -13,6 +13,8 @@ export default defineConfig({ }, server: { port: 5173, + // WSL2 /mnt/ drives don't support inotify — fall back to polling so HMR works + watch: process.cwd().startsWith("/mnt/") ? { usePolling: true, interval: 1000 } : undefined, proxy: { "/api": { target: "http://localhost:3100", From 199a2178cf4e5f2fcb2561315a9afb9d21a1474b Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 3 Apr 2026 21:52:36 +0100 Subject: [PATCH 15/49] feat(ui): collapsible system_group block in transcript view Batch consecutive system events into a single collapsible group instead of rendering each as a separate warn-toned block. Shows count in header, expands on click. --- .../transcript/RunTranscriptView.tsx | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index 32727d40..fee3a561 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -93,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; @@ -558,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; } @@ -1260,6 +1272,43 @@ function TranscriptStderrGroup({ ); } +function TranscriptSystemGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + return ( +
+
setOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > + + + {block.lines.length} system {block.lines.length === 1 ? "message" : "messages"} + + {open ? : } +
+ {open && ( +
+          {block.lines.map((line, i) => (
+            
+              {i > 0 ? "\n" : ""}
+              {line.text}
+            
+          ))}
+        
+ )} +
+ ); +} + function TranscriptStdoutRow({ block, density, @@ -1383,6 +1432,7 @@ export function RunTranscriptView({ {block.type === "tool_group" && } {block.type === "diff_group" && } {block.type === "stderr_group" && } + {block.type === "system_group" && } {block.type === "stdout" && ( )} From 00898e81940258718c0faeeae0ecfe68cae457f1 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 3 Apr 2026 15:59:42 -0500 Subject: [PATCH 16/49] Restore feedback trace export fixes --- docs/feedback-voting.md | 12 ++- server/src/__tests__/feedback-service.test.ts | 102 ++++++++++++++++++ .../__tests__/feedback-share-client.test.ts | 101 +++++++++++++++++ .../__tests__/issue-feedback-routes.test.ts | 65 +++++++++-- server/src/app.ts | 5 +- server/src/index.ts | 2 +- server/src/routes/issues.ts | 28 ++++- server/src/services/feedback-share-client.ts | 19 ++-- server/src/services/feedback.ts | 47 +++++++- 9 files changed, 357 insertions(+), 24 deletions(-) create mode 100644 server/src/__tests__/feedback-share-client.test.ts diff --git a/docs/feedback-voting.md b/docs/feedback-voting.md index 2f754bce..41b17201 100644 --- a/docs/feedback-voting.md +++ b/docs/feedback-voting.md @@ -19,7 +19,7 @@ Each vote creates two local records: All data lives in your local Paperclip database. Nothing leaves your machine unless you explicitly choose to share. -When a vote is marked for sharing, Paperclip also queues the trace bundle for background export through the Telemetry Backend. The app server never uploads raw feedback trace bundles directly to object storage. +When a vote is marked for sharing, Paperclip immediately tries to upload the trace bundle through the Telemetry Backend. The upload is compressed in transit so full trace bundles stay under gateway size limits. If that immediate push fails, the trace is left in a retriable failed state for later flush attempts. The app server never uploads raw feedback trace bundles directly to object storage. ## Viewing your votes @@ -148,6 +148,8 @@ Open any file in `traces/` to see: Open `full-traces/-/bundle.json` to see the expanded export metadata, including capture notes, adapter type, integrity metadata, and the inventory of raw files written alongside it. +Each entry in `bundle.json.files[]` includes the actual captured file payload under `contents`, not just a pathname. For text artifacts this is stored as UTF-8 text; binary artifacts use base64 plus an `encoding` marker. + Built-in local adapters now export their native session artifacts more directly: - `codex_local`: `adapter/codex/session.jsonl` @@ -168,19 +170,21 @@ Your preference is saved per-company. You can change it any time via the feedbac | Status | Meaning | |--------|---------| | `local_only` | Vote stored locally, not marked for sharing | -| `pending` | Marked for sharing, waiting to be sent | +| `pending` | Marked for sharing, saved locally, and waiting for the immediate upload attempt | | `sent` | Successfully transmitted | -| `failed` | Transmission attempted but failed (will retry) | +| `failed` | Transmission attempted but failed (for example the backend is unreachable or not configured); later flushes retry once a backend is available | Your local database always retains the full vote and trace data regardless of sharing status. ## Remote sync -Votes you choose to share are queued as `pending` traces and flushed by the server's background worker to the Telemetry Backend. The Telemetry Backend validates the request, then persists the bundle into its configured object storage. +Votes you choose to share are sent to the Telemetry Backend immediately from the vote request. The server also keeps a background flush worker so failed traces can retry later. The Telemetry Backend validates the request, then persists the bundle into its configured object storage. - App server responsibility: build the bundle, POST it to Telemetry Backend, update trace status - Telemetry Backend responsibility: authenticate the request, validate payload shape, compress/store the bundle, return the final object key - Retry behavior: failed uploads move to `failed` with an error message in `failureReason`, and the worker retries them on later ticks +- Default endpoint: when no feedback export backend URL is configured, Paperclip falls back to `https://telemetry.paperclip.ing` +- Important nuance: the uploaded object is a snapshot of the full bundle at vote time. If you fetch a local bundle later and the underlying adapter session file has continued to grow, the local regenerated bundle may be larger than the already-uploaded snapshot for that same trace. Exported objects use a deterministic key pattern so they are easy to inspect: diff --git a/server/src/__tests__/feedback-service.test.ts b/server/src/__tests__/feedback-service.test.ts index 03b4f4c6..8ce7f2fc 100644 --- a/server/src/__tests__/feedback-service.test.ts +++ b/server/src/__tests__/feedback-service.test.ts @@ -1069,6 +1069,73 @@ describe("feedbackService.saveIssueVote", () => { }); }); + it("can flush a single shared trace immediately by trace id", async () => { + const { companyId, issueId, commentId: firstCommentId } = await seedIssueWithAgentComment(); + const secondCommentId = randomUUID(); + const agentId = await db + .select({ authorAgentId: issueComments.authorAgentId }) + .from(issueComments) + .where(eq(issueComments.id, firstCommentId)) + .then((rows) => rows[0]?.authorAgentId ?? null); + + await db.insert(issueComments).values({ + id: secondCommentId, + companyId, + issueId, + authorAgentId: agentId, + body: "Second AI generated update", + }); + + const uploadTraceBundle = vi.fn().mockResolvedValue({ + objectKey: `feedback-traces/${companyId}/2026/04/01/test-trace.json`, + }); + const flushingSvc = feedbackService(db, { + shareClient: { + uploadTraceBundle, + }, + }); + + const first = await flushingSvc.saveIssueVote({ + issueId, + targetType: "issue_comment", + targetId: firstCommentId, + vote: "up", + authorUserId: "user-1", + allowSharing: true, + }); + await flushingSvc.saveIssueVote({ + issueId, + targetType: "issue_comment", + targetId: secondCommentId, + vote: "up", + authorUserId: "user-1", + allowSharing: true, + }); + + const flushResult = await flushingSvc.flushPendingFeedbackTraces({ + companyId, + traceId: first.traceId ?? undefined, + limit: 1, + }); + + expect(flushResult).toMatchObject({ + attempted: 1, + sent: 1, + failed: 0, + }); + expect(uploadTraceBundle).toHaveBeenCalledTimes(1); + + const traces = await flushingSvc.listFeedbackTraces({ + companyId, + issueId, + includePayload: true, + }); + const firstTrace = traces.find((trace) => trace.targetId === firstCommentId); + const secondTrace = traces.find((trace) => trace.targetId === secondCommentId); + expect(firstTrace?.status).toBe("sent"); + expect(secondTrace?.status).toBe("pending"); + }); + it("marks pending shared traces as failed when remote export upload fails", async () => { const { companyId, issueId, commentId } = await seedIssueWithAgentComment(); const uploadTraceBundle = vi.fn().mockRejectedValue(new Error("telemetry unavailable")); @@ -1106,4 +1173,39 @@ describe("feedbackService.saveIssueVote", () => { expect(traces[0]?.exportedAt).toBeNull(); expect(uploadTraceBundle).toHaveBeenCalledTimes(1); }); + + it("marks pending shared traces as failed when no feedback export backend is configured", async () => { + const { companyId, issueId, commentId } = await seedIssueWithAgentComment(); + + const result = await svc.saveIssueVote({ + issueId, + targetType: "issue_comment", + targetId: commentId, + vote: "up", + authorUserId: "user-1", + allowSharing: true, + }); + + const flushResult = await svc.flushPendingFeedbackTraces({ + companyId, + traceId: result.traceId ?? undefined, + limit: 1, + }); + + expect(flushResult).toMatchObject({ + attempted: 1, + sent: 0, + failed: 1, + }); + + const traces = await svc.listFeedbackTraces({ + companyId, + issueId, + includePayload: true, + }); + expect(traces[0]?.status).toBe("failed"); + expect(traces[0]?.attemptCount).toBe(1); + expect(traces[0]?.failureReason).toBe("Feedback export backend is not configured"); + expect(traces[0]?.exportedAt).toBeNull(); + }); }); diff --git a/server/src/__tests__/feedback-share-client.test.ts b/server/src/__tests__/feedback-share-client.test.ts new file mode 100644 index 00000000..fe710262 --- /dev/null +++ b/server/src/__tests__/feedback-share-client.test.ts @@ -0,0 +1,101 @@ +import { gunzipSync } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createFeedbackTraceShareClientFromConfig } from "../services/feedback-share-client.js"; + +describe("feedback trace share client", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ objectKey: "feedback-traces/test.json" }), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("defaults to telemetry.paperclip.ing when no backend url is configured", async () => { + const client = createFeedbackTraceShareClientFromConfig({ + feedbackExportBackendUrl: undefined, + feedbackExportBackendToken: undefined, + }); + + await client.uploadTraceBundle({ + traceId: "trace-1", + exportId: "export-1", + companyId: "company-1", + issueId: "issue-1", + issueIdentifier: "PAP-1", + adapterType: "codex_local", + captureStatus: "full", + notes: [], + envelope: {}, + surface: null, + paperclipRun: null, + rawAdapterTrace: null, + normalizedAdapterTrace: null, + privacy: null, + integrity: {}, + files: [], + }); + + expect(fetch).toHaveBeenCalledWith( + "https://telemetry.paperclip.ing/feedback-traces", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("wraps the feedback trace payload as gzip+base64 json before upload", async () => { + const client = createFeedbackTraceShareClientFromConfig({ + feedbackExportBackendUrl: "https://telemetry.paperclip.ing", + feedbackExportBackendToken: "test-token", + }); + + await client.uploadTraceBundle({ + traceId: "trace-1", + exportId: "export-1", + companyId: "company-1", + issueId: "issue-1", + issueIdentifier: "PAP-1", + adapterType: "codex_local", + captureStatus: "full", + notes: [], + envelope: { hello: "world" }, + surface: null, + paperclipRun: null, + rawAdapterTrace: null, + normalizedAdapterTrace: null, + privacy: null, + integrity: {}, + files: [], + }); + + const call = vi.mocked(fetch).mock.calls[0]; + expect(call?.[0]).toBe("https://telemetry.paperclip.ing/feedback-traces"); + expect(call?.[1]).toMatchObject({ + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-token", + }, + }); + + const body = JSON.parse(String(call?.[1]?.body ?? "{}")) as { + encoding?: string; + payload?: string; + }; + expect(body.encoding).toBe("gzip+base64+json"); + expect(typeof body.payload).toBe("string"); + + const decoded = gunzipSync(Buffer.from(body.payload ?? "", "base64")).toString("utf8"); + const parsed = JSON.parse(decoded) as { + objectKey: string; + bundle: { envelope: { hello: string } }; + }; + expect(parsed.objectKey).toContain("feedback-traces/company-1/"); + expect(parsed.objectKey.endsWith("/export-1.json")).toBe(true); + expect(parsed.bundle.envelope).toEqual({ hello: "world" }); + }); +}); diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 0bb7dc34..fe2ba1fa 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -12,6 +12,18 @@ const mockFeedbackService = vi.hoisted(() => ({ saveIssueVote: vi.fn(), })); +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + getByIdentifier: vi.fn(), + update: vi.fn(), + addComment: vi.fn(), + findMentionedAgents: vi.fn(), +})); + +const mockFeedbackExportService = vi.hoisted(() => ({ + flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })), +})); + vi.mock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(), @@ -42,12 +54,7 @@ vi.mock("../services/index.js", () => ({ listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), - issueService: () => ({ - getById: vi.fn(), - update: vi.fn(), - addComment: vi.fn(), - findMentionedAgents: vi.fn(), - }), + issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), routineService: () => ({ @@ -63,7 +70,7 @@ function createApp(actor: Record) { (req as any).actor = actor; next(); }); - app.use("/api", issueRoutes({} as any, {} as any)); + app.use("/api", issueRoutes({} as any, {} as any, { feedbackExportService: mockFeedbackExportService })); app.use(errorHandler); return app; } @@ -73,6 +80,50 @@ describe("issue feedback trace routes", () => { vi.clearAllMocks(); }); + it("flushes a newly shared feedback trace immediately after saving the vote", async () => { + const targetId = "11111111-1111-4111-8111-111111111111"; + mockIssueService.getById.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + identifier: "PAP-1", + }); + mockFeedbackService.saveIssueVote.mockResolvedValue({ + vote: { + targetType: "issue_comment", + targetId, + vote: "up", + reason: null, + }, + traceId: "trace-1", + consentEnabledNow: false, + persistedSharingPreference: null, + sharingEnabled: true, + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: true, + companyIds: ["company-1"], + }); + + const res = await request(app) + .post("/api/issues/issue-1/feedback-votes") + .send({ + targetType: "issue_comment", + targetId, + vote: "up", + allowSharing: true, + }); + + expect(res.status).toBe(201); + expect(mockFeedbackExportService.flushPendingFeedbackTraces).toHaveBeenCalledWith({ + companyId: "company-1", + traceId: "trace-1", + limit: 1, + }); + }); + it("rejects non-board callers before fetching a feedback trace", async () => { const app = createApp({ type: "agent", diff --git a/server/src/app.ts b/server/src/app.ts index b9faee2f..0aa099bf 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -67,6 +67,7 @@ export async function createApp( feedbackExportService?: { flushPendingFeedbackTraces(input?: { companyId?: string; + traceId?: string; limit?: number; now?: Date; }): Promise; @@ -152,7 +153,9 @@ export async function createApp( api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); - api.use(issueRoutes(db, opts.storageService)); + api.use(issueRoutes(db, opts.storageService, { + feedbackExportService: opts.feedbackExportService, + })); api.use(routineRoutes(db)); api.use(executionWorkspaceRoutes(db)); api.use(goalRoutes(db)); diff --git a/server/src/index.ts b/server/src/index.ts index 37318245..d5d9c805 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -525,7 +525,7 @@ export async function startServer(): Promise { const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; const storageService = createStorageServiceFromConfig(config); const feedback = feedbackService(db as any, { - shareClient: createFeedbackTraceShareClientFromConfig(config) ?? undefined, + shareClient: createFeedbackTraceShareClientFromConfig(config), }); const app = await createApp(db as any, { uiMode, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5eb0b83e..9551f04d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -52,7 +52,20 @@ const updateIssueRouteSchema = updateIssueSchema.extend({ interrupt: z.boolean().optional(), }); -export function issueRoutes(db: Db, storage: StorageService) { +export function issueRoutes( + db: Db, + storage: StorageService, + opts?: { + feedbackExportService?: { + flushPendingFeedbackTraces(input?: { + companyId?: string; + traceId?: string; + limit?: number; + now?: Date; + }): Promise; + }; + }, +) { const router = Router(); const svc = issueService(db); const access = accessService(db); @@ -67,6 +80,7 @@ export function issueRoutes(db: Db, storage: StorageService) { const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); const routinesSvc = routineService(db); + const feedbackExportService = opts?.feedbackExportService; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 }, @@ -1867,6 +1881,18 @@ export function issueRoutes(db: Db, storage: StorageService) { ); } + if (result.sharingEnabled && result.traceId && feedbackExportService) { + try { + await feedbackExportService.flushPendingFeedbackTraces({ + companyId: issue.companyId, + traceId: result.traceId, + limit: 1, + }); + } catch (err) { + logger.warn({ err, issueId: issue.id, traceId: result.traceId }, "failed to flush shared feedback trace immediately"); + } + } + res.status(201).json(result.vote); }); diff --git a/server/src/services/feedback-share-client.ts b/server/src/services/feedback-share-client.ts index e1761b14..7fdf97ca 100644 --- a/server/src/services/feedback-share-client.ts +++ b/server/src/services/feedback-share-client.ts @@ -1,6 +1,9 @@ +import { gzipSync } from "node:zlib"; import type { FeedbackTraceBundle } from "@paperclipai/shared"; import type { Config } from "../config.js"; +const DEFAULT_FEEDBACK_EXPORT_BACKEND_URL = "https://telemetry.paperclip.ing"; + function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Date) { const year = String(exportedAt.getUTCFullYear()); const month = String(exportedAt.getUTCMonth() + 1).padStart(2, "0"); @@ -14,10 +17,8 @@ export interface FeedbackTraceShareClient { export function createFeedbackTraceShareClientFromConfig( config: Pick, -): FeedbackTraceShareClient | null { - const baseUrl = config.feedbackExportBackendUrl?.trim(); - if (!baseUrl) return null; - +): FeedbackTraceShareClient { + const baseUrl = config.feedbackExportBackendUrl?.trim() || DEFAULT_FEEDBACK_EXPORT_BACKEND_URL; const token = config.feedbackExportBackendToken?.trim(); const endpoint = new URL("/feedback-traces", baseUrl).toString(); @@ -25,6 +26,11 @@ export function createFeedbackTraceShareClientFromConfig( async uploadTraceBundle(bundle) { const exportedAt = new Date(); const objectKey = buildFeedbackShareObjectKey(bundle, exportedAt); + const requestBody = JSON.stringify({ + objectKey, + exportedAt: exportedAt.toISOString(), + bundle, + }); const response = await fetch(endpoint, { method: "POST", headers: { @@ -32,9 +38,8 @@ export function createFeedbackTraceShareClientFromConfig( ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ - objectKey, - exportedAt: exportedAt.toISOString(), - bundle, + encoding: "gzip+base64+json", + payload: gzipSync(requestBody).toString("base64"), }), }); diff --git a/server/src/services/feedback.ts b/server/src/services/feedback.ts index 312cd5bf..26ce6107 100644 --- a/server/src/services/feedback.ts +++ b/server/src/services/feedback.ts @@ -63,6 +63,7 @@ const MAX_SKILLS = 20; const MAX_INSTRUCTION_FILES = 20; const MAX_TRACE_FILE_CHARS = 10_000_000; const DEFAULT_INSTANCE_SETTINGS_SINGLETON_KEY = "default"; +const FEEDBACK_EXPORT_BACKEND_NOT_CONFIGURED = "Feedback export backend is not configured"; type FeedbackTraceRow = typeof feedbackExports.$inferSelect & { issueIdentifier: string | null; @@ -1742,15 +1743,48 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) { flushPendingFeedbackTraces: async (input?: { companyId?: string; + traceId?: string; limit?: number; now?: Date; }) => { const shareClient = options.shareClient; if (!shareClient) { + const filters = [eq(feedbackExports.status, "pending")]; + if (input?.companyId) { + filters.push(eq(feedbackExports.companyId, input.companyId)); + } + if (input?.traceId) { + filters.push(eq(feedbackExports.id, input.traceId)); + } + + const rows = await db + .select({ + id: feedbackExports.id, + attemptCount: feedbackExports.attemptCount, + }) + .from(feedbackExports) + .where(and(...filters)) + .orderBy(asc(feedbackExports.createdAt), asc(feedbackExports.id)) + .limit(Math.max(1, Math.min(input?.limit ?? 25, 200))); + + const attemptAt = input?.now ?? new Date(); + for (const row of rows) { + await db + .update(feedbackExports) + .set({ + status: "failed", + attemptCount: row.attemptCount + 1, + lastAttemptedAt: attemptAt, + failureReason: FEEDBACK_EXPORT_BACKEND_NOT_CONFIGURED, + updatedAt: attemptAt, + }) + .where(eq(feedbackExports.id, row.id)); + } + return { - attempted: 0, + attempted: rows.length, sent: 0, - failed: 0, + failed: rows.length, }; } @@ -1761,6 +1795,9 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) { if (input?.companyId) { filters.push(eq(feedbackExports.companyId, input.companyId)); } + if (input?.traceId) { + filters.push(eq(feedbackExports.id, input.traceId)); + } const rows = await db .select({ @@ -1983,7 +2020,7 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) { }) .where(eq(feedbackVotes.id, savedVote.id)); - await tx + const [savedTrace] = await tx .insert(feedbackExports) .values({ companyId: issue.companyId, @@ -2030,6 +2067,9 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) { failureReason: null, updatedAt: now, }, + }) + .returning({ + id: feedbackExports.id, }); return { @@ -2037,6 +2077,7 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) { ...savedVote, redactionSummary: artifacts.redactionSummary, }, + traceId: savedTrace?.id ?? null, consentEnabledNow, persistedSharingPreference, sharingEnabled: sharedWithLabs, From 2a2fa31a0330ae67ce5f73487991a11b75387b47 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 3 Apr 2026 22:17:34 +0100 Subject: [PATCH 17/49] feat(adapters): allow external plugins to override built-in adapters Previously external adapters matching a built-in type were skipped with a warning. Now they override the built-in, so plugin developers can ship improved versions of existing adapters (e.g. hermes-paperclip-adapter) without removing the built-in fallback for users who haven't installed the plugin. --- server/src/__tests__/adapter-registry.test.ts | 32 +++++++++++++++++++ server/src/adapters/registry.ts | 8 ++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index d121a374..e4b109f6 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -55,4 +55,36 @@ describe("server adapter registry", () => { "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" }, + ]); + }); }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index aa8ddeb4..09cb41a8 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -234,11 +234,11 @@ const externalAdaptersReady: Promise = (async () => { try { const externalAdapters = await buildExternalAdapters(); for (const externalAdapter of externalAdapters) { - if (BUILTIN_ADAPTER_TYPES.has(externalAdapter.type)) { - console.warn( - `[paperclip] Skipping external adapter "${externalAdapter.type}" — conflicts with built-in adapter`, + const overriding = BUILTIN_ADAPTER_TYPES.has(externalAdapter.type); + if (overriding) { + console.log( + `[paperclip] External adapter \"${externalAdapter.type}\" overrides built-in adapter`, ); - continue; } adaptersByType.set( externalAdapter.type, From fb3aabb743c94579d0f8fbbdb88f3ff2a1a1e5d6 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 3 Apr 2026 22:25:27 +0100 Subject: [PATCH 18/49] feat(adapters): add overriddenBuiltin flag to API and Adapter Manager UI When an external plugin overrides a built-in adapter type, the GET /api/adapters response now includes overriddenBuiltin: true. The Adapter Manager shows an 'Overrides built-in' badge on such adapters. --- server/src/routes/adapters.ts | 3 +++ ui/src/api/adapters.ts | 2 ++ ui/src/pages/AdapterManager.tsx | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index fdeb64d2..09761412 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -63,6 +63,8 @@ interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + /** True when an external plugin has replaced a built-in adapter of the same type. */ + overriddenBuiltin?: boolean; version?: string; packageName?: string; isLocalPath?: boolean; @@ -105,6 +107,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP 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, // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. version: fromDisk ?? externalRecord?.version, packageName: externalRecord?.packageName, diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index dea60394..201c5bee 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -17,6 +17,8 @@ export interface AdapterInfo { 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; } export interface AdapterInstallResult { diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index e41b9ca3..f7f4577d 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -63,6 +63,11 @@ function AdapterRow({ {adapter.label || getAdapterLabel(adapter.type)} {adapter.source === "external" ? "External" : "Built-in"} + {adapter.overriddenBuiltin && ( + + Overrides built-in + + )} {adapter.source === "external" && ( adapter.isLocalPath ? From ef2cbb838f1a297577976a20d4cc3e243d2921ae Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 3 Apr 2026 22:37:33 +0100 Subject: [PATCH 19/49] chore: add trailing newline to server/package.json --- server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/package.json b/server/package.json index 4b95606a..b2d17ad3 100644 --- a/server/package.json +++ b/server/package.json @@ -93,4 +93,4 @@ "vite": "^6.1.0", "vitest": "^3.0.5" } -} \ No newline at end of file +} From 3c4b8711ec9eb88fbae1dfec6bbdb630df35c2ad Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 3 Apr 2026 22:38:27 +0100 Subject: [PATCH 20/49] fix(ui): remove title prop from Lucide icons (not supported in this version) --- ui/src/pages/AdapterManager.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index f7f4577d..ba482645 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -70,8 +70,8 @@ function AdapterRow({ )} {adapter.source === "external" && ( adapter.isLocalPath - ? - : + ? + : )} Date: Fri, 3 Apr 2026 22:51:49 +0100 Subject: [PATCH 21/49] =?UTF-8?q?fix(ui):=20reorder=20adapter=20badges=20?= =?UTF-8?q?=E2=80=94=20always-present=20first,=20conditional=20last?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/pages/AdapterManager.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index ba482645..0742df05 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -63,16 +63,6 @@ function AdapterRow({ {adapter.label || getAdapterLabel(adapter.type)} {adapter.source === "external" ? "External" : "Built-in"} - {adapter.overriddenBuiltin && ( - - Overrides built-in - - )} - {adapter.source === "external" && ( - adapter.isLocalPath - ? - : - )} )} + {adapter.source === "external" && ( + adapter.isLocalPath + ? + : + )} + {adapter.overriddenBuiltin && ( + + Overrides built-in + + )} {adapter.disabled && ( Hidden from menus From 01c05b5f1be5a8738c2b3ad37a74209915fe21be Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 3 Apr 2026 22:58:04 +0100 Subject: [PATCH 22/49] =?UTF-8?q?fix(ui):=20remove=20loaded=20badge,=20ord?= =?UTF-8?q?er=20badges=20=E2=80=94=20source,=20icon,=20version,=20override?= =?UTF-8?q?,=20disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/pages/AdapterManager.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index 0742df05..6d6e5f3f 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -63,22 +63,16 @@ function AdapterRow({ {adapter.label || getAdapterLabel(adapter.type)} {adapter.source === "external" ? "External" : "Built-in"} - - {adapter.loaded ? "loaded" : "error"} - - {adapter.version && ( - - v{adapter.version} - - )} {adapter.source === "external" && ( adapter.isLocalPath ? : )} + {adapter.version && ( + + v{adapter.version} + + )} {adapter.overriddenBuiltin && ( Overrides built-in From 0651f48f6cb54a88fc44fd96855acfd4da3d7247 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 3 Apr 2026 23:05:00 +0100 Subject: [PATCH 23/49] =?UTF-8?q?fix(ui):=20move=20reinstall=20button=20to?= =?UTF-8?q?=20end=20=E2=80=94=20power,=20reload,=20remove,=20reinstall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/pages/AdapterManager.tsx | 44 ++++++++++++++++----------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index 6d6e5f3f..da85689d 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -93,28 +93,6 @@ function AdapterRow({

- - {onReload && ( - - )} {onReinstall && ( )} + {onReload && ( + + )} + {canRemove && (
@@ -121,7 +139,9 @@ function AdapterRow({ variant="outline" size="icon-sm" className="h-8 w-8" - title={adapter.disabled ? "Show in agent menus" : "Hide from agent menus"} + title={adapter.disabled + ? (toggleTitleEnabled ?? "Show in agent menus") + : (toggleTitleDisabled ?? "Hide from agent menus")} disabled={isToggling} onClick={() => onToggle(adapter.type, !adapter.disabled)} > @@ -238,6 +258,11 @@ export function AdapterManager() { const queryClient = useQueryClient(); const { pushToast } = useToast(); + // Subscribe to client-side override store so the component re-renders + // immediately when setOverrideDisabled() is called, even though the + // server query data hasn't changed. + useSyncExternalStore(subscribeToOverrides, getOverridesSnapshot); + const [installPackage, setInstallPackage] = useState(""); const [installVersion, setInstallVersion] = useState(""); const [isLocalPath, setIsLocalPath] = useState(false); @@ -284,7 +309,9 @@ export function AdapterManager() { const removeMutation = useMutation({ mutationFn: (type: string) => adaptersApi.remove(type), - onSuccess: () => { + onSuccess: (_result, type) => { + // Clean up client-side override state when the external is removed. + setOverrideDisabled(type, false); invalidate(); pushToast({ title: "Adapter removed", tone: "success" }); }, @@ -341,6 +368,27 @@ export function AdapterManager() { 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. + // The virtual entry's disabled state reflects the TYPE's menu visibility + // (server-side disabled flag), NOT the external adapter's override state. + 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(" "), + // The override-paused state is client-side and independent of + // the type's server-side menu visibility. + overridePaused: isOverrideDisabled(a.type), + menuDisabled: a.disabled ?? false, + })); + if (isLoading) return
Loading adapters...
; const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending; @@ -496,20 +544,44 @@ export function AdapterManager() { ) : (
    - {externalAdapters.map((adapter) => ( - toggleMutation.mutate({ type, disabled })} - onRemove={(type) => setRemoveType(type)} - onReload={(type) => reloadMutation.mutate(type)} - onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined} - isToggling={toggleMutation.isPending} - isReloading={reloadMutation.isPending} - isReinstalling={reinstallMutation.isPending} - /> - ))} + {externalAdapters.map((adapter) => { + const isBuiltinOverride = adapter.overriddenBuiltin; + const overridePaused = isBuiltinOverride && isOverrideDisabled(adapter.type); + + // For overridden builtins, the power button controls the + // client-side override state (not server menu visibility). + const effectiveAdapter: AdapterInfo = isBuiltinOverride + ? { ...adapter, disabled: !!overridePaused } + : adapter; + + return ( + { + setOverrideDisabled(type, disabled); + // useSyncExternalStore handles the re-render; + // also invalidate so other components (e.g. menus) + // eventually pick up the registry change. + invalidate(); + } + : (type, disabled) => toggleMutation.mutate({ type, disabled }) + } + onRemove={(type) => setRemoveType(type)} + onReload={(type) => reloadMutation.mutate(type)} + onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined} + isToggling={isBuiltinOverride ? false : 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} + /> + ); + })}
)} @@ -521,7 +593,7 @@ export function AdapterManager() {

Built-in Adapters

- {builtinAdapters.length === 0 ? ( + {builtinAdapters.length === 0 && overriddenBuiltins.length === 0 ? (
No built-in adapters found.
) : (
    @@ -535,6 +607,24 @@ export function AdapterManager() { isToggling={isMutating} /> ))} + {overriddenBuiltins.map((virtual) => ( + toggleMutation.mutate({ type, disabled })} + onRemove={() => {}} + isToggling={isMutating} + overriddenBy={virtual.overridePaused ? undefined : virtual.overriddenBy} + /> + ))}
)} From b81d765d2ef48343f55ba8f958ec955fdd71c178 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Sat, 4 Apr 2026 13:17:21 +0100 Subject: [PATCH 25/49] feat: server-side override pause/resume for builtin adapter types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the client-side-only override store with a real server-side toggle. When a developer pauses the external override, the server swaps ALL adapter behavior back to the builtin — execute handler, model listing, config schema, detection — not just the UI parser. Server changes: - registry.ts: builtinFallbacks map + pausedOverrides set + setOverridePaused() - routes/adapters.ts: PATCH /api/adapters/:type/override endpoint + overridePaused in list UI changes: - adapters.ts: setOverridePaused API method + overridePaused on AdapterInfo - AdapterManager: overrideMutation calls server, instant feedback via invalidate() - use-disabled-adapters.ts: reads adapter.overridePaused from server response Removed: - disabled-overrides-store.ts: no longer needed (server is the source of truth) Note: already-running agent sessions keep the adapter they started with. Only new sessions use the swapped adapter. --- server/src/adapters/registry.ts | 63 ++++++++++++++- server/src/routes/adapters.ts | 36 +++++++++ ui/src/adapters/disabled-overrides-store.ts | 90 --------------------- ui/src/adapters/use-disabled-adapters.ts | 9 +-- ui/src/api/adapters.ts | 6 ++ ui/src/pages/AdapterManager.tsx | 49 +++++------ 6 files changed, 127 insertions(+), 126 deletions(-) delete mode 100644 ui/src/adapters/disabled-overrides-store.ts diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 09cb41a8..78854dc9 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -193,6 +193,15 @@ const hermesLocalAdapter: ServerAdapterModule = { const adaptersByType = new Map(); +// For builtin types that are overridden by an external adapter, we keep the +// original builtin so it can be restored when the override is deactivated. +const builtinFallbacks = new Map(); + +// Tracks which override types are currently deactivated (paused). When +// paused, `getServerAdapter()` returns the builtin fallback instead of the +// external. Persisted across reloads via the same disabled-adapters store. +const pausedOverrides = new Set(); + function registerBuiltInAdapters() { for (const adapter of [ claudeLocalAdapter, @@ -237,8 +246,13 @@ const externalAdaptersReady: Promise = (async () => { const overriding = BUILTIN_ADAPTER_TYPES.has(externalAdapter.type); if (overriding) { console.log( - `[paperclip] External adapter \"${externalAdapter.type}\" overrides built-in adapter`, + `[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, @@ -281,6 +295,10 @@ export function requireServerAdapter(type: string): ServerAdapterModule { } export function getServerAdapter(type: string): ServerAdapterModule { + if (pausedOverrides.has(type)) { + const fallback = builtinFallbacks.get(type); + if (fallback) return fallback; + } return adaptersByType.get(type) ?? processAdapter; } @@ -326,6 +344,49 @@ export async function detectAdapterModel( }; } +// --------------------------------------------------------------------------- +// Override pause / resume +// --------------------------------------------------------------------------- + +/** + * Pause or resume an external override for a builtin adapter type. + * + * - `paused = true` → subsequent calls to `getServerAdapter(type)` return + * the builtin fallback instead of the external adapter. Already-running + * agent sessions are unaffected (they hold a reference to the module they + * started with). + * + * - `paused = false` → the external adapter is active again. + * + * Returns `true` if the state actually changed, `false` if the type is not + * an override or was already in the requested state. + */ +export function setOverridePaused(type: string, paused: boolean): boolean { + if (!builtinFallbacks.has(type)) return false; + const wasPaused = pausedOverrides.has(type); + if (paused && !wasPaused) { + pausedOverrides.add(type); + console.log(`[paperclip] Override paused for "${type}" — builtin adapter restored`); + return true; + } + if (!paused && wasPaused) { + pausedOverrides.delete(type); + console.log(`[paperclip] Override resumed for "${type}" — external adapter active`); + return true; + } + return false; +} + +/** Check whether the external override for a builtin type is currently paused. */ +export function isOverridePaused(type: string): boolean { + return pausedOverrides.has(type); +} + +/** Get the set of types whose overrides are currently paused. */ +export function getPausedOverrides(): Set { + return pausedOverrides; +} + export function findServerAdapter(type: string): ServerAdapterModule | null { return adaptersByType.get(type) ?? null; } diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index ab84a4bc..3218cb8c 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -23,6 +23,8 @@ import { listEnabledServerAdapters, registerServerAdapter, unregisterServerAdapter, + isOverridePaused, + setOverridePaused, } from "../adapters/registry.js"; import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { @@ -65,6 +67,8 @@ interface AdapterInfo { 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; @@ -108,6 +112,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP 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, @@ -352,6 +357,37 @@ export function adapterRoutes() { 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 * diff --git a/ui/src/adapters/disabled-overrides-store.ts b/ui/src/adapters/disabled-overrides-store.ts deleted file mode 100644 index aaf3f7a7..00000000 --- a/ui/src/adapters/disabled-overrides-store.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Client-side store for disabled external adapter overrides. - * - * When an external adapter overrides a builtin type, the user may want to - * pause the override (use the builtin parser) without hiding the type from - * menus entirely. This is separate from the server's per-type `disabled` - * flag which controls menu visibility. - * - * Persisted to localStorage so it survives page reloads. - * - * Implements the React external store pattern (subscribe/getSnapshot) - * so that components using useSyncExternalStore re-render on changes. - */ - -const STORAGE_KEY = "paperclip:disabled-overrides"; - -let disabledOverrides = new Set(); - -// ── React external store plumbing ──────────────────────────────────── - -/** Monotonically increasing version — changes on every mutation. */ -let snapshotVersion = 0; - -const listeners = new Set<() => void>(); - -/** Subscribe to store changes (for useSyncExternalStore). */ -export function subscribeToOverrides(callback: () => void): () => void { - listeners.add(callback); - return () => listeners.delete(callback); -} - -/** - * Return a value that changes whenever the store changes. - * React compares this with Object.is to decide whether to re-render. - */ -export function getOverridesSnapshot(): number { - return snapshotVersion; -} - -function emitChange(): void { - snapshotVersion++; - for (const fn of listeners) fn(); -} - -// ── Public API ─────────────────────────────────────────────────────── - -/** Check if the external override for a builtin type is paused. */ -export function isOverrideDisabled(type: string): boolean { - return disabledOverrides.has(type); -} - -/** Pause or resume an external override. */ -export function setOverrideDisabled(type: string, disabled: boolean): void { - if (disabled) { - disabledOverrides.add(type); - } else { - disabledOverrides.delete(type); - } - persist(); - emitChange(); -} - -/** Get all types with paused overrides (sync read). */ -export function getDisabledOverrides(): Set { - return disabledOverrides; -} - -// ── Persistence ────────────────────────────────────────────────────── - -function persist(): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify([...disabledOverrides])); - } catch { - // localStorage unavailable — no-op - } -} - -function hydrate(): void { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - disabledOverrides = new Set(JSON.parse(raw)); - } - } catch { - // corrupt or unavailable — start empty - } -} - -// Hydrate on module load -hydrate(); diff --git a/ui/src/adapters/use-disabled-adapters.ts b/ui/src/adapters/use-disabled-adapters.ts index f2634a6a..ebc63946 100644 --- a/ui/src/adapters/use-disabled-adapters.ts +++ b/ui/src/adapters/use-disabled-adapters.ts @@ -1,8 +1,7 @@ -import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { adaptersApi } from "@/api/adapters"; import { setDisabledAdapterTypes } from "@/adapters/disabled-store"; -import { isOverrideDisabled, subscribeToOverrides, getOverridesSnapshot } from "@/adapters/disabled-overrides-store"; import { syncExternalAdapters } from "@/adapters/registry"; import { queryKeys } from "@/lib/queryKeys"; @@ -24,10 +23,6 @@ export function useDisabledAdaptersSync(): Set { staleTime: 5 * 60 * 1000, }); - // Subscribe to the client-side override store so that - // syncExternalAdapters() re-runs when overrides are toggled. - useSyncExternalStore(subscribeToOverrides, getOverridesSnapshot); - // 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. @@ -39,7 +34,7 @@ export function useDisabledAdaptersSync(): Set { type: a.type, label: a.label, disabled: a.disabled, - overrideDisabled: a.overriddenBuiltin ? isOverrideDisabled(a.type) : undefined, + overrideDisabled: a.overridePaused, })), ); } diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 201c5bee..86705bd4 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -19,6 +19,8 @@ export interface AdapterInfo { 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 { @@ -43,6 +45,10 @@ export const adaptersApi = { 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`, {}), diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index d583e28a..15474e06 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -4,7 +4,7 @@ * 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, useSyncExternalStore } from "react"; +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"; @@ -32,7 +32,6 @@ import { cn } from "@/lib/utils"; import { ChoosePathButton } from "@/components/PathInstructionsModal"; import { invalidateDynamicParser } from "@/adapters/dynamic-loader"; import { invalidateConfigSchemaCache } from "@/adapters/schema-config-fields"; -import { isOverrideDisabled, setOverrideDisabled, subscribeToOverrides, getOverridesSnapshot } from "@/adapters/disabled-overrides-store"; function AdapterRow({ adapter, @@ -258,11 +257,6 @@ export function AdapterManager() { const queryClient = useQueryClient(); const { pushToast } = useToast(); - // Subscribe to client-side override store so the component re-renders - // immediately when setOverrideDisabled() is called, even though the - // server query data hasn't changed. - useSyncExternalStore(subscribeToOverrides, getOverridesSnapshot); - const [installPackage, setInstallPackage] = useState(""); const [installVersion, setInstallVersion] = useState(""); const [isLocalPath, setIsLocalPath] = useState(false); @@ -309,9 +303,7 @@ export function AdapterManager() { const removeMutation = useMutation({ mutationFn: (type: string) => adaptersApi.remove(type), - onSuccess: (_result, type) => { - // Clean up client-side override state when the external is removed. - setOverrideDisabled(type, false); + onSuccess: () => { invalidate(); pushToast({ title: "Adapter removed", tone: "success" }); }, @@ -331,6 +323,17 @@ export function AdapterManager() { }, }); + 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) => { @@ -371,8 +374,6 @@ export function AdapterManager() { // 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. - // The virtual entry's disabled state reflects the TYPE's menu visibility - // (server-side disabled flag), NOT the external adapter's override state. const overriddenBuiltins = (adapters ?? []) .filter((a) => a.source === "external" && a.overriddenBuiltin) .filter((a) => !builtinAdapters.some((b) => b.type === a.type)) @@ -383,15 +384,13 @@ export function AdapterManager() { a.packageName, a.version ? `v${a.version}` : undefined, ].filter(Boolean).join(" "), - // The override-paused state is client-side and independent of - // the type's server-side menu visibility. - overridePaused: isOverrideDisabled(a.type), - menuDisabled: a.disabled ?? false, + overridePaused: !!a.overridePaused, + menuDisabled: !!a.disabled, })); if (isLoading) return
Loading adapters...
; - const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending; + const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || overrideMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending; return (
@@ -546,12 +545,12 @@ export function AdapterManager() {