Skip to content

Hooks : chaîne d'exécution des crochets utilisateur

源码版本v4.0.10

Responsabilités

Le système de hooks (crochets) de Cline permet à l'utilisateur d'insérer ses propres scripts shell à plusieurs points de vie du boucle principale de l'agent (avant la soumission du prompt, avant/après usage d'un outil, au début/fin d'une tâche, à l'envoi d'une notification, avant compression). Le script reçoit un JSON structuré sur stdin, et peut renvoyer un JSON via stdout pour piloter le comportement de Cline — notamment cancel: true pour bloquer une action imminente, et contextModification pour injecter du contexte additionnel dans la conversation. Le tout repose sur trois couches : HookExecutor sert d'entrée et de dispatch (executeHook:58), HookFactory + HookRunner gèrent la découverte et l'exécution du script concret (HookRunner exec:289), et HookProcess pilote le sous-processus sous-jacent (HookProcess:89).

Le système traverse tout le cycle de vie de Task. PreToolUse / PostToolUse sont appelés par ToolExecutor avant/après exécution d'un outil (PreToolUse executeHook:73). UserPromptSubmit / TaskStart / TaskResume / TaskCancel sont appelés par Task aux points de vie correspondants (UserPromptSubmit:1214). Notification est émis uniformément par le module NotificationHook (emitNotificationHook:50). La sémantique « bloquable » d'un hook ne s'applique qu'aux points annulables : un hook fire-and-forget comme Notification voit son cancel ignoré même s'il le renvoie.

Motivation de conception

  • 9 hooks standards : PreToolUse / PostToolUse / UserPromptSubmit / TaskStart / TaskResume / TaskCancel / TaskComplete / Notification / PreCompact (Hooks interface:102), couvrent tous les points de vie critiques.
  • JSON in / JSON out : le stdin reçu par le script hook est un JSON portant clineVersion / hookName / timestamp / workspaceRoots / userId et autres métadonnées (completeParams:198), la sortie est également du JSON.
  • Trois champs de retour : cancel bloque l'action, contextModification ajoute du contexte, errorMessage ajoute un message d'erreur (HookExecutionResult:33).
  • Flag cancellable : l'appelant décide si ce hook peut annuler l'action. Notification passe isCancellable: false (not cancellable:61), PreToolUse passe true.
  • Isolation des processus + registry : HookProcessRegistry suit tous les processus hook actifs, terminateAll les tue uniformément à l'annulation d'une tâche (HookProcessRegistry:17).
  • Lanceur multi-plateforme : getHookLaunchConfig distingue Windows PowerShell et Unix shebang (getHookLaunchConfig:44), de sorte que .sh / .ps1 / .js fonctionnent tous.
  • Écho streaming du stdout : HookProcess émet stdout/stderr ligne par ligne, et streamCallback ajoute un préfixe [source stream path] avant de les renvoyer à l'UI (streamCallback:124), pour qu'un hook long puisse montrer sa progression.
  • Repli sur l'extraction JSON : quand le script hook imprime du debug avant/après le JSON, on scanne à partir de la fin en appariant les accolades pour trouver le dernier objet JSON complet (JSON extraction:379).

Fichiers clés

Flux de données

Prenons PreToolUse comme exemple, c'est la chaîne « bloquable » la plus complète. ToolExecutor appelle d'abord ToolHookUtils.executePreToolUseHook avant d'exécuter réellement l'outil :

typescript
// apps/vscode/src/core/task/tools/utils/ToolHookUtils.ts
const { executeHook } = await import("@core/hooks/hook-executor")

const pendingToolInfo: any = { tool: block.name }
if (block.params.path) pendingToolInfo.path = block.params.path
if (block.params.command) pendingToolInfo.command = block.params.command
// ...

const preToolResult = await executeHook({
  hookName: "PreToolUse",
  hookInput: {
    preToolUse: { toolName: block.name, parameters: block.params },
  },
  isCancellable: true,
  say: config.callbacks.say,
  setActiveHookExecution: config.callbacks.setActiveHookExecution,
  clearActiveHookExecution: config.callbacks.clearActiveHookExecution,
  messageStateHandler: config.messageState,
  taskId: config.taskId,
  hooksEnabled,
  model: getHookModelContext(config.api, config.services.stateManager),
  toolName: block.name,
  pendingToolInfo,
})

if (preToolResult.cancel === true) {
  await config.callbacks.clearActiveHookExecution()
  await config.callbacks.cancelTask()
  throw new PreToolUseHookCancellationError(preToolResult.errorMessage || "PreToolUse hook requested cancellation")
}

