FN-8990: clean up plugin reload scratch files

Keep plugin hot-reload cache-busting copies ephemeral and remove legacy tracked artifacts.

- Delete tracked plugin reload scratch files.
- Remove temporary import copies after successful and failed reloads.
- Add cleanup and tracked-artifact regression coverage.

Files changed:
 .changeset/fn-8990-plugin-reload-scratch-cleanup.md       |   7 ++
 packages/core/src/__tests__/plugin-hot-reload.test.ts     |  25 ++++ -
 packages/core/src/plugins/plugin-loader.ts                |  16 ++-
 plugins/fusion-plugin-droid-runtime/src/.index.reload-1.ts |  84 ----------------
 plugins/fusion-plugin-droid-runtime/src/.index.reload-2.ts |  84 ----------------
 plugins/fusion-plugin-hermes-runtime/src/.index.reload-1.ts | 108 --------------------
 plugins/fusion-plugin-hermes-runtime/src/.index.reload-2.ts | 108 --------------------
 plugins/fusion-plugin-hermes-runtime/src/.index.reload-3.ts | 108 --------------------
 plugins/fusion-plugin-openclaw-runtime/src/.index.reload-1.ts |  95 -----------------
 plugins/fusion-plugin-paperclip-runtime/src/.index.reload-1.ts | 112 ---------------------
 scripts/__tests__/no-tracked-plugin-reload-artifacts.test.mjs |  27 +++++
 11 files changed, 72 insertions(+), 702 deletions(-)

Fusion-Task-Id: FN-8990

Fusion-Task-Lineage: 2221ebaf-b8ab-4a3c-8934-addfa348c98c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 03:40:21 -07:00
parent 41bb94ba77
commit 0eaa3c7b9a
11 changed files with 72 additions and 702 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Plugin hot reload no longer leaves scratch reload files in plugin folders.
category: internal
dev: Removes seven tracked artifacts and cleans importPluginModule cache-busting copies after import.

View File

