start of hooks engine (#13276)

(Experimental)

This PR adds a first MVP for hooks, with SessionStart and Stop

The core design is:

- hooks live in a dedicated engine under codex-rs/hooks
- each hook type has its own event-specific file
- hook execution is synchronous and blocks normal turn progression while
running
- matching hooks run in parallel, then their results are aggregated into
a normalized HookRunSummary

On the AppServer side, hooks are exposed as operational metadata rather
than transcript-native items:

- new live notifications: hook/started, hook/completed
- persisted/replayed hook results live on Turn.hookRuns
- we intentionally did not add hook-specific ThreadItem variants

Hooks messages are not persisted, they remain ephemeral. The context
changes they add are (they get appended to the user's prompt)
This commit is contained in:
Andrei Eternal 2026-03-09 21:11:31 -07:00 committed by GitHub
parent da616136cc
commit 244b2d53f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 4791 additions and 483 deletions

3
codex-rs/Cargo.lock generated
View file

@ -2055,9 +2055,12 @@ version = "0.0.0"
dependencies = [
"anyhow",
"chrono",
"codex-config",
"codex-protocol",
"futures",
"pretty_assertions",
"regex",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",

View file

@ -2818,6 +2818,58 @@
"title": "ItemCompletedEventMsg",
"type": "object"
},
{
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"turn_id": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"hook_started"
],
"title": "HookStartedEventMsgType",
"type": "string"
}
},
"required": [
"run",
"type"
],
"title": "HookStartedEventMsg",
"type": "object"
},
{
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"turn_id": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"hook_completed"
],
"title": "HookCompletedEventMsgType",
"type": "string"
}
},
"required": [
"run",
"type"
],
"title": "HookCompletedEventMsg",
"type": "object"
},
{
"properties": {
"delta": {
@ -3756,6 +3808,142 @@
],
"type": "object"
},
"HookEventName": {
"enum": [
"session_start",
"stop"
],
"type": "string"
},
"HookExecutionMode": {
"enum": [
"sync",
"async"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookOutputEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/HookOutputEntryKind"
},
"text": {
"type": "string"
}
},
"required": [
"kind",
"text"
],
"type": "object"
},
"HookOutputEntryKind": {
"enum": [
"warning",
"stop",
"feedback",
"context",
"error"
],
"type": "string"
},
"HookRunStatus": {
"enum": [
"running",
"completed",
"failed",
"blocked",
"stopped"
],
"type": "string"
},
"HookRunSummary": {
"properties": {
"completed_at": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"display_order": {
"format": "int64",
"type": "integer"
},
"duration_ms": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"entries": {
"items": {
"$ref": "#/definitions/HookOutputEntry"
},
"type": "array"
},
"event_name": {
"$ref": "#/definitions/HookEventName"
},
"execution_mode": {
"$ref": "#/definitions/HookExecutionMode"
},
"handler_type": {
"$ref": "#/definitions/HookHandlerType"
},
"id": {
"type": "string"
},
"scope": {
"$ref": "#/definitions/HookScope"
},
"source_path": {
"type": "string"
},
"started_at": {
"format": "int64",
"type": "integer"
},
"status": {
"$ref": "#/definitions/HookRunStatus"
},
"status_message": {
"type": [
"string",
"null"
]
}
},
"required": [
"display_order",
"entries",
"event_name",
"execution_mode",
"handler_type",
"id",
"scope",
"source_path",
"started_at",
"status"
],
"type": "object"
},
"HookScope": {
"enum": [
"thread",
"turn"
],
"type": "string"
},
"ImageDetail": {
"enum": [
"auto",
@ -8693,6 +8881,58 @@
"title": "ItemCompletedEventMsg",
"type": "object"
},
{
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"turn_id": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"hook_started"
],
"title": "HookStartedEventMsgType",
"type": "string"
}
},
"required": [
"run",
"type"
],
"title": "HookStartedEventMsg",
"type": "object"
},
{
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"turn_id": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"hook_completed"
],
"title": "HookCompletedEventMsgType",
"type": "string"
}
},
"required": [
"run",
"type"
],
"title": "HookCompletedEventMsg",
"type": "object"
},
{
"properties": {
"delta": {

View file

@ -1056,6 +1056,184 @@
},
"type": "object"
},
"HookCompletedNotification": {
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": [
"string",
"null"
]
}
},
"required": [
"run",
"threadId"
],
"type": "object"
},
"HookEventName": {
"enum": [
"sessionStart",
"stop"
],
"type": "string"
},
"HookExecutionMode": {
"enum": [
"sync",
"async"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookOutputEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/HookOutputEntryKind"
},
"text": {
"type": "string"
}
},
"required": [
"kind",
"text"
],
"type": "object"
},
"HookOutputEntryKind": {
"enum": [
"warning",
"stop",
"feedback",
"context",
"error"
],
"type": "string"
},
"HookRunStatus": {
"enum": [
"running",
"completed",
"failed",
"blocked",
"stopped"
],
"type": "string"
},
"HookRunSummary": {
"properties": {
"completedAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"displayOrder": {
"format": "int64",
"type": "integer"
},
"durationMs": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"entries": {
"items": {
"$ref": "#/definitions/HookOutputEntry"
},
"type": "array"
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"executionMode": {
"$ref": "#/definitions/HookExecutionMode"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"id": {
"type": "string"
},
"scope": {
"$ref": "#/definitions/HookScope"
},
"sourcePath": {
"type": "string"
},
"startedAt": {
"format": "int64",
"type": "integer"
},
"status": {
"$ref": "#/definitions/HookRunStatus"
},
"statusMessage": {
"type": [
"string",
"null"
]
}
},
"required": [
"displayOrder",
"entries",
"eventName",
"executionMode",
"handlerType",
"id",
"scope",
"sourcePath",
"startedAt",
"status"
],
"type": "object"
},
"HookScope": {
"enum": [
"thread",
"turn"
],
"type": "string"
},
"HookStartedNotification": {
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": [
"string",
"null"
]
}
},
"required": [
"run",
"threadId"
],
"type": "object"
},
"ItemCompletedNotification": {
"properties": {
"item": {
@ -3378,6 +3556,26 @@
"title": "Turn/startedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"hook/started"
],
"title": "Hook/startedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HookStartedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Hook/startedNotification",
"type": "object"
},
{
"properties": {
"method": {
@ -3398,6 +3596,26 @@
"title": "Turn/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"hook/completed"
],
"title": "Hook/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HookCompletedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Hook/completedNotification",
"type": "object"
},
{
"properties": {
"method": {

View file

@ -4169,6 +4169,58 @@
"title": "ItemCompletedEventMsg",
"type": "object"
},
{
"properties": {
"run": {
"$ref": "#/definitions/v2/HookRunSummary"
},
"turn_id": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"hook_started"
],
"title": "HookStartedEventMsgType",
"type": "string"
}
},
"required": [
"run",
"type"
],
"title": "HookStartedEventMsg",
"type": "object"
},
{
"properties": {
"run": {
"$ref": "#/definitions/v2/HookRunSummary"
},
"turn_id": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"hook_completed"
],
"title": "HookCompletedEventMsgType",
"type": "string"
}
},
"required": [
"run",
"type"
],
"title": "HookCompletedEventMsg",
"type": "object"
},
{
"properties": {
"delta": {
@ -7244,6 +7296,26 @@
"title": "Turn/startedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"hook/started"
],
"title": "Hook/startedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/HookStartedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Hook/startedNotification",
"type": "object"
},
{
"properties": {
"method": {
@ -7264,6 +7336,26 @@
"title": "Turn/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"hook/completed"
],
"title": "Hook/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/HookCompletedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Hook/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
@ -11406,6 +11498,188 @@
],
"type": "string"
},
"HookCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"run": {
"$ref": "#/definitions/v2/HookRunSummary"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": [
"string",
"null"
]
}
},
"required": [
"run",
"threadId"
],
"title": "HookCompletedNotification",
"type": "object"
},
"HookEventName": {
"enum": [
"sessionStart",
"stop"
],
"type": "string"
},
"HookExecutionMode": {
"enum": [
"sync",
"async"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookOutputEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/v2/HookOutputEntryKind"
},
"text": {
"type": "string"
}
},
"required": [
"kind",
"text"
],
"type": "object"
},
"HookOutputEntryKind": {
"enum": [
"warning",
"stop",
"feedback",
"context",
"error"
],
"type": "string"
},
"HookRunStatus": {
"enum": [
"running",
"completed",
"failed",
"blocked",
"stopped"
],
"type": "string"
},
"HookRunSummary": {
"properties": {
"completedAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"displayOrder": {
"format": "int64",
"type": "integer"
},
"durationMs": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"entries": {
"items": {
"$ref": "#/definitions/v2/HookOutputEntry"
},
"type": "array"
},
"eventName": {
"$ref": "#/definitions/v2/HookEventName"
},
"executionMode": {
"$ref": "#/definitions/v2/HookExecutionMode"
},
"handlerType": {
"$ref": "#/definitions/v2/HookHandlerType"
},
"id": {
"type": "string"
},
"scope": {
"$ref": "#/definitions/v2/HookScope"
},
"sourcePath": {
"type": "string"
},
"startedAt": {
"format": "int64",
"type": "integer"
},
"status": {
"$ref": "#/definitions/v2/HookRunStatus"
},
"statusMessage": {
"type": [
"string",
"null"
]
}
},
"required": [
"displayOrder",
"entries",
"eventName",
"executionMode",
"handlerType",
"id",
"scope",
"sourcePath",
"startedAt",
"status"
],
"type": "object"
},
"HookScope": {
"enum": [
"thread",
"turn"
],
"type": "string"
},
"HookStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"run": {
"$ref": "#/definitions/v2/HookRunSummary"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": [
"string",
"null"
]
}
},
"required": [
"run",
"threadId"
],
"title": "HookStartedNotification",
"type": "object"
},
"ImageDetail": {
"enum": [
"auto",

View file

@ -5979,6 +5979,58 @@
"title": "ItemCompletedEventMsg",
"type": "object"
},
{
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"turn_id": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"hook_started"
],
"title": "HookStartedEventMsgType",
"type": "string"
}
},
"required": [
"run",
"type"
],
"title": "HookStartedEventMsg",
"type": "object"
},
{
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"turn_id": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"hook_completed"
],
"title": "HookCompletedEventMsgType",
"type": "string"
}
},
"required": [
"run",
"type"
],
"title": "HookCompletedEventMsg",
"type": "object"
},
{
"properties": {
"delta": {
@ -7441,6 +7493,188 @@
],
"type": "object"
},
"HookCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": [
"string",
"null"
]
}
},
"required": [
"run",
"threadId"
],
"title": "HookCompletedNotification",
"type": "object"
},
"HookEventName": {
"enum": [
"sessionStart",
"stop"
],
"type": "string"
},
"HookExecutionMode": {
"enum": [
"sync",
"async"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookOutputEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/HookOutputEntryKind"
},
"text": {
"type": "string"
}
},
"required": [
"kind",
"text"
],
"type": "object"
},
"HookOutputEntryKind": {
"enum": [
"warning",
"stop",
"feedback",
"context",
"error"
],
"type": "string"
},
"HookRunStatus": {
"enum": [
"running",
"completed",
"failed",
"blocked",
"stopped"
],
"type": "string"
},
"HookRunSummary": {
"properties": {
"completedAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"displayOrder": {
"format": "int64",
"type": "integer"
},
"durationMs": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"entries": {
"items": {
"$ref": "#/definitions/HookOutputEntry"
},
"type": "array"
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"executionMode": {
"$ref": "#/definitions/HookExecutionMode"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"id": {
"type": "string"
},
"scope": {
"$ref": "#/definitions/HookScope"
},
"sourcePath": {
"type": "string"
},
"startedAt": {
"format": "int64",
"type": "integer"
},
"status": {
"$ref": "#/definitions/HookRunStatus"
},
"statusMessage": {
"type": [
"string",
"null"
]
}
},
"required": [
"displayOrder",
"entries",
"eventName",
"executionMode",
"handlerType",
"id",
"scope",
"sourcePath",
"startedAt",
"status"
],
"type": "object"
},
"HookScope": {
"enum": [
"thread",
"turn"
],
"type": "string"
},
"HookStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": [
"string",
"null"
]
}
},
"required": [
"run",
"threadId"
],
"title": "HookStartedNotification",
"type": "object"
},
"ImageDetail": {
"enum": [
"auto",
@ -11090,6 +11324,26 @@
"title": "Turn/startedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"hook/started"
],
"title": "Hook/startedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HookStartedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Hook/startedNotification",
"type": "object"
},
{
"properties": {
"method": {
@ -11110,6 +11364,26 @@
"title": "Turn/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"hook/completed"
],
"title": "Hook/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HookCompletedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Hook/completedNotification",
"type": "object"
},
{
"properties": {
"method": {

View file

@ -0,0 +1,161 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"HookEventName": {
"enum": [
"sessionStart",
"stop"
],
"type": "string"
},
"HookExecutionMode": {
"enum": [
"sync",
"async"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookOutputEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/HookOutputEntryKind"
},
"text": {
"type": "string"
}
},
"required": [
"kind",
"text"
],
"type": "object"
},
"HookOutputEntryKind": {
"enum": [
"warning",
"stop",
"feedback",
"context",
"error"
],
"type": "string"
},
"HookRunStatus": {
"enum": [
"running",
"completed",
"failed",
"blocked",
"stopped"
],
"type": "string"
},
"HookRunSummary": {
"properties": {
"completedAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"displayOrder": {
"format": "int64",
"type": "integer"
},
"durationMs": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"entries": {
"items": {
"$ref": "#/definitions/HookOutputEntry"
},
"type": "array"
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"executionMode": {
"$ref": "#/definitions/HookExecutionMode"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"id": {
"type": "string"
},
"scope": {
"$ref": "#/definitions/HookScope"
},
"sourcePath": {
"type": "string"
},
"startedAt": {
"format": "int64",
"type": "integer"
},
"status": {
"$ref": "#/definitions/HookRunStatus"
},
"statusMessage": {
"type": [
"string",
"null"
]
}
},
"required": [
"displayOrder",
"entries",
"eventName",
"executionMode",
"handlerType",
"id",
"scope",
"sourcePath",
"startedAt",
"status"
],
"type": "object"
},
"HookScope": {
"enum": [
"thread",
"turn"
],
"type": "string"
}
},
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": [
"string",
"null"
]
}
},
"required": [
"run",
"threadId"
],
"title": "HookCompletedNotification",
"type": "object"
}

