FN-7629: add enable/disable control for built-in runtime plugins

Adds a durable Plugin Manager toggle to enable/disable built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid) that persists across restarts, replacing the dead-end "Built-in metadata only" CTA.

- renderBuiltinPluginSection now renders an enable/disable toggle for runtime built-ins regardless of installed status
- Disabling a not-yet-installed built-in first installs it (mirroring CLI's ensureBundledPluginInstalled) then immediately disables it, so a plugin_installs row + disabled project state exists with no new persistence primitive needed
- HermesRuntimeCard/OpenClawRuntimeCard/PaperclipRuntimeCard now show "Disabled in Plugin Manager" instead of a stale detected/connected status when disabled
- Added i18n strings across all locales and updated docs
- Added changeset for @runfusion/fusion (minor)
- Expanded plugin-loader and PluginManager test coverage for the new disable/enable flows

Files changed:
 .changeset/fn-7629-builtin-runtime-disable.md      |   7 +
 docs/dashboard-guide.md                            |   1 +
 docs/plugin-management.md                          |   4 +
 packages/core/src/__tests__/plugin-loader.test.ts  |  46 ++++++
 .../dashboard/app/components/HermesRuntimeCard.tsx |  40 ++++-
 .../app/components/OpenClawRuntimeCard.tsx         |  32 +++-
 .../app/components/PaperclipRuntimeCard.tsx        |  32 +++-
 .../dashboard/app/components/PluginManager.css     |  34 +++++
 .../dashboard/app/components/PluginManager.tsx     | 167 +++++++++++++++------
 .../components/__tests__/PluginManager.test.tsx    |  12 +-
 .../__tests__/PluginManager.toggle.test.tsx        | 104 ++++++++++++-
 packages/i18n/locales/en/app.json                  |   4 +
 packages/i18n/locales/es/app.json                  |   4 +
 packages/i18n/locales/fr/app.json                  |   4 +
 packages/i18n/locales/ko/app.json                  |   4 +
 packages/i18n/locales/zh-CN/app.json               |   4 +
 packages/i18n/locales/zh-TW/app.json               |   4 +
 packages/i18n/src/resources.d.ts                   |   4 +
 18 files changed, 440 insertions(+), 67 deletions(-)

Fusion-Task-Id: FN-7629
Fusion-Task-Lineage: 2a25bb1f-3f73-4273-8769-af05c61778f9
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 08:37:40 -07:00
parent a6c60e1592
commit 0f2bfa546c
18 changed files with 439 additions and 66 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid) can now be disabled and stay disabled across restarts.
category: feature
dev: renderBuiltinPluginSection now renders a durable enable/disable toggle for runtime built-ins independent of installed status, replacing the dead-end "Built-in metadata only" CTA for the not-installed / activated-without-record case. Chosen persistence path: on disable, a not-yet-installed built-in runtime is first registered via the existing installPlugin path (mirroring the CLI's ensureBundledPluginInstalled lazy-install), then disablePlugin is called immediately so a plugin_installs row + project state exists with enabled=false — no new persistence primitive needed since loadAllPlugins/loadPlugin already skip disabled plugins and recordActivationEvent only fires on actual load, so a disabled runtime is never re-activated on restart. HermesRuntimeCard/OpenClawRuntimeCard/PaperclipRuntimeCard now reflect the Plugin Manager disabled state ("Disabled in Plugin Manager") instead of showing a stale detected/connected status.

View File

@@ -1157,6 +1157,7 @@ Features:
- Enable/disable plugins, reload active plugins, and uninstall plugins - Enable/disable plugins, reload active plugins, and uninstall plugins
- Inspect plugin runtime state and transition feedback - Inspect plugin runtime state and transition feedback
- Edit and save plugin-defined settings schemas from the same panel - Edit and save plugin-defined settings schemas from the same panel
- Built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid) always expose an interactive enable/disable toggle in the Built-in Plugins list, even before install. Disabling one registers it and disables it in the same action, and the decision survives restarts — a disabled runtime is not re-activated on the next startup. The Runtimes settings cards mirror this state instead of showing a stale detected/connected status.
For full plugin lifecycle workflows (discovery, install, enable/disable, configure, update, uninstall, troubleshooting), see [Plugin Management](./plugin-management.md). For plugin-related settings and experimental toggles, see [Settings reference](./settings-reference.md). For full plugin lifecycle workflows (discovery, install, enable/disable, configure, update, uninstall, troubleshooting), see [Plugin Management](./plugin-management.md). For plugin-related settings and experimental toggles, see [Settings reference](./settings-reference.md).

View File

@@ -133,6 +133,10 @@ fn plugin disable <id>
Expected outcome: Plugin is enabled or disabled by ID. Expected outcome: Plugin is enabled or disabled by ID.
### Built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid)
Built-in runtime plugins always expose an interactive enable/disable toggle in **Built-in Plugins**, even before the plugin has been explicitly installed (no `plugin_installs` record yet). Disabling a not-yet-installed runtime registers it (mirroring the same lazy-install path used elsewhere) and then disables it in one step, so the decision persists. A user-disabled built-in runtime is never silently re-enabled or re-activated by Fusion on the next restart — startup auto-activation only loads plugins whose project state is enabled. The **Runtimes** settings cards (Hermes/OpenClaw/Paperclip) reflect this state and show "Disabled in Plugin Manager" instead of a stale detected/connected status when the runtime has been turned off.
## 5) Configure plugin settings ## 5) Configure plugin settings
1. Go to **Settings → Plugins → Fusion Plugins**. 1. Go to **Settings → Plugins → Fusion Plugins**.

View File

