Hooks do Claude Code: os 3 bugs que a v2.1.141 eliminou

Guia prático para atualizar hooks do Claude Code em produção: configure terminalSequence, args:string[] e continueOnBlock sem gambiarras em shell.

Saturday, September 5, 2026Omid Saffari
Hooks do Claude Code: os 3 bugs que a v2.1.141 eliminou

Três dos bugs de hooks do Claude Code que mais me atrapalharam no primeiro trimestre foram corrigidos discretamente em apenas 7 dias. Na manhã seguinte, apaguei três soluções provisórias em shell do meu repositório administrativo.

A semana em que três bugs reais desapareceram

Entre 6 e 13 de maio de 2026, a Anthropic lançou as versões 2.1.132 a 2.1.141 do Claude Code. A maior parte das mudanças se concentrou no sistema de hooks.

Eu percebi porque havia três remendos no meu repositório com comentários // TODO: remove when claude-code fixes this; em menos de uma semana, consegui apagar todos eles.

Estes eram os bugs, em ordem de prejuízo:

O primeiro fazia notificações do desktop sumirem. Meu hook Notification enviava o alerta sonoro do sistema operacional para stdout, para que eu fosse avisado quando uma compactação demorada terminasse. Funcionava enquanto o Claude Code controlava o TTY em primeiro plano, mas falhava silenciosamente em qualquer outro cenário: divisões do tmux, terminais integrados do VS Code e painéis do WezTerm quando o foco estava no painel ao lado. A correção chegou como terminalSequence na versão 2.1.141.

O segundo era o descontrole do escape de caracteres no shell. Meu hook Stop executava bash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'", e bastava o motivo retornado pelo Claude conter um apóstrofo para tudo quebrar. A solução foi a forma de execução args: string[], incluída na 2.1.139.

O terceiro fazia rejeições de PostToolUse encerrarem o turno, em vez de devolverem o controle ao Claude. Precisei desativar por completo um hook de validação de schema porque o runtime tratava uma decisão block como erro fatal. A 2.1.139 trouxe continueOnBlock, que transforma o bloqueio em um sinal de nova tentativa e injeta o motivo no contexto.

O settings.json completo está no final. Sem enrolação no meio do caminho.

Como funcionam os hooks do Claude Code em 90 segundos

O Claude Code 2.1.x oferece nove eventos de hook: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact e SessionEnd. Cada hook é um comando iniciado pelo runtime com um ambiente documentado e um payload recebido por stdin. O runtime interpreta o stdout do hook como JSON, e alguns campos específicos controlam seu comportamento: decision (allow/deny/block), reason (string devolvida ao Claude ou exibida ao usuário), terminalSequence (bytes brutos enviados ao TTY de controle) e alguns campos próprios de cada evento.

O conceito essencial é simples: para o runtime, o stdout do hook é a fonte de verdade. Um campo que ele não interpreta é peso morto. Um campo que ele interpreta muda o que o Claude verá no turno seguinte. É disso que todo o mecanismo depende.

terminalSequence: notificações no desktop sem controlar o TTY

Antes da 2.1.141, meu hook Notification era assim:

JSON
{
  "hooks": {
    "Notification": [{
      "command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
    }]
  }
}

O printf "\a" deveria acionar o alerta sonoro do terminal. Na prática, ele escrevia \a no descritor de arquivo herdado pelo processo do hook, que não corresponde ao terminal do usuário quando o Claude Code não está em primeiro plano. Se o Claude estava no painel 2 de uma divisão do tmux e eu editava no painel 1, o alerta nunca chegava ao terminal externo. O notify-send funcionava, mas a notificação ficava na bandeja do GNOME e eu acabava não vendo.

A versão 2.1.141 acrescentou o campo terminalSequence ao JSON do stdout do hook. O runtime pega essa string e a escreve diretamente no dispositivo de terminal de controle, sem passar pelo stdio do processo do hook. Depois da atualização, a configuração ficou assim:

JSON
{
  "hooks": {
    "Notification": [{
      "command": "node hooks/notify.mjs"
    }]
  }
}
JavaScript
import { execSync } from "node:child_process";

const payload = JSON.parse(
  await new Response(process.stdin).text()
);

execSync(`notify-send "Claude" ${JSON.stringify(payload.message)}`);