View file

@ -0,0 +1,161 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"HookEventName": {
"enum": [
"sessionStart",
"stop"
],
"type": "string"
},
"HookExecutionMode": {
"enum": [
"sync",
"async"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookOutputEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/HookOutputEntryKind"
},
"text": {
"type": "string"
}
},
"required": [
"kind",
"text"
],
"type": "object"
},
"HookOutputEntryKind": {
"enum": [
"warning",
"stop",
"feedback",
"context",
"error"
],
"type": "string"
},
"HookRunStatus": {
"enum": [
"running",
"completed",
"failed",
"blocked",
"stopped"
],
"type": "string"
},
"HookRunSummary": {
"properties": {
"completedAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"displayOrder": {
"format": "int64",
"type": "integer"
},
"durationMs": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"entries": {
"items": {
"$ref": "#/definitions/HookOutputEntry"
},
"type": "array"
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"executionMode": {
"$ref": "#/definitions/HookExecutionMode"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"id": {
"type": "string"
},
"scope": {
"$ref": "#/definitions/HookScope"
},
"sourcePath": {
"type": "string"
},
"startedAt": {
"format": "int64",
"type": "integer"
},
"status": {
"$ref": "#/definitions/HookRunStatus"
},
"statusMessage": {
"type": [
"string",
"null"
]
}
},
"required": [
"displayOrder",
"entries",
"eventName",
"executionMode",
"handlerType",
"id",
"scope",
"sourcePath",
"startedAt",
"status"
],
"type": "object"
},
"HookScope": {
"enum": [
"thread",
"turn"
],
"type": "string"
}
},
"properties": {
"run": {
"$ref": "#/definitions/HookRunSummary"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": [
"string",
"null"
]
}
},
"required": [
"run",
"threadId"
],
"title": "HookStartedNotification",
"type": "object"
}

View file

@ -33,6 +33,8 @@ import type { ExecCommandEndEvent } from "./ExecCommandEndEvent";
import type { ExecCommandOutputDeltaEvent } from "./ExecCommandOutputDeltaEvent";
import type { ExitedReviewModeEvent } from "./ExitedReviewModeEvent";
import type { GetHistoryEntryResponseEvent } from "./GetHistoryEntryResponseEvent";
import type { HookCompletedEvent } from "./HookCompletedEvent";
import type { HookStartedEvent } from "./HookStartedEvent";
import type { ImageGenerationBeginEvent } from "./ImageGenerationBeginEvent";
import type { ImageGenerationEndEvent } from "./ImageGenerationEndEvent";
import type { ItemCompletedEvent } from "./ItemCompletedEvent";
@ -82,4 +84,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent";
* Response event from the agent
* NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.
*/
export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "image_generation_begin" } & ImageGenerationBeginEvent | { "type": "image_generation_end" } & ImageGenerationEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_permissions" } & RequestPermissionsEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "dynamic_tool_call_response" } & DynamicToolCallResponseEvent | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent;
export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "image_generation_begin" } & ImageGenerationBeginEvent | { "type": "image_generation_end" } & ImageGenerationEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_permissions" } & RequestPermissionsEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "dynamic_tool_call_response" } & DynamicToolCallResponseEvent | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "hook_started" } & HookStartedEvent | { "type": "hook_completed" } & HookCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent;

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookRunSummary } from "./HookRunSummary";
export type HookCompletedEvent = { turn_id: string | null, run: HookRunSummary, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookEventName = "session_start" | "stop";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookExecutionMode = "sync" | "async";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookHandlerType = "command" | "prompt" | "agent";

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookOutputEntryKind } from "./HookOutputEntryKind";
export type HookOutputEntry = { kind: HookOutputEntryKind, text: string, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookOutputEntryKind = "warning" | "stop" | "feedback" | "context" | "error";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookRunStatus = "running" | "completed" | "failed" | "blocked" | "stopped";

View file

@ -0,0 +1,11 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookEventName } from "./HookEventName";
import type { HookExecutionMode } from "./HookExecutionMode";
import type { HookHandlerType } from "./HookHandlerType";
import type { HookOutputEntry } from "./HookOutputEntry";
import type { HookRunStatus } from "./HookRunStatus";
import type { HookScope } from "./HookScope";
export type HookRunSummary = { id: string, event_name: HookEventName, handler_type: HookHandlerType, execution_mode: HookExecutionMode, scope: HookScope, source_path: string, display_order: bigint, status: HookRunStatus, status_message: string | null, started_at: number, completed_at: number | null, duration_ms: number | null, entries: Array<HookOutputEntry>, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookScope = "thread" | "turn";

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookRunSummary } from "./HookRunSummary";
export type HookStartedEvent = { turn_id: string | null, run: HookRunSummary, };

View file

@ -15,6 +15,8 @@ import type { ContextCompactedNotification } from "./v2/ContextCompactedNotifica
import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification";
import type { ErrorNotification } from "./v2/ErrorNotification";
import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification";
import type { HookCompletedNotification } from "./v2/HookCompletedNotification";
import type { HookStartedNotification } from "./v2/HookStartedNotification";
import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification";
import type { ItemStartedNotification } from "./v2/ItemStartedNotification";
import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification";
@ -50,4 +52,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };

View file

@ -85,6 +85,16 @@ export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams";
export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse";
export type { GitSha } from "./GitSha";
export type { HistoryEntry } from "./HistoryEntry";
export type { HookCompletedEvent } from "./HookCompletedEvent";
export type { HookEventName } from "./HookEventName";
export type { HookExecutionMode } from "./HookExecutionMode";
export type { HookHandlerType } from "./HookHandlerType";
export type { HookOutputEntry } from "./HookOutputEntry";
export type { HookOutputEntryKind } from "./HookOutputEntryKind";
export type { HookRunStatus } from "./HookRunStatus";
export type { HookRunSummary } from "./HookRunSummary";
export type { HookScope } from "./HookScope";
export type { HookStartedEvent } from "./HookStartedEvent";
export type { ImageDetail } from "./ImageDetail";
export type { ImageGenerationBeginEvent } from "./ImageGenerationBeginEvent";
export type { ImageGenerationEndEvent } from "./ImageGenerationEndEvent";

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookRunSummary } from "./HookRunSummary";
export type HookCompletedNotification = { threadId: string, turnId: string | null, run: HookRunSummary, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookEventName = "sessionStart" | "stop";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookExecutionMode = "sync" | "async";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookHandlerType = "command" | "prompt" | "agent";

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookOutputEntryKind } from "./HookOutputEntryKind";
export type HookOutputEntry = { kind: HookOutputEntryKind, text: string, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookOutputEntryKind = "warning" | "stop" | "feedback" | "context" | "error";

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookRunStatus = "running" | "completed" | "failed" | "blocked" | "stopped";

View file

@ -0,0 +1,11 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookEventName } from "./HookEventName";
import type { HookExecutionMode } from "./HookExecutionMode";
import type { HookHandlerType } from "./HookHandlerType";
import type { HookOutputEntry } from "./HookOutputEntry";
import type { HookRunStatus } from "./HookRunStatus";
import type { HookScope } from "./HookScope";
export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: string, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array<HookOutputEntry>, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookScope = "thread" | "turn";

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HookRunSummary } from "./HookRunSummary";
export type HookStartedNotification = { threadId: string, turnId: string | null, run: HookRunSummary, };

View file

@ -102,6 +102,16 @@ export type { GitInfo } from "./GitInfo";
export type { GrantedMacOsPermissions } from "./GrantedMacOsPermissions";
export type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
export type { HazelnutScope } from "./HazelnutScope";
export type { HookCompletedNotification } from "./HookCompletedNotification";
export type { HookEventName } from "./HookEventName";
export type { HookExecutionMode } from "./HookExecutionMode";
export type { HookHandlerType } from "./HookHandlerType";
export type { HookOutputEntry } from "./HookOutputEntry";
export type { HookOutputEntryKind } from "./HookOutputEntryKind";
export type { HookRunStatus } from "./HookRunStatus";
export type { HookRunSummary } from "./HookRunSummary";
export type { HookScope } from "./HookScope";
export type { HookStartedNotification } from "./HookStartedNotification";
export type { ItemCompletedNotification } from "./ItemCompletedNotification";
export type { ItemStartedNotification } from "./ItemStartedNotification";
export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams";

View file

@ -828,7 +828,9 @@ server_notification_definitions! {
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification),
TurnStarted => "turn/started" (v2::TurnStartedNotification),
HookStarted => "hook/started" (v2::HookStartedNotification),
TurnCompleted => "turn/completed" (v2::TurnCompletedNotification),
HookCompleted => "hook/completed" (v2::HookCompletedNotification),
TurnDiffUpdated => "turn/diff/updated" (v2::TurnDiffUpdatedNotification),
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),
ItemStarted => "item/started" (v2::ItemStartedNotification),

View file

@ -166,6 +166,7 @@ impl ThreadHistoryBuilder {
EventMsg::ExitedReviewMode(payload) => self.handle_exited_review_mode(payload),
EventMsg::ItemStarted(payload) => self.handle_item_started(payload),
EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload),
EventMsg::HookStarted(_) | EventMsg::HookCompleted(_) => {}
EventMsg::Error(payload) => self.handle_error(payload),
EventMsg::TokenCount(_) => {}
EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload),

View file

