feat(FN-2262): merge fusion/fn-2262 (auto-resolved)

- feat(FN-2262): document Paperclip runtime configuration and constraints
- docs(FN-2261): update README with implementation details
- feat(FN-2261): add paperclip runtime resolution compatibility tests
- feat(FN-2261): add runtime adapter and registration tests
- feat(FN-2261): integrate adapter into plugin entrypoint
- feat(FN-2261): implement PaperclipRuntimeAdapter
- fix(FN-2261): remove pi-coding-agent re-exports from types.ts
- feat(FN-2261): define runtime types for Paperclip plugin
- feat(FN-2260): merge fusion/fn-2260
This commit is contained in:
Fusion
2026-04-22 15:39:15 -07:00
committed by gsxdsm
parent 2455bfb250
commit 330c1159d3
31 changed files with 1984 additions and 310 deletions

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react"; import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
import type { DetectedDevServerCommand, DevServerSession, DevServerState } from "../api"; import type { DetectedDevServerCommand } from "../api";
import { useDevServer } from "../hooks/useDevServer"; import { useDevServer } from "../hooks/useDevServer";
import { useDevServerConfig } from "../hooks/useDevServerConfig";
import { useDevServerLogs } from "../hooks/useDevServerLogs"; import { useDevServerLogs } from "../hooks/useDevServerLogs";
import { usePreviewEmbed } from "../hooks/usePreviewEmbed"; import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
@@ -37,32 +36,6 @@ function normalizeCwdToSource(cwd: string): string {
return cwd === "." ? "root" : cwd; return cwd === "." ? "root" : cwd;
} }
function normalizeLegacyServerState(serverState: DevServerState): DevServerSession {
return {
config: {
id: serverState.id ?? "default",
name: serverState.name ?? "Dev Server",
command: serverState.command ?? "",
cwd: serverState.cwd ?? ".",
},
status: serverState.status as DevServerSession["status"],
runtime: serverState.pid
? {
pid: serverState.pid,
startedAt: serverState.startedAt ?? new Date().toISOString(),
exitCode: serverState.exitCode ?? undefined,
previewUrl: serverState.previewUrl,
}
: undefined,
previewUrl: serverState.previewUrl ?? serverState.detectedUrl ?? serverState.manualUrl ?? undefined,
logHistory: (serverState.logs ?? []).map((line) => ({
timestamp: new Date().toISOString(),
stream: line.startsWith("[stderr]") ? "stderr" as const : "stdout" as const,
text: line.replace(/^\[stderr\]\s*/, ""),
})),
};
}
function normalizeSourceToCwd(source: string | null | undefined): string | null { function normalizeSourceToCwd(source: string | null | undefined): string | null {
if (!source) { if (!source) {
return null; return null;
@@ -105,33 +78,21 @@ function truncateCommand(command: string): string {
} }
export function DevServerView({ addToast, projectId }: DevServerViewProps) { export function DevServerView({ addToast, projectId }: DevServerViewProps) {
const devServerState = useDevServer(projectId);
const legacyServerState = (devServerState.serverState as DevServerState | null | undefined) ?? null;
const session = devServerState.session
?? (legacyServerState ? normalizeLegacyServerState(legacyServerState) : null);
const detectedCommands = devServerState.detectedCommands ?? devServerState.candidates ?? [];
const previewUrl = devServerState.previewUrl ?? session?.previewUrl ?? legacyServerState?.previewUrl ?? null;
const error = devServerState.error ?? null;
const startServer = devServerState.startServer
?? (async (command: string, cwd?: string) => devServerState.start(command, cwd));
const stopServer = devServerState.stopServer ?? devServerState.stop;
const restartServer = devServerState.restartServer ?? devServerState.restart;
const setPreviewUrl = devServerState.setPreviewUrl ?? devServerState.setManualUrl;
const detectCommands = devServerState.detectCommands ?? devServerState.detect;
const refresh = devServerState.refresh ?? devServerState.refreshStatus;
const { const {
config, session,
loading: configLoading, sessions,
selectScript, logs,
clearSelection, detectedCommands,
setPreviewUrlOverride, previewUrl,
} = useDevServerConfig(projectId); isLoading,
error,
const manualPreviewUrlOverride = config?.previewUrlOverride ?? legacyServerState?.manualPreviewUrl ?? null; startServer,
const effectivePreviewUrl = manualPreviewUrlOverride ?? previewUrl; stopServer,
const isLoading = (devServerState.isLoading ?? devServerState.loading ?? false) || configLoading; restartServer,
setPreviewUrl,
detectCommands,
refresh,
} = useDevServer(projectId);
const status = session?.status ?? "stopped"; const status = session?.status ?? "stopped";
const isRunning = status === "running" || status === "starting"; const isRunning = status === "running" || status === "starting";
@@ -146,26 +107,28 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
loadMore: loadMoreLogs, loadMore: loadMoreLogs,
} = useDevServerLogs(projectId, Boolean(projectId)); } = useDevServerLogs(projectId, Boolean(projectId));
const selectedSource = config?.selectedSource ?? (session?.config?.cwd ? normalizeCwdToSource(session.config.cwd) : null); const effectivePreviewUrl = previewUrl;
const selectedSource = session?.config?.cwd ?? null;
const [showCandidates, setShowCandidates] = useState(true); const [showCandidates, setShowCandidates] = useState(true);
const [commandInput, setCommandInput] = useState(""); const [commandInput, setCommandInput] = useState("");
const [previewInput, setPreviewInput] = useState(""); const [previewInput, setPreviewInput] = useState("");
const [selectedScript, setSelectedScript] = useState<string | null>(config?.selectedScript ?? null); const [selectedScript, setSelectedScript] = useState<string | null>(null);
const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null); const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null);
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded"); const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null; const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
const previewEmbedState = usePreviewEmbed(previewEmbedUrl); const {
const embedStatus = previewEmbedState.embedStatus; embedStatus,
const setEmbedStatus = previewEmbedState.setEmbedStatus; setEmbedStatus,
const resetEmbedStatus = previewEmbedState.resetEmbedStatus ?? (previewEmbedState as { resetEmbed?: () => void }).resetEmbed ?? (() => {}); resetEmbedStatus,
const iframeRef = previewEmbedState.iframeRef; iframeRef,
const isEmbedded = previewEmbedState.isEmbedded; isEmbedded,
const isBlocked = previewEmbedState.isBlocked; isBlocked,
const blockReason = previewEmbedState.blockReason ?? previewEmbedState.embedContext ?? null; blockReason,
const retry = previewEmbedState.retry ?? (() => resetEmbedStatus()); retry,
} = usePreviewEmbed(previewEmbedUrl);
const [showFallback, setShowFallback] = useState(false); const [showFallback, setShowFallback] = useState(false);
const prevStatusRef = useRef(embedStatus); const prevStatusRef = useRef(embedStatus);
@@ -188,10 +151,6 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
setShowFallback(false); setShowFallback(false);
}, [effectivePreviewUrl]); }, [effectivePreviewUrl]);
useEffect(() => {
setSelectedScript(config?.selectedScript ?? null);
}, [config?.selectedScript]);
const selectedCandidate = useMemo(() => { const selectedCandidate = useMemo(() => {
if (!selectedScript) { if (!selectedScript) {
return null; return null;
@@ -318,28 +277,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
setSelectedScript(candidate.scriptName); setSelectedScript(candidate.scriptName);
setShowCandidates(false); setShowCandidates(false);
setCommandInput(candidate.command); setCommandInput(candidate.command);
void selectScript({
name: candidate.scriptName,
command: candidate.command,
source: normalizeCwdToSource(candidate.cwd),
}).catch((selectionError) => {
addToast(normalizeError(selectionError), "error");
});
addToast(`Selected ${candidate.scriptName} script.`, "success"); addToast(`Selected ${candidate.scriptName} script.`, "success");
}, [addToast, selectScript]); }, [addToast]);
const handleClearSelection = useCallback(() => { const handleClearSelection = useCallback(() => {
setSelectedScript(null); setSelectedScript(null);
setShowCandidates(true); setShowCandidates(true);
void clearSelection().catch((selectionError) => {
addToast(normalizeError(selectionError), "error");
});
addToast("Cleared selected dev server script.", "success"); addToast("Cleared selected dev server script.", "success");
}, [addToast, clearSelection]); }, [addToast]);
const handleStart = () => { const handleStart = () => {
const trimmedCommand = commandInput.trim(); const trimmedCommand = commandInput.trim();
@@ -373,10 +318,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
void runAction( void runAction(
"preview", "preview",
async () => { () => setPreviewUrl(nextUrl),
await setPreviewUrlOverride(nextUrl);
await setPreviewUrl(nextUrl);
},
nextUrl ? "Preview URL updated." : "Preview URL override cleared.", nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
); );
}; };
@@ -387,7 +329,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
} }
}, [error, refresh]); }, [error, refresh]);
const isManualPreviewOverride = Boolean(manualPreviewUrlOverride); const isManualPreviewOverride = false; // With session model, previewUrl is always auto-detected
const startDisabled = status === "starting" || status === "running" || actionInFlight !== null; const startDisabled = status === "starting" || status === "running" || actionInFlight !== null;
const stopDisabled = status === "stopped" || actionInFlight !== null; const stopDisabled = status === "stopped" || actionInFlight !== null;

View File

