Plugins: convention de répertoire et découverte
Responsabilités
Un plugin est le pack d'extension d'OpenClaw — un paquet npm ou un répertoire source qui, par convention de répertoire, déclare quels outils, compétences, canaux (channel), providers, commandes, hooks (hook), méthodes RPC il fournit. Au démarrage, le système scanne plusieurs racines pour la découverte (discovery) des plugins candidats; après chargement (loading), il les agrège en un PluginRegistry; ensuite la boucle principale, la passerelle, et les sessions d'agent tirent leurs capacités d'extension depuis ce registre.
Le système de plugins lui-même n'« exécute » pas de métier; il fait trois choses: scanne plusieurs racines pour trouver des candidats, parse le manifeste (manifest) et le format de bundle, enregistre dans un registry unifié. Le vrai travail est fait par les outils, providers, canaux enregistrés par les plugins — une fois chargés dans PluginRegistry, ces capacités sont au même rang que celles des autres sources (builtin, custom SDK).
OpenClaw supporte plusieurs formats de bundle: .codex-plugin/plugin.json (style Codex), .claude-plugin/plugin.json (style Claude), .cursor-plugin/plugin.json (style Cursor), et le natif plugin.json. Cela permet à OpenClaw de réutiliser directement les paquets d'autres écosystèmes, sans obliger les auteurs à repackager.
Motivation de conception
Pourquoi une convention de répertoire plutôt que des imports explicites? Parce que les sources de plugins sont hétérogènes: paquet npm, répertoire source git clone, artefact bundled dist, dossier placé manuellement dans ~/.openclaw/plugins/. Un import explicite exige que le chemin et le système de modules soient prêts, tandis qu'une convention de répertoire n'a besoin que de « un fichier manifeste dans ce répertoire » pour identifier. La découverte (discovery) devient alors une opération purement système, sans charger de module — or le chargement de module coûte cher (résolution ESM Node, compilation tsx éventuelle); séparer l'identification du chargement accélère massivement le démarrage à froid.
bundle-manifest.ts (bundle-manifest.ts constantes chemins relatifs:L21-L24) définit les chemins relatifs des trois formats:
/** 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";Notez que ce sont tous des sous-répertoires cachés + plugin.json — un paquet de plugin peut être un paquet npm normal avec package.json, README.md, le code source, et porter en plus un .codex-plugin/plugin.json qui déclare la partie qui intéresse OpenClaw (skills/hooks/capabilities).
Le type BundlePluginManifest (src/plugins/bundle-manifest.ts:L27-L39) normalise les multiples formats:
export type BundlePluginManifest = {
id: string;
name?: string;
description?: string;
version?: string;
skills: string[];
settingsFiles?: string[];
hooks: string[];
bundleFormat: PluginBundleFormat;
activation?: PluginManifestActivation;
capabilities: string[];
};bundleFormat marque le format d'origine; skills / hooks / capabilities sont les déclarations de capacités qu'OpenClaw regarde vraiment — ce sont des tableaux de chemins relatifs à la racine du plugin. Cette « normalisation » permet au loader ultérieur d'ignorer l'écosystème d'origine du plugin.
Au chargement, loadOpenClawPlugins (loader.ts loadOpenClawPlugins:L1821-L1895) a plusieurs designs clés: réutilisation de cache (même cacheKey retourne directement le registry déjà chargé, évitant de recharger les modules), séparation d'état d'activation (shouldActivate contrôle s'il faut vider l'état runtime précédent; un chargement non activant ne fait que lire sans side effect), création paresseuse du module loader (si tous les plugins sont désactivés, aucun module loader n'est créé, économisant du coût de démarrage).
PluginRegistry (registry-types.ts PluginRegistry:L434-L484) est un gros objet, un tableau par capacité:
export type PluginRegistry = {
plugins: PluginRecord[];
tools: PluginToolRegistration[];
hooks: PluginHookRegistration[];
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[];
channelSetups: PluginChannelSetupRegistration[];
providers: PluginProviderRegistration[];
modelCatalogProviders: PluginModelCatalogProviderRegistration[];
// ... encore une dizaine de types de provider et d'entrées de registre
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
commands: PluginCommandRegistration[];
// ...
diagnostics: PluginDiagnostic[];
};Notez que ce n'est pas une Map, mais un objet à buckets par capacité. Les dimensions d'enregistrement diffèrent selon la capacité: les outils ont name+executor, les canaux ont channelId+pluginId, les providers ont api+providerId — en faire une Map<string, T> demanderait d'inventer une clé artificielle. Tableaux + diagnostics permettent aux warnings/erreurs produits en découverte/chargement d'arriver jusqu'au runtime plutôt que d'être « perdus en cas d'échec ».
Fichiers clés
bundle-manifest.ts trois formats:L21-L24— Constantes de chemins relatifs Codex/Claude/Cursor.bundle-manifest.ts BundlePluginManifest:L27-L39— Type de manifeste bundle normalisé.discovery.ts discoverOpenClawPlugins:L1432-L1510— Entrée principale de découverte, scanne plusieurs racines + extraPaths.discovery.ts contrainte workspace:L1484-L1505— La découverte automatique workspace est limitée à l'extensions root, évitant de scanner tout le workspace.discovery.ts discoverFromPath:L1210-L1259— Découverte mono-chemin: branches fichier/répertoire.loader.ts PluginLoadOptions:L181-L232— Options de chargement complètes, dont cache/onlyPluginIds/mode.loader.ts loadOpenClawPlugins:L1821-L1903— Entrée principale de chargement, réutilisation de cache + séparation d'activation.registry-types.ts PluginRegistry:L434-L484— Type de registre, buckets par capacité.registry-types.ts PluginToolRegistration:L76-L86— Exemple d'entrée d'outil.discovery.ts rejet alias bundled:L1459-L1470— extraPaths pointant vers la racine bundled ne fait que warn, pas de double scan.
Flux de données
La découverte part de plusieurs racines (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", /* ... */ });
}Notez la détection d'alias bundled — si l'utilisateur pointe dans plugins.load.paths vers le répertoire de plugin bundled d'OpenClaw, on warn seulement sans rescanner, car ce répertoire est déjà dans le périmètre de scan. C'est une prévention contre « double chargement du même plugin ⇒ doublon de nom ».
La découverte automatique workspace a un commentaire clé (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",
// ...
});
}C'est un piège déjà encou: scanner récursivement tout le workspace ferait que n'importe quel sous-répertoire de node_modules ou dossier source serait traité comme candidat plugin, puis ferait échouer la validation « plugin manifest not found » — le bruit noierait les vraies erreurs. La découverte workspace est donc restreinte à l'extensions root.
Au chargement (loader.ts cache+activation:L1862-L1903) on réutilise le cache et on sépare l'activation:
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 ?? []);
// ... restauration d'un paquet d'état 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,
});La réutilisation de cache restaure bien plus que le registry: agentHarnesses, commands, compactionProviders, interactiveHandlers, embeddingProviders et tout un paquet d'effets globaux — car au chargement du plugin ces side effects s'enregistrent dans des tables globales, et lors d'une réactivation il faut les remettre à leur position. clearActivatedPluginRuntimeState vide l'ancien état avant un nouveau chargement, pour éviter que les outils/commandes d'un tour précédent ne « fuient » dans le nouveau.
Le parsing du manifeste bundle est dans bundle-manifest.ts chargement fichier:L92-L110: readRootStructuredFileSync lit le JSON à l'intérieur de la frontière root, échec renvoie { ok: false, error, manifestPath } sans throw.
Limites et modes d'échec
- Trois formats de bundle coexistent:
.codex-plugin/.claude-plugin/.cursor-pluginsont tous des sous-répertoires cachés +plugin.json(src/plugins/bundle-manifest.ts:L21-L24). Un plugin peut déclarer plusieurs formats simultanément — le loader choisit le premier format matché et le normalise enBundlePluginManifest. - Périmètre de découverte workspace restreint: la découverte auto ne scanne que l'extensions root, pas tout le workspace (
src/plugins/discovery.ts:L1489-L1505). Sinon explosion de bruit « plugin manifest not found ». - Le répertoire bundled n'est pas rescanné: un utilisateur pointant dans
plugins.load.pathsvers la racine bundled ne fait que warn (src/plugins/discovery.ts:L1459-L1470). Suggérezopenclaw doctor --fixpour nettoyer la config redondante. - La réutilisation de cache restaure les side effects: en cas de hit cache, on ne retourne pas seulement le registry; on appelle aussi
restoreRegisteredAgentHarnesses/restorePluginCommands/restoreRegisteredEmbeddingProviderset plus d'une dizaine de restaurations globales (src/plugins/loader.ts:L1872-L1885). En oublier un rend les outils/commandes « invisibles ». - Séparation d'activation:
shouldActivatefalse ne vide pas l'état runtime précédent — c'est pour les chargements snapshot (mode validate / parallel discovery), pour ne pas effacer par erreur les commandes d'autres plugins. - Chargement différé des modules: si tous les plugins sont désactivés,
createPluginModuleLoadern'est jamais appelé (src/plugins/loader.ts:L1904-L1908) — scénario courant en tests unitaires, économisant du coût de démarrage. - Diagnostics préservés:
PluginDiscoveryResultetPluginRegistryportent tous deux un tableaudiagnostics: PluginDiagnostic[]; les warn/error produits en découverte/chargement sont véhiculés jusqu'au runtime.openclaw doctorlit ces diagnostics pour proposer des fixes. - Le plugin n'est pas l'outil: un plugin peut enregistrer des outils, mais le plugin lui-même est un pack.
PluginRegistry.toolsest un tableau d'outils; chaquePluginToolRegistrationréférence l'ID du plugin + la définition d'outil — ces outils finiront fusionnés dans la Map du système d'outils. De même, un plugin peut porter un champskills/SKILL.md(voir compétences), mais c'est un pack découvert par convention de répertoire, pas une compétence elle-même.
Résumé
Les plugins sont des packs d'extension découverts par convention de répertoire: .codex-plugin/plugin.json et autres sous-répertoires cachés déclarent les capacités; discovery scanne multi-racines + extraPaths pour trouver les candidats; loader normalise en PluginRegistry à buckets par capacité. Réutilisation de cache restaurant plus d'une dizaine d'effets globaux; séparation d'activation pour que les chargements snapshot ne vidangent pas l'état runtime précédent. Les trois formats de bundle permettent à OpenClaw d'absorber directement les plugins Codex/Claude/Cursor. Différence avec outils: les outils sont des fonctions dans une Map, les plugins sont l'une des sources qui produisent les outils; différence avec compétences: les compétences sont des guides Markdown, les plugins peuvent porter des compétences comme contenu. L'enregistrement des canaux et des méthodes RPC suit la ligne Registre des canaux.
Pour comparer avec la documentation officielle: Plugins docs · README.