@ -49,6 +49,14 @@ use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus;
use codex_protocol::protocol::HookEventName as CoreHookEventName;
use codex_protocol::protocol::HookExecutionMode as CoreHookExecutionMode;
use codex_protocol::protocol::HookHandlerType as CoreHookHandlerType;
use codex_protocol::protocol::HookOutputEntry as CoreHookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus;
use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
use codex_protocol::protocol::HookScope as CoreHookScope;
use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
@ -288,6 +296,98 @@ v2_enum_from_core!(
}
);
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
SessionStart, Stop
}
);
v2_enum_from_core!(
pub enum HookHandlerType from CoreHookHandlerType {
Command, Prompt, Agent
}
);
v2_enum_from_core!(
pub enum HookExecutionMode from CoreHookExecutionMode {
Sync, Async
}
);
v2_enum_from_core!(
pub enum HookScope from CoreHookScope {
Thread, Turn
}
);
v2_enum_from_core!(
pub enum HookRunStatus from CoreHookRunStatus {
Running, Completed, Failed, Blocked, Stopped
}
);
v2_enum_from_core!(
pub enum HookOutputEntryKind from CoreHookOutputEntryKind {
Warning, Stop, Feedback, Context, Error
}
);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookOutputEntry {
pub kind: HookOutputEntryKind,
pub text: String,
}
impl From<CoreHookOutputEntry> for HookOutputEntry {
fn from(value: CoreHookOutputEntry) -> Self {
Self {
kind: value.kind.into(),
text: value.text,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookRunSummary {
pub id: String,
pub event_name: HookEventName,
pub handler_type: HookHandlerType,
pub execution_mode: HookExecutionMode,
pub scope: HookScope,
pub source_path: PathBuf,
pub display_order: i64,
pub status: HookRunStatus,
pub status_message: Option<String>,
pub started_at: i64,
pub completed_at: Option<i64>,
pub duration_ms: Option<i64>,
pub entries: Vec<HookOutputEntry>,
}
impl From<CoreHookRunSummary> for HookRunSummary {
fn from(value: CoreHookRunSummary) -> Self {
Self {
id: value.id,
event_name: value.event_name.into(),
handler_type: value.handler_type.into(),
execution_mode: value.execution_mode.into(),
scope: value.scope.into(),
source_path: value.source_path,
display_order: value.display_order,
status: value.status.into(),
status_message: value.status_message,
started_at: value.started_at,
completed_at: value.completed_at,
duration_ms: value.duration_ms,
entries: value.entries.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
@ -4108,6 +4208,15 @@ pub struct TurnStartedNotification {
pub turn: Turn,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookStartedNotification {
pub thread_id: String,
pub turn_id: Option<String>,
pub run: HookRunSummary,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@ -4125,6 +4234,15 @@ pub struct TurnCompletedNotification {
pub turn: Turn,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookCompletedNotification {
pub thread_id: String,
pub turn_id: Option<String>,
pub run: HookRunSummary,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View file

@ -43,6 +43,8 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile;
use codex_app_server_protocol::HookCompletedNotification;
use codex_app_server_protocol::HookStartedNotification;
use codex_app_server_protocol::InterruptConversationResponse;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
@ -1308,6 +1310,30 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::ItemCompleted(notification))
.await;
}
EventMsg::HookStarted(event) => {
if let ApiVersion::V2 = api_version {
let notification = HookStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event.turn_id,
run: event.run.into(),
};
outgoing
.send_server_notification(ServerNotification::HookStarted(notification))
.await;
}
}
EventMsg::HookCompleted(event) => {
if let ApiVersion::V2 = api_version {
let notification = HookCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: event.turn_id,
run: event.run.into(),
};
outgoing
.send_server_notification(ServerNotification::HookCompleted(notification))
.await;
}
}
EventMsg::ExitedReviewMode(review_event) => {
let review = match review_event.review_output {
Some(output) => render_review_output_text(&output),

View file

@ -332,6 +332,9 @@
"codex_git_commit": {
"type": "boolean"
},
"codex_hooks": {
"type": "boolean"
},
"collab": {
"type": "boolean"
},
@ -1815,6 +1818,9 @@
"codex_git_commit": {
"type": "boolean"
},
"codex_hooks": {
"type": "boolean"
},
"collab": {
"type": "boolean"
},

View file

@ -1560,6 +1560,25 @@ impl Session {
(None, None)
};
let mut hook_shell_argv = default_shell.derive_exec_args("", false);
let hook_shell_program = hook_shell_argv.remove(0);
let _ = hook_shell_argv.pop();
let hooks = Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
feature_enabled: config.features.enabled(Feature::CodexHooks),
config_layer_stack: Some(config.config_layer_stack.clone()),
shell_program: Some(hook_shell_program),
shell_args: hook_shell_argv,
});
for warning in hooks.startup_warnings() {
post_session_configured_events.push(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::Warning(WarningEvent {
message: warning.clone(),
}),
});
}
let services = SessionServices {
// Initialize the MCP connection manager with an uninitialized
// instance. It will be replaced with one created via
@ -1581,9 +1600,7 @@ impl Session {
Arc::clone(&config),
Arc::clone(&auth_manager),
),
hooks: Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
}),
hooks,
rollout: Mutex::new(rollout_recorder),
user_shell: Arc::new(default_shell),
shell_snapshot_tx,
@ -1730,9 +1747,19 @@ impl Session {
}
sess.schedule_startup_prewarm(session_configuration.base_instructions.clone())
.await;
let session_start_source = match &initial_history {
InitialHistory::Resumed(_) => codex_hooks::SessionStartSource::Resume,
InitialHistory::New | InitialHistory::Forked(_) => {
codex_hooks::SessionStartSource::Startup
}
};
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
sess.record_initial_history(initial_history).await;
{
let mut state = sess.state.lock().await;
state.set_pending_session_start_source(Some(session_start_source));
}
memories::start_memories_startup_task(
&sess,
@ -3838,6 +3865,21 @@ impl Session {
Arc::clone(&self.services.user_shell)
}
pub(crate) async fn current_rollout_path(&self) -> Option<PathBuf> {
let recorder = {
let guard = self.services.rollout.lock().await;
guard.clone()
};
recorder.map(|recorder| recorder.rollout_path().to_path_buf())
}
pub(crate) async fn take_pending_session_start_source(
&self,
) -> Option<codex_hooks::SessionStartSource> {
let mut state = self.state.lock().await;
state.take_pending_session_start_source()
}
async fn refresh_mcp_servers_inner(
&self,
turn_context: &TurnContext,
@ -5467,6 +5509,8 @@ pub(crate) async fn run_turn(
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
.await;
let mut last_agent_message: Option<String> = None;
let mut stop_hook_active = false;
let mut pending_stop_hook_message: Option<String> = None;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
@ -5478,6 +5522,55 @@ pub(crate) async fn run_turn(
prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session());
loop {
if let Some(session_start_source) = sess.take_pending_session_start_source().await {
let session_start_permission_mode = match turn_context.approval_policy.value() {
AskForApproval::Never => "bypassPermissions",
AskForApproval::UnlessTrusted
| AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::Reject(_) => "default",
}
.to_string();
let session_start_request = codex_hooks::SessionStartRequest {
session_id: sess.conversation_id,
cwd: turn_context.cwd.clone(),
transcript_path: sess.current_rollout_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: session_start_permission_mode,
source: session_start_source,
};
for run in sess.hooks().preview_session_start(&session_start_request) {
sess.send_event(
&turn_context,
EventMsg::HookStarted(crate::protocol::HookStartedEvent {
turn_id: Some(turn_context.sub_id.clone()),
run,
}),
)
.await;
}
let session_start_outcome = sess
.hooks()
.run_session_start(session_start_request, Some(turn_context.sub_id.clone()))
.await;
for completed in session_start_outcome.hook_events {
sess.send_event(&turn_context, EventMsg::HookCompleted(completed))
.await;
}
if session_start_outcome.should_stop {
break;
}
if let Some(additional_context) = session_start_outcome.additional_context {
let developer_message: ResponseItem =
DeveloperInstructions::new(additional_context).into();
sess.record_conversation_items(
&turn_context,
std::slice::from_ref(&developer_message),
)
.await;
}
}
// Note that pending_input would be something like a message the user
// submitted through the UI while the model was running. Though the UI
// may support this, the model might not.
@ -5509,11 +5602,14 @@ pub(crate) async fn run_turn(
}
// Construct the input that we will send to the model.
let sampling_request_input: Vec<ResponseItem> = {
let mut sampling_request_input: Vec<ResponseItem> = {
sess.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities)
};
if let Some(stop_hook_message) = pending_stop_hook_message.take() {
sampling_request_input.push(DeveloperInstructions::new(stop_hook_message).into());
}
let sampling_request_input_messages = sampling_request_input
.iter()
@ -5576,6 +5672,57 @@ pub(crate) async fn run_turn(
if !needs_follow_up {
last_agent_message = sampling_request_last_agent_message;
let stop_hook_permission_mode = match turn_context.approval_policy.value() {
AskForApproval::Never => "bypassPermissions",
AskForApproval::UnlessTrusted
| AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::Reject(_) => "default",
}
.to_string();
let stop_request = codex_hooks::StopRequest {
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
cwd: turn_context.cwd.clone(),
transcript_path: sess.current_rollout_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: stop_hook_permission_mode,
stop_hook_active,
last_assistant_message: last_agent_message.clone(),
};
for run in sess.hooks().preview_stop(&stop_request) {
sess.send_event(
&turn_context,
EventMsg::HookStarted(crate::protocol::HookStartedEvent {
turn_id: Some(turn_context.sub_id.clone()),
run,
}),
)
.await;
}
let stop_outcome = sess.hooks().run_stop(stop_request).await;
for completed in stop_outcome.hook_events {
sess.send_event(&turn_context, EventMsg::HookCompleted(completed))
.await;
}
if stop_outcome.should_block {
if stop_hook_active {
sess.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: "Stop hook blocked twice in the same turn; ignoring the second block to avoid an infinite loop.".to_string(),
}),
)
.await;
} else {
stop_hook_active = true;
pending_stop_hook_message = stop_outcome.block_message_for_model;
continue;
}
}
if stop_outcome.should_stop {
break;
}
let hook_outcomes = sess
.hooks()
.dispatch(HookPayload {
@ -6392,6 +6539,8 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::ExitedReviewMode(_)
| EventMsg::RawResponseItem(_)
| EventMsg::ItemStarted(_)
| EventMsg::HookStarted(_)
| EventMsg::HookCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::ReasoningContentDelta(_)

View file

@ -2183,6 +2183,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
),
hooks: Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
..HooksConfig::default()
}),
rollout: Mutex::new(None),
user_shell: Arc::new(default_user_shell()),
@ -2300,7 +2301,7 @@ async fn request_permissions_emits_event_when_reject_policy_allows_requests() {
}),
..Default::default()
},
scope: codex_protocol::request_permissions::PermissionGrantScope::Turn,
scope: PermissionGrantScope::Turn,
};
let handle = tokio::spawn({
@ -2385,7 +2386,7 @@ async fn request_permissions_returns_empty_grant_when_reject_policy_blocks_reque
Some(
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: codex_protocol::models::PermissionProfile::default(),
scope: codex_protocol::request_permissions::PermissionGrantScope::Turn,
scope: PermissionGrantScope::Turn,
}
)
);
@ -2737,6 +2738,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
),
hooks: Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
..HooksConfig::default()
}),
rollout: Mutex::new(None),
user_shell: Arc::new(default_user_shell()),

View file

@ -95,6 +95,8 @@ pub enum Feature {
ApplyPatchFreeform,
/// Allow requesting additional filesystem permissions while staying sandboxed.
RequestPermissions,
/// Enable Claude-style lifecycle hooks loaded from hooks.json files.
CodexHooks,
/// Expose the built-in request_permissions tool.
RequestPermissionsTool,
/// Allow the model to request web searches that fetch live content.
@ -591,6 +593,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::CodexHooks,
key: "codex_hooks",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::RequestPermissionsTool,
key: "request_permissions_tool",

View file

@ -165,6 +165,8 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::ShutdownComplete
| EventMsg::DeprecationNotice(_)
| EventMsg::ItemStarted(_)
| EventMsg::HookStarted(_)
| EventMsg::HookCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::ReasoningContentDelta(_)

View file

@ -33,6 +33,7 @@ pub(crate) struct SessionState {
pub(crate) startup_regular_task: Option<JoinHandle<CodexResult<RegularTask>>>,
pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
pub(crate) active_connector_selection: HashSet<String>,
pub(crate) pending_session_start_source: Option<codex_hooks::SessionStartSource>,
granted_permissions: Option<PermissionProfile>,
}
@ -51,6 +52,7 @@ impl SessionState {
startup_regular_task: None,
active_mcp_tool_selection: None,
active_connector_selection: HashSet::new(),
pending_session_start_source: None,
granted_permissions: None,
}
}
@ -250,6 +252,19 @@ impl SessionState {
pub(crate) fn clear_connector_selection(&mut self) {
self.active_connector_selection.clear();
}
pub(crate) fn set_pending_session_start_source(
&mut self,
value: Option<codex_hooks::SessionStartSource>,
) {
self.pending_session_start_source = value;
}
pub(crate) fn take_pending_session_start_source(
&mut self,
) -> Option<codex_hooks::SessionStartSource> {
self.pending_session_start_source.take()
}
}
// Sometimes new snapshots don't include credits or plan information.

View file

