fix(plugins): re-export probe symbols + declare plugin deps in dashboard

- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` /
  `probeOpenClawBinary` and their status types so the dashboard's
  `runtime-provider-probes.ts` façade can import them via the public
  package entry instead of deep paths.
- Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`,
  `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so
  pnpm symlinks them into `packages/dashboard/node_modules/`. Without
  these, the new probe imports failed with "Cannot find module" during
  `pnpm typecheck`.

This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are
in the in-flight Hermes plugin rewrite (runtime-adapter still imports
from a deleted `./pi-module.js`; the new `index.ts` calls a factory
with the wrong arg type) and should be resolved by the same change set
that landed the rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-27 20:22:18 -07:00
committed by gsxdsm
parent 5159cc2a68
commit 6e65786bc5
82 changed files with 8481 additions and 2833 deletions

View File

@@ -1205,6 +1205,197 @@ export function fetchClaudeCliStatus(): Promise<ClaudeCliStatus> {
return api<ClaudeCliStatus>("/providers/claude-cli/status");
}
// --- Runtime Provider Status Types ---
export interface RuntimeBinaryStatus {
available: boolean;
binaryPath?: string;
version?: string;
reason?: string;
probeDurationMs: number;
}
export interface PaperclipConnectionStatus {
available: boolean;
apiUrl: string;
identity?: {
agentId: string;
agentName: string;
role?: string;
companyId: string;
companyName?: string;
};
reason?: string;
probeDurationMs: number;
}
export interface HermesProviderStatus {
binary: RuntimeBinaryStatus;
ready: boolean;
}
export interface OpenClawProviderStatus {
binary: RuntimeBinaryStatus;
ready: boolean;
}
export interface PaperclipProviderStatus {
connection: PaperclipConnectionStatus;
ready: boolean;
}
/** Probe the local Hermes binary. */
export async function fetchHermesStatus(opts?: {
binaryPath?: string;
}): Promise<HermesProviderStatus> {
const qs = opts?.binaryPath
? `?binaryPath=${encodeURIComponent(opts.binaryPath)}`
: "";
return api<HermesProviderStatus>(`/providers/hermes/status${qs}`);
}
export interface HermesProfileSummary {
name: string;
model?: string;
gateway?: string;
alias?: string;
isDefault: boolean;
}
/** List Hermes profiles from `hermes profile list`. Returns empty array on error. */
export async function fetchHermesProfiles(opts?: {
binaryPath?: string;
}): Promise<HermesProfileSummary[]> {
const qs = opts?.binaryPath ? `?binaryPath=${encodeURIComponent(opts.binaryPath)}` : "";
const r = await api<{ profiles: HermesProfileSummary[]; error?: string }>(
`/providers/hermes/profiles${qs}`,
);
return r.profiles ?? [];
}
/** Probe the local OpenClaw binary. */
export async function fetchOpenClawStatus(opts?: {
binaryPath?: string;
}): Promise<OpenClawProviderStatus> {
const qs = opts?.binaryPath
? `?binaryPath=${encodeURIComponent(opts.binaryPath)}`
: "";
return api<OpenClawProviderStatus>(`/providers/openclaw/status${qs}`);
}
/** Probe the Paperclip API connection. */
export async function fetchPaperclipStatus(opts: {
apiUrl: string;
apiKey?: string;
}): Promise<PaperclipProviderStatus> {
const params = new URLSearchParams({ apiUrl: opts.apiUrl });
if (opts.apiKey) params.set("apiKey", opts.apiKey);
return api<PaperclipProviderStatus>(
`/providers/paperclip/status?${params.toString()}`,
);
}
export interface PaperclipCompanySummary {
id: string;
name: string;
urlKey?: string;
}
export interface PaperclipAgentSummary {
id: string;
name: string;
role?: string;
companyId: string;
status?: string;
isCurrent?: boolean;
}
export interface PaperclipCliDiscoverySuccess {
ok: true;
apiUrl: string;
apiKey?: string;
configPath: string;
deploymentMode?: string;
}
export interface PaperclipCliDiscoveryFailure {
ok: false;
reason: string;
configPath?: string;
}
export type PaperclipCliDiscoveryResult =
| PaperclipCliDiscoverySuccess
| PaperclipCliDiscoveryFailure;
/** List Paperclip companies visible to the bearer. Empty array on failure. */
export async function fetchPaperclipCompanies(opts: {
apiUrl: string;
apiKey?: string;
}): Promise<PaperclipCompanySummary[]> {
const params = new URLSearchParams({ apiUrl: opts.apiUrl });
if (opts.apiKey) params.set("apiKey", opts.apiKey);
const r = await api<{ companies: PaperclipCompanySummary[] }>(
`/providers/paperclip/companies?${params.toString()}`,
);
return r.companies ?? [];
}
/** List agents in a Paperclip company. Empty array on failure. */
export async function fetchPaperclipAgents(opts: {
apiUrl: string;
apiKey?: string;
companyId: string;
}): Promise<PaperclipAgentSummary[]> {
const params = new URLSearchParams({
apiUrl: opts.apiUrl,
companyId: opts.companyId,
});
if (opts.apiKey) params.set("apiKey", opts.apiKey);
const r = await api<{ agents: PaperclipAgentSummary[] }>(
`/providers/paperclip/agents?${params.toString()}`,
);
return r.agents ?? [];
}
export interface PaperclipMintKeyRequest {
cliBinaryPath?: string;
agentRef: string;
/** Required by paperclipai agent local-cli (`-C/--company-id`). */
companyId: string;
keyName?: string;
configPath?: string;
dataDir?: string;
}
export type PaperclipMintKeyResult =
| { ok: true; key: { apiKey: string; apiBase?: string; agentId?: string; companyId?: string } }
| { ok: false; reason: string };
/**
* Mints a Paperclip agent API key via the local `paperclipai` CLI.
* Always resolves (never rejects); on failure the result has `ok: false`.
*/
export async function mintPaperclipApiKey(
body: PaperclipMintKeyRequest,
): Promise<PaperclipMintKeyResult> {
return api<PaperclipMintKeyResult>(`/providers/paperclip/cli-mint-key`, {
method: "POST",
body: JSON.stringify(body),
});
}
/** Read the local paperclipai config to discover apiUrl + deploymentMode. */
export async function fetchPaperclipCliDiscovery(opts: {
cliConfigPath?: string;
} = {}): Promise<PaperclipCliDiscoveryResult> {
const params = new URLSearchParams();
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
const qs = params.toString();
return api<PaperclipCliDiscoveryResult>(
`/providers/paperclip/cli-discovery${qs ? `?${qs}` : ""}`,
);
}
/** Enable or disable the Claude CLI provider. Refuses enable if binary is missing. */
export function setClaudeCliEnabled(
enabled: boolean,

View File

@@ -433,7 +433,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
{/* State-dependent action buttons */}
{agent.state === "idle" && (
<>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Start
</button>
@@ -450,7 +450,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
</button>
)}
{agent.state === "paused" && (
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Resume
</button>
@@ -469,7 +469,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
)}
{agent.state === "error" && (
<>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Retry
</button>
@@ -481,7 +481,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
)}
{agent.state === "terminated" && (
<>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Start
</button>
@@ -1193,7 +1193,7 @@ function RunsTab({
{canRunHeartbeat && (
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)" }}>
<button
className="btn btn--sm btn--primary"
className="btn btn--sm btn-task-create"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agentName ?? agentId}`}
>
@@ -1468,7 +1468,7 @@ function RunsTab({
</button>
)}
<button
className="btn btn--sm btn--primary"
className="btn btn--sm btn-task-create"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agentName ?? agentId}`}
>
@@ -1783,7 +1783,7 @@ function SoulTab({
{!showPreview && (
<div className="config-actions">
<button
className="btn btn--primary"
className="btn btn-task-create"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>
@@ -2104,7 +2104,7 @@ function MemoryTab({
<div className="config-actions">
{!showPreview && (
<button
className="btn btn--primary"
className="btn btn-task-create"
disabled={!hasInlineChanges || isSaving || isReadOnly}
onClick={() => void handleSaveInlineMemory()}
>
@@ -2362,7 +2362,7 @@ function InstructionsTab({
{!showPreview && (
<div className="config-actions">
<button
className="btn btn--primary"
className="btn btn-task-create"
disabled={!hasInstructionsChanges || isSaving}
onClick={() => void handleSaveInstructions()}
>
@@ -2431,7 +2431,7 @@ function InstructionsTab({
<div className="config-actions">
<button
className="btn btn--primary"
className="btn btn-task-create"
disabled={!fileContentDirty || isSavingFile}
onClick={() => void handleSaveFile()}
>
@@ -3578,7 +3578,7 @@ function ConfigTab({
<div className="config-actions">
<button
className="btn btn--primary"
className="btn btn-task-create"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>

View File

@@ -19,7 +19,7 @@ export function AgentEmptyState({
<p className="agent-empty-state__title">{title}</p>
<p className="agent-empty-state__description text-secondary">{description}</p>
{onCtaClick ? (
<button type="button" className="btn btn--primary" onClick={onCtaClick}>
<button type="button" className="btn btn-task-create btn-sm" onClick={onCtaClick}>
{ctaLabel}
</button>
) : null}

View File

@@ -305,7 +305,7 @@ export function AgentGenerationModal({
</button>
{view.type === "input" && (
<button
className="btn btn--primary"
className="btn btn-task-create"
onClick={() => void handleGenerate()}
disabled={!canGenerate}
>
@@ -320,7 +320,7 @@ export function AgentGenerationModal({
>
Regenerate
</button>
<button className="btn btn--primary" onClick={handleUseSpec}>
<button className="btn btn-task-create" onClick={handleUseSpec}>
Use This
</button>
</>

View File

@@ -908,7 +908,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
</button>
{step === "input" && (
<button
className="btn btn--primary"
className="btn btn-task-create"
onClick={() => void handleParse()}
disabled={
isParsing || (
@@ -932,7 +932,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
)}
{step === "preview" && (
<button
className="btn btn--primary"
className="btn btn-task-create"
onClick={() => void handleImport()}
disabled={isImporting || (selectedAgentCount === 0 && selectedSkillCount === 0)}
>

View File

@@ -289,7 +289,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
</div>
<button
className="btn btn--primary"
className="btn btn-task-create btn-sm"
onClick={() => setIsCreating(!isCreating)}
>
<Plus size={16} />
@@ -320,7 +320,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
</option>
))}
</select>
<button className="btn btn--primary" onClick={() => void handleCreate()}>
<button className="btn btn-task-create btn-sm" onClick={() => void handleCreate()}>
Create
</button>
</div>
@@ -650,7 +650,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
{agent.state === "terminated" && (
<>
<button
className="btn btn--sm btn--primary"
className="btn btn--sm btn-task-create"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Start"

View File

@@ -429,7 +429,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
/>
<button
type="submit"
className="btn btn--primary"
className="btn btn-task-create"
disabled={newScore === 0 || isSubmittingRating}
>
{isSubmittingRating ? "Submitting..." : "Submit Rating"}

View File

@@ -973,7 +973,7 @@
/* New Agent: icon-only on mobile, sized to match view-toggle buttons.
Text is visually hidden via font-size: 0 (the SVG icon keeps its size). */
.agents-view-primary-actions .btn--primary {
.agents-view-primary-actions .btn-task-create {
width: 28px;
height: 28px;
min-width: 28px;

View File

@@ -777,7 +777,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
</button>
<button
className="btn btn--primary"
className="btn btn-task-create btn-sm"
onClick={() => {
setIsCreating(true);
setIsControlsPanelOpen(false);

View File

@@ -0,0 +1,381 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchHermesProfiles,
fetchHermesStatus,
fetchPluginSettings,
updatePluginSettings,
type HermesProfileSummary,
type HermesProviderStatus,
} from "../api";
import { ProviderIcon } from "./ProviderIcon";
import { RuntimeCardShell } from "./RuntimeCardShell";
const PLUGIN_ID = "fusion-plugin-hermes-runtime";
const HERMES_LEARN_MORE = "https://github.com/NousResearch/hermes-agent";
const HERMES_PROVIDER_OPTIONS = [
"auto",
"anthropic",
"openrouter",
"gemini",
"openai-codex",
"copilot",
"copilot-acp",
"huggingface",
"zai",
"kimi-coding",
"minimax",
"minimax-cn",
"kilocode",
"xiaomi",
"nous",
] as const;
interface HermesSettings {
binaryPath: string;
model: string;
provider: string;
maxTurns: number;
yolo: boolean;
cliTimeoutMs: number;
/** Hermes profile name; empty = "Auto / use Hermes default". */
profile: string;
}
const DEFAULT_SETTINGS: HermesSettings = {
binaryPath: "",
model: "",
provider: "auto",
maxTurns: 12,
yolo: false,
cliTimeoutMs: 300_000,
profile: "",
};
function settingsFromRecord(raw: Record<string, unknown>): HermesSettings {
return {
binaryPath: typeof raw.binaryPath === "string" ? raw.binaryPath : DEFAULT_SETTINGS.binaryPath,
model: typeof raw.model === "string" ? raw.model : DEFAULT_SETTINGS.model,
provider: typeof raw.provider === "string" ? raw.provider : DEFAULT_SETTINGS.provider,
maxTurns: typeof raw.maxTurns === "number" ? raw.maxTurns : DEFAULT_SETTINGS.maxTurns,
yolo: typeof raw.yolo === "boolean" ? raw.yolo : DEFAULT_SETTINGS.yolo,
cliTimeoutMs:
typeof raw.cliTimeoutMs === "number" ? raw.cliTimeoutMs : DEFAULT_SETTINGS.cliTimeoutMs,
profile: typeof raw.profile === "string" ? raw.profile : DEFAULT_SETTINGS.profile,
};
}
export function HermesRuntimeCard() {
const [settings, setSettings] = useState<HermesSettings>(DEFAULT_SETTINGS);
const [status, setStatus] = useState<HermesProviderStatus | null>(null);
const [profiles, setProfiles] = useState<HermesProfileSummary[]>([]);
const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null);
const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
setBusy("loading");
fetchPluginSettings(PLUGIN_ID)
.then((raw) => {
if (mountedRef.current) setSettings(settingsFromRecord(raw));
})
.catch(() => undefined)
.finally(() => {
if (mountedRef.current) setBusy(null);
});
}, []);
useEffect(() => {
fetchHermesProfiles(settings.binaryPath ? { binaryPath: settings.binaryPath } : {})
.then((p) => { if (mountedRef.current) setProfiles(p); })
.catch(() => undefined);
}, [settings.binaryPath]);
const probe = useCallback(async (): Promise<HermesProviderStatus | null> => {
try {
const next = await fetchHermesStatus(
settings.binaryPath ? { binaryPath: settings.binaryPath } : {},
);
if (mountedRef.current) setStatus(next);
return next;
} catch (err) {
if (mountedRef.current) {
setToast({
kind: "err",
message: err instanceof Error ? err.message : String(err),
});
}
return null;
}
}, [settings.binaryPath]);
useEffect(() => {
void probe();
}, [probe]);
const buildPayload = useCallback(
(): Record<string, unknown> => ({
binaryPath: settings.binaryPath,
model: settings.model,
provider: settings.provider,
maxTurns: settings.maxTurns,
yolo: settings.yolo,
cliTimeoutMs: settings.cliTimeoutMs,
profile: settings.profile,
}),
[settings],
);
const handleTest = useCallback(async () => {
setBusy("testing");
setToast(null);
const next = await probe();
if (!mountedRef.current) return;
setBusy(null);
if (!next) {
setToast({ kind: "err", message: "Test failed — see status above." });
} else if (next.binary.available) {
setToast({
kind: "ok",
message: `✓ hermes detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
});
} else {
setToast({
kind: "err",
message: `✗ ${next.binary.reason ?? "hermes not found"}`,
});
}
}, [probe]);
const handleSave = useCallback(async () => {
setBusy("saving");
setToast(null);
try {
await updatePluginSettings(PLUGIN_ID, buildPayload());
if (mountedRef.current) setToast({ kind: "ok", message: "Settings saved." });
} 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());
const next = await probe();
if (!mountedRef.current) return;
if (!next) {
setToast({ kind: "err", message: "Saved, but probe failed." });
} else if (next.binary.available) {
setToast({
kind: "ok",
message: `Saved · ✓ hermes detected${next.binary.version ? ` (${next.binary.version})` : ""}.`,
});
} else {
setToast({
kind: "err",
message: `Saved · ✗ ${next.binary.reason ?? "hermes not found"}`,
});
}
} catch (err) {
if (mountedRef.current)
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
} finally {
if (mountedRef.current) setBusy(null);
}
}, [buildPayload, probe]);
const binary = status?.binary;
const statusKind = status === null
? "loading"
: binary?.available
? "ok"
: "err";
const statusText =
status === null
? "Probing local hermes binary…"
: binary?.available
? `✓ Detected${binary.version ? ` ${binary.version}` : ""}${binary.binaryPath ? ` · ${binary.binaryPath}` : ""}`
: `✗ ${binary?.reason ?? "not detected on PATH"}`;
return (
<RuntimeCardShell
testId="hermes-runtime-card"
logo={<ProviderIcon provider="hermes" size="lg" />}
name="Hermes"
subname="by Nous Research"
learnMoreHref={HERMES_LEARN_MORE}
statusKind={statusKind}
statusText={statusText}
description={
<>
Drives the local <code>hermes</code> CLI as a subprocess. Each Fusion
prompt is sent as <code>hermes chat -q …</code>; subsequent prompts
resume the same hermes session via <code>--resume</code>. Provider,
model, and skills are configured inside hermes itself; this card
only chooses overrides.
</>
}
busy={busy}
toast={toast}
onTest={() => void handleTest()}
onSave={() => void handleSave()}
onSaveAndTest={() => void handleSaveAndTest()}
belowForm={
binary?.available === false ? (
<div className="onboarding-helper-text">
<p>
<code>hermes</code> not detected. Install the upstream agent:
</p>
<pre>
<code>pipx install hermes-agent</code>
</pre>
<p>
<a href={HERMES_LEARN_MORE} target="_blank" rel="noreferrer">
Hermes on GitHub
</a>
</p>
</div>
) : null
}
>
<div className="form-group">
<label htmlFor="hermes-profile">Profile (optional)</label>
<select
id="hermes-profile"
value={settings.profile}
onChange={(e) => setSettings((s) => ({ ...s, profile: e.target.value }))}
>
<option value="">Auto / use Hermes default</option>
{profiles.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
{p.model ? ` — ${p.model}` : ""}
{p.isDefault ? " (default)" : ""}
</option>
))}
</select>
<small>
Select a Hermes profile to use. Activates the profile by setting{" "}
<code>HERMES_HOME</code> to the profile directory when invoking{" "}
<code>hermes chat</code>.
</small>
</div>
<div className="form-group">
<label htmlFor="hermes-binary">Binary path</label>
<input
id="hermes-binary"
type="text"
placeholder="hermes (defaults to PATH)"
value={settings.binaryPath}
onChange={(e) => setSettings((s) => ({ ...s, binaryPath: e.target.value }))}
/>
<small>
Leave blank to resolve <code>hermes</code> from your PATH.
</small>
</div>
<div className="form-group">
<label htmlFor="hermes-model">Model override</label>
<input
id="hermes-model"
type="text"
placeholder="e.g. claude-sonnet-4-5, MiniMax-M2.7"
value={settings.model}
disabled={!!settings.profile}
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
/>
{settings.profile ? (
<small>Controlled by profile: <strong>{settings.profile}</strong></small>
) : (
<small>Optional — overrides Hermes's configured default model.</small>
)}
</div>
<div className="form-group">
<label htmlFor="hermes-provider">Provider</label>
<select
id="hermes-provider"
value={settings.provider}
disabled={!!settings.profile}
onChange={(e) => setSettings((s) => ({ ...s, provider: e.target.value }))}
>
{HERMES_PROVIDER_OPTIONS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
{settings.profile ? (
<small>Controlled by profile: <strong>{settings.profile}</strong></small>
) : (
<small>Inference provider Hermes routes calls through (default: <code>auto</code>)).</small>
)}
</div>
<div className="form-group">
<label htmlFor="hermes-maxTurns">Max turns</label>
<input
id="hermes-maxTurns"
type="number"
min={1}
max={500}
value={settings.maxTurns}
onChange={(e) =>
setSettings((s) => ({
...s,
maxTurns: Number(e.target.value) || DEFAULT_SETTINGS.maxTurns,
}))
}
/>
<small>Cap per Hermes turn. Hermes's own default is 90; we cap lower.</small>
</div>
<div className="form-group">
<label
htmlFor="hermes-yolo"
style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-xs)" }}
>
<input
id="hermes-yolo"
type="checkbox"
checked={settings.yolo}
onChange={(e) => setSettings((s) => ({ ...s, yolo: e.target.checked }))}
/>
Auto-approve dangerous tool calls (<code>--yolo</code>)
</label>
<small>Required for non-interactive sessions that trigger shell-style tools.</small>
</div>
<div className="form-group">
<label htmlFor="hermes-timeoutMs">CLI hard-kill timeout (ms)</label>
<input
id="hermes-timeoutMs"
type="number"
min={1000}
step={1000}
value={settings.cliTimeoutMs}
onChange={(e) =>
setSettings((s) => ({
...s,
cliTimeoutMs: Number(e.target.value) || DEFAULT_SETTINGS.cliTimeoutMs,
}))
}
/>
<small>Fusion-side hard cap. Default 5 min.</small>
</div>
</RuntimeCardShell>
);
}

