OpenAI Codex · Hooks

훅이 바꿀 수 있는 건 이벤트 계약이 정합니다

한 turn은 훅 열하나를 지나갑니다. 프로토콜은 핸들러 네 종류를 알아보지만, 런타임 표에는 명령과 MCP만 올라갑니다. 타임아웃은 기본 통과이고, 해체 구간은 stdout을 버립니다.

강의 목표읽고 나면 세 가지를 말할 수 있어요. 네 type 가운데 무엇이 런타임 표에 들어가는지, hook 타임아웃이 왜 도구를 막지 못하는지, SessionEnd가 왜 출력을 읽지 않는지. 훅 지점은 생명주기에 미리 뚫어 둔 소켓이에요. 다음 단계를 바꾸는 건 이벤트 계약이지, 설정 파일 속 이름이 아닙니다.
먼저 해보기 · 한 turn에서 훅이 어떤 순서로 울리는지
같은 세션 타임라인에서 반환값만 바꿔 보고, 누가 다음 단계를 아직 바꿀 수 있는지 보세요
이번엔 어떻게 돌려줄지
반환값 다섯 가지를 같은 축에 걸어둡니다. 타임아웃과 아직 구현 안 된 type은 다음 단계를 못 바꿉니다. 명시적 차단, 또는 prompt를 단 block은 바꿀 수 있어요.
주축 · 한 turn이 지나는 훅
나란한 가는 축 · ThreadSpawn의 서브 에이전트만
이 단계가 받는 것, 바꿀 수 있는 것
받음아직 출발 전이에요.
바꿀 수 있음반환값을 먼저 고르고 재생하세요.
도구, 승인, 컨텍스트
도구아직 PreToolUse가 아니에요.
컨텍스트비어 있어요.
로직 궤적 · 애니메이션 각 단계가 소스의 어느 구간에 대응하는지
  1. 프로토콜 enum이 이벤트 이름 열하나를 나열합니다protocol.rs L1510
  2. 설정 층은 type 네 개를 알아보지만, 뒤의 둘은 빈 구조체입니다hook_config.rs L183
  3. 발견 단계에서 Prompt와 Agent를 not supported yet로 씁니다discovery.rs L626
  4. 런타임 표는 Command와 McpTool만 받습니다engine/mod.rs L107
  5. 동기 hook만 제어 효과를 걸 수 있습니다engine/mod.rs L146
  6. 타임아웃은 error를 쓰고 should_block은 false로 둡니다command_runner.rs L317
  7. 종료 코드 2에 stderr가 있어야 Blocked로 표시됩니다pre_tool_use.rs L261
  8. Allow는 일회성 Approved로 매핑됩니다approvals.rs L465
  9. Stop의 block은 prompt를 달고 있어야 턴 층에서 continue합니다turn.rs L509
  10. SessionEnd는 종료 코드 0이면 완료로 보고 stdout을 버립니다session_end.rs L109
재생을 눌러 보세요. 한 turn에서 각 훅이 무엇을 받고, 무엇을 바꿀 수 있고, 언제면 이미 늦은지 보입니다.
이름은 능력이 아니에요설정에는 Prompt와 Agent를 적을 수 있지만, 발견 단계에서 그 카드를 찢어 버립니다. 실제로 달리는 건 명령과 MCP 도구뿐이에요.
실패하면 기본 통과타임아웃, 크래시, 잘못된 JSON은 모두 제어 비트를 false로 둡니다. 막으려면 명시적 deny, 또는 종료 코드 2와 이유를 주세요.
위치가 계약을 정합니다도구가 끝난 뒤에 막으면, 막는 건 결과입니다. 해체 구간에 JSON을 써도 stdout은 그냥 버려져요.
수업용 도해:주축은 여덟 점으로 줄였고, 압축과 서브 에이전트는 옆길에 그려 트리거 순서와 계약 차이를 보여 줍니다. 로직 궤적 오른쪽 행 번호는 openai/codex 저장소 commit 4f39251a01에 대응합니다.
아이디어 1 · 알아보는 것과 실행 가능한 것을 갈라놓기
어떤 문제를 푸는가

방금 Claude Code에서 hooks.json 한 장을 옮겼어요. 파일에 핸들러가 셋입니다. 위험한 셸을 막는 명령, 작은 모델에게 사용자 제출을 심사시키는 프롬프트, Stop에서 자식 세션을 띄워 linter를 돌리는 agent. Claude 쪽에서는 셋 다 돌아갑니다.

Codex에 붙여 넣고 세션을 켭니다. 명령 줄은 켜집니다. 나머지 둘은 로그에 각각 not supported yet를 남깁니다. 프로토콜 enum에는 PromptAgent가 분명히 있고, 설정 파싱도 이 두 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 yetagent hooks are not supported yet입니다. 엔진에서 진짜 실행 가능한 종류는 명령과 MCP 도구뿐이에요. 일반 사용자 설정에서는 이 두 줄이 warning만 남기고 세션은 이어집니다. 호스팅 필수 hook에 이들을 넣으면 기동이 실패합니다.

출처:codex-rs/hooks/src/engine/discovery.rs 626–645행

프로토콜 enum 이벤트 11, type 4 설정 파싱 Prompt / Agent는 빈 구조체 런타임 표: Command / McpTool skip: not supported yet 엔진 타입 이름이 바로 ClaudeHooksEngine입니다 Claude 호환 hooks.json이 제품 입구예요. 이름 넷을 먼저 받고, 실행기는 나중에 채웁니다 wire enum에는 SessionEnd가 없어요. 해체 구간은 stdout을 아예 판별하지 않거든요
표 세 장을 따로 쓰세요. 알아보는 것, 파싱되는 것, 실행되는 것은 서로 다른 일입니다.

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 하나예요.