@@ -923,6 +923,52 @@ export default plugin;
expect(loader.isPluginLoaded("disabled-plugin")).toBe(false); expect(loader.isPluginLoaded("disabled-plugin")).toBe(false);
}); });
/*
* FNXC:PluginLoader 2026-07-07-00:00:
* FN-7629 — regression coverage for the built-in runtime disable durability invariant.
* Symptom: with no plugin_installs row, a built-in runtime (e.g. Hermes) could not be
* disabled from the dashboard, and once registered+enabled it re-activated on every restart.
* The register-then-disable UI path (installPlugin + disablePlugin) relies on this store/loader
* contract: once a plugin's project state is enabled=false, loadAllPlugins must skip it on
* every subsequent load pass (i.e. every process restart) and must never record an activation
* event for it, even though the plugin_installs row itself persists untouched.
*/
it("keeps a user-disabled runtime built-in skipped and unactivated across repeated loadAllPlugins passes (restart simulation)", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "plugins");
const runtimePlugin = makePlugin(makeManifest({ id: "fusion-plugin-fake-runtime" }));
const runtimePath = await writePluginModule(pluginDir, "fake-runtime.js", runtimePlugin);
// Mirrors the UI's durable-disable path: register (defaults to enabled=true,
// same as installPlugin/registerPlugin), then immediately disable.
await pluginStore.registerPlugin({ manifest: runtimePlugin.manifest, path: runtimePath });
await pluginStore.disablePlugin("fusion-plugin-fake-runtime");
const firstLoader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const firstResult = await firstLoader.loadAllPlugins();
expect(firstResult).toEqual({ loaded: 0, errors: 0 });
expect(firstLoader.isPluginLoaded("fusion-plugin-fake-runtime")).toBe(false);
expect(mockTaskStore.recordPluginActivation).not.toHaveBeenCalledWith(
expect.objectContaining({ pluginId: "fusion-plugin-fake-runtime" }),
);
// Simulate a second restart with a brand-new PluginLoader instance against the
// same persisted store — the disabled decision must still be honored.
mockTaskStore.recordPluginActivation.mockClear();
const secondLoader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const secondResult = await secondLoader.loadAllPlugins();
expect(secondResult).toEqual({ loaded: 0, errors: 0 });
expect(secondLoader.isPluginLoaded("fusion-plugin-fake-runtime")).toBe(false);
expect(mockTaskStore.recordPluginActivation).not.toHaveBeenCalled();
// The install row itself must still exist and remain disabled (durable, not deleted).
const stored = await pluginStore.getPlugin("fusion-plugin-fake-runtime");
expect(stored.enabled).toBe(false);
});
it("returns error count for failed plugins", async () => { it("returns error count for failed plugins", async () => {
await pluginStore.init(); await pluginStore.init();

View File

@@ -4,6 +4,7 @@ import {
fetchHermesProfiles, fetchHermesProfiles,
fetchHermesStatus, fetchHermesStatus,
fetchPluginSettings, fetchPluginSettings,
fetchPlugins,
updatePluginSettings, updatePluginSettings,
type HermesProfileSummary, type HermesProfileSummary,
type HermesProviderStatus, type HermesProviderStatus,
@@ -72,6 +73,14 @@ export function HermesRuntimeCard() {
const [profiles, setProfiles] = useState<HermesProfileSummary[]>([]); const [profiles, setProfiles] = useState<HermesProfileSummary[]>([]);
const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null); const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null);
const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null); const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null);
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — Plugin Manager is the source of truth for the runtime's enable/disable decision.
* This card must not claim Hermes is active/detected when the user has disabled it there, so
* mirror the installed project-state (not the local settings form) and override the status
* badge when disabled.
*/
const [runtimeDisabled, setRuntimeDisabled] = useState(false);
const mountedRef = useRef(true); const mountedRef = useRef(true);
useEffect(() => { useEffect(() => {
@@ -81,6 +90,20 @@ export function HermesRuntimeCard() {
}; };
}, []); }, []);
useEffect(() => {
let cancelled = false;
fetchPlugins()
.then((list) => {
if (cancelled) return;
const installed = list.find((p) => p.id === PLUGIN_ID);
setRuntimeDisabled(installed ? !installed.enabled : false);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
setBusy("loading"); setBusy("loading");
fetchPluginSettings(PLUGIN_ID) fetchPluginSettings(PLUGIN_ID)
@@ -198,13 +221,16 @@ export function HermesRuntimeCard() {
}, [buildPayload, probe, t]); }, [buildPayload, probe, t]);
const binary = status?.binary; const binary = status?.binary;
const statusKind = status === null const statusKind = runtimeDisabled
? "loading" ? "neutral"
: binary?.available : status === null
? "ok" ? "loading"
: "err"; : binary?.available
const statusText = ? "ok"
status === null : "err";
const statusText = runtimeDisabled
? t("hermes.statusDisabledInPluginManager", "Disabled in Plugin Manager")
: status === null
? t("hermes.probing", "Probing local hermes binary…") ? t("hermes.probing", "Probing local hermes binary…")
: binary?.available : binary?.available
? t("hermes.statusDetected", "✓ Detected{{version}}{{path}}", { version: binary.version ? ` ${binary.version}` : "", path: binary.binaryPath ? ` · ${binary.binaryPath}` : "" }) ? t("hermes.statusDetected", "✓ Detected{{version}}{{path}}", { version: binary.version ? ` ${binary.version}` : "", path: binary.binaryPath ? ` · ${binary.binaryPath}` : "" })

View File

@@ -3,6 +3,7 @@ import { useTranslation, Trans } from "react-i18next";
import { import {
fetchOpenClawStatus, fetchOpenClawStatus,
fetchPluginSettings, fetchPluginSettings,
fetchPlugins,
updatePluginSettings, updatePluginSettings,
type OpenClawProviderStatus, type OpenClawProviderStatus,
} from "../api"; } from "../api";
@@ -83,6 +84,13 @@ export function OpenClawRuntimeCard() {
const [status, setStatus] = useState<OpenClawProviderStatus | null>(null); const [status, setStatus] = useState<OpenClawProviderStatus | null>(null);
const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null); const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null);
const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null); const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null);
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — Plugin Manager is the source of truth for the runtime's enable/disable decision.
* Mirror the installed project-state so this card never claims OpenClaw is active/detected when
* the user has disabled it there.
*/
const [runtimeDisabled, setRuntimeDisabled] = useState(false);
const mountedRef = useRef(true); const mountedRef = useRef(true);
useEffect(() => { useEffect(() => {
@@ -92,6 +100,20 @@ export function OpenClawRuntimeCard() {
}; };
}, []); }, []);
useEffect(() => {
let cancelled = false;
fetchPlugins()
.then((list) => {
if (cancelled) return;
const installed = list.find((p) => p.id === PLUGIN_ID);
setRuntimeDisabled(installed ? !installed.enabled : false);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
// Load saved settings on mount. // Load saved settings on mount.
useEffect(() => { useEffect(() => {
setBusy("loading"); setBusy("loading");
@@ -201,10 +223,12 @@ export function OpenClawRuntimeCard() {
}, [buildPayload, probe, t]); }, [buildPayload, probe, t]);
const binary = status?.binary; const binary = status?.binary;
const statusKind = const statusKind = runtimeDisabled
status === null ? "loading" : binary?.available ? "ok" : "err"; ? "neutral"
const statusText = : status === null ? "loading" : binary?.available ? "ok" : "err";
status === null const statusText = runtimeDisabled
? t("openclaw.statusDisabledInPluginManager", "Disabled in Plugin Manager")
: status === null
? t("openclaw.probing", "Probing local openclaw binary…") ? t("openclaw.probing", "Probing local openclaw binary…")
: binary?.available : binary?.available
? t("openclaw.statusDetected", `✓ Detected{{version}}{{path}}`, { version: binary.version ? ` ${binary.version}` : "", path: binary.binaryPath ? ` · ${binary.binaryPath}` : "" }) ? t("openclaw.statusDetected", `✓ Detected{{version}}{{path}}`, { version: binary.version ? ` ${binary.version}` : "", path: binary.binaryPath ? ` · ${binary.binaryPath}` : "" })

View File

@@ -23,6 +23,7 @@ import {
fetchPaperclipCompanies, fetchPaperclipCompanies,
fetchPaperclipStatus, fetchPaperclipStatus,
fetchPluginSettings, fetchPluginSettings,
fetchPlugins,
mintPaperclipApiKey, mintPaperclipApiKey,
updatePluginSettings, updatePluginSettings,
type PaperclipAgentSummary, type PaperclipAgentSummary,
@@ -132,6 +133,13 @@ export function PaperclipRuntimeCard() {
>(null); >(null);
const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null); const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null);
const [apiKeyDirty, setApiKeyDirty] = useState(false); const [apiKeyDirty, setApiKeyDirty] = useState(false);
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — Plugin Manager is the source of truth for the runtime's enable/disable decision.
* Mirror the installed project-state so this card never claims Paperclip is connected/active
* when the user has disabled it there.
*/
const [runtimeDisabled, setRuntimeDisabled] = useState(false);
const mountedRef = useRef(true); const mountedRef = useRef(true);
useEffect(() => { useEffect(() => {
@@ -141,6 +149,20 @@ export function PaperclipRuntimeCard() {
}; };
}, []); }, []);
useEffect(() => {
let cancelled = false;
fetchPlugins()
.then((list) => {
if (cancelled) return;
const installed = list.find((p) => p.id === PLUGIN_ID);
setRuntimeDisabled(installed ? !installed.enabled : false);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
// The "effective" apiUrl/apiKey used for status + dropdowns. // The "effective" apiUrl/apiKey used for status + dropdowns.
// In CLI mode we prefer whatever cliDiscovery returned, falling back to the // In CLI mode we prefer whatever cliDiscovery returned, falling back to the
// typed apiUrl if discovery hasn't completed yet. // typed apiUrl if discovery hasn't completed yet.
@@ -411,15 +433,17 @@ export function PaperclipRuntimeCard() {
const identity = status?.connection.identity; const identity = status?.connection.identity;
const cliOk = cliDiscovery?.ok === true; const cliOk = cliDiscovery?.ok === true;
const statusKind = const statusKind = runtimeDisabled
status === null ? "neutral"
: status === null
? "loading" ? "loading"
: connected : connected
? "ok" ? "ok"
: "err"; : "err";
const statusText = const statusText = runtimeDisabled
status === null ? t("paperclip.statusDisabledInPluginManager", "Disabled in Plugin Manager")
: status === null
? settings.transport === "cli" && cliDiscovery && !cliOk ? settings.transport === "cli" && cliDiscovery && !cliOk
? t("paperclip.statusCliDiscoveryFailed", "✗ CLI discovery failed: {{reason}}", { reason: cliDiscovery.reason }) ? t("paperclip.statusCliDiscoveryFailed", "✗ CLI discovery failed: {{reason}}", { reason: cliDiscovery.reason })
: t("paperclip.statusProbing", "Probing Paperclip server…") : t("paperclip.statusProbing", "Probing Paperclip server…")

View File

@@ -480,6 +480,20 @@
text-transform: uppercase; text-transform: uppercase;
} }
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — runtime built-ins (Hermes/Paperclip/OpenClaw/Droid) must always expose a durable
* enable/disable control alongside the existing Install/Manage affordance, on both desktop and
* mobile, and never dead-end at a static label. Wrap the toggle + action button so both share the
* same alignment as the rest of the built-in row.
*/
.plugin-builtins-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
}
.plugin-registry-section { .plugin-registry-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -831,6 +845,26 @@
align-items: stretch; align-items: stretch;
} }
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — keep the built-in runtime toggle at a 36x36 tap target on mobile, matching the
* installed-list `.plugin-actions .toggle-switch` convention, and let the actions row wrap
* below the metadata instead of squeezing the toggle + button onto one line.
*/
.plugin-builtins-actions {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
}
.plugin-builtins-actions .toggle-switch {
min-width: 36px;
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.plugin-registry-header { .plugin-registry-header {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

View File

@@ -274,6 +274,14 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState<Record<string, PluginSetupStatusResponse>>({}); const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState<Record<string, PluginSetupStatusResponse>>({});
const [loadingBuiltinSetupId, setLoadingBuiltinSetupId] = useState<string | null>(null); const [loadingBuiltinSetupId, setLoadingBuiltinSetupId] = useState<string | null>(null);
const [installingBuiltinSetupId, setInstallingBuiltinSetupId] = useState<string | null>(null); const [installingBuiltinSetupId, setInstallingBuiltinSetupId] = useState<string | null>(null);
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — built-in runtime plugins (Hermes/Paperclip/OpenClaw/Droid) must expose a durable
* enable/disable control even when no plugin_installs row exists yet ("activated-without-record").
* Track in-flight toggles separately from install/setup so the toggle-switch can show a busy
* state without blocking the Install/Manage button.
*/
const [togglingBuiltinRuntimeId, setTogglingBuiltinRuntimeId] = useState<string | null>(null);
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const loadPlugins = useCallback(async () => { const loadPlugins = useCallback(async () => {
@@ -565,6 +573,48 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
} }
}; };
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — durable built-in runtime disable path. A built-in runtime
* (Hermes/Paperclip/OpenClaw/Droid) that has never been explicitly installed
* has no plugin_installs row, so enablePlugin/disablePlugin (which both call
* getPlugin -> ENOENT) cannot persist a decision for it. When the user wants
* to disable such a runtime, first register it via the existing install path
* (mirrors the CLI's ensureBundledPluginInstalled lazy-install) so a
* plugin_installs row + project state exists, then disable it immediately so
* the decision is durable: loadAllPlugins() only loads plugins where
* enabled=true, so a disabled runtime is never re-activated on restart.
* Already-installed runtimes just toggle through the normal enable/disable
* handlers, same as the installed-plugin list row.
*/
const handleToggleBuiltinRuntime = async (builtinPlugin: BuiltinPlugin, installedPlugin?: PluginInstallation) => {
if (installedPlugin) {
if (installedPlugin.enabled) {
await handleDisable(installedPlugin);
} else {
await handleEnable(installedPlugin);
}
return;
}
if (!builtinPlugin.path) {
addToast(t("plugins.builtinNoPackage", "{{name}} is built in and does not have an installable package yet", { name: builtinPlugin.name }), "warning");
return;
}
try {
setTogglingBuiltinRuntimeId(builtinPlugin.id);
const registered = await installPlugin({ path: builtinPlugin.path }, projectId);
await disablePlugin(registered.id, projectId);
addToast(t("plugins.disabledForProject", "{{name}} disabled for this project", { name: builtinPlugin.name }), "success");
await loadPlugins();
} catch (err) {
addToast(t("plugins.disablePluginFailed", "Failed to disable plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
} finally {
setTogglingBuiltinRuntimeId(null);
}
};
const handleReload = async (plugin: PluginInstallation) => { const handleReload = async (plugin: PluginInstallation) => {
try { try {
setReloadingPluginId(plugin.id); setReloadingPluginId(plugin.id);
@@ -1073,6 +1123,16 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const setupReady = isInstalled && setupStatus?.hasSetup && pluginSetupState === "installed"; const setupReady = isInstalled && setupStatus?.hasSetup && pluginSetupState === "installed";
const setupCheckInFlight = loadingBuiltinSetupId === builtinPlugin.id; const setupCheckInFlight = loadingBuiltinSetupId === builtinPlugin.id;
const metadataOnly = !builtinPlugin.path; const metadataOnly = !builtinPlugin.path;
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — runtime built-ins (Hermes/Paperclip/OpenClaw/Droid) must always expose an
* interactive enable/disable control, independent of install status, and must never
* dead-end at the static "Built-in metadata only" label. Non-runtime built-ins (e.g.
* Agent Browser) keep the existing metadata-only/install/manage affordances unchanged.
*/
const isRuntimeBuiltin = builtinPlugin.category === "runtime";
const runtimeEnabled = installedPlugin ? installedPlugin.enabled : true;
const isTogglingRuntime = togglingBuiltinRuntimeId === builtinPlugin.id;
return ( return (
<div key={builtinPlugin.id} className="plugin-builtins-item"> <div key={builtinPlugin.id} className="plugin-builtins-item">
@@ -1083,6 +1143,9 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<span className={`plugin-builtins-status ${isInstalled ? "plugin-builtins-status--installed" : "plugin-builtins-status--available"}`}> <span className={`plugin-builtins-status ${isInstalled ? "plugin-builtins-status--installed" : "plugin-builtins-status--available"}`}>
{isInstalled ? t("plugins.statusInstalled", "Installed") : metadataOnly ? t("plugins.statusBuiltIn", "Built in") : t("plugins.statusNotInstalled", "Not installed")} {isInstalled ? t("plugins.statusInstalled", "Installed") : metadataOnly ? t("plugins.statusBuiltIn", "Built in") : t("plugins.statusNotInstalled", "Not installed")}
</span> </span>
{isRuntimeBuiltin && !runtimeEnabled && (
<span className="plugin-builtins-setup-status plugin-builtins-setup-status--warning">{t("plugins.builtinDisabled", "Disabled")}</span>
)}
{requiresSetupAction && ( {requiresSetupAction && (
<span className="plugin-builtins-setup-status plugin-builtins-setup-status--warning">{t("plugins.setupRequired", "Setup required")}</span> <span className="plugin-builtins-setup-status plugin-builtins-setup-status--warning">{t("plugins.setupRequired", "Setup required")}</span>
)} )}
@@ -1097,53 +1160,69 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)} )}
<span className="plugin-builtins-description-text">{builtinPlugin.description}</span> <span className="plugin-builtins-description-text">{builtinPlugin.description}</span>
</div> </div>
{metadataOnly ? ( <div className="plugin-builtins-actions">
isInstalled && requiresSetupAction ? ( {isRuntimeBuiltin && (
<button <label className="toggle-switch">
className="btn btn-primary btn-sm" <input
onClick={() => void handleInstallBuiltinSetup(builtinPlugin)} type="checkbox"
disabled={installingBuiltinSetupId === builtinPlugin.id || setupCheckInFlight} checked={runtimeEnabled}
> onChange={() => void handleToggleBuiltinRuntime(builtinPlugin, installedPlugin)}
{installingBuiltinSetupId === builtinPlugin.id ? t("plugins.settingUp", "Setting up...") : t("plugins.installSetup", "Install Setup")} disabled={isTogglingRuntime}
</button> aria-label={runtimeEnabled
) : isInstalled && installedPlugin ? ( ? t("plugins.disablePlugin", "Disable {{name}}", { name: builtinPlugin.name })
<button className="btn btn-secondary btn-sm" onClick={() => void handleSelectPlugin(installedPlugin)}> : t("plugins.enablePlugin", "Enable {{name}}", { name: builtinPlugin.name })}
{t("plugins.manage", "Manage")} />
</button> <span className="toggle-slider"></span>
</label>
)}
{metadataOnly ? (
isInstalled && requiresSetupAction ? (
<button
className="btn btn-primary btn-sm"
onClick={() => void handleInstallBuiltinSetup(builtinPlugin)}
disabled={installingBuiltinSetupId === builtinPlugin.id || setupCheckInFlight}
>
{installingBuiltinSetupId === builtinPlugin.id ? t("plugins.settingUp", "Setting up...") : t("plugins.installSetup", "Install Setup")}
</button>
) : isInstalled && installedPlugin ? (
<button className="btn btn-secondary btn-sm" onClick={() => void handleSelectPlugin(installedPlugin)}>
{t("plugins.manage", "Manage")}
</button>
) : isRuntimeBuiltin ? null : (
<span className="plugin-builtins-metadata-only">{t("plugins.builtinMetadataOnly", "Built-in metadata only")}</span>
)
) : ( ) : (
<span className="plugin-builtins-metadata-only">{t("plugins.builtinMetadataOnly", "Built-in metadata only")}</span> <button
) className={`btn ${(isInstalled && !requiresSetupAction) ? "btn-secondary" : "btn-primary"} btn-sm`}
) : ( onClick={() => {
<button if (!isInstalled) {
className={`btn ${(isInstalled && !requiresSetupAction) ? "btn-secondary" : "btn-primary"} btn-sm`} void handleInstallBuiltinPlugin(builtinPlugin);
onClick={() => { return;
if (!isInstalled) { }
void handleInstallBuiltinPlugin(builtinPlugin);
return;
}
if (requiresSetupAction) { if (requiresSetupAction) {
void handleInstallBuiltinSetup(builtinPlugin); void handleInstallBuiltinSetup(builtinPlugin);
return; return;
} }
if (installedPlugin) { if (installedPlugin) {
void handleSelectPlugin(installedPlugin); void handleSelectPlugin(installedPlugin);
}
}}
disabled={
installingBuiltinPluginId === builtinPlugin.id
|| installingBuiltinSetupId === builtinPlugin.id
|| setupCheckInFlight
} }
}} >
disabled={ {!isInstalled
installingBuiltinPluginId === builtinPlugin.id ? (installingBuiltinPluginId === builtinPlugin.id ? t("plugins.installing", "Installing...") : t("plugins.installNamed", "Install {{name}}", { name: builtinPlugin.name }))
|| installingBuiltinSetupId === builtinPlugin.id : requiresSetupAction
|| setupCheckInFlight ? (installingBuiltinSetupId === builtinPlugin.id ? t("plugins.settingUp", "Setting up...") : t("plugins.installSetup", "Install Setup"))
} : t("plugins.manage", "Manage")}
> </button>
{!isInstalled )}
? (installingBuiltinPluginId === builtinPlugin.id ? t("plugins.installing", "Installing...") : t("plugins.installNamed", "Install {{name}}", { name: builtinPlugin.name })) </div>
: requiresSetupAction
? (installingBuiltinSetupId === builtinPlugin.id ? t("plugins.settingUp", "Setting up...") : t("plugins.installSetup", "Install Setup"))
: t("plugins.manage", "Manage")}
</button>
)}
</div> </div>
); );
})} })}

View File

@@ -767,7 +767,7 @@ describe("PluginManager", () => {
expect(screen.getByText("Test Plugin A")).toBeTruthy(); expect(screen.getByText("Test Plugin A")).toBeTruthy();
}); });
const toggle = screen.getByRole("checkbox"); const toggle = screen.getByRole("checkbox", { name: /Test Plugin A/ });
expect(toggle).toBeTruthy(); expect(toggle).toBeTruthy();
expect(toggle).not.toBeChecked(); expect(toggle).not.toBeChecked();
@@ -796,7 +796,7 @@ describe("PluginManager", () => {
expect(screen.getByText("Test Plugin A")).toBeTruthy(); expect(screen.getByText("Test Plugin A")).toBeTruthy();
}); });
await userEvent.click(screen.getByRole("checkbox")); await userEvent.click(screen.getByRole("checkbox", { name: /Test Plugin A/ }));
await waitFor(() => { await waitFor(() => {
expect(addToast).toHaveBeenCalledWith( expect(addToast).toHaveBeenCalledWith(
@@ -818,7 +818,7 @@ describe("PluginManager", () => {
expect(screen.getByText("Test Plugin A")).toBeTruthy(); expect(screen.getByText("Test Plugin A")).toBeTruthy();
}); });
await userEvent.click(screen.getByRole("checkbox")); await userEvent.click(screen.getByRole("checkbox", { name: /Test Plugin A/ }));
await waitFor(() => { await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to enable plugin: network", "error"); expect(addToast).toHaveBeenCalledWith("Failed to enable plugin: network", "error");
@@ -835,7 +835,7 @@ describe("PluginManager", () => {
expect(screen.getByText("Test Plugin A")).toBeTruthy(); expect(screen.getByText("Test Plugin A")).toBeTruthy();
}); });
const toggle = screen.getByRole("checkbox"); const toggle = screen.getByRole("checkbox", { name: /Test Plugin A/ });
expect(toggle).toBeTruthy(); expect(toggle).toBeTruthy();
expect(toggle).toBeChecked(); expect(toggle).toBeChecked();
@@ -1150,7 +1150,7 @@ describe("PluginManager", () => {
}); });
await waitFor(() => { await waitFor(() => {
const toggle = screen.getByRole("checkbox"); const toggle = screen.getByRole("checkbox", { name: /Test Plugin A/ });
expect(toggle).toBeChecked(); expect(toggle).toBeChecked();
}); });
}); });
@@ -1183,7 +1183,7 @@ describe("PluginManager", () => {
}); });
await waitFor(() => { await waitFor(() => {
const toggle = screen.getByRole("checkbox"); const toggle = screen.getByRole("checkbox", { name: /Test Plugin A/ });
expect(toggle).not.toBeChecked(); expect(toggle).not.toBeChecked();
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react"; import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { PluginManager } from "../PluginManager"; import { PluginManager } from "../PluginManager";
import { loadAllAppCss } from "../../test/cssFixture"; import { loadAllAppCss } from "../../test/cssFixture";
@@ -21,7 +21,8 @@ vi.mock("../../api", () => ({
browseDirectory: vi.fn(() => Promise.resolve({ currentPath: "/", parentPath: null, entries: [] })), browseDirectory: vi.fn(() => Promise.resolve({ currentPath: "/", parentPath: null, entries: [] })),
})); }));
import { fetchPlugins, disablePlugin } from "../../api"; import { fetchPlugins, disablePlugin, enablePlugin, installPlugin } from "../../api";
import { BUILTIN_PLUGINS } from "../PluginManager";
const addToast = vi.fn(); const addToast = vi.fn();
@@ -71,6 +72,22 @@ afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
function builtinPlugin(id: string, enabled: boolean) {
const builtin = BUILTIN_PLUGINS.find((p) => p.id === id)!;
return {
id: builtin.id,
name: builtin.name,
version: "1.0.0",
state: "started" as const,
enabled,
path: builtin.path ?? "/plugins/unknown",
settings: {},
settingsSchema: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("PluginManager toggle switch", () => { describe("PluginManager toggle switch", () => {
it("keeps checkbox focusable but visually hidden", async () => { it("keeps checkbox focusable but visually hidden", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]); vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]);
@@ -117,3 +134,86 @@ describe("PluginManager toggle switch", () => {
expect(disabled.nextElementSibling).toHaveClass("toggle-slider"); expect(disabled.nextElementSibling).toHaveClass("toggle-slider");
}); });
}); });
/*
* FNXC:PluginManager 2026-07-07-00:00:
* FN-7629 — regression coverage for the durable built-in runtime enable/disable control.
* Symptom: with no plugin_installs row for Hermes/Paperclip/OpenClaw/Droid (fetchPlugins -> []),
* the built-in runtime rows dead-ended at a static "Built-in metadata only"/install-only CTA and
* offered no way to disable the runtime. Assert every runtime built-in exposes an interactive
* toggle in every data state, and clicking disable on a not-installed runtime persists via
* installPlugin + disablePlugin (the durable register-then-disable path).
*/
describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () => {
const RUNTIME_BUILTINS = BUILTIN_PLUGINS.filter((p) => p.category === "runtime");
async function builtinSection() {
return within(await screen.findByLabelText("Built-in plugin recommendations"));
}
it("renders an interactive, checked toggle for every runtime built-in when not installed", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
render(<PluginManager addToast={addToast} />);
const section = await builtinSection();
for (const runtime of RUNTIME_BUILTINS) {
const toggle = await section.findByRole("checkbox", { name: `Disable ${runtime.name}` });
expect(toggle).toBeChecked();
expect(toggle.closest("label.toggle-switch")).not.toBeNull();
}
// No dead-end "Built-in metadata only" label for any runtime built-in row
// (the metadata-only dead-end may still legitimately appear for non-runtime
// built-ins like Agent Browser, which this task does not change).
for (const runtime of RUNTIME_BUILTINS) {
const row = section.getByText(runtime.name).closest(".plugin-builtins-item") as HTMLElement;
expect(within(row).queryByText("Built-in metadata only")).not.toBeInTheDocument();
}
});
it("disabling a not-installed runtime built-in registers it then disables it (durable path)", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
vi.mocked(installPlugin).mockResolvedValue(builtinPlugin("fusion-plugin-hermes-runtime", true));
render(<PluginManager addToast={addToast} />);
const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Disable Hermes Runtime" });
await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined);
});
await waitFor(() => {
expect(disablePlugin).toHaveBeenCalledWith("fusion-plugin-hermes-runtime", undefined);
});
});
it("toggles an installed, enabled runtime built-in via the standard disable path (no re-install)", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([builtinPlugin("fusion-plugin-paperclip-runtime", true)]);
render(<PluginManager addToast={addToast} />);
const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Disable Paperclip Runtime" });
await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement);
await waitFor(() => {
expect(disablePlugin).toHaveBeenCalledWith("fusion-plugin-paperclip-runtime", undefined);
});
expect(installPlugin).not.toHaveBeenCalled();
});
it("toggles an installed, disabled runtime built-in back on via the standard enable path", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([builtinPlugin("fusion-plugin-openclaw-runtime", false)]);
render(<PluginManager addToast={addToast} />);
const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Enable OpenClaw Runtime" });
expect(toggle).not.toBeChecked();
await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement);
await waitFor(() => {
expect(enablePlugin).toHaveBeenCalledWith("fusion-plugin-openclaw-runtime", undefined);
});
});
});