@ -21,6 +21,11 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookStartedEvent;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallBeginEvent;
@ -850,6 +855,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
receiver_thread_id.to_string().style(self.dimmed)
);
}
EventMsg::HookStarted(event) => self.render_hook_started(event),
EventMsg::HookCompleted(event) => self.render_hook_completed(event),
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::ThreadNameUpdated(_)
| EventMsg::ExecApprovalRequest(_)
@ -928,8 +935,88 @@ impl EventProcessorWithHumanOutput {
serde_json::from_str::<AgentJobProgressMessage>(payload).ok()
}
fn render_hook_started(&self, event: HookStartedEvent) {
if !Self::should_print_hook_started(&event) {
return;
}
let event_name = Self::hook_event_name(event.run.event_name);
if let Some(status_message) = event.run.status_message
&& !status_message.trim().is_empty()
{
ts_msg!(
self,
"{} {}: {}",
"hook".style(self.magenta),
event_name,
status_message
);
}
}
fn render_hook_completed(&self, event: HookCompletedEvent) {
if !Self::should_print_hook_completed(&event) {
return;
}
let event_name = Self::hook_event_name(event.run.event_name);
let status = Self::hook_status_name(event.run.status);
ts_msg!(
self,
"{} {} ({status})",
"hook".style(self.magenta),
event_name
);
for entry in event.run.entries {
let prefix = Self::hook_entry_prefix(entry.kind);
eprintln!(" {prefix} {}", entry.text);
}
}
fn should_print_hook_started(event: &HookStartedEvent) -> bool {
event
.run
.status_message
.as_deref()
.is_some_and(|status_message| !status_message.trim().is_empty())
}
fn should_print_hook_completed(event: &HookCompletedEvent) -> bool {
event.run.status != HookRunStatus::Completed || !event.run.entries.is_empty()
}
fn hook_event_name(event_name: HookEventName) -> &'static str {
match event_name {
HookEventName::SessionStart => "SessionStart",
HookEventName::Stop => "Stop",
}
}
fn hook_status_name(status: HookRunStatus) -> &'static str {
match status {
HookRunStatus::Running => "running",
HookRunStatus::Completed => "completed",
HookRunStatus::Failed => "failed",
HookRunStatus::Blocked => "blocked",
HookRunStatus::Stopped => "stopped",
}
}
fn hook_entry_prefix(kind: HookOutputEntryKind) -> &'static str {
match kind {
HookOutputEntryKind::Warning => "warning:",
HookOutputEntryKind::Stop => "stop:",
HookOutputEntryKind::Feedback => "feedback:",
HookOutputEntryKind::Context => "context:",
HookOutputEntryKind::Error => "error:",
}
}
fn is_silent_event(msg: &EventMsg) -> bool {
matches!(
match msg {
EventMsg::HookStarted(event) => !Self::should_print_hook_started(event),
EventMsg::HookCompleted(event) => !Self::should_print_hook_completed(event),
_ => matches!(
msg,
EventMsg::ThreadNameUpdated(_)
| EventMsg::TokenCount(_)
@ -965,10 +1052,14 @@ impl EventProcessorWithHumanOutput {
| EventMsg::RequestPermissions(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
)
),
}
}
fn should_interrupt_progress(msg: &EventMsg) -> bool {
if let EventMsg::HookCompleted(event) = msg {
return Self::should_print_hook_completed(event);
}
matches!(
msg,
EventMsg::Error(_)
@ -1242,6 +1333,20 @@ fn format_mcp_invocation(invocation: &McpInvocation) -> String {
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookExecutionMode;
use codex_protocol::protocol::HookHandlerType;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookScope;
use codex_protocol::protocol::HookStartedEvent;
use super::EventProcessorWithHumanOutput;
use super::should_print_final_message_to_stdout;
use pretty_assertions::assert_eq;
@ -1276,4 +1381,73 @@ mod tests {
false
);
}
#[test]
fn hook_started_with_status_message_is_not_silent() {
let event = HookStartedEvent {
turn_id: Some("turn-1".to_string()),
run: hook_run(
HookRunStatus::Running,
Some("running hook"),
Vec::new(),
HookEventName::Stop,
),
};
assert!(!EventProcessorWithHumanOutput::is_silent_event(
&EventMsg::HookStarted(event)
));
}
#[test]
fn hook_completed_failure_interrupts_progress() {
let event = HookCompletedEvent {
turn_id: Some("turn-1".to_string()),
run: hook_run(HookRunStatus::Failed, None, Vec::new(), HookEventName::Stop),
};
assert!(EventProcessorWithHumanOutput::should_interrupt_progress(
&EventMsg::HookCompleted(event)
));
}
#[test]
fn hook_completed_success_without_entries_stays_silent() {
let event = HookCompletedEvent {
turn_id: Some("turn-1".to_string()),
run: hook_run(
HookRunStatus::Completed,
None,
Vec::new(),
HookEventName::Stop,
),
};
assert!(EventProcessorWithHumanOutput::is_silent_event(
&EventMsg::HookCompleted(event)
));
}
fn hook_run(
status: HookRunStatus,
status_message: Option<&str>,
entries: Vec<HookOutputEntry>,
event_name: HookEventName,
) -> HookRunSummary {
HookRunSummary {
id: "hook-run-1".to_string(),
event_name,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
scope: HookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status,
status_message: status_message.map(ToOwned::to_owned),
started_at: 0,
completed_at: Some(1),
duration_ms: Some(1),
entries,
}
}
}

View file

@ -1,6 +1,11 @@
load("//:defs.bzl", "codex_rust_crate")
SCHEMA_FIXTURES = glob(["schema/generated/*.json"], allow_empty = False)
codex_rust_crate(
name = "hooks",
crate_name = "codex_hooks",
compile_data = SCHEMA_FIXTURES,
integration_compile_data_extra = SCHEMA_FIXTURES,
test_data_extra = SCHEMA_FIXTURES,
)

View file

@ -13,15 +13,18 @@ path = "src/lib.rs"
workspace = true
[dependencies]
anyhow = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-config = { workspace = true }
codex-protocol = { workspace = true }
futures = { workspace = true, features = ["alloc"] }
regex = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["process"] }
tokio = { workspace = true, features = ["io-util", "process", "time"] }
[dev-dependencies]
anyhow = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }

View file

@ -0,0 +1,59 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"NullableString": {
"type": [
"string",
"null"
]
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "SessionStart",
"type": "string"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"session_id": {
"type": "string"
},
"source": {
"enum": [
"startup",
"resume",
"clear"
],
"type": "string"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
}
},
"required": [
"cwd",
"hook_event_name",
"model",
"permission_mode",
"session_id",
"source",
"transcript_path"
],
"title": "session-start.command.input",
"type": "object"
}

View file

@ -0,0 +1,57 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"HookEventNameWire": {
"enum": [
"SessionStart",
"Stop"
],
"type": "string"
},
"SessionStartHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
"additionalContext": {
"default": null,
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
}
},
"required": [
"hookEventName"
],
"type": "object"
}
},
"properties": {
"continue": {
"default": true,
"type": "boolean"
},
"hookSpecificOutput": {
"allOf": [
{
"$ref": "#/definitions/SessionStartHookSpecificOutputWire"
}
],
"default": null
},
"stopReason": {
"default": null,
"type": "string"
},
"suppressOutput": {
"default": false,
"type": "boolean"
},
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "session-start.command.output",
"type": "object"
}

View file

@ -0,0 +1,58 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"NullableString": {
"type": [
"string",
"null"
]
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "Stop",
"type": "string"
},
"last_assistant_message": {
"$ref": "#/definitions/NullableString"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"session_id": {
"type": "string"
},
"stop_hook_active": {
"type": "boolean"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
}
},
"required": [
"cwd",
"hook_event_name",
"last_assistant_message",
"model",
"permission_mode",
"session_id",
"stop_hook_active",
"transcript_path"
],
"title": "stop.command.input",
"type": "object"
}

View file

@ -0,0 +1,44 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"StopDecisionWire": {
"enum": [
"block"
],
"type": "string"
}
},
"properties": {
"continue": {
"default": true,
"type": "boolean"
},
"decision": {
"allOf": [
{
"$ref": "#/definitions/StopDecisionWire"
}
],
"default": null
},
"reason": {
"default": null,
"type": "string"
},
"stopReason": {
"default": null,
"type": "string"
},
"suppressOutput": {
"default": false,
"type": "boolean"
},
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "stop.command.output",
"type": "object"
}

View file

@ -0,0 +1,9 @@
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
let schema_root = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schema"));
codex_hooks::write_schema_fixtures(&schema_root)
}

View file

@ -0,0 +1,135 @@
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use super::CommandShell;
use super::ConfiguredHandler;
#[derive(Debug)]
pub(crate) struct CommandRunResult {
pub started_at: i64,
pub completed_at: i64,
pub duration_ms: i64,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub error: Option<String>,
}
pub(crate) async fn run_command(
shell: &CommandShell,
handler: &ConfiguredHandler,
input_json: &str,
cwd: &Path,
) -> CommandRunResult {
let started_at = chrono::Utc::now().timestamp();
let started = Instant::now();
let mut command = build_command(shell, handler);
command
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
return CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(err.to_string()),
};
}
};
if let Some(mut stdin) = child.stdin.take()
&& let Err(err) = stdin.write_all(input_json.as_bytes()).await
{
let _ = child.kill().await;
return CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(format!("failed to write hook stdin: {err}")),
};
}
let timeout_duration = Duration::from_secs(handler.timeout_sec);
match timeout(timeout_duration, child.wait_with_output()).await {
Ok(Ok(output)) => CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
error: None,
},
Ok(Err(err)) => CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(err.to_string()),
},
Err(_) => CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(format!("hook timed out after {}s", handler.timeout_sec)),
},
}
}
fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command {
let mut command = if shell.program.is_empty() {
default_shell_command()
} else {
Command::new(&shell.program)
};
if shell.program.is_empty() {
command.arg(&handler.command);
command
} else {
command.args(&shell.args);
command.arg(&handler.command);
command
}
}
fn default_shell_command() -> Command {
#[cfg(windows)]
{
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
let mut command = Command::new(comspec);
command.arg("/C");
command
}
#[cfg(not(windows))]
{
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let mut command = Command::new(shell);
command.arg("-lc");
command
}
}

View file

@ -0,0 +1,42 @@
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HooksFile {
#[serde(default)]
pub hooks: HookEvents,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HookEvents {
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct MatcherGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<HookHandlerConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub(crate) enum HookHandlerConfig {
#[serde(rename = "command")]
Command {
command: String,
#[serde(default, rename = "timeout", alias = "timeoutSec")]
timeout_sec: Option<u64>,
#[serde(default)]
r#async: bool,
#[serde(default, rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
Prompt {},
#[serde(rename = "agent")]
Agent {},
}

View file

@ -0,0 +1,162 @@
use std::fs;
use std::path::Path;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use regex::Regex;
use super::ConfiguredHandler;
use super::config::HookHandlerConfig;
use super::config::HooksFile;
pub(crate) struct DiscoveryResult {
pub handlers: Vec<ConfiguredHandler>,
pub warnings: Vec<String>,
}
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
let Some(config_layer_stack) = config_layer_stack else {
return DiscoveryResult {
handlers: Vec::new(),
warnings: Vec::new(),
};
};
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0_i64;
for layer in
config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false)
{
let Some(folder) = layer.config_folder() else {
continue;
};
let source_path = match folder.join("hooks.json") {
Ok(source_path) => source_path,
Err(err) => {
warnings.push(format!(
"failed to resolve hooks config path from {}: {err}",
folder.display()
));
continue;
}
};
if !source_path.as_path().is_file() {
continue;
}
let contents = match fs::read_to_string(source_path.as_path()) {
Ok(contents) => contents,
Err(err) => {
warnings.push(format!(
"failed to read hooks config {}: {err}",
source_path.display()
));
continue;
}
};
let parsed: HooksFile = match serde_json::from_str(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse hooks config {}: {err}",
source_path.display()
));
continue;
}
};
for group in parsed.hooks.session_start {
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::SessionStart,
group.matcher.as_deref(),
group.hooks,
);
}
for group in parsed.hooks.stop {
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::Stop,
None,
group.hooks,
);
}
}
DiscoveryResult { handlers, warnings }
}
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source_path: &Path,
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = Regex::new(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source_path.display()
));
return;
}
for handler in group_handlers {
match handler {
HookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source_path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source_path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
status_message,
source_path: source_path.to_path_buf(),
display_order: *display_order,
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source_path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source_path.display()
)),
}
}
}

View file

