/** * Lazy Extension Loader v2.3 * * Defer loading of non-critical pi extensions until after the interactive * session is ready, so startup latency stays low. Extensions placed in the * agent's `extensions/lazy/` directory are discovered and loaded in the * background on `session_start`, with optional whitelist/blacklist filtering * and hot reload. * * Configuration is read from the agent `settings.json` under the `lazyLoader` * key (see README). All paths resolve against the agent config directory * (`~/.pi/agent` by default, overridable via `PI_CODING_AGENT_DIR`), so this * package works identically whether installed globally or per-project. * * ⚠️ Timing caveat: lazy extensions are loaded *after* `session_start`, so any * tool / command / prompt-snippet they register is NOT guaranteed to be in the * first turn's system prompt if the user sends a message before the background * load completes. Only defer extensions whose absence on the first turn is * acceptable (passive listeners, trackers, notifiers). Extensions that must be * visible to the LLM from the very first message belong in the synchronous * `extensions/` directory, not `extensions/lazy/`. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { existsSync, readFileSync, watch } from "node:fs"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; import { isExtensionFile, mergeConfig, type ExtensionMeta, type LazyLoaderConfig, } from "../src/shared.ts"; interface LoadResult { name: string; path: string; success: boolean; duration: number; timedOut?: boolean; /** Set when the load eventually completed after the timeout fired. */ completedLate?: boolean; error?: string; stack?: string; } // 加载状态清理定时器:状态栏 2s 后自动清除。 const STATUS_CLEAR_DELAY_MS = 2000; export default function (pi: ExtensionAPI) { let loaded = false; const loadedExtensions = new Set(); let watcher: ReturnType | null = null; /** Set to true on session_shutdown; gates any deferred callbacks (e.g. hot reload). */ let shuttingDown = false; /** Pending timers cleared on shutdown to avoid post-shutdown fires. */ const pendingTimers = new Set>(); function armTimer(fn: () => void, ms: number): ReturnType { const handle = setTimeout(() => { pendingTimers.delete(handle); if (!shuttingDown) fn(); }, ms); pendingTimers.add(handle); return handle; } function clearAllTimers(): void { for (const handle of pendingTimers) clearTimeout(handle); pendingTimers.clear(); } // 读取配置 // // ExtensionAPI 不暴露 settings 访问器(pi.settings 不存在),因此直接 // 读取 agent 配置目录下的 settings.json 并提取 `lazyLoader` 键。 // getAgentDir() 解析 `~/.pi/agent`(可被 PI_CODING_AGENT_DIR 覆盖), // 故本包无论全局还是项目级安装,配置路径都一致。 // 输入加固(非布尔/非数字/非数组回退默认)集中在 shared.mergeConfig。 function getConfig(): LazyLoaderConfig & Required> { let userConfig: Partial | undefined; try { const settingsPath = join(getAgentDir(), "settings.json"); if (existsSync(settingsPath)) { const parsed = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record; const key = parsed?.lazyLoader; if (key && typeof key === "object") { userConfig = key as Partial; } } } catch (err) { // 配置读取失败不应阻断加载;回退默认值即可,避免污染状态栏。 const msg = err instanceof Error ? err.message : String(err); console.error(`[lazy-loader] settings.json read failed, using defaults: ${msg}`); } return mergeConfig(userConfig); } // 解析 lazy 扩展目录:优先用配置覆盖,否则回退到 /extensions/lazy。 function resolveLazyDir(config: { lazyDir?: string }): string { if (config.lazyDir) return config.lazyDir; return join(getAgentDir(), "extensions", "lazy"); } pi.on("session_start", async (_event, ctx) => { if (loaded) return; loaded = true; const config = getConfig(); if (!config.enabled) { console.log("[lazy-loader] Disabled by config"); return; } armTimer(async () => { // 守卫已在 armTimer 内部检查 shuttingDown;保留双重保险。 if (shuttingDown) return; const lazyDir = resolveLazyDir(config); if (!existsSync(lazyDir)) { return; } try { await loadAllExtensions(pi, ctx, lazyDir, config); // 启动热重载监听 if (config.hotReload) { setupHotReload(pi, ctx, lazyDir, config); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[lazy-loader] Fatal error: ${msg}`); if (!shuttingDown) ctx.ui.notify(`Lazy loader error: ${msg}`, "error"); } }, config.startDelay); }); async function loadAllExtensions( pi: ExtensionAPI, ctx: ExtensionContext, lazyDir: string, config: LazyLoaderConfig & Required> ) { // 1. 发现所有扩展 // // 规则(与 pi 自身的自动发现对齐,额外排除类型声明文件): // - 子目录 + 其内 index.ts/index.js → 以子目录名为扩展名 // - 顶层 .ts/.js 文件(非 index、非 .d.ts/.d.js)→ 以文件名为扩展名 // - 名为 index.ts/index.js 的顶层文件 → 故意跳过(lazy 目录自带的 // 占位文件,仅供 health/gate 检查存在,本身不是扩展;避免误加载)。 // - .d.ts / .d.js 类型声明文件 → 跳过(否则会被 .endsWith(".ts") 误判)。 const entries = await readdir(lazyDir, { withFileTypes: true }); const extensions: ExtensionMeta[] = []; for (const entry of entries) { let extPath: string | undefined; let extName: string | undefined; if (entry.isDirectory()) { // 优先 index.ts,回退 index.js(与 pi 行为一致)。 for (const idx of ["index.ts", "index.js"]) { const indexPath = join(lazyDir, entry.name, idx); if (existsSync(indexPath)) { extPath = indexPath; extName = entry.name; break; } } if (!extPath) continue; } else if (entry.isFile() && isExtensionFile(entry.name)) { extPath = join(lazyDir, entry.name); extName = entry.name.replace(/\.(ts|js)$/, ""); } else { continue; } const name = extName!; // 跳过已加载 if (loadedExtensions.has(name)) continue; // 白名单过滤 if (config.whitelist.length > 0 && !config.whitelist.includes(name)) continue; // 黑名单过滤 if (config.blacklist.includes(name)) { console.log(`[lazy-loader] Skipped (blacklist): ${name}`); continue; } extensions.push({ path: extPath!, name }); } if (extensions.length === 0) { console.log("[lazy-loader] No extensions to load"); return; } const globalStart = Date.now(); const allResults = await loadExtensions(extensions, pi, config.timeout); allResults.forEach(r => r.success && loadedExtensions.add(r.name)); const totalDuration = Date.now() - globalStart; // 生成统计报告 const succeeded = allResults.filter(r => r.success); const failed = allResults.filter(r => !r.success); // 单行状态栏消息(替代多行 console.log,避免污染状态栏) const statusMsg = failed.length > 0 ? `[lazy-loader] ${succeeded.length}/${allResults.length} loaded, ${failed.length} failed` : `[lazy-loader] ${succeeded.length}/${allResults.length} extensions loaded in ${totalDuration}ms`; if (!shuttingDown) ctx.ui.setStatus("lazy-loader", statusMsg); // 失败详情输出到 console.error(不占状态栏) if (failed.length > 0) { failed.forEach(r => { const reason = r.timedOut ? r.completedLate ? `Timeout (>${config.timeout}ms) but completed later` : `Timeout (>${config.timeout}ms)` : r.error; console.error(`[lazy-loader] ❌ ${r.name}: ${reason}`); }); } // 状态栏 2s 后自动清除 armTimer(() => { ctx.ui.setStatus("lazy-loader", undefined); }, STATUS_CLEAR_DELAY_MS); } function setupHotReload( pi: ExtensionAPI, ctx: ExtensionContext, lazyDir: string, config: LazyLoaderConfig & Required> ) { console.log("[lazy-loader] Hot reload enabled"); watcher = watch(lazyDir, { recursive: false }, async (eventType, filename) => { if (shuttingDown) return; if (!filename || !isExtensionFile(filename)) return; // 跳过占位文件,与 discover 阶段保持一致。 if (filename === "index.ts" || filename === "index.js") return; const extName = filename.replace(/\.(ts|js)$/, ""); if (loadedExtensions.has(extName)) return; console.log(`[lazy-loader] Hot reload detected: ${filename}`); const extPath = join(lazyDir, filename); if (!existsSync(extPath)) return; const results = await loadExtensions([{ path: extPath, name: extName }], pi, config.timeout); const r = results[0]; if (r.success) { loadedExtensions.add(extName); if (!shuttingDown) ctx.ui.notify(`Hot loaded: ${extName}`, "info"); } else if (r.timedOut && !r.completedLate) { // 超时但仍在后台跑;加载完成后 loadExtensions 内部已记录,这里只提示。 console.error(`[lazy-loader] ❌ ${extName}: hot load timeout (>${config.timeout}ms)`); } else { console.error(`[lazy-loader] ❌ ${extName}: ${r.error}`); } }); } // 清理资源(修正事件名:session_end → session_shutdown) pi.on("session_shutdown", () => { shuttingDown = true; clearAllTimers(); if (watcher) { watcher.close(); watcher = null; } }); } /** * Load a batch of extensions concurrently. * * Loading mechanism: we use the host's native dynamic `import()`. This works * because pi loads this loader via jiti v2, which registers a global ESM * loader hook in the process — so bare `import("...ts")` for child extensions * is resolved by jiti too (verified in pi's runtime). On a bare node process * without jiti this would fail; pi always provides jiti, so it is safe. * * Timeout semantics (problem-1 fix): * A timed-out extension's factory may still be running and registering * side-effects in the background — we cannot abort it. To avoid hot-reload * double-registering such an extension, we attach to the underlying load * promise and, if it eventually completes, mark the result as `completedLate` * and record the name. We also await the original promise so the process does * not leave orphaned in-flight work. */ async function loadExtensions( extensions: ExtensionMeta[], pi: ExtensionAPI, timeout: number ): Promise { const settled = await Promise.allSettled( extensions.map(async (ext): Promise => { const start = Date.now(); // 真实的加载流程(无论是否超时,它都会跑完并产生副作用)。 const loadPromise = (async (): Promise => { try { const mod = await import(ext.path); const factory = mod.default; if (typeof factory !== "function") { throw new Error("No default export function"); } await factory(pi); return { name: ext.name, path: ext.path, success: true, duration: Date.now() - start, }; } catch (err) { return { name: ext.name, path: ext.path, success: false, duration: Date.now() - start, error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, }; } })(); // 超时控制:仅决定"立即返回"的判定结果,不取消后台 loadPromise。 // 保存 timer 句柄,竞赛结束后必须 clearTimeout,否则每个扩展都会 // 遗留一个未触发的定时器卡住事件循环。 let timeoutHandle: ReturnType | undefined; const timeoutPromise = new Promise((resolve) => { timeoutHandle = setTimeout(() => { resolve({ name: ext.name, path: ext.path, success: false, duration: Date.now() - start, timedOut: true, error: `Timeout (>${timeout}ms)`, }); }, timeout); }); try { const early = await Promise.race([loadPromise, timeoutPromise]); // 若超时先到,仍等待真实 load 结束(不抛出),以便: // 1) 不留下孤儿 in-flight 任务; // 2) 若它最终成功,标记 completedLate 供上层记录,避免热重载重复注册。 if (early.timedOut) { const late = await loadPromise; return { ...late, // 以实际结果为准(成功/失败),但保留 timedOut 标记用于诊断。 timedOut: true, completedLate: late.success, duration: Date.now() - start, }; } return early; } finally { if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); } }) ); // 内部 async 永不 reject(错误被 catch 转成 LoadResult),故此处 fulfilled 必然成立; // 保留 rejected 分支仅为防御性兜底。 return settled.map(r => (r.status === "fulfilled" ? r.value : { name: "", path: "", success: false, duration: 0, error: `Unexpected rejection: ${String((r as PromiseRejectedResult).reason)}`, })); }