process.stdout.write(JSON.stringify({
  terminalSequence: ""
}));

 é BEL. O runtime o escreve no TTY de controle, e o terminal externo toca mesmo quando o Claude está em um painel em segundo plano. macOS Terminal, iTerm2, WezTerm e Alacritty se comportaram corretamente nos meus testes. O tmux repassa BEL; de qualquer forma, mantenha set -g allow-passthrough on no seu tmux.conf por causa do OSC 52.

Há um caso de borda importante: por padrão, o terminal integrado do VS Code ignora BEL. Ative "terminal.integrated.enableBell": true nas configurações do usuário para receber o alerta também ali.

args: string[] eliminou o escape de shell nos meus hooks

O hook Stop é disparado quando um turno termina. Eu o uso para registrar os metadados da sessão em uma instância do Cloudflare D1 e, assim, poder pesquisar execuções antigas com grep.

Antes da 2.1.139, eu usava esta configuração:

JSON
{
  "hooks": {
    "Stop": [{
      "command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
    }]
  }
}

No primeiro mês, esbarrei em três bugs de escape com esse formato:

Apóstrofos em $CLAUDE_STOP_REASON fechavam o argumento entre aspas simples, e o restante passava a ser interpretado como tokens do shell. Quando o Claude retornava um motivo como user's request completed, o hook falhava e o registro da sessão se perdia.

Backticks nos nomes das ferramentas. Quando um hook era disparado com $CLAUDE_TOOL_NAME contendo a string `bash` porque o Claude a havia usado em uma resposta, o shell tentava executar o conteúdo entre backticks como um subshell. Inofensivo nesse caso, assustador em qualquer outro.

Unicode nos prompts dos usuários. Na maioria das vezes, o UTF-8 atravessa bash -lc sem problemas, mas determinadas combinações de pontos de código CJK e configurações de locale descartavam bytes silenciosamente.

A versão 2.1.139 introduziu comandos no formato exec. Ao passar args como um array de strings, o runtime inicia o comando diretamente, sem colocar um shell no meio:

JSON
{
  "hooks": {
    "Stop": [{
      "args": [
        "node",
        "scripts/post-stop.js",
        "--session", "$CLAUDE_SESSION_ID",
        "--reason", "$CLAUDE_STOP_REASON"
      ]
    }]
  }
}

O runtime resolve as variáveis $CLAUDE_* do próprio ambiente antes de chamar execve. Não há shell, interpolação do shell nem preocupação com aspas. A string user's request completed chega ao meu script Node como uma única entrada argv[5], exatamente como o Claude a produziu.

Em um hook PreToolUse executado a cada chamada de ferramenta, isso faz diferença.

Quando manter o formato de shell: sempre que o comando precisar de pipes, redirecionamentos ou expansão de curingas. args:[] e command:"" são mutuamente exclusivos em uma mesma entrada de hook. Portanto, se você precisa de node x.js | jq | tee log, continue com command:"" e aceite o custo do escape. Para 90% dos hooks, o formato exec é a escolha certa.

continueOnBlock: uma rejeição de PostToolUse que realmente tenta de novo

Essa foi a correção pela qual esperei mais tempo. Uso um hook PostToolUse para validar a saída de toda chamada de ferramenta que grava no disco: se o Claude escreve um arquivo TypeScript, o hook executa tsc --noEmit e rejeita o resultado quando há erros de tipo.

Antes da 2.1.139, o fluxo de rejeição não funcionava:

JSON
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Write|Edit",
      "command": "node hooks/validate-ts.mjs"
    }]
  }
}

Quando validate-ts.mjs retornava { "decision": "block", "reason": "tsc failed: ..." }, o runtime encerrava o turno. O Claude não recebia o motivo. O usuário via apenas a mensagem enigmática "hook blocked the operation" e precisava enviar outro prompt manualmente, colando o erro. Depois que isso interrompeu turnos produtivos em três sessões reais, desativei o hook.

A versão 2.1.139 adicionou continueOnBlock como configuração individual de cada hook:

JSON
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Write|Edit",
      "args": ["node", "hooks/validate-ts.mjs"],
      "continueOnBlock": true,
      "maxAttempts": 3
    }]
  }
}

Agora, uma decisão block devolve a string reason ao contexto do Claude como um erro de resultado da ferramenta. O Claude recebe tsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number' e se corrige no turno seguinte. O usuário não vê nada, porque o loop é interno.

