/** * Paperclip runtime settings card. * * Two transport modes: * - "api": user supplies `apiUrl` + `apiKey` directly. * - "cli": auto-derives `apiUrl` (and, for local-trusted, `apiKey`) from the * local `paperclipai` instance config (default * `~/.paperclip/instances/default/config.json`). * * After a connection is established the card auto-loads the company list (or * the single company the agent key is scoped to) and the agent list inside * the chosen company so the user picks a real agent rather than typing IDs. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { fetchPaperclipAgents, fetchPaperclipCliAgents, fetchPaperclipCliCompanies, fetchPaperclipCliDiscovery, fetchPaperclipCliStatus, fetchPaperclipCompanies, fetchPaperclipStatus, fetchPluginSettings, mintPaperclipApiKey, updatePluginSettings, type PaperclipAgentSummary, type PaperclipCliDiscoveryResult, type PaperclipCompanySummary, type PaperclipProviderStatus, } from "../api"; import { ProviderIcon } from "./ProviderIcon"; import { RuntimeCardShell } from "./RuntimeCardShell"; const PLUGIN_ID = "fusion-plugin-paperclip-runtime"; const PAPERCLIP_LEARN_MORE = "https://paperclip.ing/"; const PAPERCLIP_GITHUB = "https://github.com/paperclipai/paperclip"; type PaperclipMode = "issue-per-prompt" | "rolling-issue" | "wakeup-only"; type Transport = "api" | "cli"; const MODE_OPTIONS: { value: PaperclipMode; label: string; help: string }[] = [ { value: "rolling-issue", label: "Rolling issue (default)", help: "One Paperclip issue per Fusion session; subsequent prompts are added as comments. Closest to a chat experience.", }, { value: "issue-per-prompt", label: "Issue per prompt", help: "Each prompt creates a new top-level Paperclip issue. Maximally explicit; tends to clutter the board.", }, { value: "wakeup-only", label: "Wakeup only (advanced)", help: "No issue side-effects; the prompt is delivered via the wakeup payload only. Requires the agent's prompt template to know how to handle a payload-driven wake.", }, ]; interface PaperclipSettings { transport: Transport; apiUrl: string; apiKey: string; cliBinaryPath: string; cliConfigPath: string; agentId: string; companyId: string; mode: PaperclipMode; parentIssueId: string; projectId: string; goalId: string; runTimeoutMs: number; } const DEFAULT_SETTINGS: PaperclipSettings = { transport: "api", apiUrl: "http://localhost:3100", apiKey: "", cliBinaryPath: "paperclipai", cliConfigPath: "", agentId: "", companyId: "", mode: "rolling-issue", parentIssueId: "", projectId: "", goalId: "", runTimeoutMs: 600_000, }; const VALID_MODES = new Set([ "issue-per-prompt", "rolling-issue", "wakeup-only", ]); function settingsFromRecord(raw: Record): PaperclipSettings { const str = (k: string, fallback: string): string => typeof raw[k] === "string" ? (raw[k] as string) : fallback; const num = (k: string, fallback: number): number => typeof raw[k] === "number" ? (raw[k] as number) : fallback; const transport: Transport = raw.transport === "cli" ? "cli" : "api"; const mode: PaperclipMode = VALID_MODES.has(raw.mode as PaperclipMode) ? (raw.mode as PaperclipMode) : DEFAULT_SETTINGS.mode; return { transport, apiUrl: str("apiUrl", DEFAULT_SETTINGS.apiUrl), apiKey: str("apiKey", DEFAULT_SETTINGS.apiKey), cliBinaryPath: str("cliBinaryPath", DEFAULT_SETTINGS.cliBinaryPath), cliConfigPath: str("cliConfigPath", DEFAULT_SETTINGS.cliConfigPath), agentId: str("agentId", DEFAULT_SETTINGS.agentId), companyId: str("companyId", DEFAULT_SETTINGS.companyId), mode, parentIssueId: str("parentIssueId", DEFAULT_SETTINGS.parentIssueId), projectId: str("projectId", DEFAULT_SETTINGS.projectId), goalId: str("goalId", DEFAULT_SETTINGS.goalId), runTimeoutMs: num("runTimeoutMs", DEFAULT_SETTINGS.runTimeoutMs), }; } export function PaperclipRuntimeCard() { const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [status, setStatus] = useState(null); const [cliDiscovery, setCliDiscovery] = useState(null); const [companies, setCompanies] = useState([]); const [agents, setAgents] = useState([]); const [busy, setBusy] = useState< "loading" | "saving" | "testing" | "save-test" | null >(null); const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null); const [apiKeyDirty, setApiKeyDirty] = useState(false); const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // The "effective" apiUrl/apiKey used for status + dropdowns. // In CLI mode we prefer whatever cliDiscovery returned, falling back to the // typed apiUrl if discovery hasn't completed yet. const effectiveAuth = useMemo<{ apiUrl: string; apiKey?: string }>(() => { if (settings.transport === "cli" && cliDiscovery && cliDiscovery.ok) { return { apiUrl: cliDiscovery.apiUrl, apiKey: settings.apiKey || cliDiscovery.apiKey || undefined, }; } return { apiUrl: settings.apiUrl, apiKey: settings.apiKey || undefined, }; }, [settings.transport, settings.apiUrl, settings.apiKey, cliDiscovery]); // Load saved settings on mount. useEffect(() => { setBusy("loading"); fetchPluginSettings(PLUGIN_ID) .then((raw) => { if (mountedRef.current) setSettings(settingsFromRecord(raw)); }) .catch(() => { // Defaults remain. }) .finally(() => { if (mountedRef.current) setBusy(null); }); }, []); // CLI discovery whenever transport=cli (and cliConfigPath changes). useEffect(() => { if (settings.transport !== "cli") { setCliDiscovery(null); return; } let cancelled = false; fetchPaperclipCliDiscovery({ cliConfigPath: settings.cliConfigPath || undefined, }) .then((r) => { if (!cancelled && mountedRef.current) setCliDiscovery(r); }) .catch(() => { if (!cancelled && mountedRef.current) setCliDiscovery({ ok: false, reason: "Discovery request failed" }); }); return () => { cancelled = true; }; }, [settings.transport, settings.cliConfigPath]); // Probe + load companies/agents whenever effective auth changes. // In CLI mode we shell out via the cli-* endpoints instead of hitting the // Paperclip HTTP API directly, so the test exercises the same auth path the // user has onboarded with `paperclipai`. const useCli = settings.transport === "cli"; const cliOpts = useMemo( () => ({ cliBinaryPath: settings.cliBinaryPath || undefined, cliConfigPath: settings.cliConfigPath || undefined, }), [settings.cliBinaryPath, settings.cliConfigPath], ); const probe = useCallback(async (): Promise => { if (!useCli && !effectiveAuth.apiUrl) return null; try { const next = useCli ? await fetchPaperclipCliStatus(cliOpts) : await fetchPaperclipStatus(effectiveAuth); if (mountedRef.current) setStatus(next); // Then load companies + agents for the picker. const cs = useCli ? await fetchPaperclipCliCompanies(cliOpts) : await fetchPaperclipCompanies(effectiveAuth); if (!mountedRef.current) return next; setCompanies(cs); // Auto-pick a company: keep current selection if it exists in cs; // otherwise default to identity's company; otherwise the first one. let picked = settings.companyId; if (!cs.some((c) => c.id === picked)) { picked = next.connection.identity?.companyId ?? (cs[0]?.id ?? ""); if (picked && picked !== settings.companyId) { setSettings((s) => ({ ...s, companyId: picked })); } } if (picked) { const ag = useCli ? await fetchPaperclipCliAgents({ ...cliOpts, companyId: picked }) : await fetchPaperclipAgents({ ...effectiveAuth, companyId: picked }); if (mountedRef.current) { setAgents(ag); // Auto-pick agent the same way. if (!ag.some((a) => a.id === settings.agentId)) { const nextAgent = next.connection.identity?.agentId ?? (ag[0]?.id ?? ""); if (nextAgent && nextAgent !== settings.agentId) { setSettings((s) => ({ ...s, agentId: nextAgent })); } } } } else { if (mountedRef.current) setAgents([]); } return next; } catch (err) { if (mountedRef.current) setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) }); return null; } // Deliberately keyed only on transport+auth — companyId/agentId changes // are handled by a separate effect below. }, [ useCli, cliOpts, effectiveAuth.apiUrl, effectiveAuth.apiKey, settings.companyId, settings.agentId, ]); useEffect(() => { void probe(); }, [probe]); // Reload agent list when the user manually changes companyId. useEffect(() => { if (!settings.companyId) { setAgents([]); return; } if (!useCli && !effectiveAuth.apiUrl) { setAgents([]); return; } let cancelled = false; const p = useCli ? fetchPaperclipCliAgents({ ...cliOpts, companyId: settings.companyId }) : fetchPaperclipAgents({ ...effectiveAuth, companyId: settings.companyId }); p.then((ag) => { if (!cancelled && mountedRef.current) setAgents(ag); }).catch(() => { if (!cancelled && mountedRef.current) setAgents([]); }); return () => { cancelled = true; }; }, [ useCli, cliOpts, effectiveAuth.apiUrl, effectiveAuth.apiKey, settings.companyId, ]); const buildPayload = useCallback((): Record => { const payload: Record = { transport: settings.transport, mode: settings.mode, parentIssueId: settings.parentIssueId, projectId: settings.projectId, goalId: settings.goalId, runTimeoutMs: settings.runTimeoutMs, agentId: settings.agentId, companyId: settings.companyId, }; if (settings.transport === "api") { payload.apiUrl = settings.apiUrl; } else { payload.cliBinaryPath = settings.cliBinaryPath; if (settings.cliConfigPath) payload.cliConfigPath = settings.cliConfigPath; } if (apiKeyDirty) payload.apiKey = settings.apiKey; return payload; }, [settings, apiKeyDirty]); const handleTest = useCallback(async () => { setBusy("testing"); setToast(null); const next = await probe(); if (!mountedRef.current) return; setBusy(null); if (!next) { if (settings.transport === "cli" && cliDiscovery && !cliDiscovery.ok) { setToast({ kind: "err", message: `✗ CLI discovery failed: ${cliDiscovery.reason}`, }); } else { setToast({ kind: "err", message: "Test failed — see status above." }); } } else if (next.connection.available) { const id = next.connection.identity; setToast({ kind: "ok", message: id ? `✓ Connected as ${id.agentName}${id.companyName ? ` at ${id.companyName}` : ""}.` : "✓ Connected.", }); } else { setToast({ kind: "err", message: `✗ ${next.connection.reason ?? "Paperclip server unreachable"}`, }); } }, [probe, settings.transport, cliDiscovery]); const handleSave = useCallback(async () => { setBusy("saving"); setToast(null); try { await updatePluginSettings(PLUGIN_ID, buildPayload()); if (mountedRef.current) { setToast({ kind: "ok", message: "Settings saved." }); setApiKeyDirty(false); } } catch (err) { if (mountedRef.current) setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) }); } finally { if (mountedRef.current) setBusy(null); } }, [buildPayload]); const handleSaveAndTest = useCallback(async () => { setBusy("save-test"); setToast(null); try { await updatePluginSettings(PLUGIN_ID, buildPayload()); if (mountedRef.current) setApiKeyDirty(false); const next = await probe(); if (!mountedRef.current) return; if (!next) { if (settings.transport === "cli" && cliDiscovery && !cliDiscovery.ok) { setToast({ kind: "err", message: `Saved · ✗ CLI discovery failed: ${cliDiscovery.reason}`, }); } else { setToast({ kind: "err", message: "Saved, but probe failed." }); } } else if (next.connection.available) { const id = next.connection.identity; setToast({ kind: "ok", message: id ? `Saved · ✓ Connected as ${id.agentName}${id.companyName ? ` at ${id.companyName}` : ""}.` : "Saved · ✓ Connected.", }); } else { setToast({ kind: "err", message: `Saved · ✗ ${next.connection.reason ?? "Paperclip server unreachable"}`, }); } } catch (err) { if (mountedRef.current) setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) }); } finally { if (mountedRef.current) setBusy(null); } }, [buildPayload, probe, settings.transport, cliDiscovery]); const connected = status?.connection.available ?? null; const identity = status?.connection.identity; const cliOk = cliDiscovery?.ok === true; const statusKind = status === null ? "loading" : connected ? "ok" : "err"; const statusText = status === null ? settings.transport === "cli" && cliDiscovery && !cliOk ? `✗ CLI discovery failed: ${cliDiscovery.reason}` : "Probing Paperclip server…" : connected ? identity ? `✓ Connected as ${identity.agentName}${identity.role ? ` (${identity.role})` : ""}${identity.companyName ? ` at ${identity.companyName}` : ""}` : "✓ Connected" : `✗ ${status.connection.reason ?? "Unreachable"}`; const tabs = (
); return ( } name="Paperclip" learnMoreHref={PAPERCLIP_LEARN_MORE} statusKind={statusKind} statusText={statusText} description={ <> Drive a Paperclip agent ("employee") in a Paperclip company. Each prompt dispatches a task-shaped request; governance, budgets, and approvals are enforced by Paperclip. Expect seconds-to-minutes latency per turn. } tabs={tabs} busy={busy} toast={toast} onTest={() => void handleTest()} onSave={() => void handleSave()} onSaveAndTest={() => void handleSaveAndTest()} belowForm={ connected === false ? (

Make sure a Paperclip server is running. To install Paperclip:

              npm install -g paperclipai
            

Paperclip docs {" "} ·{" "} GitHub

) : null } > {/* CLI discovery banner */} {settings.transport === "cli" && cliDiscovery && (
{cliOk ? ( CLI config: {(cliDiscovery as { configPath: string }).configPath}{" "} · resolved {cliDiscovery.apiUrl} {(cliDiscovery as { deploymentMode?: string }).deploymentMode ? ` · ${(cliDiscovery as { deploymentMode?: string }).deploymentMode}` : ""} ) : ( CLI discovery failed: {cliDiscovery.reason} )}
)} {/* API mode fields */} {settings.transport === "api" && ( <>
setSettings((s) => ({ ...s, apiUrl: e.target.value }))} /> Base URL of the Paperclip server.
{ setSettings((s) => ({ ...s, apiKey: e.target.value })); setApiKeyDirty(true); }} /> Agent API key. Local-trusted deployments may leave this blank.
)} {/* CLI mode fields */} {settings.transport === "cli" && ( <>
setSettings((s) => ({ ...s, cliBinaryPath: e.target.value })) } /> Optional — informational; the adapter currently reads the instance config file directly.
setSettings((s) => ({ ...s, cliConfigPath: e.target.value })) } /> Override the path to config.json. Leave blank for the default.
{ setSettings((s) => ({ ...s, apiKey: e.target.value })); setApiKeyDirty(true); }} /> Local-trusted deployments do not require a key. {/* Mint button: show when CLI mode, connection attempted but unavailable, and agent selected */} {status !== null && connected === false && settings.agentId && ( )}
)} {/* Company picker */}
Select a Paperclip company.
{/* Agent picker */}
Pick the Paperclip agent this Fusion runtime will proxy.
{/* Conversation mode */}
{MODE_OPTIONS.find((o) => o.value === settings.mode)?.help}
{/* Optional scoping */}
setSettings((s) => ({ ...s, projectId: e.target.value }))} /> Pin work to a specific Paperclip project.
setSettings((s) => ({ ...s, parentIssueId: e.target.value }))} /> Scope work under an existing parent issue.
setSettings((s) => ({ ...s, goalId: e.target.value }))} /> Associate work with a Paperclip goal.
setSettings((s) => ({ ...s, runTimeoutMs: parseInt(e.target.value, 10) || DEFAULT_SETTINGS.runTimeoutMs, })) } /> Local cap before Fusion gives up on a Paperclip run.
); }