@ -0,0 +1,190 @@
use std::path::Path;
use futures::future::join_all;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookExecutionMode;
use codex_protocol::protocol::HookHandlerType;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookScope;
use super::CommandShell;
use super::ConfiguredHandler;
use super::command_runner::CommandRunResult;
use super::command_runner::run_command;
#[derive(Debug)]
pub(crate) struct ParsedHandler<T> {
pub completed: HookCompletedEvent,
pub data: T,
}
pub(crate) fn select_handlers(
handlers: &[ConfiguredHandler],
event_name: HookEventName,
session_start_source: Option<&str>,
) -> Vec<ConfiguredHandler> {
handlers
.iter()
.filter(|handler| handler.event_name == event_name)
.filter(|handler| match event_name {
HookEventName::SessionStart => match (&handler.matcher, session_start_source) {
(Some(matcher), Some(source)) => regex::Regex::new(matcher)
.map(|regex| regex.is_match(source))
.unwrap_or(false),
(None, _) => true,
_ => false,
},
HookEventName::Stop => true,
})
.cloned()
.collect()
}
pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
HookRunSummary {
id: handler.run_id(),
event_name: handler.event_name,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
scope: scope_for_event(handler.event_name),
source_path: handler.source_path.clone(),
display_order: handler.display_order,
status: HookRunStatus::Running,
status_message: handler.status_message.clone(),
started_at: chrono::Utc::now().timestamp(),
completed_at: None,
duration_ms: None,
entries: Vec::new(),
}
}
pub(crate) async fn execute_handlers<T>(
shell: &CommandShell,
handlers: Vec<ConfiguredHandler>,
input_json: String,
cwd: &Path,
turn_id: Option<String>,
parse: fn(&ConfiguredHandler, CommandRunResult, Option<String>) -> ParsedHandler<T>,
) -> Vec<ParsedHandler<T>> {
let results = join_all(
handlers
.iter()
.map(|handler| run_command(shell, handler, &input_json, cwd)),
)
.await;
handlers
.into_iter()
.zip(results)
.map(|(handler, result)| parse(&handler, result, turn_id.clone()))
.collect()
}
pub(crate) fn completed_summary(
handler: &ConfiguredHandler,
run_result: &CommandRunResult,
status: HookRunStatus,
entries: Vec<codex_protocol::protocol::HookOutputEntry>,
) -> HookRunSummary {
HookRunSummary {
id: handler.run_id(),
event_name: handler.event_name,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
scope: scope_for_event(handler.event_name),
source_path: handler.source_path.clone(),
display_order: handler.display_order,
status,
status_message: handler.status_message.clone(),
started_at: run_result.started_at,
completed_at: Some(run_result.completed_at),
duration_ms: Some(run_result.duration_ms),
entries,
}
}
fn scope_for_event(event_name: HookEventName) -> HookScope {
match event_name {
HookEventName::SessionStart => HookScope::Thread,
HookEventName::Stop => HookScope::Turn,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use super::ConfiguredHandler;
use super::select_handlers;
fn make_handler(
event_name: HookEventName,
matcher: Option<&str>,
command: &str,
display_order: i64,
) -> ConfiguredHandler {
ConfiguredHandler {
event_name,
matcher: matcher.map(str::to_owned),
command: command.to_string(),
timeout_sec: 5,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order,
}
}
#[test]
fn select_handlers_keeps_duplicate_stop_handlers() {
let handlers = vec![
make_handler(HookEventName::Stop, None, "echo same", 0),
make_handler(HookEventName::Stop, None, "echo same", 1),
];
let selected = select_handlers(&handlers, HookEventName::Stop, None);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn select_handlers_keeps_overlapping_session_start_matchers() {
let handlers = vec![
make_handler(HookEventName::SessionStart, Some("start.*"), "echo same", 0),
make_handler(
HookEventName::SessionStart,
Some("^startup$"),
"echo same",
1,
),
];
let selected = select_handlers(&handlers, HookEventName::SessionStart, Some("startup"));
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn select_handlers_preserves_declaration_order() {
let handlers = vec![
make_handler(HookEventName::Stop, None, "first", 0),
make_handler(HookEventName::Stop, None, "second", 1),
make_handler(HookEventName::Stop, None, "third", 2),
];
let selected = select_handlers(&handlers, HookEventName::Stop, None);
assert_eq!(selected.len(), 3);
assert_eq!(selected[0].command, "first");
assert_eq!(selected[1].command, "second");
assert_eq!(selected[2].command, "third");
}
}

View file

@ -0,0 +1,109 @@
pub(crate) mod command_runner;
pub(crate) mod config;
pub(crate) mod discovery;
pub(crate) mod dispatcher;
pub(crate) mod output_parser;
pub(crate) mod schema_loader;
use std::path::PathBuf;
use codex_config::ConfigLayerStack;
use codex_protocol::protocol::HookRunSummary;
use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
#[derive(Debug, Clone)]
pub(crate) struct CommandShell {
pub program: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ConfiguredHandler {
pub event_name: codex_protocol::protocol::HookEventName,
pub matcher: Option<String>,
pub command: String,
pub timeout_sec: u64,
pub status_message: Option<String>,
pub source_path: PathBuf,
pub display_order: i64,
}
impl ConfiguredHandler {
pub fn run_id(&self) -> String {
format!(
"{}:{}:{}",
self.event_name_label(),
self.display_order,
self.source_path.display()
)
}
fn event_name_label(&self) -> &'static str {
match self.event_name {
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::Stop => "stop",
}
}
}
#[derive(Clone)]
pub(crate) struct ClaudeHooksEngine {
handlers: Vec<ConfiguredHandler>,
warnings: Vec<String>,
shell: CommandShell,
}
impl ClaudeHooksEngine {
pub(crate) fn new(
enabled: bool,
config_layer_stack: Option<&ConfigLayerStack>,
shell: CommandShell,
) -> Self {
if !enabled {
return Self {
handlers: Vec::new(),
warnings: Vec::new(),
shell,
};
}
let _ = schema_loader::generated_hook_schemas();
let discovered = discovery::discover_handlers(config_layer_stack);
Self {
handlers: discovered.handlers,
warnings: discovered.warnings,
shell,
}
}
pub(crate) fn warnings(&self) -> &[String] {
&self.warnings
}
pub(crate) fn preview_session_start(
&self,
request: &SessionStartRequest,
) -> Vec<HookRunSummary> {
crate::events::session_start::preview(&self.handlers, request)
}
pub(crate) async fn run_session_start(
&self,
request: SessionStartRequest,
turn_id: Option<String>,
) -> SessionStartOutcome {
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
}
pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec<HookRunSummary> {
crate::events::stop::preview(&self.handlers, request)
}
pub(crate) async fn run_stop(&self, request: StopRequest) -> StopOutcome {
crate::events::stop::run(&self.handlers, &self.shell, request).await
}
}

View file

@ -0,0 +1,71 @@
#[derive(Debug, Clone)]
pub(crate) struct UniversalOutput {
pub continue_processing: bool,
pub stop_reason: Option<String>,
pub suppress_output: bool,
pub system_message: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionStartOutput {
pub universal: UniversalOutput,
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct StopOutput {
pub universal: UniversalOutput,
pub should_block: bool,
pub reason: Option<String>,
}
use crate::schema::HookUniversalOutputWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::StopDecisionWire;
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SessionStartCommandOutputWire = parse_json(stdout)?;
let additional_context = wire
.hook_specific_output
.and_then(|output| output.additional_context);
Some(SessionStartOutput {
universal: UniversalOutput::from(wire.universal),
additional_context,
})
}
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
Some(StopOutput {
universal: UniversalOutput::from(wire.universal),
should_block: matches!(wire.decision, Some(StopDecisionWire::Block)),
reason: wire.reason,
})
}
impl From<HookUniversalOutputWire> for UniversalOutput {
fn from(value: HookUniversalOutputWire) -> Self {
Self {
continue_processing: value.r#continue,
stop_reason: value.stop_reason,
suppress_output: value.suppress_output,
system_message: value.system_message,
}
}
}
fn parse_json<T>(stdout: &str) -> Option<T>
where
T: for<'de> serde::Deserialize<'de>,
{
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
if !value.is_object() {
return None;
}
serde_json::from_value(value).ok()
}

View file

@ -0,0 +1,54 @@
use std::sync::OnceLock;
use serde_json::Value;
#[allow(dead_code)]
pub(crate) struct GeneratedHookSchemas {
pub session_start_command_input: Value,
pub session_start_command_output: Value,
pub stop_command_input: Value,
pub stop_command_output: Value,
}
pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
static SCHEMAS: OnceLock<GeneratedHookSchemas> = OnceLock::new();
SCHEMAS.get_or_init(|| GeneratedHookSchemas {
session_start_command_input: parse_json_schema(
"session-start.command.input",
include_str!("../../schema/generated/session-start.command.input.schema.json"),
),
session_start_command_output: parse_json_schema(
"session-start.command.output",
include_str!("../../schema/generated/session-start.command.output.schema.json"),
),
stop_command_input: parse_json_schema(
"stop.command.input",
include_str!("../../schema/generated/stop.command.input.schema.json"),
),
stop_command_output: parse_json_schema(
"stop.command.output",
include_str!("../../schema/generated/stop.command.output.schema.json"),
),
})
}
fn parse_json_schema(name: &str, schema: &str) -> Value {
serde_json::from_str(schema)
.unwrap_or_else(|err| panic!("invalid generated hooks schema {name}: {err}"))
}
#[cfg(test)]
mod tests {
use super::generated_hook_schemas;
use pretty_assertions::assert_eq;
#[test]
fn loads_generated_hook_schemas() {
let schemas = generated_hook_schemas();
assert_eq!(schemas.session_start_command_input["type"], "object");
assert_eq!(schemas.session_start_command_output["type"], "object");
assert_eq!(schemas.stop_command_input["type"], "object");
assert_eq!(schemas.stop_command_output["type"], "object");
}
}

View file

@ -0,0 +1,2 @@
pub mod session_start;
pub mod stop;

View file

@ -0,0 +1,393 @@
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::SessionStartCommandInput;
#[derive(Debug, Clone, Copy)]
pub enum SessionStartSource {
Startup,
Resume,
}
impl SessionStartSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Startup => "startup",
Self::Resume => "resume",
}
}
}
#[derive(Debug, Clone)]
pub struct SessionStartRequest {
pub session_id: ThreadId,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub source: SessionStartSource,
}
#[derive(Debug)]
pub struct SessionStartOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub additional_context: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct SessionStartHandlerData {
should_stop: bool,
stop_reason: Option<String>,
additional_context_for_model: Option<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &SessionStartRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
HookEventName::SessionStart,
Some(request.source.as_str()),
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: SessionStartRequest,
turn_id: Option<String>,
) -> SessionStartOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::SessionStart,
Some(request.source.as_str()),
);
if matched.is_empty() {
return SessionStartOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
additional_context: None,
};
}
let input_json = match serde_json::to_string(&SessionStartCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
request.source.as_str().to_string(),
)) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(
matched,
turn_id,
format!("failed to serialize session start hook input: {error}"),
);
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
turn_id,
parse_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let additional_contexts = results
.iter()
.filter_map(|result| result.data.additional_context_for_model.clone())
.collect::<Vec<_>>();
SessionStartOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_stop,
stop_reason,
additional_context: join_text_chunks(additional_contexts),
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<SessionStartHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut should_stop = false;
let mut stop_reason = None;
let mut additional_context_for_model = None;
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
}
None => match run_result.exit_code {
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) = output_parser::parse_session_start(&run_result.stdout)
{
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
if let Some(additional_context) = parsed.additional_context {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: additional_context.clone(),
});
if parsed.universal.continue_processing {
additional_context_for_model = Some(additional_context);
}
}
let _ = parsed.universal.suppress_output;
if !parsed.universal.continue_processing {
status = HookRunStatus::Stopped;
should_stop = true;
stop_reason = parsed.universal.stop_reason.clone();
if let Some(stop_reason_text) = parsed.universal.stop_reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: stop_reason_text,
});
}
}
// Preserve plain-text context support without treating malformed JSON as context.
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid session start JSON output".to_string(),
});
} else {
let additional_context = trimmed_stdout.to_string();
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: additional_context.clone(),
});
additional_context_for_model = Some(additional_context);
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: SessionStartHandlerData {
should_stop,
stop_reason,
additional_context_for_model,
},
}
}
fn join_text_chunks(chunks: Vec<String>) -> Option<String> {
if chunks.is_empty() {
None
} else {
Some(chunks.join("\n\n"))
}
}
fn serialization_failure_outcome(
handlers: Vec<ConfiguredHandler>,
turn_id: Option<String>,
error_message: String,
) -> SessionStartOutcome {
let hook_events = handlers
.into_iter()
.map(|handler| {
let mut run = dispatcher::running_summary(&handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error_message.clone(),
}];
HookCompletedEvent {
turn_id: turn_id.clone(),
run,
}
})
.collect();
SessionStartOutcome {
hook_events,
should_stop: false,
stop_reason: None,
additional_context: None,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use pretty_assertions::assert_eq;
use super::SessionStartHandlerData;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn plain_stdout_becomes_model_context() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "hello from hook\n", ""),
None,
);
assert_eq!(
parsed.data,
SessionStartHandlerData {
should_stop: false,
stop_reason: None,
additional_context_for_model: Some("hello from hook".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "hello from hook".to_string(),
}]
);
}
#[test]
fn continue_false_keeps_context_out_of_model_input() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"do not inject"}}"#,
"",
),
None,
);
assert_eq!(
parsed.data,
SessionStartHandlerData {
should_stop: true,
stop_reason: Some("pause".to_string()),
additional_context_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
}
#[test]
fn invalid_json_like_stdout_fails_instead_of_becoming_model_context() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"SessionStart""#,
"",
),
None,
);
assert_eq!(
parsed.data,
SessionStartHandlerData {
should_stop: false,
stop_reason: None,
additional_context_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid session start JSON output".to_string(),
}]
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::SessionStart,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 600,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
CommandRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
}

View file

