Entenda a cadeia de plugins
O Rafa tem três mods ligados, e os três penduram hook em tool.call. Quando o Claude pede um Edit, os três rodam. Não lado a lado: um dentro do outro, como camadas de cebola.
O evento entra pela camada de fora. Cada hook chama next(e) para entrar na camada seguinte. No miolo está o comportamento do próprio Claude Code. O resultado volta pelo mesmo caminho, de dentro para fora, e cada hook pode olhar para ele na volta.
🆕 Novo aqui? Três palavras da cadeia
- Cadeia — todos os hooks do mesmo evento, de todos os plugins, mais o comportamento do Claude Code no fim, ligados por
next. - Dispatch — uma passagem de um evento pela cadeia. Cada
Edité um dispatch detool.call. - Camada (tier) — os tipos dividem a cadeia em cinco:
prepend,user,append,builtin,core. Os seus mods ficam emuser.
Como ler o desenho: quanto mais de fora, mais autoridade. Um plugin gerenciado pela empresa (prepend) vê o evento antes do seu mod e pode encerrar a cadeia ali. O seu mod vê o evento antes dos plugins de fábrica e do engine. Na volta, a ordem se inverte.
💡 Não dependa da ordem entre dois mods seus
Os dois ficam na mesma camada user. Escreva cada mod como se fosse o único: ele recebe um e, chama next e cuida do próprio resultado. O exemplo de quem convive bem na mesma tela é a faixa, no módulo 3.2.
Observe e passe adiante
A maioria dos hooks só observa. Anota, conta, desenha, e devolve o que veio de baixo sem mexer. Existem dois jeitos, e a diferença é quando o hook olha.
Antes do next, ele vê a intenção: o Claude quer editar este arquivo. Depois do await next(e), ele vê o que aconteceu: editou, falhou ou foi negado. Os tipos chamam isso de escrever o hook "em pós-ordem".
// plugin-authoring/examples/band.tsx — olha ANTES e passa
on('prompt.submit', async ($, e, next) => {
startedAt = await $.clock.now()
tools = 0
return next(e)
})
// inema-mods/mods/recibo-sessao/hooks/register.tsx — olha DEPOIS
on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
const ran = await next(e)
if (ran.deny === undefined && ran.isError !== true) await registrar($, e.file_path, 'editado')
return ran
})
deny, sem isError). Por isso precisa esperar o next. E devolve ran sem tocar: quem está acima recebe o resultado intacto.✓ Observador bem-educado
- ✓ Sempre devolve o que
nextdevolveu - ✓ Olha depois do
nextquando precisa do resultado - ✓ Trabalho rápido: anotar no estado, não processar o mundo
✗ Observador que atrapalha
- ✗ Esquece de devolver o resultado do
next - ✗ Conta antes do
nexte soma as edições negadas - ✗ Faz trabalho lento antes do
nexte atrasa a ferramenta
🆕 Novo aqui? Resultado do next
Cada evento tem o seu. No tool.call vem a resposta da ferramenta, com isError e, se alguém negou, deny. No turn.complete vem { text }. No session.start vem { cwd }. O comentário do evento nos tipos diz qual é (módulo 2.1, tópico 6).
Reescreva o que vem depois
O e que você recebe é congelado: não dá para mudar um campo dele. Para mudar o que as camadas de baixo veem, você chama next com uma cópia alterada: next({ ...e, campo: novo }).
Mas só dentro do que o evento permite. Cada evento diz nos tipos o que pode ser reescrito. Mudar um campo travado faz o hook falhar e ser pulado. Mudar o retorno de um evento que só observa não muda nada.
on('agent.spawn', async ($, e, next) => {
// Respeita o que foi pedido: modelo explícito, fork (sempre herda) e teammate.
if (e.model !== undefined || e.fork || e.isTeammate === true) return next(e)
const alvo = destino(await read($, modo), mapa, e.subagentType)
if (alvo === undefined) return next(e)
const r = await next({ ...e, model: alvo })
const id = r.agentId
if (id !== undefined) await update($, roteados, x => ({ ...x, [id]: alvo }))
return r
})
e intacto. Reescrever o que a pessoa pediu de forma explícita seria passar por cima dela.| Evento | O que dá para reescrever | Efeito |
|---|---|---|
prompt.submit | next({ ...e, text }) | o modelo e a tela recebem o texto novo |
agent.spawn | next({ ...e, model }) | o subagente roda com outro modelo |
ui.render | next({ ...e, props }) | o engine desenha com outras props |
session.append | só o content da mensagem | mudar outro campo faz o hook falhar |
turn.start | nada | só observa; outro retorno não muda nada |
O que olhar na tabela: a última linha é tão importante quanto as outras. Antes de reescrever, confira nos tipos se o evento aceita. Se o comentário diz "Observe", ele não aceita.
✓ Reescrita respeitosa
- ✓ muda só o que ninguém pediu de forma explícita, como o roteador
- ✓ confere nos tipos se o campo pode mudar
- ✓ avisa na tela quando muda algo que a pessoa vê
✗ Reescrita que atropela
- ✗ troca o modelo que a pessoa escolheu
- ✗ mexe em campo travado do
session.append - ✗ espera efeito ao devolver outra coisa no
turn.start
⚠️ Reescrever o prompt é poderoso e invisível
Um prompt.submit que muda o texto altera o que o modelo lê sem a pessoa perceber. Faça isso só quando o mod disser claramente o que faz, e de preferência com um aviso na tela.
Responda sem chamar next
Um hook que devolve algo sem chamar next responde no lugar de todo o resto. As camadas de baixo não rodam: nem os outros mods, nem o engine. No tool.call, isso inclui o pedido de permissão e a ferramenta.
É assim que um mod barra uma ação. O guarda-colisao do kit INEMA decide antes de qualquer coisa. Se decidiu barrar, devolve a recusa e acabou.
on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
const barrar = await decidir($, e.file_path, cfg)
if (barrar) return barrar // { deny: '...' } e nada abaixo roda
const ran = await next(e)
if (ran.deny === undefined && ran.isError !== true) await marcar($, e.file_path, cfg)
return ran
})
decidir é uma função declarada no topo do arquivo, porque recebe $ (pegadinha 1 do kit INEMA). Ela devolve { deny } ou undefined.| Evento | Resposta sem next | O que acontece |
|---|---|---|
tool.call | { deny: motivo } | a ferramenta não roda; o modelo lê o motivo |
tool.call | { result } | o mod responde pela ferramenta (sem pedir permissão) |
prompt.submit | { drop: motivo } | o prompt não vira turno |
command.run | { text } | o comando do mod responde, sem modelo |
⚠️ Responder com { result } pula a permissão
O pedido de permissão mora no core, que é a camada de dentro. Quem responde sem next nunca chega lá. Para ferramentas suas isso é o normal (módulo 3.4). Para ferramentas do Claude, como Bash, é atalho perigoso: prefira negar com motivo. Outro detalhe dos tipos: um hook que devolve enquanto o seu next ainda está pendente aborta o que roda abaixo.
recusa a ferramenta
responde por ela
descarta o prompt
responde o comando
Use .catch para falhar com segurança
Hook que lança erro, devolve o que o evento não aceita ou passa do tempo é pulado. O engine chama next(e) por ele e a cadeia segue como se ele não existisse. Para um contador, ótimo. Para uma guarda, péssimo: falhar vira deixar passar.
O conserto é o .catch na própria registração: on(...).catch(handler). Se o hook falhar, o handler roda no lugar dele, com a mesma forma ($, e, next), e a resposta dele vale como a do hook.
plugin-authoring/reference.md)on("session.append", ($, e, next) => next({ ...e, message: redact(e.message) }))
.catch(($, e, next) => next({ ...e, message: placeholderFor(e.message) }))
.catch não deixa a mensagem original passar: troca por um texto neutro. Falha segura, não falha aberta.import type { EngineInterface, Register } from 'claude-code'
// recebe $, então fica no topo do arquivo (pegadinha 1)
async function decidir($: EngineInterface, caminho: string) {
const fora = !caminho.startsWith(await $.session.cwd())
return fora ? { deny: 'Fora da pasta do projeto.' } : undefined
}
export const register: Register = (on, options) => {
on('tool.call', { tool: 'Write' }, async ($, e, next) => {
const barrar = await decidir($, e.file_path)
return barrar ?? next(e)
}).catch(($, e, next) => ({ deny: 'A guarda falhou; escrita barrada por segurança.' }))
}
.catch, um erro em decidir faria o engine chamar next(e) e a escrita passaria. Com ele, a falha vira recusa com motivo. A comparação por texto do caminho é simplificada aqui; a forma robusta, com realPath, está no módulo 2.4.O hook falha
Lançou erro (throw) ou passou do orçamento (timeout).
O handler roda do zero
next.error diz o motivo; next.called diz se o hook já tinha chamado next. Aqui o next é seguro: se já foi chamado, devolve o mesmo resultado sem rodar nada de novo.
Ele tem 1 segundo
O prazo do handler é de 1.000 ms. Devolveu a tempo, vale. Devolveu undefined ou estourou, o hook conta como ausente.
💡 Três regras do .catch
Um por registração (um segundo .catch lança erro). Só dentro do register (depois que ele retorna, também lança). Em evento de stream, o handler também é um gerador. O kit INEMA usa isso em todo mod que pergunta: sem tela para perguntar, o padrão é seguro e explícito, nunca seguir calado.
Respeite o orçamento e o next.signal
Cada hook tem 10 segundos do próprio tempo por dispatch. O relógio para enquanto ele espera um next ou uma chamada ao $. Uma cadeia lenta abaixo, ou uma chamada longa ao engine, não gastam o orçamento do seu hook.
Com uma exceção: as esperas do relógio. $.clock.sleep conta. Um hook que dorme em laço para "esperar alguma coisa" paga cada soneca do mesmo orçamento. O tempo que resta está em next.budget.remainingMs.
Como ler o desenho: some só os trechos azuis. Esperar o next ou o engine é de graça. Dormir com o relógio não é. Quem chega na linha vermelha perde a vez: o .catch é chamado, se houver, ou o engine segue sem o hook.
O next.signal é um AbortSignal que dispara quando o dispatch é abandonado: a pessoa interrompeu, um hook acima já respondeu, ou o orçamento acabou. Passe esse sinal adiante, para a espera acabar junto.
$.clock.sleep nos tiposawait $.clock.sleep(500, { signal: next.signal })
Teste rápido (opcional): o hook do Rafa em tool.call devolve { deny: 'não' } sem chamar next. O que acontece com o pedido de permissão e com os mods abaixo dele?
do próprio tempo
para o .catch
depois do signal
pare junto
🎓 Resumo do módulo
Próximo módulo:
2.3 — Estado que sobrevive