feat(FN-3093): address plugin manager responsive overflow

The merge fixes responsive overflow issues in the PluginManager component by adding 15 lines of CSS.

Fusion-Task-Id: FN-3093
This commit is contained in:
Fusion
2026-05-01 16:46:45 -07:00
committed by gsxdsm
parent 92d7c7bbb8
commit 76797f71cb
8 changed files with 614 additions and 17 deletions

View File

@@ -17,6 +17,10 @@ A comprehensive guide to creating Fusion plugins that extend the task board with
11. [Testing Plugins](#11-testing-plugins) 11. [Testing Plugins](#11-testing-plugins)
12. [Publishing Plugins](#12-publishing-plugins) 12. [Publishing Plugins](#12-publishing-plugins)
13. [Example Plugins](#13-example-plugins) 13. [Example Plugins](#13-example-plugins)
14. [Registering Skills](#14-registering-skills)
15. [Registering Workflow Steps](#15-registering-workflow-steps)
16. [Plugin Prompt Contributions](#16-plugin-prompt-contributions)
17. [Plugin Binary Setup Hooks](#17-plugin-binary-setup-hooks)
--- ---
@@ -1006,3 +1010,115 @@ export default definePlugin({
--- ---
For more information, see the [Plugin SDK Reference](../packages/plugin-sdk/src/index.ts). For more information, see the [Plugin SDK Reference](../packages/plugin-sdk/src/index.ts).
---
## 14. Registering Skills
Plugins can contribute reusable skills that are surfaced in agent sessions through Fusion's skill-selection flow.
```typescript
import type { PluginSkillContribution } from "@fusion/plugin-sdk";
const skills: PluginSkillContribution[] = [
{
skillId: "web-research",
name: "Web Research",
description: "Finds and summarizes web sources for a task",
skillFiles: ["skills/web-research/SKILL.md"],
enabled: true,
triggerPatterns: ["research", "search the web", "find sources"],
},
];
```
`skillFiles` are relative to the plugin root. `skillId` must be kebab-case.
## 15. Registering Workflow Steps
Plugins can ship workflow step templates that users can enable like built-in quality gates.
```typescript
import type { PluginWorkflowStepContribution } from "@fusion/plugin-sdk";
const workflowSteps: PluginWorkflowStepContribution[] = [
{
stepId: "strict-review",
name: "Strict Review",
description: "Run an AI review with strict failure criteria",
mode: "prompt",
phase: "pre-merge",
prompt: "Review this task for correctness, regressions, and missing tests.",
toolMode: "readonly",
defaultOn: true,
},
{
stepId: "smoke-build",
name: "Smoke Build",
description: "Build package before merge",
mode: "script",
scriptName: "build",
toolMode: "coding",
},
];
```
Use `mode: "prompt" | "script"` and `toolMode: "readonly" | "coding"`.
## 16. Plugin Prompt Contributions
Prompt contributions let a plugin inject additional instructions into specific prompt surfaces.
Supported surfaces:
- `executor-system`
- `executor-task`
- `triage`
- `reviewer`
- `heartbeat`
```typescript
import type { PluginPromptContributions } from "@fusion/plugin-sdk";
const promptContributions: PluginPromptContributions = {
enabledByDefault: false,
contributions: [
{
surface: "reviewer",
position: "append",
content: "Always call out missing tests and unsafe assumptions.",
condition: "Only for backend code changes",
},
],
};
```
Use `enabledByDefault: false` when contributions should require explicit opt-in.
## 17. Plugin Binary Setup Hooks
Plugins can expose setup metadata and lifecycle hooks for optional binaries or runtimes.
```typescript
import type { PluginSetupCheckResult, PluginSetupHooks, PluginSetupManifest } from "@fusion/plugin-sdk";
const setupManifest: PluginSetupManifest = {
binaryName: "agent-browser",
description: "Headless browser runtime for web-enabled agents",
channel: "stable",
defaultTimeoutMs: 120_000,
};
const setupHooks: PluginSetupHooks = {
async checkSetup(ctx): Promise<PluginSetupCheckResult> {
return { status: "not-installed" };
},
async install(ctx) {
// Use async process execution with timeout; never use execSync.
},
async uninstall(ctx) {
// Remove managed binary/runtime artifacts.
},
};
```
`checkSetup` is required. `install` and `uninstall` are optional.

View File

@@ -1,4 +1,13 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import type {
FusionPlugin,
PluginPromptContribution,
PluginPromptContributions,
PluginSetupHooks,
PluginSetupManifest,
PluginSkillContribution,
PluginWorkflowStepContribution,
} from "../plugin-types.js";
import { validatePluginManifest } from "../plugin-types.js"; import { validatePluginManifest } from "../plugin-types.js";
describe("validatePluginManifest", () => { describe("validatePluginManifest", () => {
@@ -991,3 +1000,187 @@ describe("PluginRuntimeRegistration", () => {
expect(typeof registration.factory).toBe("function"); expect(typeof registration.factory).toBe("function");
}); });
}); });
describe("plugin contribution types", () => {
it("accepts a minimal PluginSkillContribution shape", () => {
const skill: PluginSkillContribution = {
skillId: "browser-scan",
name: "Browser Scan",
description: "Scans web pages",
skillFiles: ["skills/browser/SKILL.md"],
};
expect(skill.skillId).toBe("browser-scan");
});
it("accepts a full PluginSkillContribution shape", () => {
const skill: PluginSkillContribution = {
skillId: "deep-research",
name: "Deep Research",
description: "Performs deep research tasks",
skillFiles: ["skills/research/SKILL.md", "skills/research/README.md"],
enabled: false,
triggerPatterns: ["research", "investigate"],
};
expect(skill.enabled).toBe(false);
expect(skill.triggerPatterns).toContain("research");
});
it("accepts prompt and script workflow step contributions", () => {
const promptStep: PluginWorkflowStepContribution = {
stepId: "quality-review",
name: "Quality Review",
description: "Ask reviewer agent to evaluate quality",
mode: "prompt",
prompt: "Review this change",
toolMode: "readonly",
};
const scriptStep: PluginWorkflowStepContribution = {
stepId: "run-tests",
name: "Run Tests",
description: "Run test suite",
mode: "script",
scriptName: "test",
toolMode: "coding",
phase: "post-merge",
};
expect(promptStep.mode).toBe("prompt");
expect(scriptStep.mode).toBe("script");
});
it("accepts all plugin prompt contribution surfaces", () => {
const contributions: PluginPromptContribution[] = [
{ surface: "executor-system", content: "executor system" },
{ surface: "executor-task", content: "executor task", position: "prepend" },
{ surface: "triage", content: "triage" },
{ surface: "reviewer", content: "reviewer" },
{ surface: "heartbeat", content: "heartbeat", condition: "only for heartbeat audits" },
];
expect(contributions).toHaveLength(5);
expect(contributions[1]?.position).toBe("prepend");
expect(contributions[4]?.condition).toContain("heartbeat");
});
it("accepts prompt contributions wrapper with optional enabledByDefault", () => {
const promptContributions: PluginPromptContributions = {
contributions: [{ surface: "triage", content: "Always gather constraints" }],
};
expect(promptContributions.enabledByDefault).toBeUndefined();
});
it("accepts setup manifest and hooks shapes", async () => {
const manifest: PluginSetupManifest = {
binaryName: "agent-browser",
description: "Headless browser runtime",
channel: "stable",
defaultTimeoutMs: 120000,
};
const hooks: PluginSetupHooks = {
checkSetup: async () => ({ status: "installed", version: "1.2.3", binaryPath: "/tmp/agent-browser" }),
install: async () => {},
uninstall: async () => {},
};
const result = await hooks.checkSetup({} as any);
expect(manifest.binaryName).toBe("agent-browser");
expect(result.status).toBe("installed");
});
it("accepts FusionPlugin with all new contribution types and remains backward compatible", () => {
const withContributions: FusionPlugin = {
manifest: { id: "full-plugin", name: "Full Plugin", version: "1.0.0" },
state: "installed",
hooks: {},
skills: [{ skillId: "web-tools", name: "Web Tools", description: "Web helper", skillFiles: ["skills/SKILL.md"] }],
workflowSteps: [{ stepId: "verify", name: "Verify", description: "Verify output", mode: "prompt", prompt: "verify" }],
promptContributions: {
enabledByDefault: false,
contributions: [{ surface: "reviewer", content: "Use strict review" }],
},
setup: {
manifest: { binaryName: "agent-browser", description: "Browser runtime" },
hooks: {
checkSetup: async () => ({ status: "not-installed" }),
},
},
};
const backwardCompatible: FusionPlugin = {
manifest: { id: "legacy-plugin", name: "Legacy Plugin", version: "1.0.0" },
state: "installed",
hooks: {},
};
expect(withContributions.skills?.[0]?.skillId).toBe("web-tools");
expect(backwardCompatible.skills).toBeUndefined();
});
});
describe("validatePluginManifest contribution metadata", () => {
it("accepts valid contribution metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
skills: [{ skillId: "web-reader", name: "Web Reader" }],
workflowSteps: [{ stepId: "quality-gate", name: "Quality Gate", mode: "prompt" }],
promptSurfaces: ["executor-system", "reviewer"],
setup: { binaryName: "agent-browser", description: "Browser runtime", channel: "beta" },
});
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("rejects invalid skill slug metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
skills: [{ skillId: "Bad Skill", name: "Skill" }],
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("skills[0].skillId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
});
it("rejects invalid workflow step mode metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
workflowSteps: [{ stepId: "quality-gate", name: "Quality Gate", mode: "invalid" }],
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("workflowSteps[0].mode must be one of: prompt, script");
});
it("rejects invalid prompt surfaces metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
promptSurfaces: ["invalid-surface"],
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("promptSurfaces[0] must be one of: executor-system, executor-task, triage, reviewer, heartbeat");
});
it("rejects incomplete setup metadata", () => {
const result = validatePluginManifest({
id: "plugin-a",
name: "Plugin A",
version: "1.0.0",
setup: { binaryName: "" },
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("setup.binaryName is required and must be a non-empty string");
expect(result.errors).toContain("setup.description is required and must be a non-empty string");
});
});

View File

@@ -145,6 +145,15 @@ export type {
PluginRuntimeRegistration, PluginRuntimeRegistration,
PluginContext, PluginContext,
PluginLogger, PluginLogger,
PluginSkillContribution,
PluginWorkflowStepContribution,
PluginPromptSurface,
PluginPromptContribution,
PluginPromptContributions,
PluginSetupStatus,
PluginSetupCheckResult,
PluginSetupHooks,
PluginSetupManifest,
FusionPlugin, FusionPlugin,
PluginState, PluginState,
PluginInstallation, PluginInstallation,

View File

@@ -12,6 +12,11 @@
*/ */
import type { TaskStore } from "./store.js"; import type { TaskStore } from "./store.js";
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const;
const SETUP_CHANNELS = ["stable", "beta", "nightly"] as const;
// ── Plugin Manifest ─────────────────────────────────────────────────── // ── Plugin Manifest ───────────────────────────────────────────────────
@@ -39,6 +44,14 @@ export interface PluginManifest {
settingsSchema?: Record<string, PluginSettingSchema>; settingsSchema?: Record<string, PluginSettingSchema>;
/** Optional agent runtime metadata for discovery (runtime factory is in FusionPlugin.runtime) */ /** Optional agent runtime metadata for discovery (runtime factory is in FusionPlugin.runtime) */
runtime?: PluginRuntimeManifestMetadata; runtime?: PluginRuntimeManifestMetadata;
/** Optional skill metadata used for discovery UIs. */
skills?: Array<{ skillId: string; name: string }>;
/** Optional workflow step metadata used for discovery UIs. */
workflowSteps?: Array<{ stepId: string; name: string }>;
/** Prompt surfaces this plugin contributes to. */
promptSurfaces?: PluginPromptSurface[];
/** Setup metadata for plugin-managed binaries/runtimes. */
setup?: PluginSetupManifest;
} }
// ── Plugin Setting Schema ────────────────────────────────────────────── // ── Plugin Setting Schema ──────────────────────────────────────────────
@@ -206,6 +219,122 @@ export interface PluginRuntimeRegistration {
factory: PluginRuntimeFactory; factory: PluginRuntimeFactory;
} }
// ── Plugin Contribution Types ───────────────────────────────────────
/**
* Plugin-contributed skill surfaced in agent sessions via the skill-selection system.
*/
export interface PluginSkillContribution {
/** Unique skill identifier within the plugin namespace (kebab-case). */
skillId: string;
/** Human-readable skill name. */
name: string;
/** What the skill does. */
description: string;
/** Paths (relative to plugin root) to SKILL.md or equivalent definitions. */
skillFiles: string[];
/** Whether this skill is enabled by default. Defaults to true. */
enabled?: boolean;
/** Optional keyword/pattern hints used by skill matching. */
triggerPatterns?: string[];
}
/**
* Workflow step template contributed by a plugin. These templates are
* materialized into concrete WorkflowStep instances when selected for a task.
*/
export interface PluginWorkflowStepContribution {
/** Unique step identifier within the plugin namespace (kebab-case). */
stepId: string;
/** Human-readable step name. */
name: string;
/** Short description for UI. */
description: string;
/** Execution mode, aligned with WorkflowStepMode. */
mode: WorkflowStepMode;
/** Task lifecycle phase where this step runs. Defaults to "pre-merge". */
phase?: "pre-merge" | "post-merge";
/** Prompt text used when mode is "prompt". */
prompt?: string;
/** Script name used when mode is "script". */
scriptName?: string;
/** Tool access level, aligned with WorkflowStepToolMode. */
toolMode?: WorkflowStepToolMode;
/** Whether this step is enabled by default. Defaults to true. */
enabled?: boolean;
/** Whether this step is auto-selected on new tasks. */
defaultOn?: boolean;
/** Optional model provider override for prompt steps. */
modelProvider?: string;
/** Optional model ID override for prompt steps. */
modelId?: string;
}
/**
* Prompt injection surfaces for plugin-contributed instructions.
* - executor-system: Appended to executor agent system prompt
* - executor-task: Injected into per-task execution context
* - triage: Appended to triage/planning prompts
* - reviewer: Appended to reviewer/validation prompts
* - heartbeat: Appended to heartbeat agent system prompts
*/
export type PluginPromptSurface = (typeof PROMPT_CONTRIBUTION_SURFACES)[number];
export interface PluginPromptContribution {
/** Which prompt surface this contribution targets. */
surface: PluginPromptSurface;
/** Prompt text to inject. */
content: string;
/** Position relative to existing prompt content. Defaults to "append". */
position?: "append" | "prepend";
/** Human-readable applicability description, reserved for future filtering. */
condition?: string;
}
export interface PluginPromptContributions {
contributions: PluginPromptContribution[];
/** Whether contributions are active by default. Defaults to false for safety. */
enabledByDefault?: boolean;
}
export type PluginSetupStatus = "not-installed" | "installing" | "installed" | "error";
export interface PluginSetupCheckResult {
status: PluginSetupStatus;
/** Installed version if available. */
version?: string;
/** Installed binary path if detected. */
binaryPath?: string;
/** Error details when status is "error". */
error?: string;
}
/**
* Plugin-managed setup hooks. All process execution in hooks MUST be async
* (never execSync) to avoid blocking the engine event loop.
*/
export interface PluginSetupHooks {
/** Check whether required binaries/runtimes are installed and ready. */
checkSetup: (ctx: PluginContext) => Promise<PluginSetupCheckResult>;
/** Install required binaries/runtimes. */
install?: (ctx: PluginContext) => Promise<void>;
/** Uninstall managed binaries/runtimes. */
uninstall?: (ctx: PluginContext) => Promise<void>;
}
export interface PluginSetupManifest {
/** Binary/runtime name being managed (e.g. "agent-browser"). */
binaryName: string;
/** What this binary/runtime provides. */
description: string;
/** Expected or pinned version. */
version?: string;
/** Installation channel. */
channel?: (typeof SETUP_CHANNELS)[number];
/** Timeout for setup/install commands. Defaults to 120000. */
defaultTimeoutMs?: number;
}
// ── Fusion Plugin ──────────────────────────────────────────────────── // ── Fusion Plugin ────────────────────────────────────────────────────
export type PluginState = "installed" | "started" | "stopped" | "error"; export type PluginState = "installed" | "started" | "stopped" | "error";
@@ -229,6 +358,17 @@ export interface FusionPlugin {
uiSlots?: PluginUiSlotDefinition[]; uiSlots?: PluginUiSlotDefinition[];
/** Agent runtime registration for providing custom runtime implementations */ /** Agent runtime registration for providing custom runtime implementations */
runtime?: PluginRuntimeRegistration; runtime?: PluginRuntimeRegistration;
/** Plugin-contributed skills surfaced by the skill resolver. */
skills?: PluginSkillContribution[];
/** Plugin-contributed workflow step templates. */
workflowSteps?: PluginWorkflowStepContribution[];
/** Plugin-contributed prompt injections. */
promptContributions?: PluginPromptContributions;
/** Plugin-managed setup metadata and lifecycle hooks. */
setup?: {
manifest: PluginSetupManifest;
hooks: PluginSetupHooks;
};
} }
// ── Plugin Installation ─────────────────────────────────────────────── // ── Plugin Installation ───────────────────────────────────────────────
@@ -281,7 +421,7 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
// Required fields // Required fields
if (!m.id || typeof m.id !== "string" || m.id.trim() === "") { if (!m.id || typeof m.id !== "string" || m.id.trim() === "") {
errors.push("id is required and must be a non-empty string"); errors.push("id is required and must be a non-empty string");
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(m.id)) { } else if (!SLUG_PATTERN.test(m.id)) {
errors.push("id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)"); errors.push("id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
} }
@@ -344,7 +484,7 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
// runtimeId is required // runtimeId is required
if (!runtime.runtimeId || typeof runtime.runtimeId !== "string" || runtime.runtimeId.trim() === "") { if (!runtime.runtimeId || typeof runtime.runtimeId !== "string" || runtime.runtimeId.trim() === "") {
errors.push("runtime.runtimeId is required and must be a non-empty string"); errors.push("runtime.runtimeId is required and must be a non-empty string");
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(runtime.runtimeId as string)) { } else if (!SLUG_PATTERN.test(runtime.runtimeId as string)) {
errors.push("runtime.runtimeId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)"); errors.push("runtime.runtimeId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
} }
@@ -364,12 +504,92 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
} }
} }
// Optional: plugin skill discovery metadata
if (m.skills !== undefined) {
if (!Array.isArray(m.skills)) {
errors.push("skills must be an array");
} else {
for (const [index, skill] of m.skills.entries()) {
if (!skill || typeof skill !== "object") {
errors.push(`skills[${index}] must be an object`);
continue;
}
const skillMeta = skill as Record<string, unknown>;
if (!skillMeta.skillId || typeof skillMeta.skillId !== "string" || skillMeta.skillId.trim() === "") {
errors.push(`skills[${index}].skillId is required and must be a non-empty string`);
} else if (!SLUG_PATTERN.test(skillMeta.skillId)) {
errors.push(`skills[${index}].skillId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`);
}
if (!skillMeta.name || typeof skillMeta.name !== "string" || skillMeta.name.trim() === "") {
errors.push(`skills[${index}].name is required and must be a non-empty string`);
}
}
}
}
// Optional: plugin workflow step discovery metadata
if (m.workflowSteps !== undefined) {
if (!Array.isArray(m.workflowSteps)) {
errors.push("workflowSteps must be an array");
} else {
for (const [index, step] of m.workflowSteps.entries()) {
if (!step || typeof step !== "object") {
errors.push(`workflowSteps[${index}] must be an object`);
continue;
}
const stepMeta = step as Record<string, unknown>;
if (!stepMeta.stepId || typeof stepMeta.stepId !== "string" || stepMeta.stepId.trim() === "") {
errors.push(`workflowSteps[${index}].stepId is required and must be a non-empty string`);
} else if (!SLUG_PATTERN.test(stepMeta.stepId)) {
errors.push(`workflowSteps[${index}].stepId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`);
}
if (!stepMeta.name || typeof stepMeta.name !== "string" || stepMeta.name.trim() === "") {
errors.push(`workflowSteps[${index}].name is required and must be a non-empty string`);
}
if (stepMeta.mode !== undefined && (typeof stepMeta.mode !== "string" || !["prompt", "script"].includes(stepMeta.mode))) {
errors.push(`workflowSteps[${index}].mode must be one of: prompt, script`);
}
}
}
}
// Optional: prompt surface metadata
if (m.promptSurfaces !== undefined) {
if (!Array.isArray(m.promptSurfaces)) {
errors.push("promptSurfaces must be an array");
} else {
for (const [index, surface] of m.promptSurfaces.entries()) {
if (typeof surface !== "string" || !PROMPT_CONTRIBUTION_SURFACES.includes(surface as PluginPromptSurface)) {
errors.push(`promptSurfaces[${index}] must be one of: ${PROMPT_CONTRIBUTION_SURFACES.join(", ")}`);
}
}
}
}
// Optional: setup manifest metadata
if (m.setup !== undefined) {
if (typeof m.setup !== "object" || m.setup === null) {
errors.push("setup must be an object");
} else {
const setup = m.setup as Record<string, unknown>;
if (!setup.binaryName || typeof setup.binaryName !== "string" || setup.binaryName.trim() === "") {
errors.push("setup.binaryName is required and must be a non-empty string");
}
if (!setup.description || typeof setup.description !== "string" || setup.description.trim() === "") {
errors.push("setup.description is required and must be a non-empty string");
}
if (setup.channel !== undefined && (typeof setup.channel !== "string" || !SETUP_CHANNELS.includes(setup.channel as (typeof SETUP_CHANNELS)[number]))) {
errors.push(`setup.channel must be one of: ${SETUP_CHANNELS.join(", ")}`);
}
if (setup.defaultTimeoutMs !== undefined && (typeof setup.defaultTimeoutMs !== "number" || !Number.isFinite(setup.defaultTimeoutMs) || setup.defaultTimeoutMs <= 0)) {
errors.push("setup.defaultTimeoutMs must be a positive finite number");
}
}
}
return { return {
valid: errors.length === 0, valid: errors.length === 0,
errors, errors,
}; };
} }
// ── Re-export Task type for hook signatures ───────────────────────────
// The Task type is used in hook signatures; we import it via types.js
import type { Task } from "./types.js";