View File

@@ -3039,6 +3039,7 @@
"savedProbeFailed": "Saved, but probe failed.", "savedProbeFailed": "Saved, but probe failed.",
"settingsSaved": "Settings saved.", "settingsSaved": "Settings saved.",
"statusDetected": "✓ Detected{{version}}{{path}}", "statusDetected": "✓ Detected{{version}}{{path}}",
"statusDisabledInPluginManager": "Disabled in Plugin Manager",
"statusNotDetected": "✗ {{reason}}", "statusNotDetected": "✗ {{reason}}",
"subname": "by Nous Research", "subname": "by Nous Research",
"testFailed": "Test failed — see status above.", "testFailed": "Test failed — see status above.",
@@ -4401,6 +4402,7 @@
"savedNotFound": "Saved · ✗ openclaw not found", "savedNotFound": "Saved · ✗ openclaw not found",
"savedProbeFailed": "Saved, but probe failed.", "savedProbeFailed": "Saved, but probe failed.",
"statusDetected": "✓ Detected{{version}}{{path}}", "statusDetected": "✓ Detected{{version}}{{path}}",
"statusDisabledInPluginManager": "Disabled in Plugin Manager",
"statusNotFound": "✗ not detected on PATH", "statusNotFound": "✗ not detected on PATH",
"subprocess": "CLI subprocess timeout (ms)", "subprocess": "CLI subprocess timeout (ms)",
"subprocessHint": "Fusion-side hard cap before the CLI subprocess is killed (default: {{default}}s).", "subprocessHint": "Fusion-side hard cap before the CLI subprocess is killed (default: {{default}}s).",
@@ -4464,6 +4466,7 @@
"statusCliDiscoveryFailed": "CLI discovery failed: {{reason}}", "statusCliDiscoveryFailed": "CLI discovery failed: {{reason}}",
"statusConnected": "Connected", "statusConnected": "Connected",
"statusConnectedAs": "Connected as {{agentName}}{{roleInfo}}{{companyInfo}}", "statusConnectedAs": "Connected as {{agentName}}{{roleInfo}}{{companyInfo}}",
"statusDisabledInPluginManager": "Disabled in Plugin Manager",
"statusProbing": "Probing Paperclip server…", "statusProbing": "Probing Paperclip server…",
"statusUnreachable": "{{reason}}", "statusUnreachable": "{{reason}}",
"tabApi": "API (URL + token)", "tabApi": "API (URL + token)",
@@ -4660,6 +4663,7 @@
"author": "Author:", "author": "Author:",
"backToList": "Back to plugin list", "backToList": "Back to plugin list",
"browseRegistry": "Browse Registry", "browseRegistry": "Browse Registry",
"builtinDisabled": "Disabled",
"builtinInstalledGlobally": "{{name}} installed globally", "builtinInstalledGlobally": "{{name}} installed globally",
"builtinInstallFailed": "Failed to install {{name}}: {{error}}", "builtinInstallFailed": "Failed to install {{name}}: {{error}}",
"builtinMetadataOnly": "Built-in metadata only", "builtinMetadataOnly": "Built-in metadata only",

