## Summary Gives external integrations (command palettes, plugin launchers, alternate dashboard shells) a supported way to **discover the host UI** — instead of hardcoding the dashboard's view ids, labels and settings search terms and hand-syncing them on every release. This is the read-only metadata slice of the "constrained by a stable host context and API client" idea in `docs/proposals/2026-07-01-dashboard-theme-plugin-system.md`, and the follow-on to #2415 (theme tokens + overlay layering). Two additions, both inert unless called: | Endpoint | Returns | |---|---| | `GET /api/views` | Every registered built-in view id, in dashboard order — `id`, English `label`, plus optional i18n `labelKey`, legacy `aliases` and `internal` flag. | | `GET /api/settings/sections` | Selectable Settings sections — `id`, `label`, `labelKey`, `scope`, `group`, `keywords`, `searchableKeys`, `advanced`. | Both are read-only, return static project-independent metadata, take no project id, and are mounted inside `createApiRoutes` so they sit behind exactly the same `/api` authentication as every other dashboard route — no more, no less. ## What actually changed — one source of truth The endpoints are the small part. The core of the diff is **collapsing duplicated UI metadata into two shared registries that now drive both the dashboard UI and the API**: - `packages/dashboard/src/shared/dashboard-views.ts` — canonical view ids + English labels + i18n keys + legacy aliases. - `packages/dashboard/src/shared/settings-sections.ts` — canonical settings sections + scope/group/search metadata, with `group` and `advanced` derived from the list's own structure. `LeftSidebarNav`, `SettingsModal` and `useViewState` were rewritten to consume those registries instead of carrying their own copies (net **−230 lines** in `SettingsModal` alone). Edit the registry and the rendered UI and the API move together. ## Drift protection Being precise about what each test can and cannot catch, because "no-drift" claims are easy to overstate: - `left-sidebar-nav-registry-parity.test.tsx` — the one test that catches drift the registry does not already determine. It **renders** the sidebar with a recording `t()` spy and pins each entry's translation key and English fallback to the registry (the sidebar still hardcodes its keys). It also asserts the rendered destination count equals the enrolled id list, so a newly added sidebar view fails until it is enrolled. - `ui-metadata-sync.test.ts` — pins the Settings navigation list, advanced-visibility set, persisted view list, reset-key registry and both endpoint payloads to the registries. Since those consumers are now *derived* from the registries, these assertions mainly guard against a future consumer **re-hardcoding** its own copy. Two of them do stand on their own: each section's served `group` is pinned to the group header it actually renders under, and no published `labelKey` may resolve to a non-leaf i18n node. - `register-ui-metadata-routes.test.ts` — drives the real Express router and asserts each endpoint serves the registry payload verbatim, with no filtering or reshaping. - Exactly two **existing** tests are updated, both for the same reason: they asserted that `SettingsModal.tsx`'s *source text* contains a section literal that now lives in the registry. `VoiceInputSection.modal-visibility.test.tsx` now asserts Voice Input's Basic-mode contract against `SETTINGS_SECTION_METADATA`, and `mcp-documentation.test.ts` reads the registry for the two MCP section ids. No other existing test in the package changes. ## Design notes / decisions for review - **`GET /api/views` returns the full registry, not the live menu.** It includes flag-gated / experimental ids and `internal` (non-navigable) destinations; reachability depends on flags and plugins this endpoint does not evaluate. Documented as "known view ids", not "visible nav entries". - **`labelKey` is optional and best-effort; `label` is the guarantee.** A `labelKey` is published only where the dashboard itself renders that view's title through it. `graph` (labelled from a plugin manifest) and the internal `task-detail` carry none rather than advertise a key that resolves to nothing — and `task-detail` in particular must not point at `taskDetail.title`, which is an occupied i18n *namespace* whose lookup returns an object rather than falling through to a default. A guard test now enforces that. Separately, a few published keys (`nav.ideation`, `nav.importTasks`, `nav.automations`, `pr.view.title`) are the dashboard's real keys but aren't in the shipped catalogs yet because the host supplies their English inline; the docs say plainly that consumers must fall back to `label`. - **`keywords` / `searchableKeys` are explicitly non-contractual.** `searchableKeys` exposes the raw i18n translation-key strings backing a section's searchable copy; values, ordering and presence may change between releases. Documented as best-effort search hints, never stable identifiers. - **Migration is deliberately partial.** The desktop sidebar, Settings navigation and persisted view list now come from the registries; `Header.tsx` and the mobile More sheet still hardcode a few of the same labels. They can still drift from what `GET /api/views` reports; converting them is left to a follow-up so this diff stays reviewable. - **No project scoping, deliberately.** The proposal doc rightly pushes plugin traffic through a project-scoped client — these two endpoints are the exception that proves the rule: they return static registry metadata that is identical for every project, so threading a `projectId` would imply a scoping guarantee that does not exist here. They never touch `getScopedStore` / `TaskStore`. - **Two endpoints rather than one `/api/ui-metadata` envelope.** Views and Settings sections are independent registries with different consumers, and `/settings/sections` sits naturally beside the existing `/settings/*` routes. A consumer that only needs navigation doesn't pay for settings metadata. - **The registry extraction ships with the endpoints rather than as a separate PR.** The registries *are* the mechanism that keeps the API honest — split apart, the first half is a refactor with no observable effect and the second can't land without it. - **Placement:** `packages/dashboard/src/shared/` is a new directory, and these are the first *production* `app/ → src/` imports in the package (today the only one is in `ProviderIcon.test.tsx`). They sit under `src/` because `src/`'s tsconfig cannot import `app/`, so a module both sides consume has nowhere else to go; both registries are dependency-free data leaves, and `vite build` plus `check-no-node-only-core-imports-in-dashboard` confirm the client bundle is unaffected. The considered alternative was `packages/core/src` behind the `dashboard-browser-safe-core-modules.json` allowlist, where `mobile-nav-primary-items.ts` keeps a destination→labelKey table — these stayed out of `core` because they are dashboard-owned UI ids, and because the two tables describe different surfaces (core mirrors the mobile nav's `nav.skills`/`nav.settings`; this registry mirrors the desktop sidebar's `header.skillsView`/`header.settings`). - Ships a `@runfusion/fusion` **minor** changeset (`category: feature`). Happy to adjust any of the above — shape, placement, or dropping `searchableKeys` — if you'd rather it landed differently. ## Verification - Rebased onto `main@26dcccb7c`. Two conflicts, both resolved by absorbing upstream's work rather than reverting it: - `SettingsModal.tsx` — upstream's `voice-input` section (and the `FNXC:VoiceInput` decision comment explaining it stays out of the advanced-only set) moved into the registry. The registry's section list is byte-identical to `main`'s `SETTINGS_SECTIONS` (45/45 entries, all fields), and the registry-derived `ADVANCED_SETTINGS_SECTION_IDS` is identical to `main`'s hardcoded set (19/19, same order) — both verified mechanically, not by eye. Upstream's `RUNTIME_*` hide-uninstalled-runtimes sets are untouched. - `routes/README.md` — the `mount-sequence` list regenerated from `CREATE_API_ROUTES_REGISTRAR_MOUNT_SEQUENCE`, so `registerVoiceRoutes` and `registerUiMetadataRoutes` are both in place and the contract test passes. - `DASHBOARD_VIEWS` covers exactly `main`'s `BuiltInTaskView` union, aliases included, and `BUILT_IN_TASK_VIEWS` reproduces `main`'s 27-entry array in order (`devserver` still preceding `dev-server` for the migration path). - Every one of the 20 sidebar labels the refactor rewrote was checked to be byte-identical to `main`'s hardcoded fallback, and every `FNXC:` decision comment displaced by the move was accounted for — all 75 in `SettingsModal.tsx` and all 11 in `useViewState.ts` survive, relocated onto the registry entries they document. - The full `dashboard-app` + `dashboard-api` suites were run at this commit (**20,706 passing**) and again on unmodified `main@26dcccb7c`, and the failing-file sets compared: **every file that fails here also fails on `main`** — nothing regresses. The overlap is environment-driven (Postgres-backed `*.pg.test.ts`, tests needing built `dist` artifacts, and `SettingsModalNodeRouting.test.tsx`'s `No "fetchSystemInfo" export is defined on the "../../api" mock`), none of it touched by this change. - `tsc --noEmit` clean for both dashboard projects, `eslint` clean on every changed file, and `vite build` of the client bundle succeeds (the two pre-existing `@fusion-plugin-examples/claude-runtime` / `playwright-core` module-resolution errors reproduce on unmodified `main`). - Repo gate scripts pass: `check-changeset-format`, `check-routes-modular`, `check-no-node-only-core-imports-in-dashboard`, `check-no-cwd-relative-dashboard-test-reads`, `check-mock-completeness`. - The three new assertions were mutation-tested rather than assumed load-bearing: breaking the registry's `group` derivation, dropping an enrolled sidebar id, and re-pointing `task-detail` at the `taskDetail.title` namespace each make their test fail. - Local CodeRabbit review over two passes: 3 minor findings, all addressed (parity projection missing `group`; route tests asserting partial instead of exact payloads; the `labelKey` guard not covering the settings registry). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added authenticated, read-only APIs for discovering dashboard views and selectable Settings sections. * Added dashboard view metadata, including labels, aliases, internal status, and translation keys. * Added Settings metadata with grouping, scope, advanced status, and search-related information. * Updated navigation and Settings UI labels to use shared metadata. * **Documentation** * Documented the new metadata endpoints and integration guidance. * **Bug Fixes** * Added safeguards and automated checks to keep UI navigation and API metadata synchronized. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude <noreply@anthropic.com>
2007 lines
82 KiB
Markdown
2007 lines
82 KiB
Markdown
# Plugin Authoring Guide
|
|
|
|
A comprehensive guide to creating Fusion plugins that extend the task board with custom tools, routes, and lifecycle hooks.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Getting Started](#1-getting-started)
|
|
2. [Plugin Manifest Reference](#2-plugin-manifest-reference)
|
|
3. [Plugin Settings Schema](#3-plugin-settings-schema)
|
|
4. [Available Hooks and Signatures](#4-available-hooks-and-signatures)
|
|
5. [Registering Tools](#5-registering-tools)
|
|
6. [Registering Routes](#6-registering-routes)
|
|
7. [Registering UI Slots](#7-registering-ui-slots)
|
|
8. [Registering Top-Level Dashboard Views](#8-registering-top-level-dashboard-views)
|
|
- [Theming & Overlay Layering for Dashboard Views](#theming--overlay-layering-for-dashboard-views)
|
|
9. [Registering Agent Runtimes](#9-registering-agent-runtimes)
|
|
10. [Plugin Context API Reference](#10-plugin-context-api-reference)
|
|
11. [Plugin Lifecycle States](#11-plugin-lifecycle-states)
|
|
12. [Testing Plugins](#12-testing-plugins)
|
|
13. [Publishing Plugins](#13-publishing-plugins)
|
|
14. [Example Plugins](#14-example-plugins)
|
|
15. [Registering Skills](#15-registering-skills)
|
|
16. [Registering Workflow Steps](#16-registering-workflow-steps)
|
|
17. [Contributing Prompt Modifications](#17-contributing-prompt-modifications)
|
|
18. [Plugin Binary Setup Hooks](#18-plugin-binary-setup-hooks)
|
|
|
|
---
|
|
|
|
See also: [External Plugin Authoring guide](./plugins/external-authoring.md)
|
|
|
|
## 1. Getting Started
|
|
|
|
### What Are Fusion Plugins?
|
|
|
|
Fusion plugins extend the task board with custom functionality:
|
|
|
|
- **Lifecycle Hooks**: React to task creation, movement, completion, and errors
|
|
- **AI Agent Tools**: Add custom tools that AI agents can use during task execution
|
|
- **Custom API Routes**: Create dashboard API endpoints for frontend integration
|
|
- **Settings**: Accept user configuration via typed settings schemas
|
|
|
|
### Prerequisites
|
|
|
|
- Node.js 18+
|
|
- TypeScript familiarity
|
|
- A Fusion project with the plugin system installed
|
|
|
|
### Quick Start
|
|
|
|
External authors should use the standalone scaffold and dev loop (see the [External Plugin Authoring guide](./plugins/external-authoring.md)):
|
|
|
|
```bash
|
|
fn plugin new my-first-plugin
|
|
cd my-first-plugin
|
|
pnpm install
|
|
fn plugin dev .
|
|
pnpm test
|
|
```
|
|
|
|
The legacy `fn plugin create` scaffold remains available for workspace-bound examples.
|
|
|
|
### Optional AI Security Scan (Opt-in)
|
|
|
|
Plugin installs now support an opt-in `aiScanOnLoad` flag. When enabled, Fusion runs an AI security review before loading plugin code.
|
|
|
|
- **Opt-in:** disabled by default (`aiScanOnLoad: false`)
|
|
- **When it runs:** on plugin load/reload and explicit rescan
|
|
- **Scan inputs (deterministic order):** `manifest.json`, optional `package.json`, optional `README.md`, entry module, then prioritized source files
|
|
- **Boundaries:** excludes `node_modules`, `dist`, lockfiles, binary assets, files over 20 KB each, and enforces a 120 KB total raw-content cap
|
|
|
|
### Scan Verdicts
|
|
|
|
- `clean` — no concerning patterns found
|
|
- `warning` — suspicious patterns found; plugin may still load
|
|
- `blocked` — dangerous patterns found; plugin is blocked before import
|
|
- `error` — scan failed to produce a valid decision
|
|
- `unavailable` — AI scan service unavailable
|
|
|
|
When a plugin is blocked (`blocked`/`error`/`unavailable`), Fusion does **not** execute plugin code for that load attempt and stores the scan result on plugin metadata (`lastSecurityScan`) for operator visibility.
|
|
|
|
### Author Guidance for Blocked Plugins
|
|
|
|
If your plugin is blocked:
|
|
- remove dynamic execution patterns (`eval`, shell-outs, hidden network exfiltration behavior)
|
|
- keep behavior explicit in source and manifest
|
|
- document external calls and sensitive operations in README
|
|
- ask operators to run `fn plugin rescan <id>` after publishing fixes
|
|
|
|
### Signature Verification and Publisher Trust
|
|
|
|
Fusion supports deterministic detached-signature verification for plugin provenance before plugin code is loaded.
|
|
|
|
Expected plugin files at the plugin root:
|
|
- `manifest.json`
|
|
- `plugin-publisher.json` (publisher identity + public key metadata)
|
|
- `plugin-signature.json` (detached signature over canonical payload)
|
|
|
|
Canonical payload inputs (sorted, deterministic):
|
|
- normalized `manifest.json`
|
|
- publisher metadata from `plugin-publisher.json`
|
|
- declared file digest map (sorted by relative path)
|
|
|
|
Verification statuses:
|
|
- `trusted-local` — bundled/in-repo plugin path trusted by local policy exception
|
|
- `verified-trusted` — signature verifies and publisher key is trusted
|
|
- `verified-untrusted` — signature verifies but publisher/key is not trusted yet
|
|
- `unsigned` — no signature bundle present
|
|
- `invalid` — signature or digest validation failed (tampered/corrupt)
|
|
|
|
Trust decisions are explicit and keyed by publisher ID + key fingerprint. Manifest author/homepage strings are informational only and are never used as trust proof.
|
|
|
|
### Plugin Author Checklist for Signed Releases
|
|
|
|
1. Produce deterministic file digests for distributed plugin files
|
|
2. Publish `plugin-publisher.json` with stable publisher ID and key fingerprint
|
|
3. Sign the canonical payload and ship `plugin-signature.json`
|
|
4. Keep digest/signature files in source control and release artifacts
|
|
5. In release notes, include publisher ID + key fingerprint so operators can verify trust prompts
|
|
|
|
### Plugin Project Structure
|
|
|
|
```
|
|
my-plugin/
|
|
├── package.json # Plugin metadata + "fusion-plugin" keyword
|
|
├── tsconfig.json # TypeScript configuration
|
|
├── vitest.config.ts # Test configuration
|
|
├── src/
|
|
│ ├── index.ts # Plugin entry point (exports default FusionPlugin)
|
|
│ └── __tests__/
|
|
│ └── index.test.ts # Plugin tests
|
|
└── README.md # Plugin documentation
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Plugin Manifest Reference
|
|
|
|
The manifest defines your plugin's metadata and capabilities:
|
|
|
|
```typescript
|
|
import type { PluginManifest } from "@fusion/plugin-sdk";
|
|
|
|
const manifest: PluginManifest = {
|
|
id: "my-custom-plugin", // Unique identifier (kebab-case)
|
|
name: "My Custom Plugin", // Human-readable name
|
|
version: "1.0.0", // Semver version
|
|
description: "Does something useful",
|
|
author: "Your Name",
|
|
homepage: "https://github.com/you/plugin",
|
|
fusionVersion: ">=1.0.0", // Optional: minimum Fusion version
|
|
dependencies: [], // Optional: plugin IDs this depends on
|
|
settingsSchema: { /* ... */ }, // Optional: configuration schema
|
|
runtime: { // Optional: agent runtime metadata
|
|
runtimeId: "code-interpreter",
|
|
name: "Code Interpreter",
|
|
description: "Executes code in a sandbox",
|
|
},
|
|
};
|
|
```
|
|
|
|
### Fields
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `id` | string | Yes | Unique identifier (kebab-case, validated) |
|
|
| `name` | string | Yes | Human-readable display name |
|
|
| `version` | string | Yes | Semver version (e.g., "1.0.0") |
|
|
| `description` | string | No | Short description |
|
|
| `author` | string | No | Author name or organization |
|
|
| `homepage` | string | No | URL to documentation or repository |
|
|
| `fusionVersion` | string | No | Minimum Fusion version required |
|
|
| `dependencies` | string[] | No | IDs of plugins this depends on |
|
|
| `settingsSchema` | Record<string, PluginSettingSchema> | No | Configuration schema |
|
|
| `runtime` | PluginRuntimeManifestMetadata | No | Agent runtime metadata for discovery |
|
|
|
|
---
|
|
|
|
## 3. Plugin Settings Schema
|
|
|
|
Settings allow users to configure your plugin through the dashboard:
|
|
|
|
```typescript
|
|
import type { PluginSettingSchema } from "@fusion/plugin-sdk";
|
|
|
|
const settingsSchema: Record<string, PluginSettingSchema> = {
|
|
webhookUrl: {
|
|
type: "string",
|
|
label: "Webhook URL",
|
|
description: "URL to send notifications to",
|
|
required: true,
|
|
},
|
|
maxRetries: {
|
|
type: "number",
|
|
label: "Max Retries",
|
|
description: "Maximum number of retry attempts",
|
|
defaultValue: 3,
|
|
},
|
|
enabled: {
|
|
type: "boolean",
|
|
label: "Enable Feature",
|
|
description: "Toggle the feature on/off",
|
|
defaultValue: true,
|
|
},
|
|
severity: {
|
|
type: "enum",
|
|
label: "Log Severity",
|
|
description: "Minimum severity level to log",
|
|
enumValues: ["debug", "info", "warn", "error"],
|
|
defaultValue: "info",
|
|
},
|
|
};
|
|
```
|
|
|
|
### Setting Types
|
|
|
|
Common optional fields on all setting types:
|
|
|
|
- `group?: string` — Optional heading used by the dashboard to render settings in grouped sections (for example: `"General"`, `"Browser"`, `"Prompt Contributions"`, `"Skills"`). Ungrouped settings still render first in their existing flat order, then grouped sections render under stable headings.
|
|
- `description?: string` — Helper text shown below the setting label.
|
|
- `required?: boolean` — Marks the field as required.
|
|
- `defaultValue?: unknown` — Default value used when no user value is provided.
|
|
|
|
| Type | Description | Extra Fields |
|
|
|------|-------------|--------------|
|
|
| `"string"` | Text input | `multiline?: boolean` (renders textarea) |
|
|
| `"number"` | Numeric input | — |
|
|
| `"boolean"` | Toggle switch | — |
|
|
| `"enum"` | Dropdown select | `enumValues: string[]` |
|
|
| `"password"` | Password input (hidden) | — |
|
|
| `"array"` | Dynamic list with add/remove | `itemType: "string" \| "number"` |
|
|
|
|
### Example: All Setting Types
|
|
|
|
```typescript
|
|
const settingsSchema: Record<string, PluginSettingSchema> = {
|
|
// Simple string input
|
|
username: {
|
|
type: "string",
|
|
label: "Username",
|
|
description: "Your username",
|
|
},
|
|
|
|
// Multiline text area
|
|
message: {
|
|
type: "string",
|
|
label: "Message",
|
|
description: "Multi-line message",
|
|
multiline: true,
|
|
defaultValue: "Hello!",
|
|
},
|
|
|
|
// Password input (hidden)
|
|
apiSecret: {
|
|
type: "password",
|
|
label: "API Secret",
|
|
description: "Your secret key",
|
|
},
|
|
|
|
// Number input
|
|
maxRetries: {
|
|
type: "number",
|
|
label: "Max Retries",
|
|
defaultValue: 3,
|
|
},
|
|
|
|
// Boolean toggle
|
|
enabled: {
|
|
type: "boolean",
|
|
label: "Enable Feature",
|
|
group: "General",
|
|
defaultValue: true,
|
|
},
|
|
|
|
// Dropdown select
|
|
severity: {
|
|
type: "enum",
|
|
label: "Severity",
|
|
enumValues: ["debug", "info", "warn", "error"],
|
|
defaultValue: "info",
|
|
},
|
|
|
|
// Array of strings
|
|
tags: {
|
|
type: "array",
|
|
label: "Tags",
|
|
description: "Tags to track",
|
|
group: "Skills",
|
|
itemType: "string",
|
|
defaultValue: ["bug", "feature"],
|
|
},
|
|
|
|
// Array of numbers
|
|
thresholds: {
|
|
type: "array",
|
|
label: "Thresholds",
|
|
itemType: "number",
|
|
defaultValue: [10, 20, 30],
|
|
},
|
|
};
|
|
```
|
|
|
|
### Accessing Settings
|
|
|
|
Settings are available in hooks via `ctx.settings`:
|
|
|
|
```typescript
|
|
hooks: {
|
|
onLoad: (ctx) => {
|
|
const webhookUrl = ctx.settings.webhookUrl as string;
|
|
if (!webhookUrl) {
|
|
ctx.logger.warn("No webhook URL configured");
|
|
}
|
|
},
|
|
},
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Available Hooks and Signatures
|
|
|
|
Hooks let your plugin react to events in the Fusion system:
|
|
|
|
```typescript
|
|
import type { FusionPlugin, PluginContext } from "@fusion/plugin-sdk";
|
|
|
|
const plugin: FusionPlugin = {
|
|
manifest: { /* ... */ },
|
|
state: "installed",
|
|
hooks: {
|
|
onLoad: async (ctx) => {
|
|
ctx.logger.info("Plugin loaded!");
|
|
},
|
|
onTaskCreated: async (task, ctx) => {
|
|
ctx.logger.info(`New task: ${task.title}`);
|
|
},
|
|
// ... other hooks
|
|
},
|
|
};
|
|
```
|
|
|
|
### Hook Reference
|
|
|
|
| Hook | Signature | When It Fires |
|
|
|------|-----------|---------------|
|
|
| `onLoad` | `(ctx: PluginContext) => Promise<void> \| void` | Plugin first loaded and started |
|
|
| `onUnload` | `(ctx: PluginContext) => Promise<void> \| void` | Plugin stopped/shutdown |
|
|
| `onTaskCreated` | `(task: Task, ctx: PluginContext) => Promise<void> \| void` | New task created |
|
|
| `onTaskMoved` | `(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> \| void` | Task moved between columns |
|
|
| `onTaskCompleted` | `(task: Task, ctx: PluginContext) => Promise<void> \| void` | Task reached "done" |
|
|
| `onError` | `(error: Error, ctx: PluginContext) => Promise<void> \| void` | Error occurred in plugin execution |
|
|
| `onPostgresSchemaInit` | `() => PluginPostgresSchemaDefinition` | Before `onLoad`; Fusion validates and applies the declarative plan with a short-lived migration connection |
|
|
| `onSchemaInit` (legacy) | `(db: Database) => Promise<void> \| void` | SQLite-only compatibility declaration; unsupported for third-party plugins in the PostgreSQL runtime |
|
|
| `executorRuntimeEnv` | `(taskCtx: ExecutorRuntimeTaskContext, ctx: PluginContext) => Promise<ExecutorRuntimeEnvContribution> \| ExecutorRuntimeEnvContribution` | Before executor-spawned task commands run, to contribute task-scoped env and PATH prepends |
|
|
|
|
### Hook Behavior
|
|
|
|
- **Single-load lifecycle**: For one project in one Fusion process, Fusion invokes `onLoad` exactly once for each intentional load lifecycle, including when the host and engine bootstrap concurrently. Plugin authors do not need process-local locking to defend against an accidental second host/engine startup load. Explicit enable→load and `reloadPlugin` are new lifecycles and can invoke `onLoad` again after unload; request-scoped temporary loaders for *another* project root may also load and then stop a plugin while discovering skills. Keep registration idempotent where inexpensive so these intentional lifecycles remain safe.
|
|
- **Context parity**: `onUnload` receives the same `PluginContext` shape as `onLoad`.
|
|
- **Timeout**: 5 seconds per invocation (logged and skipped if exceeded)
|
|
- **Error Isolation**: Hook failures never block other hooks or abort startup
|
|
- **Optional**: Only define the hooks you need
|
|
- **Schema preflight**: `onPostgresSchemaInit` is evaluated and validated before the plugin is marked started or `onLoad` runs. An invalid or SQLite-only third-party schema fails without leaving `onLoad` subscriptions or timers behind.
|
|
- **No privileged handle**: The hook returns data and receives no database object. Fusion alone opens the short-lived migration connection; ordinary plugin hooks and routes continue to use the project-bound forced-RLS runtime role.
|
|
- **Allowed DDL**: Return one idempotent `CREATE TABLE IF NOT EXISTS`, `CREATE [UNIQUE] INDEX IF NOT EXISTS`, or `ALTER TABLE` statement per array item. Every object must live in the `project` schema and every referenced table must begin with the declared `tablePrefix`. Semicolon-separated batches and data-changing/admin statements are rejected.
|
|
- **Required ownership**: Every created table declares `project_id text NOT NULL` and a `project_id`-leading composite primary key. Fusion installs the column default, ownership trigger, forced RLS policy, and runtime grants after creation. Composite foreign keys should include `project_id` on both sides.
|
|
- **Schema evolution**: Increment `version` when the declarative plan changes. Statements remain idempotent and must safely rerun during restart or hot reload. Avoid data backfills or long-running logic.
|
|
- **Legacy cutover**: Third-party `onSchemaInit(Database)` hooks are no longer executable in the PostgreSQL runtime. Bundled plugins retain host-owned PostgreSQL equivalents during migration, but external plugins must provide `onPostgresSchemaInit`.
|
|
|
|
### Example: Schema initialization hook
|
|
|
|
```typescript
|
|
hooks: {
|
|
onPostgresSchemaInit: () => ({
|
|
version: 1,
|
|
tablePrefix: "acme_",
|
|
statements: [
|
|
`CREATE TABLE IF NOT EXISTS project.acme_roadmaps (
|
|
project_id text NOT NULL,
|
|
id text NOT NULL,
|
|
title text NOT NULL,
|
|
created_at text NOT NULL,
|
|
PRIMARY KEY (project_id, id)
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_acme_roadmaps_created_at
|
|
ON project.acme_roadmaps(project_id, created_at)`,
|
|
],
|
|
}),
|
|
},
|
|
```
|
|
|
|
### `executorRuntimeEnv`: task-scoped executor subprocess environment
|
|
|
|
Use `executorRuntimeEnv` when your plugin needs to provide runtime environment values for **executor-spawned user commands** tied to a specific task.
|
|
|
|
This hook runs when Fusion prepares subprocess environments for executor command surfaces such as configured commands, verification commands, and step-session subprocesses.
|
|
|
|
It does **not** apply to internal git plumbing subprocesses used by worktree/branch management.
|
|
|
|
```typescript
|
|
import type {
|
|
ExecutorRuntimeEnvContribution,
|
|
ExecutorRuntimeTaskContext,
|
|
PluginContext,
|
|
} from "@fusion/plugin-sdk";
|
|
|
|
function executorRuntimeEnv(
|
|
taskCtx: ExecutorRuntimeTaskContext,
|
|
ctx: PluginContext,
|
|
): ExecutorRuntimeEnvContribution {
|
|
ctx.logger.info(`Preparing env for task ${taskCtx.taskId}`);
|
|
return {
|
|
pathPrepend: ["/absolute/path/to/tools"],
|
|
env: {
|
|
MY_PLUGIN_TASK_ID: taskCtx.taskId,
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
`ExecutorRuntimeTaskContext` fields:
|
|
|
|
- `taskId`: Fusion task ID
|
|
- `worktreePath`: absolute path to the task worktree
|
|
- `rootDir`: project root directory
|
|
- `branch?`: task branch name when available
|
|
|
|
`ExecutorRuntimeEnvContribution` fields:
|
|
|
|
- `pathPrepend?`: array of **absolute** path strings prepended to `PATH` for executor-spawned commands
|
|
- `env?`: key/value string map merged into the subprocess environment
|
|
- `description?`: optional human-readable note for debugging/telemetry
|
|
|
|
Validation and merge behavior (from engine runtime collection):
|
|
|
|
- `pathPrepend` must be an array of absolute path strings; non-absolute entries are rejected.
|
|
- `env` values must be strings.
|
|
- `env` must not include `PATH`; use `pathPrepend` instead.
|
|
- When multiple plugins set the same `env` key, later plugins override earlier values and the engine logs a warning.
|
|
- `pathPrepend` entries from later plugins are placed earlier in the final prepend list.
|
|
|
|
### Example: prepend a generated tool directory for executor commands
|
|
|
|
```typescript
|
|
import { definePlugin } from "@fusion/plugin-sdk";
|
|
import path from "node:path";
|
|
|
|
export default definePlugin({
|
|
manifest: {
|
|
id: "fusion-plugin-tooling-example",
|
|
name: "Tooling Example",
|
|
version: "1.0.0",
|
|
},
|
|
state: "installed",
|
|
hooks: {},
|
|
executorRuntimeEnv: (taskCtx) => {
|
|
const toolDir = path.resolve(taskCtx.rootDir, ".fusion/tools/my-plugin/bin");
|
|
|
|
return {
|
|
pathPrepend: [toolDir],
|
|
env: {
|
|
MY_PLUGIN_TOOL_HOME: toolDir,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
```
|
|
|
|
With this hook enabled, executor-spawned commands (for example verification commands or `bash` tool subprocesses in the task session) can resolve binaries from `toolDir` without mutating global process environment.
|
|
|
|
### Example: Notification on Task Completion
|
|
|
|
```typescript
|
|
hooks: {
|
|
onTaskCompleted: async (task, ctx) => {
|
|
const webhookUrl = ctx.settings.webhookUrl as string;
|
|
if (!webhookUrl) return;
|
|
|
|
await fetch(webhookUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
text: `✅ Task completed: ${task.title || task.id}`,
|
|
}),
|
|
});
|
|
},
|
|
},
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Registering Tools
|
|
|
|
Tools extend AI agents with custom capabilities:
|
|
|
|
```typescript
|
|
import type { FusionPlugin, PluginToolDefinition, PluginToolResult } from "@fusion/plugin-sdk";
|
|
|
|
const myTool: PluginToolDefinition = {
|
|
name: "my_custom_tool",
|
|
description: "Does something useful with input text",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
input: {
|
|
type: "string",
|
|
description: "The text to process",
|
|
},
|
|
},
|
|
required: ["input"],
|
|
},
|
|
execute: async (params, ctx) => {
|
|
const input = params.input as string;
|
|
|
|
// Do something useful...
|
|
const result = input.toUpperCase();
|
|
|
|
return {
|
|
content: [{ type: "text", text: result }],
|
|
};
|
|
},
|
|
};
|
|
|
|
const plugin: FusionPlugin = {
|
|
manifest: { /* ... */ },
|
|
state: "installed",
|
|
tools: [myTool],
|
|
};
|
|
```
|
|
|
|
### Tool Naming
|
|
|
|
- Use a unique name prefixed with your plugin ID (e.g., `my-plugin_action`)
|
|
- Avoid conflicts with built-in tools
|
|
|
|
### Tool Result Format
|
|
|
|
```typescript
|
|
interface PluginToolResult {
|
|
content: Array<{ type: "text"; text: string }>;
|
|
isError?: boolean;
|
|
details?: Record<string, unknown>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Registering Routes
|
|
|
|
Routes create custom API endpoints in the dashboard:
|
|
|
|
```typescript
|
|
import type { FusionPlugin, PluginRouteDefinition } from "@fusion/plugin-sdk";
|
|
|
|
const routes: PluginRouteDefinition[] = [
|
|
{
|
|
method: "GET",
|
|
path: "/status",
|
|
description: "Get plugin status",
|
|
handler: async (req, ctx) => {
|
|
return { status: "ok", uptime: process.uptime() };
|
|
},
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/action",
|
|
description: "Perform an action",
|
|
handler: async (req, ctx) => {
|
|
// Access request body
|
|
const body = req as { action?: string };
|
|
ctx.logger.info(`Action: ${body.action}`);
|
|
return { success: true };
|
|
},
|
|
},
|
|
];
|
|
|
|
const plugin: FusionPlugin = {
|
|
manifest: { /* ... */ },
|
|
state: "installed",
|
|
routes,
|
|
};
|
|
```
|
|
|
|
Route handlers may return either plain JSON values or a `PluginRouteResponse` envelope. `PluginRouteResponse` now supports optional `headers` and `contentType` fields so plugins can serve non-JSON payloads like downloadable HTML. Example: return `{ status: 200, body: html, contentType: "text/html; charset=utf-8", headers: { "Content-Disposition": "attachment; filename=\"report.html\"" } }` to send an attachment response directly from a plugin route.
|
|
|
|
### Route Mounting
|
|
|
|
Routes are mounted at `/api/plugins/{pluginId}/{path}`.
|
|
Route handlers receive the same loader-built `PluginContext` used by hooks/tools, including real `taskStore`, plugin `settings`, `logger`, `emitEvent`, and engine-injected `createAiSession` (when available):
|
|
|
|
- Example roadmap plugin route: `path: "/roadmaps"` in plugin `fusion-plugin-roadmap` resolves to `/api/plugins/fusion-plugin-roadmap/roadmaps`
|
|
- Roadmap suggestion endpoints follow the same namespace (for example `/api/plugins/fusion-plugin-roadmap/roadmaps/:roadmapId/suggestions/milestones`)
|
|
- Do not document or depend on legacy host-owned `/api/roadmaps` routes unless your current source still ships them
|
|
|
|
- Plugin ID: `fusion-plugin-notification`
|
|
- Route path: `/status`
|
|
- Full URL: `/api/plugins/fusion-plugin-notification/status`
|
|
|
|
### UI metadata endpoints
|
|
|
|
<!-- FNXC:UiMetadataApi 2026-07-14-00:00: Frontend plugins and command palettes must discover the host's registered view ids and Settings metadata through authenticated, read-only APIs instead of hardcoding dashboard-owned ids, labels, or search terms. -->
|
|
|
|
Frontend integrations can enumerate the host UI before presenting navigation or settings commands. Both endpoints inherit the dashboard's standard `/api` authentication, are read-only, return static project-independent metadata, and do not require a project id.
|
|
|
|
`GET /api/views` returns every registered built-in view id in dashboard order. This is the full registry, not a live navigation menu: it includes ids that are flag-gated or experimental (for example `graph`, `todos`, `secrets`), and internal, non-navigable destinations (see `internal` below). Whether any given id is currently reachable in the UI depends on feature flags and plugins the endpoint does not evaluate, so treat the list as the set of known view ids rather than a guaranteed set of visible navigation entries.
|
|
|
|
```json
|
|
{
|
|
"views": [
|
|
{ "id": "board", "label": "Board", "labelKey": "nav.board" },
|
|
{ "id": "command-center", "label": "Dashboard", "labelKey": "nav.commandCenter" },
|
|
{
|
|
"id": "dev-server",
|
|
"label": "Dev Server",
|
|
"labelKey": "nav.devServer",
|
|
"aliases": ["devserver"]
|
|
},
|
|
{
|
|
"id": "task-detail",
|
|
"label": "Task Detail",
|
|
"internal": true
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`aliases` lists accepted legacy ids; use the canonical `id` for new links. `internal: true` identifies a programmatic destination that is not a normal navigation entry. Optional fields are omitted when they do not apply.
|
|
|
|
`label` is the guaranteed English display string. `labelKey` is present only for views whose title the dashboard itself renders through that translation key, so a few ids (for example `graph`, whose label comes from a plugin manifest, and the internal `task-detail`) carry no `labelKey` at all — fall back to `label` when it is absent. Note also that some keys are not yet present in the shipped translation catalogs because the dashboard supplies their English text inline; resolving such a key yields nothing, so treat `labelKey` as best-effort localization support rather than a guaranteed lookup.
|
|
|
|
`GET /api/settings/sections` returns selectable Settings sections; non-selectable group-header rows are excluded:
|
|
|
|
```json
|
|
{
|
|
"sections": [
|
|
{
|
|
"id": "appearance",
|
|
"label": "Appearance",
|
|
"labelKey": "settings.nav.appearance",
|
|
"scope": "global",
|
|
"group": "Preferences",
|
|
"keywords": ["theme", "color", "sidebar"],
|
|
"searchableKeys": [],
|
|
"advanced": false
|
|
},
|
|
{
|
|
"id": "authentication",
|
|
"label": "Authentication",
|
|
"labelKey": "settings.nav.authentication",
|
|
"scope": null,
|
|
"group": "AI & Models",
|
|
"keywords": ["login", "OAuth", "API key"],
|
|
"searchableKeys": [],
|
|
"advanced": false
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`scope` is `global`, `project`, or `null` for sections backed by a dedicated subsystem rather than a settings blob. `group` is the Settings navigation group label, `keywords` and `searchableKeys` support command/search matching, and `advanced` indicates whether the dashboard hides the section until Advanced settings are enabled.
|
|
|
|
`keywords` and `searchableKeys` are best-effort, non-contractual search hints, not a stable API surface. `searchableKeys` in particular exposes raw i18n translation-key strings that back a section's searchable copy; their exact values, ordering, and presence may change between releases as the dashboard's internal translation keys evolve. Use them to widen local search matching, but do not treat any specific key string as a stable identifier or depend on it programmatically.
|
|
|
|
### Supported Methods
|
|
|
|
- `GET`
|
|
- `POST`
|
|
- `PUT`
|
|
- `DELETE`
|
|
|
|
---
|
|
|
|
## 7. Registering UI Slots
|
|
|
|
UI slots are mount points in the Fusion dashboard where plugins can inject custom UI components. Each slot is identified by a unique `slotId` that corresponds to a specific location in the dashboard UI.
|
|
|
|
### How UI Slots Work
|
|
|
|
Plugins declare `uiSlots` in their `FusionPlugin` definition. The dashboard discovers all registered UI slots via `GET /api/plugins/ui-slots` and renders matching components at each mount point.
|
|
|
|
### Available Slot IDs
|
|
|
|
| Slot ID | Location | Description | Status |
|
|
|---------|----------|-------------|--------|
|
|
| `task-detail-tab` | Task detail modal | Tab added to the task detail view | Available |
|
|
| `header-action` | Dashboard header | Action button in the header toolbar | Available |
|
|
| `settings-section` | Settings modal | Section added to the settings panel | Available |
|
|
| `settings-provider-card` | Settings → Authentication | Provider card contribution in Authentication section | Available |
|
|
| `onboarding-provider-card` | Onboarding modal → AI setup | Provider card content rendered before host fallback cards | Available |
|
|
| `onboarding-setup-help` | Onboarding modal → AI setup | Additional setup-help content rendered below provider sections | Available |
|
|
| `post-onboarding-recommendation` | Dashboard post-onboarding card | Recommendation item rendered in host-owned next-steps container | Available |
|
|
| `settings-integration-card` | Legacy structured name | Compatibility alias now normalized to `settings-config-section` in structured API | Compatibility only |
|
|
| `onboarding-recommendation-card` | Legacy structured name | Compatibility alias now normalized to `onboarding-provider-recommendation` in structured API | Compatibility only |
|
|
| `task-card-badge` | Task card on the board | Small badge displayed on task cards (e.g., CI status indicator) | Planned |
|
|
| `board-column-footer` | Board column | Footer area below the last card in a column | Planned |
|
|
|
|
### Defining UI Slots
|
|
|
|
Add `uiSlots` to your `FusionPlugin` definition:
|
|
|
|
```typescript
|
|
import type { FusionPlugin, PluginUiSlotDefinition } from "@fusion/plugin-sdk";
|
|
|
|
const uiSlots: PluginUiSlotDefinition[] = [
|
|
{
|
|
slotId: "task-detail-tab",
|
|
label: "CI History",
|
|
icon: "history",
|
|
componentPath: "./components/ci-tab.js",
|
|
},
|
|
{
|
|
slotId: "task-card-badge",
|
|
label: "CI Status",
|
|
icon: "circle-check",
|
|
componentPath: "./components/ci-badge.js",
|
|
},
|
|
];
|
|
|
|
const plugin: FusionPlugin = {
|
|
manifest: { /* ... */ },
|
|
state: "installed",
|
|
uiSlots,
|
|
hooks: { /* ... */ },
|
|
};
|
|
```
|
|
|
|
### PluginUiSlotDefinition Fields
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `slotId` | `string` | Yes | One of the known slot IDs (e.g., "task-detail-tab", "header-action") |
|
|
| `label` | `string` | Yes | Human-readable label for the slot |
|
|
| `icon` | `string` | No | Lucide icon name for visual identification |
|
|
| `componentPath` | `string` | Yes | Path to the JS module exporting the component, relative to the plugin root |
|
|
|
|
### Component Module Format and Host Resolution
|
|
|
|
`componentPath` is part of the plugin contract, but dashboard rendering is intentionally host-resolved through a **static slot registry** (`pluginId + slotId + componentPath`).
|
|
|
|
Important implications:
|
|
|
|
- The dashboard does **not** load arbitrary plugin modules at runtime from `componentPath`.
|
|
- To render in dashboard flows, your slot entry must match a host-registered mapping.
|
|
- Unknown/unmapped entries degrade safely to a visible “missing component” shell (or render nothing when the host sets `renderPlaceholder={false}`).
|
|
- Host flows still own modal structure, navigation, callbacks, and fallback content when no slot entry exists.
|
|
|
|
For this reason, plugin authors should still provide stable `componentPath` values in manifests, but coordinate with dashboard host maintainers when adding new UI surfaces or module paths that need mapping.
|
|
|
|
### Structured UI Contributions (data-only, parallel to `uiSlots`)
|
|
|
|
For Settings/onboarding/post-onboarding flows, use structured `uiContributions` instead of legacy placeholder slots.
|
|
|
|
- Discovery API: `GET /api/plugins/ui-contributions`
|
|
- Type surface: `PluginUiContributionDefinition` (`@fusion/plugin-sdk`)
|
|
- Structured surfaces:
|
|
- `settings-provider-card`
|
|
- `settings-config-section`
|
|
- `onboarding-provider-card`
|
|
- `onboarding-setup-help`
|
|
- `onboarding-provider-recommendation`
|
|
- `post-onboarding-recommendation`
|
|
|
|
Rules:
|
|
- Structured contributions are **data-only JSON payloads**.
|
|
- Do **not** include `componentPath`.
|
|
- Do **not** send live callbacks/functions through REST.
|
|
- Use `actions: PluginUiActionDescriptor[]` so host-owned renderers bind behavior.
|
|
|
|
Compatibility normalization:
|
|
- `settings-integration-card` → `settings-config-section`
|
|
- `onboarding-recommendation-card` → `onboarding-provider-recommendation`
|
|
|
|
The API only returns normalized surface names.
|
|
|
|
---
|
|
|
|
## 8. Registering Top-Level Dashboard Views
|
|
|
|
Top-level views are a **sibling contribution type** to `uiSlots`.
|
|
|
|
- `uiSlots` are embedded surfaces (task detail tab, header action, etc.)
|
|
- `dashboardViews` is the shipped top-level plugin field for full-screen dashboard destinations
|
|
- Earlier planning language may say `views`; the implemented API in `FusionPlugin` is `dashboardViews`
|
|
|
|
Register `dashboardViews` on the plugin definition:
|
|
|
|
```ts
|
|
import type { PluginDashboardViewDefinition } from "@fusion/plugin-sdk";
|
|
|
|
const dashboardViews: PluginDashboardViewDefinition[] = [
|
|
{
|
|
viewId: "graph",
|
|
label: "Graph",
|
|
componentPath: "./src/DependencyGraphView.tsx",
|
|
icon: "Network",
|
|
order: 40,
|
|
placement: "overflow",
|
|
description: "Explore task dependency links",
|
|
},
|
|
];
|
|
```
|
|
|
|
### PluginDashboardViewDefinition fields
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `viewId` | `string` | Yes | Unique slug-like ID within your plugin namespace |
|
|
| `label` | `string` | Yes | Human-readable nav label |
|
|
| `componentPath` | `string` | Yes | Module path for the view component, relative to plugin root |
|
|
| `icon` | `string` | No | Lucide icon name |
|
|
| `order` | `number` | No | Lower values appear earlier in nav |
|
|
| `placement` | `"primary" \| "overflow" \| "more"` | No | Navigation placement hint (default is host-defined overflow behavior) |
|
|
| `description` | `string` | No | Short summary for nav/help surfaces |
|
|
|
|
Current host constraints:
|
|
- Discovery API: `GET /api/plugins/dashboard-views`
|
|
- The dashboard **does not eval or filesystem-load plugin code in-browser**
|
|
- `componentPath` is stored for authoring symmetry/future expansion, but render resolution is currently done through a host-side static registry (`pluginId + viewId`)
|
|
- Use stable IDs; runtime view key format is `plugin:${pluginId}:${viewId}`
|
|
|
|
### Static host registry model
|
|
|
|
Dashboard view components are resolved from a host-side registry and must be explicitly registered:
|
|
|
|
```ts
|
|
import { lazy } from "react";
|
|
import { registerPluginView } from "../app/plugins/pluginViewRegistry";
|
|
|
|
registerPluginView(
|
|
"fusion-plugin-dependency-graph",
|
|
"graph",
|
|
lazy(() => import("@fusion-plugin-examples/dependency-graph/dashboard-view")),
|
|
);
|
|
```
|
|
|
|
The host then renders plugin views via `PluginDashboardViewHost` using the composite ID.
|
|
|
|
Bundled workspace plugin pattern:
|
|
- Keep plugin package under `plugins/` (for example `plugins/fusion-plugin-roadmap`)
|
|
- Export backend/plugin entry from `src/index.ts` and keep dashboard view exports in the plugin package (for example `./dashboard-view`)
|
|
- Do not re-export dashboard view entrypoints (including `dashboard-view`, `manage-view`, or other `-view` modules) from `src/index.ts`; the `fusion/no-plugin-view-reexport` ESLint guard catches violations because server-side plugin entries must not transitively load CSS
|
|
- Register the lazy dashboard component in host code (currently `packages/dashboard/app/plugins/registerBundledPluginViews.ts`)
|
|
- CLI bundling inlines backend plugin code from workspace packages; dashboard view modules are imported by the dashboard build via the host registry
|
|
|
|
<!-- FNXC:BundledPlugins 2026-07-13-00:00: FN-7936 requires `@runfusion/fusion` bundled plugin backend outputs to be install-self-contained. The CLI bundler resolves plugin-sdk runtime re-exports from `@fusion/core` through `plugin-sdk-core-runtime-shim.ts`, so `packages/cli/dist/plugins/<id>/bundled.js` must not ship private `@fusion/*` runtime specifiers that npm installs cannot resolve. -->
|
|
Bundled `bundled.js` outputs must be self-contained at runtime. Do not leave private workspace package imports such as `@fusion/core` in emitted bundled plugin code; `@fusion/plugin-sdk` core runtime re-exports are resolved through the CLI runtime shim during packaging.
|
|
|
|
<!-- FNXC:BundledPlugins 2026-07-14-12:00: FN-7955 requires runtime-read bundled plugin assets to be self-contained too. esbuild only inlines statically imported code, so package-local files read from disk, including Compound Engineering `src/skills/<id>/SKILL.md` bodies, must be copied by `bundlePluginEntry()` into the matching `packages/cli/dist/plugins/<id>/skills/` tree before publishing. -->
|
|
If a bundled plugin reads package-local files at runtime, stage those assets explicitly during CLI packaging. `bundlePluginEntry()` copies any committed `src/skills/` directory into `dist/plugins/<id>/skills/`; use the same pattern for similar runtime-read assets instead of assuming esbuild will include files that are never imported.
|
|
|
|
### Bundled plugin build-freshness guard
|
|
|
|
<!-- FNXC:BundledPlugins 2026-06-17-22:31: Bundled plugins can load gitignored compiled artifacts before source during workspace/dev resolution, so plugin authors need a documented recovery path when the generic freshness guard detects stale dist output. -->
|
|
|
|
Bundled plugins shipped in `@runfusion/fusion` are tracked by the staged bundled-plugin set in `packages/cli/src/plugins/staged-bundled-plugin-ids.ts`; the default auto-install subset remains `BUNDLED_PLUGIN_IDS` in `packages/cli/src/plugins/bundled-plugin-install.ts`. The CLI build asserts every staged bundled plugin has a loadable entry under `packages/cli/dist/plugins/<id>/`, and the freshness test checks any per-plugin `plugins/<id>/dist/index.js` that exists against the newest `src/**` mtime.
|
|
|
|
This catches stale `dist/` drift: `resolvePluginEntryPath` prefers `bundled.js` and compiled `dist/index.js` before falling back to `src/index.ts`, while per-plugin `dist/` is gitignored and can lag behind source edits. If `bundled-plugin-freshness` reports `dist is stale relative to src`, run `pnpm build` from the workspace root to regenerate plugin `dist/` outputs and staged CLI plugin artifacts before rerunning tests.
|
|
|
|
### Bundled-plugin auto-install: CLI and desktop
|
|
|
|
<!-- FNXC:PluginLoader 2026-07-07-00:00: FN-7637 ported bundled-plugin auto-install into a shared, host-agnostic @fusion/core helper so the desktop embedded runtime auto-installs bundled runtime plugins the same way the CLI dashboard/serve/daemon commands do. Documented here so plugin authors and host maintainers know both hosts share one code path and only differ in bundle-directory resolution. -->
|
|
|
|
The install/update/fail-soft-load logic for `BUNDLED_PLUGIN_IDS` (Dependency Graph, Hermes, OpenClaw, Paperclip, Cursor, Grok, CLI Printing Press, Compound Engineering, Linear Import, Reports, WhatsApp Chat, Roadmap) lives once in `packages/core/src/plugins/bundled-plugin-install.ts` as `ensureBundledPluginInstalled(pluginStore, pluginLoader, pluginId, getCandidatePluginDirs)`. The only host-specific input is `getCandidatePluginDirs` — the ordered list of directories to probe for a plugin's `manifest.json` — because the CLI and desktop stage bundled plugins differently:
|
|
|
|
- **CLI** (`packages/cli/src/plugins/bundled-plugin-install.ts`): resolves `<cli>/dist/plugins/<manifest-id>/` (plus source/dev fallbacks) from its own `import.meta.url`. The CLI module is now a thin adapter that supplies this resolver and re-exports the same public surface (`ensureBundledPluginInstalled`, `ensureBundledDependencyGraphPluginInstalled`, `ensureBundledCursorRuntimePluginInstalled`, `isBundledPluginId`, `BUNDLED_PLUGIN_IDS`, `resolvePluginEntryPath`) that `dashboard.ts`, `serve.ts`, and `daemon.ts` already depend on — no behavior change for CLI hosts.
|
|
- **Desktop** (`packages/desktop/src/bundled-plugin-dirs.ts`): resolves each manifest id (`fusion-plugin-<short-name>`) to its staged `@fusion-plugin-examples/<short-name>` npm package directory via `import.meta.resolve` against the package's `"."` export, which works whether `node_modules` is flat/hoisted (the packaged `pnpm deploy` closure) or nested (workspace dev). These plugins are workspace dependencies of `@fusion/dashboard`, so `packages/desktop/scripts/workspace-tools.ts#stageDesktopDeploy` already materializes their `manifest.json` + `dist/index.js` into the desktop closure — no desktop-specific build/staging changes were needed. A bundled id desktop does not depend on (currently `reports`, `whatsapp-chat`, `linear-import`) resolves to no candidate directories and is correctly reported as `missing-bundle`, matching the CLI's "not found in this build" behavior for an unstaged plugin.
|
|
|
|
Both `packages/desktop/src/local-runtime.ts` (`createDashboardServerDefault`) and `packages/desktop/src/local-server.ts` (`DesktopLocalServerManager.start`) wire this identically to the CLI `dashboard` command: auto-install the bundled Dependency Graph plugin before `loadAllPlugins()`, and pass an `ensureBundledPluginInstalled` callback into `createServer(...)` options so `PUT /api/plugins/:id/settings` can lazy-install Hermes/OpenClaw/Paperclip/etc. on first Settings save. Both paths run this inside the existing fail-soft try/catch (see the desktop `pluginStore`/`pluginLoader` wiring, FN-7623) so a plugin auto-install failure logs (via the `FUSION_STARTUP_TRACE` `strace` helper in `local-runtime.ts`) but never crashes embedded startup. `packages/desktop` still does not depend on the CLI package (`@runfusion/fusion`) — the shared helper lives entirely in `@fusion/core`.
|
|
|
|
Runtime host context contract:
|
|
- Registered views receive a `context` object from the dashboard host (`PluginDashboardViewContext`).
|
|
- Context includes the active `projectId`, current visible `tasks`, optional `workflowSteps`, `openTaskDetail` for launching the native task detail flow, and `openFile(path, options?)` for opening project-relative files in the dashboard's built-in file viewer.
|
|
- Keep view-specific UI behavior in the plugin; treat host context as service/data injection only.
|
|
|
|
Placement guidance:
|
|
- `primary`: top-level nav tab (host may limit count on mobile)
|
|
- `overflow`: desktop header overflow menu
|
|
- `more`: mobile More sheet / secondary nav surfaces
|
|
|
|
Project-scoped UI state guidance:
|
|
- Persist plugin view layout/state in browser storage using a plugin-owned base key and the shared project-scoped pattern (`kb:${projectId}:${baseKey}`).
|
|
- For dependency graph layout, the canonical base key is `fusion-plugin-dependency-graph:positions`.
|
|
- Do not persist plugin UI state in task metadata or server-side task records.
|
|
|
|
### Theming & Overlay Layering for Dashboard Views
|
|
|
|
Use only the [stable theme token contract](./dashboard-guide.md#stable-theme-token-contract-integrators--plugins) for plugin UI. It provides supported surface, text, spacing, status, motion, and layering variables; internal CSS names may change without notice.
|
|
|
|
```css
|
|
.my-plugin-panel {
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
}
|
|
|
|
.my-plugin-owned-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: calc(var(--fusion-max-z) + 1);
|
|
pointer-events: auto;
|
|
}
|
|
```
|
|
|
|
For overlays that should share Fusion's root stacking context, append the plugin element to the supported `#plugin-overlay-root` mount point:
|
|
|
|
```ts
|
|
const overlayRoot = document.querySelector("#plugin-overlay-root");
|
|
overlayRoot?.append(pluginOverlayElement);
|
|
```
|
|
|
|
The mount point is fixed and click-through; set `pointer-events: auto` on interactive plugin children. Its layer follows `--fusion-max-z` as Fusion's monotonic floating-window stack rises, so plugins do not need to track dashboard window focus in JavaScript.
|
|
|
|
---
|
|
|
|
## 9. Registering Agent Runtimes
|
|
|
|
Plugins can provide custom agent runtime implementations that extend the Fusion engine's ability to execute agent sessions. Runtimes are discovered through the plugin discovery pipeline and can be used by the engine to route agent session creation.
|
|
|
|
### How Runtimes Work
|
|
|
|
A plugin runtime consists of two parts:
|
|
1. **Runtime Metadata** (in manifest): Declares the runtime's identity for discovery
|
|
2. **Runtime Factory** (in plugin instance): Creates the runtime instance when needed
|
|
|
|
### Runtime Manifest Metadata
|
|
|
|
Declare runtime metadata in your plugin's manifest:
|
|
|
|
```typescript
|
|
import type { PluginManifest } from "@fusion/plugin-sdk";
|
|
|
|
const manifest: PluginManifest = {
|
|
id: "my-runtime-plugin",
|
|
name: "My Runtime Plugin",
|
|
version: "1.0.0",
|
|
runtime: {
|
|
runtimeId: "code-interpreter",
|
|
name: "Code Interpreter",
|
|
description: "Executes code in a sandboxed environment",
|
|
version: "1.0.0",
|
|
},
|
|
};
|
|
```
|
|
|
|
### PluginRuntimeManifestMetadata Fields
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `runtimeId` | `string` | Yes | Unique runtime identifier within the plugin (kebab-case slug) |
|
|
| `name` | `string` | Yes | Human-readable name for the runtime |
|
|
| `description` | `string` | No | Short description of what the runtime provides |
|
|
| `version` | `string` | No | Semantic version of the runtime implementation |
|
|
|
|
### Runtime Factory
|
|
|
|
The runtime factory is a function that creates the runtime instance:
|
|
|
|
```typescript
|
|
import type { FusionPlugin, PluginContext } from "@fusion/plugin-sdk";
|
|
|
|
const plugin: FusionPlugin = {
|
|
manifest: { /* ... */ },
|
|
state: "installed",
|
|
hooks: {},
|
|
runtime: {
|
|
metadata: {
|
|
runtimeId: "code-interpreter",
|
|
name: "Code Interpreter",
|
|
description: "Executes code in a sandboxed environment",
|
|
},
|
|
factory: async (ctx: PluginContext) => {
|
|
// Initialize the runtime with plugin context
|
|
const apiKey = ctx.settings.apiKey as string;
|
|
|
|
return {
|
|
name: "code-interpreter",
|
|
version: "1.0.0",
|
|
async execute(code: string) {
|
|
// Execute code in sandbox
|
|
const result = await runSandbox(code, { apiKey });
|
|
return result;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
```
|
|
|
|
### PluginRuntimeFactory Signature
|
|
|
|
```typescript
|
|
type PluginRuntimeFactory = (ctx: PluginContext) => Promise<unknown> | unknown;
|
|
```
|
|
|
|
The factory receives `PluginContext` (same as hooks) and should return the runtime instance. The returned instance's structure depends on the runtime's purpose.
|
|
|
|
### PluginRuntimeRegistration Structure
|
|
|
|
```typescript
|
|
interface PluginRuntimeRegistration {
|
|
metadata: PluginRuntimeManifestMetadata;
|
|
factory: PluginRuntimeFactory;
|
|
}
|
|
```
|
|
|
|
### Discovery Pipeline
|
|
|
|
Runtimes are discovered through the plugin discovery pipeline:
|
|
|
|
1. **PluginLoader** aggregates runtime registrations from all loaded plugins via `getPluginRuntimes()`
|
|
2. **PluginRunner** caches runtime registrations and exposes them via `getPluginRuntimes()`
|
|
|
|
Both components follow the same pattern as tools, routes, and UI slots.
|
|
|
|
### Backwards Compatibility
|
|
|
|
Runtime registration is entirely optional. Plugins that don't declare a `runtime` field:
|
|
- Continue to work unchanged
|
|
- Pass manifest validation
|
|
- Don't affect the runtime discovery pipeline
|
|
|
|
### Example: Complete Runtime Plugin
|
|
|
|
```typescript
|
|
import { definePlugin } from "@fusion/plugin-sdk";
|
|
|
|
export default definePlugin({
|
|
manifest: {
|
|
id: "fusion-plugin-code-interpreter",
|
|
name: "Code Interpreter Plugin",
|
|
version: "1.0.0",
|
|
description: "Provides a sandboxed code execution runtime",
|
|
runtime: {
|
|
runtimeId: "code-interpreter",
|
|
name: "Code Interpreter",
|
|
description: "Executes code in a sandboxed environment",
|
|
version: "1.0.0",
|
|
},
|
|
},
|
|
state: "installed",
|
|
hooks: {
|
|
onLoad: async (ctx) => {
|
|
ctx.logger.info("Code interpreter runtime plugin loaded");
|
|
},
|
|
},
|
|
runtime: {
|
|
metadata: {
|
|
runtimeId: "code-interpreter",
|
|
name: "Code Interpreter",
|
|
description: "Executes code in a sandboxed environment",
|
|
version: "1.0.0",
|
|
},
|
|
factory: async (ctx) => {
|
|
// Runtime initialization
|
|
return {
|
|
name: "code-interpreter",
|
|
version: "1.0.0",
|
|
async execute(code: string, language: string) {
|
|
// Sandbox execution logic
|
|
return { output: `Executed ${language} code`, result: "success" };
|
|
},
|
|
};
|
|
},
|
|
},
|
|
} satisfies FusionPlugin);
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Plugin Context API Reference
|
|
|
|
The context object is passed to hooks, tools, and route handlers:
|
|
|
|
```typescript
|
|
interface PluginContext {
|
|
pluginId: string;
|
|
taskStore: TaskStore;
|
|
settings: Record<string, unknown>;
|
|
logger: PluginLogger;
|
|
emitEvent: (event: string, data: unknown) => void;
|
|
createAiSession?: CreateAiSessionFactory;
|
|
}
|
|
```
|
|
|
|
### Properties
|
|
|
|
| Property | Type | Description |
|
|
|----------|------|-------------|
|
|
| `pluginId` | `string` | Your plugin's unique ID |
|
|
| `taskStore` | `TaskStore` | Access to task data (read-only) |
|
|
| `settings` | `Record<string, unknown>` | User configuration (merged with defaults) |
|
|
| `logger` | `PluginLogger` | Structured logging |
|
|
| `emitEvent` | `(event, data) => void` | Emit custom events |
|
|
| `createAiSession` | `CreateAiSessionFactory \| undefined` | Engine-injected AI session factory (undefined when engine isn't loaded) |
|
|
|
|
### Durable data access: PostgreSQL / `AsyncDataLayer`
|
|
|
|
Production Fusion hosts are PostgreSQL-only. **Plugin routes, hooks, and dashboard-backed feature paths must not call `ctx.taskStore.getDatabase()`**: it is the legacy synchronous SQLite accessor and throws in backend mode. Use a project-bound `AsyncDataLayer` and make store methods asynchronous instead.
|
|
|
|
```typescript
|
|
function getWidgetStore(ctx: PluginContext): AsyncWidgetStore {
|
|
const asyncLayer = ctx.taskStore.getAsyncLayer();
|
|
if (!asyncLayer) {
|
|
throw new Error("Widget plugin requires PostgreSQL AsyncDataLayer");
|
|
}
|
|
return new AsyncWidgetStore(asyncLayer);
|
|
}
|
|
|
|
const widget = await getWidgetStore(ctx).getWidget(widgetId);
|
|
```
|
|
|
|
Use a direct `drizzle-orm` dependency for plugin-owned PostgreSQL tables and scope every query by `asyncLayer.projectId`. See Reports' `getReportStore` / `ReportStore` async siblings and Quality's `AsyncQualityStore` for production patterns. SQLite/`DatabaseSync` is permitted only inside intentional unit harnesses, never as a production fallback.
|
|
|
|
The repository gate `scripts/check-no-getdatabase.mjs` scans tracked plugin, dashboard, engine, and core paths. A legitimate transitional exception must be reviewed in `scripts/lib/getdatabase-allowlist.json` and pin the exact `file`, one-based `line`, and trimmed `snippet`; there is no file-level or inline exemption. New plugin feature code is not eligible for that allowlist.
|
|
|
|
### Logger Methods
|
|
|
|
```typescript
|
|
interface PluginLogger {
|
|
info(message: string, ...args: unknown[]): void;
|
|
warn(message: string, ...args: unknown[]): void;
|
|
error(message: string, ...args: unknown[]): void;
|
|
debug(message: string, ...args: unknown[]): void;
|
|
}
|
|
```
|
|
|
|
### `createAiSession` API
|
|
|
|
```typescript
|
|
interface CreateAiSessionOptions {
|
|
cwd: string;
|
|
systemPrompt: string;
|
|
tools?: "coding" | "readonly";
|
|
defaultProvider?: string;
|
|
defaultModelId?: string;
|
|
}
|
|
|
|
interface AiSessionResult {
|
|
session: {
|
|
prompt(text: string): Promise<void>;
|
|
state: { messages: Array<{ role: string; content?: unknown }> };
|
|
};
|
|
sessionFile?: string;
|
|
}
|
|
|
|
type CreateAiSessionFactory = (
|
|
options: CreateAiSessionOptions,
|
|
) => Promise<AiSessionResult>;
|
|
```
|
|
|
|
The factory is dependency-injected by the engine at runtime. In test-only or core-only environments where the engine module is not loaded, `ctx.createAiSession` is `undefined`, so guard before calling it.
|
|
|
|
### Example: Using `ctx.createAiSession()`
|
|
|
|
Use this context factory for plugin AI features (for example roadmap milestone/feature suggestion generation). Avoid direct `@fusion/engine` imports from plugin code; engine wiring is injected by the host through `PluginContext`.
|
|
|
|
```typescript
|
|
hooks: {
|
|
onLoad: async (ctx) => {
|
|
if (!ctx.createAiSession) {
|
|
ctx.logger.warn("AI session factory unavailable; engine not loaded");
|
|
return;
|
|
}
|
|
|
|
const { session } = await ctx.createAiSession({
|
|
cwd: process.cwd(),
|
|
systemPrompt: "You are a release assistant for this plugin.",
|
|
tools: "readonly",
|
|
});
|
|
|
|
await session.prompt("Summarize what this plugin contributes.");
|
|
const latest = session.state.messages.at(-1);
|
|
ctx.logger.info("AI summary generated", latest);
|
|
},
|
|
},
|
|
```
|
|
|
|
### Example: Using the Context
|
|
|
|
```typescript
|
|
hooks: {
|
|
onLoad: (ctx) => {
|
|
ctx.logger.info("Plugin starting...");
|
|
|
|
// Access settings
|
|
const apiKey = ctx.settings.apiKey as string;
|
|
|
|
// Emit custom event
|
|
ctx.emitEvent("my-plugin:ready", { timestamp: Date.now() });
|
|
},
|
|
},
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Plugin Lifecycle States
|
|
|
|
Plugins transition through these states:
|
|
|
|
```
|
|
┌────────────┐
|
|
│ installed │ (registered, not loaded)
|
|
└─────┬──────┘
|
|
│ enable
|
|
▼
|
|
┌────────────┐
|
|
│ started │ ←─────┐ (loaded, hooks active)
|
|
└─────┬──────┘ │
|
|
│ │ load
|
|
│ stop │
|
|
▼ │
|
|
┌────────────┐ │
|
|
│ stopped │ ──────┘
|
|
└────────────┘
|
|
|
|
Any state can transition to:
|
|
┌────────────┐
|
|
│ error │ (load failure or runtime error)
|
|
└────────────┘
|
|
```
|
|
|
|
### State Descriptions
|
|
|
|
| State | Description |
|
|
|-------|-------------|
|
|
| `installed` | Plugin registered but not yet loaded |
|
|
| `started` | Plugin loaded and hooks active |
|
|
| `stopped` | Plugin shut down gracefully |
|
|
| `error` | Plugin failed during load or execution |
|
|
|
|
### Updating path-registered plugins
|
|
|
|
For plugins installed from a filesystem path, the normal update loop is now:
|
|
|
|
1. Pull or edit the plugin source.
|
|
2. Rebuild the plugin entrypoint if your plugin uses a build step.
|
|
3. Restart Fusion, or disable and re-enable the plugin.
|
|
|
|
On the next load/reload, Fusion re-imports the plugin module and refreshes the persisted manifest `version` and `settingsSchema` from the rebuilt manifest. Existing per-project enablement and saved setting values are preserved, so you do not need to unregister and re-register a path-based plugin just to expose a new version or settings field.
|
|
|
|
---
|
|
|
|
## 12. Testing Plugins
|
|
|
|
Use Vitest for unit testing your plugins:
|
|
|
|
### Test Structure
|
|
|
|
```typescript
|
|
// src/__tests__/index.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import plugin from "../index.js";
|
|
|
|
describe("my plugin", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should export a valid plugin", () => {
|
|
expect(plugin.manifest.id).toBe("my-plugin");
|
|
expect(plugin.manifest.name).toBeDefined();
|
|
});
|
|
|
|
it("should call onLoad hook", async () => {
|
|
const mockCtx = {
|
|
pluginId: "my-plugin",
|
|
settings: {},
|
|
logger: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
emitEvent: vi.fn(),
|
|
taskStore: {},
|
|
};
|
|
|
|
await plugin.hooks.onLoad?.(mockCtx as any);
|
|
expect(mockCtx.logger.info).toHaveBeenCalled();
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Tools
|
|
|
|
```typescript
|
|
it("should return correct result from tool", async () => {
|
|
const tool = plugin.tools![0];
|
|
const mockCtx = { /* ... */ };
|
|
|
|
const result = await tool.execute({ input: "hello" }, mockCtx as any);
|
|
|
|
expect(result.content[0].text).toBe("HELLO");
|
|
});
|
|
```
|
|
|
|
### Testing Routes
|
|
|
|
```typescript
|
|
it("should return status from GET /status", async () => {
|
|
const route = plugin.routes!.find(r => r.path === "/status");
|
|
const req = { params: {}, method: "GET", url: "/status" };
|
|
const ctx = { /* ... */ };
|
|
|
|
const result = await route.handler(req as any, ctx as any);
|
|
|
|
expect(result).toHaveProperty("status");
|
|
});
|
|
```
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
pnpm test
|
|
```
|
|
|
|
### Fusion Host Regression Coverage (Plugin Discovery / Load / Registration)
|
|
|
|
When a plugin changes host integration contracts, add or update host-side regression tests in this repository:
|
|
|
|
- **Core loader pipeline** (`packages/core/src/__tests__/plugin-loader.test.ts`): verify `PluginStore.registerPlugin()` → `PluginLoader.loadAllPlugins()` / `loadPlugin()` and assert `started` state transitions, manifest validation failures, disabled-plugin skip behavior, missing entrypoint failures, and `onLoad` error handling.
|
|
- **Dashboard API aggregation** (`packages/dashboard/src/__tests__/plugin-routes.test.ts`, `packages/dashboard/src/__tests__/plugin-routes.routes.test.ts`): verify plugin visibility via `GET /api/plugins`, `GET /api/plugins/ui-slots`, and `GET /api/plugins/runtimes` using standard loader/store aggregation (no plugin-specific route branches).
|
|
- **Dashboard slot consumers** (`packages/dashboard/app/components/__tests__/PluginSlot.test.tsx`, `packages/dashboard/app/hooks/__tests__/usePluginUiSlots.test.ts`): cover slot filtering, ordering, and rendering behavior for host slot IDs used by your plugin.
|
|
|
|
Keep this layer focused on **discovery/load/registration plumbing**. Deeper feature-flow regressions (Settings UX, onboarding UX, runtime execution/provider behavior) belong in dedicated follow-up suites, not in these plumbing tests.
|
|
|
|
### Runtime/Provider Migration Regression Placement
|
|
|
|
For runtime-provider migrations (like Droid), use layered regression suites instead of duplicating the same matrix everywhere:
|
|
|
|
- **Engine runtime execution + fallback**: `packages/engine/src/__tests__/droid-runtime-e2e.test.ts` (patterned after Hermes/OpenClaw/Paperclip E2E suites) verifies plugin runtime resolution + default `pi` fallback when missing.
|
|
- **Runtime hint matrix guardrail**: `packages/engine/src/__tests__/runtime-selection-regression.test.ts` keeps a lightweight hint-to-runtime routing assertion.
|
|
- **Dashboard provider/auth routes**: `packages/dashboard/src/__tests__/routes-auth.test.ts` covers `POST /api/auth/droid-cli`, `GET /api/providers/droid-cli/status`, and `/api/auth/status` readiness/authenticated surfacing.
|
|
- **Dashboard model filtering + settings hook**: `packages/dashboard/src/__tests__/register-model-routes-droid-cli.test.ts` and `packages/dashboard/src/__tests__/register-settings-droid-cli.test.ts` guard `useDroidCli` routing/filter behavior.
|
|
- **Compatibility shim boundaries**: if `packages/droid-cli` remains, keep tests there focused on delegation to plugin-owned implementations (not a second behavior matrix).
|
|
|
|
This keeps regressions durable while preserving clear ownership boundaries across engine, dashboard, plugin, and shim layers.
|
|
|
|
---
|
|
|
|
## 13. Publishing Plugins
|
|
|
|
For end-to-end standalone packaging, `pnpm pack`, and installing on another machine, follow the [External Plugin Authoring guide](./plugins/external-authoring.md). Run `fn plugin publish --dry-run .` before packing to validate the manifest, compiled entrypoint, lifecycle hook shape, and optional version bump without installing, uploading, or tagging anything.
|
|
|
|
### Package Requirements
|
|
|
|
<!-- FNXC:Packaging 2026-06-26-08:55: Published plugin packages must not declare private @fusion/* or workspace:* dependencies; those names only resolve inside the monorepo and cause off-workspace package-manager installs to fail. -->
|
|
|
|
```json
|
|
{
|
|
"name": "fusion-plugin-my-plugin",
|
|
"version": "1.0.0",
|
|
"keywords": ["fusion-plugin"],
|
|
"exports": {
|
|
".": {
|
|
"types": "./dist/index.d.ts",
|
|
"import": "./dist/index.js"
|
|
}
|
|
},
|
|
"dependencies": {
|
|
"@runfusion/fusion": "^0.48.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Publishing Steps
|
|
|
|
1. Update `package.json`:
|
|
- Set `name` to `fusion-plugin-*` or `@scope/fusion-plugin-*`
|
|
- Add `"keywords": ["fusion-plugin"]`
|
|
- Set `"private": false`
|
|
|
|
2. Build the plugin:
|
|
```bash
|
|
pnpm build
|
|
```
|
|
|
|
3. Run the non-mutating publish preflight:
|
|
```bash
|
|
fn plugin publish --dry-run . --previous-version 0.9.0
|
|
```
|
|
|
|
4. Publish to npm:
|
|
```bash
|
|
npm publish --access public
|
|
```
|
|
|
|
### Signed plugin recommendation
|
|
|
|
For production distribution, publish signed artifacts (`plugin-publisher.json` + `plugin-signature.json`) alongside your compiled plugin output so operators can verify provenance under warn/enforce trust policy modes.
|
|
|
|
### Installation
|
|
|
|
Users can install your plugin via CLI:
|
|
|
|
```bash
|
|
fn plugin install fusion-plugin-my-plugin
|
|
# or
|
|
fn plugin install @scope/fusion-plugin-my-plugin
|
|
```
|
|
|
|
Or by copying to the plugins directory:
|
|
|
|
```bash
|
|
cp -r fusion-plugin-my-plugin ~/.fusion/plugins/
|
|
```
|
|
|
|
### Dashboard registry manifest
|
|
|
|
The dashboard **Browse Registry** surface is backed by the static manifest at `packages/dashboard/src/registry-manifest.json`. Each entry describes a plugin that may appear in Settings → Plugins before it is installed.
|
|
|
|
Manifest entries use this shape:
|
|
|
|
```json
|
|
{
|
|
"id": "fusion-plugin-my-plugin",
|
|
"name": "My Plugin",
|
|
"description": "What the plugin adds to Fusion.",
|
|
"category": "Runtime",
|
|
"version": "0.1.0",
|
|
"author": "Fusion Team",
|
|
"homepage": "https://example.com/plugin",
|
|
"path": "plugins/fusion-plugin-my-plugin/dist/index.js"
|
|
}
|
|
```
|
|
|
|
Required fields are `id`, `name`, `description`, and `category`. Optional metadata (`version`, `author`, `homepage`) is displayed when present. `path` is optional: entries with a `path` are installable from the registry; entries without one are discovery-only and render as Coming Soon (`canInstall: false`) until a loadable plugin entry path is available.
|
|
|
|
When adding a bundled plugin, mirror the existing bundled-plugin registration surfaces and then add a registry manifest entry whose `path` points at a concrete loadable file, not a package directory. Use stable ids because installed-state annotation matches registry entries to installed plugins by id.
|
|
|
|
---
|
|
|
|
## 14. Example Plugins
|
|
|
|
Explore these reference implementations:
|
|
|
|
### [Notification Plugin](../../plugins/examples/fusion-plugin-notification/)
|
|
|
|
Sends webhook notifications on task lifecycle events (Slack, Discord, generic HTTP).
|
|
|
|
- Demonstrates: `onLoad`, `onTaskCompleted`, `onTaskMoved`, `onError` hooks
|
|
- Features: Settings schema, webhook formatting, event filtering
|
|
|
|
### [Auto-Label Plugin](../../plugins/examples/fusion-plugin-auto-label/)
|
|
|
|
Automatically labels tasks based on description content using keyword matching.
|
|
|
|
- Demonstrates: `onTaskCreated` hook, AI agent tools
|
|
- Features: Text classification, event emission, tool registration
|
|
|
|
### [CI Status Plugin](../../plugins/examples/fusion-plugin-ci-status/)
|
|
|
|
Polls CI status for branches and provides custom API endpoints.
|
|
|
|
- Demonstrates: Custom routes, periodic background work, route handlers, UI slot registration
|
|
- Features: `onLoad`/`onUnload` lifecycle, `setInterval` polling, REST API, UI slots for task cards and task detail tabs
|
|
|
|
### [Roadmap Planner Plugin](../../plugins/fusion-plugin-roadmap/)
|
|
|
|
Standalone roadmap planning plugin extracted from dashboard host code.
|
|
|
|
- Demonstrates: the bundled legacy `hooks.onSchemaInit` plus its host-owned PostgreSQL schema during the cutover; new external plugins use `hooks.onPostgresSchemaInit`
|
|
- Demonstrates: plugin-scoped route namespace under `/api/plugins/fusion-plugin-roadmap/*`
|
|
- Demonstrates: top-level navigation registration through `dashboardViews` (`viewId: "roadmaps"`) and host static view registration
|
|
- Demonstrates: AI suggestion flows that consume `ctx.createAiSession` through plugin route handlers
|
|
|
|
### [Linear Import Plugin](../../plugins/fusion-plugin-linear-import/)
|
|
|
|
Bundled integration plugin that imports Linear issues into Fusion tasks without adding host-owned Linear routes or settings.
|
|
|
|
- Demonstrates: password plugin settings, plugin-scoped HTTP routes under `/api/plugins/fusion-plugin-linear-import/*`, agent tools, top-level `dashboardViews`, host static dashboard view registration, and task creation through `PluginContext.taskStore`.
|
|
- Demonstrates: external SaaS API evidence in plugin docs, bounded GraphQL pagination, duplicate detection with durable source provenance, and safe error responses that do not leak API keys.
|
|
|
|
### [Droid Runtime Plugin](../../plugins/fusion-plugin-droid-runtime/)
|
|
|
|
Reference runtime plugin that migrates a CLI-backed provider into the plugin system.
|
|
|
|
- Demonstrates: runtime adapter pattern (`runtime-adapter.ts`) and plugin-owned streaming/provider orchestration (`provider.ts`, `process-manager.ts`)
|
|
- Demonstrates structured contribution registration for `settings-provider-card`, `settings-config-section`, `onboarding-provider-card`, `onboarding-setup-help`, `onboarding-provider-recommendation`, and `post-onboarding-recommendation`
|
|
- Demonstrates dashboard probe delegation through plugin-owned `probeDroidBinary`
|
|
- Preserves provider id `droid-cli` via `@fusion/droid-cli` compatibility shim
|
|
|
|
### [Settings Demo Plugin](../../plugins/examples/fusion-plugin-settings-demo/)
|
|
|
|
Example plugin demonstrating settings schema and runtime configuration with all four setting types.
|
|
|
|
- Demonstrates: Settings schema (string, number, boolean, enum), hooks that read settings, tools with settings-driven output
|
|
- Features: Configurable greeting message, tag limit, logging toggle, log level selector
|
|
- **Install from Settings**: Designed to be installed via the dashboard Settings → Plugins UI
|
|
|
|
### [Even Realities Glasses Plugin](../../plugins/fusion-plugin-even-realities-glasses/)
|
|
|
|
Task-focused card bridge plugin for Even Realities glasses companion flows.
|
|
|
|
- Features: quick capture text into new tasks via the plugin route
|
|
- Features: polling-based task transition notifications on configured columns (default `in-review`)
|
|
- Features: board/task card endpoints (`GET /board/cards`, `GET /board`, `GET /tasks/:id/cards`)
|
|
- Features: agent actions for start work (`in-progress`) and request review (`in-review`), gated by `enableAgentActions`
|
|
- Features: webhook transport bridge with companion action ingestion (`POST /transport/actions`) and reconnect/status endpoints
|
|
- Demonstrates: settings schema for `fusionApiBaseUrl`, `fusionApiToken`, `apiKey`, `glassesDeviceId`, `companionWebhookUrl`, `pollingIntervalSeconds`, `notifyOnColumns`, `quickCaptureDefaultColumn`, and `enableAgentActions`
|
|
- Demonstrates FN-3737-aligned display limits: `EVEN_CARD_MAX_CHARS_PER_LINE = 28`, `EVEN_CARD_MAX_LINES_PER_CARD = 8`, `EVEN_CARD_MAX_DECK_SIZE = 12`
|
|
|
|
### Installing Example Plugins from Settings
|
|
|
|
All example plugins can be installed via the dashboard Settings → Plugins UI:
|
|
|
|
1. Open Fusion dashboard and navigate to **Settings** (gear icon in header)
|
|
2. Click **Plugins** in the sidebar
|
|
3. Click the **Install** button
|
|
4. Enter the absolute path to the plugin directory (e.g., `/path/to/fusion/plugins/examples/fusion-plugin-settings-demo`)
|
|
5. Click **Install** to register the plugin
|
|
6. Enable the plugin using the toggle switch
|
|
7. Configure settings via the settings (gear) icon
|
|
8. The plugin will reload automatically with new settings
|
|
|
|
---
|
|
|
|
## Quick Reference
|
|
|
|
### Minimal Plugin
|
|
|
|
```typescript
|
|
import { definePlugin } from "@fusion/plugin-sdk";
|
|
|
|
export default definePlugin({
|
|
manifest: {
|
|
id: "my-plugin",
|
|
name: "My Plugin",
|
|
version: "1.0.0",
|
|
},
|
|
state: "installed",
|
|
hooks: {
|
|
onLoad: (ctx) => {
|
|
ctx.logger.info("Hello from my plugin!");
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### Full Plugin Example
|
|
|
|
```typescript
|
|
import { definePlugin } from "@fusion/plugin-sdk";
|
|
import type { FusionPlugin, PluginContext, PluginUiSlotDefinition } from "@fusion/plugin-sdk";
|
|
|
|
// UI slots for custom dashboard components
|
|
const uiSlots: PluginUiSlotDefinition[] = [
|
|
{
|
|
slotId: "task-card-badge",
|
|
label: "CI Status",
|
|
icon: "circle-check",
|
|
componentPath: "./components/ci-badge.js",
|
|
},
|
|
{
|
|
slotId: "task-detail-tab",
|
|
label: "CI History",
|
|
icon: "history",
|
|
componentPath: "./components/ci-history-tab.js",
|
|
},
|
|
];
|
|
|
|
export default definePlugin({
|
|
manifest: {
|
|
id: "my-full-plugin",
|
|
name: "My Full Plugin",
|
|
version: "1.0.0",
|
|
description: "A complete example with hooks, tools, routes, UI slots, and runtimes",
|
|
settingsSchema: {
|
|
apiKey: {
|
|
type: "string",
|
|
label: "API Key",
|
|
required: true,
|
|
},
|
|
},
|
|
},
|
|
state: "installed",
|
|
tools: [
|
|
{
|
|
name: "my_tool",
|
|
description: "Does something useful",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
input: { type: "string" },
|
|
},
|
|
required: ["input"],
|
|
},
|
|
execute: async (params, ctx) => {
|
|
const result = process(params.input as string);
|
|
return { content: [{ type: "text", text: result }] };
|
|
},
|
|
},
|
|
],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "/status",
|
|
handler: async () => ({ status: "ok" }),
|
|
},
|
|
],
|
|
uiSlots,
|
|
hooks: {
|
|
onLoad: (ctx) => ctx.logger.info("Loaded!"),
|
|
onTaskCreated: (task, ctx) => {
|
|
ctx.logger.info(`Task created: ${task.id}`);
|
|
},
|
|
onUnload: (ctx) => {
|
|
// Cleanup with the same context shape passed to onLoad
|
|
ctx.logger.info("Shutting down plugin");
|
|
},
|
|
},
|
|
} satisfies FusionPlugin);
|
|
```
|
|
|
|
---
|
|
|
|
For more information, see the [Plugin SDK Reference](../packages/plugin-sdk/src/index.ts).
|
|
|
|
---
|
|
|
|
## 15. 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/research/web-research/SKILL.md"],
|
|
enabled: true,
|
|
triggerPatterns: ["research", "search the web", "find sources"],
|
|
},
|
|
];
|
|
```
|
|
|
|
`skillFiles` are relative to the plugin root. The first entry, `skillFiles[0]`, is the authoritative body file that Fusion resolves for the skill, so plugins can organize skill bodies in category subdirectories such as `skills/research/web-research/SKILL.md` while keeping a short `skillId`. When `skillFiles` is omitted or empty, Fusion falls back to the compatibility path `skills/<name>/SKILL.md`. `skillId` must be kebab-case.
|
|
|
|
Plugin skills are discovered per requesting project: the Skills view and workflow editor surface `plugin:<id>` skills only when that plugin is enabled for that project's plugin state, even if the daemon was started from a different directory.
|
|
|
|
<!-- FNXC:PluginSkills 2026-07-12-00:00: GitHub #2017 requires plugin skill bodies to be available anywhere native skills are available. The engine threads enabled plugin skill body discovery paths into agent sessions, and the dashboard reads the resolved plugin-package SKILL.md/reference files instead of showing a runtime placeholder. -->
|
|
Enabled plugin skills are delivered to agent sessions from their plugin-package `SKILL.md` files. Fusion resolves the first `skillFiles` entry (or the compatibility fallback) through the plugin root, adds the skill body directory to the session's skill discovery paths, and keeps the requested skill name in the same selection filter used for native and installed skills. The Skills view also reads the resolved `SKILL.md` plus sibling reference files from disk, so users can inspect the exact guidance agents receive.
|
|
|
|
## 16. 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"`.
|
|
|
|
Plugin-contributed workflow steps are materialized through core `resolvePluginWorkflowStep(...)`; `mode`, `phase`, `scriptName`, `toolMode`, `defaultOn`, `modelProvider`, and `modelId` are preserved from your contribution (with defaults when omitted).
|
|
|
|
## 16.5. Contributing Column Traits
|
|
|
|
> Requires the `experimentalFeatures.workflowColumns` flag. Traits are the
|
|
> composable building blocks of workflow-defined columns (declarative flags +
|
|
> lifecycle hooks). Plugins contribute traits the same way they contribute
|
|
> workflow steps: declare them on the plugin object and the engine aggregates,
|
|
> caches, and invalidates them through the `PluginRunner` (mirroring
|
|
> `workflowSteps`).
|
|
|
|
A plugin trait is registered into the core trait registry under a
|
|
plugin-namespaced id `plugin:<pluginId>:<traitId>`, so it can never collide
|
|
with a built-in trait or another plugin's trait, and it resolves through the
|
|
same registry lookup as the 14 built-in traits.
|
|
|
|
```typescript
|
|
import type { PluginTraitContribution } from "@fusion/plugin-sdk";
|
|
|
|
const traits: PluginTraitContribution[] = [
|
|
{
|
|
traitId: "security-approval",
|
|
name: "Security Approval Gate",
|
|
description: "Holds a card until a security review prompt passes.",
|
|
schemaVersion: 1,
|
|
flags: { gate: true },
|
|
hooks: {
|
|
gate: {
|
|
mode: "prompt",
|
|
prompt: "Approve this change for security-sensitive paths?",
|
|
gateMode: "blocking",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
traitId: "slack-notify",
|
|
name: "Slack Notify",
|
|
description: "Posts to Slack when a card enters/leaves the column.",
|
|
schemaVersion: 1,
|
|
flags: { notify: true },
|
|
hooks: {
|
|
onEnter: { mode: "script", scriptName: "slack-notify-enter" },
|
|
onExit: { mode: "script", scriptName: "slack-notify-exit" },
|
|
},
|
|
},
|
|
];
|
|
|
|
export default definePlugin({
|
|
manifest: { id: "my-plugin", name: "My Plugin", version: "1.0.0" },
|
|
state: "installed",
|
|
hooks: {},
|
|
traits,
|
|
});
|
|
```
|
|
|
|
### Contribution shape
|
|
|
|
| Field | Required | Notes |
|
|
|---|---|---|
|
|
| `traitId` | yes | kebab-case slug, unique within the plugin |
|
|
| `name` | yes | display name |
|
|
| `description` | no | UI description |
|
|
| `schemaVersion` | yes | must be `1` — the versioned hook-descriptor contract (see below) |
|
|
| `flags` | no | declarative flags (restricted flags rejected, see below) |
|
|
| `configSchema` | no | declarative config fields (`{ fields: [...] }`) |
|
|
| `hooks` | no | async hook descriptors (see below) |
|
|
|
|
### Hook points (async only)
|
|
|
|
Plugin traits get **async hook points only**:
|
|
|
|
- `gate` — evaluated **before** a card moves into the column (pre-move, outside
|
|
the task lock). The verdict is recorded and re-checked cheaply when the move
|
|
commits.
|
|
- `onEnter` / `onExit` — post-commit, async, idempotent effects.
|
|
- `releaseCondition` — evaluated by the hold/release sweep for `hold` columns.
|
|
|
|
The synchronous `guard` hook point is **built-in-only** and is rejected at
|
|
validation. Sync guards run inside the task lock and must be fast and pure — a
|
|
plugin hook there could wedge the lock, so plugins use the async `gate` surface
|
|
instead.
|
|
|
|
Each hook descriptor mirrors the workflow-step shape:
|
|
|
|
```typescript
|
|
{ mode: "prompt" | "script", prompt?: string, scriptName?: string, gateMode?: "blocking" | "advisory" }
|
|
```
|
|
|
|
Hooks execute through the **same prompt-session / script / verdict machinery**
|
|
contributed workflow steps use — plugin trait code never runs raw in-process.
|
|
|
|
### Gate semantics
|
|
|
|
- `gateMode: "blocking"` (default for gates) **fails closed**: a non-pass
|
|
verdict — or no recorded verdict at move time — rejects the move with a typed
|
|
`TransitionRejection`.
|
|
- `gateMode: "advisory"` **records and allows**: the verdict is logged but the
|
|
move proceeds.
|
|
- Engine-sourced and recovery moves bypass gates entirely (they carry
|
|
`bypassGuards`), so self-healing is never blocked by a plugin gate.
|
|
|
|
### Restricted flags
|
|
|
|
A plugin trait may **not** declare these flags (rejected at validation, and as a
|
|
backstop at registry registration):
|
|
|
|
- `complete` — a terminal-success column that silently satisfies dependencies.
|
|
- `archived` — globally hidden column semantics.
|
|
|
|
A plugin needing those semantics composes its trait **alongside** the built-in
|
|
`complete` / `archived` trait on the same column.
|
|
|
|
### Versioned hook-descriptor schema
|
|
|
|
`schemaVersion: 1` is required. It pins the hook-descriptor contract so the
|
|
built-in trait vocabulary can grow additively (new flags, hook points, config
|
|
fields) without breaking already-published plugin traits. Validate your
|
|
contribution with `validatePluginTraitContribution(...)` from
|
|
`@fusion/plugin-sdk`.
|
|
|
|
### Disabling a plugin with live dependents
|
|
|
|
If a card is currently sitting in a column that uses one of your plugin's
|
|
traits, disabling/uninstalling the plugin is **blocked** with a typed error
|
|
listing the dependent tasks (mirroring the built-in-workflow deletion block).
|
|
|
|
A **force** path degrades the affected columns to **passive**: the trait's hooks
|
|
become no-ops (the registry resolves them to a no-op plus an audit warning), a
|
|
single audit event is emitted, and the cards remain fully movable. A degraded
|
|
gate column never blocks a card.
|
|
|
|
## 16.6. Contributing Workflow Extensions
|
|
|
|
Workflow extensions let plugins participate in engine and workflow decisions
|
|
without replacing the base engine. Each extension is registered under
|
|
`plugin:<pluginId>:<extensionId>` and may be referenced from workflow IR
|
|
`extensions` metadata on columns or nodes.
|
|
|
|
Use `WORKFLOW_EXTENSION_SCHEMA_VERSION` from `@fusion/plugin-sdk` and declare
|
|
extensions on the plugin object:
|
|
|
|
```typescript
|
|
import {
|
|
WORKFLOW_EXTENSION_SCHEMA_VERSION,
|
|
type WorkflowExtensionContribution,
|
|
} from "@fusion/plugin-sdk";
|
|
|
|
const workflowExtensions: WorkflowExtensionContribution[] = [
|
|
{
|
|
extensionId: "security-move-policy",
|
|
name: "Security Move Policy",
|
|
kind: "move-policy",
|
|
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
|
|
fallback: "failClosed",
|
|
evaluate: async ({ task, fromColumn, toColumn, actor }) => {
|
|
if (toColumn === "done" && actor?.kind !== "human") {
|
|
return {
|
|
allowed: false,
|
|
reason: "human approval required",
|
|
message: `Cannot move ${task.id} to done without human approval`,
|
|
};
|
|
}
|
|
return { allowed: true };
|
|
},
|
|
},
|
|
];
|
|
|
|
export default definePlugin({
|
|
manifest: { id: "my-plugin", name: "My Plugin", version: "1.0.0" },
|
|
state: "installed",
|
|
workflowExtensions,
|
|
});
|
|
```
|
|
|
|
Supported kinds:
|
|
|
|
| Kind | Purpose | Binding |
|
|
|---|---|---|
|
|
| `column-metadata` | Typed metadata schema for workflow columns | Column `extensions` metadata |
|
|
| `move-policy` | Async pre-move policy that can allow or reject a valid transition | Global evaluator |
|
|
| `work-engine` | Claims execution for a column before the built-in executor starts | Column `extensions` metadata |
|
|
| `node-handler` | Handles an extension-marked workflow node before the built-in handler | Node `extensions` metadata |
|
|
| `verdict-provider` | Adds async task-completion verdicts before `fn_task_done` can finish | Global evaluator |
|
|
| `merge-fact-provider` | Adds route/fact inputs to auto-merge request initialization | Global evaluator |
|
|
|
|
Fallback behavior controls what happens when an extension handler fails:
|
|
|
|
- `degradeToDefault` — continue through the built-in behavior.
|
|
- `parkNeedsAttention` — block or park the action with a retryable/manual signal.
|
|
- `failClosed` — block the action with a fail-closed diagnostic.
|
|
|
|
Workflow IR extension metadata is preserved only on v2 workflows. Keys must use
|
|
the `plugin:<pluginId>:<extensionId>` form, and values must be JSON objects. If
|
|
the extension is registered and declares a `configSchema`, the engine validates
|
|
the metadata fields during IR validation.
|
|
|
|
## 16.7. Plugin-Gated Built-in Workflows
|
|
|
|
Plugin-gated built-in workflows are read-only workflow definitions that ship in
|
|
Fusion's `BUILTIN_WORKFLOWS` array but stay hidden until their associated plugin
|
|
is installed. For example, `builtin:compound-engineering` is bundled with core so
|
|
it can be selected like any other built-in workflow, but it references
|
|
Compound Engineering plugin skills that do not exist unless the plugin is active.
|
|
|
|
Runtime gating is declared in `packages/core/src/builtin-workflows.ts`. The
|
|
`PLUGIN_GATED_BUILTIN_WORKFLOWS` map links each gated built-in workflow id to the
|
|
plugin id that unlocks it:
|
|
|
|
```typescript
|
|
// In packages/core/src/builtin-workflows.ts
|
|
const PLUGIN_GATED_BUILTIN_WORKFLOWS: ReadonlyMap<string, string> = new Map([
|
|
["builtin:compound-engineering", "fusion-plugin-compound-engineering"],
|
|
]);
|
|
```
|
|
|
|
When the dashboard or API lists workflow definitions, `listWorkflowDefinitions()`
|
|
checks each built-in with `isBuiltinWorkflowPluginGated()` and then calls
|
|
`isPluginInstalled()` for the required plugin before including it. Direct lookup
|
|
uses the same rule: `getWorkflowDefinition()` returns `undefined` for a gated
|
|
built-in until `getRequiredPluginIdForBuiltinWorkflow()` resolves to an installed
|
|
plugin.
|
|
|
|
The workflow-definition cache is invalidated on plugin register/unregister
|
|
events, so installing or removing a plugin updates the visible workflow list
|
|
immediately. Gated built-ins are also excluded from
|
|
`defaultEnabledBuiltinWorkflowIds()`: they are never enabled by default and only
|
|
surface when the gating plugin is present.
|
|
|
|
Use this pattern when a first-party built-in workflow references plugin-provided
|
|
skills, tools, workflow handlers, or other runtime capabilities that would be
|
|
missing without the plugin. The gate prevents users from selecting a workflow
|
|
that cannot run in their project.
|
|
|
|
The current first-party example is `builtin:compound-engineering`, which is gated
|
|
on `fusion-plugin-compound-engineering`. It does not appear in the workflow
|
|
picker or task workflow selection until the Compound Engineering plugin is
|
|
installed and enabled; if the plugin is uninstalled, the workflow is hidden
|
|
again.
|
|
|
|
> **Plugin author note:** plugin-gated built-in workflows are currently for
|
|
> first-party, bundled workflows because the gating map lives in `@fusion/core`.
|
|
> Plugin authors who want to add their own workflow behavior should use the
|
|
> workflow step contribution API in §16 or workflow extensions in §16.6 instead.
|
|
|
|
## 17. Contributing Prompt Modifications
|
|
|
|
Prompt contributions let a plugin inject additional instructions into specific prompt surfaces.
|
|
|
|
Supported surfaces:
|
|
- `executor-system`
|
|
- `executor-task`
|
|
- `triage`
|
|
- `reviewer`
|
|
- `heartbeat`
|
|
|
|
Each contribution uses the `PluginPromptContribution` shape:
|
|
- `surface`: one of the five supported surfaces
|
|
- `content`: prompt text to inject
|
|
- `position?`: `"append"` (default) or `"prepend"`
|
|
- `condition?`: optional host-enforced gate evaluated against this plugin's per-project effective settings
|
|
|
|
`condition` supports a deliberately small, injection-safe grammar:
|
|
|
|
- `settings["key"] === "value"`
|
|
- `settings["key"] !== "value"`
|
|
|
|
Single or double quotes are accepted for both the setting key and string literal, and whitespace around `settings`, brackets, and operators is ignored. Fusion resolves effective settings by applying each `settingsSchema` `defaultValue` first, then overlaying stored per-project plugin settings. A missing or whitespace-only condition is treated as absent and includes the contribution; malformed or unsupported conditions fail closed and exclude the contribution. Comparisons are string-strict: non-string or missing setting values are not equal to a string literal, so `===` is false and `!==` is true.
|
|
|
|
```typescript
|
|
import type { PluginPromptContributions } from "@fusion/plugin-sdk";
|
|
|
|
const promptContributions: PluginPromptContributions = {
|
|
enabledByDefault: false,
|
|
contributions: [
|
|
{
|
|
surface: "executor-system",
|
|
position: "append",
|
|
content: "Prefer .NET minimal APIs for route examples.",
|
|
condition: 'settings["api-style"] === "minimal-apis"',
|
|
},
|
|
],
|
|
};
|
|
```
|
|
|
|
Use `enabledByDefault: false` when contributions should require explicit opt-in.
|
|
|
|
## 18. 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.
|
|
|
|
## Declarative MCP servers
|
|
|
|
Plugins may declare MCP servers with `mcpServers`. Declarations are active only in projects where the plugin is enabled; Fusion does not install the referenced binary.
|
|
|
|
```ts
|
|
mcpServers: [{
|
|
name: "roslyn-navigator",
|
|
transport: "stdio",
|
|
command: "cwm-roslyn-navigator",
|
|
args: [],
|
|
env: { TOKEN: { secretRef: "roslyn-token", scope: "project" } },
|
|
enabledByDefault: true,
|
|
}]
|
|
```
|
|
|
|
`enabledByDefault` defaults to `true`. Plugin declarations cannot set `enabled`: project settings own enablement. Effective precedence is global settings, enabled plugin declarations, then project settings by name. A project definition overrides a plugin declaration and a same-named project `enabled:false` entry tombstones it. Use Fusion secret references for sensitive `env` or `headers`; never ship plaintext credentials. Missing commands retain normal per-server MCP spawn-failure isolation.
|