fix(FN-1952): restore pi auth and extension loading

This commit is contained in:
gsxdsm
2026-04-16 21:42:16 -07:00
parent 22e411a82b
commit 32a92e961e
30 changed files with 644 additions and 72 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Read legacy Pi model and extension configuration from Fusion, restore Kimi provider resolution, and add dashboard controls for loading Pi extensions.

View File

@@ -459,6 +459,7 @@ vi.mock("@fusion/core", () => ({
PluginLoader: mocks.pluginLoaderCtor,
GlobalSettingsStore: vi.fn().mockImplementation(() => mocks.globalSettingsStoreInstance),
resolveGlobalDir: vi.fn().mockReturnValue("/home/user/.fusion"),
getEnabledPiExtensionPaths: vi.fn(() => []),
DaemonTokenManager: vi.fn().mockImplementation(() => ({
getToken: vi.fn().mockImplementation(() => Promise.resolve(mocks.globalSettingsData.daemonToken as string | undefined)),
generateToken: vi.fn().mockImplementation(() => {

View File

@@ -150,6 +150,7 @@ vi.mock("@fusion/core", () => ({
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
})),
getEnabledPiExtensionPaths: vi.fn(() => []),
getTaskMergeBlocker: vi.fn().mockReturnValue(undefined),
syncInsightExtractionAutomation: mockSyncInsightExtraction,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
@@ -479,7 +480,11 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
await runDashboard(0, {});
expect(mockDiscoverAndLoadExtensions).toHaveBeenCalledWith([], expect.any(String), undefined);
expect(mockDiscoverAndLoadExtensions).toHaveBeenCalledWith(
[],
expect.any(String),
expect.stringContaining(".fusion/disabled-auto-extension-discovery"),
);
expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith(
"custom-anthropic",
expect.objectContaining({ models: [{ id: "claude-sonnet-4-5" }] }),
@@ -786,8 +791,7 @@ describe("runDashboard — multi-project cwd/default engine resolution", () => {
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
// Verify CentralCore.getProjectByPath was called with cwd
const centralCore = centralInstances[0];
expect(centralCore.getProjectByPath).toHaveBeenCalled();
expect(centralInstances.some((instance) => instance.getProjectByPath.mock.calls.length > 0)).toBe(true);
// Verify createServer received an engine (the cwd/default engine)
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];

View File

@@ -495,6 +495,7 @@ vi.mock("@fusion/core", () => ({
CentralCore: mocks.centralCoreCtor,
PluginStore: mocks.pluginStoreCtor,
PluginLoader: mocks.pluginLoaderCtor,
getEnabledPiExtensionPaths: vi.fn(() => []),
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",

View File

@@ -1,8 +1,17 @@
import { homedir } from "node:os";
import { existsSync } from "node:fs";
import { join } from "node:path";
export function getFusionAgentDir(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".fusion", "agent");
}
export function getLegacyAgentDir(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".pi", "agent");
}
export function getFusionAuthPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".fusion", "agent", "auth.json");
return join(getFusionAgentDir(home), "auth.json");
}
export function getLegacyAuthPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
@@ -11,3 +20,36 @@ export function getLegacyAuthPaths(home = process.env.HOME || process.env.USERPR
join(home, ".pi", "auth.json"),
];
}
export function getFusionModelsPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(getFusionAgentDir(home), "models.json");
}
export function getLegacyModelsPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
return [
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
}
export function getModelRegistryModelsPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
const fusionModelsPath = getFusionModelsPath(home);
if (existsSync(fusionModelsPath)) {
return fusionModelsPath;
}
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
export function getPackageManagerAgentDir(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
const fusionAgentDir = getFusionAgentDir(home);
if (
existsSync(join(fusionAgentDir, "settings.json")) ||
existsSync(join(fusionAgentDir, "extensions"))
) {
return fusionAgentDir;
}
const legacyAgentDir = getLegacyAgentDir(home);
return existsSync(legacyAgentDir) ? legacyAgentDir : fusionAgentDir;
}

View File

@@ -9,6 +9,7 @@
*/
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import {
CentralCore,
PluginStore,
@@ -19,6 +20,7 @@ import {
DaemonTokenManager,
GlobalSettingsStore,
resolveGlobalDir,
getEnabledPiExtensionPaths,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
@@ -28,7 +30,6 @@ import {
DefaultPackageManager,
ModelRegistry,
discoverAndLoadExtensions,
getAgentDir,
createExtensionRuntime,
} from "@mariozechner/pi-coding-agent";
import {
@@ -38,12 +39,21 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
let daemonDbHealthCheck: (() => boolean) | null = null;
async function resolveRuntimeProjectPath(): Promise<string> {
try {
return (await resolveProject(undefined)).projectPath;
} catch {
return process.cwd();
}
}
/**
* Format bytes to human-readable string
*/
@@ -198,7 +208,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
const selectedHost = opts.host ?? "0.0.0.0";
const cwd = process.cwd();
const cwd = await resolveRuntimeProjectPath();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
let ntfyProjectId: string | undefined;
@@ -328,13 +338,13 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const modelRegistry = new ModelRegistry(mergedAuthStorage);
const modelRegistry = new ModelRegistry(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails
let packageManager: DefaultPackageManager | undefined;
try {
const agentDir = getAgentDir();
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
agentDir,
@@ -346,9 +356,9 @@ export async function runDaemon(opts: DaemonOptions = {}) {
.map((r) => r.path);
const extensionsResult = await discoverAndLoadExtensions(
packageExtensionPaths,
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
cwd,
undefined,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {

View File

@@ -156,6 +156,7 @@ vi.mock("@fusion/core", () => ({
emit: emitter.emit.bind(emitter),
};
}),
getEnabledPiExtensionPaths: vi.fn(() => []),
getTaskMergeBlocker: vi.fn((task: any) => {
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
if (task.paused) return "task is paused";

View File

@@ -1,8 +1,9 @@
import type { AddressInfo } from "node:net";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker } from "@fusion/core";
import { join } from "node:path";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, getEnabledPiExtensionPaths } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
import { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import {
getMergeStrategy,
processPullRequestMergeTask,
@@ -10,7 +11,8 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
// Re-export for backward compatibility with tests
export { promptForPort };
@@ -183,6 +185,14 @@ function setDiagnosticStoreListenerCheck(check: () => Record<string, number>): v
diagnosticStoreListenerCheck = check;
}
async function resolveRuntimeProjectPath(): Promise<string> {
try {
return (await resolveProject(undefined)).projectPath;
} catch {
return process.cwd();
}
}
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean } = {}) {
ensureProcessDiagnostics();
@@ -199,7 +209,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
throw err;
}
}
const cwd = process.cwd();
const cwd = await resolveRuntimeProjectPath();
const store = new TaskStore(cwd);
await store.init();
await store.watch();
@@ -365,7 +375,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const modelRegistry = new ModelRegistry(mergedAuthStorage);
const modelRegistry = new ModelRegistry(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails
@@ -374,7 +384,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Resolve extension paths from pi settings packages (npm, git, local).
// This picks up extensions like @howaboua/pi-glm-via-anthropic that
// register custom providers (e.g. glm-5.1) via registerProvider().
const agentDir = getAgentDir();
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
agentDir,
@@ -385,8 +395,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
.filter((r) => r.enabled)
.map((r) => r.path);
// Load all extensions: filesystem-discovered + package-resolved
const extensionsResult = await discoverAndLoadExtensions(packageExtensionPaths, cwd, undefined);
// Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved.
const extensionsResult = await discoverAndLoadExtensions(
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {
console.log(`[extensions] Failed to load ${path}: ${error}`);

View File

@@ -10,6 +10,7 @@
*/
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import {
CentralCore,
PluginStore,
@@ -20,6 +21,7 @@ import {
DaemonTokenManager,
GlobalSettingsStore,
resolveGlobalDir,
getEnabledPiExtensionPaths,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
@@ -29,7 +31,6 @@ import {
DefaultPackageManager,
ModelRegistry,
discoverAndLoadExtensions,
getAgentDir,
createExtensionRuntime,
} from "@mariozechner/pi-coding-agent";
import {
@@ -39,13 +40,22 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
let serveStartTime = 0;
let serveDbHealthCheck: (() => boolean) | null = null;
async function resolveRuntimeProjectPath(): Promise<string> {
try {
return (await resolveProject(undefined)).projectPath;
} catch {
return process.cwd();
}
}
/**
* Format bytes to human-readable string
*/
@@ -209,7 +219,7 @@ export async function runServe(
}
const selectedHost = opts.host ?? "0.0.0.0";
const cwd = process.cwd();
const cwd = await resolveRuntimeProjectPath();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
//
@@ -387,13 +397,13 @@ export async function runServe(
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const modelRegistry = new ModelRegistry(mergedAuthStorage);
const modelRegistry = new ModelRegistry(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails
let packageManager: DefaultPackageManager | undefined;
try {
const agentDir = getAgentDir();
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
agentDir,
@@ -405,9 +415,9 @@ export async function runServe(
.map((r) => r.path);
const extensionsResult = await discoverAndLoadExtensions(
packageExtensionPaths,
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
cwd,
undefined,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {

View File

@@ -14,8 +14,9 @@ import {
isGhAvailable,
runGhJsonAsync,
} from "@fusion/core/gh-cli";
import { resolve, basename, extname } from "node:path";
import { resolve, basename, extname, join } from "node:path";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { spawn, type ChildProcess } from "node:child_process";
// ── Helpers ────────────────────────────────────────────────────────
@@ -36,19 +37,39 @@ const MIME_TYPES: Record<string, string> = {
".xml": "application/xml",
};
/** Cache stores per cwd to avoid re-init on every tool call. */
function resolveProjectRoot(cwd: string): string {
let current = resolve(cwd);
while (true) {
if (existsSync(join(current, ".fusion"))) {
return current;
}
const parent = resolve(current, "..");
if (parent === current) {
return resolve(cwd);
}
current = parent;
}
}
/** Cache stores per project root to avoid re-init on every tool call. */
const storeCache = new Map<string, TaskStore>();
async function getStore(cwd: string): Promise<TaskStore> {
const existing = storeCache.get(cwd);
const projectRoot = resolveProjectRoot(cwd);
const existing = storeCache.get(projectRoot);
if (existing) return existing;
const store = new TaskStore(cwd);
const store = new TaskStore(projectRoot);
await store.init();
storeCache.set(cwd, store);
storeCache.set(projectRoot, store);
return store;
}
function getFusionDir(cwd: string): string {
return join(resolveProjectRoot(cwd), ".fusion");
}
function formatTaskLine(t: Task): string {
const label =
t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
@@ -1534,7 +1555,7 @@ export default function kbExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: ctx.cwd + "/.fusion" });
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(params.id);
@@ -1597,7 +1618,7 @@ export default function kbExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: ctx.cwd + "/.fusion" });
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(params.id);
@@ -1759,7 +1780,7 @@ export default function kbExtension(pi: ExtensionAPI) {
// Execute via spawn
const child = spawn("npx", npxArgs, {
cwd: ctx.cwd,
cwd: resolveProjectRoot(ctx.cwd),
stdio: "pipe",
});
@@ -1864,7 +1885,7 @@ export default function kbExtension(pi: ExtensionAPI) {
// Find the fn binary: prefer local node_modules, then global
const child = spawn("fn", ["dashboard", "--port", String(port)], {
cwd: ctx.cwd,
cwd: resolveProjectRoot(ctx.cwd),
stdio: ["ignore", "pipe", "pipe"],
detached: false,
env: { ...process.env },

View File

@@ -48,6 +48,8 @@ export { ArchiveDatabase } from "./archive-db.js";
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";
export {

View File

@@ -0,0 +1,235 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join, relative, resolve, sep } from "node:path";
const FUSION_DISABLED_EXTENSIONS_KEY = "fusionDisabledExtensions";
export type PiExtensionSource = "fusion-global" | "pi-global" | "fusion-project" | "pi-project";
export interface PiExtensionEntry {
id: string;
name: string;
path: string;
source: PiExtensionSource;
enabled: boolean;
}
export interface PiExtensionSettings {
extensions: PiExtensionEntry[];
disabledIds: string[];
settingsPath: string;
}
function getHomeDir(home?: string): string {
return home ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
}
export function getFusionAgentDir(home?: string): string {
return join(getHomeDir(home), ".fusion", "agent");
}
export function getLegacyPiAgentDir(home?: string): string {
return join(getHomeDir(home), ".pi", "agent");
}
export function getFusionAgentSettingsPath(home?: string): string {
return join(getFusionAgentDir(home), "settings.json");
}
export function resolvePiExtensionProjectRoot(cwd: string): string {
let current = resolve(cwd);
while (true) {
if (existsSync(join(current, ".fusion"))) {
return current;
}
const parent = resolve(current, "..");
if (parent === current) {
return resolve(cwd);
}
current = parent;
}
}
function sourceForDir(dir: string, cwd: string, home?: string): PiExtensionSource {
const projectRoot = resolvePiExtensionProjectRoot(cwd);
const resolved = resolve(dir);
if (resolved === resolve(projectRoot, ".fusion", "extensions")) return "fusion-project";
if (resolved === resolve(projectRoot, ".pi", "extensions")) return "pi-project";
if (resolved === resolve(getFusionAgentDir(home), "extensions")) return "fusion-global";
return "pi-global";
}
function extensionName(extensionPath: string): string {
const base = basename(extensionPath).replace(/\.(ts|js)$/i, "");
if (base === "index") {
return basename(resolve(extensionPath, ".."));
}
return base;
}
function readPiManifest(packageJsonPath: string): { extensions?: string[] } | null {
try {
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { pi?: { extensions?: unknown } };
if (parsed.pi && Array.isArray(parsed.pi.extensions)) {
return { extensions: parsed.pi.extensions.filter((entry): entry is string => typeof entry === "string") };
}
} catch {
// Ignore invalid extension manifests.
}
return null;
}
function resolveExtensionEntries(dir: string): string[] | null {
const packageJsonPath = join(dir, "package.json");
if (existsSync(packageJsonPath)) {
const manifest = readPiManifest(packageJsonPath);
if (manifest?.extensions?.length) {
const entries = manifest.extensions
.map((entry) => resolve(dir, entry))
.filter((entry) => existsSync(entry));
if (entries.length > 0) return entries;
}
}
const indexTs = join(dir, "index.ts");
if (existsSync(indexTs)) return [indexTs];
const indexJs = join(dir, "index.js");
if (existsSync(indexJs)) return [indexJs];
return null;
}
function discoverExtensionsInDir(dir: string, cwd: string, home?: string): PiExtensionEntry[] {
if (!existsSync(dir)) return [];
const discovered: PiExtensionEntry[] = [];
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".")) continue;
const entryPath = join(dir, entry.name);
if ((entry.isFile() || entry.isSymbolicLink()) && /\.(ts|js)$/i.test(entry.name)) {
const resolved = resolve(entryPath);
discovered.push({
id: resolved,
name: extensionName(resolved),
path: resolved,
source: sourceForDir(dir, cwd, home),
enabled: true,
});
continue;
}
if (entry.isDirectory() || entry.isSymbolicLink()) {
let isDirectory = entry.isDirectory();
if (entry.isSymbolicLink()) {
try {
isDirectory = statSync(entryPath).isDirectory();
} catch {
isDirectory = false;
}
}
if (!isDirectory) continue;
const entries = resolveExtensionEntries(entryPath);
for (const extensionPath of entries ?? []) {
const resolved = resolve(extensionPath);
discovered.push({
id: resolved,
name: extensionName(resolved),
path: resolved,
source: sourceForDir(dir, cwd, home),
enabled: true,
});
}
}
}
} catch {
return [];
}
return discovered;
}
export function getPiExtensionDiscoveryDirs(cwd: string, home?: string): string[] {
const projectRoot = resolvePiExtensionProjectRoot(cwd);
return [
join(projectRoot, ".fusion", "extensions"),
join(projectRoot, ".pi", "extensions"),
join(getFusionAgentDir(home), "extensions"),
join(getLegacyPiAgentDir(home), "extensions"),
];
}
function readFusionDisabledExtensions(settingsPath: string): string[] {
try {
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
const disabled = parsed[FUSION_DISABLED_EXTENSIONS_KEY];
return Array.isArray(disabled)
? disabled.filter((entry): entry is string => typeof entry === "string").map((entry) => resolve(entry))
: [];
} catch {
return [];
}
}
export function discoverPiExtensions(cwd: string, home?: string): PiExtensionSettings {
const settingsPath = getFusionAgentSettingsPath(home);
const disabledIds = readFusionDisabledExtensions(settingsPath);
const disabled = new Set(disabledIds);
const byPath = new Map<string, PiExtensionEntry>();
for (const dir of getPiExtensionDiscoveryDirs(cwd, home)) {
for (const entry of discoverExtensionsInDir(dir, cwd, home)) {
byPath.set(entry.id, { ...entry, enabled: !disabled.has(entry.id) });
}
}
return {
extensions: [...byPath.values()].sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)),
disabledIds,
settingsPath,
};
}
export function getEnabledPiExtensionPaths(cwd: string, home?: string): string[] {
return discoverPiExtensions(cwd, home)
.extensions
.filter((entry) => entry.enabled)
.map((entry) => entry.path);
}
export function updatePiExtensionDisabledIds(cwd: string, disabledIds: string[], home?: string): PiExtensionSettings {
const settingsPath = getFusionAgentSettingsPath(home);
const existing = (() => {
try {
return JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
} catch {
return {};
}
})();
const known = new Set(discoverPiExtensions(cwd, home).extensions.map((entry) => entry.id));
const normalizedDisabledIds = Array.from(new Set(
disabledIds.map((entry) => resolve(entry)).filter((entry) => known.has(entry)),
)).sort();
mkdirSync(resolve(settingsPath, ".."), { recursive: true });
writeFileSync(settingsPath, `${JSON.stringify({
...existing,
[FUSION_DISABLED_EXTENSIONS_KEY]: normalizedDisabledIds,
}, null, 2)}\n`);
return discoverPiExtensions(cwd, home);
}
export function formatPiExtensionSource(source: PiExtensionSource, extensionPath: string, cwd: string, home?: string): string {
const homeDir = getHomeDir(home);
const projectRoot = resolvePiExtensionProjectRoot(cwd);
const relativePath = extensionPath.startsWith(homeDir)
? `~${extensionPath.slice(homeDir.length)}`
: extensionPath.startsWith(projectRoot)
? relative(projectRoot, extensionPath).split(sep).join("/")
: extensionPath;
return `${source}: ${relativePath}`;
}

View File

@@ -87,6 +87,10 @@ export class PluginLoader extends EventEmitter<{
super();
}
private getProjectRoot(): string {
return this.options.taskStore.getRootDir();
}
// ── Context Creation ───────────────────────────────────────────────
private async createContext(plugin: FusionPlugin): Promise<PluginContext> {
@@ -247,11 +251,11 @@ export class PluginLoader extends EventEmitter<{
if (path.startsWith("@") || path.includes("/")) {
// For npm packages, we'd use require.resolve in a real implementation
// For now, assume it's a local path relative to project root
return resolve(process.cwd(), path);
return resolve(this.getProjectRoot(), path);
}
// Default: resolve relative to project root
return resolve(process.cwd(), path);
return resolve(this.getProjectRoot(), path);
}
private async importPluginModule(path: string, bypassCache = false): Promise<unknown> {

View File

@@ -386,6 +386,31 @@ export function fetchSettingsByScope(projectId?: string): Promise<{ global: Glob
return api<{ global: GlobalSettings; project: Partial<ProjectSettings> }>(withProjectId("/settings/scopes", projectId));
}
export interface PiExtensionEntry {
id: string;
name: string;
path: string;
source: "fusion-global" | "pi-global" | "fusion-project" | "pi-project";
enabled: boolean;
}
export interface PiExtensionSettings {
extensions: PiExtensionEntry[];
disabledIds: string[];
settingsPath: string;
}
export function fetchPiExtensions(projectId?: string): Promise<PiExtensionSettings> {
return api<PiExtensionSettings>(withProjectId("/settings/pi-extensions", projectId));
}
export function updatePiExtensions(disabledIds: string[], projectId?: string): Promise<PiExtensionSettings> {
return api<PiExtensionSettings>(withProjectId("/settings/pi-extensions", projectId), {
method: "PUT",
body: JSON.stringify({ disabledIds }),
});
}
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string }, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId("/settings/test-ntfy", projectId), {
method: "POST",
@@ -5553,4 +5578,3 @@ export function getInsightCreateTaskData(
method: "POST",
});
}

View File

@@ -2,8 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { Globe, Folder } from "lucide-react";
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, PromptKey, AgentPromptsConfig } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency, compactMemory } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency, compactMemory, fetchPiExtensions, updatePiExtensions } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, PiExtensionSettings } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -59,6 +59,7 @@ type SettingsSection = {
const SETTINGS_SECTIONS: SettingsSection[] = [
// Global group
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
{ id: "pi-extensions", label: "Pi Extensions", scope: undefined },
{ id: "appearance", label: "Appearance", scope: "global" },
{ id: "notifications", label: "Notifications", scope: "global" },
{ id: "node-sync", label: "Node Sync", scope: "global" },
@@ -152,6 +153,11 @@ export function SettingsModal({
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Pi extension state (independent of the settings save flow)
const [piExtensions, setPiExtensions] = useState<PiExtensionSettings | null>(null);
const [piExtensionsLoading, setPiExtensionsLoading] = useState(false);
const [piExtensionsSaving, setPiExtensionsSaving] = useState(false);
// Model state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
@@ -296,6 +302,38 @@ export function SettingsModal({
};
}, [activeSection, loadAuthStatus]);
const loadPiExtensions = useCallback(() => {
setPiExtensionsLoading(true);
fetchPiExtensions(projectId)
.then(setPiExtensions)
.catch((err) => addToast(err.message, "error"))
.finally(() => setPiExtensionsLoading(false));
}, [addToast, projectId]);
useEffect(() => {
if (activeSection === "pi-extensions") {
loadPiExtensions();
}
}, [activeSection, loadPiExtensions]);
const togglePiExtension = async (extensionId: string, enabled: boolean) => {
if (!piExtensions) return;
const nextDisabledIds = enabled
? piExtensions.disabledIds.filter((id) => id !== extensionId)
: Array.from(new Set([...piExtensions.disabledIds, extensionId]));
setPiExtensionsSaving(true);
try {
const nextSettings = await updatePiExtensions(nextDisabledIds, projectId);
setPiExtensions(nextSettings);
addToast("Pi extension settings saved");
} catch (err) {
addToast(err instanceof Error ? err.message : "Failed to save Pi extension settings", "error");
} finally {
setPiExtensionsSaving(false);
}
};
const handleLogin = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
try {
@@ -2641,6 +2679,52 @@ export function SettingsModal({
<PluginSlot slotId="settings-section" projectId={projectId} />
</>
);
case "pi-extensions":
return (
<>
<h4 className="settings-section-heading">Pi Extensions</h4>
<div className="form-group">
<small>Choose which project and global Pi extensions Fusion loads. Changes are saved to your Fusion agent settings and apply after restarting the dashboard or headless node.</small>
</div>
<div className="modal-actions modal-actions-left">
<button
type="button"
className="btn btn-sm"
onClick={loadPiExtensions}
disabled={piExtensionsLoading || piExtensionsSaving}
>
Refresh
</button>
</div>
{piExtensionsLoading ? (
<div className="settings-empty-state">Loading Pi extensions</div>
) : !piExtensions || piExtensions.extensions.length === 0 ? (
<div className="settings-empty-state settings-muted">
No Pi extensions found in this project, ~/.fusion/agent, or ~/.pi/agent.
</div>
) : (
<>
{piExtensions.extensions.map((extension) => (
<div key={extension.id} className="form-group">
<label htmlFor={`pi-extension-${extension.id}`} className="checkbox-label">
<input
id={`pi-extension-${extension.id}`}
type="checkbox"
checked={extension.enabled}
disabled={piExtensionsSaving}
onChange={(e) => togglePiExtension(extension.id, e.target.checked)}
/>
{extension.name}
</label>
<small>
{extension.source.replace("-", " ")} · {extension.path}
</small>
</div>
))}
</>
)}
</>
);
case "authentication":
// Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name.
const sortedProviders = [...authProviders].sort((a, b) => {

View File

@@ -57,8 +57,11 @@ async function initEngine() {
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -212,6 +215,7 @@ function cleanupExpiredSessions(): void {
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => {
clearInterval(cleanupInterval);
@@ -461,7 +465,7 @@ export async function generateAgentSpec(
}
try {
await engineReady;
await ensureEngineReady();
await promptCatalogReadyPromise;
const spec = await generateSpecWithAI(session, rootDir, promptOverrides);
session.spec = spec;

View File

@@ -33,8 +33,11 @@ async function initEngine() {
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Types ───────────────────────────────────────────────────────────────────
@@ -194,6 +197,7 @@ function cleanupExpiredRateLimits(): void {
// Start cleanup interval
const cleanupInterval = setInterval(cleanupExpiredRateLimits, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
// Handle graceful shutdown
process.on("beforeExit", () => {
@@ -264,7 +268,7 @@ export async function refineText(
promptOverrides?: PromptOverrideMap,
): Promise<string> {
// Ensure engine is loaded before using createKbAgent
await engineReady;
await ensureEngineReady();
if (!createKbAgent) {
throw new AiServiceError("AI engine not available");

View File

@@ -47,8 +47,11 @@ async function initEngine() {
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -348,7 +351,7 @@ export class ChatManager {
try {
// Ensure engine is loaded
await engineReady;
await ensureEngineReady();
if (!createKbAgent) {
throw new Error("AI agent not available");

View File

@@ -118,6 +118,7 @@ export class GitHubPollingService extends EventEmitter<GitHubPollingServiceEvent
this.timer = setInterval(() => {
void this.pollOnce();
}, this.pollingIntervalMs);
this.timer.unref?.();
void this.pollOnce();
}
@@ -409,4 +410,3 @@ function hasIssueBadgeChanged(current: IssueInfo | undefined, next: IssueInfo):
current.title !== next.title ||
current.stateReason !== next.stateReason;
}

View File

@@ -101,7 +101,11 @@ async function initEngine() {
}
}
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -492,6 +496,7 @@ function cleanupExpiredSessions(): void {
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => clearInterval(cleanupInterval));
// ── Stream Manager ──────────────────────────────────────────────────────────
@@ -671,7 +676,7 @@ async function createTargetInterviewAgent(
session: TargetInterviewSession,
rootDir: string,
): Promise<AgentResult> {
await engineReady;
await ensureEngineReady();
return createKbAgent({
cwd: rootDir,

View File

@@ -41,7 +41,11 @@ async function initEngine() {
}
}
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -392,6 +396,7 @@ function cleanupExpiredSessions(): void {
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => clearInterval(cleanupInterval));
// ── Stream Manager ──────────────────────────────────────────────────────────
@@ -748,7 +753,7 @@ async function createMissionInterviewAgent(
rootDir: string,
promptOverrides?: PromptOverrideMap,
): Promise<AgentResult> {
await engineReady;
await ensureEngineReady();
const effectivePrompt = resolvePrompt("mission-interview-system", promptOverrides);

View File

@@ -50,8 +50,11 @@ async function initEngine() {
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
@@ -360,6 +363,7 @@ function cleanupExpiredSessions(): void {
// Start cleanup interval
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
// Handle graceful shutdown
process.on("beforeExit", () => {
@@ -576,7 +580,7 @@ export async function createSession(
// Create AI agent and get the first question
// Only await engineReady if createKbAgent hasn't been set externally (e.g., via __setCreateKbAgent)
if (!createKbAgent) {
await engineReady;
await ensureEngineReady();
}
const agentResult = await createKbAgent({
@@ -802,7 +806,7 @@ async function createPlanningAgent(
promptOverrides?: PromptOverrideMap,
): Promise<AgentResult> {
// Ensure engine is loaded before using createKbAgent
await engineReady;
await ensureEngineReady();
// Resolve the effective system prompt (override or default)
const systemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;

View File

@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -2838,6 +2838,43 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/settings/pi-extensions
* List Pi/Fusion extension entry points and their Fusion-owned enabled state.
*/
router.get("/settings/pi-extensions", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
res.json(discoverPiExtensions(scopedStore.getRootDir()));
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* PUT /api/settings/pi-extensions
* Persist Fusion-owned disabled extension ids in ~/.fusion/agent/settings.json.
*/
router.put("/settings/pi-extensions", async (req, res) => {
try {
const disabledIds = (req.body as { disabledIds?: unknown }).disabledIds;
if (!Array.isArray(disabledIds) || disabledIds.some((entry) => typeof entry !== "string")) {
throw badRequest("disabledIds must be an array of extension ids");
}
const { store: scopedStore } = await getProjectContext(req);
res.json(updatePiExtensionDisabledIds(scopedStore.getRootDir(), disabledIds));
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/settings/test-ntfy
* Send a test notification to verify ntfy configuration.
@@ -5655,6 +5692,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
}, batchImportWindowMs);
batchImportCleanupInterval.unref?.();
}
return (req: Request, res: Response, next: NextFunction): void => {

View File

@@ -20,7 +20,11 @@ async function initEngine() {
}
}
const engineReady = initEngine();
let engineReady: Promise<void> | undefined;
function ensureEngineReady() {
engineReady ??= initEngine();
return engineReady;
}
export interface SubtaskItem {
id: string;
@@ -263,6 +267,7 @@ function cleanupExpiredSessions(): void {
}
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => {
clearInterval(cleanupInterval);
});
@@ -396,7 +401,7 @@ async function generateSubtasks(
const session = sessions.get(sessionId);
if (!session) throw new SessionNotFoundError(`Subtask session ${sessionId} not found`);
await engineReady;
await ensureEngineReady();
// Resolve the effective system prompt (override or default)
const systemPrompt = resolvePrompt("subtask-breakdown-system", promptOverrides) || SUBTASK_BREAKDOWN_PROMPT;

View File

@@ -335,6 +335,7 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
}
}
}, this.heartbeatIntervalMs);
this.heartbeatTimer.unref?.();
}
private clearHeartbeat(): void {

View File

@@ -22,6 +22,10 @@ export function getFusionAuthPath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "auth.json");
}
export function getFusionModelsPath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "models.json");
}
function getLegacyAuthPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "auth.json"),
@@ -29,6 +33,22 @@ function getLegacyAuthPaths(home = getHomeDir()): string[] {
];
}
function getLegacyModelsPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
}
export function getModelRegistryModelsPath(home = getHomeDir()): string {
const fusionModelsPath = getFusionModelsPath(home);
if (existsSync(fusionModelsPath)) {
return fusionModelsPath;
}
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
function readLegacyCredentials(authPaths = getLegacyAuthPaths()): Record<string, StoredCredential> {
const credentials: Record<string, StoredCredential> = {};

View File

@@ -13,7 +13,7 @@ import { Type, type Static } from "@mariozechner/pi-ai";
import { createKbAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { ModelRegistry, SessionManager, getAgentDir, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import { isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
@@ -42,7 +42,7 @@ import {
createTaskLogTool as sharedCreateTaskLogTool,
} from "./agent-tools.js";
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
import { createFusionAuthStorage } from "./auth-storage.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
// Re-export for backward compatibility (tests import from executor.ts)
export { summarizeToolArgs } from "./agent-logger.js";
@@ -392,7 +392,7 @@ export class TaskExecutor {
private get modelRegistry(): InstanceType<typeof ModelRegistry> {
if (!this._modelRegistry) {
const authStorage = createFusionAuthStorage();
this._modelRegistry = new ModelRegistry(authStorage, join(getAgentDir(), "models.json"));
this._modelRegistry = new ModelRegistry(authStorage, getModelRegistryModelsPath());
this._modelRegistry.refresh();
}
return this._modelRegistry;

View File

@@ -422,7 +422,11 @@ describe("createKbAgent", () => {
defaultModelId: "glm-5.1",
});
expect(discoverAndLoadExtensionsMock).toHaveBeenCalledWith(["/extensions/zai-provider"], "/tmp", undefined);
expect(discoverAndLoadExtensionsMock).toHaveBeenCalledWith(
["/extensions/zai-provider"],
"/tmp",
"/tmp/.fusion/disabled-auto-extension-discovery",
);
expect(registerProviderMock).toHaveBeenCalledWith("zai", expect.objectContaining({
models: [{ id: "glm-5.1" }],
}));

View File

@@ -20,20 +20,20 @@ import {
DefaultResourceLoader,
DefaultPackageManager,
discoverAndLoadExtensions,
getAgentDir,
ModelRegistry,
SessionManager,
SettingsManager,
type AgentSession,
type ToolDefinition,
} from "@mariozechner/pi-coding-agent";
import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, resolvePiExtensionProjectRoot } from "@fusion/core";
import {
resolveSessionSkills,
createSkillsOverrideFromSelection,
type SkillSelectionContext,
} from "./skill-resolver.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { createFusionAuthStorage } from "./auth-storage.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
export interface AgentResult {
session: AgentSession;
@@ -252,8 +252,9 @@ function readJsonObject(path: string): Record<string, any> {
}
function createReadOnlyPiSettingsView(cwd: string, agentDir: string): PackageManagerSettingsView {
const projectRoot = resolvePiExtensionProjectRoot(cwd);
const globalSettings = readJsonObject(join(agentDir, "settings.json"));
const fusionProjectSettings = readJsonObject(join(cwd, ".fusion", "settings.json"));
const fusionProjectSettings = readJsonObject(join(projectRoot, ".fusion", "settings.json"));
const mergedSettings = { ...globalSettings, ...fusionProjectSettings };
return {
@@ -265,9 +266,22 @@ function createReadOnlyPiSettingsView(cwd: string, agentDir: string): PackageMan
};
}
function getPackageManagerAgentDir(): string {
const fusionAgentDir = getFusionAgentDir();
if (
existsSync(join(fusionAgentDir, "settings.json")) ||
existsSync(join(fusionAgentDir, "extensions"))
) {
return fusionAgentDir;
}
const legacyAgentDir = getLegacyPiAgentDir();
return existsSync(legacyAgentDir) ? legacyAgentDir : fusionAgentDir;
}
async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegistry): Promise<void> {
try {
const agentDir = getAgentDir();
const agentDir = getPackageManagerAgentDir();
const packageManager = new DefaultPackageManager({
cwd,
agentDir,
@@ -278,7 +292,11 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis
.filter((resource) => resource.enabled)
.map((resource) => resource.path);
const extensionsResult = await discoverAndLoadExtensions(packageExtensionPaths, cwd, undefined);
const extensionsResult = await discoverAndLoadExtensions(
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
cwd,
join(resolvePiExtensionProjectRoot(cwd), ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {
console.error(`[extensions] Failed to load ${path}: ${error}`);
@@ -475,7 +493,7 @@ export function wrapToolsWithBoundary(
export async function createKbAgent(options: AgentOptions): Promise<AgentResult> {
console.error(`[pi] createKbAgent called (cwd=${options.cwd}, tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`);
const authStorage = createFusionAuthStorage();
const modelRegistry = new ModelRegistry(authStorage, join(getAgentDir(), "models.json"));
const modelRegistry = new ModelRegistry(authStorage, getModelRegistryModelsPath());
await registerExtensionProviders(options.cwd, modelRegistry);
const tools =

View File

@@ -97,6 +97,7 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
private globalSemaphore: AgentSemaphore;
/** Mutable limit read by the shared semaphore's getter function. */
private currentGlobalLimit = 4;
private globalLimitRefreshInterval: ReturnType<typeof setInterval>;
/**
* @param centralCore - CentralCore reference for global coordination
@@ -112,7 +113,8 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
// Refresh the global limit periodically
this.refreshGlobalLimit();
setInterval(() => this.refreshGlobalLimit(), 30000); // Refresh every 30s
this.globalLimitRefreshInterval = setInterval(() => this.refreshGlobalLimit(), 30000);
this.globalLimitRefreshInterval.unref?.();
projectManagerLog.log("ProjectManager initialized");
}
@@ -475,6 +477,7 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
await Promise.all(stopPromises);
clearInterval(this.globalLimitRefreshInterval);
projectManagerLog.log("All project runtimes stopped");
this.removeAllListeners();
}