@ -0,0 +1,495 @@
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::StopCommandInput;
#[derive(Debug, Clone)]
pub struct StopRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub stop_hook_active: bool,
pub last_assistant_message: Option<String>,
}
#[derive(Debug)]
pub struct StopOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub should_block: bool,
pub block_reason: Option<String>,
pub block_message_for_model: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct StopHandlerData {
should_stop: bool,
stop_reason: Option<String>,
should_block: bool,
block_reason: Option<String>,
block_message_for_model: Option<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
_request: &StopRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(handlers, HookEventName::Stop, None)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: StopRequest,
) -> StopOutcome {
let matched = dispatcher::select_handlers(handlers, HookEventName::Stop, None);
if matched.is_empty() {
return StopOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
};
}
let input_json = match serde_json::to_string(&StopCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
request.stop_hook_active,
request.last_assistant_message.clone(),
)) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(
matched,
Some(request.turn_id),
format!("failed to serialize stop hook input: {error}"),
);
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let should_block = !should_stop && results.iter().any(|result| result.data.should_block);
let block_reason = if should_block {
results
.iter()
.find_map(|result| result.data.block_reason.clone())
} else {
None
};
let block_message_for_model = if should_block {
results
.iter()
.find_map(|result| result.data.block_message_for_model.clone())
} else {
None
};
StopOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_stop,
stop_reason,
should_block,
block_reason,
block_message_for_model,
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<StopHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut should_stop = false;
let mut stop_reason = None;
let mut should_block = false;
let mut block_reason = None;
let mut block_message_for_model = None;
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
}
None => match run_result.exit_code {
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) = output_parser::parse_stop(&run_result.stdout) {
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
let _ = parsed.universal.suppress_output;
if !parsed.universal.continue_processing {
status = HookRunStatus::Stopped;
should_stop = true;
stop_reason = parsed.universal.stop_reason.clone();
if let Some(stop_reason_text) = parsed.universal.stop_reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: stop_reason_text,
});
}
} else if parsed.should_block {
if let Some(reason) = parsed.reason.as_deref().and_then(trimmed_non_empty) {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
block_message_for_model = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason"
.to_string(),
});
}
}
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid stop hook JSON output".to_string(),
});
}
}
Some(2) => {
if let Some(reason) = trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
block_message_for_model = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited with code 2 without stderr feedback".to_string(),
});
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: StopHandlerData {
should_stop,
stop_reason,
should_block,
block_reason,
block_message_for_model,
},
}
}
fn trimmed_non_empty(text: &str) -> Option<String> {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
None
}
fn serialization_failure_outcome(
handlers: Vec<ConfiguredHandler>,
turn_id: Option<String>,
error_message: String,
) -> StopOutcome {
let hook_events = handlers
.into_iter()
.map(|handler| {
let mut run = dispatcher::running_summary(&handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error_message.clone(),
}];
HookCompletedEvent {
turn_id: turn_id.clone(),
run,
}
})
.collect();
StopOutcome {
hook_events,
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use pretty_assertions::assert_eq;
use super::StopHandlerData;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn continue_false_overrides_block_decision() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"continue":false,"stopReason":"done","decision":"block","reason":"keep going"}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: true,
stop_reason: Some("done".to_string()),
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
}
#[test]
fn exit_code_two_uses_stderr_feedback_only() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "ignored stdout", "retry with tests"),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: true,
block_reason: Some("retry with tests".to_string()),
block_message_for_model: Some("retry with tests".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
}
#[test]
fn block_decision_without_reason_fails_instead_of_blocking() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), r#"{"decision":"block"}"#, ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason".to_string(),
}]
);
}
#[test]
fn block_decision_with_blank_reason_fails_instead_of_blocking() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "{\"decision\":\"block\",\"reason\":\" \"}", ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason".to_string(),
}]
);
}
#[test]
fn exit_code_two_without_stderr_feedback_fails_instead_of_blocking() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "ignored stdout", " "),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited with code 2 without stderr feedback".to_string(),
}]
);
}
#[test]
fn invalid_stdout_fails_instead_of_silently_nooping() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "not json", ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid stop hook JSON output".to_string(),
}]
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::Stop,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 600,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
CommandRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
}

View file

