fix(FN-1952): restore pi auth and extension loading
This commit is contained in:
5
.changeset/fix-pi-auth-extensions.md
Normal file
5
.changeset/fix-pi-auth-extensions.md
Normal 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.
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
235
packages/core/src/pi-extensions.ts
Normal file
235
packages/core/src/pi-extensions.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -335,6 +335,7 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
|
||||
}
|
||||
}
|
||||
}, this.heartbeatIntervalMs);
|
||||
this.heartbeatTimer.unref?.();
|
||||
}
|
||||
|
||||
private clearHeartbeat(): void {
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }],
|
||||
}));
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user