if (preToolResult.contextModification) {
  ToolHookUtils.addHookContextToConversation(config, preToolResult.contextModification, "PreToolUse")
}

Ce bloc se trouve près de PreToolUse caller:73. Une fois entré dans executeHook, on regarde d'abord hooksEnabled (early return:72) : si désactivé, on retourne directement. Puis HookFactory.hasHook vérifie si un script est configuré pour ce nom de hook (hasHook:80) ; si non, retour direct également. Si oui, on crée HookProcess et on spawn le sous-processus :

typescript
// apps/vscode/src/core/hooks/HookProcess.ts
this.childProcess = spawn(launchConfig.command, launchConfig.args, {
  stdio: ["pipe", "pipe", "pipe"],
  shell: launchConfig.shell,
  detached: launchConfig.detached,
  cwd: this.cwd,
  windowsHide: true,
})

this.timeoutHandle = setTimeout(() => {
  if (this.childProcess && !this.isCompleted) {
    this.childProcess.kill("SIGTERM")
    reject(new Error(`Hook execution timed out after ${this.timeoutMs}ms.`))
  }
}, this.timeoutMs)

this.childProcess.stdout?.on("data", (data) => {
  this.stdoutBuffer += output
  this.handleOutput(output, didEmitEmptyLine, "stdout")
})
// ...
this.childProcess.on("close", (code, signal) => {
  this.exitCode = code
  // resolve / reject
})

this.childProcess.stdin?.write(inputJson)
this.childProcess.stdin?.end()

Ce bloc se trouve près de spawn child:176. Une fois le sous-processus terminé, HookRunner.exec passe le stdout à parseJsonOutput (parseJsonOutput:349). S'il y a un JSON valide, on suit le JSON (même si exitCode n'est pas 0, le JSON prime — JSON priority:454) ; sinon, on se rabat sur exitCode. cancel: true remonte jusqu'à HookExecutor.executeHook, et ToolHookUtils à la réception lève PreToolUseHookCancellationError et appelle cancelTask.

Limites et échecs

  • PreToolUse cancel aborte toute la tâche : pas seulement ignorer l'outil, mais appeler cancelTask pour stopper toute la boucle d'agent (cancelTask on cancel:100).
  • PostToolUse cancel sans abort de tâche : runPostToolUseHook à la réception d'un cancel se contente de say error et renvoie true. L'outil a déjà été exécuté et n'est pas annulable (post cancel soft:494).
  • Hook Notification ignore cancel et contextModification : ce hook est une notification unidirectionnelle, les champs de sortie sont explicitement ignorés (ignore unsupported output:70).
  • abort signal reject immédiat : HookProcess écoute abortSignal. Dès l'abort, il tue le sous-processus et reject, sans attendre close (abortHandler:139).
  • Timeout signalé par chemin de script : le message d'erreur de timeout contient this.scriptPath et la durée en ms, pour aider à localiser quel hook est resté bloqué (timeout error:192).
  • Limite de sortie 1 Mo : MAX_HOOK_OUTPUT_SIZE intercepte les sorties infinies. Au-delà, on émet une notification de troncation (output truncation:311).
  • Troncation de contextModification : MAX_CONTEXT_MODIFICATION_SIZE empêche un hook d'injecter un contexte démesuré, au-delà on tronque avec notification (context truncation:363).
  • PreToolUse saute attempt_completion : attempt_completion est l'outil de fin, il ne passe pas par le blocage PreToolUse (skip attempt_completion:30).
  • Hook NoOp renvoie les defaults proto : un hook sans script trouvé renvoie cancel: false / contextModification: "" / errorMessage: "". L'executor le considère comme un no-op normal (NoOp runner:238).
  • Extraction JSON scannée de droite à gauche : quand le script hook imprime du debug devant le JSON et que le premier JSON.parse échoue, parseJsonOutput appareille les accolades pour trouver le dernier objet complet (brace scan:385). Ce repli est crucial.

Résumé

Le système de hooks est l'extension que Cline laisse à l'utilisateur pour « insérer un script aux points clés de la boucle d'agent ». Pour voir à quels moments Task déclenche un hook, lire agent-loop/task-class ; pour voir comment l'exécuteur d'outils enchaîne PreToolUse / PostToolUse, lire cap-tools ; pour voir comment StateManager fournit à un hook les métadonnées comme workspaceRoots, lire state-manager.

Voir la documentation officielle : documentation Cline · README.