@ -0,0 +1,145 @@
use std::process::Stdio;
use std::sync::Arc;
use serde::Serialize;
use crate::Hook;
use crate::HookEvent;
use crate::HookPayload;
use crate::HookResult;
use crate::command_from_argv;
/// Legacy notify payload appended as the final argv argument for backward compatibility.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum UserNotification {
#[serde(rename_all = "kebab-case")]
AgentTurnComplete {
thread_id: String,
turn_id: String,
cwd: String,
#[serde(skip_serializing_if = "Option::is_none")]
client: Option<String>,
input_messages: Vec<String>,
last_assistant_message: Option<String>,
},
}
pub fn legacy_notify_json(payload: &HookPayload) -> Result<String, serde_json::Error> {
match &payload.hook_event {
HookEvent::AfterAgent { event } => {
serde_json::to_string(&UserNotification::AgentTurnComplete {
thread_id: event.thread_id.to_string(),
turn_id: event.turn_id.clone(),
cwd: payload.cwd.display().to_string(),
client: payload.client.clone(),
input_messages: event.input_messages.clone(),
last_assistant_message: event.last_assistant_message.clone(),
})
}
HookEvent::AfterToolUse { .. } => Err(serde_json::Error::io(std::io::Error::other(
"legacy notify payload is only supported for after_agent",
))),
}
}
pub fn notify_hook(argv: Vec<String>) -> Hook {
let argv = Arc::new(argv);
Hook {
name: "legacy_notify".to_string(),
func: Arc::new(move |payload: &HookPayload| {
let argv = Arc::clone(&argv);
Box::pin(async move {
let mut command = match command_from_argv(&argv) {
Some(command) => command,
None => return HookResult::Success,
};
if let Ok(notify_payload) = legacy_notify_json(payload) {
command.arg(notify_payload);
}
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match command.spawn() {
Ok(_) => HookResult::Success,
Err(err) => HookResult::FailedContinue(err.into()),
}
})
}),
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::path::Path;
use super::*;
use crate::HookEventAfterAgent;
fn expected_notification_json() -> Value {
json!({
"type": "agent-turn-complete",
"thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
"turn-id": "12345",
"cwd": "/Users/example/project",
"client": "codex-tui",
"input-messages": ["Rename `foo` to `bar` and update the callsites."],
"last-assistant-message": "Rename complete and verified `cargo build` succeeds.",
})
}
#[test]
fn test_user_notification() -> Result<()> {
let notification = UserNotification::AgentTurnComplete {
thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(),
turn_id: "12345".to_string(),
cwd: "/Users/example/project".to_string(),
client: Some("codex-tui".to_string()),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
};
let serialized = serde_json::to_string(&notification)?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
Ok(())
}
#[test]
fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> {
let payload = HookPayload {
session_id: ThreadId::new(),
cwd: Path::new("/Users/example/project").to_path_buf(),
client: Some("codex-tui".to_string()),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::AfterAgent {
event: HookEventAfterAgent {
thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666")
.expect("valid thread id"),
turn_id: "12345".to_string(),
input_messages: vec![
"Rename `foo` to `bar` and update the callsites.".to_string(),
],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
},
},
};
let serialized = legacy_notify_json(&payload)?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
Ok(())
}
}

View file

@ -1,10 +1,21 @@
mod engine;
pub mod events;
mod legacy_notify;
mod registry;
mod schema;
mod types;
mod user_notification;
pub use events::session_start::SessionStartOutcome;
pub use events::session_start::SessionStartRequest;
pub use events::session_start::SessionStartSource;
pub use events::stop::StopOutcome;
pub use events::stop::StopRequest;
pub use legacy_notify::legacy_notify_json;
pub use legacy_notify::notify_hook;
pub use registry::Hooks;
pub use registry::HooksConfig;
pub use registry::command_from_argv;
pub use schema::write_schema_fixtures;
pub use types::Hook;
pub use types::HookEvent;
pub use types::HookEventAfterAgent;
@ -15,5 +26,3 @@ pub use types::HookResult;
pub use types::HookToolInput;
pub use types::HookToolInputLocalShell;
pub use types::HookToolKind;
pub use user_notification::legacy_notify_json;
pub use user_notification::notify_hook;

View file

@ -1,5 +1,12 @@
use codex_config::ConfigLayerStack;
use tokio::process::Command;
use crate::engine::ClaudeHooksEngine;
use crate::engine::CommandShell;
use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
use crate::types::Hook;
use crate::types::HookEvent;
use crate::types::HookPayload;
@ -8,12 +15,17 @@ use crate::types::HookResponse;
#[derive(Default, Clone)]
pub struct HooksConfig {
pub legacy_notify_argv: Option<Vec<String>>,
pub feature_enabled: bool,
pub config_layer_stack: Option<ConfigLayerStack>,
pub shell_program: Option<String>,
pub shell_args: Vec<String>,
}
#[derive(Clone)]
pub struct Hooks {
after_agent: Vec<Hook>,
after_tool_use: Vec<Hook>,
engine: ClaudeHooksEngine,
}
impl Default for Hooks {
@ -22,8 +34,6 @@ impl Default for Hooks {
}
}
// Hooks are arbitrary, user-specified functions that are deterministically
// executed after specific events in the Codex lifecycle.
impl Hooks {
pub fn new(config: HooksConfig) -> Self {
let after_agent = config
@ -32,12 +42,25 @@ impl Hooks {
.map(crate::notify_hook)
.into_iter()
.collect();
let engine = ClaudeHooksEngine::new(
config.feature_enabled,
config.config_layer_stack.as_ref(),
CommandShell {
program: config.shell_program.unwrap_or_default(),
args: config.shell_args,
},
);
Self {
after_agent,
after_tool_use: Vec::new(),
engine,
}
}
pub fn startup_warnings(&self) -> &[String] {
self.engine.warnings()
}
fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] {
match hook_event {
HookEvent::AfterAgent { .. } => &self.after_agent,
@ -59,6 +82,32 @@ impl Hooks {
outcomes
}
pub fn preview_session_start(
&self,
request: &SessionStartRequest,
) -> Vec<codex_protocol::protocol::HookRunSummary> {
self.engine.preview_session_start(request)
}
pub async fn run_session_start(
&self,
request: SessionStartRequest,
turn_id: Option<String>,
) -> SessionStartOutcome {
self.engine.run_session_start(request, turn_id).await
}
pub fn preview_stop(
&self,
request: &StopRequest,
) -> Vec<codex_protocol::protocol::HookRunSummary> {
self.engine.preview_stop(request)
}
pub async fn run_stop(&self, request: StopRequest) -> StopOutcome {
self.engine.run_stop(request).await
}
}
pub fn command_from_argv(argv: &[String]) -> Option<Command> {
@ -70,415 +119,3 @@ pub fn command_from_argv(argv: &[String]) -> Option<Command> {
command.args(args);
Some(command)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use anyhow::Result;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::to_string;
use tempfile::tempdir;
use tokio::time::timeout;
use super::*;
use crate::types::HookEventAfterAgent;
use crate::types::HookEventAfterToolUse;
use crate::types::HookResult;
use crate::types::HookToolInput;
use crate::types::HookToolKind;
const CWD: &str = "/tmp";
const INPUT_MESSAGE: &str = "hello";
fn hook_payload(label: &str) -> HookPayload {
HookPayload {
session_id: ThreadId::new(),
cwd: PathBuf::from(CWD),
client: None,
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: HookEvent::AfterAgent {
event: HookEventAfterAgent {
thread_id: ThreadId::new(),
turn_id: format!("turn-{label}"),
input_messages: vec![INPUT_MESSAGE.to_string()],
last_assistant_message: Some("hi".to_string()),
},
},
}
}
fn counting_success_hook(calls: &Arc<AtomicUsize>, name: &str) -> Hook {
let hook_name = name.to_string();
let calls = Arc::clone(calls);
Hook {
name: hook_name,
func: Arc::new(move |_| {
let calls = Arc::clone(&calls);
Box::pin(async move {
calls.fetch_add(1, Ordering::SeqCst);
HookResult::Success
})
}),
}
}
fn failing_continue_hook(calls: &Arc<AtomicUsize>, name: &str, message: &str) -> Hook {
let hook_name = name.to_string();
let message = message.to_string();
let calls = Arc::clone(calls);
Hook {
name: hook_name,
func: Arc::new(move |_| {
let calls = Arc::clone(&calls);
let message = message.clone();
Box::pin(async move {
calls.fetch_add(1, Ordering::SeqCst);
HookResult::FailedContinue(std::io::Error::other(message).into())
})
}),
}
}
fn failing_abort_hook(calls: &Arc<AtomicUsize>, name: &str, message: &str) -> Hook {
let hook_name = name.to_string();
let message = message.to_string();
let calls = Arc::clone(calls);
Hook {
name: hook_name,
func: Arc::new(move |_| {
let calls = Arc::clone(&calls);
let message = message.clone();
Box::pin(async move {
calls.fetch_add(1, Ordering::SeqCst);
HookResult::FailedAbort(std::io::Error::other(message).into())
})
}),
}
}
fn after_tool_use_payload(label: &str) -> HookPayload {
HookPayload {
session_id: ThreadId::new(),
cwd: PathBuf::from(CWD),
client: None,
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: HookEvent::AfterToolUse {
event: HookEventAfterToolUse {
turn_id: format!("turn-{label}"),
call_id: format!("call-{label}"),
tool_name: "apply_patch".to_string(),
tool_kind: HookToolKind::Custom,
tool_input: HookToolInput::Custom {
input: "*** Begin Patch".to_string(),
},
executed: true,
success: true,
duration_ms: 1,
mutating: true,
sandbox: "none".to_string(),
sandbox_policy: "danger-full-access".to_string(),
output_preview: "ok".to_string(),
},
},
}
}
#[test]
fn command_from_argv_returns_none_for_empty_args() {
assert!(command_from_argv(&[]).is_none());
assert!(command_from_argv(&["".to_string()]).is_none());
}
#[tokio::test]
async fn command_from_argv_builds_command() -> Result<()> {
let argv = if cfg!(windows) {
vec![
"cmd".to_string(),
"/C".to_string(),
"echo hello world".to_string(),
]
} else {
vec!["echo".to_string(), "hello".to_string(), "world".to_string()]
};
let mut command = command_from_argv(&argv).ok_or_else(|| anyhow::anyhow!("command"))?;
let output = command.stdout(Stdio::piped()).output().await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim_end_matches(['\r', '\n']);
assert_eq!(trimmed, "hello world");
Ok(())
}
#[test]
fn hooks_new_requires_program_name() {
assert!(Hooks::new(HooksConfig::default()).after_agent.is_empty());
assert!(
Hooks::new(HooksConfig {
legacy_notify_argv: Some(vec![]),
})
.after_agent
.is_empty()
);
assert!(
Hooks::new(HooksConfig {
legacy_notify_argv: Some(vec!["".to_string()]),
})
.after_agent
.is_empty()
);
assert_eq!(
Hooks::new(HooksConfig {
legacy_notify_argv: Some(vec!["notify-send".to_string()]),
})
.after_agent
.len(),
1
);
}
#[tokio::test]
async fn dispatch_executes_hook() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
after_agent: vec![counting_success_hook(&calls, "counting")],
..Hooks::default()
};
let outcomes = hooks.dispatch(hook_payload("1")).await;
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].hook_name, "counting");
assert!(matches!(outcomes[0].result, HookResult::Success));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn default_hook_is_noop_and_continues() {
let payload = hook_payload("d");
let outcome = Hook::default().execute(&payload).await;
assert_eq!(outcome.hook_name, "default");
assert!(matches!(outcome.result, HookResult::Success));
}
#[tokio::test]
async fn dispatch_executes_multiple_hooks_for_same_event() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
after_agent: vec![
counting_success_hook(&calls, "counting-1"),
counting_success_hook(&calls, "counting-2"),
],
..Hooks::default()
};
let outcomes = hooks.dispatch(hook_payload("2")).await;
assert_eq!(outcomes.len(), 2);
assert_eq!(outcomes[0].hook_name, "counting-1");
assert_eq!(outcomes[1].hook_name, "counting-2");
assert!(matches!(outcomes[0].result, HookResult::Success));
assert!(matches!(outcomes[1].result, HookResult::Success));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn dispatch_stops_when_hook_requests_abort() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
after_agent: vec![
failing_abort_hook(&calls, "abort", "hook failed"),
counting_success_hook(&calls, "counting"),
],
..Hooks::default()
};
let outcomes = hooks.dispatch(hook_payload("3")).await;
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].hook_name, "abort");
assert!(matches!(outcomes[0].result, HookResult::FailedAbort(_)));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn dispatch_executes_after_tool_use_hooks() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
after_tool_use: vec![counting_success_hook(&calls, "counting")],
..Hooks::default()
};
let outcomes = hooks.dispatch(after_tool_use_payload("p")).await;
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].hook_name, "counting");
assert!(matches!(outcomes[0].result, HookResult::Success));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn dispatch_continues_after_continueable_failure() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
after_agent: vec![
failing_continue_hook(&calls, "failing", "hook failed"),
counting_success_hook(&calls, "counting"),
],
..Hooks::default()
};
let outcomes = hooks.dispatch(hook_payload("err")).await;
assert_eq!(outcomes.len(), 2);
assert_eq!(outcomes[0].hook_name, "failing");
assert!(matches!(outcomes[0].result, HookResult::FailedContinue(_)));
assert_eq!(outcomes[1].hook_name, "counting");
assert!(matches!(outcomes[1].result, HookResult::Success));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn dispatch_returns_after_tool_use_failure_outcome() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
after_tool_use: vec![failing_continue_hook(
&calls,
"failing",
"after_tool_use hook failed",
)],
..Hooks::default()
};
let outcomes = hooks.dispatch(after_tool_use_payload("err-tool")).await;
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].hook_name, "failing");
assert!(matches!(outcomes[0].result, HookResult::FailedContinue(_)));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[cfg(not(windows))]
#[tokio::test]
async fn hook_executes_program_with_payload_argument_unix() -> Result<()> {
let temp_dir = tempdir()?;
let payload_path = temp_dir.path().join("payload.json");
let payload_path_arg = payload_path.to_string_lossy().into_owned();
let hook = Hook {
name: "write_payload".to_string(),
func: Arc::new(move |payload: &HookPayload| {
let payload_path_arg = payload_path_arg.clone();
Box::pin(async move {
let json = to_string(payload).expect("serialize hook payload");
let mut command = command_from_argv(&[
"/bin/sh".to_string(),
"-c".to_string(),
"printf '%s' \"$2\" > \"$1\"".to_string(),
"sh".to_string(),
payload_path_arg,
json,
])
.expect("build command");
command.status().await.expect("run hook command");
HookResult::Success
})
}),
};
let payload = hook_payload("4");
let expected = to_string(&payload)?;
let hooks = Hooks {
after_agent: vec![hook],
..Hooks::default()
};
let outcomes = hooks.dispatch(payload).await;
assert_eq!(outcomes.len(), 1);
assert!(matches!(outcomes[0].result, HookResult::Success));
let contents = timeout(Duration::from_secs(2), async {
loop {
if let Ok(contents) = fs::read_to_string(&payload_path)
&& !contents.is_empty()
{
return contents;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert_eq!(contents, expected);
Ok(())
}
#[cfg(windows)]
#[tokio::test]
async fn hook_executes_program_with_payload_argument_windows() -> Result<()> {
let temp_dir = tempdir()?;
let payload_path = temp_dir.path().join("payload.json");
let payload_path_arg = payload_path.to_string_lossy().into_owned();
let script_path = temp_dir.path().join("write_payload.ps1");
fs::write(&script_path, "[IO.File]::WriteAllText($args[0], $args[1])")?;
let script_path_arg = script_path.to_string_lossy().into_owned();
let hook = Hook {
name: "write_payload".to_string(),
func: Arc::new(move |payload: &HookPayload| {
let payload_path_arg = payload_path_arg.clone();
let script_path_arg = script_path_arg.clone();
Box::pin(async move {
let json = to_string(payload).expect("serialize hook payload");
let mut command = command_from_argv(&[
"powershell.exe".to_string(),
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
script_path_arg,
payload_path_arg,
json,
])
.expect("build command");
command.status().await.expect("run hook command");
HookResult::Success
})
}),
};
let payload = hook_payload("4");
let expected = to_string(&payload)?;
let hooks = Hooks {
after_agent: vec![hook],
..Hooks::default()
};
let outcomes = hooks.dispatch(payload).await;
assert_eq!(outcomes.len(), 1);
assert!(matches!(outcomes[0].result, HookResult::Success));
let contents = timeout(Duration::from_secs(2), async {
loop {
if let Ok(contents) = fs::read_to_string(&payload_path)
&& !contents.is_empty()
{
return contents;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert_eq!(contents, expected);
Ok(())
}
}

View file

@ -0,0 +1,360 @@
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::r#gen::SchemaSettings;
use schemars::schema::InstanceType;
use schemars::schema::RootSchema;
use schemars::schema::Schema;
use schemars::schema::SchemaObject;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Map;
use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
const GENERATED_DIR: &str = "generated";
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json";
const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json";
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub(crate) struct NullableString(Option<String>);
impl NullableString {
fn from_path(path: Option<PathBuf>) -> Self {
Self(path.map(|path| path.display().to_string()))
}
fn from_string(value: Option<String>) -> Self {
Self(value)
}
}
impl JsonSchema for NullableString {
fn schema_name() -> String {
"NullableString".to_string()
}
fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
instance_type: Some(vec![InstanceType::String, InstanceType::Null].into()),
..Default::default()
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct HookUniversalOutputWire {
#[serde(default = "default_continue")]
pub r#continue: bool,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub suppress_output: bool,
#[serde(default)]
pub system_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum HookEventNameWire {
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "Stop")]
Stop,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "session-start.command.output")]
pub(crate) struct SessionStartCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub hook_specific_output: Option<SessionStartHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct SessionStartHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "stop.command.output")]
pub(crate) struct StopCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<StopDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum StopDecisionWire {
#[serde(rename = "block")]
Block,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "session-start.command.input")]
pub(crate) struct SessionStartCommandInput {
pub session_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "session_start_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
#[schemars(schema_with = "session_start_source_schema")]
pub source: String,
}
impl SessionStartCommandInput {
pub(crate) fn new(
session_id: impl Into<String>,
transcript_path: Option<PathBuf>,
cwd: impl Into<String>,
model: impl Into<String>,
permission_mode: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self {
session_id: session_id.into(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.into(),
hook_event_name: "SessionStart".to_string(),
model: model.into(),
permission_mode: permission_mode.into(),
source: source.into(),
}
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "stop.command.input")]
pub(crate) struct StopCommandInput {
pub session_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "stop_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub stop_hook_active: bool,
pub last_assistant_message: NullableString,
}
impl StopCommandInput {
pub(crate) fn new(
session_id: impl Into<String>,
transcript_path: Option<PathBuf>,
cwd: impl Into<String>,
model: impl Into<String>,
permission_mode: impl Into<String>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> Self {
Self {
session_id: session_id.into(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.into(),
hook_event_name: "Stop".to_string(),
model: model.into(),
permission_mode: permission_mode.into(),
stop_hook_active,
last_assistant_message: NullableString::from_string(last_assistant_message),
}
}
}
pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
write_schema(
&generated_dir.join(SESSION_START_INPUT_FIXTURE),
schema_json::<SessionStartCommandInput>()?,
)?;
write_schema(
&generated_dir.join(SESSION_START_OUTPUT_FIXTURE),
schema_json::<SessionStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(STOP_INPUT_FIXTURE),
schema_json::<StopCommandInput>()?,
)?;
write_schema(
&generated_dir.join(STOP_OUTPUT_FIXTURE),
schema_json::<StopCommandOutputWire>()?,
)?;
Ok(())
}
fn write_schema(path: &Path, json: Vec<u8>) -> anyhow::Result<()> {
std::fs::write(path, json)?;
Ok(())
}
fn ensure_empty_dir(dir: &Path) -> anyhow::Result<()> {
if dir.exists() {
std::fs::remove_dir_all(dir)?;
}
std::fs::create_dir_all(dir)?;
Ok(())
}
fn schema_json<T>() -> anyhow::Result<Vec<u8>>
where
T: JsonSchema,
{
let schema = schema_for_type::<T>();
let value = serde_json::to_value(schema)?;
let value = canonicalize_json(&value);
Ok(serde_json::to_vec_pretty(&value)?)
}
fn schema_for_type<T>() -> RootSchema
where
T: JsonSchema,
{
SchemaSettings::draft07()
.with(|settings| {
settings.option_add_null_type = false;
})
.into_generator()
.into_root_schema_for::<T>()
}
fn canonicalize_json(value: &Value) -> Value {
match value {
Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()),
Value::Object(map) => {
let mut entries: Vec<_> = map.iter().collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut sorted = Map::with_capacity(map.len());
for (key, child) in entries {
sorted.insert(key.clone(), canonicalize_json(child));
}
Value::Object(sorted)
}
_ => value.clone(),
}
}
fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SessionStart")
}
fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Stop")
}
fn permission_mode_schema(_gen: &mut SchemaGenerator) -> Schema {
string_enum_schema(&[
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions",
])
}
fn session_start_source_schema(_gen: &mut SchemaGenerator) -> Schema {
string_enum_schema(&["startup", "resume", "clear"])
}
fn string_const_schema(value: &str) -> Schema {
let mut schema = SchemaObject {
instance_type: Some(InstanceType::String.into()),
..Default::default()
};
schema.const_value = Some(Value::String(value.to_string()));
Schema::Object(schema)
}
fn string_enum_schema(values: &[&str]) -> Schema {
let mut schema = SchemaObject {
instance_type: Some(InstanceType::String.into()),
..Default::default()
};
schema.enum_values = Some(
values
.iter()
.map(|value| Value::String((*value).to_string()))
.collect(),
);
Schema::Object(schema)
}
fn default_continue() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::SESSION_START_INPUT_FIXTURE;
use super::SESSION_START_OUTPUT_FIXTURE;
use super::STOP_INPUT_FIXTURE;
use super::STOP_OUTPUT_FIXTURE;
use super::write_schema_fixtures;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn expected_fixture(name: &str) -> &'static str {
match name {
SESSION_START_INPUT_FIXTURE => {
include_str!("../schema/generated/session-start.command.input.schema.json")
}
SESSION_START_OUTPUT_FIXTURE => {
include_str!("../schema/generated/session-start.command.output.schema.json")
}
STOP_INPUT_FIXTURE => {
include_str!("../schema/generated/stop.command.input.schema.json")
}
STOP_OUTPUT_FIXTURE => {
include_str!("../schema/generated/stop.command.output.schema.json")
}
_ => panic!("unexpected fixture name: {name}"),
}
}
fn normalize_newlines(value: &str) -> String {
value.replace("\r\n", "\n")
}
#[test]
fn generated_hook_schemas_match_fixtures() {
let temp_dir = TempDir::new().expect("create temp dir");
let schema_root = temp_dir.path().join("schema");
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
for fixture in [
SESSION_START_INPUT_FIXTURE,
SESSION_START_OUTPUT_FIXTURE,
STOP_INPUT_FIXTURE,
STOP_OUTPUT_FIXTURE,
] {
let expected = normalize_newlines(expected_fixture(fixture));
let actual = std::fs::read_to_string(schema_root.join("generated").join(fixture))
.unwrap_or_else(|err| panic!("read generated schema {fixture}: {err}"));
let actual = normalize_newlines(&actual);
assert_eq!(expected, actual, "fixture should match generated schema");
}
}
}