View File

@@ -170,12 +170,19 @@
color: var(--text-muted); color: var(--text-muted);
} }
.plugin-homepage {
flex-wrap: wrap;
align-items: flex-start;
}
.plugin-homepage a { .plugin-homepage a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-xs); gap: var(--space-xs);
color: var(--color-info); color: var(--color-info);
font-size: 0.85rem; font-size: 0.85rem;
flex-wrap: wrap;
overflow-wrap: anywhere;
} }
.plugin-detail-section-heading { .plugin-detail-section-heading {
@@ -258,6 +265,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
row-gap: var(--space-xs);
flex-wrap: wrap;
min-width: 0; min-width: 0;
} }
@@ -312,6 +321,12 @@
text-transform: uppercase; text-transform: uppercase;
} }
@media (min-width: 769px) {
.plugin-bundled-runtime-status {
margin-left: auto;
}
}
.plugin-bundled-runtime-status--installed { .plugin-bundled-runtime-status--installed {
background: var(--status-done-bg); background: var(--status-done-bg);
color: var(--done); color: var(--done);
@@ -323,6 +338,11 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.plugin-manager,
.plugin-manager-detail {
padding-inline: var(--space-sm);
}
.plugin-manager-detail-header { .plugin-manager-detail-header {
gap: var(--space-sm); gap: var(--space-sm);
} }

View File

@@ -47,6 +47,12 @@ interface BundledRuntimePlugin {
} }
const BUNDLED_RUNTIME_PLUGINS: BundledRuntimePlugin[] = [ const BUNDLED_RUNTIME_PLUGINS: BundledRuntimePlugin[] = [
{
id: "fusion-plugin-agent-browser-runtime",
name: "Agent Browser Runtime",
path: "./plugins/fusion-plugin-agent-browser-runtime",
experimental: true,
},
{ {
id: "fusion-plugin-hermes-runtime", id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime", name: "Hermes Runtime",
@@ -351,6 +357,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</label> </label>
{schema.type === "string" && !schema.multiline && ( {schema.type === "string" && !schema.multiline && (
<input <input
className="input"
type="text" type="text"
id={`setting-${key}`} id={`setting-${key}`}
value={(pluginSettings[key] as string) ?? ""} value={(pluginSettings[key] as string) ?? ""}
@@ -361,6 +368,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)} )}
{schema.type === "string" && schema.multiline && ( {schema.type === "string" && schema.multiline && (
<textarea <textarea
className="input"
id={`setting-${key}`} id={`setting-${key}`}
rows={4} rows={4}
value={(pluginSettings[key] as string) ?? ""} value={(pluginSettings[key] as string) ?? ""}
@@ -371,6 +379,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)} )}
{schema.type === "password" && ( {schema.type === "password" && (
<input <input
className="input"
type="password" type="password"
id={`setting-${key}`} id={`setting-${key}`}
value={(pluginSettings[key] as string) ?? ""} value={(pluginSettings[key] as string) ?? ""}
@@ -381,6 +390,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)} )}
{schema.type === "number" && ( {schema.type === "number" && (
<input <input
className="input"
type="number" type="number"
id={`setting-${key}`} id={`setting-${key}`}
value={(pluginSettings[key] as number) ?? ""} value={(pluginSettings[key] as number) ?? ""}
@@ -400,6 +410,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)} )}
{schema.type === "enum" && ( {schema.type === "enum" && (
<select <select
className="select"
id={`setting-${key}`} id={`setting-${key}`}
value={(pluginSettings[key] as string) ?? ""} value={(pluginSettings[key] as string) ?? ""}
onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.value })} onChange={(e) => setPluginSettings({ ...pluginSettings, [key]: e.target.value })}
@@ -416,6 +427,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
{(pluginSettings[key] as unknown[] | undefined)?.map((item, index) => ( {(pluginSettings[key] as unknown[] | undefined)?.map((item, index) => (
<div key={index} className="plugin-settings-array-item"> <div key={index} className="plugin-settings-array-item">
<input <input
className="input"
type={schema.itemType === "number" ? "number" : "text"} type={schema.itemType === "number" ? "number" : "text"}
value={(item as string | number) ?? ""} value={(item as string | number) ?? ""}
onChange={(e) => { onChange={(e) => {
@@ -497,11 +509,11 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
} }
const installedPluginIds = new Set(plugins.map((plugin) => plugin.id)); const installedPluginIds = new Set(plugins.map((plugin) => plugin.id));
const installedPluginsById = new Map(plugins.map((plugin) => [plugin.id, plugin]));
// Bundled runtime plugin IDs — these are shown in the dedicated section below, // Keep bundled plugins in the main list once installed so users can always
// so we exclude them from the main plugin list to avoid duplication. // access enable/disable, settings, and uninstall controls.
const bundledPluginIds = new Set(BUNDLED_RUNTIME_PLUGINS.map((p) => p.id)); const installedPlugins = plugins;
const userInstalledPlugins = plugins.filter((p) => !bundledPluginIds.has(p.id));
const renderBundledRuntimeSection = () => ( const renderBundledRuntimeSection = () => (
<section className="plugin-bundled-runtime-section" aria-label="Bundled Runtime Plugins"> <section className="plugin-bundled-runtime-section" aria-label="Bundled Runtime Plugins">
@@ -529,11 +541,20 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</div> </div>
<button <button
className={`btn ${isInstalled ? "btn-secondary" : "btn-primary"} btn-sm`} className={`btn ${isInstalled ? "btn-secondary" : "btn-primary"} btn-sm`}
onClick={() => handleInstallBundledRuntimePlugin(bundledPlugin)} onClick={() => {
disabled={isInstalled || installingBundledPluginId === bundledPlugin.id} if (isInstalled) {
const installedPlugin = installedPluginsById.get(bundledPlugin.id);
if (installedPlugin) {
void handleSelectPlugin(installedPlugin);
}
return;
}
void handleInstallBundledRuntimePlugin(bundledPlugin);
}}
disabled={installingBundledPluginId === bundledPlugin.id}
> >
{isInstalled {isInstalled
? "Installed" ? "Manage"
: installingBundledPluginId === bundledPlugin.id : installingBundledPluginId === bundledPlugin.id
? "Installing..." ? "Installing..."
: `Install ${bundledPlugin.name}`} : `Install ${bundledPlugin.name}`}
@@ -592,7 +613,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<div className="settings-empty-state">Loading plugins...</div> <div className="settings-empty-state">Loading plugins...</div>
) : ( ) : (
<> <>
{userInstalledPlugins.length === 0 ? ( {installedPlugins.length === 0 ? (
<div className="settings-empty-state"> <div className="settings-empty-state">
<Package size={32} className="text-muted" /> <Package size={32} className="text-muted" />
<p>No plugins installed.</p> <p>No plugins installed.</p>
@@ -600,7 +621,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</div> </div>
) : ( ) : (
<div className="plugin-list"> <div className="plugin-list">
{userInstalledPlugins.map((plugin) => ( {installedPlugins.map((plugin) => (
<div key={plugin.id} className="plugin-item"> <div key={plugin.id} className="plugin-item">
<div className="plugin-info"> <div className="plugin-info">
<span className="plugin-name">{plugin.name}</span> <span className="plugin-name">{plugin.name}</span>

View File

@@ -226,6 +226,7 @@ describe("PluginManager", () => {
}); });
expect(screen.getByText("No plugins installed.")).toBeTruthy(); expect(screen.getByText("No plugins installed.")).toBeTruthy();
expect(screen.getByText("Agent Browser Runtime")).toBeTruthy();
expect(screen.getByText("Hermes Runtime")).toBeTruthy(); expect(screen.getByText("Hermes Runtime")).toBeTruthy();
expect(screen.getByText("Paperclip Runtime")).toBeTruthy(); expect(screen.getByText("Paperclip Runtime")).toBeTruthy();
expect(screen.getByText("OpenClaw Runtime")).toBeTruthy(); expect(screen.getByText("OpenClaw Runtime")).toBeTruthy();
@@ -503,7 +504,7 @@ describe("PluginManager", () => {
}); });
}); });
it("shows installed status and disables bundled runtime install button when already installed", async () => { it("keeps bundled plugin manageable after install", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([ vi.mocked(fetchPlugins).mockResolvedValueOnce([
{ {
...mockPlugins[0], ...mockPlugins[0],
@@ -521,9 +522,17 @@ describe("PluginManager", () => {
const hermesCard = screen.getByText("Hermes Runtime").closest(".plugin-bundled-runtime-item"); const hermesCard = screen.getByText("Hermes Runtime").closest(".plugin-bundled-runtime-item");
expect(hermesCard).toBeTruthy(); expect(hermesCard).toBeTruthy();
const installButton = within(hermesCard as HTMLElement).getByRole("button", { name: /^Installed$/i }); const manageButton = within(hermesCard as HTMLElement).getByRole("button", { name: /^Manage$/i });
expect(installButton).toBeDisabled(); expect(manageButton).not.toBeDisabled();
expect(within(hermesCard as HTMLElement).getAllByText("Installed").length).toBeGreaterThanOrEqual(1); expect(within(hermesCard as HTMLElement).getAllByText("Installed").length).toBeGreaterThanOrEqual(1);
await userEvent.click(manageButton);
await waitFor(() => {
expect(fetchPluginSettings).toHaveBeenCalledWith("fusion-plugin-hermes-runtime", undefined);
});
expect(screen.getByTestId("plugin-manager-detail")).toBeTruthy();
}); });
describe("SSE Live Updates", () => { describe("SSE Live Updates", () => {

View File

@@ -54,6 +54,15 @@ export type {
PluginRuntimeRegistration, PluginRuntimeRegistration,
PluginContext, PluginContext,
PluginLogger, PluginLogger,
PluginSkillContribution,
PluginWorkflowStepContribution,
PluginPromptSurface,
PluginPromptContribution,
PluginPromptContributions,
PluginSetupStatus,
PluginSetupCheckResult,
PluginSetupHooks,
PluginSetupManifest,
FusionPlugin, FusionPlugin,
PluginState, PluginState,
PluginInstallation, PluginInstallation,