Aller au contenu

Hooks

Ce document décrit le code actuel du sous-système de hooks dans src/extensibility/hooks/*.

Le paquet de hooks (src/extensibility/hooks/) est toujours exporté et utilisable comme surface d’API, mais le runtime CLI par défaut initialise désormais le chemin du runner d’extension. Dans le flux de démarrage actuel :

  • --hook est traité comme un alias de --extension (les chemins CLI sont fusionnés dans additionalExtensionPaths)
  • les outils sont encapsulés par ExtensionToolWrapper, et non par HookToolWrapper
  • les transformations de contexte et les émissions de cycle de vie passent par ExtensionRunner

Ce fichier documente donc l’implémentation du sous-système de hooks lui-même (types/loader/runner/wrapper), y compris le comportement et les contraintes hérités.

  • src/extensibility/hooks/types.ts — contexte de hook, types d’événements et contrats de résultat
  • src/extensibility/hooks/loader.ts — chargement de module et pont de découverte de hooks
  • src/extensibility/hooks/runner.ts — distribution d’événements, recherche de commandes et signalisation d’erreurs
  • src/extensibility/hooks/tool-wrapper.ts — wrapper d’interception d’outils pré/post
  • src/extensibility/hooks/index.ts — exports/réexports

Un module hook doit exporter par défaut une factory :

import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function hook(pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && String(event.input.command ?? "").includes("rm -rf")) {
return { block: true, reason: "blocked by policy" };
}
});
}

La factory peut :

  • enregistrer des gestionnaires d’événements avec pi.on(...)
  • envoyer des messages personnalisés persistants avec pi.sendMessage(...)
  • conserver un état non-LLM avec pi.appendEntry(...)
  • enregistrer des commandes slash via pi.registerCommand(...)
  • enregistrer des rendus de messages personnalisés via pi.registerMessageRenderer(...)
  • exécuter des commandes shell via pi.exec(...)

discoverAndLoadHooks(configuredPaths, cwd) effectue les opérations suivantes :

  1. Charger les hooks découverts depuis le registre de capacités (loadCapability("hooks"))
  2. Ajouter les chemins configurés explicitement (dédupliqués par chemin absolu)
  3. Appeler loadHooks(allPaths, cwd)

loadHooks importe ensuite chaque chemin et attend une fonction default.

loader.ts résout les chemins de hooks comme suit :

  • chemin absolu : utilisé tel quel
  • chemin ~ : développé
  • chemin relatif : résolu par rapport à cwd

Les fournisseurs de découverte pour hookCapability modélisent encore des fichiers de hooks shell de style pré/post (par exemple .claude/hooks/pre/*, .xcsh/.../hooks/pre/*).

Le chargeur de hooks ici utilise l’import de module dynamique et nécessite une factory de hook JS/TS par défaut. Si un chemin de hook découvert n’est pas importable en tant que module, le chargement échoue et est signalé dans LoadHooksResult.errors.

Les événements de hook sont fortement typés dans types.ts.

  • session_start
  • session_before_switch → peut retourner { cancel?: boolean }
  • session_switch
  • session_before_branch → peut retourner { cancel?: boolean; skipConversationRestore?: boolean }
  • session_branch
  • session_before_compact → peut retourner { cancel?: boolean; compaction?: CompactionResult }
  • session.compacting → peut retourner { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }
  • session_compact
  • session_before_tree → peut retourner { cancel?: boolean; summary?: { summary: string; details?: unknown } }
  • session_tree
  • session_shutdown
  • context → peut retourner { messages?: Message[] }
  • before_agent_start → peut retourner { message?: { customType; content; display; details } }
  • agent_start
  • agent_end
  • turn_start
  • turn_end
  • auto_compaction_start
  • auto_compaction_end
  • auto_retry_start
  • auto_retry_end
  • ttsr_triggered
  • todo_reminder
  • tool_call (pré-exécution) → peut retourner { block?: boolean; reason?: string }
  • tool_result (post-exécution) → peut retourner { content?; details?; isError? }

Il s’agit du modèle d’interception pré/post au cœur du sous-système de hooks.

Flux d'interception des outils par les hooks
Gestionnaires tool_call
├─ any { block: true }? ── oui ──> throw (outil bloqué)
└─ non
exécution de l'outil sous-jacent
├─ succès ──> les gestionnaires tool_result peuvent remplacer { content, details }
└─ erreur ──> émettre tool_result(isError=true) puis relancer l'erreur d'origine

HookToolWrapper.execute() émet tool_call avant l’exécution de l’outil.

  • si un gestionnaire retourne { block: true }, l’exécution s’arrête
  • si un gestionnaire lève une exception, le wrapper échoue de façon sécurisée et bloque l’exécution
  • le reason retourné devient le texte de l’erreur levée

L’outil sous-jacent s’exécute normalement s’il n’est pas bloqué.

Après un succès, le wrapper émet tool_result avec :

  • toolName, toolCallId, input
  • content
  • details
  • isError: false

Si un gestionnaire retourne des substitutions :

  • content peut remplacer le contenu du résultat
  • details peut remplacer les détails du résultat

En cas d’échec de l’outil, le wrapper émet tool_result avec isError: true et le texte d’erreur en contenu, puis relance l’erreur d’origine.

  • le contexte LLM pour un seul appel via context (chaîne de remplacement de messages)
  • le contenu/détails de sortie d’outil lors d’appels d’outils réussis (chemin tool_result)
  • le message injecté avant l’agent via before_agent_start
  • le comportement d’annulation/compaction personnalisée/arbre via session_before_* et session.compacting

Ce que les hooks ne peuvent pas muter dans cette implémentation

Section intitulée « Ce que les hooks ne peuvent pas muter dans cette implémentation »
  • les paramètres d’entrée bruts de l’outil en place (uniquement bloquer/autoriser sur tool_call)
  • la continuation de l’exécution après des erreurs d’outil levées (le chemin d’erreur relance)
  • le statut final de succès/erreur dans le comportement du wrapper (le isError retourné est typé mais non appliqué par HookToolWrapper)

Les fournisseurs de capacités sont triés par priorité (les plus élevées en premier). La déduplication se fait par clé de capacité, le premier l’emporte.

Pour hooks, la clé de capacité est ${type}:${tool}:${name}. Les doublons masqués provenant de fournisseurs de priorité inférieure sont marqués et exclus de la liste découverte effective.

discoverAndLoadHooks construit une liste plate allPaths, dédupliquée par chemin absolu résolu, puis loadHooks itère dans cet ordre. L’ordre des fichiers dans chaque répertoire découvert dépend de la sortie de readdir ; le chargeur de hooks n’effectue pas de tri supplémentaire.

Dans HookRunner, l’ordre est déterministe par séquence d’enregistrement :

  1. ordre du tableau des hooks
  2. ordre d’enregistrement des gestionnaires par hook/événement

Comportement en cas de conflit par type d’événement :

  • tool_call : le dernier résultat retourné l’emporte sauf si un gestionnaire bloque ; le premier blocage court-circuite
  • tool_result : la dernière substitution retournée l’emporte (pas de court-circuit)
  • context : chaîné ; chaque gestionnaire reçoit la sortie de messages du gestionnaire précédent
  • before_agent_start : le premier message retourné est conservé ; les messages ultérieurs sont ignorés
  • session_before_* : le dernier résultat retourné est suivi ; cancel: true court-circuite immédiatement
  • session.compacting : le dernier résultat retourné l’emporte

Conflits de commandes/rendus :

  • getCommand(name) retourne la première correspondance parmi les hooks (le premier chargé l’emporte)
  • getMessageRenderer(customType) retourne la première correspondance
  • getRegisteredCommands() retourne toutes les commandes (sans déduplication)

HookUIContext inclut :

  • select, confirm, input, editor
  • notify
  • setStatus
  • custom
  • setEditorText, getEditorText
  • getter theme

ctx.hasUI indique si une interface utilisateur interactive est disponible.

Lors d’une exécution sans UI, le comportement par défaut du contexte sans opération est :

  • select/input/editor retournent undefined
  • confirm retourne false
  • notify, setStatus, setEditorText sont des no-ops
  • getEditorText retourne ""

Le texte de statut de hook défini via ctx.ui.setStatus(key, text) est :

  • stocké par clé
  • trié par nom de clé
  • assaini (\r, \n, \t → espaces ; espaces répétés réduits)
  • joint et tronqué en largeur pour l’affichage
  • module invalide ou export par défaut manquant → capturé dans LoadHooksResult.errors
  • le chargement continue pour les autres hooks

HookRunner.emit(...) capture les erreurs de gestionnaire pour la plupart des événements et émet HookError aux écouteurs (hookPath, event, error), puis continue.

emitToolCall(...) est plus strict : les erreurs de gestionnaire n’y sont pas absorbées ; elles se propagent à l’appelant. Dans HookToolWrapper, cela bloque l’appel d’outil (sécurité par défaut).

import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function (pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd = String(event.input.command ?? "");
if (!cmd.includes("rm -rf")) return;
if (!ctx.hasUI) return { block: true, reason: "rm -rf blocked (no UI)" };
const ok = await ctx.ui.confirm("Dangerous command", `Allow: ${cmd}`);
if (!ok) return { block: true, reason: "user denied command" };
});
}
import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function (pi: HookAPI): void {
pi.on("tool_result", async event => {
if (event.toolName !== "read" || event.isError) return;
const redacted = event.content.map(chunk => {
if (chunk.type !== "text") return chunk;
return { ...chunk, text: chunk.text.replaceAll(/API_KEY=\S+/g, "API_KEY=[REDACTED]") };
});
return { content: redacted };
});
}
import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function (pi: HookAPI): void {
pi.on("context", async event => {
const filtered = event.messages.filter(msg => !(msg.role === "custom" && msg.customType === "debug-only"));
return { messages: filtered };
});
}

Enregistrer une commande slash avec des méthodes de contexte sécurisées pour les commandes

Section intitulée « Enregistrer une commande slash avec des méthodes de contexte sécurisées pour les commandes »
import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function (pi: HookAPI): void {
pi.registerCommand("handoff", {
description: "Create a new session with setup message",
handler: async (_args, ctx) => {
await ctx.waitForIdle();
await ctx.newSession({
parentSession: ctx.sessionManager.getSessionFile(),
setup: async sm => {
sm.appendMessage({
role: "user",
content: [{ type: "text", text: "Continue from prior session summary." }],
timestamp: Date.now(),
});
},
});
},
});
}

src/extensibility/hooks/index.ts exporte :

  • les API de chargement (discoverAndLoadHooks, loadHooks)
  • le runner et le wrapper (HookRunner, HookToolWrapper)
  • tous les types de hooks
  • réexport de execCommand

Et la racine du paquet (src/index.ts) réexporte les types de hooks comme surface de compatibilité héritée.