아이디어 2 · 실패하면 기본 통과
어떤 문제를 푸는가

어떤 사람이 PreToolUse 스크립트를 쓰고 타임아웃을 1초로 둔 뒤, 스크립트에서 5초를 sleep했습니다. hook이 죽으면 차단이라고 생각한 거죠. 도구는 그대로 실행됐어요. 로그에서 이 hook 상태는 failed이고, 문구는 timed out after 1s입니다.

아이디어는 무엇인가

run_command가 타임아웃이면 errorhook 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행

PreToolUse 타임아웃 / 크래시 종료 코드 2 + 이유 Failed, 도구는 계속 실행 Blocked, 도구 건너뜀 handle_any_tool RespondToModel hook 자신이 죽어도 도구는 달립니다. 이게 기본 통과예요.
같은 훅 지점에서, 타임아웃과 명시적 차단이 도구를 두 방향으로 데려갑니다.

승인 경로의 규칙은 거꾸로입니다. 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합니다. 그 층은 읽지 못한 필드를 허용으로 보면 안 되거든요.

아이디어 3 · 늦으면 이미 일어난 일을 못 고칩니다
어떤 문제를 푸는가

PostToolUse가 위험한 쓰기를 막고 싶어도, 파일은 이미 디스크에 있습니다. SessionEnd가 컨텍스트에 마무리 설명을 넣고 싶어도, stdout은 버려집니다. wire enum에도 이 이벤트 이름이 없어요. 두 사고의 공통점은, 훅 지점이 바꿀 수 있는 구간을 이미 지나왔다는 것입니다.

아이디어는 무엇인가

훅 지점 열하나는 생명주기 순입니다. 주축은 SessionStartUserPromptSubmitPreToolUsePermissionRequest → 도구 → PostToolUsePreCompactPostCompactStopSessionEnd. 서브 에이전트는 가는 축을 따로 가고, SubagentStartSubagentStop은 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는 여기서 무시됩니다. SessionStartcontinue: 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이 상한을 넘깁니다. 그래서 순수 알림이 됩니다. 런타임을 바꿔도 물을 일은 같아요. 이 지점이 이미 일어난 일을 고치기에 아직 늦은가.

가로 비교 · 같은 hooks.json을 받는 세 가지 접법

Claude Code: 이벤트 스물일곱, Prompt와 Agent가 진짜 달립니다

복원 소스에서 HOOK_EVENTS는 27항입니다. Codex의 열하나도 들어 있고, 실패 후 이벤트, 알림, 워크트리와 파일 변경도 있어요. execPromptHook은 작은 모델로 프롬프트를 돌리고, user message를 만들 때 processUserInput을 피해 UserPromptSubmit이 다시 안 울리게 합니다. execAgentHook은 완전한 query 한 라운드를 띄웁니다.

Claude의 런타임 면은 설정 면보다 넓습니다. Codex는 반대로, type 넷을 먼저 받고 실행기는 나중에 채워요. 이벤트 이름이 크게 겹치는 건 맞추기 동작입니다. ClaudeHooksEngine이라는 타입 이름이 제품 판단을 식별자에 적어 넣었어요.

양쪽 모두 소스 대조 완료 · 2026-08-22

DSH: 플러그인 폭포가 네이티브 인터페이스이고, hook 파일은 호환 다리입니다

DSH의 도구 파이프는 tools/pre-executetools/post-execute에 폭포를 하나씩 엽니다. 네이티브 플러그인은 다리가 하는 일을 모두 할 수 있어요. hooks-codex는 다섯 점만 매핑합니다. PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop. rewrite도 없고 PermissionRequest도 없습니다. type: command가 아닌 것과 비동기는 파싱 뒤에 건너뜁니다.

DSH는 플러그인이 먼저 있고 파일 호환을 나중에 붙였습니다. Codex는 Claude 파일 계약이 먼저 있고, 플러그인이 같은 엔진에 선언을 넣게 해요. Grok은 이벤트 이름 열다섯으로 관찰 면을 채우고, is_blocking()PreToolUse에만 참을 돌리며, 승인 전 hook은 없습니다.

양쪽 모두 소스 대조 완료 · 2026-08-22 · DSH · 플러그인 폭포
수업 실습
01

같은 PreToolUse, 반환값 두 가지

프로젝트에 matcher가 무해한 echo를 보는 PreToolUse 명령 hook이 있어요. 먼저 timeout을 1로 두고 스크립트에서 5초 sleep하세요. 그다음 스크립트를 종료 코드 2로 바꾸고 stderr에 blocked by test를 쓰세요.

두 결말을 밀어 보세요. 도구가 실행되는지, 모델이 무엇을 보는지, hook 상태가 failed인지 blocked인지. 그리고 첫 번째가 왜 “hook이 죽었다”를 차단으로 쓸 수 없는지 설명하세요.

Takeaway:프로토콜, 설정, 런타임은 표 세 장이고, 달릴 수 있는 건 명령과 MCP뿐입니다. 실패는 기본 통과이니, 막으려면 이유를 주세요. 각 훅이 바꿀 수 있는 것은 생명주기 속 위치에 묶여 있어요. 해체 구간과 도구가 끝난 뒤에는, 이미 일어난 일을 고치기에 늦습니다.