View File

@@ -3029,6 +3029,7 @@
"savedProbeFailed": "Guardado, pero la prueba falló.", "savedProbeFailed": "Guardado, pero la prueba falló.",
"settingsSaved": "Configuración guardada.", "settingsSaved": "Configuración guardada.",
"statusDetected": "✓ Detectado{{version}}{{path}}", "statusDetected": "✓ Detectado{{version}}{{path}}",
"statusDisabledInPluginManager": "Deshabilitado en el gestor de complementos",
"statusNotDetected": "✗ {{reason}}", "statusNotDetected": "✗ {{reason}}",
"subname": "por Nous Research", "subname": "por Nous Research",
"testFailed": "La prueba falló — consulte el estado anterior.", "testFailed": "La prueba falló — consulte el estado anterior.",
@@ -4391,6 +4392,7 @@
"savedNotFound": "Guardado · ✗ openclaw no encontrado", "savedNotFound": "Guardado · ✗ openclaw no encontrado",
"savedProbeFailed": "Guardado, pero la prueba falló.", "savedProbeFailed": "Guardado, pero la prueba falló.",
"statusDetected": "✓ Detectado{{version}}{{path}}", "statusDetected": "✓ Detectado{{version}}{{path}}",
"statusDisabledInPluginManager": "Deshabilitado en el gestor de complementos",
"statusNotFound": "✗ no detectado en PATH", "statusNotFound": "✗ no detectado en PATH",
"subprocess": "Tiempo de espera del subproceso CLI (ms)", "subprocess": "Tiempo de espera del subproceso CLI (ms)",
"subprocessHint": "Límite de Fusion antes de que se mate el subproceso CLI (predeterminado: {{default}}s).", "subprocessHint": "Límite de Fusion antes de que se mate el subproceso CLI (predeterminado: {{default}}s).",
@@ -4454,6 +4456,7 @@
"statusCliDiscoveryFailed": "Error al descubrir CLI: {{reason}}", "statusCliDiscoveryFailed": "Error al descubrir CLI: {{reason}}",
"statusConnected": "Conectado", "statusConnected": "Conectado",
"statusConnectedAs": "Conectado como {{agentName}}{{roleInfo}}{{companyInfo}}", "statusConnectedAs": "Conectado como {{agentName}}{{roleInfo}}{{companyInfo}}",
"statusDisabledInPluginManager": "Deshabilitado en el gestor de complementos",
"statusProbing": "Sondeando servidor de Paperclip…", "statusProbing": "Sondeando servidor de Paperclip…",
"statusUnreachable": "{{reason}}", "statusUnreachable": "{{reason}}",
"tabApi": "API (URL + token)", "tabApi": "API (URL + token)",
@@ -4650,6 +4653,7 @@
"author": "Autor:", "author": "Autor:",
"backToList": "Volver a la lista de plugins", "backToList": "Volver a la lista de plugins",
"browseRegistry": "", "browseRegistry": "",
"builtinDisabled": "Deshabilitado",
"builtinInstalledGlobally": "{{name}} instalado globalmente", "builtinInstalledGlobally": "{{name}} instalado globalmente",
"builtinInstallFailed": "Error al instalar {{name}}: {{error}}", "builtinInstallFailed": "Error al instalar {{name}}: {{error}}",
"builtinMetadataOnly": "Solo metadatos integrados", "builtinMetadataOnly": "Solo metadatos integrados",