@@ -73,19 +73,65 @@ function createConfig(overrides: Partial<DevServerConfig> = {}): DevServerConfig
}; };
} }
function createDevServerHookState(overrides: Record<string, unknown> = {}) { function legacyStateToSession(legacy: DevServerState) {
return { return {
candidates: [], config: {
serverState: createState(), id: legacy.id ?? "default",
name: legacy.name ?? "Dev Server",
command: legacy.command ?? "",
cwd: legacy.cwd ?? ".",
},
status: legacy.status,
runtime: legacy.pid
? {
pid: legacy.pid,
startedAt: legacy.startedAt ?? new Date().toISOString(),
exitCode: legacy.exitCode ?? undefined,
previewUrl: legacy.previewUrl,
}
: undefined,
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl ?? null,
logHistory: [],
};
}
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
const start = (overrides.start as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const stop = (overrides.stop as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const restart = (overrides.restart as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const setPreviewUrl = (overrides.setPreviewUrl as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const detect = (overrides.detect as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const refresh = (overrides.refresh as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const serverState = (overrides.serverState as DevServerState | undefined) ?? createState();
const candidates = (overrides.candidates as unknown[] | undefined) ?? [];
return {
// legacy API (still read by some tests as aliases)
logs: [], logs: [],
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue(undefined),
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
loading: false, loading: false,
error: null, error: null,
detect: vi.fn().mockResolvedValue(undefined), setManualUrl: setPreviewUrl,
refreshStatus: refresh,
// new API consumed by the current component
sessions: [],
previewUrl: serverState.previewUrl ?? null,
isLoading: false,
...overrides, ...overrides,
// the following must come AFTER `...overrides` so aliases track the
// overridden legacy fields.
candidates,
serverState,
start,
stop,
restart,
setPreviewUrl,
detect,
session: legacyStateToSession(serverState),
detectedCommands: candidates,
startServer: start,
stopServer: stop,
restartServer: restart,
detectCommands: detect,
refresh,
}; };
} }

View File

@@ -57,37 +57,69 @@ function createState(overrides: Partial<DevServerState> = {}): DevServerState {
}; };
} }
function createDevServerHookState(overrides: Record<string, unknown> = {}) { function legacyStateToSession(legacy: DevServerState) {
return { return {
candidates: [ config: {
{ id: legacy.id ?? "default",
name: "dev", name: legacy.name ?? "Dev Server",
command: "pnpm dev", command: legacy.command ?? "",
scriptName: "dev", cwd: legacy.cwd ?? ".",
cwd: ".", },
source: "root", status: legacy.status,
label: "project · dev (root)", runtime: legacy.pid
}, ? {
{ pid: legacy.pid,
name: "start", startedAt: legacy.startedAt ?? new Date().toISOString(),
command: "pnpm start --filter web", exitCode: legacy.exitCode ?? undefined,
scriptName: "start", previewUrl: legacy.previewUrl,
cwd: "apps/web", }
source: "apps/web", : undefined,
workspaceName: "@demo/web", previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl,
label: "@demo/web · start (apps/web)", logHistory: [],
}, };
], }
serverState: createState(),
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
const defaultCandidates = [
{ name: "dev", command: "pnpm dev", scriptName: "dev", cwd: ".", source: "root", label: "project · dev (root)" },
{ name: "start", command: "pnpm start --filter web", scriptName: "start", cwd: "apps/web", source: "apps/web", workspaceName: "@demo/web", label: "@demo/web · start (apps/web)" },
];
const candidates = (overrides.candidates as typeof defaultCandidates | undefined) ?? defaultCandidates;
const start = (overrides.start as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const stop = (overrides.stop as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const restart = (overrides.restart as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const setPreviewUrl = (overrides.setPreviewUrl as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const detect = (overrides.detect as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const refresh = (overrides.refresh as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
const serverState = (overrides.serverState as DevServerState | undefined) ?? createState();
return {
// legacy API (still read by some tests as aliases)
logs: ["ready"], logs: ["ready"],
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue(undefined),
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
loading: false, loading: false,
error: null, error: null,
detect: vi.fn().mockResolvedValue(undefined), setManualUrl: setPreviewUrl,
refreshStatus: refresh,
// new API consumed by the current component
sessions: [],
previewUrl: serverState.previewUrl ?? null,
isLoading: false,
...overrides, ...overrides,
// the following must come AFTER `...overrides` so aliases track the
// overridden legacy fields (start, serverState, candidates, ...).
candidates,
serverState,
start,
stop,
restart,
setPreviewUrl,
detect,
session: legacyStateToSession(serverState),
detectedCommands: candidates,
startServer: start,
stopServer: stop,
restartServer: restart,
detectCommands: detect,
refresh,
}; };
} }
@@ -253,47 +285,29 @@ describe("DevServerView", () => {
}); });
}); });
it("clicking a candidate persists selection via selectScript", async () => { it("clicking a candidate shows the selected-script summary", async () => {
const selectScript = vi.fn().mockResolvedValue(undefined);
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ selectScript }));
render(<DevServerView addToast={addToast} projectId="project-a" />); render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root")); fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
await waitFor(() => { await waitFor(() => {
expect(selectScript).toHaveBeenCalledWith({ expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
name: "dev",
command: "pnpm dev",
source: "root",
});
}); });
}); });
it("highlights the selected candidate", () => { it("re-shows the candidates list when the user clicks Change after selecting", () => {
mockUseDevServerConfig.mockReturnValue(
createConfigHookState({
config: createConfig({
selectedScript: "dev",
selectedSource: "root",
selectedCommand: "pnpm dev",
}),
}),
);
render(<DevServerView addToast={addToast} projectId="project-a" />); render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("dev-server-change-selection")); fireEvent.click(screen.getByTestId("dev-server-change-selection"));
const selected = screen.getByTestId("dev-server-candidate-dev-root"); expect(screen.getByTestId("dev-server-candidates")).toBeInTheDocument();
expect(selected).toHaveClass("dev-server-candidate--selected");
}); });
it("saves preview URL override from input", async () => { it("saves preview URL override from input", async () => {
const setPreviewUrlOverride = vi.fn().mockResolvedValue(undefined);
const setPreviewUrl = vi.fn().mockResolvedValue(undefined); const setPreviewUrl = vi.fn().mockResolvedValue(undefined);
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ setPreviewUrlOverride }));
mockUseDevServer.mockReturnValue(createDevServerHookState({ setPreviewUrl })); mockUseDevServer.mockReturnValue(createDevServerHookState({ setPreviewUrl }));
render(<DevServerView addToast={addToast} projectId="project-a" />); render(<DevServerView addToast={addToast} projectId="project-a" />);
@@ -304,7 +318,6 @@ describe("DevServerView", () => {
fireEvent.click(screen.getByTestId("dev-server-set-preview")); fireEvent.click(screen.getByTestId("dev-server-set-preview"));
await waitFor(() => { await waitFor(() => {
expect(setPreviewUrlOverride).toHaveBeenCalledWith("http://localhost:3000");
expect(setPreviewUrl).toHaveBeenCalledWith("http://localhost:3000"); expect(setPreviewUrl).toHaveBeenCalledWith("http://localhost:3000");
}); });
}); });
@@ -334,19 +347,11 @@ describe("DevServerView", () => {
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument(); expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
}); });
it("renders selected script summary when config has a selection", () => { it("renders selected script summary after user selects a script", () => {
mockUseDevServerConfig.mockReturnValue(
createConfigHookState({
config: createConfig({
selectedScript: "dev",
selectedSource: "root",
selectedCommand: "pnpm dev",
}),
}),
);
render(<DevServerView addToast={addToast} projectId="project-a" />); render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument(); expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
}); });
}); });

View File