@@ -7,7 +7,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { writeFile, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { mkdtempSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { EventEmitter } from "node:events";
import { PluginLoader } from "../plugins/plugin-loader.js";
@@ -390,6 +390,29 @@ describe("PluginLoader Hot-Reload", () => {
expect(pluginLoader.getPluginTools()[0].name).toBe("reloaded_tool");
});
it("removes the cache-busting copy after a successful reload", async () => {
const entryPath = await writePluginModule(tmpDir, "index.js", baseManifest);
(mockPluginStore as any)._installation.path = entryPath;
await pluginLoader.loadPlugin("hot-reload-test");
await writePluginModule(tmpDir, "index.js", makeManifest({ version: "2.0.0" }));
await pluginLoader.reloadPlugin("hot-reload-test");
expect(pluginLoader.getPlugin("hot-reload-test")?.manifest.version).toBe("2.0.0");
expect(readdirSync(tmpDir).filter((entry) => /^\.index\.reload-/.test(entry))).toEqual([]);
});
it("removes the cache-busting copy when the reload import fails", async () => {
const entryPath = await writePluginModule(tmpDir, "index.js", baseManifest);
(mockPluginStore as any)._installation.path = entryPath;
await pluginLoader.loadPlugin("hot-reload-test");
await writeFile(entryPath, 'throw new Error("reload import failure");');
await expect(pluginLoader.reloadPlugin("hot-reload-test")).rejects.toThrow("reload import failure");
expect(readdirSync(tmpDir).filter((entry) => /^\.index\.reload-/.test(entry))).toEqual([]);
});
it("should emit plugin:reloaded event on successful reload", async () => {
await pluginLoader.loadPlugin("hot-reload-test");

View File

@@ -11,7 +11,7 @@
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { existsSync, readdirSync, statSync } from "node:fs";
import { copyFile, readFile, stat } from "node:fs/promises";
import { copyFile, readFile, rm, stat } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { EventEmitter } from "node:events";
import type { TaskStore } from "../store.js";
@@ -647,7 +647,19 @@ export class PluginLoader extends EventEmitter<{
const baseName = basename(path, ext);
const reloadedPath = resolve(dirname(path), `.${baseName}.reload-${moduleImportVersion}${ext}`);
await copyFile(path, reloadedPath);
mod = await import(pathToFileURL(reloadedPath).href);
/*
FNXC:PluginLoader 2026-08-11-09:58:
FN-8990 requires the cache-busting copy to exist only for the dynamic ESM import.
The module remains resident after import settles, so remove the copy to prevent
plugin working trees accumulating and accidentally committing reload scratch files.
*/
try {
mod = await import(pathToFileURL(reloadedPath).href);
} finally {
try {
await rm(reloadedPath, { force: true });
} catch {}
}
} else {
mod = await import(moduleUrl);
}

View File

@@ -1,84 +0,0 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { DroidRuntimeAdapter } from "./runtime-adapter.js";
export const DROID_RUNTIME_ID = "droid";
const DROID_RUNTIME_VERSION = "0.1.0";
export const droidRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: DROID_RUNTIME_ID,
name: "Droid Runtime",
description: "Drives the Droid CLI for Fusion agents",
version: DROID_RUNTIME_VERSION,
};
export const droidRuntimeFactory: PluginRuntimeFactory = async (ctx) =>
new DroidRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: DROID_RUNTIME_VERSION,
description: "Drives the Droid CLI for Fusion agents",
runtime: droidRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx) => {
const settings = resolveCliSettings(ctx.settings as Record<string, unknown>);
ctx.logger.info(`Droid Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"}`);
},
},
uiSlots: [
{
slotId: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
order: 10,
},
{
slotId: "settings-integration-card",
label: "Droid CLI Integration",
componentPath: "./components/settings-integration-card.js",
order: 20,
},
{
slotId: "onboarding-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/onboarding-provider-card.js",
order: 10,
},
{
slotId: "onboarding-setup-help",
label: "Droid CLI Setup Help",
componentPath: "./components/onboarding-setup-help.js",
order: 20,
},
{
slotId: "post-onboarding-recommendation",
label: "Droid CLI Recommendation",
componentPath: "./components/post-onboarding-recommendation.js",
order: 10,
},
],
runtime: {
metadata: droidRuntimeMetadata,
factory: droidRuntimeFactory,
},
});
export default plugin;
export { DroidRuntimeAdapter };
export { probeDroidBinary } from "./probe.js";
export type { DroidBinaryStatus } from "./probe.js";
export { streamViaCli } from "./provider.js";
export {
discoverDroidModels,
validateCliPresenceAsync,
validateCliAuthAsync,
killAllProcesses,
} from "./process-manager.js";
export { getCustomToolDefs, toolsFromContext, writeMcpConfig } from "./mcp-config.js";
export type { McpToolDef } from "./mcp-config.js";

View File

@@ -1,84 +0,0 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { DroidRuntimeAdapter } from "./runtime-adapter.js";
export const DROID_RUNTIME_ID = "droid";
const DROID_RUNTIME_VERSION = "0.1.0";
export const droidRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: DROID_RUNTIME_ID,
name: "Droid Runtime",
description: "Drives the Droid CLI for Fusion agents",
version: DROID_RUNTIME_VERSION,
};
export const droidRuntimeFactory: PluginRuntimeFactory = async (ctx) =>
new DroidRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: DROID_RUNTIME_VERSION,
description: "Drives the Droid CLI for Fusion agents",
runtime: droidRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx) => {
const settings = resolveCliSettings(ctx.settings as Record<string, unknown>);
ctx.logger.info(`Droid Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"}`);
},
},
uiSlots: [
{
slotId: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
order: 10,
},
{
slotId: "settings-integration-card",
label: "Droid CLI Integration",
componentPath: "./components/settings-integration-card.js",
order: 20,
},
{
slotId: "onboarding-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/onboarding-provider-card.js",
order: 10,
},
{
slotId: "onboarding-setup-help",
label: "Droid CLI Setup Help",
componentPath: "./components/onboarding-setup-help.js",
order: 20,
},
{
slotId: "post-onboarding-recommendation",
label: "Droid CLI Recommendation",
componentPath: "./components/post-onboarding-recommendation.js",
order: 10,
},
],
runtime: {
metadata: droidRuntimeMetadata,
factory: droidRuntimeFactory,
},
});
export default plugin;
export { DroidRuntimeAdapter };
export { probeDroidBinary } from "./probe.js";
export type { DroidBinaryStatus } from "./probe.js";
export { streamViaCli } from "./provider.js";
export {
discoverDroidModels,
validateCliPresenceAsync,
validateCliAuthAsync,
killAllProcesses,
} from "./process-manager.js";
export { getCustomToolDefs, toolsFromContext, writeMcpConfig } from "./mcp-config.js";
export type { McpToolDef } from "./mcp-config.js";