View File

@@ -3029,6 +3029,7 @@
"savedProbeFailed": "Enregistré, mais la sonde a échoué.", "savedProbeFailed": "Enregistré, mais la sonde a échoué.",
"settingsSaved": "Paramètres enregistrés.", "settingsSaved": "Paramètres enregistrés.",
"statusDetected": "✓ Détecté{{version}}{{path}}", "statusDetected": "✓ Détecté{{version}}{{path}}",
"statusDisabledInPluginManager": "Désactivé dans le gestionnaire de plugins",
"statusNotDetected": "✗ {{reason}}", "statusNotDetected": "✗ {{reason}}",
"subname": "par Nous Research", "subname": "par Nous Research",
"testFailed": "Le test a échoué — voir le statut ci-dessus.", "testFailed": "Le test a échoué — voir le statut ci-dessus.",
@@ -4391,6 +4392,7 @@
"savedNotFound": "Enregistré · ✗ openclaw non trouvé", "savedNotFound": "Enregistré · ✗ openclaw non trouvé",
"savedProbeFailed": "Enregistré, mais la sonde a échoué.", "savedProbeFailed": "Enregistré, mais la sonde a échoué.",
"statusDetected": "✓ Détecté{{version}}{{path}}", "statusDetected": "✓ Détecté{{version}}{{path}}",
"statusDisabledInPluginManager": "Désactivé dans le gestionnaire de plugins",
"statusNotFound": "✗ non détecté sur PATH", "statusNotFound": "✗ non détecté sur PATH",
"subprocess": "Délai d'expiration du sous-processus CLI (ms)", "subprocess": "Délai d'expiration du sous-processus CLI (ms)",
"subprocessHint": "Limite côté Fusion avant la suppression du sous-processus CLI (par défaut: {{default}}s).", "subprocessHint": "Limite côté Fusion avant la suppression du sous-processus CLI (par défaut: {{default}}s).",
@@ -4454,6 +4456,7 @@
"statusCliDiscoveryFailed": "Échec de la découverte CLI : {{reason}}", "statusCliDiscoveryFailed": "Échec de la découverte CLI : {{reason}}",
"statusConnected": "Connecté", "statusConnected": "Connecté",
"statusConnectedAs": "Connecté en tant que {{agentName}}{{roleInfo}}{{companyInfo}}", "statusConnectedAs": "Connecté en tant que {{agentName}}{{roleInfo}}{{companyInfo}}",
"statusDisabledInPluginManager": "Désactivé dans le gestionnaire de plugins",
"statusProbing": "Sondage du serveur Paperclip…", "statusProbing": "Sondage du serveur Paperclip…",
"statusUnreachable": "{{reason}}", "statusUnreachable": "{{reason}}",
"tabApi": "API (URL + jeton)", "tabApi": "API (URL + jeton)",
@@ -4650,6 +4653,7 @@
"author": "Auteur :", "author": "Auteur :",
"backToList": "Retour à la liste des plugins", "backToList": "Retour à la liste des plugins",
"browseRegistry": "", "browseRegistry": "",
"builtinDisabled": "Désactivé",
"builtinInstalledGlobally": "{{name}} installé globalement", "builtinInstalledGlobally": "{{name}} installé globalement",
"builtinInstallFailed": "Échec de l'installation de {{name}} : {{error}}", "builtinInstallFailed": "Échec de l'installation de {{name}} : {{error}}",
"builtinMetadataOnly": "Métadonnées intégrées uniquement", "builtinMetadataOnly": "Métadonnées intégrées uniquement",

