Plugins: convención de directorio y descubrimiento
Responsabilidad
Un plugin (plugin) es el paquete de extensión de OpenClaw — un paquete npm o directorio de código fuente que, siguiendo una convención de directorio, declara qué herramientas, skills, canales (channel), providers, comandos, hooks (hook) y métodos RPC ofrece. El sistema, al arrancar, escanea múltiples directorios raíz para descubrir (discovery) plugins candidatos; tras cargarlos (loading), los funde en un PluginRegistry a partir del cual el bucle principal, el gateway y las sesiones de agent obtienen las capacidades extendidas.
El sistema de plugins en sí no «ejecuta» negocio; solo hace tres cosas: escanear múltiples raíces para encontrar candidatos, parsear el manifest (manifest) y el formato de bundle, y registrar en un registry unificado. Quienes de verdad trabajan son las herramientas, providers y canales que el plugin registra — una vez cargadas en PluginRegistry, estas capacidades conviven en igualdad de condiciones con las de otros orígenes (builtin, custom del SDK).
OpenClaw soporta simultáneamente varios formatos de bundle: .codex-plugin/plugin.json (estilo Codex), .claude-plugin/plugin.json (estilo Claude), .cursor-plugin/plugin.json (estilo Cursor), además del plugin.json nativo. Esto permite a OpenClaw reutilizar directamente paquetes de plugins de otros ecosistemas, sin obligar al autor a reempaquetar.
Motivación de diseño
¿Por qué convención de directorio en lugar de import explícito? Porque las fuentes de plugins son muy heterogéneas: unas son paquetes npm, otras son directorios de código fuente clonados con git, otras son productos bundled de un dist, otras son carpetas que el usuario colocó manualmente en ~/.openclaw/plugins/. El import explícito exige que la ruta y el sistema de módulos estén listos, mientras que la convención de directorio solo requiere «en este directorio hay un archivo manifest» para identificar. Esto permite que el descubrimiento (discovery) sea puramente de sistema de archivos, sin cargar módulos — cargar módulos es costoso (implica resolución ESM de Node y posiblemente compilación tsx), y separar identificación de carga acelera enormemente el arranque en frío.
bundle-manifest.ts (bundle-manifest.ts rutas relativas:L21-L24) define las rutas relativas de los tres formatos:
/** Relative manifest path for Codex-style plugin bundles. */
export const CODEX_BUNDLE_MANIFEST_RELATIVE_PATH = ".codex-plugin/plugin.json";
export const CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH = ".claude-plugin/plugin.json";
export const CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH = ".cursor-plugin/plugin.json";Nótese que todos son subdirectorios ocultos dentro del propio bundle + plugin.json — así el paquete de plugin puede ser un paquete npm normal, con package.json, README.md y código fuente, y al mismo tiempo llevar un .codex-plugin/plugin.json que declara la parte que interesa a OpenClaw (skills/hooks/capabilities).
El tipo BundlePluginManifest (src/plugins/bundle-manifest.ts:L27-L39) normaliza los múltiples formatos:
export type BundlePluginManifest = {
id: string;
name?: string;
description?: string;
version?: string;
skills: string[];
settingsFiles?: string[];
hooks: string[];
bundleFormat: PluginBundleFormat;
activation?: PluginManifestActivation;
capabilities: string[];
};El campo bundleFormat marca el formato de origen; skills / hooks / capabilities son las declaraciones de capacidad que de verdad interesan a OpenClaw — arrays de rutas relativas a la raíz del plugin. Esta normalización permite que el loader posterior no se preocupe por el ecosistema de origen.
La fase de carga loadOpenClawPlugins (loader.ts loadOpenClawPlugins:L1821-L1895) tiene además varios diseños clave: reutilización de cache (misma cacheKey devuelve el registry ya cargado, evitando cargar módulos repetidos), separación del estado de activación (shouldActivate controla si se limpia el estado runtime anterior; una carga no activa solo lee, sin escribir efectos), y creación perezosa del module loader (si todos los plugins están deshabilitados, no se crea el module loader, ahorrando coste de arranque en frío).
PluginRegistry (registry-types.ts PluginRegistry:L434-L484) es un objeto grande, con un array por cada capacidad:
export type PluginRegistry = {
plugins: PluginRecord[];
tools: PluginToolRegistration[];
hooks: PluginHookRegistration[];
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[];
channelSetups: PluginChannelSetupRegistration[];
providers: PluginProviderRegistration[];
modelCatalogProviders: PluginModelCatalogProviderRegistration[];
// ... más de una decena de tipos de provider y entradas de registro
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
commands: PluginCommandRegistration[];
// ...
diagnostics: PluginDiagnostic[];
};Nótese que no es un Map, sino un objeto con arrays por capacidad. La razón es que las dimensiones de registro difieren entre capacidades: las herramientas tienen name+executor; los canales tienen channelId+pluginId; los providers tienen api+providerId — meterlo en un Map<string, T> exigiría inventar una pseudoclave. Array + diagnostics permite que warnings y errores generados durante descubrimiento/carga se propaguen hasta el runtime, en lugar de «carga fallida, se pierde».
Archivos clave
bundle-manifest.ts tres formatos:L21-L24— rutas relativas de los bundles Codex/Claude/Cursor.bundle-manifest.ts BundlePluginManifest:L27-L39— tipo de manifest normalizado.discovery.ts discoverOpenClawPlugins:L1432-L1510— entrada principal del descubrimiento; escanea múltiples raíces + extraPaths.discovery.ts restricción workspace:L1484-L1505— el auto-descubrimiento del workspace se limita al extensions root para evitar escanear todo el workspace por error.discovery.ts discoverFromPath:L1210-L1259— descubrimiento de una ruta: ramifica archivo/directorio.loader.ts PluginLoadOptions:L181-L232— opciones de carga completas, con cache/onlyPluginIds/mode, etc.loader.ts loadOpenClawPlugins:L1821-L1903— entrada principal de carga; reutilización de cache + separación del estado de activación.registry-types.ts PluginRegistry:L434-L484— tipo del registry, con buckets por capacidad.registry-types.ts PluginToolRegistration:L76-L86— ejemplo de entrada de registro de herramienta.discovery.ts rechazo alias bundled:L1459-L1470— cuando un extraPath apunta a una raíz bundled, solo se avisa y no se reescanea.
Flujo de datos
El descubrimiento empieza desde múltiples raíces (discovery.ts discoverOpenClawPlugins:L1432-L1510):
export function discoverOpenClawPlugins(params: {
workspaceDir?: string;
extraPaths?: string[];
installRecords?: Record<string, PluginInstallRecord>;
ownershipUid?: number | null;
env?: NodeJS.ProcessEnv;
}): PluginDiscoveryResult {
const env = params.env ?? process.env;
const workspaceDir = normalizeOptionalString(params.workspaceDir);
const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined;
const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env });
// ...
for (const extraPath of extra) {
// ...
const bundledAlias = resolvePackagedBundledLoadPathAlias({
bundledRoot: roots.stock,
loadPath: resolveUserPath(trimmed, env),
});
if (bundledAlias) {
result.diagnostics.push({
level: "warn",
source: trimmed,
message: `ignored plugins.load.paths entry that points at OpenClaw's ${bundledAlias.kind} bundled plugin directory; remove this redundant path or run openclaw doctor --fix`,
});
continue;
}
discoverFromPath({ rawPath: trimmed, origin: "config", /* ... */ });
}Nótese la detección de alias bundled — si el usuario apunta plugins.load.paths al directorio de plugins bundled de OpenClaw, solo se avisa sin reescanear, porque ese directorio ya está dentro del rango de escaneo. Es una profilaxis contra «doble carga del mismo plugin causando error de duplicados».
El auto-descubrimiento del workspace tiene un comentario clave (src/plugins/discovery.ts:L1489-L1505):
if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) {
// Keep workspace auto-discovery constrained to the OpenClaw extensions root.
// Recursively scanning the full workspace treats arbitrary project folders as
// plugin candidates and causes noisy "plugin manifest not found" validation failures.
discoverInDirectory({
dir: roots.workspace,
origin: "workspace",
// ...
});
}Esto solucionó un pozo visitado: si se escanea recursivamente todo el workspace, cualquier node_modules o carpeta de código fuente se considera candidato a plugin y luego se reporta «plugin manifest not found» — el ruido ahogaría los errores reales. Por eso el descubrimiento del workspace se limita al extensions root.
La fase de carga (loader.ts cache+activación:L1862-L1903) usa reutilización de cache + separación del estado de activación:
const cacheEnabled = options.cache !== false && options.resolveRawConfigEnvVars !== true;
if (cacheEnabled) {
const cached = getReusableCachedPluginRegistry({
cacheKey,
onlyPluginIds,
runtimeSubagentMode,
options,
});
if (cached) {
if (shouldActivate) {
restoreRegisteredAgentHarnesses(cached.state.agentHarnesses);
restorePluginCommands(cached.state.commands ?? []);
// ... restaura un montón de estado runtime
activatePluginRegistry(
cached.state.registry,
cached.cacheKey,
cached.runtimeSubagentMode,
options.workspaceDir,
);
}
return cached.state.registry;
}
}
pluginLoaderCacheState.beginLoad(cacheKey);
try {
if (shouldActivate) {
clearActivatedPluginRuntimeState();
}
const loadPluginModule = createPluginModuleLoader({
devSourceRoot,
pluginSdkResolution: options.pluginSdkResolution,
});Lo que hay que restaurar al reutilizar cache no es solo el registry, sino también agentHarnesses, commands, compactionProviders, interactiveHandlers, embeddingProviders y muchas tablas globales — porque al cargar el plugin esos side effects se registran en tablas globales, y al reactivar hay que devolverlos a su sitio. clearActivatedPluginRuntimeState limpia el estado viejo antes de una carga nueva, evitando que herramientas o comandos de la ronda anterior se «cuelen» en esta.
El parseo del manifest del bundle está en bundle-manifest.ts carga archivo:L92-L110: usa readRootStructuredFileSync para leer JSON dentro del límite de root; si falla, devuelve { ok: false, error, manifestPath } sin lanzar error.
Límites y fallos
- Tres formatos de bundle coexisten:
.codex-plugin/.claude-plugin/.cursor-pluginson todos subdirectorios ocultos +plugin.json(src/plugins/bundle-manifest.ts:L21-L24). Un plugin puede declarar varios formatos a la vez — el loader escoge el primero que encaje y lo normaliza aBundlePluginManifest. - El descubrimiento del workspace está acotado: el auto-descubrimiento solo escanea el extensions root, no recursa por todo el workspace (
src/plugins/discovery.ts:L1489-L1505). En caso contrario explotaría el ruido «plugin manifest not found». - El directorio bundled no se reescanea: si el usuario apunta
plugins.load.pathsa una raíz bundled, solo se avisa sin reescanear (src/plugins/discovery.ts:L1459-L1470). Recomendación: ejecutaropenclaw doctor --fixpara limpiar config redundante. - La reutilización de cache restaura side effects: al acertar cache, no basta con devolver el registry; hay que
restoreRegisteredAgentHarnesses/restorePluginCommands/restoreRegisteredEmbeddingProvidersy más de una docena de restauraciones globales (src/plugins/loader.ts:L1872-L1885). Olvidar una deja herramientas o comandos «invisibles». - Separación del estado de activación:
shouldActivatefalse no limpia el estado runtime previo — está pensado para cargas de snapshot (modo validate / parallel discovery), evitando limpiar por error los comandos de otros plugins. - Carga perezosa de módulos: si todos los plugins están deshabilitados,
createPluginModuleLoaderni se invoca (src/plugins/loader.ts:L1904-L1908) — escenario común en los unit tests, ahorra coste de arranque. - Los diagnostics no se pierden:
PluginDiscoveryResultyPluginRegistryllevan ambos un arraydiagnostics: PluginDiagnostic[]; los warnings/errores producidos durante descubrimiento/carga se propagan hasta el runtime.openclaw doctorlee estos diagnostics y sugiere correcciones. - El plugin no es la herramienta: un plugin puede registrar herramientas, pero el plugin en sí es un paquete.
PluginRegistry.toolses un array de herramientas, y cadaPluginToolRegistrationreferencia el ID del plugin + la definición de herramienta — estas herramientas al final se integran en el Map del sistema de herramientas. Igualmente, un plugin puede llevar un camposkills/SKILL.md(ver Skills), pero el plugin en sí es un paquete de extensión descubierto por convención de directorio, no una skill.
Resumen
Los plugins son paquetes de extensión descubiertos por convención de directorio: subdirectorios ocultos como .codex-plugin/plugin.json declaran capacidades; discovery escanea múltiples raíces + extraPaths para encontrar candidatos; loader normaliza en un PluginRegistry con buckets por capacidad. La reutilización de cache restaura más de una docena de side effects globales; la separación del estado de activación permite que las cargas de snapshot no limpien el estado runtime anterior. Los tres formatos de bundle permiten a OpenClaw comerse directamente los plugins de los ecosistemas Codex/Claude/Cursor. La diferencia con herramientas: las herramientas son funciones en un Map; los plugins son una de las fuentes que producen herramientas. La diferencia con skills: las skills son instrucciones Markdown; los plugins pueden llevar skills como contenido. El registro de canales y de métodos RPC va por la línea del registro de canales.
Referencias oficiales: Documentación de plugins · README.