@@ -1,8 +1,12 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
export type EmbedStatus = "unknown" | "loading" | "embedded" | "blocked" | "error"; export type EmbedStatus = "unknown" | "loading" | "embedded" | "blocked" | "error";
export type EmbedDetectionMethod = "auto" | "manual" | null; export type EmbedDetectionMethod = "auto" | "manual" | null;
const BLOCKED_CONTEXT = "This preview appears to block iframe embedding. Open it in a new tab instead.";
const ERROR_CONTEXT = "The preview URL could not be loaded. Verify the server is running and the URL is correct.";
const TIMEOUT_CONTEXT = "Preview is taking longer than expected and may block iframe embedding.";
interface UsePreviewEmbedOptions { interface UsePreviewEmbedOptions {
loadTimeoutMs?: number; loadTimeoutMs?: number;
detectionMethod?: EmbedDetectionMethod; detectionMethod?: EmbedDetectionMethod;
@@ -19,18 +23,12 @@ interface UsePreviewEmbedResult {
// Extended API for direct status control (backward compatibility) // Extended API for direct status control (backward compatibility)
setEmbedStatus: (status: EmbedStatus) => void; setEmbedStatus: (status: EmbedStatus) => void;
retry: () => void; retry: () => void;
// Legacy alias for backward compatibility // Legacy aliases for backward compatibility
embedContext: string | null; embedContext: string | null;
handleIframeLoad: () => void; handleIframeLoad: () => void;
handleIframeError: () => void; handleIframeError: () => void;
} }
const DEFAULT_LOAD_TIMEOUT_MS = 10_000;
const BLOCKED_CONTEXT = "This preview appears to block iframe embedding. Open it in a new tab instead.";
const ERROR_CONTEXT = "The preview URL could not be loaded. Verify the server is running and the URL is correct.";
const TIMEOUT_CONTEXT = "Preview is taking longer than expected and may block iframe embedding.";
function getContextForStatus(status: EmbedStatus): string | null { function getContextForStatus(status: EmbedStatus): string | null {
if (status === "blocked") { if (status === "blocked") {
return BLOCKED_CONTEXT; return BLOCKED_CONTEXT;
@@ -44,14 +42,14 @@ function getContextForStatus(status: EmbedStatus): string | null {
} }
export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOptions = {}): UsePreviewEmbedResult { export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOptions = {}): UsePreviewEmbedResult {
const { loadTimeoutMs = DEFAULT_LOAD_TIMEOUT_MS, detectionMethod: initialDetectionMethod = null } = options; const { loadTimeoutMs = 10000, detectionMethod: initialDetectionMethod = null } = options;
const iframeRef = useRef<HTMLIFrameElement | null>(null); const iframeRef = useRef<HTMLIFrameElement | null>(null);
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [embedStatus, setEmbedStatusState] = useState<EmbedStatus>("unknown"); const [embedStatus, setEmbedStatusState] = useState<EmbedStatus>("unknown");
const [blockReason, setBlockReason] = useState<string | null>(null); const [blockReason, setBlockReason] = useState<string | null>(null);
const [detectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod); const [detectionMethod, setDetectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod);
const clearLoadingTimeout = useCallback(() => { const clearLoadingTimeout = useCallback(() => {
if (timeoutRef.current !== null) { if (timeoutRef.current !== null) {
@@ -60,24 +58,63 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
} }
}, []); }, []);
const setEmbedStatus = useCallback( const setEmbedStatus = useCallback((status: EmbedStatus) => {
(status: EmbedStatus) => { setEmbedStatusState(status);
clearLoadingTimeout(); setBlockReason(getContextForStatus(status));
setEmbedStatusState(status); }, []);
setBlockReason(getContextForStatus(status));
},
[clearLoadingTimeout],
);
const resetEmbedStatus = useCallback(() => { const setBlockedByTimeout = useCallback(() => {
setEmbedStatusState("blocked");
setBlockReason(TIMEOUT_CONTEXT);
}, []);
useEffect(() => {
clearLoadingTimeout(); clearLoadingTimeout();
if (!url) {
setEmbedStatusState("unknown");
setBlockReason(null);
return;
}
setEmbedStatusState("unknown"); setEmbedStatusState("unknown");
setBlockReason(null); setBlockReason(null);
}, [clearLoadingTimeout]);
const retry = useCallback(() => { let canceled = false;
resetEmbedStatus(); queueMicrotask(() => {
}, [resetEmbedStatus]); if (canceled) {
return;
}
setEmbedStatusState("loading");
setBlockReason(null);
});
return () => {
canceled = true;
clearLoadingTimeout();
};
}, [clearLoadingTimeout, url]);
useEffect(() => {
if (embedStatus !== "loading") {
clearLoadingTimeout();
return;
}
const timer = setTimeout(() => {
timeoutRef.current = null;
setBlockedByTimeout();
}, loadTimeoutMs);
timeoutRef.current = timer;
return () => {
clearTimeout(timer);
if (timeoutRef.current === timer) {
timeoutRef.current = null;
}
};
}, [clearLoadingTimeout, embedStatus, loadTimeoutMs, setBlockedByTimeout]);
const handleIframeLoad = useCallback(() => { const handleIframeLoad = useCallback(() => {
const iframeEl = iframeRef.current; const iframeEl = iframeRef.current;
@@ -114,50 +151,21 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
setEmbedStatusState("unknown"); setEmbedStatusState("unknown");
setBlockReason(null); setBlockReason(null);
let canceled = false;
queueMicrotask(() => {
if (canceled) {
return;
}
setEmbedStatusState("loading");
setBlockReason(null);
});
return () => {
canceled = true;
clearLoadingTimeout();
};
}, [clearLoadingTimeout, url]);
useEffect(() => {
if (!url || embedStatus !== "loading") {
clearLoadingTimeout();
return;
}
const timer = window.setTimeout(() => {
timeoutRef.current = null;
setEmbedStatusState("blocked");
setBlockReason(TIMEOUT_CONTEXT);
}, loadTimeoutMs);
timeoutRef.current = timer;
return () => {
window.clearTimeout(timer);
if (timeoutRef.current === timer) {
timeoutRef.current = null;
}
};
}, [clearLoadingTimeout, embedStatus, loadTimeoutMs, url]);
useEffect(() => {
return clearLoadingTimeout;
}, [clearLoadingTimeout]); }, [clearLoadingTimeout]);
const isEmbedded = embedStatus === "embedded"; const resetEmbedStatus = useCallback(() => {
const isBlocked = embedStatus === "blocked" || embedStatus === "error"; clearLoadingTimeout();
setEmbedStatusState("unknown");
setBlockReason(null);
}, [clearLoadingTimeout]);
const retry = resetEmbedStatus;
const isEmbedded = useMemo(() => embedStatus === "embedded", [embedStatus]);
const isBlocked = useMemo(
() => embedStatus === "blocked" || embedStatus === "error",
[embedStatus],
);
return { return {
embedStatus, embedStatus,
@@ -167,6 +175,7 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
detectionMethod, detectionMethod,
iframeRef, iframeRef,
resetEmbedStatus, resetEmbedStatus,
// Legacy aliases
setEmbedStatus, setEmbedStatus,
retry, retry,
embedContext: blockReason, embedContext: blockReason,

6
packages/engine/src/auth-storage.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
import { AuthStorage } from "@mariozechner/pi-coding-agent";
export declare function getFusionAuthPath(home?: string): string;
export declare function getFusionModelsPath(home?: string): string;
export declare function getModelRegistryModelsPath(home?: string): string;
export declare function createFusionAuthStorage(): AuthStorage;
//# sourceMappingURL=auth-storage.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"auth-storage.d.ts","sourceRoot":"","sources":["auth-storage.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAiB5D,wBAAgB,iBAAiB,CAAC,IAAI,SAAe,GAAG,MAAM,CAE7D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,SAAe,GAAG,MAAM,CAE/D;AAgBD,wBAAgB,0BAA0B,CAAC,IAAI,SAAe,GAAG,MAAM,CAOtE;AAmDD,wBAAgB,uBAAuB,IAAI,WAAW,CA6CrD"}

View File

@@ -0,0 +1,114 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { AuthStorage } from "@mariozechner/pi-coding-agent";
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
function getHomeDir() {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
export function getFusionAuthPath(home = getHomeDir()) {
return join(home, ".fusion", "agent", "auth.json");
}
export function getFusionModelsPath(home = getHomeDir()) {
return join(home, ".fusion", "agent", "models.json");
}
function getLegacyAuthPaths(home = getHomeDir()) {
return [
join(home, ".pi", "agent", "auth.json"),
join(home, ".pi", "auth.json"),
];
}
function getLegacyModelsPaths(home = getHomeDir()) {
return [
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
}
export function getModelRegistryModelsPath(home = getHomeDir()) {
const fusionModelsPath = getFusionModelsPath(home);
if (existsSync(fusionModelsPath)) {
return fusionModelsPath;
}
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
function readLegacyCredentials(authPaths = getLegacyAuthPaths()) {
const credentials = {};
for (const authPath of authPaths) {
if (!existsSync(authPath)) {
continue;
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8"));
for (const [provider, credential] of Object.entries(parsed)) {
credentials[provider] ??= credential;
}
}
catch {
// Ignore invalid legacy auth files and continue with other candidates.
}
}
return credentials;
}
function resolveStoredApiKey(key) {
if (!key)
return undefined;
return process.env[key] ?? key;
}
function resolveOAuthApiKey(providerId, credential) {
if (credential.type !== "oauth" ||
typeof credential.access !== "string" ||
typeof credential.refresh !== "string" ||
typeof credential.expires !== "number" ||
Date.now() >= credential.expires) {
return undefined;
}
return getOAuthProvider(providerId)?.getApiKey(credential);
}
function resolveStoredCredentialApiKey(providerId, credential) {
if (credential?.type === "api_key") {
return resolveStoredApiKey(credential.key);
}
if (credential?.type === "oauth") {
return resolveOAuthApiKey(providerId, credential);
}
return undefined;
}
export function createFusionAuthStorage() {
const primary = AuthStorage.create(getFusionAuthPath());
let legacyCredentials = readLegacyCredentials();
return new Proxy(primary, {
get(target, prop, receiver) {
if (prop === "reload") {
return () => {
target.reload();
legacyCredentials = readLegacyCredentials();
};
}
if (prop === "get") {
return (provider) => target.get(provider) ?? legacyCredentials[provider];
}
if (prop === "has") {
return (provider) => target.has(provider) || provider in legacyCredentials;
}
if (prop === "hasAuth") {
return (provider) => target.hasAuth(provider) || Boolean(legacyCredentials[provider]);
}
if (prop === "getAll") {
return () => ({ ...legacyCredentials, ...target.getAll() });
}
if (prop === "list") {
return () => Array.from(new Set([...Object.keys(legacyCredentials), ...target.list()]));
}
if (prop === "getApiKey") {
return async (provider) => {
const primaryKey = await target.getApiKey(provider);
if (primaryKey)
return primaryKey;
return resolveStoredCredentialApiKey(provider, legacyCredentials[provider]);
};
}
return Reflect.get(target, prop, receiver);
},
});
}
//# sourceMappingURL=auth-storage.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"auth-storage.js","sourceRoot":"","sources":["auth-storage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAY7D,SAAS,UAAU;IACjB,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;AAClE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAI,GAAG,UAAU,EAAE;IACnD,OAAO,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC;AACrD,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,IAAI,GAAG,UAAU,EAAE;IACrD,OAAO,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC;AACvD,CAAC;AAED,SAAS,kBAAkB,CAAC,IAAI,GAAG,UAAU,EAAE;IAC7C,OAAO;QACL,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,WAAW,CAAC;QACvC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,WAAW,CAAC;KAC/B,CAAC;AACJ,CAAC;AAED,SAAS,oBAAoB,CAAC,IAAI,GAAG,UAAU,EAAE;IAC/C,OAAO;QACL,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,CAAC;QACzC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,aAAa,CAAC;KACjC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,0BAA0B,CAAC,IAAI,GAAG,UAAU,EAAE;IAC5D,MAAM,gBAAgB,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,UAAU,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACjC,OAAO,gBAAgB,CAAC;IAC1B,CAAC;IAED,OAAO,oBAAoB,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,IAAI,gBAAgB,CAAC;AACrG,CAAC;AAED,SAAS,qBAAqB,CAAC,SAAS,GAAG,kBAAkB,EAAE;IAC7D,MAAM,WAAW,GAAqC,EAAE,CAAC;IAEzD,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC1B,SAAS;QACX,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAqC,CAAC;YAC/F,KAAK,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC5D,WAAW,CAAC,QAAQ,CAAC,KAAK,UAAU,CAAC;YACvC,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,uEAAuE;QACzE,CAAC;IACH,CAAC;IAED,OAAO,WAAW,CAAC;AACrB,CAAC;AAED,SAAS,mBAAmB,CAAC,GAAuB;IAClD,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,OAAO,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC;AACjC,CAAC;AAED,SAAS,kBAAkB,CAAC,UAAkB,EAAE,UAA4B;IAC1E,IACE,UAAU,CAAC,IAAI,KAAK,OAAO;QAC3B,OAAO,UAAU,CAAC,MAAM,KAAK,QAAQ;QACrC,OAAO,UAAU,CAAC,OAAO,KAAK,QAAQ;QACtC,OAAO,UAAU,CAAC,OAAO,KAAK,QAAQ;QACtC,IAAI,CAAC,GAAG,EAAE,IAAI,UAAU,CAAC,OAAO,EAChC,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,OAAO,gBAAgB,CAAC,UAAU,CAAC,EAAE,SAAS,CAAC,UAA8B,CAAC,CAAC;AACjF,CAAC;AAED,SAAS,6BAA6B,CAAC,UAAkB,EAAE,UAAwC;IACjG,IAAI,UAAU,EAAE,IAAI,KAAK,SAAS,EAAE,CAAC;QACnC,OAAO,mBAAmB,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,UAAU,EAAE,IAAI,KAAK,OAAO,EAAE,CAAC;QACjC,OAAO,kBAAkB,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,uBAAuB;IACrC,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxD,IAAI,iBAAiB,GAAG,qBAAqB,EAAE,CAAC;IAEhD,OAAO,IAAI,KAAK,CAAC,OAAO,EAAE;QACxB,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ;YACxB,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,OAAO,GAAG,EAAE;oBACV,MAAM,CAAC,MAAM,EAAE,CAAC;oBAChB,iBAAiB,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,CAAC,CAAC;YACJ,CAAC;YAED,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;gBACnB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,iBAAiB,CAAC,QAAQ,CAAC,CAAC;YACnF,CAAC;YAED,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;gBACnB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,QAAQ,IAAI,iBAAiB,CAAC;YACrF,CAAC;YAED,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;gBACvB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC;YAChG,CAAC;YAED,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,OAAO,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,iBAAiB,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;YAC9D,CAAC;YAED,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;gBACpB,OAAO,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;YAC1F,CAAC;YAED,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;gBACzB,OAAO,KAAK,EAAE,QAAgB,EAAE,EAAE;oBAChC,MAAM,UAAU,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;oBACpD,IAAI,UAAU;wBAAE,OAAO,UAAU,CAAC;oBAElC,OAAO,6BAA6B,CAAC,QAAQ,EAAE,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC;gBAC9E,CAAC,CAAC;YACJ,CAAC;YAED,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC;QAC7C,CAAC;KACF,CAAgB,CAAC;AACpB,CAAC"}

View File

@@ -0,0 +1,26 @@
/**
* Context limit error detection.
*
* Classifies errors from LLM providers that indicate the conversation context
* has grown too large for the model's window. Used by the executor to trigger
* compact-and-resume recovery before falling back to kill/requeue.
*
* Patterns are intentionally conservative — we only match errors that
* explicitly reference context/token overflow, NOT generic rate limits or
* server errors (those are handled by usage-limit-detector and transient-error-detector).
*/
/**
* Check if an error message indicates a context-window overflow.
*
* Returns true only when the message explicitly references context overflow
* from a known LLM provider pattern. Returns false for:
* - Rate limit errors (handled by usage-limit-detector)
* - Transient network errors (handled by transient-error-detector)
* - Generic "limit exceeded" without context keywords (false positive prevention)
* - "Aborted" errors without context signal
*
* @param message — The error message string to classify
* @returns true if the message indicates a context overflow
*/
export declare function isContextLimitError(message: string): boolean;
//# sourceMappingURL=context-limit-detector.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"context-limit-detector.d.ts","sourceRoot":"","sources":["context-limit-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAoCH;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAG5D"}

View File

@@ -0,0 +1,63 @@
/**
* Context limit error detection.
*
* Classifies errors from LLM providers that indicate the conversation context
* has grown too large for the model's window. Used by the executor to trigger
* compact-and-resume recovery before falling back to kill/requeue.
*
* Patterns are intentionally conservative — we only match errors that
* explicitly reference context/token overflow, NOT generic rate limits or
* server errors (those are handled by usage-limit-detector and transient-error-detector).
*/
/** Patterns that indicate a context-window overflow from the LLM provider. */
const CONTEXT_OVERFLOW_PATTERNS = [
// Anthropic: "prompt is too long: X tokens > Y maximum"
/prompt is too long/i,
// OpenAI (Completions & Responses): "exceeds the context window"
/exceeds?\s+the\s+context\s+window/i,
// Google Gemini: "input token count exceeds the maximum"
/input token count exceeds/i,
// xAI (Grok): "maximum prompt length is X but request contains Y"
/maximum prompt length/i,
// Groq: "reduce the length of the messages"
/reduce the length of the messages/i,
// Mistral: "too large for model with Y maximum context length"
/too large for model with.*maximum context length/i,
// OpenRouter (all backends): "maximum context length is X tokens"
/maximum context length is \d+ tokens/i,
// llama.cpp: "exceeds the available context size"
/exceeds?\s+the\s+available\s+context\s+size/i,
// LM Studio: "greater than the context length"
/greater than the context length/i,
// Kimi: "exceeded model token limit"
/exceeded model token limit/i,
// Generic catch-all: "context length exceeded" / "context window exceeded"
/context (?:length|window|size) exceeded/i,
// Token limit patterns with context keywords
/token limit.*context/i,
/too many tokens/i,
// Anthropic variant: "messages with that many tokens would exceed"
/tokens? would exceed/i,
// Provider JSON error envelope variant: "context window exceeds limit (2013)"
// Matches when "context window" and "exceeds" appear together (order-flexible)
/context\s+window\s+exceeds/i,
];
/**
* Check if an error message indicates a context-window overflow.
*
* Returns true only when the message explicitly references context overflow
* from a known LLM provider pattern. Returns false for:
* - Rate limit errors (handled by usage-limit-detector)
* - Transient network errors (handled by transient-error-detector)
* - Generic "limit exceeded" without context keywords (false positive prevention)
* - "Aborted" errors without context signal
*
* @param message — The error message string to classify
* @returns true if the message indicates a context overflow
*/
export function isContextLimitError(message) {
if (!message)
return false;
return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(message));
}
//# sourceMappingURL=context-limit-detector.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"context-limit-detector.js","sourceRoot":"","sources":["context-limit-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,8EAA8E;AAC9E,MAAM,yBAAyB,GAAa;IAC1C,wDAAwD;IACxD,qBAAqB;IACrB,iEAAiE;IACjE,oCAAoC;IACpC,yDAAyD;IACzD,4BAA4B;IAC5B,kEAAkE;IAClE,wBAAwB;IACxB,4CAA4C;IAC5C,oCAAoC;IACpC,+DAA+D;IAC/D,mDAAmD;IACnD,kEAAkE;IAClE,uCAAuC;IACvC,kDAAkD;IAClD,8CAA8C;IAC9C,+CAA+C;IAC/C,kCAAkC;IAClC,qCAAqC;IACrC,6BAA6B;IAC7B,2EAA2E;IAC3E,0CAA0C;IAC1C,6CAA6C;IAC7C,uBAAuB;IACvB,kBAAkB;IAClB,mEAAmE;IACnE,uBAAuB;IACvB,8EAA8E;IAC9E,+EAA+E;IAC/E,6BAA6B;CAC9B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAAe;IACjD,IAAI,CAAC,OAAO;QAAE,OAAO,KAAK,CAAC;IAC3B,OAAO,yBAAyB,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;AAC5E,CAAC"}

68
packages/engine/src/logger.d.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
/**
* Lightweight structured logger for the `@fusion/engine` package.
*
* Usage:
* ```ts
* import { createLogger } from "./logger.js";
* const log = createLogger("my-module");
* log.log("hello"); // → console.error("[my-module] hello")
* log.warn("oops"); // → console.warn("[my-module] oops")
* log.error("fail"); // → console.error("[my-module] fail")
* ```
*
* All engine subsystems should use the pre-built instances exported below
* rather than calling `console.*` directly. This gives us a single point
* of control for filtering, suppressing (e.g. in tests), or redirecting
* engine log output in the future.
*/
export interface Logger {
log(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}
/**
* Create a structured logger that prefixes every message with `[prefix]`.
*
* @param prefix - Short subsystem name, e.g. `"scheduler"` or `"executor"`.
* @returns A `Logger` whose output is prefixed and sent to stderr. Keeping
* engine logs off stdout prevents command/test output consumers from
* receiving Fusion execution chatter.
*/
export declare function createLogger(prefix: string): Logger;
/** Logger for the scheduler subsystem. */
export declare const schedulerLog: Logger;
/** Logger for the task executor subsystem. */
export declare const executorLog: Logger;
/** Logger for the triage processor subsystem. */
export declare const triageLog: Logger;
/** Logger for the pi agent session subsystem. */
export declare const piLog: Logger;
/** Logger for extension discovery/provider registration. */
export declare const extensionsLog: Logger;
/** Logger for the merge/auto-merge subsystem. */
export declare const mergerLog: Logger;
/** Logger for the worktree pool subsystem. */
export declare const worktreePoolLog: Logger;
/** Logger for the review subsystem. */
export declare const reviewerLog: Logger;
/** Logger for the PR monitor subsystem. */
export declare const prMonitorLog: Logger;
/** Logger for the project runtime subsystem. */
export declare const runtimeLog: Logger;
/** Logger for the IPC subsystem. */
export declare const ipcLog: Logger;
/** Logger for the project manager subsystem. */
export declare const projectManagerLog: Logger;
/** Logger for the hybrid executor subsystem. */
export declare const hybridExecutorLog: Logger;
/** Logger for the mission autopilot subsystem. */
export declare const autopilotLog: Logger;
/** Logger for the heartbeat execution subsystem. */
export declare const heartbeatLog: Logger;
/** Logger for remote node runtime/client subsystems. */
export declare const remoteNodeLog: Logger;
/** Logger for periodic node health monitor subsystem. */
export declare const nodeHealthMonitorLog: Logger;
/** Logger for the peer exchange (gossip) subsystem. */
export declare const peerExchangeLog: Logger;
//# sourceMappingURL=logger.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,MAAM;IACrB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC/C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAClD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAanD;AAED,0CAA0C;AAC1C,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,8CAA8C;AAC9C,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,iDAAiD;AACjD,eAAO,MAAM,KAAK,QAAqB,CAAC;AAExC,4DAA4D;AAC5D,eAAO,MAAM,aAAa,QAA6B,CAAC;AAExD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,8CAA8C;AAC9C,eAAO,MAAM,eAAe,QAAgC,CAAC;AAE7D,uCAAuC;AACvC,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,2CAA2C;AAC3C,eAAO,MAAM,YAAY,QAA6B,CAAC;AAEvD,gDAAgD;AAChD,eAAO,MAAM,UAAU,QAA0B,CAAC;AAElD,oCAAoC;AACpC,eAAO,MAAM,MAAM,QAAsB,CAAC;AAE1C,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,kDAAkD;AAClD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,oDAAoD;AACpD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,wDAAwD;AACxD,eAAO,MAAM,aAAa,QAA8B,CAAC;AAEzD,yDAAyD;AACzD,eAAO,MAAM,oBAAoB,QAAsC,CAAC;AAExE,uDAAuD;AACvD,eAAO,MAAM,eAAe,QAAgC,CAAC"}

View File

@@ -0,0 +1,76 @@
/**
* Lightweight structured logger for the `@fusion/engine` package.
*
* Usage:
* ```ts
* import { createLogger } from "./logger.js";
* const log = createLogger("my-module");
* log.log("hello"); // → console.error("[my-module] hello")
* log.warn("oops"); // → console.warn("[my-module] oops")
* log.error("fail"); // → console.error("[my-module] fail")
* ```
*
* All engine subsystems should use the pre-built instances exported below
* rather than calling `console.*` directly. This gives us a single point
* of control for filtering, suppressing (e.g. in tests), or redirecting
* engine log output in the future.
*/
/**
* Create a structured logger that prefixes every message with `[prefix]`.
*
* @param prefix - Short subsystem name, e.g. `"scheduler"` or `"executor"`.
* @returns A `Logger` whose output is prefixed and sent to stderr. Keeping
* engine logs off stdout prevents command/test output consumers from
* receiving Fusion execution chatter.
*/
export function createLogger(prefix) {
const tag = `[${prefix}]`;
return {
log(message, ...args) {
console.error(`${tag} ${message}`, ...args);
},
warn(message, ...args) {
console.warn(`${tag} ${message}`, ...args);
},
error(message, ...args) {
console.error(`${tag} ${message}`, ...args);
},
};
}
/** Logger for the scheduler subsystem. */
export const schedulerLog = createLogger("scheduler");
/** Logger for the task executor subsystem. */
export const executorLog = createLogger("executor");
/** Logger for the triage processor subsystem. */
export const triageLog = createLogger("triage");
/** Logger for the pi agent session subsystem. */
export const piLog = createLogger("pi");
/** Logger for extension discovery/provider registration. */
export const extensionsLog = createLogger("extensions");
/** Logger for the merge/auto-merge subsystem. */
export const mergerLog = createLogger("merger");
/** Logger for the worktree pool subsystem. */
export const worktreePoolLog = createLogger("worktree-pool");
/** Logger for the review subsystem. */
export const reviewerLog = createLogger("reviewer");
/** Logger for the PR monitor subsystem. */
export const prMonitorLog = createLogger("pr-monitor");
/** Logger for the project runtime subsystem. */
export const runtimeLog = createLogger("runtime");
/** Logger for the IPC subsystem. */
export const ipcLog = createLogger("ipc");
/** Logger for the project manager subsystem. */
export const projectManagerLog = createLogger("project-manager");
/** Logger for the hybrid executor subsystem. */
export const hybridExecutorLog = createLogger("hybrid-executor");
/** Logger for the mission autopilot subsystem. */
export const autopilotLog = createLogger("autopilot");
/** Logger for the heartbeat execution subsystem. */
export const heartbeatLog = createLogger("heartbeat");
/** Logger for remote node runtime/client subsystems. */
export const remoteNodeLog = createLogger("remote-node");
/** Logger for periodic node health monitor subsystem. */
export const nodeHealthMonitorLog = createLogger("node-health-monitor");
/** Logger for the peer exchange (gossip) subsystem. */
export const peerExchangeLog = createLogger("peer-exchange");
//# sourceMappingURL=logger.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,GAAG,GAAG,IAAI,MAAM,GAAG,CAAC;IAC1B,OAAO;QACL,GAAG,CAAC,OAAe,EAAE,GAAG,IAAe;YACrC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC9C,CAAC;QACD,IAAI,CAAC,OAAe,EAAE,GAAG,IAAe;YACtC,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC7C,CAAC;QACD,KAAK,CAAC,OAAe,EAAE,GAAG,IAAe;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC9C,CAAC;KACF,CAAC;AACJ,CAAC;AAED,0CAA0C;AAC1C,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;AAEpD,iDAAiD;AACjD,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;AAEhD,iDAAiD;AACjD,MAAM,CAAC,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;AAExC,4DAA4D;AAC5D,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;AAExD,iDAAiD;AACjD,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;AAEhD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC;AAE7D,uCAAuC;AACvC,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;AAEpD,2CAA2C;AAC3C,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;AAEvD,gDAAgD;AAChD,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;AAElD,oCAAoC;AACpC,MAAM,CAAC,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;AAE1C,gDAAgD;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAEjE,gDAAgD;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAEjE,kDAAkD;AAClD,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,oDAAoD;AACpD,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,wDAAwD;AACxD,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;AAEzD,yDAAyD;AACzD,MAAM,CAAC,MAAM,oBAAoB,GAAG,YAAY,CAAC,qBAAqB,CAAC,CAAC;AAExE,uDAAuD;AACvD,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC"}

95
packages/engine/src/pi.d.ts vendored Normal file
View File

@@ -0,0 +1,95 @@
/**
* Shared pi SDK setup for fn engine agents.
*
* Uses Fusion auth for writes and legacy pi auth as a read-only fallback.
* Provides factory functions for creating triage and executor agent sessions.
*/
import { SessionManager, type AgentSession, type ToolDefinition } from "@mariozechner/pi-coding-agent";
import { type SkillSelectionContext } from "./skill-resolver.js";
export interface AgentResult {
session: AgentSession;
/** Path to the persisted session file (undefined for in-memory sessions). */
sessionFile?: string;
}
export interface PromptableSession extends AgentSession {
promptWithFallback: (prompt: string, options?: unknown) => Promise<void>;
}
export declare function promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
/**
* Extract a human-readable model description from an AgentSession.
* Returns `"<provider>/<modelId>"` (e.g. `"anthropic/claude-sonnet-4-5"`)
* or `"unknown model"` when the session has no model set.
*/
export declare function describeModel(session: AgentSession): string;
/**
* Default instructions used when calling `session.compact()` for loop recovery.
* These guide the compaction summary to preserve essential context while
* freeing up the context window for continued work.
*/
export declare const COMPACTION_FALLBACK_INSTRUCTIONS: string;
/**
* Compact an agent session's context to free up the context window.
*
* Uses the SDK's native `session.compact()` method when available (the
* preferred path — it produces structured, LLM-generated summaries).
*
* @param session — The agent session to compact
* @param customInstructions — Optional instructions for the compaction summary.
* When not provided, uses COMPACTION_FALLBACK_INSTRUCTIONS.
* @returns The compaction result with summary and token metrics, or null if
* compaction was not available or failed.
*/
export declare function compactSessionContext(session: AgentSession, customInstructions?: string): Promise<{
summary: string;
tokensBefore: number;
} | null>;
export interface AgentOptions {
cwd: string;
systemPrompt: string;
tools?: "coding" | "readonly";
customTools?: ToolDefinition[];
onText?: (delta: string) => void;
onThinking?: (delta: string) => void;
onToolStart?: (name: string, args?: Record<string, unknown>) => void;
onToolEnd?: (name: string, isError: boolean, result?: unknown) => void;
/** Default model provider (e.g. "anthropic"). Used with `defaultModelId` to select a specific model. */
defaultProvider?: string;
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). Used with `defaultProvider`. */
defaultModelId?: string;
/** Optional fallback model provider used when the primary selected model hits
* a retryable provider-side failure such as rate limiting or overload. */
fallbackProvider?: string;
/** Optional fallback model ID used with `fallbackProvider`. */
fallbackModelId?: string;
/** Default thinking effort level (e.g. "medium", "high"). When provided, sets the session's thinking level after creation. */
defaultThinkingLevel?: string;
/** Optional pre-configured SessionManager. When provided, the agent session
* uses this instead of creating an in-memory session. Pass a file-based
* SessionManager to enable session persistence and pause/resume. */
sessionManager?: SessionManager;
/** Optional skill selection context. When provided, the agent session's
* skills are filtered according to project execution settings and any
* caller-requested skill names. Omit to use default skill discovery
* (all discovered skills included). */
skillSelection?: SkillSelectionContext;
/** Convenience: skill names to include in the session. When provided
* (and `skillSelection` is not), auto-constructs a SkillSelectionContext
* from the cwd and these names. Ignored when `skillSelection` is set. */
skills?: string[];
}
/**
* Wrap tools with worktree boundary validation.
* When cwd is a worktree path, file operations are validated against worktree boundaries.
*
* @param tools - Array of tool definitions to wrap
* @param worktreePath - Absolute path to the worktree directory (if applicable)
* @param projectRoot - Absolute path to the project root (if applicable)
* @returns Wrapped tools with boundary validation
*/
export declare function wrapToolsWithBoundary(tools: ToolDefinition[], worktreePath: string | null, projectRoot: string | null): ToolDefinition[];
/**
* Create a pi agent session configured for fn.
* Reuses the user's existing pi auth and model configuration.
*/
export declare function createFnAgent(options: AgentOptions): Promise<AgentResult>;
//# sourceMappingURL=pi.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"pi.d.ts","sourceRoot":"","sources":["pi.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,OAAO,EASL,cAAc,EAEd,KAAK,YAAY,EACjB,KAAK,cAAc,EACpB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EAGL,KAAK,qBAAqB,EAC3B,MAAM,qBAAqB,CAAC;AAK7B,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,YAAY,CAAC;IACtB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,iBAAkB,SAAQ,YAAY;IACrD,kBAAkB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAkCD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAkDhH;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAI3D;AAED;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,QAKlC,CAAC;AA8GZ;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,YAAY,EACrB,kBAAkB,CAAC,EAAE,MAAM,GAC1B,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAsB3D;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC9B,WAAW,CAAC,EAAE,cAAc,EAAE,CAAC;IAC/B,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACrE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IACvE,wGAAwG;IACxG,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oGAAoG;IACpG,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;+EAC2E;IAC3E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8HAA8H;IAC9H,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;yEAEqE;IACrE,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;4CAGwC;IACxC,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC;;8EAE0E;IAC1E,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AA4QD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,cAAc,EAAE,EACvB,YAAY,EAAE,MAAM,GAAG,IAAI,EAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,GACzB,cAAc,EAAE,CAmDlB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAoQ/E"}

753
packages/engine/src/pi.js Normal file
View File

@@ -0,0 +1,753 @@
/**
* Shared pi SDK setup for fn engine agents.
*
* Uses Fusion auth for writes and legacy pi auth as a read-only fallback.
* Provides factory functions for creating triage and executor agent sessions.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { existsSync, readFileSync } from "node:fs";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { basename, dirname, join, relative, isAbsolute, resolve } from "node:path";
const execAsync = promisify(exec);
import { createAgentSession, createCodingTools, createExtensionRuntime, createReadOnlyTools, DefaultResourceLoader, DefaultPackageManager, discoverAndLoadExtensions, ModelRegistry, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, resolvePiExtensionProjectRoot } from "@fusion/core";
import { resolveSessionSkills, createSkillsOverrideFromSelection, } from "./skill-resolver.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
import { piLog, extensionsLog } from "./logger.js";
function getSessionStateError(session) {
const error = session.state?.error;
return typeof error === "string" ? error : "";
}
function clearSessionStateError(session) {
const state = session.state;
if (!state || typeof state !== "object" || !("error" in state)) {
return;
}
try {
state.error = undefined;
}
catch {
// Best effort only. Some session implementations may expose readonly state.
}
}
async function promptSessionAndCheck(session, prompt, options) {
clearSessionStateError(session);
if (options === undefined) {
await session.prompt(prompt);
}
else {
await session.prompt(prompt, options);
}
const stateError = getSessionStateError(session);
if (stateError) {
throw new Error(stateError);
}
}
export async function promptWithFallback(session, prompt, options) {
const maybePromptable = session;
if (typeof maybePromptable.promptWithFallback === "function") {
piLog.log(`promptWithFallback: delegating to session.promptWithFallback (prompt length=${prompt.length})`);
await maybePromptable.promptWithFallback(prompt, options);
piLog.log("promptWithFallback: completed");
return;
}
piLog.log(`promptWithFallback: calling session.prompt (prompt length=${prompt.length})`);
try {
await promptSessionAndCheck(session, prompt, options);
piLog.log("promptWithFallback: prompt completed");
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (!isContextLimitError(errorMessage)) {
piLog.error(`promptWithFallback: non-context error — propagating: ${errorMessage}`);
throw err;
}
// Context limit error — attempt auto-compaction and retry once
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, options);
if (promptMemoryRetry.recovered) {
return;
}
if (promptMemoryRetry.error) {
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptMemoryRetry.error;
}
}
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(session);
const compactResult = await compactSessionContext(session);
if (!compactResult) {
piLog.error("promptWithFallback: compaction unavailable — propagating original error");
throw err;
}
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
try {
await promptSessionAndCheck(session, prompt, options);
piLog.log("promptWithFallback: prompt completed after auto-compaction");
}
catch (retryErr) {
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
piLog.error(`promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
throw err; // Throw original error to preserve original context
}
}
}
/**
* Extract a human-readable model description from an AgentSession.
* Returns `"<provider>/<modelId>"` (e.g. `"anthropic/claude-sonnet-4-5"`)
* or `"unknown model"` when the session has no model set.
*/
export function describeModel(session) {
const model = session.model;
if (!model)
return "unknown model";
return `${model.provider}/${model.id}`;
}
/**
* Default instructions used when calling `session.compact()` for loop recovery.
* These guide the compaction summary to preserve essential context while
* freeing up the context window for continued work.
*/
export const COMPACTION_FALLBACK_INSTRUCTIONS = [
"Summarize all completed steps concisely.",
"Preserve the current step number and any in-progress work details.",
"Keep references to key files, decisions, and error states.",
"Discard verbose tool output, repeated attempts, and exploration history.",
].join(" ");
const MAX_COMPACTED_PROMPT_MEMORY_CHARS = 8_000;
function compactMarkdownMemorySection(sectionBody) {
const lines = sectionBody.split("\n");
const kept = [];
let used = 0;
for (const line of lines) {
const trimmed = line.trimEnd();
const normalized = trimmed.trimStart();
const isUseful = normalized.startsWith("##")
|| normalized.startsWith("- ")
|| normalized.startsWith("* ")
|| /^\d+\.\s/.test(normalized)
|| normalized.length === 0;
if (!isUseful) {
continue;
}
const nextLength = used + trimmed.length + 1;
if (nextLength > MAX_COMPACTED_PROMPT_MEMORY_CHARS) {
break;
}
kept.push(trimmed);
used = nextLength;
}
const compacted = kept.join("\n").trim();
if (compacted.length >= sectionBody.trim().length) {
return sectionBody.trim();
}
return [
compacted,
"",
`<!-- Memory compacted from ${sectionBody.length} characters to avoid context overflow. Use memory tools or the selected memory file later only if essential. -->`,
].join("\n").trim();
}
function compactPromptMemory(prompt) {
const sectionPattern = /(^|\n)(## (?:Project Memory|Agent Memory|Memory)\n\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
let changed = false;
const compactedPrompt = prompt.replace(sectionPattern, (match, prefix, heading, body) => {
const trimmedBody = body.trim();
if (trimmedBody.length <= MAX_COMPACTED_PROMPT_MEMORY_CHARS) {
return match;
}
const compacted = compactMarkdownMemorySection(trimmedBody);
if (compacted.length >= trimmedBody.length) {
return match;
}
changed = true;
return `${prefix}${heading}${compacted}`;
});
return changed && compactedPrompt.length < prompt.length ? compactedPrompt : null;
}
async function retryWithCompactedPromptMemory(session, prompt, options) {
const compactedPrompt = compactPromptMemory(prompt);
if (!compactedPrompt) {
return { recovered: false };
}
piLog.log(`promptWithFallback: retrying with compacted prompt memory (${prompt.length}${compactedPrompt.length} chars)`);
try {
await promptSessionAndCheck(session, compactedPrompt, options);
piLog.log("promptWithFallback: prompt completed after prompt-memory compaction");
return { recovered: true };
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
piLog.error(`promptWithFallback: retry after prompt-memory compaction failed: ${errorMessage}`);
return { recovered: false, error: err };
}
}
async function flushMemoryBeforeSessionCompaction(session) {
if (session.__fusionMemoryAppendAvailable !== true) {
return;
}
const flushPrompt = [
"Before context compaction, preserve only unresolved durable memory if needed.",
"If memory_append is available and you learned reusable project decisions, conventions, pitfalls, or open loops that are not already saved, append them now.",
"Use layer=\"long-term\" for durable facts and layer=\"daily\" for running notes/open loops.",
"If there is nothing durable to save, reply exactly: NONE.",
].join("\n");
try {
await promptSessionAndCheck(session, flushPrompt);
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
piLog.warn(`promptWithFallback: memory flush before compaction skipped: ${errorMessage}`);
}
}
/**
* Compact an agent session's context to free up the context window.
*
* Uses the SDK's native `session.compact()` method when available (the
* preferred path — it produces structured, LLM-generated summaries).
*
* @param session — The agent session to compact
* @param customInstructions — Optional instructions for the compaction summary.
* When not provided, uses COMPACTION_FALLBACK_INSTRUCTIONS.
* @returns The compaction result with summary and token metrics, or null if
* compaction was not available or failed.
*/
export async function compactSessionContext(session, customInstructions) {
const instructions = customInstructions ?? COMPACTION_FALLBACK_INSTRUCTIONS;
// Check if session.compact is available (runtime capability detection)
if (typeof session.compact !== "function") {
return null;
}
try {
const result = await session.compact(instructions);
if (result && typeof result === "object") {
return {
summary: result.summary ?? "",
tokensBefore: result.tokensBefore ?? 0,
};
}
return null;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
piLog.warn(`Context compaction failed (will fall through to kill/requeue): ${msg}`);
return null;
}
}
function resolveConfiguredModel(modelRegistry, kind, provider, modelId) {
if (!provider || !modelId) {
return undefined;
}
const model = modelRegistry.find(provider, modelId);
if (model) {
return model;
}
// Fall back to constructing a model on-the-fly if the provider is known.
// This mirrors the pi CLI's buildFallbackModel behaviour, which accepts any
// model ID for a configured provider (e.g. any OpenRouter model string) even
// when it isn't in the built-in or custom model list.
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider);
if (providerModels.length > 0) {
const baseModel = providerModels[0];
piLog.warn(`${kind} model ${provider}/${modelId} not in registry; using provider base model as template`);
return { ...baseModel, id: modelId, name: modelId };
}
throw new Error(`Configured ${kind} model ${provider}/${modelId} was not found in the pi model registry. ` +
"Open Settings and choose a model from /api/models, or update your pi model configuration.");
}
function isRetryableModelSelectionError(message) {
const normalized = message.toLowerCase();
return normalized.includes("rate limit")
|| normalized.includes("too many requests")
|| normalized.includes("429")
|| normalized.includes("401")
|| normalized.includes("403")
|| normalized.includes("unauthorized")
|| normalized.includes("forbidden")
|| normalized.includes("authentication")
|| normalized.includes("invalid api key")
|| normalized.includes("invalid key")
|| normalized.includes("api key")
|| normalized.includes("overloaded")
|| normalized.includes("quota")
|| normalized.includes("capacity")
|| normalized.includes("temporarily unavailable")
|| normalized.includes("invalid temperature");
}
function readJsonObject(path) {
if (!existsSync(path)) {
return {};
}
try {
const parsed = JSON.parse(readFileSync(path, "utf-8"));
return parsed && typeof parsed === "object" ? parsed : {};
}
catch {
return {};
}
}
function hasPackageManagerSettings(settings) {
return Array.isArray(settings.packages) || Array.isArray(settings.npmCommand);
}
function siblingAgentDir(agentDir, siblingRoot) {
if (basename(agentDir) !== "agent") {
return undefined;
}
return join(dirname(dirname(agentDir)), siblingRoot, "agent");
}
function createReadOnlyPiSettingsView(cwd, agentDir) {
const projectRoot = resolvePiExtensionProjectRoot(cwd);
const fusionAgentDir = agentDir.includes(`${join(".fusion", "agent")}`)
? agentDir
: siblingAgentDir(agentDir, ".fusion");
const legacyAgentDir = agentDir.includes(`${join(".pi", "agent")}`)
? agentDir
: siblingAgentDir(agentDir, ".pi");
const legacyGlobalSettings = legacyAgentDir ? readJsonObject(join(legacyAgentDir, "settings.json")) : {};
const fusionGlobalSettings = fusionAgentDir ? readJsonObject(join(fusionAgentDir, "settings.json")) : {};
const directGlobalSettings = readJsonObject(join(agentDir, "settings.json"));
const globalSettings = { ...legacyGlobalSettings, ...directGlobalSettings, ...fusionGlobalSettings };
const fusionProjectSettings = readJsonObject(join(projectRoot, ".fusion", "settings.json"));
const mergedSettings = { ...globalSettings, ...fusionProjectSettings };
return {
getGlobalSettings: () => structuredClone(globalSettings),
getProjectSettings: () => structuredClone(fusionProjectSettings),
getNpmCommand: () => Array.isArray(mergedSettings.npmCommand)
? [...mergedSettings.npmCommand]
: undefined,
};
}
function getPackageManagerAgentDir() {
const fusionAgentDir = getFusionAgentDir();
const legacyAgentDir = getLegacyPiAgentDir();
const fusionSettings = readJsonObject(join(fusionAgentDir, "settings.json"));
const legacySettings = readJsonObject(join(legacyAgentDir, "settings.json"));
if (hasPackageManagerSettings(fusionSettings) || !existsSync(legacyAgentDir)) {
return fusionAgentDir;
}
if (hasPackageManagerSettings(legacySettings)) {
return legacyAgentDir;
}
return existsSync(fusionAgentDir) ? fusionAgentDir : legacyAgentDir;
}
async function registerExtensionProviders(cwd, modelRegistry) {
try {
const agentDir = getPackageManagerAgentDir();
const packageManager = new DefaultPackageManager({
cwd,
agentDir,
settingsManager: createReadOnlyPiSettingsView(cwd, agentDir),
});
const resolvedPaths = await packageManager.resolve();
const packageExtensionPaths = resolvedPaths.extensions
.filter((resource) => resource.enabled)
.map((resource) => resource.path);
const extensionsResult = await discoverAndLoadExtensions([...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths], cwd, join(resolvePiExtensionProjectRoot(cwd), ".fusion", "disabled-auto-extension-discovery"));
for (const { path, error } of extensionsResult.errors) {
extensionsLog.warn(`Failed to load ${path}: ${error}`);
}
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
extensionsLog.warn(`Failed to register provider from ${extensionPath}: ${message}`);
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
extensionsLog.error(`Failed to discover extensions: ${message}`);
createExtensionRuntime();
modelRegistry.refresh();
}
}
// ── Worktree Path Boundary Helpers ──────────────────────────────────────────
/**
* Detect if a path is a task worktree under `.worktrees/`.
* Returns the project root if the path is a worktree, otherwise null.
*
* Examples:
* `/project/.worktrees/fn-001` → `/project`
* `/project/.worktrees/fn-001/src/file.ts` → `/project`
* `/project` → null (not a worktree)
*/
function getProjectRootFromWorktree(cwd) {
// Match paths like /project/.worktrees/task-id or /project/.worktrees/task-id/...
const match = cwd.match(/^(.+?)\/\.worktrees\/[^/]+/);
if (match) {
return match[1];
}
return null;
}
async function isRegisteredGitWorktree(projectRoot, worktreePath) {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: projectRoot,
encoding: "utf-8",
});
const resolvedWorktree = resolve(worktreePath);
return stdout.split("\n").some((line) => line.startsWith("worktree ") && resolve(line.slice("worktree ".length)) === resolvedWorktree);
}
catch {
return false;
}
}
async function isCompleteGitWorktree(worktreePath) {
try {
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
cwd: worktreePath,
encoding: "utf-8",
});
return resolve(stdout.trim()) === resolve(worktreePath);
}
catch {
return false;
}
}
async function assertValidWorktreeSession(cwd, projectRoot) {
if (!existsSync(cwd)) {
throw new Error(`Refusing to start coding agent in missing worktree: ${cwd}`);
}
if (!existsSync(join(cwd, ".git")) || !await isCompleteGitWorktree(cwd)) {
throw new Error(`Refusing to start coding agent in incomplete worktree: ${cwd}`);
}
if (!await isRegisteredGitWorktree(projectRoot, cwd)) {
throw new Error(`Refusing to start coding agent in unregistered git worktree: ${cwd}`);
}
}
/**
* Check if a path is allowed to be accessed from a worktree session.
* Rules:
* - Paths inside the worktree are always allowed
* - Project root .fusion/memory/ files are allowed (for durable project learnings)
* - Task attachments under .fusion/tasks/N/attachments/ are allowed (for reading context files)
* - All other paths outside the worktree are rejected
*
* @param worktreePath - Absolute path to the worktree directory
* @param projectRoot - Absolute path to the project root (derived from worktree)
* @param requestedPath - The path being accessed
* @returns true if allowed, false if rejected
*/
function isWorktreeAllowedPath(worktreePath, projectRoot, requestedPath) {
// Normalize paths
const worktreeResolved = resolve(worktreePath);
const projectRootResolved = resolve(projectRoot);
const requestedResolved = isAbsolute(requestedPath) ? resolve(requestedPath) : resolve(worktreeResolved, requestedPath);
// Check if path is inside the worktree
const relToWorktree = relative(worktreeResolved, requestedResolved);
if (!relToWorktree.startsWith("..") && !isAbsolute(relToWorktree)) {
return true; // Path is inside the worktree
}
// Exception: project root `.fusion/memory/` files for durable project learnings
const relToProjectRoot = relative(projectRootResolved, requestedResolved).replace(/\\/g, "/");
if (relToProjectRoot === ".fusion/memory" ||
relToProjectRoot === ".fusion/memory/" ||
relToProjectRoot.startsWith(".fusion/memory/")) {
return true;
}
// Exception: task attachments under `.fusion/tasks/*/attachments/*`
if (relToProjectRoot.match(/^\.fusion\/tasks\/[^/]+\/attachments\//)) {
return true;
}
// All other paths outside the worktree are rejected
return false;
}
/**
* Wrap tools with worktree boundary validation.
* When cwd is a worktree path, file operations are validated against worktree boundaries.
*
* @param tools - Array of tool definitions to wrap
* @param worktreePath - Absolute path to the worktree directory (if applicable)
* @param projectRoot - Absolute path to the project root (if applicable)
* @returns Wrapped tools with boundary validation
*/
export function wrapToolsWithBoundary(tools, worktreePath, projectRoot) {
if (!worktreePath || !projectRoot) {
return tools; // Not a worktree session, no wrapping needed
}
return tools.map((tool) => {
// Only wrap tools that access the filesystem
const fileToolNames = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
if (!fileToolNames.has(tool.name)) {
return tool;
}
// Store the original execute function
const originalExecute = tool.execute;
return {
...tool,
execute: async (...args) => {
const _toolCallId = args[0];
const params = args[1];
const _signal = args[2];
// Check path argument for file operations
const pathArg = params.path;
if (pathArg && !isWorktreeAllowedPath(worktreePath, projectRoot, pathArg)) {
const relToProject = relative(projectRoot, pathArg);
return {
ok: false,
error: `Path "${relToProject}" is outside the worktree boundary. ` +
`Coding agents can only modify files inside the current worktree. ` +
`Exception: .fusion/memory/ (project root) and .fusion/tasks/*/attachments/* are permitted for reading.`,
};
}
// For bash, also check the working directory if specified
const cwdArg = params.cwd;
if (tool.name === "bash" && cwdArg && !isWorktreeAllowedPath(worktreePath, projectRoot, cwdArg)) {
return {
ok: false,
error: `Working directory is outside the worktree boundary. ` +
`Commands must run inside the worktree.`,
};
}
// Call the original tool implementation with all arguments passed through
return originalExecute(...args);
},
};
});
}
/**
* Create a pi agent session configured for fn.
* Reuses the user's existing pi auth and model configuration.
*/
export async function createFnAgent(options) {
piLog.log(`createFnAgent called (cwd=${options.cwd}, tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`);
const authStorage = createFusionAuthStorage();
const modelRegistry = new ModelRegistry(authStorage, getModelRegistryModelsPath());
await registerExtensionProviders(options.cwd, modelRegistry);
const tools = options.tools === "readonly"
? createReadOnlyTools(options.cwd)
: createCodingTools(options.cwd);
// Detect if this is a worktree session and apply path boundaries
const worktreePath = options.cwd;
const projectRoot = getProjectRootFromWorktree(worktreePath);
if (projectRoot) {
await assertValidWorktreeSession(worktreePath, projectRoot);
}
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, projectRoot);
// Compaction is explicitly enabled to prevent context-window overflow during
// long-running agent conversations (triage, execution, review, merge).
// When the context fills up, pi auto-compacts the conversation history to
// keep the session alive without manual intervention. This must remain enabled
// as a reliability safeguard — disabling it would cause overflow failures.
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: true },
retry: { enabled: true, maxRetries: 3 },
});
// Resolve explicit model selection if provider and model ID are specified
const selectedModel = resolveConfiguredModel(modelRegistry, "primary", options.defaultProvider, options.defaultModelId);
const fallbackModel = resolveConfiguredModel(modelRegistry, "fallback", options.fallbackProvider, options.fallbackModelId);
// Resolve skill selection: explicit skillSelection wins over convenience `skills`
let effectiveSkillSelection = options.skillSelection;
if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {
piLog.log(`Using skills from convenience parameter: [${options.skills.join(", ")}]`);
effectiveSkillSelection = {
projectRootDir: options.cwd,
requestedSkillNames: options.skills,
sessionPurpose: "executor",
};
}
// Resolve skill selection if provided
let skillsOverrideFn;
if (effectiveSkillSelection) {
const selectionResult = resolveSessionSkills(effectiveSkillSelection);
if (selectionResult.diagnostics.length > 0) {
const purpose = effectiveSkillSelection.sessionPurpose ?? "skills";
for (const diag of selectionResult.diagnostics) {
piLog.warn(`[skills] [${purpose}] ${diag.type}: ${diag.message}`);
}
}
skillsOverrideFn = createSkillsOverrideFromSelection(selectionResult, {
requestedSkillNames: effectiveSkillSelection.requestedSkillNames,
sessionPurpose: effectiveSkillSelection.sessionPurpose,
});
}
const resourceLoader = new DefaultResourceLoader({
cwd: options.cwd,
settingsManager,
systemPromptOverride: () => options.systemPrompt,
appendSystemPromptOverride: () => [],
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
});
await resourceLoader.reload();
const sessionManager = options.sessionManager ?? SessionManager.inMemory();
const createSessionWithModel = async (modelOverride) => {
return createAgentSession({
cwd: options.cwd,
authStorage,
modelRegistry,
resourceLoader,
tools: wrappedTools,
customTools: options.customTools,
sessionManager,
settingsManager,
...(modelOverride ? { model: modelOverride } : {}),
});
};
let sessionResult;
let usingFallback = false;
try {
sessionResult = await createSessionWithModel(selectedModel);
piLog.log(`Session created successfully (model=${selectedModel ? `${selectedModel.provider}/${selectedModel.id}` : "default"})`);
}
catch (err) {
if (!fallbackModel || !selectedModel || !isRetryableModelSelectionError(err?.message || "")) {
piLog.error(`Session creation failed: ${err.message}`);
throw err;
}
piLog.warn(`Primary model failed (${err.message}), trying fallback`);
usingFallback = true;
sessionResult = await createSessionWithModel(fallbackModel);
piLog.log("Fallback session created successfully");
}
const { session } = sessionResult;
session.__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === "memory_append") === true;
const promptableSession = session;
promptableSession.promptWithFallback = async (prompt, promptOptions) => {
try {
await promptSessionAndCheck(session, prompt, promptOptions);
return;
}
catch (err) {
const errorMessage = err?.message || "";
if (isContextLimitError(errorMessage)) {
// Context limit error — attempt auto-compaction and retry once
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, promptOptions);
if (promptMemoryRetry.recovered) {
return;
}
if (promptMemoryRetry.error) {
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptMemoryRetry.error;
}
}
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(session);
const compactResult = await compactSessionContext(session);
if (compactResult) {
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
try {
await promptSessionAndCheck(session, prompt, promptOptions);
return;
}
catch (retryErr) {
const retryErrorMessage = retryErr?.message || "";
piLog.error(`promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
// Throw original error to preserve original context
throw err;
}
}
else {
piLog.error("promptWithFallback: compaction unavailable — propagating original error");
throw err;
}
}
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
throw err;
}
usingFallback = true;
try {
session.dispose();
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
piLog.warn(`Failed to dispose session during model fallback swap: ${msg}`);
}
const fallbackSessionResult = await createSessionWithModel(fallbackModel);
const fallbackSession = fallbackSessionResult.session;
fallbackSession.__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === "memory_append") === true;
if (options.defaultThinkingLevel) {
fallbackSession.setThinkingLevel(options.defaultThinkingLevel);
}
fallbackSession.subscribe((event) => {
if (event.type === "message_update") {
const msgEvent = event.assistantMessageEvent;
if (msgEvent.type === "text_delta") {
options.onText?.(msgEvent.delta);
}
else if (msgEvent.type === "thinking_delta") {
options.onThinking?.(msgEvent.delta);
}
}
if (event.type === "tool_execution_start") {
options.onToolStart?.(event.toolName, event.args);
}
if (event.type === "tool_execution_end") {
options.onToolEnd?.(event.toolName, event.isError, event.result);
}
});
Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(fallbackSession));
Object.assign(promptableSession, fallbackSession);
promptableSession.promptWithFallback = fallbackSession.promptWithFallback ?? promptableSession.promptWithFallback;
// Retry with fallback model, also with auto-compaction support
try {
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
return;
}
catch (fallbackErr) {
const fallbackErrorMessage = fallbackErr?.message || "";
if (isContextLimitError(fallbackErrorMessage)) {
const promptMemoryRetry = await retryWithCompactedPromptMemory(fallbackSession, prompt, promptOptions);
if (promptMemoryRetry.recovered) {
return;
}
if (promptMemoryRetry.error) {
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptMemoryRetry.error;
}
}
piLog.warn("promptWithFallback: fallback session context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(fallbackSession);
const compactResult = await compactSessionContext(fallbackSession);
if (compactResult) {
piLog.log(`promptWithFallback: fallback compaction succeeded (${compactResult.tokensBefore} tokens) — retrying`);
try {
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
return;
}
catch (retryErr) {
const retryErrorMessage = retryErr?.message || "";
piLog.error(`promptWithFallback: fallback retry after auto-compaction failed: ${retryErrorMessage}`);
throw fallbackErr; // Throw original fallback error
}
}
else {
piLog.error("promptWithFallback: fallback compaction unavailable — propagating original error");
throw fallbackErr;
}
}
throw fallbackErr;
}
}
};
// Apply thinking level if specified
if (options.defaultThinkingLevel) {
promptableSession.setThinkingLevel(options.defaultThinkingLevel);
}
// Wire up event listeners
promptableSession.subscribe((event) => {
if (event.type === "message_update") {
const msgEvent = event.assistantMessageEvent;
if (msgEvent.type === "text_delta") {
options.onText?.(msgEvent.delta);
}
else if (msgEvent.type === "thinking_delta") {
options.onThinking?.(msgEvent.delta);
}
}
if (event.type === "tool_execution_start") {
options.onToolStart?.(event.toolName, event.args);
}
if (event.type === "tool_execution_end") {
options.onToolEnd?.(event.toolName, event.isError, event.result);
}
});
return { session: promptableSession, sessionFile: promptableSession.sessionFile };
}
//# sourceMappingURL=pi.js.map

File diff suppressed because one or more lines are too long

View File

@@ -822,6 +822,98 @@ describe("PluginRunner", () => {
}); });
}); });
describe("Paperclip runtime compatibility", () => {
/**
* Verify that the paperclip runtime registration from
* plugins/fusion-plugin-paperclip-runtime is correctly resolvable
* through the engine's runtime resolution system.
*/
it("should resolve paperclip runtime when registered", () => {
const paperclipRuntime = {
metadata: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session using the user's configured pi provider and model",
version: "1.0.0",
},
factory: vi.fn().mockResolvedValue({}),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
]);
const result = pluginRunner.getRuntimeById("paperclip");
expect(result).toBeDefined();
expect(result?.pluginId).toBe("fusion-plugin-paperclip-runtime");
expect(result?.runtime.metadata.runtimeId).toBe("paperclip");
expect(result?.runtime.metadata.name).toBe("Paperclip Runtime");
expect(result?.runtime.metadata.description).toContain("Paperclip");
expect(result?.runtime.metadata.version).toBe("1.0.0");
});
it("should expose paperclip runtime metadata correctly", () => {
const paperclipRuntime = {
metadata: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session using the user's configured pi provider and model",
version: "1.0.0",
},
factory: vi.fn().mockResolvedValue({}),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
]);
const runtimes = pluginRunner.getPluginRuntimes();
const paperclip = runtimes.find(r => r.runtime.metadata.runtimeId === "paperclip");
expect(paperclip).toBeDefined();
expect(paperclip?.runtime.metadata).toEqual({
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session using the user's configured pi provider and model",
version: "1.0.0",
});
});
it("should allow factory invocation for paperclip runtime", async () => {
const mockAdapter = {
id: "paperclip",
name: "Paperclip Runtime",
createSession: vi.fn(),
promptWithFallback: vi.fn(),
describeModel: vi.fn(),
dispose: vi.fn(),
};
const paperclipRuntime = {
metadata: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session using the user's configured pi provider and model",
version: "1.0.0",
},
factory: vi.fn().mockResolvedValue(mockAdapter),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
]);
const result = pluginRunner.getRuntimeById("paperclip");
expect(result).toBeDefined();
// Invoke the factory (simulating runtime instantiation)
const context = { pluginId: "fusion-plugin-paperclip-runtime" };
const runtime = (await result!.runtime.factory(context as any)) as typeof mockAdapter;
expect(paperclipRuntime.factory).toHaveBeenCalledWith(context);
expect(runtime).toBe(mockAdapter);
expect(runtime.id).toBe("paperclip");
expect(runtime.name).toBe("Paperclip Runtime");
});
});
describe("getLoader() / getStore()", () => { describe("getLoader() / getStore()", () => {
it("should return the plugin loader", () => { it("should return the plugin loader", () => {
const loader = pluginRunner.getLoader(); const loader = pluginRunner.getLoader();

112
packages/engine/src/skill-resolver.d.ts vendored Normal file
View File

@@ -0,0 +1,112 @@
/**
* Skill selection resolver for deterministic session skill sets.
*
* Computes which skills should be available in agent sessions based on:
* 1. Project execution-enabled skill patterns from settings
* 2. Optional caller-requested skill names (for per-task overrides)
*
* The resolver reads project settings files directly (read-only) and produces
* a filter set used by createFnAgent's DefaultResourceLoader.skillsOverride.
*/
import type { ResourceDiagnostic, Skill } from "@mariozechner/pi-coding-agent";
/**
* Context for skill selection resolution.
*/
export interface SkillSelectionContext {
/**
* Absolute path to the project root for reading settings.
*/
projectRootDir: string;
/**
* Optional explicit skill names the caller wants (e.g., from task config).
* These are skill names (not IDs), matched case-insensitively against Skill.name.
*/
requestedSkillNames?: string[];
/**
* Diagnostic label for log messages (e.g., "executor", "triage", "reviewer").
*/
sessionPurpose?: string;
}
/**
* Diagnostic about a configured or requested skill.
*/
export interface SkillDiagnostic {
type: "info" | "warning" | "error";
message: string;
skillName?: string;
skillPath?: string;
}
/**
* Result of skill selection resolution.
*/
export interface SkillSelectionResult {
/**
* Set of skill file paths to include in the session.
* Used by skillsOverride to filter discovered skills.
*/
allowedSkillPaths: Set<string>;
/**
* Set of skill file paths that were explicitly excluded by project patterns.
* These paths were disabled via -prefix patterns.
* Used by skillsOverride to distinguish "disabled" (exists but excluded) from "missing" (doesn't exist).
*/
excludedSkillPaths: Set<string>;
/**
* Diagnostics about configured/requested skills.
*/
diagnostics: SkillDiagnostic[];
/**
* Whether filtering should be applied.
* false = all discovered skills pass through (no patterns configured, no requested names)
* true = skills are filtered according to allowedSkillPaths
*/
filterActive: boolean;
}
/**
* Compute deterministic skill selection from project settings and optional requested names.
*
* Resolution rules:
* 1. If NO skill patterns exist AND no requestedSkillNames → filterActive: false (all pass through)
* 2. If skill patterns exist:
* - + prefix or no prefix = add to allowed set
* - - prefix = exclude from allowed set
* - Last entry wins for duplicate paths
* 3. If requestedSkillNames provided:
* - Acts as additional intersection filter (skills must match name AND be in allowed set)
* - Case-insensitive matching against Skill.name
* 4. Diagnostics produced for:
* - Patterns that don't match discovered skills (warning)
* - Requested names not matching any discovered skill (warning)
*/
export declare function resolveSessionSkills(context: SkillSelectionContext): SkillSelectionResult;
/**
* Options for skills override filtering.
* We track requested names here so we can validate against base.skills.
*/
export interface SkillsOverrideOptions {
/** Set of allowed skill paths */
allowedSkillPaths: Set<string>;
/** Set of explicitly excluded skill paths (from -patterns). If not provided, defaults to empty set. */
excludedSkillPaths?: Set<string>;
/** Whether filtering is active */
filterActive: boolean;
/** Requested skill names for diagnostic purposes */
requestedSkillNames?: string[];
/** Session purpose for log messages */
sessionPurpose?: string;
}
/**
* Create a skillsOverride callback compatible with DefaultResourceLoaderOptions.skillsOverride.
*
* @param selection - The skill selection result from resolveSessionSkills
* @param options - Additional options for the override
* @returns A skillsOverride callback for DefaultResourceLoader
*/
export declare function createSkillsOverrideFromSelection(selection: SkillSelectionResult, options?: Omit<SkillsOverrideOptions, "allowedSkillPaths" | "filterActive">): (base: {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
}) => {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
};
//# sourceMappingURL=skill-resolver.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"skill-resolver.d.ts","sourceRoot":"","sources":["skill-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,+BAA+B,CAAC;AAK/E;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;OAEG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAE/B;;OAEG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAE/B;;;;OAIG;IACH,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAEhC;;OAEG;IACH,WAAW,EAAE,eAAe,EAAE,CAAC;IAE/B;;;;OAIG;IACH,YAAY,EAAE,OAAO,CAAC;CACvB;AAqED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,oBAAoB,CA0GzF;AAID;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,iCAAiC;IACjC,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,uGAAuG;IACvG,kBAAkB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,kCAAkC;IAClC,YAAY,EAAE,OAAO,CAAC;IACtB,oDAAoD;IACpD,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,oBAAoB,EAC/B,OAAO,GAAE,IAAI,CAAC,qBAAqB,EAAE,mBAAmB,GAAG,cAAc,CAAM,GAC9E,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,WAAW,EAAE,kBAAkB,EAAE,CAAA;CAAE,KAAK;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,WAAW,EAAE,kBAAkB,EAAE,CAAA;CAAE,CAoG1H"}

View File

@@ -0,0 +1,271 @@
/**
* Skill selection resolver for deterministic session skill sets.
*
* Computes which skills should be available in agent sessions based on:
* 1. Project execution-enabled skill patterns from settings
* 2. Optional caller-requested skill names (for per-task overrides)
*
* The resolver reads project settings files directly (read-only) and produces
* a filter set used by createFnAgent's DefaultResourceLoader.skillsOverride.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { piLog } from "./logger.js";
// ── Settings Reading ─────────────────────────────────────────────────────────
/**
* Read a JSON object from a file path.
* Returns empty object if file doesn't exist or is invalid.
*/
function readJsonObject(path) {
if (!existsSync(path)) {
return {};
}
try {
const parsed = JSON.parse(readFileSync(path, "utf-8"));
return parsed && typeof parsed === "object" ? parsed : {};
}
catch {
return {};
}
}
/**
* Read project settings from .fusion/settings.json.
*/
function readProjectSettings(projectRootDir) {
const fusionSettings = join(projectRootDir, ".fusion", "settings.json");
if (existsSync(fusionSettings)) {
const parsed = readJsonObject(fusionSettings);
// Only return skill-relevant fields
return {
skills: Array.isArray(parsed.skills) ? parsed.skills : undefined,
packages: Array.isArray(parsed.packages) ? parsed.packages : undefined,
};
}
return {};
}
// ── Pattern Normalization ────────────────────────────────────────────────────
/**
* Normalize a skill pattern by removing the + prefix (enabled by default).
* Returns the path portion of the pattern.
*/
function normalizePattern(pattern) {
if (pattern.startsWith("+") || pattern.startsWith("-")) {
return pattern.slice(1);
}
return pattern;
}
/**
* Check if a pattern is an exclusion pattern (-prefixed).
*/
function isExclusionPattern(pattern) {
return pattern.startsWith("-");
}
// ── Main Resolution Logic ────────────────────────────────────────────────────
/**
* Compute deterministic skill selection from project settings and optional requested names.
*
* Resolution rules:
* 1. If NO skill patterns exist AND no requestedSkillNames → filterActive: false (all pass through)
* 2. If skill patterns exist:
* - + prefix or no prefix = add to allowed set
* - - prefix = exclude from allowed set
* - Last entry wins for duplicate paths
* 3. If requestedSkillNames provided:
* - Acts as additional intersection filter (skills must match name AND be in allowed set)
* - Case-insensitive matching against Skill.name
* 4. Diagnostics produced for:
* - Patterns that don't match discovered skills (warning)
* - Requested names not matching any discovered skill (warning)
*/
export function resolveSessionSkills(context) {
const { projectRootDir, requestedSkillNames } = context;
// Read project settings
const settings = readProjectSettings(projectRootDir);
// Collect all skill patterns from settings
const skillPatterns = [];
// Top-level skills patterns
if (settings.skills) {
for (const pattern of settings.skills) {
if (typeof pattern === "string") {
skillPatterns.push(pattern);
}
}
}
// Package-scoped skill patterns
if (settings.packages) {
for (const pkg of settings.packages) {
if (typeof pkg === "object" && pkg !== null && "skills" in pkg && Array.isArray(pkg.skills)) {
for (const pattern of pkg.skills) {
if (typeof pattern === "string") {
skillPatterns.push(pattern);
}
}
}
}
}
const hasPatterns = skillPatterns.length > 0;
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
// If no patterns and no requested names, no filtering needed
if (!hasPatterns && !hasRequestedNames) {
return {
allowedSkillPaths: new Set(),
excludedSkillPaths: new Set(),
diagnostics: [],
filterActive: false,
};
}
// Build allowed and excluded sets from patterns
// Last entry wins for duplicate paths: we track the "final decision" per path
const finalDecisions = new Map(); // true = allowed, false = excluded
for (const pattern of skillPatterns) {
const path = normalizePattern(pattern);
const isExclusion = isExclusionPattern(pattern);
finalDecisions.set(path, !isExclusion);
}
// Build allowed and excluded sets from final decisions
const allowedSet = new Set();
const excludedSet = new Set();
for (const [path, allowed] of finalDecisions) {
if (allowed) {
allowedSet.add(path);
}
else {
excludedSet.add(path);
}
}
// Determine if filtering is active
// filterActive is true when:
// - Patterns exist (some skills are explicitly configured)
// - OR only requested names are provided (filter to those names)
const filterActive = hasPatterns || hasRequestedNames;
// Produce diagnostics for patterns (we can't check against actual discovered skills here,
// so we note which patterns are configured)
const diagnostics = [];
if (hasPatterns) {
for (const pattern of skillPatterns) {
if (!isExclusionPattern(pattern)) {
// Note: We don't have access to discovered skills here to check if pattern matches
// The actual validation happens in createSkillsOverrideFromSelection when base.skills is available
const path = normalizePattern(pattern);
diagnostics.push({
type: "info",
message: `Configured skill pattern: ${pattern}`,
skillPath: path,
});
}
}
}
if (hasRequestedNames) {
for (const name of requestedSkillNames) {
diagnostics.push({
type: "info",
message: `Requested skill: ${name}`,
skillName: name,
});
}
}
return {
allowedSkillPaths: allowedSet,
excludedSkillPaths: excludedSet,
diagnostics,
filterActive,
};
}
/**
* Create a skillsOverride callback compatible with DefaultResourceLoaderOptions.skillsOverride.
*
* @param selection - The skill selection result from resolveSessionSkills
* @param options - Additional options for the override
* @returns A skillsOverride callback for DefaultResourceLoader
*/
export function createSkillsOverrideFromSelection(selection, options = {}) {
const { allowedSkillPaths, excludedSkillPaths, filterActive } = selection;
const { requestedSkillNames, sessionPurpose } = options;
return (base) => {
// If filtering is not active, return base unchanged
if (!filterActive) {
return base;
}
// Determine the effective filter criteria
// When requestedSkillNames is provided without patterns, filter by name
// When patterns are provided, filter by file path
const hasPatterns = allowedSkillPaths.size > 0;
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
// Filter skills
// Skills must match the inclusion criteria AND not be in the exclusion list
const hasExcluded = excludedSkillPaths.size > 0;
let filteredSkills;
if (hasRequestedNames) {
// Filter by requested names (case-insensitive match)
const requestedNamesLower = new Set(requestedSkillNames.map((n) => n.toLowerCase()));
filteredSkills = base.skills.filter((skill) => requestedNamesLower.has(skill.name.toLowerCase()) && !excludedSkillPaths.has(skill.filePath));
}
else if (hasPatterns) {
// Filter by file path (in allowed set AND not in excluded set)
filteredSkills = base.skills.filter((skill) => allowedSkillPaths.has(skill.filePath) && !excludedSkillPaths.has(skill.filePath));
}
else if (hasExcluded) {
// Only exclusions set - filter out excluded skills
filteredSkills = base.skills.filter((skill) => !excludedSkillPaths.has(skill.filePath));
}
else {
// No filter criteria - this shouldn't happen if filterActive is true
filteredSkills = base.skills;
}
// Build diagnostics for missing and disabled skills
const newDiagnostics = [];
// Check for excluded paths that DO match a discovered skill (disabled)
// These are skills that exist but were explicitly excluded by project patterns
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
const discoveredPaths = new Set(base.skills.map((s) => s.filePath));
for (const excludedPath of excludedSkillPaths) {
if (discoveredPaths.has(excludedPath)) {
// Skill exists but was disabled by project patterns
// Use "warning" type since ResourceDiagnostic only supports warning|error|collision
newDiagnostics.push({
type: "warning",
message: `Skill at '${excludedPath}' exists but is disabled by project execution settings${purpose}`,
path: excludedPath,
});
}
// If the path doesn't match any discovered skill, it's not a disabled skill - it's just not relevant
}
// Check for configured patterns (allowed paths) that don't match any discovered skill
// Note: At this point, we have access to base.skills for validation
for (const allowedPath of allowedSkillPaths) {
if (!discoveredPaths.has(allowedPath)) {
// Allowed path doesn't match any discovered skill - this is a missing/invalid pattern
newDiagnostics.push({
type: "warning",
message: `Configured skill pattern '${allowedPath}' not found in discovered skills${purpose}`,
path: allowedPath,
});
}
}
// Check for requested names that don't match any discovered skill
if (requestedSkillNames) {
const discoveredNamesLower = new Set(base.skills.map((s) => s.name.toLowerCase()));
for (const requestedName of requestedSkillNames) {
if (!discoveredNamesLower.has(requestedName.toLowerCase())) {
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
newDiagnostics.push({
type: "warning",
message: `Requested skill '${requestedName}' not found in discovered skills${purpose}`,
});
}
}
}
// Log diagnostics if any
if (newDiagnostics.length > 0) {
const _purpose = sessionPurpose ? `[${sessionPurpose}]` : "skills";
for (const diag of newDiagnostics) {
piLog.warn(`[skills] ${diag.type}: ${diag.message}`);
}
}
return {
skills: filteredSkills,
diagnostics: [...base.diagnostics, ...newDiagnostics],
};
};
}
//# sourceMappingURL=skill-resolver.js.map

File diff suppressed because one or more lines are too long

View File

@@ -52,13 +52,9 @@ describe("paperclip-runtime plugin", () => {
describe("runtime factory invocation", () => { describe("runtime factory invocation", () => {
beforeEach(() => { beforeEach(() => {
// Mock @fusion/engine for createFnAgent vi.mock("../../../../packages/engine/src/pi.js", () => ({
vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn().mockResolvedValue({ session: {} }), createFnAgent: vi.fn().mockResolvedValue({ session: {} }),
promptWithFallback: vi.fn(), promptWithFallback: vi.fn(),
}));
// Mock describeModel
vi.mock("../../engine/src/pi.js", () => ({
describeModel: vi.fn().mockReturnValue("mock/model"), describeModel: vi.fn().mockReturnValue("mock/model"),
})); }));
}); });
@@ -73,7 +69,7 @@ describe("paperclip-runtime plugin", () => {
}); });
it("should return an adapter with correct id and name", async () => { it("should return an adapter with correct id and name", async () => {
const runtime = await plugin.runtime!.factory({} as any); const runtime = (await plugin.runtime!.factory({} as any)) as PaperclipRuntimeAdapter;
expect(runtime.id).toBe("paperclip"); expect(runtime.id).toBe("paperclip");
expect(runtime.name).toBe("Paperclip Runtime"); expect(runtime.name).toBe("Paperclip Runtime");
}); });

View File

@@ -9,18 +9,12 @@ import { PaperclipRuntimeAdapter } from "../runtime-adapter.js";
// ── Mock Modules ──────────────────────────────────────────────────────────────── // ── Mock Modules ────────────────────────────────────────────────────────────────
// Mock @fusion/engine for createFnAgent and promptWithFallback
const mockCreateFnAgent = vi.fn(); const mockCreateFnAgent = vi.fn();
const mockPromptWithFallback = vi.fn(); const mockPromptWithFallback = vi.fn();
vi.mock("@fusion/engine", () => ({ vi.mock("../../../../packages/engine/src/pi.js", () => ({
createFnAgent: mockCreateFnAgent, createFnAgent: mockCreateFnAgent,
promptWithFallback: mockPromptWithFallback, promptWithFallback: mockPromptWithFallback,
}));
// Mock the relative import of describeModel from pi.ts
// This uses require() in the adapter, so we mock the entire module
vi.mock("../../engine/src/pi.js", () => ({
describeModel: vi.fn().mockReturnValue("mock/anthropic-claude"), describeModel: vi.fn().mockReturnValue("mock/anthropic-claude"),
})); }));
@@ -169,7 +163,7 @@ describe("PaperclipRuntimeAdapter", () => {
describe("describeModel", () => { describe("describeModel", () => {
it("should return model description from pi describeModel", () => { it("should return model description from pi describeModel", () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const { describeModel } = require("../../engine/src/pi.js"); const { describeModel } = require("../../../../packages/engine/src/pi.js");
const mockSession = { model: { provider: "anthropic", id: "claude-sonnet-4-5" } }; const mockSession = { model: { provider: "anthropic", id: "claude-sonnet-4-5" } };
const result = adapter.describeModel(mockSession as any); const result = adapter.describeModel(mockSession as any);

View File

@@ -30,8 +30,12 @@
* ``` * ```
*/ */
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./types.js"; import type {
import type { AgentSession } from "@mariozechner/pi-coding-agent"; AgentRuntime,
AgentRuntimeOptions,
AgentSession,
AgentSessionResult,
} from "./types.js";
// ── describeModel (from pi.ts, not re-exported from @fusion/engine) ───────────── // ── describeModel (from pi.ts, not re-exported from @fusion/engine) ─────────────
// //
@@ -40,8 +44,16 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
// This is acceptable within the monorepo workspace. External plugins would need a // This is acceptable within the monorepo workspace. External plugins would need a
// different approach (e.g., the engine could export it publicly in the future). // different approach (e.g., the engine could export it publicly in the future).
// //
type PiModule = {
createFnAgent: (options: unknown) => Promise<AgentSessionResult>;
promptWithFallback: (session: unknown, prompt: string, options?: unknown) => Promise<void>;
describeModel: (session: unknown) => string;
};
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const { describeModel: getModelDescription } = require("../../engine/src/pi.js"); const loadPiModule = (): PiModule => require("../../../packages/engine/src/pi.js") as PiModule;
const { describeModel: getModelDescription } = loadPiModule();
/** /**
* Paperclip runtime adapter implementing the Fusion AgentRuntime interface. * Paperclip runtime adapter implementing the Fusion AgentRuntime interface.
@@ -69,7 +81,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
* @returns Promise resolving to the session result with session and optional sessionFile * @returns Promise resolving to the session result with session and optional sessionFile
*/ */
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> { async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
const { createFnAgent } = await import("@fusion/engine"); const { createFnAgent } = loadPiModule();
return createFnAgent({ return createFnAgent({
cwd: options.cwd, cwd: options.cwd,
systemPrompt: options.systemPrompt, systemPrompt: options.systemPrompt,
@@ -103,7 +115,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
* @param options - Optional prompt options (e.g., images for vision) * @param options - Optional prompt options (e.g., images for vision)
*/ */
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> { async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
const { promptWithFallback: pwf } = await import("@fusion/engine"); const { promptWithFallback: pwf } = loadPiModule();
return pwf(session, prompt, options); return pwf(session, prompt, options);
} }

View File

@@ -1,74 +1,58 @@
/** /**
* Paperclip Runtime Plugin - Type Definitions * Paperclip Runtime Plugin - Type Definitions
* *
* Re-exports runtime contract types from @fusion/engine and plugin types from @fusion/plugin-sdk. * The plugin runtime contract is defined locally in this example plugin to avoid
* These types define the interface that the Paperclip runtime adapter must implement. * a hard compile-time dependency on internal engine package exports.
*
* ## Type Sources
*
* - `AgentRuntime`, `AgentRuntimeOptions`, `AgentSessionResult`: from @fusion/engine (FN-2256 contract)
* - `PluginRuntimeRegistration`, `PluginRuntimeManifestMetadata`, `FusionPlugin`: from @fusion/plugin-sdk
*
* ## Internal Types
*
* `AgentSession` and `ToolDefinition` are used internally in the adapter implementation
* but are NOT re-exported here since they come from @mariozechner/pi-coding-agent,
* which is not a direct dependency of this plugin. They are accessible via
* `AgentSessionResult.session` and `AgentRuntimeOptions.customTools` respectively.
*/ */
// ── Agent Runtime Contract (from @fusion/engine) ────────────────────────────── // ── Local Agent Runtime Contract ──────────────────────────────────────────────
/** Minimal session shape used by the runtime adapter. */
export interface AgentSession {
dispose?: () => Promise<void> | void;
}
/** Options for creating an agent session. Mirrors createFnAgent inputs used by the adapter. */
export interface AgentRuntimeOptions {
cwd: string;
systemPrompt: string;
tools?: unknown;
customTools?: unknown;
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, result?: unknown) => void;
defaultProvider?: string;
defaultModelId?: string;
fallbackProvider?: string;
fallbackModelId?: string;
defaultThinkingLevel?: string;
sessionManager?: unknown;
skillSelection?: unknown;
skills?: string[];
}
/** Result of creating a session. */
export interface AgentSessionResult {
session: AgentSession;
sessionFile?: string;
}
/** Agent runtime adapter interface. */
export interface AgentRuntime {
id: string;
name: string;
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
describeModel(session: AgentSession): string;
dispose?(session: AgentSession): Promise<void>;
}
// ── Plugin Registration Types (from @fusion/plugin-sdk) ─────────────────────
export type { export type {
/**
* Agent runtime adapter interface.
*
* All session runtimes (default pi runtime, plugin-provided runtimes) must
* implement this interface to ensure consistent behavior across engine subsystems.
*/
AgentRuntime,
/**
* Options for creating an agent session.
* Mirrors the options accepted by createFnAgent.
*/
AgentRuntimeOptions,
/**
* Result of creating an agent session.
*/
AgentSessionResult,
} from "@fusion/engine";
// ── Plugin Registration Types (from @fusion/plugin-sdk) ───────────────────────
export type {
/**
* Plugin runtime registration metadata.
* Contains identity and versioning information for a runtime.
*/
PluginRuntimeManifestMetadata, PluginRuntimeManifestMetadata,
/**
* Plugin runtime factory function.
* Creates a runtime instance when the plugin is loaded.
*/
PluginRuntimeFactory, PluginRuntimeFactory,
/**
* Plugin runtime registration with metadata and factory.
* The primary registration format used by Fusion's plugin system.
*/
PluginRuntimeRegistration, PluginRuntimeRegistration,
/**
* Fusion plugin definition.
* The main export type for all Fusion plugins.
*/
FusionPlugin, FusionPlugin,
} from "@fusion/plugin-sdk"; } from "@fusion/plugin-sdk";
// ── Note on describeModel ──────────────────────────────────────────────────────
//
// describeModel is NOT exported from @fusion/engine's public API.
// It is defined in packages/engine/src/pi.ts but only used internally.
// Plugin adapters should import describeModel directly from the relative path:
// import { describeModel } from "../../engine/src/pi.js";
//
// This relative import is only valid within the monorepo workspace.
// External plugins would need a different approach.

2
pnpm-lock.yaml generated
View File

@@ -471,7 +471,7 @@ importers:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3) version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
plugins/fusion-plugin-hermes-runtime: plugins/fusion-plugin-paperclip-runtime:
dependencies: dependencies:
'@fusion/plugin-sdk': '@fusion/plugin-sdk':
specifier: workspace:* specifier: workspace:*

View File

@@ -1,4 +1,4 @@
packages: packages:
- "packages/*" - "packages/*"
- "plugins/examples/*" - "plugins/examples/*"
- "plugins/fusion-plugin-hermes-runtime" - "plugins/fusion-plugin-paperclip-runtime"