훅이 바꿀 수 있는 건 이벤트 계약이 정합니다
한 turn은 훅 열하나를 지나갑니다. 프로토콜은 핸들러 네 종류를 알아보지만, 런타임 표에는 명령과 MCP만 올라갑니다. 타임아웃은 기본 통과이고, 해체 구간은 stdout을 버립니다.
SessionEnd가 왜 출력을 읽지 않는지. 훅 지점은 생명주기에 미리 뚫어 둔 소켓이에요. 다음 단계를 바꾸는 건 이벤트 계약이지, 설정 파일 속 이름이 아닙니다.
- 프로토콜 enum이 이벤트 이름 열하나를 나열합니다protocol.rs L1510
- 설정 층은 type 네 개를 알아보지만, 뒤의 둘은 빈 구조체입니다hook_config.rs L183
- 발견 단계에서 Prompt와 Agent를 not supported yet로 씁니다discovery.rs L626
- 런타임 표는 Command와 McpTool만 받습니다engine/mod.rs L107
- 동기 hook만 제어 효과를 걸 수 있습니다engine/mod.rs L146
- 타임아웃은 error를 쓰고 should_block은 false로 둡니다command_runner.rs L317
- 종료 코드 2에 stderr가 있어야 Blocked로 표시됩니다pre_tool_use.rs L261
- Allow는 일회성 Approved로 매핑됩니다approvals.rs L465
- Stop의 block은 prompt를 달고 있어야 턴 층에서 continue합니다turn.rs L509
- SessionEnd는 종료 코드 0이면 완료로 보고 stdout을 버립니다session_end.rs L109
방금 Claude Code에서 hooks.json 한 장을 옮겼어요. 파일에 핸들러가 셋입니다. 위험한 셸을 막는 명령, 작은 모델에게 사용자 제출을 심사시키는 프롬프트, Stop에서 자식 세션을 띄워 linter를 돌리는 agent. Claude 쪽에서는 셋 다 돌아갑니다.
Codex에 붙여 넣고 세션을 켭니다. 명령 줄은 켜집니다. 나머지 둘은 로그에 각각 not supported yet를 남깁니다. 프로토콜 enum에는 Prompt와 Agent가 분명히 있고, 설정 파싱도 이 두 tag를 받습니다. 런타임 표에 들어가는 건 명령과 MCP 도구뿐이에요.
커널이 밖으로 내미는 표는 세 장이고, 폭이 다릅니다.
프로토콜 면은 이벤트 이름 열하나를 나열하고, serde는 snake_case를 씁니다. 옆에 핸들러 타입 넷도 있어요. Command, McpTool, Prompt, Agent.
출처:codex-rs/protocol/src/protocol.rs 1508–1531행
설정 면은 type 태그로 이 네 이름을 모두 받습니다. 뒤의 둘은 빈 구조체예요. 파싱은 통과하지만, 필드에 실행할 내용은 없습니다.
출처:codex-rs/config/src/hook_config.rs 183–187행
발견 단계는 뒤의 둘을 보면 continue하고, 문구는 prompt hooks are not supported yet와 agent hooks are not supported yet입니다. 엔진에서 진짜 실행 가능한 종류는 명령과 MCP 도구뿐이에요. 일반 사용자 설정에서는 이 두 줄이 warning만 남기고 세션은 이어집니다. 호스팅 필수 hook에 이들을 넣으면 기동이 실패합니다.
출처:codex-rs/hooks/src/engine/discovery.rs 626–645행
JSON hook 런타임 타입 이름은 그대로 ClaudeHooksEngine입니다. 호환은 주석 속 바람이 아니라 타입 이름이에요. stdin에 JSON을 넣고, stdout은 schema로 파싱합니다. 옆에는 옛 notify 길도 남아, turn이 끝날 때 명령을 하나 fire-and-forget합니다. 두 길을 섞지 마세요.
출처:codex-rs/hooks/src/engine/mod.rs 107–119행
설정 면은 실행기보다 넓어도 됩니다. 생태계에 이미 있는 type 넷을 먼저 받아야, 모르는 tag가 hooks.json 전체를 터뜨리지 않아요. 대가는, 이사 온 사람이 enum 이름을 능력으로 읽는다는 점입니다. 그래서 발견 단계는 안정된 문구를 남겨야 하고, 필수 정책이 미구현 type을 만나면 기동을 거부해야 합니다. 다른 언어로 다시 써도 최소 형태는 여전히 표 두 장과 skip 하나예요.
어떤 사람이 PreToolUse 스크립트를 쓰고 타임아웃을 1초로 둔 뒤, 스크립트에서 5초를 sleep했습니다. hook이 죽으면 차단이라고 생각한 거죠. 도구는 그대로 실행됐어요. 로그에서 이 hook 상태는 failed이고, 문구는 timed out after 1s입니다.
run_command가 타임아웃이면 error를 hook timed out after {n}s로 쓰고, exit_code는 비어 있습니다. 파싱은 error를 보고 Failed만 찍고, should_block은 기본값 false로 남습니다. 그래서 도구 레지스트리는 handle_any_tool로 이어갑니다.
출처:codex-rs/hooks/src/engine/command_runner.rs 317–326행
막는 길은 두 갈래뿐입니다. JSON에서 deny 또는 block을 주거나, 종료 코드 2에 stderr가 비어 있지 않아야 합니다. 종료 코드 2인데 이유가 없으면 실패이지, 차단이 아니에요. 비동기 hook이 deny를 돌려줘도 제어 효과는 못 겁니다. 동기이고, 신뢰되고, 타임아웃되지 않은 핸들러만 다음 단계를 바꿀 수 있어요.
출처:codex-rs/hooks/src/events/pre_tool_use.rs 261–277행
승인 경로의 규칙은 거꾸로입니다. PermissionRequest는 Guardian과 사용자 승인 UI보다 먼저 달립니다. 도구 입력은 고치지 않아요. 접는 규칙은, deny가 하나라도 있으면 즉시 이기고, 아니면 마지막 allow를 남깁니다. Allow는 일회성 Approved로 매핑되고 세션 캐시에 안 들어갑니다. 다음에 같은 명령을 또 물어봅니다.
출처:codex-rs/core/src/tools/approvals.rs 454–474행
Claude의 PermissionRequest 출력에는 updatedInput이 있습니다. Codex는 이 필드를 reserved로 표시하고, 보면 fail closed합니다. Claude에서 개서가 있는 승인 hook을 그대로 옮기면 여기서 실패하고, 개서는 일어나지 않아요.
죽은 linter hook 하나가 모든 도구를 멈춰 세우면 안 됩니다. 가용성을 차단 신뢰성 앞에 둡니다. 흐름을 바꾸려면 동기여야 하고, 분명한 결정이 있어야 해요. 알림을 보내려면 비동기여도 되고, 최대 8개가 나란히 달립니다. 승인 경로는 애매한 출력에 fail closed합니다. 그 층은 읽지 못한 필드를 허용으로 보면 안 되거든요.
PostToolUse가 위험한 쓰기를 막고 싶어도, 파일은 이미 디스크에 있습니다. SessionEnd가 컨텍스트에 마무리 설명을 넣고 싶어도, stdout은 버려집니다. wire enum에도 이 이벤트 이름이 없어요. 두 사고의 공통점은, 훅 지점이 바꿀 수 있는 구간을 이미 지나왔다는 것입니다.
훅 지점 열하나는 생명주기 순입니다. 주축은 SessionStart → UserPromptSubmit → PreToolUse → PermissionRequest → 도구 → PostToolUse → PreCompact → PostCompact → Stop → SessionEnd. 서브 에이전트는 가는 축을 따로 가고, SubagentStart와 SubagentStop은 ThreadSpawn에서만 울립니다.
각 지점의 계약이 다릅니다.
PreToolUse는 도구를 막고 입력을 고칠 수 있습니다. 실패는 기본 통과예요. PostToolUse는 도구가 성공한 뒤에만 달리고, block이 거절하는 건 결과이며 부작용은 이미 일어났습니다. Stop의 block이 continuation prompt를 달고 있어야 턴 층에서 continue하고, TurnStarted는 다시 안 보냅니다. prompt 없는 block은 무시됩니다. should_stop이어야 제어권이 태스크 껍질로 돌아갑니다. stop이 block보다 앞섭니다.
출처:codex-rs/core/src/session/turn.rs 509–538행
UserPromptSubmit의 block은 should_stop으로 쓰입니다. 멈추는 건 지금 이 사용자 메시지이고, stderr를 다음 라운드 prompt로 쓰지 않아요. matcher는 여기서 무시됩니다. SessionStart는 continue: false를 존중합니다. SubagentStart는 컨텍스트 주입만 하고, 같은 필드는 버려집니다.
SessionEnd는 해체 구간 알림입니다. 타임아웃 기본 1초, 상한 3초로, app-server의 5초 shutdown에 여유를 남깁니다. 종료 코드 0이면 완료이고, stdout은 통째로 버려집니다. MCP 형태는 바로 skip입니다. reason은 지금 other로 박혀 있어요.
출처:codex-rs/hooks/src/events/session_end.rs 20–24행
hook 텍스트는 모델에 들어가기 전에 developer 역할 조각이 됩니다. 기본 예산은 대략 토큰 2500개예요. 넘치면 전문을 임시 디렉터리에 쓰고, 모델은 머리·꼬리 미리보기와 경로 한 줄을 봅니다. 디스크 쓰기가 실패하면 잘라 내기만 합니다.
출처:codex-rs/hooks/src/output_spill.rs 53–91행
훅의 능력은 생명주기 속 위치에 묶여 있습니다. 도구가 아직 안 달렸어야 입력을 고치거나 건너뛸 수 있어요. 도구가 끝나면 모델이 보는 그 한 토막만 고칠 수 있습니다. 해체 구간은 몇 초뿐이라, JSON을 읽고 컨텍스트를 되돌리고 MCP를 기다리면 shutdown이 상한을 넘깁니다. 그래서 순수 알림이 됩니다. 런타임을 바꿔도 물을 일은 같아요. 이 지점이 이미 일어난 일을 고치기에 아직 늦은가.
Claude Code: 이벤트 스물일곱, Prompt와 Agent가 진짜 달립니다
복원 소스에서 HOOK_EVENTS는 27항입니다. Codex의 열하나도 들어 있고, 실패 후 이벤트, 알림, 워크트리와 파일 변경도 있어요. execPromptHook은 작은 모델로 프롬프트를 돌리고, user message를 만들 때 processUserInput을 피해 UserPromptSubmit이 다시 안 울리게 합니다. execAgentHook은 완전한 query 한 라운드를 띄웁니다.
Claude의 런타임 면은 설정 면보다 넓습니다. Codex는 반대로, type 넷을 먼저 받고 실행기는 나중에 채워요. 이벤트 이름이 크게 겹치는 건 맞추기 동작입니다. ClaudeHooksEngine이라는 타입 이름이 제품 판단을 식별자에 적어 넣었어요.
DSH: 플러그인 폭포가 네이티브 인터페이스이고, hook 파일은 호환 다리입니다
DSH의 도구 파이프는 tools/pre-execute와 tools/post-execute에 폭포를 하나씩 엽니다. 네이티브 플러그인은 다리가 하는 일을 모두 할 수 있어요. hooks-codex는 다섯 점만 매핑합니다. PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop. rewrite도 없고 PermissionRequest도 없습니다. type: command가 아닌 것과 비동기는 파싱 뒤에 건너뜁니다.
DSH는 플러그인이 먼저 있고 파일 호환을 나중에 붙였습니다. Codex는 Claude 파일 계약이 먼저 있고, 플러그인이 같은 엔진에 선언을 넣게 해요. Grok은 이벤트 이름 열다섯으로 관찰 면을 채우고, is_blocking()은 PreToolUse에만 참을 돌리며, 승인 전 hook은 없습니다.
같은 PreToolUse, 반환값 두 가지
프로젝트에 matcher가 무해한 echo를 보는 PreToolUse 명령 hook이 있어요. 먼저 timeout을 1로 두고 스크립트에서 5초 sleep하세요. 그다음 스크립트를 종료 코드 2로 바꾸고 stderr에 blocked by test를 쓰세요.
두 결말을 밀어 보세요. 도구가 실행되는지, 모델이 무엇을 보는지, hook 상태가 failed인지 blocked인지. 그리고 첫 번째가 왜 “hook이 죽었다”를 차단으로 쓸 수 없는지 설명하세요.