inema-mods. A API está em acesso antecipado: confira cada nome no .claude-plugin/types/claude-code/index.d.ts da sua instalação.
Intercepte uma chamada de ferramenta
Todo mod que muda o fluxo começa no mesmo lugar: o evento tool.call. Ele dispara quando o Claude Code está prestes a rodar uma ferramenta (Bash, Edit, Write, Read, uma ferramenta MCP).
Os tipos dizem o que acontece por baixo: next(e) roda os hooks dos outros plugins e depois o núcleo, que é o pedido de permissão e a própria ferramenta. O seu hook fica antes de tudo isso, com três saídas possíveis.
Como ler o desenho: o hook âmbar está antes da permissão. Para cima, { deny } encerra a chamada. Para a direita, next(e) segue o caminho de sempre. Para baixo, { result } responde no lugar da ferramenta: é o único caminho que pula o pedido de permissão, por isso fica pontilhado.
🆕 Novo aqui? As respostas de um tool.call
{ deny: motivo }— recusa a chamada. O modelo recebe o texto como resultado de erro.{ result }— responde sem rodar a ferramenta. É como um mod serve a própria ferramenta MCP (módulo 3.4).next(e)/next({ ...e, command })— deixa seguir, igual ou reescrito.
import type { Register } from 'claude-code'
const PROTECTED = /(^|\/)\.env(\.|$)/
export const register: Register = on => {
on('tool.call', { tool: 'Edit' }, ($, e, next) =>
PROTECTED.test(e.file_path)
? { deny: `${$.plugin.name}: ${e.file_path} is protected here.` }
: next(e),
)
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
const ran = await next({ ...e, command: e.command.trim() })
const hasFailed = ran.deny === undefined && ran.isError === true
$.ui.status(hasFailed ? `failed: ${e.command.slice(0, 40)}` : undefined)
return ran
})
}
{ tool: 'Edit' } estreita o e: dentro do hook, e.file_path já tem tipo. O segundo hook reescreve o comando (tira espaços) e só depois olha o resultado.✓ Bom uso do matcher
- ✓
{ tool: 'Bash' }para analisar comandos - ✓ Um hook por ferramenta de edição (Edit, Write, NotebookEdit)
- ✓ Sem matcher só quando a regra vale para todas (ex.: ferramentas MCP pagas)
✗ Armadilhas
- ✗ Hook sem matcher que trata todo
ecomo Bash - ✗ Devolver antes do
nextterminar: aborta o que roda por baixo - ✗ Contar sucesso antes de
await next(e)
Negue com motivo claro
O texto do deny não é para você: é para o modelo. Ele lê a recusa como resultado de erro da ferramenta e decide o próximo passo com base nela.
Um "negado" seco faz o modelo tentar de novo por outro caminho: find -delete no lugar de rm, um script Python que apaga. Um bom motivo diz quem barrou, o que teria acontecido e o que fazer agora.
| Parte do motivo | Para quê | No freio-de-mao |
|---|---|---|
| Quem barrou | o modelo e a pessoa sabem que foi um mod, não um erro | freio-de-mao: no começo |
| O que aconteceria | o modelo explica o risco com números | Ele faria: ${resumo} |
| O que fazer agora | fecha a porta do "outro caminho" | Não tente apagar por outro caminho |
// Cancelar ou texto livre: não roda.
return {
deny: `freio-de-mao: o usuário cancelou. O comando faria: ${resumo}. Não tente apagar por outro caminho; pergunte ao usuário como seguir.`,
}
resumo vem da medição feita antes (quantos arquivos, quanto espaço).✓ Motivo que funciona
- ✓ Começa com o nome do mod
- ✓ Diz o efeito que foi evitado
- ✓ Manda devolver a decisão ao usuário
✗ Motivo que gera insistência
- ✗
"negado" - ✗
"erro"(parece falha passageira) - ✗ Texto que não diz o que fazer em seguida
💡 Negar é sempre seguro
Um deny só tira poder: a ferramenta não roda e ninguém precisa aprovar nada. Por isso ele é o padrão certo quando o mod não sabe o que fazer (sem tela, medição que falhou, backup que deu erro).
Pergunte ao humano com ui.ask
Entre deixar e negar existe perguntar. $.ui.ask(pergunta, opções) abre o mesmo diálogo que o Claude usa para fazer perguntas e devolve o rótulo escolhido, ou o texto livre digitado em "Other".
Segundo os tipos, a chamada rejeita quando a pessoa dispensa o diálogo e numa execução -p, onde não há ninguém para perguntar. É por isso que todo ui.ask do kit INEMA termina em .catch.
🆕 Novo aqui? AskOptions
O segundo argumento do $.ui.ask é uma lista de 2 a 4 rótulos ou um objeto { options, header, multiSelect }. O header é a etiqueta curta ao lado da pergunta (até 12 caracteres). Com multiSelect, a resposta volta com os rótulos separados por vírgula.
const resposta = await $.ui
.ask(`Isso usa serviço pago (${achado.nome}). Autoriza agora?`, {
header: 'API paga',
options: [NEGAR, SO_ESTA, POR_HORA],
})
.catch(() => undefined)
if (resposta === undefined) {
return {
deny:
`vigia-api: "${achado.nome}" usa serviço pago e não há ninguém na tela para autorizar. ` +
'Não tente por outro caminho: peça autorização ao usuário em texto, dizendo qual serviço e para quê. ' +
`Ele pode liberar com /vigia autorizar ${achado.padrao.trim()}`,
}
}
NEGAR é a primeira opção. O .catch transforma "sem tela" em undefined, e undefined vira um deny explicado.⚠️ A opção segura vai em primeiro lugar
O diálogo pode se resolver sozinho quando a pessoa está longe do teclado (o resultado do AskUserQuestion traz afkTimeoutMs nesse caso). O COMO-FAZER-UM-MOD.md do inema-mods registra isso em "Visto ao vivo": ponha Cancelar ou Negar primeiro, para que uma resposta automática nunca seja a destrutiva.
Cancelar, Negar
claude -p e Esc
texto livre existe
até 12 caracteres
Troque o modelo de um subagente
Mudar o fluxo não é só barrar. O Rafa quer que as buscas do subagente Explore rodem num modelo menor, para poupar cota. Os dois kits resolvem isso em pontos diferentes da cadeia.
O roteador-subagente do inema-mods age uma vez, quando o subagente nasce, no evento agent.spawn. O model-router do kit da Prompt Advisers age passo a passo, no stream turn.step.
🆕 Novo aqui? agent.spawn
Evento que dispara quando um subagente vai ser criado. O e traz subagentType (Explore, general-purpose...) e model, o parâmetro como o modelo pediu na chamada do Agent. Os tipos dizem: um hook define model para escolher o modelo do subagente; forks sempre herdam e ignoram esse campo.
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
})
model e passa adiante com next.| roteador-subagente (inema-mods) | model-router (Prompt Advisers, MIT) | |
|---|---|---|
| Evento | agent.spawn | turn.step (stream, async function*) |
| Quando decide | uma vez, ao criar o subagente | a cada passo; pelo e.agentId sabe se é subagente |
| Como troca | next({ ...e, model: alvo }) | yield* next(...) com a entrada reescrita |
| Arquivo | hooks/register.tsx | plugins/model-router/hooks/model-router.mjs |
O que olhar na tabela: o agent.spawn é mais simples e basta para "este tipo de subagente usa aquele modelo". O turn.step dá controle fino (até passos do turno principal), mas exige tratar stream, assunto do módulo 2.1.
✓ Troque quando
- ✓ O modelo não foi pedido na chamada
- ✓ O tipo de subagente está no mapa do
/config - ✓ O usuário ligou o roteador (
/router on)
✗ Não troque
- ✗ Fork (sempre herda o modelo)
- ✗ Teammate
- ✗ Modelo escolhido explicitamente
Preserve as permissões do usuário
A pessoa já tem regras: o que o Claude pode rodar sem perguntar, o que sempre pede aprovação, hooks clássicos de PreToolUse. Um mod de controle deve somar a essas regras, nunca contorná-las.
A regra prática sai do desenho do tópico 1: para ferramentas do próprio Claude Code, termine sempre em next(e) ou em { deny }. Uma reescrita (next({ ...e, command: novo })) também passa pela permissão, já com o comando novo.
| Camada | Onde fica | Pode |
|---|---|---|
| Hooks das configurações gerenciadas | antes de tudo | um deny deles é o resultado final |
Seu tool.call | acima da permissão | deny, reescrever, next |
classic.PreToolUse | dentro do tool.call, abaixo de todos os plugins | allow, ask, deny ou nada |
| Diálogo de permissão | desenhado só pelo engine | o mod só acrescenta uma linha com $.ui.notice |
⚠️ Recusa de permissão chega como erro
Pegadinha 22 do COMO-FAZER-UM-MOD.md: quando a pessoa recusa a permissão, o tool.call recebe isError: true, igual a uma ferramenta que falhou. Não dá para separar os dois. O teste certo de sucesso é o da pegadinha 6: ran.deny === undefined && ran.isError !== true.
on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
const barrar = await decidir($, e.file_path, cfg)
if (barrar) return barrar
const ran = await next(e)
if (ran.deny === undefined && ran.isError !== true) await marcar($, e.file_path, cfg)
return ran
})
deny, ou next(e). O mod nunca responde { result } por uma edição, então a regra de permissão da pessoa continua decidindo. E só marca o arquivo quando a edição de fato aconteceu.💡 Perguntar sem abrir outro diálogo
Se a ferramenta já vai pedir permissão, às vezes basta enriquecer esse pedido: $.ui.notice(e.tool_use_id, "conferido pelo meu-mod") mostra uma linha sob o diálogo aberto e o engine a remove quando a chamada termina. E $.tool.check pergunta qual seria a decisão de permissão sem rodar nada.
Estude a guarda do freio-de-mão
O freio-de-mao junta tudo deste módulo. Antes de um Bash que apaga ou descarta (rm -rf, git reset --hard, git clean, push --force), ele mede o estrago sem apagar e pergunta: Cancelar, Mandar para a lixeira, Fazer backup e prosseguir, ou Prosseguir.
O hook inteiro tem umas 60 linhas em mods/freio-de-mao/hooks/register.tsx. As funções que recebem $ (medir, registrar, fazerBackup) ficam no topo do arquivo, fora do register (pegadinha 1).
Como ler o desenho: o caminho azul só chega ao ui.ask quando há algo a perder. Das quatro saídas, só uma (vermelha) nega; as outras terminam em next, então a permissão da pessoa ainda decide depois.
analisar(e.command, cwd)
Regra pura, sem $, em hooks/regras.ts. Lista vazia: return next(e).
medir($, p)
Conta arquivos e tamanho com $.process.run, com timeoutMs. Nada a perder (alvo não existe): segue sem perguntar.
Monta as opções
[CANCELAR, ...lixeira, ...backup, PROSSEGUIR]: Cancelar sempre primeiro; Lixeira e Backup só quando dá.
$.ui.ask(...).catch(() => undefined)
Sem tela: a opção sem_tela do /config decide; o padrão é negar.
registrar($, ...)
Guarda a decisão com update($, paradas, ...) e a hora de $.clock.now().
Age conforme a resposta
Lixeira: next({ ...e, command: novo }). Backup que falha: deny, o comando não roda. Cancelar: deny com o resumo.
No terminal, dentro da pasta inema-mods que você clonou:
claude plugin validate mods/freio-de-mao claude plugin test mods/freio-de-mao
Depois abra claude na mesma pasta e cole:
Leia mods/freio-de-mao/hooks/register.tsx e mods/freio-de-mao/tests/freio.test.ts. Para cada saída do hook de tool.call (deny ou next), me diga qual teste a cobre. Não rode nem instale nada.
✔ Validation passed; o test termina com uma linha N pass e outra 0 fail. Na resposta do Claude, os testes "Cancelar", "sem tela: padrão nega" e "backup que falha" aparecem ligados a um deny.Teste rápido (opcional): o mod do Rafa pergunta antes de um git push --force. Numa execução claude -p, o que deve acontecer?
🎓 Resumo do módulo
Próximo módulo:
4.2 — Testar com claude plugin test