View File

@@ -3029,6 +3029,7 @@
"savedProbeFailed": "저장됨, 하지만 탐색에 실패했습니다.", "savedProbeFailed": "저장됨, 하지만 탐색에 실패했습니다.",
"settingsSaved": "설정이 저장되었습니다.", "settingsSaved": "설정이 저장되었습니다.",
"statusDetected": "✓ 감지됨{{version}}{{path}}", "statusDetected": "✓ 감지됨{{version}}{{path}}",
"statusDisabledInPluginManager": "플러그인 관리자에서 비활성화됨",
"statusNotDetected": "✗ {{reason}}", "statusNotDetected": "✗ {{reason}}",
"subname": "by Nous Research", "subname": "by Nous Research",
"testFailed": "테스트 실패 — 위의 상태를 확인하세요.", "testFailed": "테스트 실패 — 위의 상태를 확인하세요.",
@@ -4391,6 +4392,7 @@
"savedNotFound": "저장됨 · ✗ openclaw를 찾을 수 없음", "savedNotFound": "저장됨 · ✗ openclaw를 찾을 수 없음",
"savedProbeFailed": "저장되었지만, 확인에 실패했습니다.", "savedProbeFailed": "저장되었지만, 확인에 실패했습니다.",
"statusDetected": "✓ 감지됨{{version}}{{path}}", "statusDetected": "✓ 감지됨{{version}}{{path}}",
"statusDisabledInPluginManager": "플러그인 관리자에서 비활성화됨",
"statusNotFound": "✗ PATH에서 감지되지 않음", "statusNotFound": "✗ PATH에서 감지되지 않음",
"subprocess": "CLI 서브프로세스 타임아웃 (ms)", "subprocess": "CLI 서브프로세스 타임아웃 (ms)",
"subprocessHint": "CLI 서브프로세스가 종료되기 전 Fusion 측 최대 대기 시간 (기본값: {{default}}s).", "subprocessHint": "CLI 서브프로세스가 종료되기 전 Fusion 측 최대 대기 시간 (기본값: {{default}}s).",
@@ -4454,6 +4456,7 @@
"statusCliDiscoveryFailed": "CLI 검색에 실패했습니다: {{reason}}", "statusCliDiscoveryFailed": "CLI 검색에 실패했습니다: {{reason}}",
"statusConnected": "연결됨", "statusConnected": "연결됨",
"statusConnectedAs": "{{agentName}}{{roleInfo}}{{companyInfo}}(으)로 연결됨", "statusConnectedAs": "{{agentName}}{{roleInfo}}{{companyInfo}}(으)로 연결됨",
"statusDisabledInPluginManager": "플러그인 관리자에서 비활성화됨",
"statusProbing": "Paperclip 서버 확인 중…", "statusProbing": "Paperclip 서버 확인 중…",
"statusUnreachable": "{{reason}}", "statusUnreachable": "{{reason}}",
"tabApi": "API (URL + 토큰)", "tabApi": "API (URL + 토큰)",
@@ -4650,6 +4653,7 @@
"author": "작성자:", "author": "작성자:",
"backToList": "플러그인 목록으로 돌아가기", "backToList": "플러그인 목록으로 돌아가기",
"browseRegistry": "", "browseRegistry": "",
"builtinDisabled": "비활성화됨",
"builtinInstalledGlobally": "{{name}} 전역 설치됨", "builtinInstalledGlobally": "{{name}} 전역 설치됨",
"builtinInstallFailed": "{{name}} 설치 실패: {{error}}", "builtinInstallFailed": "{{name}} 설치 실패: {{error}}",
"builtinMetadataOnly": "기본 제공 메타데이터만", "builtinMetadataOnly": "기본 제공 메타데이터만",