View File

@@ -0,0 +1,362 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchOpenClawStatus,
fetchPluginSettings,
updatePluginSettings,
type OpenClawProviderStatus,
} from "../api";
import { ProviderIcon } from "./ProviderIcon";
import { RuntimeCardShell } from "./RuntimeCardShell";
const PLUGIN_ID = "fusion-plugin-openclaw-runtime";
const OPENCLAW_LEARN_MORE = "https://docs.openclaw.ai/";
const OPENCLAW_GITHUB = "https://github.com/openclaw/openclaw";
type ThinkingLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "adaptive"
| "max";
const THINKING_LEVEL_OPTIONS: ThinkingLevel[] = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
];
interface OpenClawSettings {
binaryPath: string;
agentId: string;
model: string;
thinking: ThinkingLevel;
useGateway: boolean;
cliTimeoutSec: number;
cliTimeoutMs: number;
}
const DEFAULT_SETTINGS: OpenClawSettings = {
binaryPath: "",
agentId: "main",
model: "",
thinking: "off",
useGateway: false,
cliTimeoutSec: 0,
cliTimeoutMs: 300_000,
};
function settingsFromRecord(raw: Record<string, unknown>): OpenClawSettings {
return {
binaryPath:
typeof raw.binaryPath === "string" ? raw.binaryPath : DEFAULT_SETTINGS.binaryPath,
agentId:
typeof raw.agentId === "string" ? raw.agentId : DEFAULT_SETTINGS.agentId,
model: typeof raw.model === "string" ? raw.model : DEFAULT_SETTINGS.model,
thinking: (THINKING_LEVEL_OPTIONS as string[]).includes(raw.thinking as string)
? (raw.thinking as ThinkingLevel)
: DEFAULT_SETTINGS.thinking,
useGateway:
typeof raw.useGateway === "boolean" ? raw.useGateway : DEFAULT_SETTINGS.useGateway,
cliTimeoutSec:
typeof raw.cliTimeoutSec === "number"
? raw.cliTimeoutSec
: DEFAULT_SETTINGS.cliTimeoutSec,
cliTimeoutMs:
typeof raw.cliTimeoutMs === "number"
? raw.cliTimeoutMs
: DEFAULT_SETTINGS.cliTimeoutMs,
};
}
export function OpenClawRuntimeCard() {
const [settings, setSettings] = useState<OpenClawSettings>(DEFAULT_SETTINGS);
const [status, setStatus] = useState<OpenClawProviderStatus | null>(null);
const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null);
const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Load saved settings on mount.
useEffect(() => {
setBusy("loading");
fetchPluginSettings(PLUGIN_ID)
.then((raw) => {
if (mountedRef.current) setSettings(settingsFromRecord(raw));
})
.catch(() => undefined)
.finally(() => {
if (mountedRef.current) setBusy(null);
});
}, []);
const probe = useCallback(async (): Promise<OpenClawProviderStatus | null> => {
try {
const next = await fetchOpenClawStatus(
settings.binaryPath ? { binaryPath: settings.binaryPath } : undefined,
);
if (mountedRef.current) setStatus(next);
return next;
} catch (err) {
if (mountedRef.current)
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
return null;
}
}, [settings.binaryPath]);
// Initial probe.
useEffect(() => {
void probe();
}, [probe]);
const buildPayload = useCallback(
(): Record<string, unknown> => ({
binaryPath: settings.binaryPath,
agentId: settings.agentId,
model: settings.model,
thinking: settings.thinking,
useGateway: settings.useGateway,
cliTimeoutSec: settings.cliTimeoutSec,
cliTimeoutMs: settings.cliTimeoutMs,
}),
[settings],
);
const handleTest = useCallback(async () => {
setBusy("testing");
setToast(null);
const next = await probe();
if (!mountedRef.current) return;
setBusy(null);
if (!next) {
setToast({ kind: "err", message: "Test failed — see status above." });
} else if (next.binary.available) {
setToast({
kind: "ok",
message: `✓ openclaw detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
});
} else {
setToast({
kind: "err",
message: `✗ ${next.binary.reason ?? "openclaw not found"}`,
});
}
}, [probe]);
const handleSave = useCallback(async () => {
setBusy("saving");
setToast(null);
try {
await updatePluginSettings(PLUGIN_ID, buildPayload());
if (mountedRef.current) setToast({ kind: "ok", message: "Settings saved." });
} 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());
const next = await probe();
if (!mountedRef.current) return;
if (!next) {
setToast({ kind: "err", message: "Saved, but probe failed." });
} else if (next.binary.available) {
setToast({
kind: "ok",
message: `Saved · ✓ openclaw detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
});
} else {
setToast({
kind: "err",
message: `Saved · ✗ ${next.binary.reason ?? "openclaw not found"}`,
});
}
} catch (err) {
if (mountedRef.current)
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
} finally {
if (mountedRef.current) setBusy(null);
}
}, [buildPayload, probe]);
const binary = status?.binary;
const statusKind =
status === null ? "loading" : binary?.available ? "ok" : "err";
const statusText =
status === null
? "Probing local openclaw binary…"
: binary?.available
? `✓ Detected${binary.version ? ` ${binary.version}` : ""}${binary.binaryPath ? ` · ${binary.binaryPath}` : ""}`
: `✗ ${binary?.reason ?? "not detected on PATH"}`;
return (
<RuntimeCardShell
testId="openclaw-runtime-card"
logo={<ProviderIcon provider="openclaw" size="lg" />}
name="OpenClaw"
learnMoreHref={OPENCLAW_LEARN_MORE}
statusKind={statusKind}
statusText={statusText}
description={
<>
Drives the local <code>openclaw</code> CLI as a subprocess. Each
Fusion prompt is dispatched via{" "}
<code>openclaw agent --local --json</code>; the agent definition,
model, and thinking level are resolved by OpenClaw. This card only
sets overrides and the binary path.
</>
}
busy={busy}
toast={toast}
onTest={() => void handleTest()}
onSave={() => void handleSave()}
onSaveAndTest={() => void handleSaveAndTest()}
belowForm={
binary?.available === false ? (
<div className="onboarding-helper-text">
<p>
<code>openclaw</code> not detected. Install the upstream agent:
</p>
<pre>
<code>npm install -g openclaw</code>
</pre>
<p>
<a href={OPENCLAW_GITHUB} target="_blank" rel="noreferrer">
openclaw on GitHub
</a>
</p>
</div>
) : null
}
>
<div className="form-group">
<label htmlFor="openclaw-binaryPath">Binary path</label>
<input
id="openclaw-binaryPath"
type="text"
placeholder="openclaw (defaults to PATH)"
value={settings.binaryPath}
onChange={(e) => setSettings((s) => ({ ...s, binaryPath: e.target.value }))}
/>
<small>Leave blank to resolve <code>openclaw</code> from your PATH.</small>
</div>
<div className="form-group">
<label htmlFor="openclaw-agentId">Agent ID</label>
<input
id="openclaw-agentId"
type="text"
placeholder="main"
value={settings.agentId}
onChange={(e) => setSettings((s) => ({ ...s, agentId: e.target.value }))}
/>
<small>OpenClaw agent definition to run (default: <code>main</code>).</small>
</div>
<div className="form-group">
<label htmlFor="openclaw-model">Model override</label>
<input
id="openclaw-model"
type="text"
placeholder="e.g. anthropic/claude-haiku-4-5, minimax/MiniMax-M2.7"
value={settings.model}
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
/>
<small>Optional — overrides the OpenClaw default model.</small>
</div>
<div className="form-group">
<label htmlFor="openclaw-thinking">Thinking level</label>
<select
id="openclaw-thinking"
value={settings.thinking}
onChange={(e) =>
setSettings((s) => ({ ...s, thinking: e.target.value as ThinkingLevel }))
}
>
{THINKING_LEVEL_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<small>Controls how much extended thinking the model uses.</small>
</div>
<div className="form-group">
<label
htmlFor="openclaw-useGateway"
style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-xs)" }}
>
<input
id="openclaw-useGateway"
type="checkbox"
checked={settings.useGateway}
onChange={(e) => setSettings((s) => ({ ...s, useGateway: e.target.checked }))}
/>
Route through OpenClaw gateway (otherwise embedded)
</label>
<small>When enabled, calls pass through the OpenClaw gateway service.</small>
</div>
<div className="form-group">
<label htmlFor="openclaw-cliTimeoutSec">OpenClaw timeout (sec)</label>
<input
id="openclaw-cliTimeoutSec"
type="number"
min={0}
step={1}
value={settings.cliTimeoutSec}
onChange={(e) =>
setSettings((s) => ({
...s,
cliTimeoutSec: parseInt(e.target.value, 10) || 0,
}))
}
/>
<small>OpenClaw-side run timeout in seconds (0 = no timeout).</small>
</div>
<div className="form-group">
<label htmlFor="openclaw-cliTimeoutMs">CLI subprocess timeout (ms)</label>
<input
id="openclaw-cliTimeoutMs"
type="number"
min={0}
step={1000}
value={settings.cliTimeoutMs}
onChange={(e) =>
setSettings((s) => ({
...s,
cliTimeoutMs:
parseInt(e.target.value, 10) || DEFAULT_SETTINGS.cliTimeoutMs,
}))
}
/>
<small>
Fusion-side hard cap before the CLI subprocess is killed (default:{" "}
{DEFAULT_SETTINGS.cliTimeoutMs / 1000}s).
</small>
</div>
</RuntimeCardShell>
);
}