View file

@ -359,6 +359,8 @@ async fn run_codex_tool_session_inner(
| EventMsg::EnteredReviewMode(_)
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
| EventMsg::HookStarted(_)
| EventMsg::HookCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)

View file

@ -1234,6 +1234,8 @@ pub enum EventMsg {
ItemStarted(ItemStartedEvent),
ItemCompleted(ItemCompletedEvent),
HookStarted(HookStartedEvent),
HookCompleted(HookCompletedEvent),
AgentMessageContentDelta(AgentMessageContentDeltaEvent),
PlanDelta(PlanDeltaEvent),
@ -1262,6 +1264,97 @@ pub enum EventMsg {
CollabResumeEnd(CollabResumeEndEvent),
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
SessionStart,
Stop,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum HookHandlerType {
Command,
Prompt,
Agent,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum HookExecutionMode {
Sync,
Async,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum HookScope {
Thread,
Turn,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum HookRunStatus {
Running,
Completed,
Failed,
Blocked,
Stopped,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum HookOutputEntryKind {
Warning,
Stop,
Feedback,
Context,
Error,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub struct HookOutputEntry {
pub kind: HookOutputEntryKind,
pub text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub struct HookRunSummary {
pub id: String,
pub event_name: HookEventName,
pub handler_type: HookHandlerType,
pub execution_mode: HookExecutionMode,
pub scope: HookScope,
pub source_path: PathBuf,
pub display_order: i64,
pub status: HookRunStatus,
pub status_message: Option<String>,
#[ts(type = "number")]
pub started_at: i64,
#[ts(type = "number | null")]
pub completed_at: Option<i64>,
#[ts(type = "number | null")]
pub duration_ms: Option<i64>,
pub entries: Vec<HookOutputEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub struct HookStartedEvent {
pub turn_id: Option<String>,
pub run: HookRunSummary,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub struct HookCompletedEvent {
pub turn_id: Option<String>,
pub run: HookRunSummary,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct RealtimeConversationStartedEvent {
pub session_id: Option<String>,

View file

@ -402,6 +402,8 @@ mod tests {
use std::os::fd::FromRawFd;
use std::path::PathBuf;
use std::sync::LazyLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
use tokio::time::Instant;
use tokio::time::sleep;
@ -542,7 +544,9 @@ mod tests {
std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
}
struct AfterSpawnAssertingShellCommandExecutor;
struct AfterSpawnAssertingShellCommandExecutor {
after_spawn_invoked: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl ShellCommandExecutor for AfterSpawnAssertingShellCommandExecutor {
@ -559,19 +563,8 @@ mod tests {
.expect("session should export shell escalation socket")
.parse::<i32>()?;
assert_ne!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1);
let preserved_socket_fd = unsafe { libc::dup(socket_fd) };
assert!(
preserved_socket_fd >= 0,
"expected dup() of client socket to succeed",
);
let preserved_socket =
unsafe { std::os::fd::OwnedFd::from_raw_fd(preserved_socket_fd) };
after_spawn.expect("one-shot exec should install an after-spawn hook")();
let replacement_fd =
unsafe { libc::fcntl(preserved_socket.as_raw_fd(), libc::F_DUPFD, socket_fd) };
assert_eq!(replacement_fd, socket_fd);
let replacement_socket = unsafe { std::os::fd::OwnedFd::from_raw_fd(replacement_fd) };
drop(replacement_socket);
self.after_spawn_invoked.store(true, Ordering::Relaxed);
Ok(ExecResult {
exit_code: 0,
stdout: String::new(),
@ -644,8 +637,19 @@ mod tests {
let socket_fd = socket_fd.parse::<i32>()?;
assert!(socket_fd >= 0);
assert_ne!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1);
assert!(
session
.client_socket
.lock()
.is_ok_and(|socket| socket.is_some())
);
session.close_client_socket();
assert_eq!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1);
assert!(
session
.client_socket
.lock()
.is_ok_and(|socket| socket.is_none())
);
Ok(())
}
@ -653,6 +657,7 @@ mod tests {
#[tokio::test]
async fn exec_closes_parent_socket_after_shell_spawn() -> anyhow::Result<()> {
let _guard = ESCALATE_SERVER_TEST_LOCK.lock().await;
let after_spawn_invoked = Arc::new(AtomicBool::new(false));
let server = EscalateServer::new(
PathBuf::from("/bin/bash"),
PathBuf::from("/tmp/codex-execve-wrapper"),
@ -672,10 +677,13 @@ mod tests {
login: Some(false),
},
CancellationToken::new(),
Arc::new(AfterSpawnAssertingShellCommandExecutor),
Arc::new(AfterSpawnAssertingShellCommandExecutor {
after_spawn_invoked: Arc::clone(&after_spawn_invoked),
}),
)
.await?;
assert_eq!(0, result.exit_code);
assert!(after_spawn_invoked.load(Ordering::Relaxed));
Ok(())
}

View file

@ -2561,6 +2561,37 @@ impl ChatWidget {
self.set_status_header(message);
}
fn on_hook_started(&mut self, event: codex_protocol::protocol::HookStartedEvent) {
let label = hook_event_label(event.run.event_name);
let mut message = format!("Running {label} hook");
if let Some(status_message) = event.run.status_message
&& !status_message.is_empty()
{
message.push_str(": ");
message.push_str(&status_message);
}
self.add_to_history(history_cell::new_info_event(message, None));
self.request_redraw();
}
fn on_hook_completed(&mut self, event: codex_protocol::protocol::HookCompletedEvent) {
let status = format!("{:?}", event.run.status).to_lowercase();
let header = format!("{} hook ({status})", hook_event_label(event.run.event_name));
let mut lines: Vec<ratatui::text::Line<'static>> = vec![header.into()];
for entry in event.run.entries {
let prefix = match entry.kind {
codex_protocol::protocol::HookOutputEntryKind::Warning => "warning: ",
codex_protocol::protocol::HookOutputEntryKind::Stop => "stop: ",
codex_protocol::protocol::HookOutputEntryKind::Feedback => "feedback: ",
codex_protocol::protocol::HookOutputEntryKind::Context => "hook context: ",
codex_protocol::protocol::HookOutputEntryKind::Error => "error: ",
};
lines.push(format!(" {prefix}{}", entry.text).into());
}
self.add_to_history(PlainHistoryCell::new(lines));
self.request_redraw();
}
fn on_undo_started(&mut self, event: UndoStartedEvent) {
self.bottom_pane.ensure_status_indicator();
self.bottom_pane.set_interrupt_hint_visible(false);
@ -4981,6 +5012,8 @@ impl ChatWidget {
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_) => {}
EventMsg::HookStarted(event) => self.on_hook_started(event),
EventMsg::HookCompleted(event) => self.on_hook_completed(event),
EventMsg::RealtimeConversationStarted(ev) => {
if !from_replay {
self.on_realtime_conversation_started(ev);
@ -8785,6 +8818,13 @@ fn extract_first_bold(s: &str) -> Option<String> {
None
}
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::Stop => "Stop",
}
}
async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Vec<RateLimitSnapshot> {
match BackendClient::from_auth(base_url, &auth) {
Ok(client) => match client.get_rate_limits_many().await {

View file

@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9494
expression: combined
---

View file

@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 8586
expression: combined
---
• Running SessionStart hook: warming the shell
SessionStart hook (completed)
warning: Heads up from the hook
hook context: Remember the startup checklist.

View file

@ -9702,6 +9702,133 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() {
assert_snapshot!(combined);
}
#[tokio::test]
async fn deltas_then_same_final_message_are_rendered_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
// Stream some reasoning deltas first.
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "I will ".into(),
}),
});
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "first analyze the ".into(),
}),
});
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "request.".into(),
}),
});
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentReasoning(AgentReasoningEvent {
text: "request.".into(),
}),
});
// Then stream answer deltas, followed by the exact same final message.
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Here is the ".into(),
}),
});
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "result.".into(),
}),
});
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Here is the result.".into(),
phase: None,
}),
});
// Snapshot the combined visible content to ensure we render as expected
// when deltas are followed by the identical final message.
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_snapshot!(combined);
}
#[tokio::test]
async fn hook_events_render_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "hook-1".into(),
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: "session-start:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::SessionStart,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Thread,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Running,
status_message: Some("warming the shell".to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
entries: vec![],
},
}),
});
chat.handle_codex_event(Event {
id: "hook-1".into(),
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: "session-start:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::SessionStart,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Thread,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Completed,
status_message: Some("warming the shell".to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: vec![
codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Warning,
text: "Heads up from the hook".to_string(),
},
codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Context,
text: "Remember the startup checklist.".to_string(),
},
],
},
}),
});
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_snapshot!("hook_events_render_snapshot", combined);
}
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.
// This renders the final visual as seen in a terminal: history above, then a blank line,
// then the exec block, another blank line, the status line, a blank line, and the composer.

View file

@ -442,16 +442,18 @@ async fn pipe_process_can_expose_split_stdout_and_stderr() -> anyhow::Result<()>
exit_rx,
} = spawned;
let timeout_ms = if cfg!(windows) { 10_000 } else { 2_000 };
let timeout = tokio::time::Duration::from_millis(timeout_ms);
let stdout_task = tokio::spawn(async move { collect_split_output(stdout_rx).await });
let stderr_task = tokio::spawn(async move { collect_split_output(stderr_rx).await });
let code = tokio::time::timeout(tokio::time::Duration::from_secs(2), exit_rx)
let code = tokio::time::timeout(timeout, exit_rx)
.await
.map_err(|_| anyhow::anyhow!("timed out waiting for split process exit"))?
.unwrap_or(-1);
let stdout = tokio::time::timeout(tokio::time::Duration::from_secs(2), stdout_task)
let stdout = tokio::time::timeout(timeout, stdout_task)
.await
.map_err(|_| anyhow::anyhow!("timed out waiting to drain split stdout"))??;
let stderr = tokio::time::timeout(tokio::time::Duration::from_secs(2), stderr_task)
let stderr = tokio::time::timeout(timeout, stderr_task)
.await
.map_err(|_| anyhow::anyhow!("timed out waiting to drain split stderr"))??;

View file

@ -82,6 +82,10 @@ write-config-schema:
write-app-server-schema *args:
cargo run -p codex-app-server-protocol --bin write_schema_fixtures -- "$@"
[no-cd]
write-hooks-schema:
cargo run --manifest-path ./codex-rs/Cargo.toml -p codex-hooks --bin write_hooks_schema_fixtures
# Tail logs from the state SQLite database
log *args:
if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@"

View file

@ -4,7 +4,8 @@
"description": "Tools for repo-wide maintenance.",
"scripts": {
"format": "prettier --check *.json *.md docs/*.md .github/workflows/*.yml **/*.js",
"format:fix": "prettier --write *.json *.md docs/*.md .github/workflows/*.yml **/*.js"
"format:fix": "prettier --write *.json *.md docs/*.md .github/workflows/*.yml **/*.js",
"write-hooks-schema": "cargo run --manifest-path ./codex-rs/Cargo.toml -p codex-hooks --bin write_hooks_schema_fixtures"
},
"devDependencies": {
"prettier": "^3.5.3"