/** * Extension loader - loads TypeScript extension modules using jiti. * */ import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core"; import type { Provider } from "@earendil-works/pi-ai"; import * as _bundledPiAiCompat from "@earendil-works/pi-ai/compat"; import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth"; import * as _bundledPiAiProviders from "@earendil-works/pi-ai/providers/all"; import type { KeyId } from "@earendil-works/pi-tui"; import * as _bundledPiTui from "@earendil-works/pi-tui"; import { createJiti } from "jiti/static"; // Static imports of packages that extensions may use. // These MUST be static so Bun bundles them into the compiled binary. // The virtualModules option then makes them available to extensions. import * as _bundledTypebox from "typebox"; import * as _bundledTypeboxCompile from "typebox/compile"; import * as _bundledTypeboxValue from "typebox/value"; import { CONFIG_DIR_NAME, getAgentDir, isBunBinary, isBundledNode } from "../../config.ts"; // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, // avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent. import * as _bundledPiCodingAgent from "../../index.ts"; import { resolvePath } from "../../utils/paths.ts"; import { createEventBus, type EventBus } from "../event-bus.ts"; import type { ExecOptions } from "../exec.ts"; import { execCommand } from "../exec.ts"; import { readPiManifest } from "../pi-manifest.ts"; import { createSyntheticSourceInfo } from "../source-info.ts"; import { time } from "../timings.ts"; import type { EntryRenderer, Extension, ExtensionAPI, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult, MarkdownTransformer, MessageRenderer, ProviderConfig, RegisteredCommand, ToolDefinition, } from "./types.ts"; /** Modules available to extensions via virtualModules (for compiled binaries) */ const VIRTUAL_MODULES: Record = { typebox: _bundledTypebox, "typebox/compile": _bundledTypeboxCompile, "typebox/value": _bundledTypeboxValue, "@sinclair/typebox": _bundledTypebox, "@sinclair/typebox/compile": _bundledTypeboxCompile, "@sinclair/typebox/value": _bundledTypeboxValue, "@earendil-works/pi-agent-core": _bundledPiAgentCore, "@earendil-works/pi-tui": _bundledPiTui, // Extensions resolve the pi-ai root to the compat entrypoint (a strict // superset of the core entrypoint): existing extensions using the old // global API keep working at runtime until compat is removed. "@earendil-works/pi-ai": _bundledPiAiCompat, "@earendil-works/pi-ai/compat": _bundledPiAiCompat, "@earendil-works/pi-ai/oauth": _bundledPiAiOauth, "@earendil-works/pi-ai/providers/all": _bundledPiAiProviders, "@earendil-works/pi-coding-agent": _bundledPiCodingAgent, "@mariozechner/pi-agent-core": _bundledPiAgentCore, "@mariozechner/pi-tui": _bundledPiTui, "@mariozechner/pi-ai": _bundledPiAiCompat, "@mariozechner/pi-ai/compat": _bundledPiAiCompat, "@mariozechner/pi-ai/oauth": _bundledPiAiOauth, "@mariozechner/pi-ai/providers/all": _bundledPiAiProviders, "@mariozechner/pi-coding-agent": _bundledPiCodingAgent, }; const require = createRequire(import.meta.url); const isNodeSeaBinary = ("sea" in process.features && process.features.sea === true) || process.getBuiltinModule("node:sea")?.isSea() === true; const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts"; /** * Get aliases for jiti (used in built Node.js mode). * In compiled binary mode, virtualModules is used instead. */ let _aliases: Record | null = null; function getAliases(): Record { if (_aliases) return _aliases; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const packageIndex = path.resolve(__dirname, "../..", "index.js"); const typeboxEntry = require.resolve("typebox"); const typeboxCompileEntry = require.resolve("typebox/compile"); const typeboxValueEntry = require.resolve("typebox/value"); const packagesRoot = path.resolve(__dirname, "../../../../"); const resolveWorkspaceOrImport = (workspaceRelativePath: string, specifier: string): string => { const workspacePath = path.join(packagesRoot, workspaceRelativePath); if (fs.existsSync(workspacePath)) { return workspacePath; } return fileURLToPath(import.meta.resolve(specifier)); }; const piCodingAgentEntry = packageIndex; const piAgentCoreEntry = resolveWorkspaceOrImport("agent/dist/index.js", "@earendil-works/pi-agent-core"); const piTuiEntry = resolveWorkspaceOrImport("tui/dist/index.js", "@earendil-works/pi-tui"); // Extensions resolve the pi-ai root to the compat entrypoint (a strict // superset of the core entrypoint): existing extensions using the old // global API keep working at runtime until compat is removed. const piAiCompatEntry = resolveWorkspaceOrImport("ai/dist/compat.js", "@earendil-works/pi-ai/compat"); const piAiOauthEntry = resolveWorkspaceOrImport("ai/dist/oauth.js", "@earendil-works/pi-ai/oauth"); const piAiProvidersEntry = resolveWorkspaceOrImport( "ai/dist/providers/all.js", "@earendil-works/pi-ai/providers/all", ); _aliases = { "@earendil-works/pi-coding-agent": piCodingAgentEntry, "@earendil-works/pi-agent-core": piAgentCoreEntry, "@earendil-works/pi-tui": piTuiEntry, "@earendil-works/pi-ai/providers/all": piAiProvidersEntry, "@earendil-works/pi-ai/compat": piAiCompatEntry, "@earendil-works/pi-ai/oauth": piAiOauthEntry, "@earendil-works/pi-ai": piAiCompatEntry, "@mariozechner/pi-coding-agent": piCodingAgentEntry, "@mariozechner/pi-agent-core": piAgentCoreEntry, "@mariozechner/pi-tui": piTuiEntry, "@mariozechner/pi-ai/providers/all": piAiProvidersEntry, "@mariozechner/pi-ai/compat": piAiCompatEntry, "@mariozechner/pi-ai/oauth": piAiOauthEntry, "@mariozechner/pi-ai": piAiCompatEntry, typebox: typeboxEntry, "typebox/compile": typeboxCompileEntry, "typebox/value": typeboxValueEntry, "@sinclair/typebox": typeboxEntry, "@sinclair/typebox/compile": typeboxCompileEntry, "@sinclair/typebox/value": typeboxValueEntry, }; return _aliases; } type HandlerFn = (...args: unknown[]) => Promise; let extensionCacheCwd: string | undefined; let extensionCacheGeneration = 0; const extensionCache = new Map(); interface ExtensionCacheToken { cwd: string; generation: number; } export function clearExtensionCache(): void { extensionCache.clear(); extensionCacheCwd = undefined; extensionCacheGeneration++; } function useExtensionCacheCwd(cwd: string): ExtensionCacheToken { const resolvedCwd = resolvePath(cwd); if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) { clearExtensionCache(); } extensionCacheCwd = resolvedCwd; return { cwd: resolvedCwd, generation: extensionCacheGeneration }; } /** * Create a runtime with throwing stubs for action methods. * Runner.bindCore() replaces these with real implementations. */ export function createExtensionRuntime(): ExtensionRuntime { const notInitialized = () => { throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading."); }; const state: { staleMessage?: string } = {}; const eventBusUnsubscribers = new Set<() => void>(); const assertActive = () => { if (state.staleMessage) { throw new Error(state.staleMessage); } }; const runtime: ExtensionRuntime = { sendMessage: notInitialized, sendUserMessage: notInitialized, appendEntry: notInitialized, setSessionName: notInitialized, getSessionName: notInitialized, setLabel: notInitialized, getActiveTools: notInitialized, getAllTools: notInitialized, setActiveTools: notInitialized, // registerTool() is valid during extension load; refresh is only needed post-bind. refreshTools: () => {}, getCommands: notInitialized, setModel: () => Promise.reject(new Error("Extension runtime not initialized")), getThinkingLevel: notInitialized, setThinkingLevel: notInitialized, flagValues: new Map(), pendingProviderRegistrations: [], pendingNativeProviderRegistrations: [], assertActive, invalidate: (message) => { if (state.staleMessage) return; state.staleMessage = message ?? "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload()."; for (const unsubscribe of eventBusUnsubscribers) unsubscribe(); eventBusUnsubscribers.clear(); }, trackEventBusSubscription: (unsubscribe) => { let active = true; const trackedUnsubscribe = () => { if (!active) return; active = false; eventBusUnsubscribers.delete(trackedUnsubscribe); unsubscribe(); }; eventBusUnsubscribers.add(trackedUnsubscribe); return trackedUnsubscribe; }, // Pre-bind: queue registrations so bindCore() can flush them once the // model registry is available. bindCore() replaces both with direct calls. registerProvider: (name, config, extensionPath = "") => { runtime.pendingProviderRegistrations.push({ name, config, extensionPath }); }, registerNativeProvider: (provider, extensionPath = "") => { runtime.pendingNativeProviderRegistrations.push({ provider, extensionPath }); }, unregisterProvider: (name) => { runtime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name); runtime.pendingNativeProviderRegistrations = runtime.pendingNativeProviderRegistrations.filter( (r) => r.provider.id !== name, ); }, }; return runtime; } /** * Create the ExtensionAPI for an extension. * Registration methods write to the extension object. * Action methods delegate to the shared runtime. */ function createExtensionAPI( extension: Extension, runtime: ExtensionRuntime, cwd: string, eventBus: EventBus, ): { api: ExtensionAPI; commit: () => void; discard: () => void } { const pendingFlagValues = new Map(); const pendingRuntimeChanges: Array<() => void> = []; const loadingUnsubscribers: Array<() => void> = []; let state: "loading" | "active" | "failed" = "loading"; const assertActive = () => { if (state === "failed") { throw new Error(`Extension "${extension.path}" failed to load and its API is no longer active.`); } runtime.assertActive(); }; const applyRuntimeChange = (change: () => void) => { if (state === "loading") pendingRuntimeChanges.push(change); else change(); }; const clearPending = () => { pendingFlagValues.clear(); pendingRuntimeChanges.length = 0; loadingUnsubscribers.length = 0; }; const api = { // Registration methods - write to extension on(event: string, handler: HandlerFn): void { assertActive(); const list = extension.handlers.get(event) ?? []; list.push(handler); extension.handlers.set(event, list); }, registerTool(tool: ToolDefinition): void { assertActive(); if (typeof tool.parameters !== "object" || tool.parameters === null || Array.isArray(tool.parameters)) { throw new Error( `Tool "${tool.name}" registered by extension "${extension.path}" must define an object parameter schema.`, ); } extension.tools.set(tool.name, { definition: tool, sourceInfo: extension.sourceInfo, }); runtime.refreshTools(); }, registerCommand(name: string, options: Omit): void { assertActive(); extension.commands.set(name, { name, sourceInfo: extension.sourceInfo, ...options, }); }, registerShortcut( shortcut: KeyId, options: { description?: string; handler: (ctx: import("./types.ts").ExtensionContext) => Promise | void; }, ): void { assertActive(); extension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options }); }, registerFlag( name: string, options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, ): void { assertActive(); if (options.default !== undefined && typeof options.default !== options.type) { throw new Error( `Invalid default for flag "${name}": expected ${options.type}, got ${typeof options.default}`, ); } extension.flags.set(name, { name, extensionPath: extension.path, ...options }); if (options.default !== undefined && !runtime.flagValues.has(name)) { if (state === "loading") { if (!pendingFlagValues.has(name)) pendingFlagValues.set(name, options.default); } else { runtime.flagValues.set(name, options.default); } } }, registerMessageRenderer(customType: string, renderer: MessageRenderer): void { assertActive(); extension.messageRenderers.set(customType, renderer as MessageRenderer); }, registerMarkdownTransformer(transformer: MarkdownTransformer): void { assertActive(); extension.markdownTransformer = transformer; }, registerEntryRenderer(customType: string, renderer: EntryRenderer): void { assertActive(); extension.entryRenderers ??= new Map(); extension.entryRenderers.set(customType, renderer as EntryRenderer); }, // Flag access - checks extension registered it, reads from runtime getFlag(name: string): boolean | string | undefined { assertActive(); if (!extension.flags.has(name)) return undefined; return runtime.flagValues.has(name) ? runtime.flagValues.get(name) : pendingFlagValues.get(name); }, // Action methods - delegate to shared runtime sendMessage(message, options): void { assertActive(); runtime.sendMessage(message, options); }, sendUserMessage(content, options): void { assertActive(); runtime.sendUserMessage(content, options); }, appendEntry(customType: string, data?: unknown): void { assertActive(); runtime.appendEntry(customType, data); }, setSessionName(name: string): void { assertActive(); runtime.setSessionName(name); }, getSessionName(): string | undefined { assertActive(); return runtime.getSessionName(); }, setLabel(entryId: string, label: string | undefined): void { assertActive(); runtime.setLabel(entryId, label); }, exec(command: string, args: string[], options?: ExecOptions) { assertActive(); return execCommand(command, args, options?.cwd ?? cwd, options); }, getActiveTools(): string[] { assertActive(); return runtime.getActiveTools(); }, getAllTools() { assertActive(); return runtime.getAllTools(); }, setActiveTools(toolNames: string[]): void { assertActive(); runtime.setActiveTools(toolNames); }, getCommands() { assertActive(); return runtime.getCommands(); }, setModel(model) { assertActive(); return runtime.setModel(model); }, getThinkingLevel() { assertActive(); return runtime.getThinkingLevel(); }, setThinkingLevel(level) { assertActive(); runtime.setThinkingLevel(level); }, registerProvider(providerOrName: Provider | string, config?: ProviderConfig) { assertActive(); if (typeof providerOrName === "string") { if (!config) throw new Error("Provider config is required when registering by name"); applyRuntimeChange(() => runtime.registerProvider(providerOrName, config, extension.path)); return; } applyRuntimeChange(() => runtime.registerNativeProvider(providerOrName, extension.path)); }, unregisterProvider(name: string) { assertActive(); applyRuntimeChange(() => runtime.unregisterProvider(name, extension.path)); }, events: { emit(channel, data) { assertActive(); eventBus.emit(channel, data); }, on(channel, handler) { assertActive(); const unsubscribe = runtime.trackEventBusSubscription(eventBus.on(channel, handler)); if (state === "loading") loadingUnsubscribers.push(unsubscribe); return unsubscribe; }, }, } as ExtensionAPI; return { api, commit: () => { if (state !== "loading") return; runtime.assertActive(); for (const [name, value] of pendingFlagValues) { if (!runtime.flagValues.has(name)) runtime.flagValues.set(name, value); } for (const apply of pendingRuntimeChanges) apply(); state = "active"; clearPending(); }, discard: () => { if (state !== "loading") return; state = "failed"; for (const unsubscribe of loadingUnsubscribers) unsubscribe(); clearPending(); }, }; } function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken { return ( cacheToken !== undefined && extensionCacheCwd === cacheToken.cwd && extensionCacheGeneration === cacheToken.generation ); } async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) { if (isCurrentCacheToken(cacheToken)) { const cachedFactory = extensionCache.get(extensionPath); if (cachedFactory) { return cachedFactory; } } const jiti = createJiti(import.meta.url, { moduleCache: false, // Compiled binaries and the bundled Node distribution use embedded modules. // Source TypeScript reuses host modules and root tsconfig paths. Unbundled // Node builds use dist aliases. ...(isBunBinary || isNodeSeaBinary || isBundledNode ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true } : { alias: getAliases() }), }); const module = await jiti.import(extensionPath, { default: true }); const factory = module as ExtensionFactory; if (typeof factory !== "function") { return undefined; } if (isCurrentCacheToken(cacheToken)) { extensionCache.set(extensionPath, factory); } return factory; } /** * Create an Extension object with empty collections. */ function createExtension(extensionPath: string, resolvedPath: string): Extension { const source = extensionPath.startsWith("<") && extensionPath.endsWith(">") ? extensionPath.slice(1, -1).split(":")[0] || "temporary" : "local"; const baseDir = extensionPath.startsWith("<") ? undefined : path.dirname(resolvedPath); return { path: extensionPath, resolvedPath, sourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }), handlers: new Map(), tools: new Map(), messageRenderers: new Map(), entryRenderers: new Map(), commands: new Map(), flags: new Map(), shortcuts: new Map(), }; } async function initializeExtension( factory: ExtensionFactory, extensionPath: string, resolvedPath: string, cwd: string, eventBus: EventBus, runtime: ExtensionRuntime, ): Promise { const extension = createExtension(extensionPath, resolvedPath); const load = createExtensionAPI(extension, runtime, cwd, eventBus); try { await factory(load.api); load.commit(); } catch (error) { load.discard(); throw error; } time(`${extensionPath} factory`, "extensions"); return extension; } async function loadExtension( extensionPath: string, cwd: string, eventBus: EventBus, runtime: ExtensionRuntime, cacheToken?: ExtensionCacheToken, ): Promise<{ extension: Extension | null; error: string | null }> { const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true }); try { const factory = await loadExtensionModule(resolvedPath, cacheToken); time(`${extensionPath} module import`, "extensions"); if (!factory) { return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; } const extension = await initializeExtension(factory, extensionPath, resolvedPath, cwd, eventBus, runtime); return { extension, error: null }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { extension: null, error: `Failed to load extension: ${message}` }; } } /** * Create an Extension from an inline factory function. */ export async function loadExtensionFromFactory( factory: ExtensionFactory, cwd: string, eventBus: EventBus, runtime: ExtensionRuntime, extensionPath = "", ): Promise { const resolvedCwd = resolvePath(cwd); return initializeExtension(factory, extensionPath, extensionPath, resolvedCwd, eventBus, runtime); } /** * Load extensions from paths. */ async function loadExtensionsInternal( paths: string[], cwd: string, eventBus?: EventBus, runtime?: ExtensionRuntime, useCache = false, ): Promise { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; const cacheToken = useCache ? useExtensionCacheCwd(cwd) : undefined; const resolvedCwd = cacheToken?.cwd ?? resolvePath(cwd); const resolvedEventBus = eventBus ?? createEventBus(); const resolvedRuntime = runtime ?? createExtensionRuntime(); for (const extPath of paths) { const { extension, error } = await loadExtension( extPath, resolvedCwd, resolvedEventBus, resolvedRuntime, cacheToken, ); if (error) { errors.push({ path: extPath, error }); continue; } if (extension) { extensions.push(extension); } } return { extensions, errors, runtime: resolvedRuntime, }; } export async function loadExtensions( paths: string[], cwd: string, eventBus?: EventBus, runtime?: ExtensionRuntime, ): Promise { return loadExtensionsInternal(paths, cwd, eventBus, runtime); } export async function loadExtensionsCached( paths: string[], cwd: string, eventBus?: EventBus, runtime?: ExtensionRuntime, ): Promise { return loadExtensionsInternal(paths, cwd, eventBus, runtime, true); } function isExtensionFile(name: string): boolean { return name.endsWith(".ts") || name.endsWith(".js"); } /** * Resolve extension entry points from a directory. * * Checks for: * 1. package.json with "pi.extensions" field -> returns declared paths * 2. index.ts or index.js -> returns the index file * * Returns resolved paths or null if no entry points found. */ function resolveExtensionEntries(dir: string): string[] | null { // Check for package.json with "pi" field first const packageJsonPath = path.join(dir, "package.json"); if (fs.existsSync(packageJsonPath)) { const manifest = readPiManifest(packageJsonPath); if (manifest?.extensions?.length) { const entries: string[] = []; for (const extPath of manifest.extensions) { const resolvedExtPath = path.resolve(dir, extPath); if (fs.existsSync(resolvedExtPath)) { entries.push(resolvedExtPath); } } if (entries.length > 0) { return entries; } } } // Check for index.ts or index.js const indexTs = path.join(dir, "index.ts"); const indexJs = path.join(dir, "index.js"); if (fs.existsSync(indexTs)) { return [indexTs]; } if (fs.existsSync(indexJs)) { return [indexJs]; } return null; } /** * Discover extensions in a directory. * * Discovery rules: * 1. Direct files: `extensions/*.ts` or `*.js` → load * 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load * 3. Subdirectory with package.json: `extensions/* /package.json` with "pi" field → load what it declares * * No recursion beyond one level. Complex packages must use package.json manifest. */ function discoverExtensionsInDir(dir: string): string[] { if (!fs.existsSync(dir)) { return []; } const discovered: string[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); // 1. Direct files: *.ts or *.js if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) { discovered.push(entryPath); continue; } // 2 & 3. Subdirectories if (entry.isDirectory() || entry.isSymbolicLink()) { const entries = resolveExtensionEntries(entryPath); if (entries) { discovered.push(...entries); } } } } catch { return []; } return discovered; } /** * Discover and load extensions from standard locations. */ export async function discoverAndLoadExtensions( configuredPaths: string[], cwd: string, agentDir: string = getAgentDir(), eventBus?: EventBus, ): Promise { const resolvedCwd = resolvePath(cwd); const resolvedAgentDir = resolvePath(agentDir); const allPaths: string[] = []; const seen = new Set(); const addPaths = (paths: string[]) => { for (const p of paths) { const resolved = path.resolve(p); if (!seen.has(resolved)) { seen.add(resolved); allPaths.push(p); } } }; // 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/ const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions"); addPaths(discoverExtensionsInDir(localExtDir)); // 2. Global extensions: agentDir/extensions/ const globalExtDir = path.join(resolvedAgentDir, "extensions"); addPaths(discoverExtensionsInDir(globalExtDir)); // 3. Explicitly configured paths for (const p of configuredPaths) { const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true }); if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { // Check for package.json with pi manifest or index.ts const entries = resolveExtensionEntries(resolved); if (entries) { addPaths(entries); continue; } // No explicit entries - discover individual files in directory addPaths(discoverExtensionsInDir(resolved)); continue; } addPaths([resolved]); } return loadExtensions(allPaths, resolvedCwd, eventBus); }