View File

@@ -1,108 +0,0 @@
/**
* Hermes Runtime Plugin
*
* Provides an executable Hermes runtime adapter that drives the local `hermes`
* CLI as a subprocess. Discovered by Fusion's plugin runtime registry; the
* settings configured in the dashboard's "Runtimes → Hermes" page flow through
* `ctx.settings` into the CLI invocation.
*/
import { definePlugin } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js";
import type {
FusionPlugin,
PluginContext,
PluginRuntimeFactory,
PluginRuntimeManifestMetadata,
} from "@fusion/plugin-sdk";
// ── Hermes Runtime Metadata ───────────────────────────────────────────────────
const HERMES_RUNTIME_ID = "hermes";
const HERMES_RUNTIME_VERSION = "0.2.0";
const hermesRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: HERMES_RUNTIME_ID,
name: "Hermes Runtime",
description: "Drives the local `hermes` CLI (NousResearch/hermes-agent)",
version: HERMES_RUNTIME_VERSION,
};
// ── Hermes Runtime Factory ────────────────────────────────────────────────────
const hermesRuntimeFactory: PluginRuntimeFactory = async (ctx) => {
return new HermesRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
};
// ── Plugin Definition ─────────────────────────────────────────────────────────
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime Plugin",
version: HERMES_RUNTIME_VERSION,
description:
"Drives the local `hermes` CLI for Fusion agents — captures session ids and resumes via --resume.",
author: "Fusion Team",
homepage: "https://github.com/NousResearch/hermes-agent",
runtime: hermesRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx: PluginContext) => {
const settings = resolveCliSettings(ctx.settings);
const skillInstall = installFusionSkillIntoHermesHome({ profile: settings.profile });
if (skillInstall.outcome === "warning") {
ctx.logger.warn(
`Hermes Runtime Plugin: Fusion skill auto-install warning: ${skillInstall.reason ?? "unknown"}`,
);
} else if (skillInstall.outcome === "skipped") {
ctx.logger.warn(
`Hermes Runtime Plugin: Fusion skill auto-install skipped: ${skillInstall.reason ?? "unknown"}`,
);
}
ctx.logger.info(
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}`,
);
ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID,
version: HERMES_RUNTIME_VERSION,
});
},
onUnload: () => {
// No persistent state to clean up — each prompt spawns a fresh subprocess.
},
},
runtime: {
metadata: hermesRuntimeMetadata,
factory: hermesRuntimeFactory,
},
});
export default plugin;
// ── Public exports ────────────────────────────────────────────────────────────
export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
export { HermesRuntimeAdapter } from "./runtime-adapter.js";
export {
resolveCliSettings,
invokeHermesCli,
buildHermesArgs,
parseHermesOutput,
listHermesProfiles,
} from "./cli-spawn.js";
export {
installFusionSkillIntoHermesHome,
resolveBundledFusionSkillSource,
resolveHermesHome,
} from "./fusion-skill-install.js";
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeHermesBinary } from "./probe.js";
export type { HermesBinaryStatus } from "./probe.js";

View File

@@ -1,108 +0,0 @@
/**
* Hermes Runtime Plugin
*
* Provides an executable Hermes runtime adapter that drives the local `hermes`
* CLI as a subprocess. Discovered by Fusion's plugin runtime registry; the
* settings configured in the dashboard's "Runtimes → Hermes" page flow through
* `ctx.settings` into the CLI invocation.
*/
import { definePlugin } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js";
import type {
FusionPlugin,
PluginContext,
PluginRuntimeFactory,
PluginRuntimeManifestMetadata,
} from "@fusion/plugin-sdk";
// ── Hermes Runtime Metadata ───────────────────────────────────────────────────
const HERMES_RUNTIME_ID = "hermes";
const HERMES_RUNTIME_VERSION = "0.2.0";
const hermesRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: HERMES_RUNTIME_ID,
name: "Hermes Runtime",
description: "Drives the local `hermes` CLI (NousResearch/hermes-agent)",
version: HERMES_RUNTIME_VERSION,
};
// ── Hermes Runtime Factory ────────────────────────────────────────────────────
const hermesRuntimeFactory: PluginRuntimeFactory = async (ctx) => {
return new HermesRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
};
// ── Plugin Definition ─────────────────────────────────────────────────────────
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime Plugin",
version: HERMES_RUNTIME_VERSION,
description:
"Drives the local `hermes` CLI for Fusion agents — captures session ids and resumes via --resume.",
author: "Fusion Team",
homepage: "https://github.com/NousResearch/hermes-agent",
runtime: hermesRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx: PluginContext) => {
const settings = resolveCliSettings(ctx.settings);
const skillInstall = installFusionSkillIntoHermesHome({ profile: settings.profile });
if (skillInstall.outcome === "warning") {
ctx.logger.warn(
`Hermes Runtime Plugin: Fusion skill auto-install warning: ${skillInstall.reason ?? "unknown"}`,
);
} else if (skillInstall.outcome === "skipped") {
ctx.logger.warn(
`Hermes Runtime Plugin: Fusion skill auto-install skipped: ${skillInstall.reason ?? "unknown"}`,
);
}
ctx.logger.info(
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}`,
);
ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID,
version: HERMES_RUNTIME_VERSION,
});
},
onUnload: () => {
// No persistent state to clean up — each prompt spawns a fresh subprocess.
},
},
runtime: {
metadata: hermesRuntimeMetadata,
factory: hermesRuntimeFactory,
},
});
export default plugin;
// ── Public exports ────────────────────────────────────────────────────────────
export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
export { HermesRuntimeAdapter } from "./runtime-adapter.js";
export {
resolveCliSettings,
invokeHermesCli,
buildHermesArgs,
parseHermesOutput,
listHermesProfiles,
} from "./cli-spawn.js";
export {
installFusionSkillIntoHermesHome,
resolveBundledFusionSkillSource,
resolveHermesHome,
} from "./fusion-skill-install.js";
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeHermesBinary } from "./probe.js";
export type { HermesBinaryStatus } from "./probe.js";