View File

@@ -3029,6 +3029,7 @@
"savedProbeFailed": "已保存,但探测失败。", "savedProbeFailed": "已保存,但探测失败。",
"settingsSaved": "设置已保存。", "settingsSaved": "设置已保存。",
"statusDetected": "✓ 已检测{{version}}{{path}}", "statusDetected": "✓ 已检测{{version}}{{path}}",
"statusDisabledInPluginManager": "已在插件管理器中禁用",
"statusNotDetected": "✗ {{reason}}", "statusNotDetected": "✗ {{reason}}",
"subname": "由 Nous Research 提供", "subname": "由 Nous Research 提供",
"testFailed": "测试失败 — 见上方状态。", "testFailed": "测试失败 — 见上方状态。",
@@ -4391,6 +4392,7 @@
"savedNotFound": "已保存 · ✗ openclaw 未找到", "savedNotFound": "已保存 · ✗ openclaw 未找到",
"savedProbeFailed": "已保存,但探测失败。", "savedProbeFailed": "已保存,但探测失败。",
"statusDetected": "✓ 已检测到{{version}}{{path}}", "statusDetected": "✓ 已检测到{{version}}{{path}}",
"statusDisabledInPluginManager": "已在插件管理器中禁用",
"statusNotFound": "✗ 在 PATH 上未检测到", "statusNotFound": "✗ 在 PATH 上未检测到",
"subprocess": "CLI 子进程超时(毫秒)", "subprocess": "CLI 子进程超时(毫秒)",
"subprocessHint": "Fusion 端限制,超时后 CLI 子进程被终止(默认:{{default}}s)。", "subprocessHint": "Fusion 端限制,超时后 CLI 子进程被终止(默认:{{default}}s)。",
@@ -4454,6 +4456,7 @@
"statusCliDiscoveryFailed": "CLI 发现失败:{{reason}}", "statusCliDiscoveryFailed": "CLI 发现失败:{{reason}}",
"statusConnected": "已连接", "statusConnected": "已连接",
"statusConnectedAs": "已连接为 {{agentName}}{{roleInfo}}{{companyInfo}}", "statusConnectedAs": "已连接为 {{agentName}}{{roleInfo}}{{companyInfo}}",
"statusDisabledInPluginManager": "已在插件管理器中禁用",
"statusProbing": "正在探测 Paperclip 服务器…", "statusProbing": "正在探测 Paperclip 服务器…",
"statusUnreachable": "{{reason}}", "statusUnreachable": "{{reason}}",
"tabApi": "API(URL + 令牌)", "tabApi": "API(URL + 令牌)",
@@ -4650,6 +4653,7 @@
"author": "作者:", "author": "作者:",
"backToList": "返回插件列表", "backToList": "返回插件列表",
"browseRegistry": "", "browseRegistry": "",
"builtinDisabled": "已禁用",
"builtinInstalledGlobally": "{{name}} 已全局安装", "builtinInstalledGlobally": "{{name}} 已全局安装",
"builtinInstallFailed": "安装 {{name}} 失败:{{error}}", "builtinInstallFailed": "安装 {{name}} 失败:{{error}}",
"builtinMetadataOnly": "仅内置元数据", "builtinMetadataOnly": "仅内置元数据",

