/** * Per-surface mtime cache for user plugins (discovery + tools). * * Instead of caching whole `Plugin` objects, the user-plugin system caches * individual surfaces keyed by their source file's mtime. This module owns * plugin **discovery** (which plugin directories exist, in what order) and the * **tool** cache; the **hook** cache and every hook operation live in * `../hooks/hook-loader.ts`. This module is the boot orchestrator: it scans the * plugins directory, registers tools, and drives each owner's `init` / `shutdown` * lifecycle by handing the discovered directories to the hook loader. Dispatch * hooks are not imported here — the hook loader resolves each one on demand on * the first read that needs it. * * - Boot does one full discovery scan; after that, every change (plugin * installed, removed, disabled, or any source file inside a plugin edited * — including helper modules hooks/tools import) is applied by the * imperative reconcile the install/uninstall/enable/disable routes call * ({@link reconcilePluginSourcesNow}). The dispatch path is a pure cache * read — it never scans disk, activates a plugin, or runs `init`. * - A changed plugin is redeployed in place: its `shutdown` runs (resolved * from disk), its hook/tool cache entries and module-registry entries are * swept, and reactivation runs `init`; the next read of each hook re-resolves * it from the swept-clean registry. The whole directory is the reload unit * (every module path is swept) so a re-imported hook can never pair with a * stale cached helper. * - Plugins are never "registered" as a unit — we register their tools into * the global tool registry. * * Tools are populated at boot by `loadUserPlugins()`; hook dispatch reads * discovered plugin names from this cache via {@link getDiscoveredUserPluginNames} * and resolves each hook through the hook loader on demand. */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import type { Logger } from "pino"; import { clearPluginHooks, evictHooksForOwner, hasWorkspaceHooks, type HookOwnerKind, resetHookCacheForTests, runInitHook, runShutdownHook, WORKSPACE_HOOKS_OWNER, } from "../hooks/hook-loader.js"; import type { ShutdownReason } from "../plugin-api/types.js"; import { registerPluginSecretPatterns, resetPluginSecretPatternsForTests, unregisterPluginSecretPatterns, } from "../security/plugin-secret-patterns.js"; import { finalizeTool } from "../tools/tool-defaults.js"; import type { Tool, ToolDefinition } from "../tools/types.js"; import { getLogger } from "../util/logger.js"; import { getWorkspaceHooksDir, getWorkspacePluginsDir, } from "../util/platform.js"; import { collectSourceVersions } from "./collect-source-versions.js"; import { deriveToolName, listSurfaceDir, parsePluginManifest, } from "./external-plugin-loader.js"; import { isInsidePluginRoot } from "./installed-plugin-dirs.js"; import { snapshotPluginSource } from "./source-fingerprint.js"; import type { PluginSourceVersion } from "./source-versions.js"; import { clearSurfaceImportInflight, evictModule, getMtime, importWithTimeout, setSurfaceImportTimeout, } from "./surface-import.js"; import type { PluginCredentialKeyPattern } from "./types.js"; const log = getLogger("plugin-mtime-cache"); /** * Cached install-date timestamps per plugin directory, so `scanPlugins` * doesn't re-read `install-meta.json` on every turn. Populated on first * discovery, cleared on eviction and in test reset. The install date * doesn't change during a process lifetime. */ const installDateCache = new Map(); /** * The filename of the provenance sidecar written by the plugin install CLI. * We read only the `installedAt` field for ordering — the full `InstallMeta` * type lives in `src/cli/lib/install-from-github.ts` and we avoid pulling * the CLI dependency graph into the daemon. */ const INSTALL_META_FILENAME = "install-meta.json"; /** * Get a sortable timestamp for a plugin directory, used to order plugins * deterministically by their original install date. * * Resolution order: * 1. `install-meta.json` → `installedAt` field (ISO-8601 string → epoch ms) * 2. `statSync(pluginDir).birthtimeMs` (directory creation time as fallback) * 3. `Infinity` (unknown — sorts after all dated plugins) * * Results are cached in `installDateCache` so repeated `scanPlugins` calls * during a process lifetime don't re-read the sidecar. */ function getInstallDate(pluginDir: string): number { const cached = installDateCache.get(pluginDir); if (cached !== undefined) { return cached; } // Try install-meta.json first. const metaPath = join(pluginDir, INSTALL_META_FILENAME); if (existsSync(metaPath)) { try { const raw = JSON.parse(readFileSync(metaPath, "utf8")); if ( typeof raw === "object" && raw !== null && typeof raw.installedAt === "string" ) { const parsed = Date.parse(raw.installedAt); if (!Number.isNaN(parsed)) { installDateCache.set(pluginDir, parsed); return parsed; } } } catch { // Malformed sidecar — fall through to birthtime. } } // Fall back to directory birthtime. On Linux ext4 and other filesystems // that don't support birth time, statSync returns 0 — treat that as // unknown so undated plugins sort after dated ones. let timestamp = Infinity; try { const birthtime = statSync(pluginDir).birthtimeMs; if (birthtime > 0) { timestamp = birthtime; } } catch { // statSync failed — leave as Infinity. } installDateCache.set(pluginDir, timestamp); return timestamp; } // ─── Cache entries ─────────────────────────────────────────────────────────── /** * A cached tool plus the mtime of its source file. When the on-disk mtime * changes, the tool is re-imported and the cache entry replaced; the tool * registry picks the new version up on its next pull reconcile (the mtime is * part of the {@link ActivePluginTools} fingerprint). */ interface CachedTool { readonly tool: Tool; /** mtimeMs of the source file this tool was imported from. */ readonly sourceMtime: number; /** The plugin name that owns this tool (for unregister). */ readonly pluginName: string; } // ─── Internal state ────────────────────────────────────────────────────────── /** * Cached tools keyed by `${pluginName}/${toolName}`. The key includes the * plugin name so tools from different plugins don't collide. */ const toolCache = new Map(); /** * Plugin directories discovered at boot, in discovery order. Maps directory * path to the plugin's scope-stripped manifest name so eviction can find * the right cache key prefix without reading the (now-deleted) manifest. */ const discoveredPluginDirs = new Map(); /** * Plugin directories that have a `.disabled` sentinel and were logged as * disabled. Tracked so we only emit the "plugin disabled" log line once * per scan cycle (the scan runs on every hook read). Cleared when a plugin * transitions back to active or is evicted entirely. */ const disabledPluginDirs = new Set(); /** * The source-versions state this process last applied, keyed by directory * (see `./source-versions.ts`). Seeded at boot from the daemon's own walk, * so the first imperative reconcile after boot diffs correctly against the * state boot just loaded. */ let lastVersions: Record = {}; /** In-flight reconcile — concurrent imperative pokes await it rather than racing. */ let reconcileInFlight: Promise | null = null; // ─── Discovery reads ───────────────────────────────────────────────────────── /** * Plugin names currently in the discovery cache, in install-date order. * Hook lookup in `hooks/registry.ts` walks this set; the cache itself does * not resolve hooks. */ export function getDiscoveredUserPluginNames(): Iterable { return discoveredPluginDirs.values(); } /** * True when this daemon process brought the plugin directory `dir` up: * {@link bringUpPlugin} ran for it and its `init` was attempted. An `init` that * threw still counts, because activation never aborts on init failure (see * {@link activatePlugin}), and that is deliberate: an init-failed plugin's * hooks and tools stay live, so its schedules must too. What membership * excludes is a directory dropped in out-of-band that no boot scan and no * reconcile pass has activated yet. * * The backing map is per-process, so a process that runs no plugin loader (the * schedule worker, sidecar turn workers) reads `false` for every directory. * Only code that provably runs in the main daemon may treat this as an answer. */ export function isPluginDirActivated(dir: string): boolean { return discoveredPluginDirs.has(dir); } // ─── Source-versions reconcile ─────────────────────────────────────────────── /** * Imperatively reconcile the plugin caches against the current on-disk state * *now*. * * An install / uninstall / enable / disable materializes files on disk; * nothing else applies that change to the caches. Callers that just changed * the plugin set on disk (the install / uninstall routes, and the CLI's * best-effort post-install poke) call this to bring the change up * deterministically, so a freshly installed plugin's `init` fires as part of * the install rather than at the next daemon boot. Together with the boot * scan this is the only path that activates plugins — dispatch-time hook and * tool reads are pure cache reads — so activation only ever happens in the * main daemon, where these routes run. * * Idempotent and safe to call redundantly: `applySourceVersions` only * redeploys directories whose fingerprint moved, and activation is guarded so * a plugin already up is never re-initialized. Never throws — a failure is * contained inside `applySourceVersions` and logged there. * * Concurrent pokes serialize through the `reconcileInFlight` latch, so two * applies never overlap. */ export async function reconcilePluginSourcesNow(): Promise { while (reconcileInFlight !== null) { await reconcileInFlight; } // `applySourceVersions` contains its own failures, but the `collectSourceVersions()` // walk that feeds it runs outside that guard — wrap the whole thing so an // imperative reconcile never rejects into its callers (the install route must // still return success for an install whose files already landed on disk). reconcileInFlight = (async () => { try { await applySourceVersions(collectSourceVersions()); } catch (err) { log.error({ err }, "imperative plugin reconcile failed"); } // Converge plugin-declared schedules against the plugin set this apply // just settled, so install/uninstall/upgrade arm and disarm rows in the // same poke. Imported lazily to keep the notification pipeline out of // this module's static graph (sidecar workers import it for hook reads). // Self-contained: never throws and checks DB readiness itself. try { const { reconcilePluginSchedules } = await import("../schedule/plugin-schedule-reconciler.js"); await reconcilePluginSchedules(); } catch (err) { log.error({ err }, "plugin schedule reconcile failed"); } // Converge plugin-declared MCP servers the same way, so an install // brings its `mcp.json` servers up and an uninstall/disable takes their // connected clients and registered tools back down. Reloads only when // the declared set actually moved. Lazily imported for the same reason // as the schedule reconciler, and equally self-contained. try { const { reconcilePluginMcpServers } = await import("../daemon/mcp-reload-service.js"); await reconcilePluginMcpServers(); } catch (err) { log.error({ err }, "plugin MCP reconcile failed"); } })().finally(() => { reconcileInFlight = null; }); await reconcileInFlight; } /** * Validate that a directory path from a collected source-versions map is an * allowed plugin source: either under the workspace plugins directory or the * standalone workspace hooks directory. This check ensures `bringUpPlugin` * never dynamically imports code from outside the designated plugin roots, * regardless of what the collector walked. * * Containment runs through {@link isInsidePluginRoot}, which resolves the * candidate and the root, so a symlinked path that looks like it's under the * plugins dir but points elsewhere is rejected while a plugins dir reached * through a symlinked path component still accepts its own children. */ function isAllowedPluginDir( dir: string, pluginsDir: string, hooksDir: string, ): boolean { if (!existsSync(dir)) { // Directory doesn't exist or is inaccessible: allow it through so // bringUpPlugin/parsePluginManifest can log the normal failure, and so a // directory that just went away still reaches its teardown branch. The // danger is importing code from an unexpected location, not a missing // directory. return true; } return ( isInsidePluginRoot(dir, pluginsDir) || isInsidePluginRoot(dir, hooksDir) ); } /** * Apply a collected source-versions map: diff it against the state last * applied and redeploy exactly what changed. Never throws — a failed apply * is logged and the next imperative reconcile retries from disk. * * Per directory, the transitions are: * - present + enabled with a moved fingerprint → in-place redeploy: * `shutdown` (reason `reload`) → sweep hook/tool caches and the module * registry → re-import, re-register tools, `init`. The whole directory * is the reload unit on purpose: partial eviction would let a re-imported * hook pair with a stale cached helper, silently mixing versions. * - newly present (installed, or `.disabled` removed) → bring up. * - gone → tear down (`uninstall`; `shutdown` already ran at removal time or the * directory is gone, so none runs here) or newly disabled → tear down * (`disable`, resolving `shutdown` from the still-present directory). * The workspace hooks pseudo-entry gets the same treatment through its own * `init`/`shutdown` lifecycle. */ async function applySourceVersions( next: Readonly>, ): Promise { try { const workspaceHooksDir = getWorkspaceHooksDir(); const pluginsDir = getWorkspacePluginsDir(); const prev = lastVersions; const dirs = new Set([...Object.keys(prev), ...Object.keys(next)]); let membershipChanged = false; for (const dir of dirs) { if (dir === workspaceHooksDir) { continue; } // Reject directories outside the allowed plugin roots; without this // check, bringUpPlugin would dynamic-import code from anywhere on // the filesystem. if (!isAllowedPluginDir(dir, pluginsDir, workspaceHooksDir)) { log.warn( { dir }, "source-versions map references directory outside allowed plugin roots — skipping", ); continue; } const before = prev[dir]; const after = next[dir]; const activeName = discoveredPluginDirs.get(dir); const shouldBeUp = after !== undefined && !after.disabled; if (activeName !== undefined && !shouldBeUp) { const reason: ShutdownReason = after === undefined ? "uninstall" : "disable"; await deactivatePlugin(activeName, reason); await evictPlugin(dir, activeName); sweepModules(before, after); membershipChanged = true; } else if (activeName === undefined && shouldBeUp) { // Reinstalls land at the same path: sweep any modules cached from a // prior install before the fresh import. sweepModules(before, after); if (await bringUpPlugin(dir)) { membershipChanged = true; } } else if ( activeName !== undefined && before !== undefined && after !== undefined && before.fingerprint !== after.fingerprint ) { log.info( { plugin: activeName, dir }, "plugin source changed — reloading", ); // Tear the old version down first: `deactivatePlugin` runs `shutdown`, // then `evictHooksForOwner` drops the owner's cached resolutions so the // next read re-resolves fresh. await deactivatePlugin(activeName, "reload"); evictHooksForOwner("plugin", activeName); evictToolCacheEntries(activeName); sweepModules(before, after); // Re-read the manifest — the edit may have renamed the plugin (or // broken the manifest, in which case the plugin stays down until a // later edit fixes it and moves the fingerprint again). const manifest = await parsePluginManifest(dir); if (manifest === undefined) { discoveredPluginDirs.delete(dir); membershipChanged = true; } else { if (manifest.name !== activeName) { discoveredPluginDirs.set(dir, manifest.name); membershipChanged = true; } await reconcilePluginTools(dir, manifest.name); await activatePlugin( dir, manifest.name, manifest.credentialKeyPatterns, ); } } } if (membershipChanged) { sortDiscoveredPluginDirs(); } await reconcileWorkspaceHooks( prev[workspaceHooksDir], next[workspaceHooksDir], ); lastVersions = { ...next }; } catch (err) { log.error( { err }, "source-versions reconcile failed — keeping current state", ); } } /** * Bring up a directory the source-versions map reports as present and enabled. Returns * whether the plugin joined the discovered set (a malformed manifest is * logged by the parser and the directory is skipped until it changes again). */ async function bringUpPlugin(dir: string): Promise { const manifest = await parsePluginManifest(dir); if (manifest === undefined) { return false; } discoveredPluginDirs.set(dir, manifest.name); disabledPluginDirs.delete(dir); log.info({ plugin: manifest.name, dir }, "plugin discovered"); await reconcilePluginTools(dir, manifest.name); await activatePlugin(dir, manifest.name, manifest.credentialKeyPatterns); return true; } /** * Evict the union of two version entries' module paths from the module * registry, so re-imports re-evaluate a mutually consistent set (deleted * files may still be cached — hence the union, not just the new list). */ function sweepModules( before: PluginSourceVersion | undefined, after: PluginSourceVersion | undefined, ): void { const paths = new Set([ ...(before?.evictionPaths ?? []), ...(after?.evictionPaths ?? []), ]); for (const path of paths) { evictModule(path); } } /** * Reconcile the standalone workspace hooks pseudo-entry: same * shutdown → evict → sweep → init cycle a plugin gets, through the workspace * owner's lifecycle. Dispatch hooks are not re-imported here; the eviction * drops the owner's cached resolutions so the next read re-resolves them fresh. */ async function reconcileWorkspaceHooks( before: PluginSourceVersion | undefined, after: PluginSourceVersion | undefined, ): Promise { if (before?.fingerprint === after?.fingerprint) { return; } const reason: ShutdownReason = after === undefined ? "uninstall" : "reload"; const activeIdx = activatedPlugins.findIndex( (p) => p.kind === "workspace" && p.name === WORKSPACE_HOOKS_OWNER, ); if (activeIdx >= 0) { await runShutdownHook("workspace", WORKSPACE_HOOKS_OWNER, reason); activatedPlugins.splice(activeIdx, 1); } evictHooksForOwner("workspace", WORKSPACE_HOOKS_OWNER); sweepModules(before, after); if (after !== undefined) { await runInitHook(WORKSPACE_HOOKS_OWNER); activatedPlugins.push({ kind: "workspace", name: WORKSPACE_HOOKS_OWNER }); log.info("workspace hooks reloaded"); } } /** * Seed the reconcile baseline at boot from the daemon's own walk, so the * first imperative reconcile after boot diffs against the state boot just * loaded (an unchanged plugin costs a fingerprint compare, not a redeploy). */ function seedVersionBaseline(): void { const seeded: Record = {}; for (const [dir] of discoveredPluginDirs) { const snapshot = snapshotPluginSource(dir); seeded[dir] = { fingerprint: snapshot.fingerprint, evictionPaths: snapshot.evictionPaths, disabled: false, }; } const workspaceHooksDir = getWorkspaceHooksDir(); if (existsSync(workspaceHooksDir)) { const snapshot = snapshotPluginSource(workspaceHooksDir); seeded[workspaceHooksDir] = { fingerprint: snapshot.fingerprint, evictionPaths: snapshot.evictionPaths, disabled: false, }; } lastVersions = seeded; } // ─── Tool cache ────────────────────────────────────────────────────────────── /** * Cache key for a tool: `${pluginName}/${toolName}`. */ function toolKey(pluginName: string, toolName: string): string { return `${pluginName}/${toolName}`; } /** * Reconcile the tool cache for a single plugin directory. Re-imports * changed tool files, evicts cache entries for deleted files, and caches * newly appeared ones. * * Called during `scanPlugins()` so that by the time the tool registry pulls * via {@link getActiveUserPluginTools}, the cache is fresh. */ async function reconcilePluginTools( pluginDir: string, pluginName: string, ): Promise { const toolsDir = join(pluginDir, "tools"); const surfaceFiles = listSurfaceDir(toolsDir); const onDiskNames = new Set(); for (const file of surfaceFiles) { const toolName = deriveToolName(file.name); onDiskNames.add(toolName); const key = toolKey(pluginName, toolName); const currentMtime = getMtime(file.path); // Cache hit — same mtime. const cached = toolCache.get(key); if ( cached !== undefined && cached.sourceMtime === currentMtime && currentMtime > 0 ) { continue; } // Cache miss — re-import. if (currentMtime === 0) { // File was deleted — will be handled by the eviction loop below. continue; } try { const toolSpec = await importWithTimeout(file.path); if ( toolSpec === undefined || toolSpec === null || typeof toolSpec !== "object" ) { log.error( { plugin: pluginName, tool: toolName, path: file.path }, `tool default export must be an object — skipping`, ); continue; } const tool = finalizeTool(toolSpec, toolName); toolCache.set(key, { tool, sourceMtime: currentMtime, pluginName }); } catch (err) { log.error( { err, plugin: pluginName, tool: toolName, path: file.path }, `Failed to import tool ${toolName} from ${file.path}`, ); } } // Evict cached tools whose files no longer exist on disk. for (const key of toolCache.keys()) { const [cachedPluginName, cachedToolName] = key.split("/"); if (cachedPluginName !== pluginName) { continue; } if (!onDiskNames.has(cachedToolName)) { toolCache.delete(key); } } } /** * Get all cached tools from user plugins, active or not. Test/diagnostic * read; production consumers go through {@link getActiveUserPluginTools}. */ export function getCachedUserTools(): Tool[] { return Array.from(toolCache.values()).map((c) => c.tool); } /** * A single active plugin's tool contribution, as pulled by the tool * registry's plugin reconcile (`loadPluginTools` in `tools/registry.ts`). */ export interface ActivePluginTools { /** * Change stamp for this plugin's tool set: tool names paired with their * source-file mtimes. The registry reconcile re-registers a plugin's tools * only when this moves, so an unchanged plugin costs a string compare. */ fingerprint: string; tools: Tool[]; } /** * Return the tool contributions of every *active* (discovered, enabled, * activated) user plugin, keyed by plugin name in install-date order. * * This is a pure read of the already-reconciled caches — it never scans disk, * activates a plugin, or runs an `init` hook. Reconciliation (which activates * plugins and is the only thing that runs `init`) is owned exclusively by the * two paths that legitimately change the plugin set, both main-daemon only: * the boot scan ({@link populateCacheAtBoot}) and the imperative * install/uninstall poke ({@link reconcilePluginSourcesNow}). Pulling tools * (or dispatching hooks) must not be a third: those reads run in processes * that never run plugin lifecycle (sidecar workers call `initializeTools()` * for their own tool surface and dispatch hooks for the conversations they * wake), so folding activation into a read would run `init` in a worker * against daemon-owned plugin storage. * * This is the pull half of the tool-registry relationship: the registry's * `loadPluginTools()` reconcile calls this and diffs the result into its own * maps — this module never writes to the registry. Because a runtime plugin * change lands in the cache via the install poke, the very next * `loadPluginTools()` reads the updated set, so install/remove through the * routes is still picked up without recreating the conversation. */ export function getActiveUserPluginTools(): Map { const byPlugin = new Map(); for (const cached of toolCache.values()) { if (!activatedNames.has(cached.pluginName)) { continue; } const list = byPlugin.get(cached.pluginName); if (list) { list.push(cached); } else { byPlugin.set(cached.pluginName, [cached]); } } // Emit in install-date order (discoveredPluginDirs is kept sorted), so the // registry registers plugin tools in the same deterministic order the push // model used. Plugins activated but no longer discovered (mid-teardown) // simply don't appear. const result = new Map(); for (const pluginName of discoveredPluginDirs.values()) { const cachedTools = byPlugin.get(pluginName); if (cachedTools === undefined || result.has(pluginName)) { continue; } const fingerprint = cachedTools .map((c) => `${c.tool.name}:${c.sourceMtime}`) .sort() .join("\n"); result.set(pluginName, { fingerprint, tools: cachedTools.map((c) => c.tool), }); } return result; } // ─── Plugin discovery ──────────────────────────────────────────────────────── /** * Scan the plugins directory, update the discovered set, and reconcile * tools for each plugin. Also evicts cache entries for deleted plugins. * * Runs once at boot ({@link populateCacheAtBoot}); steady-state changes — * installs, removals, disables, and source edits — arrive through the * source-versions reconcile instead, so the dispatch path never pays for * this walk. */ async function scanPlugins(): Promise { const pluginsDir = getWorkspacePluginsDir(); if (!existsSync(pluginsDir)) { // No plugins directory — evict everything. await evictAll(); return; } let entries: string[]; try { entries = readdirSync(pluginsDir); } catch { log.warn({ pluginsDir }, "scanPlugins: failed to read plugins directory"); return; } const currentDirs = new Map(); // Declared credential key patterns per directory, carried from the manifest // parse below to the activation loop at the bottom of the scan. const credentialPatternsByDir = new Map< string, PluginCredentialKeyPattern[] | undefined >(); for (const entry of entries) { const pluginDir = join(pluginsDir, entry); try { if (!statSync(pluginDir).isDirectory()) { continue; } } catch { continue; } // Judge the entry by where it resolves. A symlinked plugin root pointing // out of the plugins directory is not an install, and activating one at // boot would run code that enumeration and the reload path both refuse to // touch. if (!isInsidePluginRoot(pluginDir, pluginsDir)) { log.warn( { pluginDir }, "plugin root resolves outside the plugins directory, skipping", ); continue; } if (!existsSync(join(pluginDir, "package.json"))) { continue; } // Check for the .disabled sentinel. A plugin is disabled when a file // named `.disabled` exists inside its plugin directory. Disabled // plugins are skipped entirely — no hooks, no tools, no cache entries. // If the plugin was previously active, its cache entries are evicted. if (existsSync(join(pluginDir, ".disabled"))) { const manifest = await parsePluginManifest(pluginDir); const pluginName = manifest?.name ?? entry; if (discoveredPluginDirs.has(pluginDir)) { await deactivatePlugin(pluginName, "disable"); await evictPlugin(pluginDir, pluginName); } if (!disabledPluginDirs.has(pluginDir)) { log.info( { plugin: pluginName, pluginDir }, "plugin disabled via .disabled sentinel — skipping", ); disabledPluginDirs.add(pluginDir); } continue; } const manifest = await parsePluginManifest(pluginDir); if (manifest === undefined) { continue; } const { name: pluginName } = manifest; currentDirs.set(pluginDir, pluginName); credentialPatternsByDir.set(pluginDir, manifest.credentialKeyPatterns); disabledPluginDirs.delete(pluginDir); if (!discoveredPluginDirs.has(pluginDir)) { log.info({ plugin: pluginName, pluginDir }, "plugin discovered"); } // Reconcile this plugin's tools (re-imports changed files). await reconcilePluginTools(pluginDir, pluginName); } // Deactivate and evict cache entries for deleted plugins. for (const [pluginDir, pluginName] of discoveredPluginDirs) { if (!currentDirs.has(pluginDir)) { await deactivatePlugin(pluginName, "uninstall"); await evictPlugin(pluginDir, pluginName); } } // Update the discovered set, sorted by original install date so // hook execution order and tool registration order are deterministic. discoveredPluginDirs.clear(); for (const [dir, name] of currentDirs) { discoveredPluginDirs.set(dir, name); } sortDiscoveredPluginDirs(); // Activate any plugin not yet brought up. Idempotent: already-active plugins // are skipped by the `activatedNames` guard, so steady-state scans (one per // hook dispatch) cost only a membership check per plugin. Tools were imported // into `toolCache` by `reconcilePluginTools` above, so they are visible to // the registry's pull reconcile as soon as activation flips. for (const [dir, name] of discoveredPluginDirs) { await activatePlugin(dir, name, credentialPatternsByDir.get(dir)); } } /** * Re-sort the discovered set by original install date, so hook execution * order and tool registration order stay deterministic as membership * changes at runtime. */ function sortDiscoveredPluginDirs(): void { const sorted = [...discoveredPluginDirs.entries()].sort( ([dirA], [dirB]) => getInstallDate(dirA) - getInstallDate(dirB), ); discoveredPluginDirs.clear(); for (const [dir, name] of sorted) { discoveredPluginDirs.set(dir, name); } } /** * Evict all cache entries for a deleted plugin directory. The plugin name * is passed in from the discoveredPluginDirs map (captured when the plugin * was last scanned), so we don't need to read the now-deleted manifest. */ async function evictPlugin( pluginDir: string, pluginName: string, ): Promise { // Evict hooks (owned by the hook loader). evictHooksForOwner("plugin", pluginName); // Evict tools. evictToolCacheEntries(pluginName); // Belt to deactivatePlugin's suspenders — eviction can run for a plugin // that never fully activated. unregisterPluginSecretPatterns(pluginName); log.info( { plugin: pluginName, pluginDir }, "plugin evicted (directory removed)", ); discoveredPluginDirs.delete(pluginDir); installDateCache.delete(pluginDir); } /** Drop every `toolCache` entry owned by `pluginName`. */ function evictToolCacheEntries(pluginName: string): void { const toolPrefix = `${pluginName}/`; for (const key of toolCache.keys()) { if (key.startsWith(toolPrefix)) { toolCache.delete(key); } } } /** * Evict all plugin-owned cache entries (when the plugins directory is gone * entirely). Standalone workspace hooks are preserved by the hook loader: * they live outside the plugins directory, so the absence of any plugin must * not evict them. */ async function evictAll(): Promise { clearPluginHooks(); for (const pluginName of discoveredPluginDirs.values()) { unregisterPluginSecretPatterns(pluginName); } toolCache.clear(); discoveredPluginDirs.clear(); installDateCache.clear(); disabledPluginDirs.clear(); } // ─── Activation lifecycle ──────────────────────────────────────────────────── /** * Plugins (and the workspace-hooks pseudo-owner) fully activated (tools * registered + `init` hook run) within this process, in activation order. A * runtime uninstall/disable tears a single entry down via * {@link deactivatePlugin}; at daemon shutdown the owners' `shutdown` hooks * fire through the unified `runHook(HOOKS.SHUTDOWN)` pipeline. * * Entries carry their owner {@link HookOwnerKind} so lookups are scoped by * `(kind, name)`: a plugin and the workspace pseudo-owner can share a name * (a hand-installed plugin named {@link WORKSPACE_HOOKS_OWNER}), and matching on * name alone would splice the wrong record. */ const activatedPlugins: Array<{ kind: HookOwnerKind; name: string }> = []; /** * Names in {@link activatedPlugins}, kept as a set for O(1) membership and — * critically — reserved *synchronously* at the top of `activatePlugin`. The * per-turn hook dispatch reaches `scanPlugins` on every turn (sometimes * concurrently), so the synchronous reservation is what prevents a second scan * from double-activating a plugin while its async `init()` is still in flight. */ const activatedNames = new Set(); /** * Activate a single discovered plugin: mark its cached tools live (the tool * registry pulls them via {@link getActiveUserPluginTools} on its next * reconcile) and run its `init` hook. Running `init` also resolves the owner's * `shutdown` (caching it for teardown), so no separate pre-import step is * needed; dispatch hooks resolve on their first read. Idempotent — a plugin * already activated (or mid-activation) is skipped. Never throws; per-surface * failures are logged and the plugin still counts as activated so the shutdown * teardown handles whatever came up (mirrors boot semantics). * * Called from `scanPlugins`, which runs both at boot and on every subsequent * scan — so a plugin whose files appear at runtime (installed via the CLI or * provisioned out-of-band) becomes live without a daemon restart. */ async function activatePlugin( pluginDir: string, pluginName: string, credentialKeyPatterns?: PluginCredentialKeyPattern[], ): Promise { if (activatedNames.has(pluginName)) { return; } // Reserve synchronously, before any await, so a re-entrant or concurrent // scan observes this plugin as already handled. From this point the // plugin's cached tools are visible to the registry's pull reconcile. activatedNames.add(pluginName); // Register declared credential key patterns BEFORE the `init` hook runs so // init-time logging (including caught-error paths that echo a configured // key) is already covered by log redaction — mirroring the default-plugin // bootstrap. Activation never aborts (init failures are swallowed below and // the plugin still counts as activated), so every teardown path unregisters. registerDeclaredCredentialKeyPatterns(pluginName, credentialKeyPatterns, log); // Run the `init` hook if present. await runInitHook(pluginName, pluginDir); activatedPlugins.push({ kind: "plugin", name: pluginName }); } /** * Register a plugin's declared credential key patterns (its manifest * `credentialKeyPatterns`) into the secret-pattern registry, replacing any * prior set for that plugin. Invalid declarations never fail activation: each * rejection is logged with plugin attribution — the declared label plus the * rejection reason, never the full pattern source. Shared by the user-plugin * activation path here and the default-plugin bootstrap * (`daemon/external-plugins-bootstrap.ts`); disabled plugins never reach * either call site, which is what keeps disabled-state filtering at the * lifecycle layer (the registry itself does no config reads). */ export function registerDeclaredCredentialKeyPatterns( pluginName: string, patterns: readonly PluginCredentialKeyPattern[] | undefined, logger: Logger, ): void { if (patterns === undefined || patterns.length === 0) { return; } const { rejected } = registerPluginSecretPatterns(pluginName, patterns); if (rejected.length === 0) { return; } const labelByPattern = new Map(patterns.map((p) => [p.pattern, p.label])); logger.warn( { plugin: pluginName, rejected: rejected.map((r) => ({ label: labelByPattern.get(r.pattern), reason: r.reason, })), }, `plugin ${pluginName} declared ${rejected.length} invalid credential key pattern(s) — ignoring them`, ); } /** * Deactivate a plugin that was disabled (`disable`), removed (`uninstall`), or * is being redeployed (`reload`) at runtime: drop it from the active set (the * tool registry's pull reconcile removes its tools on its next pass) and run * its `shutdown` hook. Must run *before* `evictPlugin` / `evictHooksForOwner` * clear the owner's cache. Idempotent — a plugin that was never activated is a * no-op. * * `disable` and `reload` keep the directory present, so {@link runShutdownHook} * resolves and runs the on-disk `shutdown` (via the same resolution the dispatch * path uses). `uninstall` runs nothing here: a managed uninstall runs `shutdown` * *before* removing the directory (see `cli/lib/uninstall-plugin.ts`), and an * out-of-band `rm` leaves nothing to resolve. */ /** * Deactivate a plugin ahead of an in-place upgrade's file swap: run the * outgoing version's `shutdown` while its files are still on disk and drop * its cached hook/tool resolutions. The upgrade route passes this as the * upgrade's `beforeSwap` so teardown precedes the new files landing; the * post-swap reconcile's redeploy branch then finds the plugin already * deactivated (the `activatedNames` guard makes its own deactivate a no-op, * so `shutdown` never double-runs) and proceeds straight to re-import and * the new version's `init`. Safe no-op when the plugin is not active. */ export async function deactivatePluginForUpdate( pluginName: string, ): Promise { await deactivatePlugin(pluginName, "reload"); evictHooksForOwner("plugin", pluginName); evictToolCacheEntries(pluginName); } async function deactivatePlugin( pluginName: string, reason: ShutdownReason, ): Promise { if (!activatedNames.has(pluginName)) { return; } activatedNames.delete(pluginName); const idx = activatedPlugins.findIndex( (p) => p.kind === "plugin" && p.name === pluginName, ); if (idx >= 0) { activatedPlugins.splice(idx, 1); } if (reason !== "uninstall") { await runShutdownHook("plugin", pluginName, reason); } // Unregister AFTER the shutdown hook: patterns must stay active while the // hook runs so shutdown-time logging (including caught-error paths that // echo a configured key) is still covered by log redaction — the mirror of // the register-before-init ordering in activatePlugin. unregisterPluginSecretPatterns(pluginName); } // ─── Boot population ───────────────────────────────────────────────────────── /** * Populate the caches at boot by scanning the plugins directory once (which * imports surfaces into the caches and runs `init` hooks via `activatePlugin` * inside `scanPlugins`) and activating standalone workspace hooks. At daemon * shutdown these owners' `shutdown` hooks fire through the unified * `runHook(HOOKS.SHUTDOWN)` pipeline; a runtime uninstall/disable tears a single * owner down via {@link deactivatePlugin}. * * This replaces the old `loadExternalPlugin` → `registerPlugin` → * `bootstrapPlugins` path for user plugins. Instead of registering whole * `Plugin` objects into the plugin registry, we cache individual surfaces by * mtime; the tool registry pulls the active tool set through * {@link getActiveUserPluginTools}. * * Called by `loadUserPlugins()` during daemon startup. After boot, the same * `activatePlugin`/`deactivatePlugin` reconciliation runs only through the * imperative poke ({@link reconcilePluginSourcesNow}) the install/uninstall/ * upgrade/enable routes call, so plugin lifecycle stays confined to the main * daemon — dispatch-time hook and tool reads never activate anything. */ export async function populateCacheAtBoot( opts: { importTimeoutMs?: number } = {}, ): Promise { if (opts.importTimeoutMs !== undefined) { setSurfaceImportTimeout(opts.importTimeoutMs); } // Scans + activates every discovered plugin (tools registered + `init` run). await scanPlugins(); // Activate standalone workspace hooks under `/hooks/`. These // carry no package.json, no tools, and no install-date ordering — just hook // files. Running their `init` hook resolves the workspace `init`/`shutdown`, // so a workspace-wide lifecycle works the same way a plugin's does. Only // register for teardown when at least one hook file is actually present, so // an empty/absent directory adds no shutdown work. if (hasWorkspaceHooks()) { await runInitHook(WORKSPACE_HOOKS_OWNER); activatedPlugins.push({ kind: "workspace", name: WORKSPACE_HOOKS_OWNER }); } // From here on, changes arrive via the imperative reconcile diffed // against this seed. seedVersionBaseline(); } // ─── Test hooks ────────────────────────────────────────────────────────────── /** * Clear all caches. Test-only. */ export function resetPluginCacheForTests(): void { const isTest = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test"; if (!isTest) { throw new Error( "resetPluginCacheForTests may only be called in test environments", ); } resetHookCacheForTests(); clearSurfaceImportInflight(); resetPluginSecretPatternsForTests(); toolCache.clear(); discoveredPluginDirs.clear(); installDateCache.clear(); activatedPlugins.length = 0; activatedNames.clear(); disabledPluginDirs.clear(); lastVersions = {}; reconcileInFlight = null; } /** * Test-only: inspect the tool cache. */ export function _inspectToolCacheForTests(): Array<{ key: string; sourceMtime: number; }> { const isTest = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test"; if (!isTest) { throw new Error( "_inspectToolCacheForTests may only be called in test environments", ); } return Array.from(toolCache.entries()).map(([key, c]) => ({ key, sourceMtime: c.sourceMtime, })); }