FN-7778: resolve plugin-contributed skills per requesting project instead of daemon root
Fixes plugin skills silently disappearing when the fn daemon is started outside the project that enabled the contributing plugin, by making skill resolution project-aware instead of scoped to the daemon's root PluginLoader. - getPluginSkills now resolves per requesting rootDir against project_plugin_states rather than the daemon-root PluginLoader scope - Plugins skipped as disabled are now logged at load time for visibility - Wired the new project-aware resolution through dashboard.ts, serve.ts, and daemon.ts CLI commands - Added regression coverage in plugin-loader.test.ts and skills-adapter.test.ts - Documented the project-scoped behavior in docs/PLUGIN_AUTHORING.md and docs/agents.md - Added a patch changeset for @runfusion/fusion Files changed: .changeset/fn-7778-plugin-skills-project-scope.md | 7 +++ docs/PLUGIN_AUTHORING.md | 2 + docs/agents.md | 2 +- packages/cli/src/commands/daemon.ts | 68 +++++++++++++++++++-- packages/cli/src/commands/dashboard.ts | 71 ++++++++++++++++++++-- packages/cli/src/commands/serve.ts | 68 +++++++++++++++++++-- packages/core/src/__tests__/plugin-loader.test.ts | 69 +++++++++++++++++++++ packages/core/src/plugin-loader.ts | 29 ++++++--- .../dashboard/src/__tests__/skills-adapter.test.ts | 29 +++++++++ packages/dashboard/src/skills-adapter.ts | 19 ++++-- 10 files changed, 337 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-7778 Fusion-Task-Lineage: 5d9a8ff2-ed0e-4859-bf9c-a16f715b081d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7778-plugin-skills-project-scope.md
Normal file
7
.changeset/fn-7778-plugin-skills-project-scope.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Plugin skills now show for the project that enabled them, even when the daemon starts elsewhere.
|
||||||
|
category: fix
|
||||||
|
dev: getPluginSkills is now project-aware — resolved per requesting rootDir against project_plugin_states instead of the daemon-root PluginLoader scope; plugins skipped as disabled are now logged at load time. Wired in dashboard.ts/serve.ts/daemon.ts. Strategy: B per-project resolution.
|
||||||
@@ -1495,6 +1495,8 @@ const skills: PluginSkillContribution[] = [
|
|||||||
|
|
||||||
`skillFiles` are relative to the plugin root. `skillId` must be kebab-case.
|
`skillFiles` are relative to the plugin root. `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.
|
||||||
|
|
||||||
## 16. Registering Workflow Steps
|
## 16. Registering Workflow Steps
|
||||||
|
|
||||||
Plugins can ship workflow step templates that users can enable like built-in quality gates.
|
Plugins can ship workflow step templates that users can enable like built-in quality gates.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
|
|||||||
- `fn chat <agent-id>` opens an interactive REPL.
|
- `fn chat <agent-id>` opens an interactive REPL.
|
||||||
- Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`.
|
- Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`.
|
||||||
- Agent replies are polled from your inbox and printed as they arrive.
|
- Agent replies are polled from your inbox and printed as they arrive.
|
||||||
- Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills.
|
- Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled for the requesting project. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills.
|
||||||
- Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them.
|
- Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them.
|
||||||
- In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command.
|
- In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command.
|
||||||
- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`.
|
- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`.
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { join } from "node:path";
|
import { join, resolve as pathResolve } from "node:path";
|
||||||
import {
|
import {
|
||||||
CentralCore,
|
CentralCore,
|
||||||
|
TaskStore,
|
||||||
PluginLoader,
|
PluginLoader,
|
||||||
|
PluginStore,
|
||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
INSIGHT_EXTRACTION_SCHEDULE_NAME,
|
INSIGHT_EXTRACTION_SCHEDULE_NAME,
|
||||||
processAndAuditInsightExtraction,
|
processAndAuditInsightExtraction,
|
||||||
@@ -727,14 +729,72 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Skills adapter for skills discovery and execution toggling ─────────────
|
// ── Skills adapter for skills discovery and execution toggling ─────────────
|
||||||
|
const pluginSkillCache = new Map<
|
||||||
|
string,
|
||||||
|
{ enabledKey: string; skills: ReturnType<PluginLoader["getPluginSkills"]> }
|
||||||
|
>();
|
||||||
|
const getProjectScopedPluginSkills = async (rootDir: string): Promise<ReturnType<PluginLoader["getPluginSkills"]>> => {
|
||||||
|
const normalizedRootDir = pathResolve(rootDir);
|
||||||
|
const stateStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() });
|
||||||
|
await stateStore.init();
|
||||||
|
try {
|
||||||
|
const enabledPlugins = await stateStore.listPlugins({ enabled: true });
|
||||||
|
const enabledKey = enabledPlugins
|
||||||
|
.map((plugin) => `${plugin.id}:${plugin.updatedAt}`)
|
||||||
|
.sort()
|
||||||
|
.join("\0");
|
||||||
|
const cached = pluginSkillCache.get(normalizedRootDir);
|
||||||
|
if (cached?.enabledKey === enabledKey) return cached.skills;
|
||||||
|
if (enabledPlugins.length === 0) {
|
||||||
|
const skills: ReturnType<PluginLoader["getPluginSkills"]> = [];
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||||
|
* Same-root skill discovery must reuse the daemon's active PluginLoader; request-scoped loaders are only for other project roots and are stopped after metadata collection to avoid leaking plugin side effects or SQLite handles.
|
||||||
|
*/
|
||||||
|
if (normalizedRootDir === pathResolve(store.getRootDir())) {
|
||||||
|
const enabledIds = new Set(enabledPlugins.map((plugin) => plugin.id));
|
||||||
|
const skills = pluginLoader.getPluginSkills().filter((entry) => enabledIds.has(entry.pluginId));
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedPluginStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() });
|
||||||
|
const scopedTaskStore = new TaskStore(normalizedRootDir);
|
||||||
|
const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: scopedTaskStore });
|
||||||
|
try {
|
||||||
|
await scopedPluginStore.init();
|
||||||
|
await scopedTaskStore.init();
|
||||||
|
const { errors } = await scopedPluginLoader.loadAllPlugins();
|
||||||
|
if (errors > 0) {
|
||||||
|
console.warn(`[plugins] Project-scoped plugin skill loading for ${normalizedRootDir} had ${errors} error(s)`);
|
||||||
|
}
|
||||||
|
const skills = scopedPluginLoader.getPluginSkills();
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
} finally {
|
||||||
|
await scopedPluginLoader.stopAllPlugins();
|
||||||
|
scopedPluginStore.close();
|
||||||
|
scopedTaskStore.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stateStore.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const skillsAdapter = packageManager
|
const skillsAdapter = packageManager
|
||||||
? createSkillsAdapter({
|
? createSkillsAdapter({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
|
||||||
packageManager: packageManager as any,
|
packageManager: packageManager as any,
|
||||||
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
||||||
// Surface plugin-contributed skills (e.g. compound-engineering ce-*) in
|
/*
|
||||||
// the editor catalog; the package manager only scans disk.
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||||
getPluginSkills: () => pluginLoader.getPluginSkills(),
|
* `fn daemon` serves managed projects independently from its startup root. Resolve plugin skills with a PluginStore scoped to the requesting rootDir so disabled daemon-root plugins do not suppress project-enabled plugin:<id> skills.
|
||||||
|
*/
|
||||||
|
getPluginSkills: getProjectScopedPluginSkills,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CentralCore,
|
CentralCore,
|
||||||
AgentStore,
|
AgentStore,
|
||||||
PluginLoader,
|
PluginLoader,
|
||||||
|
PluginStore,
|
||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
getEnabledPiExtensionPaths,
|
getEnabledPiExtensionPaths,
|
||||||
isEphemeralAgent,
|
isEphemeralAgent,
|
||||||
@@ -1679,15 +1680,77 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
//
|
//
|
||||||
// Create the skills adapter using the same DefaultPackageManager instance
|
// Create the skills adapter using the same DefaultPackageManager instance
|
||||||
// that was set up earlier for extension resolution.
|
// that was set up earlier for extension resolution.
|
||||||
|
const pluginSkillCache = new Map<
|
||||||
|
string,
|
||||||
|
{ enabledKey: string; skills: ReturnType<PluginLoader["getPluginSkills"]> }
|
||||||
|
>();
|
||||||
|
const getProjectScopedPluginSkills = async (rootDir: string): Promise<ReturnType<PluginLoader["getPluginSkills"]>> => {
|
||||||
|
const normalizedRootDir = pathResolve(rootDir);
|
||||||
|
const stateStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() });
|
||||||
|
await stateStore.init();
|
||||||
|
try {
|
||||||
|
const enabledPlugins = await stateStore.listPlugins({ enabled: true });
|
||||||
|
const enabledKey = enabledPlugins
|
||||||
|
.map((plugin) => `${plugin.id}:${plugin.updatedAt}`)
|
||||||
|
.sort()
|
||||||
|
.join("\0");
|
||||||
|
const cached = pluginSkillCache.get(normalizedRootDir);
|
||||||
|
if (cached?.enabledKey === enabledKey) {
|
||||||
|
return cached.skills;
|
||||||
|
}
|
||||||
|
if (enabledPlugins.length === 0) {
|
||||||
|
const skills: ReturnType<PluginLoader["getPluginSkills"]> = [];
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||||
|
* Same-root skill discovery must reuse the dashboard daemon's active PluginLoader; request-scoped loaders are only for other project roots and are stopped after metadata collection to avoid leaking plugin side effects or SQLite handles.
|
||||||
|
*/
|
||||||
|
if (normalizedRootDir === pathResolve(store.getRootDir())) {
|
||||||
|
const enabledIds = new Set(enabledPlugins.map((plugin) => plugin.id));
|
||||||
|
const skills = pluginLoader.getPluginSkills().filter((entry) => enabledIds.has(entry.pluginId));
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedPluginStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() });
|
||||||
|
const scopedTaskStore = new TaskStore(normalizedRootDir);
|
||||||
|
const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: scopedTaskStore });
|
||||||
|
try {
|
||||||
|
await scopedPluginStore.init();
|
||||||
|
await scopedTaskStore.init();
|
||||||
|
const { errors } = await scopedPluginLoader.loadAllPlugins();
|
||||||
|
if (errors > 0) {
|
||||||
|
logSink.warn(`Project-scoped plugin skill loading for ${normalizedRootDir} had ${errors} error(s)`, "plugins");
|
||||||
|
}
|
||||||
|
const skills = scopedPluginLoader.getPluginSkills();
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
} finally {
|
||||||
|
await scopedPluginLoader.stopAllPlugins();
|
||||||
|
scopedPluginStore.close();
|
||||||
|
scopedTaskStore.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stateStore.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const skillsAdapter = packageManager
|
const skillsAdapter = packageManager
|
||||||
? createSkillsAdapter({
|
? createSkillsAdapter({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
|
||||||
packageManager: packageManager as any,
|
packageManager: packageManager as any,
|
||||||
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
||||||
// Surface plugin-contributed skills (e.g. compound-engineering ce-*) in
|
/*
|
||||||
// the discovered-skills catalog so the workflow editor can resolve and
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||||
// display them. Lazy thunk: plugins finish loading before discovery runs.
|
* `fn dashboard` can start outside the managed project whose Skills view is being served. Resolve plugin skill contributions with a PluginStore scoped to the requesting rootDir so project_plugin_states, not the daemon root, decides which plugin:<id> skills appear.
|
||||||
getPluginSkills: () => pluginLoader.getPluginSkills(),
|
*/
|
||||||
|
getPluginSkills: getProjectScopedPluginSkills,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { join } from "node:path";
|
import { join, resolve as pathResolve } from "node:path";
|
||||||
import {
|
import {
|
||||||
CentralCore,
|
CentralCore,
|
||||||
|
TaskStore,
|
||||||
PluginLoader,
|
PluginLoader,
|
||||||
|
PluginStore,
|
||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
INSIGHT_EXTRACTION_SCHEDULE_NAME,
|
INSIGHT_EXTRACTION_SCHEDULE_NAME,
|
||||||
processAndAuditInsightExtraction,
|
processAndAuditInsightExtraction,
|
||||||
@@ -808,14 +810,72 @@ export async function runServe(
|
|||||||
//
|
//
|
||||||
// Create the skills adapter using the same DefaultPackageManager instance
|
// Create the skills adapter using the same DefaultPackageManager instance
|
||||||
// that was set up earlier for extension resolution.
|
// that was set up earlier for extension resolution.
|
||||||
|
const pluginSkillCache = new Map<
|
||||||
|
string,
|
||||||
|
{ enabledKey: string; skills: ReturnType<PluginLoader["getPluginSkills"]> }
|
||||||
|
>();
|
||||||
|
const getProjectScopedPluginSkills = async (rootDir: string): Promise<ReturnType<PluginLoader["getPluginSkills"]>> => {
|
||||||
|
const normalizedRootDir = pathResolve(rootDir);
|
||||||
|
const stateStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() });
|
||||||
|
await stateStore.init();
|
||||||
|
try {
|
||||||
|
const enabledPlugins = await stateStore.listPlugins({ enabled: true });
|
||||||
|
const enabledKey = enabledPlugins
|
||||||
|
.map((plugin) => `${plugin.id}:${plugin.updatedAt}`)
|
||||||
|
.sort()
|
||||||
|
.join("\0");
|
||||||
|
const cached = pluginSkillCache.get(normalizedRootDir);
|
||||||
|
if (cached?.enabledKey === enabledKey) return cached.skills;
|
||||||
|
if (enabledPlugins.length === 0) {
|
||||||
|
const skills: ReturnType<PluginLoader["getPluginSkills"]> = [];
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||||
|
* Same-root skill discovery must reuse the daemon's active PluginLoader; request-scoped loaders are only for other project roots and are stopped after metadata collection to avoid leaking plugin side effects or SQLite handles.
|
||||||
|
*/
|
||||||
|
if (normalizedRootDir === pathResolve(store.getRootDir())) {
|
||||||
|
const enabledIds = new Set(enabledPlugins.map((plugin) => plugin.id));
|
||||||
|
const skills = pluginLoader.getPluginSkills().filter((entry) => enabledIds.has(entry.pluginId));
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedPluginStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() });
|
||||||
|
const scopedTaskStore = new TaskStore(normalizedRootDir);
|
||||||
|
const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: scopedTaskStore });
|
||||||
|
try {
|
||||||
|
await scopedPluginStore.init();
|
||||||
|
await scopedTaskStore.init();
|
||||||
|
const { errors } = await scopedPluginLoader.loadAllPlugins();
|
||||||
|
if (errors > 0) {
|
||||||
|
console.warn(`[plugins] Project-scoped plugin skill loading for ${normalizedRootDir} had ${errors} error(s)`);
|
||||||
|
}
|
||||||
|
const skills = scopedPluginLoader.getPluginSkills();
|
||||||
|
pluginSkillCache.set(normalizedRootDir, { enabledKey, skills });
|
||||||
|
return skills;
|
||||||
|
} finally {
|
||||||
|
await scopedPluginLoader.stopAllPlugins();
|
||||||
|
scopedPluginStore.close();
|
||||||
|
scopedTaskStore.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stateStore.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const skillsAdapter = packageManager
|
const skillsAdapter = packageManager
|
||||||
? createSkillsAdapter({
|
? createSkillsAdapter({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
|
||||||
packageManager: packageManager as any,
|
packageManager: packageManager as any,
|
||||||
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
||||||
// Surface plugin-contributed skills (e.g. compound-engineering ce-*) in
|
/*
|
||||||
// the editor catalog; the package manager only scans disk.
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||||
getPluginSkills: () => pluginLoader.getPluginSkills(),
|
* `fn serve` may serve a project other than the daemon startup root. Resolve plugin skills through a requesting-root PluginStore so plugin:<id> catalog entries follow that project's enablement and cannot leak from the daemon root.
|
||||||
|
*/
|
||||||
|
getPluginSkills: getProjectScopedPluginSkills,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const plugin = {
|
|||||||
hooks: {},
|
hooks: {},
|
||||||
tools: ${JSON.stringify(plugin.tools || [])},
|
tools: ${JSON.stringify(plugin.tools || [])},
|
||||||
routes: ${JSON.stringify(plugin.routes || [])},
|
routes: ${JSON.stringify(plugin.routes || [])},
|
||||||
|
skills: ${JSON.stringify(plugin.skills || [])},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default plugin;
|
export default plugin;
|
||||||
@@ -915,12 +916,16 @@ export default plugin;
|
|||||||
await pluginStore.registerPlugin({ manifest: disabledPlugin.manifest, path: disabledPath });
|
await pluginStore.registerPlugin({ manifest: disabledPlugin.manifest, path: disabledPath });
|
||||||
await pluginStore.disablePlugin("disabled-plugin");
|
await pluginStore.disablePlugin("disabled-plugin");
|
||||||
|
|
||||||
|
const { loggerMap } = mockStructuredLoggerFactory();
|
||||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||||
const result = await loader.loadAllPlugins();
|
const result = await loader.loadAllPlugins();
|
||||||
|
|
||||||
expect(result).toEqual({ loaded: 1, errors: 0 });
|
expect(result).toEqual({ loaded: 1, errors: 0 });
|
||||||
expect(loader.isPluginLoaded("enabled-plugin")).toBe(true);
|
expect(loader.isPluginLoaded("enabled-plugin")).toBe(true);
|
||||||
expect(loader.isPluginLoaded("disabled-plugin")).toBe(false);
|
expect(loader.isPluginLoaded("disabled-plugin")).toBe(false);
|
||||||
|
expect(loggerMap.get("plugin-loader")?.warn).toHaveBeenCalledWith(
|
||||||
|
"Skipped disabled plugin during loadAllPlugins: disabled-plugin",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -2588,6 +2593,70 @@ export default plugin;
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads plugin skills according to each project's enablement scope", async () => {
|
||||||
|
await pluginStore.init();
|
||||||
|
const projectDir = join(rootDir, "managed-project");
|
||||||
|
await mkdir(projectDir, { recursive: true });
|
||||||
|
const projectPluginStore = new PluginStore(projectDir, { centralGlobalDir: rootDir });
|
||||||
|
await projectPluginStore.init();
|
||||||
|
|
||||||
|
const pluginDir = join(rootDir, "plugins");
|
||||||
|
const daemonPlugin = makePlugin(makeManifest({ id: "daemon-skill-plugin" }));
|
||||||
|
daemonPlugin.skills = [{ skillId: "daemon", name: "daemon-only", skillFiles: ["./SKILL.md"] }];
|
||||||
|
const projectPlugin = makePlugin(makeManifest({ id: "project-skill-plugin" }));
|
||||||
|
projectPlugin.skills = [{ skillId: "project", name: "project-only", skillFiles: ["./SKILL.md"] }];
|
||||||
|
const sharedPlugin = makePlugin(makeManifest({ id: "shared-skill-plugin" }));
|
||||||
|
sharedPlugin.skills = [{ skillId: "shared", name: "shared-skill", skillFiles: ["./SKILL.md"] }];
|
||||||
|
const disabledPlugin = makePlugin(makeManifest({ id: "disabled-everywhere-skill-plugin" }));
|
||||||
|
disabledPlugin.skills = [{ skillId: "disabled", name: "disabled-skill", skillFiles: ["./SKILL.md"] }];
|
||||||
|
|
||||||
|
await pluginStore.registerPlugin({
|
||||||
|
manifest: daemonPlugin.manifest,
|
||||||
|
path: await writePluginModule(pluginDir, "daemon-skill.js", daemonPlugin),
|
||||||
|
});
|
||||||
|
const projectRelativePluginDir = join(projectDir, "plugins");
|
||||||
|
await pluginStore.registerPlugin({
|
||||||
|
manifest: projectPlugin.manifest,
|
||||||
|
path: "plugins/project-skill.js",
|
||||||
|
});
|
||||||
|
await writePluginModule(projectRelativePluginDir, "project-skill.js", projectPlugin);
|
||||||
|
await pluginStore.registerPlugin({
|
||||||
|
manifest: sharedPlugin.manifest,
|
||||||
|
path: await writePluginModule(pluginDir, "shared-skill.js", sharedPlugin),
|
||||||
|
});
|
||||||
|
await pluginStore.registerPlugin({
|
||||||
|
manifest: disabledPlugin.manifest,
|
||||||
|
path: await writePluginModule(pluginDir, "disabled-skill.js", disabledPlugin),
|
||||||
|
});
|
||||||
|
|
||||||
|
await pluginStore.disablePlugin("project-skill-plugin");
|
||||||
|
await pluginStore.disablePlugin("disabled-everywhere-skill-plugin");
|
||||||
|
await projectPluginStore.enablePlugin("project-skill-plugin");
|
||||||
|
await projectPluginStore.enablePlugin("shared-skill-plugin");
|
||||||
|
|
||||||
|
const daemonTaskStore = { ...mockTaskStore, getRootDir: () => rootDir };
|
||||||
|
const projectTaskStore = { ...mockTaskStore, getRootDir: () => projectDir };
|
||||||
|
const daemonLoader = new PluginLoader({ pluginStore, taskStore: daemonTaskStore });
|
||||||
|
const projectLoader = new PluginLoader({ pluginStore: projectPluginStore, taskStore: projectTaskStore });
|
||||||
|
|
||||||
|
await daemonLoader.loadAllPlugins();
|
||||||
|
await projectLoader.loadAllPlugins();
|
||||||
|
|
||||||
|
expect(daemonLoader.getPluginSkills().map((entry) => `${entry.pluginId}:${entry.skill.name}`).sort()).toEqual([
|
||||||
|
"daemon-skill-plugin:daemon-only",
|
||||||
|
"shared-skill-plugin:shared-skill",
|
||||||
|
]);
|
||||||
|
expect(projectLoader.getPluginSkills().map((entry) => `${entry.pluginId}:${entry.skill.name}`).sort()).toEqual([
|
||||||
|
"project-skill-plugin:project-only",
|
||||||
|
"shared-skill-plugin:shared-skill",
|
||||||
|
]);
|
||||||
|
expect(projectLoader.getPluginSkills().some((entry) => entry.pluginId === "daemon-skill-plugin")).toBe(false);
|
||||||
|
expect(daemonLoader.getPluginSkills().some((entry) => entry.pluginId === "project-skill-plugin")).toBe(false);
|
||||||
|
expect(projectLoader.getPluginSkills().some((entry) => entry.pluginId === "disabled-everywhere-skill-plugin")).toBe(false);
|
||||||
|
|
||||||
|
projectPluginStore.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns workflow steps, prompt contributions, and setup info", async () => {
|
it("returns workflow steps, prompt contributions, and setup info", async () => {
|
||||||
await pluginStore.init();
|
await pluginStore.init();
|
||||||
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||||
|
|||||||
@@ -705,11 +705,20 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
// ── Load All ──────────────────────────────────────────────────────
|
// ── Load All ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all enabled plugins in dependency order.
|
* Load all plugins that are enabled for this PluginStore's project scope in dependency order.
|
||||||
*/
|
*/
|
||||||
async loadAllPlugins(): Promise<{ loaded: number; errors: number }> {
|
async loadAllPlugins(): Promise<{ loaded: number; errors: number }> {
|
||||||
const enabled = await this.options.pluginStore.listPlugins({ enabled: true });
|
const plugins = await this.options.pluginStore.listPlugins();
|
||||||
const sorted = this.resolveLoadOrder(enabled);
|
for (const installation of plugins) {
|
||||||
|
if (!installation.enabled) {
|
||||||
|
/*
|
||||||
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||||
|
* Disabled-at-load plugins must be visible in normal daemon logs. Issue #1981 was expensive to diagnose because loadAllPlugins silently omitted disabled plugins, hiding that the loader was using the wrong project enablement scope.
|
||||||
|
*/
|
||||||
|
this.log.warn(`Skipped disabled plugin during loadAllPlugins: ${installation.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = this.resolveLoadOrder(plugins.filter((plugin) => plugin.enabled));
|
||||||
|
|
||||||
let loaded = 0;
|
let loaded = 0;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
@@ -719,13 +728,15 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
await this.loadPlugin(installation.id);
|
await this.loadPlugin(installation.id);
|
||||||
loaded++;
|
loaded++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as { code?: string }).code !== "PLUGIN_DISABLED") {
|
if ((err as { code?: string }).code === "PLUGIN_DISABLED") {
|
||||||
errors++;
|
this.log.warn(`Skipped disabled plugin during loadAllPlugins: ${installation.id}`);
|
||||||
this.log.error(
|
continue;
|
||||||
`Failed to load plugin ${installation.id}:`,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
errors++;
|
||||||
|
this.log.error(
|
||||||
|
`Failed to load plugin ${installation.id}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -800,6 +800,35 @@ describe("createSkillsAdapter - plugin skill merge", () => {
|
|||||||
expect(byName.get("ce-plan")!.id).toContain("::");
|
expect(byName.get("ce-plan")!.id).toContain("::");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the requesting project root into async plugin-skill discovery", async () => {
|
||||||
|
const daemonRoot = "/tmp/daemon-root";
|
||||||
|
const projectRoot = "/tmp/managed-project";
|
||||||
|
const getPluginSkills = vi.fn(async (rootDir: string) => {
|
||||||
|
if (rootDir === projectRoot) {
|
||||||
|
return [{ pluginId: "project-plugin", skill: { name: "project-only-skill" } }];
|
||||||
|
}
|
||||||
|
if (rootDir === daemonRoot) {
|
||||||
|
return [{ pluginId: "daemon-plugin", skill: { name: "daemon-only-skill" } }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const adapter = createSkillsAdapter({
|
||||||
|
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
|
||||||
|
getSettingsPath: () => "/tmp/does-not-exist-settings.json",
|
||||||
|
getPluginSkills,
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectSkills = await adapter.discoverSkills(projectRoot);
|
||||||
|
const daemonSkills = await adapter.discoverSkills(daemonRoot);
|
||||||
|
|
||||||
|
expect(getPluginSkills).toHaveBeenCalledWith(projectRoot);
|
||||||
|
expect(getPluginSkills).toHaveBeenCalledWith(daemonRoot);
|
||||||
|
expect(projectSkills.map((skill) => skill.metadata.source)).toEqual(["plugin:project-plugin"]);
|
||||||
|
expect(projectSkills.map((skill) => skill.name)).toEqual(["project-only-skill"]);
|
||||||
|
expect(daemonSkills.map((skill) => skill.metadata.source)).toEqual(["plugin:daemon-plugin"]);
|
||||||
|
expect(daemonSkills.map((skill) => skill.name)).toEqual(["daemon-only-skill"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("dedups a plugin skill that is already discovered on disk (by bare name)", async () => {
|
it("dedups a plugin skill that is already discovered on disk (by bare name)", async () => {
|
||||||
const adapter = createSkillsAdapter({
|
const adapter = createSkillsAdapter({
|
||||||
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [diskSkillResource] }) },
|
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [diskSkillResource] }) },
|
||||||
|
|||||||
@@ -355,10 +355,15 @@ export function createSkillsAdapter(options: {
|
|||||||
* skill catalog omits them. Lazy thunk: plugins may load after the adapter is
|
* skill catalog omits them. Lazy thunk: plugins may load after the adapter is
|
||||||
* created, so it is invoked per discovery rather than captured eagerly.
|
* created, so it is invoked per discovery rather than captured eagerly.
|
||||||
*/
|
*/
|
||||||
getPluginSkills?: () => Array<{
|
getPluginSkills?: (rootDir: string) =>
|
||||||
pluginId: string;
|
| Array<{
|
||||||
skill: { name: string; description?: string; enabled?: boolean };
|
pluginId: string;
|
||||||
}>;
|
skill: { name: string; description?: string; enabled?: boolean };
|
||||||
|
}>
|
||||||
|
| Promise<Array<{
|
||||||
|
pluginId: string;
|
||||||
|
skill: { name: string; description?: string; enabled?: boolean };
|
||||||
|
}>>;
|
||||||
/** Optional superviseSpawn seam for tests */
|
/** Optional superviseSpawn seam for tests */
|
||||||
superviseSpawn?: typeof superviseSpawn;
|
superviseSpawn?: typeof superviseSpawn;
|
||||||
}): SkillsAdapter {
|
}): SkillsAdapter {
|
||||||
@@ -413,7 +418,11 @@ export function createSkillsAdapter(options: {
|
|||||||
// would otherwise never appear in the editor catalog. Dedup against disk
|
// would otherwise never appear in the editor catalog. Dedup against disk
|
||||||
// skills by bare name so a plugin skill that is also installed on disk is
|
// skills by bare name so a plugin skill that is also installed on disk is
|
||||||
// not listed twice.
|
// not listed twice.
|
||||||
const pluginSkills = options.getPluginSkills?.() ?? [];
|
/*
|
||||||
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||||
|
* Skill discovery is project-scoped: plugin contributions must be resolved for the requesting rootDir's project_plugin_states, not the daemon startup directory. This keeps /api/skills/discovered from leaking daemon-root plugin skills into unrelated projects while still surfacing skills enabled only for the requested managed project.
|
||||||
|
*/
|
||||||
|
const pluginSkills = await (options.getPluginSkills?.(rootDir) ?? []);
|
||||||
if (pluginSkills.length > 0) {
|
if (pluginSkills.length > 0) {
|
||||||
const seenBareNames = new Set(discoveredSkills.map((s) => bareSkillName(s.name)));
|
const seenBareNames = new Set(discoveredSkills.map((s) => bareSkillName(s.name)));
|
||||||
for (const { pluginId, skill } of pluginSkills) {
|
for (const { pluginId, skill } of pluginSkills) {
|
||||||
|
|||||||
Reference in New Issue
Block a user