maxAttempts define um limite rígido. Sem ele, uma validação não determinística — por exemplo, uma que dependa de uma API remota temporariamente indisponível — consome contexto em novas tentativas infinitas. Eu uso três. Depois de três falhas, o hook escala para um bloqueio definitivo e mostra o erro ao usuário.

Antipadrão: não ative continueOnBlock em hooks cujas decisões dependam do horário. Um hook que rejeita gravações durante uma janela de deploy continuará em loop se o deploy ainda estiver acontecendo quando o Claude tentar de novo. Nesse caso, condicione a execução a $CLAUDE_EFFORT ou inclua um contador de tentativas no script do hook.

A dupla extra: $CLAUDE_EFFORT e CLAUDE_PROJECT_DIR

Duas novas variáveis de ambiente apareceram discretamente nesse intervalo, e ambas são bastante úteis.

A versão 2.1.133 passou a injetar $CLAUDE_EFFORT no ambiente dos hooks. Os valores são low, medium, high e xhigh, de acordo com o nível de esforço do Claude no turno atual. Assim, um hook pode variar o comportamento conforme o esforço sem precisar interpretar o prompt:

JavaScript
const effort = process.env.CLAUDE_EFFORT;

if (effort === "low" || effort === "medium") {
  // skip expensive tsc check on quick edits
  process.stdout.write(JSON.stringify({ decision: "allow" }));
  process.exit(0);
}

// run full tsc --noEmit on high/xhigh

Economizo cerca de 800ms por edição rápida ao pular o tsc em níveis baixos de esforço. Em um turno de planejamento high, no qual o Claude escreve uma dúzia de arquivos, a verificação completa ainda é executada e encontra bugs reais.

A versão 2.1.139 incluiu CLAUDE_PROJECT_DIR no ambiente dos servidores MCP stdio iniciados pelo runtime. Antes disso, esses servidores precisavam inferir a raiz do workspace por meio de process.cwd(), o que falhava quando o usuário abria o Claude Code a partir de um subdiretório. Agora, qualquer servidor MCP pode ler process.env.CLAUDE_PROJECT_DIR e resolver corretamente caminhos relativos ao workspace.

Quem mantém um servidor MCP deve atualizar a resolução de caminhos do manifesto para usar CLAUDE_PROJECT_DIR, recorrendo a cwd() como fallback para clientes antigos. São duas linhas de mudança que eliminam toda uma classe de bugs.

O settings.json exato que uso em produção

Este é o bloco de produção do omidsaffari-admin, com alguns dados omitidos. Seis DOs e um Workflow dependem da saída dos hooks para notificações no desktop e validações de CI.

JSON
{
  "model": "claude-sonnet-4-7-20260501",
  "permissions": {
    "edit": "ask"
  },
  "hooks": {
    "SessionStart": [{
      "args": ["node", "hooks/session-start.mjs"]
    }],
    "PreToolUse": [{
      "matcher": "Bash",
      "args": ["node", "hooks/gate-bash.mjs"]
    }],
    "PostToolUse": [{
      "matcher": "Write|Edit",
      "args": ["node", "hooks/validate-ts.mjs"],
      "continueOnBlock": true,
      "maxAttempts": 3
    }],
    "Notification": [{
      "args": ["node", "hooks/notify.mjs"]
    }],
    "Stop": [{
      "args": [
        "node",
        "scripts/post-stop.js",
        "--session", "$CLAUDE_SESSION_ID",
        "--reason", "$CLAUDE_STOP_REASON"
      ]
    }],
    "PreCompact": [{
      "args": ["node", "hooks/notify.mjs"]
    }]
  }
}
JSON
{
  "devDependencies": {
    "@anthropic-ai/claude-code": "2.1.141"
  }
}

Instale e verifique:

Bash
pnpm add -D @anthropic-ai/claude-code@2.1.141
claude --version    # expect: 2.1.141
claude config doctor    # expect: 0 hook warnings

Os destaques: continueOnBlock e maxAttempts formam a dupla do loop de novas tentativas introduzida na 2.1.139. O formato args:[] aparece em todo hook que não precisa de recursos do shell — nesta configuração, são todos eles. Os hooks Notification e PreCompact compartilham o notify.mjs porque ambos precisam de notificações no desktop com a mesma saída terminalSequence.

Checklist de implantação e quando não atualizar

Antes de começar, faça um snapshot do settings.json atual. Anote quais hooks usam command:"" e quais já adotam args:[]. Liste todos os tipos de evento configurados.