View File

@@ -3029,6 +3029,7 @@
"savedProbeFailed": "已保存,但探測失敗。", "savedProbeFailed": "已保存,但探測失敗。",
"settingsSaved": "設置已保存。", "settingsSaved": "設置已保存。",
"statusDetected": "✓ 已偵測{{version}}{{path}}", "statusDetected": "✓ 已偵測{{version}}{{path}}",
"statusDisabledInPluginManager": "已在外掛程式管理器中停用",
"statusNotDetected": "✗ {{reason}}", "statusNotDetected": "✗ {{reason}}",
"subname": "由 Nous Research 提供", "subname": "由 Nous Research 提供",
"testFailed": "測試失敗 — 見上方狀態。", "testFailed": "測試失敗 — 見上方狀態。",
@@ -4391,6 +4392,7 @@
"savedNotFound": "已保存 · ✗ openclaw 未找到", "savedNotFound": "已保存 · ✗ openclaw 未找到",
"savedProbeFailed": "已保存,但探測失敗。", "savedProbeFailed": "已保存,但探測失敗。",
"statusDetected": "✓ 已偵測到{{version}}{{path}}", "statusDetected": "✓ 已偵測到{{version}}{{path}}",
"statusDisabledInPluginManager": "已在外掛程式管理器中停用",
"statusNotFound": "✗ 在 PATH 上未偵測到", "statusNotFound": "✗ 在 PATH 上未偵測到",
"subprocess": "CLI 子進程逾時(毫秒)", "subprocess": "CLI 子進程逾時(毫秒)",
"subprocessHint": "Fusion 端限制,逾時後 CLI 子進程被終止(預設:{{default}}s)。", "subprocessHint": "Fusion 端限制,逾時後 CLI 子進程被終止(預設:{{default}}s)。",
@@ -4454,6 +4456,7 @@
"statusCliDiscoveryFailed": "CLI 發現失敗:{{reason}}", "statusCliDiscoveryFailed": "CLI 發現失敗:{{reason}}",
"statusConnected": "已連線", "statusConnected": "已連線",
"statusConnectedAs": "已連線為 {{agentName}}{{roleInfo}}{{companyInfo}}", "statusConnectedAs": "已連線為 {{agentName}}{{roleInfo}}{{companyInfo}}",
"statusDisabledInPluginManager": "已在外掛程式管理器中停用",
"statusProbing": "正在探測 Paperclip 伺服器…", "statusProbing": "正在探測 Paperclip 伺服器…",
"statusUnreachable": "{{reason}}", "statusUnreachable": "{{reason}}",
"tabApi": "API(URL + 令牌)", "tabApi": "API(URL + 令牌)",
@@ -4650,6 +4653,7 @@
"author": "作者:", "author": "作者:",
"backToList": "返回插件列表", "backToList": "返回插件列表",
"browseRegistry": "", "browseRegistry": "",
"builtinDisabled": "已停用",
"builtinInstalledGlobally": "{{name}} 已全域安裝", "builtinInstalledGlobally": "{{name}} 已全域安裝",
"builtinInstallFailed": "安裝 {{name}} 失敗:{{error}}", "builtinInstallFailed": "安裝 {{name}} 失敗:{{error}}",
"builtinMetadataOnly": "僅內建中繼資料", "builtinMetadataOnly": "僅內建中繼資料",

View File

@@ -3040,6 +3040,7 @@ export default interface Resources {
"savedProbeFailed": "Saved, but probe failed.", "savedProbeFailed": "Saved, but probe failed.",
"settingsSaved": "Settings saved.", "settingsSaved": "Settings saved.",
"statusDetected": "✓ Detected{{version}}{{path}}", "statusDetected": "✓ Detected{{version}}{{path}}",
"statusDisabledInPluginManager": "Disabled in Plugin Manager",
"statusNotDetected": "✗ {{reason}}", "statusNotDetected": "✗ {{reason}}",
"subname": "by Nous Research", "subname": "by Nous Research",
"testFailed": "Test failed — see status above.", "testFailed": "Test failed — see status above.",
@@ -4402,6 +4403,7 @@ export default interface Resources {
"savedNotFound": "Saved · ✗ openclaw not found", "savedNotFound": "Saved · ✗ openclaw not found",
"savedProbeFailed": "Saved, but probe failed.", "savedProbeFailed": "Saved, but probe failed.",
"statusDetected": "✓ Detected{{version}}{{path}}", "statusDetected": "✓ Detected{{version}}{{path}}",
"statusDisabledInPluginManager": "Disabled in Plugin Manager",
"statusNotFound": "✗ not detected on PATH", "statusNotFound": "✗ not detected on PATH",
"subprocess": "CLI subprocess timeout (ms)", "subprocess": "CLI subprocess timeout (ms)",
"subprocessHint": "Fusion-side hard cap before the CLI subprocess is killed (default: {{default}}s).", "subprocessHint": "Fusion-side hard cap before the CLI subprocess is killed (default: {{default}}s).",
@@ -4465,6 +4467,7 @@ export default interface Resources {
"statusCliDiscoveryFailed": "CLI discovery failed: {{reason}}", "statusCliDiscoveryFailed": "CLI discovery failed: {{reason}}",
"statusConnected": "Connected", "statusConnected": "Connected",
"statusConnectedAs": "Connected as {{agentName}}{{roleInfo}}{{companyInfo}}", "statusConnectedAs": "Connected as {{agentName}}{{roleInfo}}{{companyInfo}}",
"statusDisabledInPluginManager": "Disabled in Plugin Manager",
"statusProbing": "Probing Paperclip server…", "statusProbing": "Probing Paperclip server…",
"statusUnreachable": "{{reason}}", "statusUnreachable": "{{reason}}",
"tabApi": "API (URL + token)", "tabApi": "API (URL + token)",
@@ -4662,6 +4665,7 @@ export default interface Resources {
"backToList": "Back to plugin list", "backToList": "Back to plugin list",
"browseRegistry": "Browse Registry", "browseRegistry": "Browse Registry",
"builtinInstallFailed": "Failed to install {{name}}: {{error}}", "builtinInstallFailed": "Failed to install {{name}}: {{error}}",
"builtinDisabled": "Disabled",
"builtinInstalledGlobally": "{{name}} installed globally", "builtinInstalledGlobally": "{{name}} installed globally",
"builtinMetadataOnly": "Built-in metadata only", "builtinMetadataOnly": "Built-in metadata only",
"builtinNoPackage": "{{name}} is built in and does not have an installable package yet", "builtinNoPackage": "{{name}} is built in and does not have an installable package yet",