View File

@@ -0,0 +1,731 @@
/**
* 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,
fetchPaperclipCliDiscovery,
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<PaperclipMode>([
"issue-per-prompt",
"rolling-issue",
"wakeup-only",
]);
function settingsFromRecord(raw: Record<string, unknown>): 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<PaperclipSettings>(DEFAULT_SETTINGS);
const [status, setStatus] = useState<PaperclipProviderStatus | null>(null);
const [cliDiscovery, setCliDiscovery] =
useState<PaperclipCliDiscoveryResult | null>(null);
const [companies, setCompanies] = useState<PaperclipCompanySummary[]>([]);
const [agents, setAgents] = useState<PaperclipAgentSummary[]>([]);
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.
const probe = useCallback(async (): Promise<PaperclipProviderStatus | null> => {
if (!effectiveAuth.apiUrl) return null;
try {
const next = await fetchPaperclipStatus(effectiveAuth);
if (mountedRef.current) setStatus(next);
// Then load companies + agents for the picker.
const cs = 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 = 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 the URL/key — companyId/agentId changes are
// handled by a separate effect below.
}, [effectiveAuth.apiUrl, effectiveAuth.apiKey, settings.companyId, settings.agentId]);
useEffect(() => {
void probe();
}, [probe]);
// Reload agent list when the user manually changes companyId.
useEffect(() => {
if (!effectiveAuth.apiUrl || !settings.companyId) {
setAgents([]);
return;
}
let cancelled = false;
fetchPaperclipAgents({
...effectiveAuth,
companyId: settings.companyId,
})
.then((ag) => {
if (!cancelled && mountedRef.current) setAgents(ag);
})
.catch(() => {
if (!cancelled && mountedRef.current) setAgents([]);
});
return () => {
cancelled = true;
};
}, [effectiveAuth.apiUrl, effectiveAuth.apiKey, settings.companyId]);
const buildPayload = useCallback((): Record<string, unknown> => {
const payload: Record<string, unknown> = {
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 = (
<div
className="runtime-card__tabs"
role="tablist"
aria-label="Paperclip connection mode"
>
<button
type="button"
role="tab"
aria-selected={settings.transport === "api"}
className="runtime-card__tab"
onClick={() => setSettings((s) => ({ ...s, transport: "api" }))}
>
API (URL + token)
</button>
<button
type="button"
role="tab"
aria-selected={settings.transport === "cli"}
className="runtime-card__tab"
onClick={() => setSettings((s) => ({ ...s, transport: "cli" }))}
>
Local CLI (auto-derive)
</button>
</div>
);
return (
<RuntimeCardShell
testId="paperclip-runtime-card"
logo={<ProviderIcon provider="paperclip" size="lg" />}
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 ? (
<div className="onboarding-helper-text">
<p>Make sure a Paperclip server is running. To install Paperclip:</p>
<pre>
<code>npm install -g paperclipai</code>
</pre>
<p>
<a href={PAPERCLIP_LEARN_MORE} target="_blank" rel="noreferrer">
Paperclip docs
</a>{" "}
·{" "}
<a href={PAPERCLIP_GITHUB} target="_blank" rel="noreferrer">
GitHub
</a>
</p>
</div>
) : null
}
>
{/* CLI discovery banner */}
{settings.transport === "cli" && cliDiscovery && (
<div className="settings-muted" style={{ marginBottom: "var(--space-sm)" }}>
{cliOk ? (
<small>
CLI config: <code>{(cliDiscovery as { configPath: string }).configPath}</code>{" "}
· resolved <code>{cliDiscovery.apiUrl}</code>
{(cliDiscovery as { deploymentMode?: string }).deploymentMode
? ` · ${(cliDiscovery as { deploymentMode?: string }).deploymentMode}`
: ""}
</small>
) : (
<small>CLI discovery failed: {cliDiscovery.reason}</small>
)}
</div>
)}
{/* API mode fields */}
{settings.transport === "api" && (
<>
<div className="form-group">
<label htmlFor="paperclip-apiUrl">API URL</label>
<input
id="paperclip-apiUrl"
type="text"
placeholder="http://localhost:3100"
value={settings.apiUrl}
onChange={(e) => setSettings((s) => ({ ...s, apiUrl: e.target.value }))}
/>
<small>Base URL of the Paperclip server.</small>
</div>
<div className="form-group">
<label htmlFor="paperclip-apiKey">API key</label>
<input
id="paperclip-apiKey"
type="password"
placeholder={apiKeyDirty ? "" : "•••••••• (leave blank to keep existing)"}
value={settings.apiKey}
onChange={(e) => {
setSettings((s) => ({ ...s, apiKey: e.target.value }));
setApiKeyDirty(true);
}}
/>
<small>Agent API key. Local-trusted deployments may leave this blank.</small>
</div>
</>
)}
{/* CLI mode fields */}
{settings.transport === "cli" && (
<>
<div className="form-group">
<label htmlFor="paperclip-cliBinaryPath">paperclipai binary</label>
<input
id="paperclip-cliBinaryPath"
type="text"
placeholder="paperclipai"
value={settings.cliBinaryPath}
onChange={(e) =>
setSettings((s) => ({ ...s, cliBinaryPath: e.target.value }))
}
/>
<small>
Optional — informational; the adapter currently reads the instance
config file directly.
</small>
</div>
<div className="form-group">
<label htmlFor="paperclip-cliConfigPath">Instance config path</label>
<input
id="paperclip-cliConfigPath"
type="text"
placeholder="~/.paperclip/instances/default/config.json"
value={settings.cliConfigPath}
onChange={(e) =>
setSettings((s) => ({ ...s, cliConfigPath: e.target.value }))
}
/>
<small>
Override the path to <code>config.json</code>. Leave blank for the
default.
</small>
</div>
<div className="form-group">
<label htmlFor="paperclip-cli-apikey">API key (override, optional)</label>
<input
id="paperclip-cli-apikey"
type="password"
placeholder={
apiKeyDirty ? "" : "Optional — only required for non-local-trusted modes"
}
value={settings.apiKey}
onChange={(e) => {
setSettings((s) => ({ ...s, apiKey: e.target.value }));
setApiKeyDirty(true);
}}
/>
<small>Local-trusted deployments do not require a key.</small>
{/* Mint button: show when CLI mode, connection attempted but unavailable, and agent selected */}
{status !== null && connected === false && settings.agentId && (
<button
type="button"
className="btn btn--sm"
style={{ marginTop: "0.4rem" }}
disabled={busy !== null}
onClick={async () => {
setBusy("testing");
setToast(null);
const result = await mintPaperclipApiKey({
cliBinaryPath: settings.cliBinaryPath || undefined,
agentRef: settings.agentId,
companyId: settings.companyId || undefined,
keyName: "fusion-runtime",
configPath: settings.cliConfigPath || undefined,
});
if (!mountedRef.current) return;
setBusy(null);
if (result.ok) {
setSettings((s) => ({ ...s, apiKey: result.key.apiKey }));
setApiKeyDirty(true);
setToast({
kind: "ok",
message: `✓ API key minted via paperclipai (key 'fusion-runtime' installed for agent ${settings.agentId}). Click Save to persist.`,
});
void probe();
} else {
setToast({
kind: "err",
message: `✗ Mint failed: ${result.reason}. Run \`paperclipai onboard\` first if your CLI isn't authenticated.`,
});
}
}}
>
✨ Mint API key via paperclipai
</button>
)}
</div>
</>
)}
{/* Company picker */}
<div className="form-group">
<label htmlFor="paperclip-companyId">Company</label>
<select
id="paperclip-companyId"
value={settings.companyId}
onChange={(e) => setSettings((s) => ({ ...s, companyId: e.target.value }))}
disabled={companies.length === 0}
>
{companies.length === 0 ? (
<option value="">
{connected ? "No companies discovered" : "Connect to populate"}
</option>
) : (
companies.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.id})
</option>
))
)}
</select>
<small>Select a Paperclip company.</small>
</div>
{/* Agent picker */}
<div className="form-group">
<label htmlFor="paperclip-agentId">Agent</label>
<select
id="paperclip-agentId"
value={settings.agentId}
onChange={(e) => setSettings((s) => ({ ...s, agentId: e.target.value }))}
disabled={agents.length === 0}
>
{agents.length === 0 ? (
<option value="">
{settings.companyId ? "No agents discovered" : "Pick a company first"}
</option>
) : (
agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
{a.role ? ` (${a.role})` : ""}
</option>
))
)}
</select>
<small>Pick the Paperclip agent this Fusion runtime will proxy.</small>
</div>
{/* Conversation mode */}
<div className="form-group">
<label htmlFor="paperclip-mode">Conversation mode</label>
<select
id="paperclip-mode"
value={settings.mode}
onChange={(e) =>
setSettings((s) => ({ ...s, mode: e.target.value as PaperclipMode }))
}
>
{MODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<small>{MODE_OPTIONS.find((o) => o.value === settings.mode)?.help}</small>
</div>
{/* Optional scoping */}
<div className="form-group">
<label htmlFor="paperclip-projectId">Project ID (optional)</label>
<input
id="paperclip-projectId"
type="text"
placeholder="Optional"
value={settings.projectId}
onChange={(e) => setSettings((s) => ({ ...s, projectId: e.target.value }))}
/>
<small>Pin work to a specific Paperclip project.</small>
</div>
<div className="form-group">
<label htmlFor="paperclip-parentIssueId">Parent issue ID (optional)</label>
<input
id="paperclip-parentIssueId"
type="text"
placeholder="Optional"
value={settings.parentIssueId}
onChange={(e) => setSettings((s) => ({ ...s, parentIssueId: e.target.value }))}
/>
<small>Scope work under an existing parent issue.</small>
</div>
<div className="form-group">
<label htmlFor="paperclip-goalId">Goal ID (optional)</label>
<input
id="paperclip-goalId"
type="text"
placeholder="Optional"
value={settings.goalId}
onChange={(e) => setSettings((s) => ({ ...s, goalId: e.target.value }))}
/>
<small>Associate work with a Paperclip goal.</small>
</div>
<div className="form-group">
<label htmlFor="paperclip-runTimeoutMs">Run timeout (ms)</label>
<input
id="paperclip-runTimeoutMs"
type="number"
min={0}
step={1000}
value={settings.runTimeoutMs}
onChange={(e) =>
setSettings((s) => ({
...s,
runTimeoutMs: parseInt(e.target.value, 10) || DEFAULT_SETTINGS.runTimeoutMs,
}))
}
/>
<small>Local cap before Fusion gives up on a Paperclip run.</small>
</div>
</RuntimeCardShell>
);
}

