↓ Ir para o conteúdo principal

Protocolo do hook

Versão documentada: v0.1.8

Tradução da página original em inglês, que é a referência se algo estiver diferente.

O vults-hook e o app trocam um objeto JSON por linha através de um socket local.

Transporte
#

PlataformaEndpointControle de acesso
Linux$XDG_RUNTIME_DIR/vults.sock (alternativa /tmp/vults-<uid>/, pasta 0700)socket 0600, SO_PEERCRED
Windows\\.\pipe\vults-<SID>as duas pontas verificam o SID do outro processo
macOSplanejadogetpeereid

Mensagens
#

Do hook para o app, versão 8:

{ "kind": "event", "v": 8, "id": "18f…-1a2b", "agent": "claude", "event": "PermissionRequest",
  "wants_reply": true,
  "terminal": { "cwd": "/home/me/project", "pid": 4242, "env": { "TERM_PROGRAM": "kitty" } },
  "payload": { "tool_name": "Bash", "tool_input": { "command": "cargo test" } } }
  • agent: claude, codex, opencode (o nosso plugin), qwen, copilot, ou other para qualquer outra ferramenta (veja Outros agentes). Antigravity, Cursor, Factory Droid e Kimi Code são nativos, mas vão como other, com agent_name antigravity, cursor, droid e kimi.
  • agent_name: só com other, o nome da ferramenta: de 1 a 24 caracteres entre a-z, 0-9 e -. São recusados os nomes para os quais o fio tem um tipo (claude, codex, copilot, opencode, qwen e other) e gemini, que não tem tipo desde a versão 7 mas segue reservado porque os hooks antigos do Gemini CLI podem continuar instalados. Qualquer outro nome, inclusive os desses quatro nativos, é um other com nome, e um other nunca ganha card. Ausente nos outros casos.
  • id: único por mensagem, opaco.
  • zeca: true só num evento do próprio claude ou codex do chat (o hook vê VULTS_ZECA=1 no ambiente dele), ausente nos outros. Um evento assim é trabalho do Zeca: o app nunca o transforma em sessão, card ou pássaro, e ele nunca tem wants_reply, já que o chat pergunta os próprios cards. Definir a variável em outro lugar só esconde aquele agente da ilha; o terminal dele continua perguntando. Todo processo que a CLI do chat inicia a herda: um claude que o chat abre por um comando também fica escondido.
  • terminal: todos os campos são opcionais. env lista só as variáveis definidas entre as que identificam o terminal, mais VULTS_TASK numa sessão que uma tarefa sua começou (a ave dela decola do Zeca). cwd é o cwd do payload, senão a primeira entrada de workspacePaths (Antigravity) ou workspace_roots (Cursor), senão a pasta de trabalho do próprio hook.
  • payload: o JSON de hook do agente sem tool_response, transcript_path (e os transcriptPath e artifactDirectoryPath do Antigravity); as strings são limitadas a 2000 bytes. Duas coisas sobrevivem em tool_response: o error de uma ferramenta e, num PostToolUse, o structuredPatch do Claude Code, cortado nas primeiras 400 linhas (com "cut": true quando linhas ficaram de fora). Num PostToolUse do apply_patch do Codex, tool_input.command (o patch) guarda até 64 KiB. Os campos são opcionais e o envelope não mudou, então isso não precisou de uma versão nova. Um evento que ainda passaria do tamanho de mensagem de 1 MiB tem as strings cortadas mais curtas e depois fica só com os campos que dizem o que aconteceu (hook_event_name, session_id, cwd, tool_name…): o app descarta uma linha maior sem ler. Um evento assim nunca espera resposta (wants_reply é falso): o card dele não conseguiria mostrar tudo o que o Allow autorizaria, então o próprio terminal do agente pergunta.

Um evento espera uma resposta (wants_reply) quando é um PermissionRequest do Claude Code, do Codex, do OpenCode, do Qwen Code ou do Copilot CLI, ou um PreToolUse para AskUserQuestion do Claude Code ou do OpenCode enviado por uma entrada instalada com --ask (vults-hook --agent claude --ask PreToolUse, com um timeout de 120 segundos; o plugin do OpenCode roda o relay do mesmo jeito para a ferramenta question dele, nas palavras do Claude Code). Uma entrada sem a flag tem o timeout curto de todos os outros eventos, então ela nunca espera.

