Ir al contenido

Hooks

Este documento describe el código actual del subsistema de hooks en src/extensibility/hooks/*.

El paquete de hooks (src/extensibility/hooks/) sigue exportándose y siendo utilizable como superficie de API, pero el tiempo de ejecución predeterminado de la CLI ahora inicializa la ruta del ejecutor de extensiones. En el flujo de inicio actual:

  • --hook se trata como un alias de --extension (las rutas de CLI se fusionan en additionalExtensionPaths)
  • las herramientas están envueltas por ExtensionToolWrapper, no por HookToolWrapper
  • las transformaciones de contexto y las emisiones de ciclo de vida pasan por ExtensionRunner

Por lo tanto, este archivo documenta la implementación del subsistema de hooks en sí (tipos/cargador/ejecutor/envoltorio), incluyendo el comportamiento heredado y las restricciones.

  • src/extensibility/hooks/types.ts — contexto de hook, tipos de evento y contratos de resultado
  • src/extensibility/hooks/loader.ts — carga de módulos y puente de descubrimiento de hooks
  • src/extensibility/hooks/runner.ts — despacho de eventos, búsqueda de comandos y señalización de errores
  • src/extensibility/hooks/tool-wrapper.ts — envoltorio de intercepción previo/posterior de herramientas
  • src/extensibility/hooks/index.ts — exportaciones/reexportaciones

Un módulo hook debe exportar por defecto una fábrica:

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 fábrica puede:

  • registrar manejadores de eventos con pi.on(...)
  • enviar mensajes personalizados persistentes con pi.sendMessage(...)
  • persistir estado no-LLM con pi.appendEntry(...)
  • registrar comandos slash mediante pi.registerCommand(...)
  • registrar renderizadores de mensajes personalizados mediante pi.registerMessageRenderer(...)
  • ejecutar comandos de shell mediante pi.exec(...)

discoverAndLoadHooks(configuredPaths, cwd) realiza lo siguiente:

  1. Carga los hooks descubiertos desde el registro de capacidades (loadCapability("hooks"))
  2. Añade las rutas configuradas explícitamente (deduplicadas por ruta absoluta)
  3. Llama a loadHooks(allPaths, cwd)

loadHooks luego importa cada ruta y espera una función default.

loader.ts resuelve las rutas de hooks de la siguiente manera:

  • ruta absoluta: se usa tal cual
  • ruta ~: expandida
  • ruta relativa: resuelta contra cwd

Los proveedores de descubrimiento para hookCapability siguen modelando archivos de hook de estilo shell pre/post (por ejemplo, .claude/hooks/pre/*, .xcsh/.../hooks/pre/*).

El cargador de hooks aquí usa importación dinámica de módulos y requiere una fábrica de hooks JS/TS por defecto. Si una ruta de hook descubierta no es importable como módulo, la carga falla y se reporta en LoadHooksResult.errors.

Los eventos de hook están fuertemente tipados en types.ts.

  • session_start
  • session_before_switch → puede retornar { cancel?: boolean }
  • session_switch
  • session_before_branch → puede retornar { cancel?: boolean; skipConversationRestore?: boolean }
  • session_branch
  • session_before_compact → puede retornar { cancel?: boolean; compaction?: CompactionResult }
  • session.compacting → puede retornar { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }
  • session_compact
  • session_before_tree → puede retornar { cancel?: boolean; summary?: { summary: string; details?: unknown } }
  • session_tree
  • session_shutdown
  • context → puede retornar { messages?: Message[] }
  • before_agent_start → puede retornar { 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

Eventos de herramienta (modelo previo/posterior)

Sección titulada «Eventos de herramienta (modelo previo/posterior)»
  • tool_call (pre-ejecución) → puede retornar { block?: boolean; reason?: string }
  • tool_result (post-ejecución) → puede retornar { content?; details?; isError? }

Este es el modelo de intercepción previo/posterior central del subsistema de hooks.

Hook tool interception flow
tool_call handlers
├─ any { block: true }? ── yes ──> throw (tool blocked)
└─ no
execute underlying tool
├─ success ──> tool_result handlers can override { content, details }
└─ error ──> emit tool_result(isError=true) then rethrow original error

Modelo de ejecución y semántica de mutación

Sección titulada «Modelo de ejecución y semántica de mutación»

HookToolWrapper.execute() emite tool_call antes de la ejecución de la herramienta.

  • si algún manejador retorna { block: true }, la ejecución se detiene
  • si el manejador lanza una excepción, el envoltorio falla de forma cerrada y bloquea la ejecución
  • el reason retornado se convierte en el texto del error lanzado

La herramienta subyacente se ejecuta normalmente si no está bloqueada.

Tras el éxito, el envoltorio emite tool_result con:

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

Si el manejador retorna sobreescrituras:

  • content puede reemplazar el contenido del resultado
  • details puede reemplazar los detalles del resultado

En caso de fallo de la herramienta, el envoltorio emite tool_result con isError: true y el contenido del texto de error, luego relanza el error original.

  • el contexto LLM para una sola llamada mediante context (cadena de reemplazo de messages)
  • el contenido/detalles de la salida de la herramienta en llamadas exitosas (ruta tool_result)
  • el mensaje inyectado pre-agente mediante before_agent_start
  • el comportamiento de cancelación/compactación personalizada/árbol mediante session_before_* y session.compacting

Qué no pueden mutar los hooks en esta implementación

Sección titulada «Qué no pueden mutar los hooks en esta implementación»
  • los parámetros de entrada de la herramienta en su lugar (solo bloquear/permitir en tool_call)
  • la continuación de la ejecución tras errores lanzados por la herramienta (la ruta de error relanza)
  • el estado final de éxito/error en el comportamiento del envoltorio (el isError retornado está tipado pero no es aplicado por HookToolWrapper)

Los proveedores de capacidades se ordenan por prioridad (mayor primero). La deduplicación es por clave de capacidad, gana el primero.

Para hooks, la clave de capacidad es ${type}:${tool}:${name}. Los duplicados sombreados de proveedores de menor prioridad se marcan y se excluyen de la lista descubierta efectiva.

discoverAndLoadHooks construye una lista plana allPaths, deduplicada por ruta absoluta resuelta, luego loadHooks itera en ese orden. El orden de los archivos dentro de cada directorio descubierto depende de la salida de readdir; el cargador de hooks no realiza una ordenación adicional.

Orden de manejadores en tiempo de ejecución

Sección titulada «Orden de manejadores en tiempo de ejecución»

Dentro de HookRunner, el orden es determinista por secuencia de registro:

  1. orden del arreglo de hooks
  2. orden de registro de manejadores por hook/evento

Comportamiento de conflictos por tipo de evento:

  • tool_call: gana el último resultado retornado, a menos que un manejador bloquee; el primer bloqueo produce un cortocircuito
  • tool_result: gana la última sobreescritura retornada (sin cortocircuito)
  • context: encadenado; cada manejador recibe la salida de mensajes del manejador anterior
  • before_agent_start: se conserva el primer mensaje retornado; los mensajes posteriores se ignoran
  • session_before_*: se rastrea el último resultado retornado; cancel: true produce un cortocircuito inmediato
  • session.compacting: gana el último resultado retornado

Conflictos de comandos/renderizadores:

  • getCommand(name) retorna la primera coincidencia entre hooks (gana el primero cargado)
  • getMessageRenderer(customType) retorna la primera coincidencia
  • getRegisteredCommands() retorna todos los comandos (sin deduplicación)

HookUIContext incluye:

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

ctx.hasUI indica si la UI interactiva está disponible.

Cuando se ejecuta sin UI, el comportamiento predeterminado del contexto sin operación es:

  • select/input/editor retornan undefined
  • confirm retorna false
  • notify, setStatus, setEditorText son operaciones sin efecto
  • getEditorText retorna ""

El texto de estado del hook establecido mediante ctx.ui.setStatus(key, text):

  • se almacena por clave
  • se ordena por nombre de clave
  • se sanea (\r, \n, \t → espacios; espacios repetidos colapsados)
  • se une y se trunca por ancho para la visualización
  • módulo inválido o exportación por defecto faltante → capturado en LoadHooksResult.errors
  • la carga continúa para otros hooks

HookRunner.emit(...) captura los errores de los manejadores para la mayoría de los eventos y emite HookError a los escuchadores (hookPath, event, error), luego continúa.

emitToolCall(...) es más estricto: los errores de los manejadores no se absorben allí; se propagan al llamador. En HookToolWrapper, esto bloquea la llamada a la herramienta (a prueba de fallos).

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" };
});
}

Redactar la salida de la herramienta en post-ejecución

Sección titulada «Redactar la salida de la herramienta en post-ejecución»
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 };
});
}

Modificar el contexto del modelo por llamada LLM

Sección titulada «Modificar el contexto del modelo por llamada LLM»
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 };
});
}

Registrar un comando slash con métodos de contexto seguros para comandos

Sección titulada «Registrar un comando slash con métodos de contexto seguros para comandos»
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 exporta:

  • APIs de carga (discoverAndLoadHooks, loadHooks)
  • ejecutor y envoltorio (HookRunner, HookToolWrapper)
  • todos los tipos de hooks
  • reexportación de execCommand

Y la raíz del paquete (src/index.ts) reexporta los tipos de hook como superficie de compatibilidad heredada.