Skip to content

Plugins: convención de directorio y descubrimiento

源码版本v2026.6.11

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:

typescript
/** 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:

typescript
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:

typescript
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

Flujo de datos

El descubrimiento empieza desde múltiples raíces (discovery.ts discoverOpenClawPlugins:L1432-L1510):

typescript
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):

typescript
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:

typescript
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-plugin son 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 a BundlePluginManifest.
  • 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.paths a una raíz bundled, solo se avisa sin reescanear (src/plugins/discovery.ts:L1459-L1470). Recomendación: ejecutar openclaw doctor --fix para limpiar config redundante.
  • La reutilización de cache restaura side effects: al acertar cache, no basta con devolver el registry; hay que restoreRegisteredAgentHarnesses / restorePluginCommands / restoreRegisteredEmbeddingProviders y 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: shouldActivate false 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, createPluginModuleLoader ni se invoca (src/plugins/loader.ts:L1904-L1908) — escenario común en los unit tests, ahorra coste de arranque.
  • Los diagnostics no se pierden: PluginDiscoveryResult y PluginRegistry llevan ambos un array diagnostics: PluginDiagnostic[]; los warnings/errores producidos durante descubrimiento/carga se propagan hasta el runtime. openclaw doctor lee 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.tools es un array de herramientas, y cada PluginToolRegistration referencia 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 campo skills/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.