Migre um tipo de evento por vez e deixe cada migração rodar por 24 horas. Acompanhe os avisos de hooks com claude config doctor e procure as strings decision e reason nos logs para confirmar que o runtime está recebendo o esperado.

Esta é a ordem que eu seguiria:

  1. Atualize o pacote para a versão 2.1.141.
  2. Converta um hook de command:"" para args:[] e confirme que ele é disparado.
  3. Adicione terminalSequence ao hook Notification e teste o alerta em um painel do tmux em segundo plano.
  4. Adicione continueOnBlock ao hook PostToolUse que mais incomoda. Acompanhe uma sessão real para confirmar que o Claude recebe o motivo e se corrige.
  5. Migre o restante dos hooks para args:[] em lotes.

Não atualize se você usa uma instalação gerenciada, presa à versão definida por um SDK pai que ainda não validou a 2.1.141, ou se depende de um campo de hook que a 2.1.141 tornou obsoleto. Até o momento desta publicação, nenhuma mudança do intervalo de maio quebrou campos existentes. Mesmo assim, fixe a versão e teste em uma branch antes de liberar para a equipe.

Para ter o playbook completo de fixação de versões, estratégia de hooks e estruturação de projetos que uso com seis agentes em produção, consulte o Checklist de configuração do Claude Code + Codex. Ele reúne os mesmos padrões de settings.json e também a estrutura do lado dos agentes — Workflows, DOs e bindings do Vectorize — que esses hooks controlam.

Para conferir uma análise complementar sobre a execução desses agentes em sandboxes remotos, leia o comparativo entre ambientes do Cursor Cloud Agent e Cloudflare Workers. Para entender o contexto da stack de produção por trás deste settings.json, veja o artigo sobre o engenheiro 100x com Cloudflare.

terminalSequence funciona no tmux?

Sim, com uma ressalva. O runtime escreve a sequência no dispositivo de terminal de controle, que o tmux encaminha ao terminal externo quando set -g allow-passthrough on está presente no tmux.conf ou quando a sequência é um BEL simples (). BEL sempre passa; sequências OSC exigem que o passthrough esteja ativado.

Posso usar args:[] e command:'' juntos na mesma configuração de hook?

Não. Eles são mutuamente exclusivos em cada entrada de hook. Use o formato exec (args:[]) para comandos iniciados diretamente e o formato shell (command:"") quando precisar de pipes, redirecionamentos ou expansão de curingas. Se for necessário combinar os dois, crie um script wrapper que use o formato exec e concentre internamente os recursos de shell.

continueOnBlock pode entrar em loop infinito se o Claude repetir a mesma falha de validação?

Não, desde que maxAttempts esteja definido. O runtime limita as novas tentativas a esse número e depois escala para um bloqueio definitivo. Sem maxAttempts, é possível consumir contexto indefinidamente com um validador não determinístico. Sempre defina esse valor. Três é um padrão razoável para validações de tipo; um é o número certo para qualquer processo que dependa de estado remoto.

$CLAUDE_EFFORT está disponível em todos os eventos de hook?

Sim. Desde a 2.1.133, a variável é injetada no ambiente de todo hook iniciado pelo runtime. O valor reflete o nível de esforço do turno atual: SessionStart recebe o esforço selecionado pelo usuário ao iniciar, enquanto PostToolUse recebe aquele que estava ativo quando a ferramenta foi chamada.

O que deixa de funcionar se eu voltar para a 2.1.138?

terminalSequence, args:[], continueOnBlock e CLAUDE_PROJECT_DIR deixam de funcionar silenciosamente. O runtime ignora campos JSON desconhecidos e volta à interpretação de command:"". Seus hooks continuam sendo disparados, mas os novos comportamentos desaparecem. Teste o downgrade em uma branch antes de depender dele.

Última atualização

5 de set. de 2026

CategoriaBuild

Prefira este site no Google

Adicionar omidsaffari.com como fonte preferida na Busca do Google

Marque omidsaffari.com como fonte preferida e o Google destaca o site para você em Top Stories, AI Overviews e AI Mode.

Newsletter

Uma carta, todo domingo. Sistemas que funcionam, não hot takes.

Build logs, sistemas em produção e notas de campo de um portfólio de ventures de IA.

Semanal. Sem spam. Cancele quando quiser.