View File

@@ -488,6 +488,96 @@ function GitHubIcon({ size, color, label = "GitHub" }: { size: number; color: st
);
}
// Hermes — caduceus (winged staff) mark, single-color so it adapts to theme.
function HermesIcon({ size, color, label = "Hermes" }: { size: number; color: string; label?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label={label}>
<rect x="30" y="10" width="4" height="46" rx="2" fill={color} />
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill={color} opacity="0.9" />
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill={color} opacity="0.7" />
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill={color} opacity="0.9" />
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill={color} opacity="0.7" />
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" opacity="0.8" />
<circle cx="32" cy="10" r="4" fill={color} />
</svg>
);
}
// OpenClaw — pixel-art lobster (verbatim 16×16 source SVG, recolor disabled
// so the iconic palette survives).
function OpenClawIcon({ size, label = "OpenClaw" }: { size: number; color: string; label?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" role="img" aria-label={label}>
<rect width="16" height="16" fill="none" />
<g fill="#3a0a0d">
<rect x="1" y="5" width="1" height="3" />
<rect x="2" y="4" width="1" height="1" />
<rect x="2" y="8" width="1" height="1" />
<rect x="3" y="3" width="1" height="1" />
<rect x="3" y="9" width="1" height="1" />
<rect x="4" y="2" width="1" height="1" />
<rect x="4" y="10" width="1" height="1" />
<rect x="5" y="2" width="6" height="1" />
<rect x="11" y="2" width="1" height="1" />
<rect x="12" y="3" width="1" height="1" />
<rect x="12" y="9" width="1" height="1" />
<rect x="13" y="4" width="1" height="1" />
<rect x="13" y="8" width="1" height="1" />
<rect x="14" y="5" width="1" height="3" />
<rect x="5" y="11" width="6" height="1" />
<rect x="4" y="12" width="1" height="1" />
<rect x="11" y="12" width="1" height="1" />
<rect x="3" y="13" width="1" height="1" />
<rect x="12" y="13" width="1" height="1" />
<rect x="5" y="14" width="6" height="1" />
</g>
<g fill="#ff4f40">
<rect x="5" y="3" width="6" height="1" />
<rect x="4" y="4" width="8" height="1" />
<rect x="3" y="5" width="10" height="1" />
<rect x="3" y="6" width="10" height="1" />
<rect x="3" y="7" width="10" height="1" />
<rect x="4" y="8" width="8" height="1" />
<rect x="5" y="9" width="6" height="1" />
<rect x="5" y="12" width="6" height="1" />
<rect x="6" y="13" width="4" height="1" />
</g>
<g fill="#ff775f">
<rect x="1" y="6" width="2" height="1" />
<rect x="2" y="5" width="1" height="1" />
<rect x="2" y="7" width="1" height="1" />
<rect x="13" y="6" width="2" height="1" />
<rect x="13" y="5" width="1" height="1" />
<rect x="13" y="7" width="1" height="1" />
</g>
<g fill="#081016">
<rect x="6" y="5" width="1" height="1" />
<rect x="9" y="5" width="1" height="1" />
</g>
<g fill="#f5fbff">
<rect x="6" y="4" width="1" height="1" />
<rect x="9" y="4" width="1" height="1" />
</g>
</svg>
);
}
// Paperclip — official paperclip outline, theme-color aware.
function PaperclipIcon({ size, color, label = "Paperclip" }: { size: number; color: string; label?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label={label}>
<path
d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
// Anthropic "A" mark composited with a small terminal "> _" badge in the
// bottom-right, visually signalling "Anthropic, but via the local CLI".
function ClaudeCliIcon({ size, color, label = "Anthropic — via Claude CLI" }: { size: number; color: string; label?: string }) {
@@ -600,6 +690,16 @@ const providerConfig: Record<
vercel: { component: VercelIcon, color: "var(--provider-vercel)" },
"vercel-ai-gateway": { component: VercelIcon, color: "var(--provider-vercel)", label: "Vercel AI Gateway" },
// Runtime-plugin marks (Hermes / OpenClaw / Paperclip).
hermes: { component: HermesIcon, color: "var(--provider-hermes)", label: "Hermes" },
"hermes-agent": { component: HermesIcon, color: "var(--provider-hermes)", label: "Hermes" },
hermesagent: { component: HermesIcon, color: "var(--provider-hermes)", label: "Hermes" },
openclaw: { component: OpenClawIcon, color: "var(--provider-openclaw)", label: "OpenClaw" },
"open-claw": { component: OpenClawIcon, color: "var(--provider-openclaw)", label: "OpenClaw" },
paperclip: { component: PaperclipIcon, color: "var(--provider-paperclip)", label: "Paperclip" },
paperclipai: { component: PaperclipIcon, color: "var(--provider-paperclip)", label: "Paperclip" },
"paperclip-ai": { component: PaperclipIcon, color: "var(--provider-paperclip)", label: "Paperclip" },
};
export function ProviderIcon({ provider, size = "sm" }: ProviderIconProps) {

View File

@@ -0,0 +1,162 @@
/**
* Shared visual shell for the three runtime-provider settings cards
* (Hermes, OpenClaw, Paperclip).
*
* Renders a consistent header (logo + name + status badge + Learn more link),
* a description block, a slot for connection-mode tabs, the form contents the
* caller passes as children, and a footer with Test/Save/Save & Test buttons
* plus a status toast that surfaces probe + save outcomes.
*/
import { ReactNode } from "react";
import { Loader2 } from "lucide-react";
export type RuntimeStatusKind = "neutral" | "ok" | "err" | "loading";
export interface RuntimeCardShellProps {
/** Name shown next to the logo (e.g. "Hermes"). */
name: string;
/** Optional sub-line under the name (e.g. "by Nous Research"). */
subname?: string;
/** The provider logo. Use a `<ProviderIcon>` or an inline `<img>` for brand SVGs. */
logo: ReactNode;
/** "Learn more" target — official documentation/site. */
learnMoreHref: string;
/** Status kind drives the badge color. */
statusKind: RuntimeStatusKind;
/** Status text (e.g. "✓ Detected v0.8.0"). */
statusText: string;
/** Description shown under the header. */
description: ReactNode;
/** Optional extra header chrome (e.g. tabs). Rendered above the form. */
tabs?: ReactNode;
/** The form fields. */
children: ReactNode;
/** Disabled state for the action row. */
busy: "loading" | "saving" | "testing" | "save-test" | null;
/** Disable Test if some required input is missing. */
canTest?: boolean;
/** Toast under the action row — shown when not null. */
toast?: { kind: "ok" | "err"; message: string } | null;
onTest: () => void;
onSave: () => void;
onSaveAndTest: () => void;
/** Optional extra content right above the footer (e.g. an install hint). */
belowForm?: ReactNode;
/** A test-id forwarded to the root element. */
testId?: string;
}
export function RuntimeCardShell(props: RuntimeCardShellProps) {
const {
name,
subname,
logo,
learnMoreHref,
statusKind,
statusText,
description,
tabs,
children,
busy,
canTest = true,
toast,
onTest,
onSave,
onSaveAndTest,
belowForm,
testId,
} = props;
const statusClass =
statusKind === "ok"
? "runtime-card__status runtime-card__status--ok"
: statusKind === "err"
? "runtime-card__status runtime-card__status--err"
: "runtime-card__status runtime-card__status--neutral";
return (
<div className="runtime-card" data-testid={testId} aria-live="polite">
<header className="runtime-card__header">
<span className="runtime-card__logo">{logo}</span>
<div className="runtime-card__title">
<h3 className="runtime-card__name">{name}</h3>
{subname && (
<small className="runtime-card__cobrand">{subname}</small>
)}
<small className={statusClass}>
{statusKind === "loading" && <Loader2 size={12} className="animate-spin" />}
{statusText}
</small>
</div>
<a
className="runtime-card__learn-more btn btn-sm btn-ghost"
href={learnMoreHref}
target="_blank"
rel="noreferrer"
>
Learn more →
</a>
</header>
<p className="runtime-card__description">{description}</p>
{tabs}
<div className="runtime-card__form">{children}</div>
{belowForm}
<footer className="runtime-card__footer">
{toast && (
<span
className={
toast.kind === "ok"
? "runtime-card__toast runtime-card__toast--ok"
: "runtime-card__toast runtime-card__toast--err"
}
role="status"
>
{toast.message}
</span>
)}
<button
type="button"
className="btn btn-sm"
onClick={onTest}
disabled={busy !== null || !canTest}
>
{busy === "testing" ? (
<>
<Loader2 size={12} className="animate-spin" /> Testing…
</>
) : (
"Test"
)}
</button>
<button
type="button"
className="btn btn-sm"
onClick={onSave}
disabled={busy !== null}
>
{busy === "saving" ? "Saving…" : "Save"}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={onSaveAndTest}
disabled={busy !== null || !canTest}
>
{busy === "save-test" ? (
<>
<Loader2 size={12} className="animate-spin" /> Saving…
</>
) : (
"Save & Test"
)}
</button>
</footer>
</div>
);
}

View File

@@ -17,6 +17,9 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { HermesRuntimeCard } from "./HermesRuntimeCard";
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
import { PaperclipRuntimeCard } from "./PaperclipRuntimeCard";
import { PluginSlot } from "./PluginSlot";
import { AgentPromptsManager } from "./AgentPromptsManager";
import { LoginInstructions } from "./LoginInstructions";
@@ -161,6 +164,12 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "global-models", label: "Models", scope: "global" },
{ id: "updates", label: "Updates", scope: "global" },
// Runtimes group (plugin runtimes with their own settings)
{ id: "__runtimes_header", label: "Runtimes", scope: undefined, isGroupHeader: true },
{ id: "hermes-runtime", label: "Hermes", scope: "global" },
{ id: "openclaw-runtime", label: "OpenClaw", scope: "global" },
{ id: "paperclip-runtime", label: "Paperclip", scope: "global" },
// Project group (specific to this project)
{ id: "__project_header", label: "Project", scope: undefined, isGroupHeader: true },
{ id: "general", label: "General", scope: "project" },
@@ -4276,6 +4285,27 @@ export function SettingsModal({
</>
);
}
case "hermes-runtime":
return (
<>
<h4 className="settings-section-heading">Hermes Runtime</h4>
<HermesRuntimeCard />
</>
);
case "openclaw-runtime":
return (
<>
<h4 className="settings-section-heading">OpenClaw Runtime</h4>
<OpenClawRuntimeCard />
</>
);
case "paperclip-runtime":
return (
<>
<h4 className="settings-section-heading">Paperclip Runtime</h4>
<PaperclipRuntimeCard />
</>
);
}
};
@@ -4320,7 +4350,7 @@ export function SettingsModal({
</div>
</div>
<div className="settings-header-actions">
{form.showGitHubStarButton !== false && !starClicked && (
{form.showGitHubStarButton !== false && (
<a
href="https://github.com/Runfusion/Fusion"
target="_blank"
@@ -4329,9 +4359,11 @@ export function SettingsModal({
aria-label="Star Fusion on GitHub"
title="Star Fusion on GitHub"
onClick={markStarClicked}
data-clicked={starClicked ? "true" : "false"}
>
<span className="settings-github-star-btn__action">
<Star size={13} aria-hidden="true" />
<ProviderIcon provider="github" size="sm" />
<Star size={11} aria-hidden="true" />
Star
</span>
{gitHubStarCount !== null && (

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -218,7 +218,176 @@ html {
--provider-cerebras: #f1592a;
--provider-groq: #f55036;
--provider-vercel: var(--text);
/* Runtime-plugin marks. */
--provider-hermes: #d4961c;
--provider-openclaw: #ff4f40;
--provider-paperclip: var(--text);
--provider-icon-contrast: var(--bg);
}
/* -------------------------------------------------------------------------
* Runtime provider settings card — unified layout used by Hermes / OpenClaw /
* Paperclip cards. Header (large logo + name + status), description, form,
* footer action row.
* ------------------------------------------------------------------------- */
.runtime-card {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.runtime-card__header {
display: flex;
align-items: center;
gap: var(--space-md);
}
.runtime-card__logo {
flex: 0 0 auto;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--provider-icon-color, currentColor) 8%, transparent);
}
.runtime-card__title {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.runtime-card__name {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.runtime-card__status {
font-size: 0.85rem;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.runtime-card__status--ok {
color: var(--accent-green, #22c55e);
}
.runtime-card__status--err {
color: var(--accent-red, #ef4444);
}
.runtime-card__status--neutral {
color: var(--text-muted);
}
.runtime-card__learn-more {
flex: 0 0 auto;
font-size: 0.85rem;
}
.runtime-card__description {
margin: 0;
color: var(--text-muted);
line-height: 1.5;
}
.runtime-card__form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.runtime-card__form .form-group {
margin: 0;
}
.runtime-card__tabs {
display: flex;
gap: var(--space-xxs, 2px);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-sm);
}
.runtime-card__tab {
appearance: none;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: var(--space-sm) var(--space-md);
cursor: pointer;
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
margin-bottom: -1px;
}
.runtime-card__tab:hover {
color: var(--text);
}
.runtime-card__tab[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent, #4f46e5);
}
.runtime-card__footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.runtime-card__toast {
flex: 1;
min-width: 0;
font-size: 0.85rem;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
}
.runtime-card__toast--ok {
background: color-mix(in srgb, var(--accent-green, #22c55e) 12%, transparent);
color: var(--accent-green, #22c55e);
}
.runtime-card__toast--err {
background: color-mix(in srgb, var(--accent-red, #ef4444) 12%, transparent);
color: var(--accent-red, #ef4444);
}
.runtime-card__cobrand {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.75rem;
color: var(--text-muted);
margin-top: var(--space-xxs, 2px);
}
.runtime-card__cobrand-mark {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: currentColor;
color: var(--text-muted);
border-radius: 2px;
flex: 0 0 auto;
/* Task-creation CTA tokens */
--cta-bg: #238636;

View File

@@ -49,6 +49,9 @@
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.36.4",
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
"@fusion/core": "workspace:*",
"@fusion/engine": "workspace:*",
"@mariozechner/pi-coding-agent": "^0.70.0",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -60,7 +60,7 @@ export async function probeClaudeCli(
};
let settled = false;
const child = spawn("claude", ["--version"], {
const child = spawn(binaryPath ?? "claude", ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
});

View File

@@ -60,6 +60,7 @@ import { registerProxyRoutes } from "./routes/register-proxy-routes.js";
import { registerModelRoutes } from "./routes/register-model-routes.js";
import { registerUsageRoutes } from "./routes/register-usage-routes.js";
import { registerAuthRoutes } from "./routes/register-auth-routes.js";
import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { runGitCommand } from "./routes/resolve-diff-base.js";
@@ -1407,6 +1408,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// ---------- Auth routes ----------
registerAuthRoutes(routeContext);
// ---------- Runtime-plugin probe routes (Hermes / OpenClaw / Paperclip) ----------
registerRuntimeProviderRoutes(routeContext);
/**
* POST /api/ai/refine-text
* AI-powered text refinement for task descriptions.

View File

@@ -0,0 +1,265 @@
import { ApiError, badRequest } from "../api-error.js";
import {
discoverPaperclipCli,
listHermesProviderProfiles,
listPaperclipCompanies,
listPaperclipCompanyAgents,
mintPaperclipKeyViaCli,
probeHermesProvider,
probeOpenClawProvider,
probePaperclipProvider,
} from "../runtime-provider-probes.js";
import type { ApiRouteRegistrar } from "./types.js";
/**
* Registers three read-only status probes for the runtime provider plugins:
*
* GET /providers/hermes/status
* GET /providers/openclaw/status
* GET /providers/paperclip/status
*
* These routes mirror the existing GET /providers/claude-cli/status pattern
* in register-auth-routes.ts. They intentionally do NOT require authentication
* because they are introspection endpoints consumed by the provider-card UI.
*/
export const registerRuntimeProviderRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx;
/**
* GET /providers/hermes/status
*
* Query params:
* binaryPath? — override the hermes binary path (default: "hermes")
*
* Response: { binary: HermesBinaryStatus, ready: boolean }
* ready === binary.available
*/
router.get("/providers/hermes/status", async (req, res) => {
try {
const binaryPath =
typeof req.query.binaryPath === "string" ? req.query.binaryPath : undefined;
const binary = await probeHermesProvider({ binaryPath });
res.json({ binary, ready: binary.available });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /providers/hermes/profiles
*
* Query params:
* binaryPath? — override the hermes binary path (default: "hermes")
*
* Response: { profiles: HermesProfileSummary[] }
* On any error: { profiles: [], error: "<reason>" } with HTTP 200.
*/
router.get("/providers/hermes/profiles", async (req, res) => {
const binaryPath =
typeof req.query.binaryPath === "string" ? req.query.binaryPath : undefined;
try {
const profiles = await listHermesProviderProfiles({ binaryPath });
res.json({ profiles });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.json({ profiles: [], error: message });
}
});
/**
* GET /providers/openclaw/status
*
* Query params:
* binaryPath? — override the openclaw binary path (default: "openclaw")
*
* Response: { binary: OpenClawBinaryStatus, ready: boolean }
* ready === binary.available
*/
router.get("/providers/openclaw/status", async (req, res) => {
try {
const binaryPath =
typeof req.query.binaryPath === "string" ? req.query.binaryPath : undefined;
const binary = await probeOpenClawProvider({ binaryPath });
res.json({ binary, ready: binary.available });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /providers/paperclip/status
*
* Query params:
* apiUrl — REQUIRED. The Paperclip server base URL (must start with http:// or https://).
* apiKey? — Optional API key forwarded to the probe.
*
* Response: { connection: PaperclipConnectionStatus, ready: boolean }
* ready === connection.available
*
* Returns 400 when apiUrl is missing or does not start with http:// or https://.
*/
router.get("/providers/paperclip/status", async (req, res) => {
try {
const rawApiUrl = req.query.apiUrl;
if (typeof rawApiUrl !== "string" || rawApiUrl.trim() === "") {
throw badRequest("Missing required query parameter: apiUrl");
}
const apiUrl = rawApiUrl.trim();
if (!apiUrl.startsWith("http://") && !apiUrl.startsWith("https://")) {
throw badRequest("Invalid apiUrl: must start with http:// or https://");
}
const apiKey =
typeof req.query.apiKey === "string" ? req.query.apiKey : undefined;
const connection = await probePaperclipProvider({ apiUrl, apiKey });
res.json({ connection, ready: connection.available });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /providers/paperclip/companies
*
* Query: apiUrl (required), apiKey?
* Returns: { companies: PaperclipCompanySummary[] } — never throws on
* upstream failure; an empty list signals "no companies visible".
*/
router.get("/providers/paperclip/companies", async (req, res) => {
try {
const apiUrl = readApiUrl(req.query.apiUrl);
const apiKey =
typeof req.query.apiKey === "string" ? req.query.apiKey : undefined;
const companies = await listPaperclipCompanies({ apiUrl, apiKey });
res.json({ companies });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /providers/paperclip/agents
*
* Query: apiUrl (required), companyId (required), apiKey?
* Returns: { agents: PaperclipAgentSummary[] } — empty list on upstream error.
*/
router.get("/providers/paperclip/agents", async (req, res) => {
try {
const apiUrl = readApiUrl(req.query.apiUrl);
const apiKey =
typeof req.query.apiKey === "string" ? req.query.apiKey : undefined;
const companyId =
typeof req.query.companyId === "string"
? req.query.companyId.trim()
: "";
if (!companyId) {
throw badRequest("Missing required query parameter: companyId");
}
const agents = await listPaperclipCompanyAgents({ apiUrl, apiKey, companyId });
res.json({ agents });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /providers/paperclip/cli-discovery
*
* Query: cliConfigPath?
* Returns the result of reading the local paperclipai config (apiUrl,
* deploymentMode, etc.). Never throws — failures are reported as
* `{ ok: false, reason }` so the card can render a clear error state.
*/
router.get("/providers/paperclip/cli-discovery", async (req, res) => {
try {
const cliConfigPath =
typeof req.query.cliConfigPath === "string"
? req.query.cliConfigPath
: undefined;
const result = await discoverPaperclipCli({ cliConfigPath });
res.json(result);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* POST /providers/paperclip/cli-mint-key
*
* Body: { agentRef (required), cliBinaryPath?, companyId?, keyName?, configPath?, dataDir? }
*
* Spawns `paperclipai agent local-cli <agentRef> --json --no-install-skills` to
* mint a fresh agent API key. Always responds HTTP 200; failures are reported as
* `{ ok: false, reason }` so the card can render an inline error.
*
* Returns 400 if `agentRef` is missing or empty.
*/
router.post("/providers/paperclip/cli-mint-key", async (req, res) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const agentRef =
typeof body.agentRef === "string" ? body.agentRef.trim() : "";
if (!agentRef) {
throw badRequest("Missing required body field: agentRef");
}
const cliBinaryPath =
typeof body.cliBinaryPath === "string" && body.cliBinaryPath.trim()
? body.cliBinaryPath.trim()
: undefined;
const companyId =
typeof body.companyId === "string" ? body.companyId.trim() : "";
if (!companyId) {
throw badRequest(
"Missing required body field: companyId (paperclipai agent local-cli requires -C/--company-id)",
);
}
const keyName =
typeof body.keyName === "string" && body.keyName.trim()
? body.keyName.trim()
: undefined;
const configPath =
typeof body.configPath === "string" && body.configPath.trim()
? body.configPath.trim()
: undefined;
const dataDir =
typeof body.dataDir === "string" && body.dataDir.trim()
? body.dataDir.trim()
: undefined;
const result = await mintPaperclipKeyViaCli({
agentRef,
cliBinaryPath,
companyId,
keyName,
configPath,
dataDir,
});
res.json(result);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
};
/**
* Validate the apiUrl query param (must be present + http(s)).
* Throws an ApiError(400) on failure.
*/
function readApiUrl(raw: unknown): string {
if (typeof raw !== "string" || raw.trim() === "") {
throw badRequest("Missing required query parameter: apiUrl");
}
const apiUrl = raw.trim();
if (!apiUrl.startsWith("http://") && !apiUrl.startsWith("https://")) {
throw badRequest("Invalid apiUrl: must start with http:// or https://");
}
return apiUrl;
}

View File

@@ -0,0 +1,158 @@
/**
* Thin façade over the three runtime-provider plugin probe functions.
*
* Each exported function delegates directly to the corresponding plugin's
* probe. The indirection exists so that:
* 1. Route handlers import from one stable internal module rather than
* reaching into plugin packages directly.
* 2. Tests can spy/mock at this module boundary without touching the
* plugin packages.
* 3. If a plugin package is somehow not installed, the error surfaces
* as an import-time TypeError with a clear message rather than a
* cryptic missing-module crash inside a route handler.
*/
import {
listHermesProfiles,
probeHermesBinary,
type HermesBinaryStatus,
type HermesProfileSummary,
} from "@fusion-plugin-examples/hermes-runtime";
import {
probeOpenClawBinary,
type OpenClawBinaryStatus,
} from "@fusion-plugin-examples/openclaw-runtime";
import {
agentsMe,
discoverPaperclipCliConfig,
listCompanies,
listCompanyAgents,
mintAgentApiKeyViaCli,
probePaperclipConnection,
type MintCliKeyOptions,
type MintedApiKey,
type PaperclipAgentSummary,
type PaperclipCliDiscoveryResult,
type PaperclipCompanySummary,
type PaperclipConnectionStatus,
} from "@fusion-plugin-examples/paperclip-runtime";
export type {
HermesBinaryStatus,
HermesProfileSummary,
MintCliKeyOptions,
MintedApiKey,
OpenClawBinaryStatus,
PaperclipAgentSummary,
PaperclipCliDiscoveryResult,
PaperclipCompanySummary,
PaperclipConnectionStatus,
};
export { mintAgentApiKeyViaCli };
/**
* Probe the local Hermes binary.
*
* Never throws — failures are reported as `available: false` with a reason
* field so HTTP handlers can render the provider card without try/catch.
*/
export async function probeHermesProvider(opts?: {
binaryPath?: string;
}): Promise<HermesBinaryStatus> {
return probeHermesBinary(opts);
}
/**
* List Hermes profiles via `hermes profile list`.
*
* Delegates directly to the plugin's listHermesProfiles.
* Callers are expected to handle errors; this function does not swallow them.
*/
export async function listHermesProviderProfiles(opts?: {
binaryPath?: string;
}): Promise<HermesProfileSummary[]> {
return listHermesProfiles(opts);
}
/**
* Probe the local OpenClaw binary.
*
* Never throws — failures are reported as `available: false` with a reason.
*/
export async function probeOpenClawProvider(opts?: {
binaryPath?: string;
}): Promise<OpenClawBinaryStatus> {
return probeOpenClawBinary(opts);
}
/**
* Probe a Paperclip server by its API URL.
*
* Never throws — failures are reported as `available: false` with a reason.
*/
export async function probePaperclipProvider(opts: {
apiUrl: string;
apiKey?: string;
}): Promise<PaperclipConnectionStatus> {
return probePaperclipConnection(opts);
}
/**
* List companies visible to the bearer. Falls back to the single company
* derived from `/api/agents/me` when `/api/companies` returns 403 (typical
* for agent-key-scoped requests in `authenticated` deployment mode).
*
* Always returns at least an empty array; never throws — failures degrade
* to an empty list so the UI can render a "no companies discovered" state.
*/
export async function listPaperclipCompanies(opts: {
apiUrl: string;
apiKey?: string;
}): Promise<PaperclipCompanySummary[]> {
// Prefer the broad listing if it works (board cookie / multi-company access).
try {
const cs = await listCompanies(opts.apiUrl, opts.apiKey);
if (cs.length > 0) return cs;
} catch {
// Fall through to /agents/me below.
}
// Fall back: agent keys can see their own company via /agents/me.
try {
const me = await agentsMe(opts.apiUrl, opts.apiKey);
return [{ id: me.companyId, name: me.companyName ?? me.companyId }];
} catch {
return [];
}
}
export async function listPaperclipCompanyAgents(opts: {
apiUrl: string;
apiKey?: string;
companyId: string;
}): Promise<PaperclipAgentSummary[]> {
return listCompanyAgents(opts.apiUrl, opts.apiKey, opts.companyId);
}
export async function discoverPaperclipCli(opts: {
cliConfigPath?: string;
}): Promise<PaperclipCliDiscoveryResult> {
return discoverPaperclipCliConfig({ configPath: opts.cliConfigPath });
}
/**
* Thin façade over `mintAgentApiKeyViaCli` that never throws.
* Returns `{ ok: true, key }` on success or `{ ok: false, reason }` on failure,
* so HTTP handlers and tests can destructure without try/catch.
*/
export async function mintPaperclipKeyViaCli(
opts: MintCliKeyOptions,
): Promise<{ ok: true; key: MintedApiKey } | { ok: false; reason: string }> {
try {
const key = await mintAgentApiKeyViaCli(opts);
return { ok: true, key };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
}