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() {
    {externalAdapters.map((adapter) => { const isBuiltinOverride = adapter.overriddenBuiltin; - const overridePaused = isBuiltinOverride && isOverrideDisabled(adapter.type); + const overridePaused = isBuiltinOverride && !!adapter.overridePaused; // For overridden builtins, the power button controls the - // client-side override state (not server menu visibility). + // override pause state (not server menu visibility). const effectiveAdapter: AdapterInfo = isBuiltinOverride - ? { ...adapter, disabled: !!overridePaused } + ? { ...adapter, disabled: overridePaused } : adapter; return ( @@ -561,19 +560,13 @@ export function AdapterManager() { canRemove={true} onToggle={ isBuiltinOverride - ? (type, disabled) => { - 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) => overrideMutation.mutate({ type, paused: disabled }) : (type, disabled) => toggleMutation.mutate({ type, disabled }) } onRemove={(type) => setRemoveType(type)} onReload={(type) => reloadMutation.mutate(type)} onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined} - isToggling={isBuiltinOverride ? false : toggleMutation.isPending} + isToggling={isBuiltinOverride ? overrideMutation.isPending : toggleMutation.isPending} isReloading={reloadMutation.isPending} isReinstalling={reinstallMutation.isPending} toggleTitleDisabled={isBuiltinOverride ? "Pause external override" : undefined}