View File

@@ -1,108 +0,0 @@
/**
* Hermes Runtime Plugin
*
* Provides an executable Hermes runtime adapter that drives the local `hermes`
* CLI as a subprocess. Discovered by Fusion's plugin runtime registry; the
* settings configured in the dashboard's "Runtimes → Hermes" page flow through
* `ctx.settings` into the CLI invocation.
*/
import { definePlugin } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js";
import type {
FusionPlugin,
PluginContext,
PluginRuntimeFactory,
PluginRuntimeManifestMetadata,
} from "@fusion/plugin-sdk";
// ── Hermes Runtime Metadata ───────────────────────────────────────────────────
const HERMES_RUNTIME_ID = "hermes";
const HERMES_RUNTIME_VERSION = "0.2.0";
const hermesRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: HERMES_RUNTIME_ID,
name: "Hermes Runtime",
description: "Drives the local `hermes` CLI (NousResearch/hermes-agent)",
version: HERMES_RUNTIME_VERSION,
};
// ── Hermes Runtime Factory ────────────────────────────────────────────────────
const hermesRuntimeFactory: PluginRuntimeFactory = async (ctx) => {
return new HermesRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
};
// ── Plugin Definition ─────────────────────────────────────────────────────────
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime Plugin",
version: HERMES_RUNTIME_VERSION,
description:
"Drives the local `hermes` CLI for Fusion agents — captures session ids and resumes via --resume.",
author: "Fusion Team",
homepage: "https://github.com/NousResearch/hermes-agent",
runtime: hermesRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx: PluginContext) => {
const settings = resolveCliSettings(ctx.settings);
const skillInstall = installFusionSkillIntoHermesHome({ profile: settings.profile });
if (skillInstall.outcome === "warning") {
ctx.logger.warn(
`Hermes Runtime Plugin: Fusion skill auto-install warning: ${skillInstall.reason ?? "unknown"}`,
);
} else if (skillInstall.outcome === "skipped") {
ctx.logger.warn(
`Hermes Runtime Plugin: Fusion skill auto-install skipped: ${skillInstall.reason ?? "unknown"}`,
);
}
ctx.logger.info(
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}`,
);
ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID,
version: HERMES_RUNTIME_VERSION,
});
},
onUnload: () => {
// No persistent state to clean up — each prompt spawns a fresh subprocess.
},
},
runtime: {
metadata: hermesRuntimeMetadata,
factory: hermesRuntimeFactory,
},
});
export default plugin;
// ── Public exports ────────────────────────────────────────────────────────────
export { hermesRuntimeMetadata, hermesRuntimeFactory, HERMES_RUNTIME_ID };
export { HermesRuntimeAdapter } from "./runtime-adapter.js";
export {
resolveCliSettings,
invokeHermesCli,
buildHermesArgs,
parseHermesOutput,
listHermesProfiles,
} from "./cli-spawn.js";
export {
installFusionSkillIntoHermesHome,
resolveBundledFusionSkillSource,
resolveHermesHome,
} from "./fusion-skill-install.js";
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeHermesBinary } from "./probe.js";
export type { HermesBinaryStatus } from "./probe.js";

View File

@@ -1,95 +0,0 @@
/**
* OpenClaw Runtime Plugin
*
* Drives the local `openclaw` CLI as a subprocess (via
* `openclaw --no-color agent --local --json`). No daemon required.
*/
import { definePlugin } from "@fusion/plugin-sdk";
import { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
import { resolveCliConfig } from "./pi-module.js";
import { probeOpenClawBinary } from "./probe.js";
import type {
FusionPlugin,
PluginContext,
PluginRuntimeFactory,
PluginRuntimeManifestMetadata,
} from "@fusion/plugin-sdk";
const OPENCLAW_RUNTIME_ID = "openclaw";
const OPENCLAW_RUNTIME_VERSION = "0.2.0";
const openclawRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: OPENCLAW_RUNTIME_ID,
name: "OpenClaw Runtime",
description: "Drives the local `openclaw` CLI (openclaw/openclaw)",
version: OPENCLAW_RUNTIME_VERSION,
};
const openclawRuntimeFactory: PluginRuntimeFactory = async (ctx?: PluginContext) => {
return new OpenClawRuntimeAdapter(ctx?.settings as Record<string, unknown> | undefined);
};
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-openclaw-runtime",
name: "OpenClaw Runtime Plugin",
version: OPENCLAW_RUNTIME_VERSION,
description:
"Drives the local `openclaw` CLI for Fusion agents — embedded `--local` mode by default; gateway optional.",
author: "Fusion Team",
homepage: "https://docs.openclaw.ai/",
runtime: openclawRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: async (ctx: PluginContext) => {
const config = resolveCliConfig(ctx.settings);
const probe = await probeOpenClawBinary({ binaryPath: config.binaryPath });
ctx.logger.info(
probe.available
? `OpenClaw Runtime Plugin loaded — binary=${config.binaryPath}${probe.version ? ` (${probe.version})` : ""}`
: `OpenClaw Runtime Plugin loaded but binary not detected: ${probe.reason ?? "unknown"}`,
);
ctx.emitEvent("openclaw-runtime:loaded", {
runtimeId: OPENCLAW_RUNTIME_ID,
version: OPENCLAW_RUNTIME_VERSION,
binaryAvailable: probe.available,
binaryPath: probe.binaryPath ?? config.binaryPath,
});
},
onUnload: () => {
// No persistent state to clean up — each prompt spawns a fresh subprocess.
},
},
runtime: {
metadata: openclawRuntimeMetadata,
factory: openclawRuntimeFactory,
},
});
export default plugin;
// ── Public exports ────────────────────────────────────────────────────────────
export { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID };
export { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
export {
resolveCliConfig,
buildOpenClawArgs,
createCliSession,
promptCli,
describeCliModel,
extractStderrError,
configureOpenClawMcpServer,
} from "./pi-module.js";
export type { CliConfig, GatewaySession, OpenClawAgentJson } from "./types.js";
export {
toolsToMcpToolDefs,
writeOpenClawMcpBridgeFiles,
} from "./mcp-config.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeOpenClawBinary } from "./probe.js";
export type { OpenClawBinaryStatus } from "./probe.js";

View File

@@ -1,112 +0,0 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { PluginContext } from "@fusion/plugin-sdk";
import {
probePaperclipConnection,
resolvePaperclipConfig,
} from "./paperclip-client.js";
import { PaperclipRuntimeAdapter } from "./runtime-adapter.js";
import type {
FusionPlugin,
PluginRuntimeRegistration,
RuntimeLogger,
} from "./types.js";
// Public exports — consumed by the dashboard probe façade and tests.
export type {
PaperclipAgentSummary,
PaperclipCliDiscovery,
PaperclipCliDiscoveryResult,
PaperclipCompanySummary,
PaperclipConnectionStatus,
} from "./paperclip-client.js";
export {
agentsMe,
agentsMeViaCli,
createIssueViaCli,
discoverPaperclipCliConfig,
getIssueViaCli,
listCompanies,
listCompaniesViaCli,
listCompanyAgents,
listCompanyAgentsViaCli,
mintAgentApiKeyViaCli,
probePaperclipConnection,
probePaperclipViaCli,
} from "./paperclip-client.js";
export type { MintCliKeyOptions, MintedApiKey } from "./paperclip-client.js";
export { PaperclipRuntimeAdapter } from "./runtime-adapter.js";
function getSettingsConfig(settings: unknown) {
return resolvePaperclipConfig((settings ?? {}) as Record<string, unknown>);
}
async function paperclipRuntimeFactory(ctx: {
settings?: unknown;
logger?: RuntimeLogger;
}): Promise<unknown> {
const config = getSettingsConfig(ctx.settings);
// resolvePaperclipConfig returns `mode: string`; the adapter narrows it.
return new PaperclipRuntimeAdapter(
config as unknown as Record<string, unknown>,
ctx.logger,
);
}
const paperclipRuntime: PluginRuntimeRegistration = {
metadata: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
version: "1.0.0",
},
factory: paperclipRuntimeFactory,
};
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-paperclip-runtime",
name: "Paperclip Runtime Plugin",
version: "1.0.0",
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
author: "Fusion Team",
homepage: "https://paperclip.ing/",
fusionVersion: ">=0.1.0",
runtime: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
version: "1.0.0",
},
},
state: "installed",
runtime: paperclipRuntime,
hooks: {
onLoad: async (ctx: PluginContext) => {
const config = getSettingsConfig(ctx.settings);
ctx.logger.info(`Paperclip Runtime Plugin loaded (apiUrl=${config.apiUrl})`);
// Best-effort connectivity probe; failures are warnings, not errors.
try {
const status = await probePaperclipConnection({
apiUrl: config.apiUrl,
apiKey: config.apiKey,
});
if (status.available) {
const ident = status.identity;
ctx.logger.info(
ident
? `Paperclip reachable as ${ident.agentName} (${ident.role ?? "agent"}) at ${ident.companyName ?? ident.companyId}`
: `Paperclip reachable at ${config.apiUrl}`,
);
} else {
ctx.logger.warn(`Paperclip probe failed: ${status.reason ?? "unknown"}`);
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
ctx.logger.warn(`Paperclip probe threw: ${reason}`);
}
},
},
});
export default plugin;

View File

@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
/*
FNXC:PluginLoader 2026-08-11-09:58:
FN-8990 requires reload scratch copies to remain untracked. Gitignore cannot
remove a path already in git's index, so inspect tracked files directly.
*/
function getTrackedReloadArtifacts() {
return execFileSync("git", ["ls-files"], { cwd: rootDir, encoding: "utf8" })
.split("\n")
.filter((path) => /\.index\.reload-\d+\.ts$/.test(path));
}
test("plugin hot-reload scratch copies are never tracked", () => {
const artifacts = getTrackedReloadArtifacts();
assert.deepEqual(
artifacts,
[],
`Tracked plugin hot-reload scratch copies:\n${artifacts.map((path) => `- ${path}`).join("\n")}`,
);
});