Do app para o hook, só quando wants_reply é true:

{ "kind": "waiting", "v": 8, "id": "18f…-1a2b" }
{ "kind": "decision", "v": 8, "id": "18f…-1a2b", "decision": "allow" }
{ "kind": "answer", "v": 8, "id": "18f…-1a2b", "answers": ["Blue", ["S", "M"]] }
{ "kind": "unsupported", "v": 8, "id": "18f…-1a2b" }

waiting vem primeiro, assim que o card está na tela: um humano está sendo consultado, e a decisão pode demorar. Um hook que não ouve nem waiting nem a resposta em 2 s desiste, então um app parado ou congelado (cujo socket o kernel ainda aceita) segura um agente por 2 s, não pelo prazo inteiro de decisão. Uma decisão que chega antes do card (uma regra Always) vem sem ele.

answers tem uma entrada por pergunta, na ordem de tool_input.questions: uma string (o rótulo de uma opção, ou as palavras do próprio usuário) ou, numa seleção múltipla, uma lista delas. Vai por posição porque o app só viu as perguntas com as strings limitadas; o hook associa as respostas às perguntas que ele leu.

Um evento de other nunca tem wants_reply: nada no app responde à permissão de outra ferramenta.

StatusLine é a entrada de statusLine do Claude Code, enviada por vults-hook --agent claude --statusline. O payload dele é só rate_limits (as janelas de 5 horas e semanal do plano), cost.total_cost_usd (o que os tokens da sessão custariam em preço de API, para o card dela) e session_id; os caminhos, os outros números de custo e o modelo da sessão nunca saem do hook. Antes da primeira resposta da sessão não há rate_limits, e sem custo também nada é enviado. O hook imprime só o que o status line do próprio usuário imprime (salvo na instalação, veja configurações), ou nada quando não há nenhum, então o status line do Claude Code fica como era antes.

A versão 2 adicionou other e agent_name, depois gemini. A versão 3 adicionou answer. A versão 4 adicionou opencode, que um hook mais antigo mandava como other. A versão 5 adicionou waiting, depois qwen, que um hook mais antigo manda como other (o app o lê como o de qualquer outra ferramenta, sem card). A versão 6 adicionou zeca. A versão 7 removeu gemini: os hooks que sobraram do Gemini CLI não mandam nada. A versão 8 adicionou copilot. O app instala o próprio hook quando inicia, então os dois sempre falam a mesma versão; um evento de outra versão recebe unsupported.

Uma conexão que não recebe resposta, uma resposta para outro id, ou unsupported fazem o hook não imprimir nada.

Limites
#

LimiteValor
Tamanho da mensagem1 MiB
Hook: conectar300 ms
Hook: evento sem resposta2 s no total
Hook: ouvindo waiting2 s
Hook: esperando uma decisão110 s
App: confirmação de um card pela UI800 ms
App: decisão108 s
App: resposta de um card aceita107 s a partir de quando a linha do hook foi lida (1 s para a resposta voltar)
App: leitura de uma mensagem5 s
App: conexões simultâneas32

Saída para o agente
#

O hook transforma uma decisão no formato que cada agente espera. O Claude Code e o Codex leem a mesma saída de PermissionRequest:

{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Vults"}}}

O Copilot CLI lê a decisão sozinha:

{"behavior":"allow"}
{"behavior":"deny","message":"Denied from Vults"}

O plugin do OpenCode lê a resposta que ele entrega ao endpoint de permissão do OpenCode, once ou reject (nunca o always do OpenCode: um Always allow é uma regra do próprio app):

{"reply":"once"}
{"reply":"reject"}

e, para respostas, uma lista de rótulos (ou as palavras do próprio usuário) por pergunta, em ordem, que ele entrega ao endpoint de perguntas do OpenCode:

{"answers":[["Blue"],["S","M"]]}

Uma resposta vira a saída de PreToolUse do Claude Code: a ferramenta roda com as respostas adicionadas à própria entrada, associadas ao texto de cada pergunta (verificado com o Claude Code 2.1.286):

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
  "updatedInput":{"questions":[…],"answers":{"Which color?":"Blue","Which sizes?":["S","M"]}}}}

Quando o número de respostas não bate com o de perguntas, o hook não imprime nada e o Claude Code pergunta no terminal dele.