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:
parent
da616136cc
commit
244b2d53f4
73 changed files with 4791 additions and 483 deletions
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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/")]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
9
codex-rs/hooks/src/bin/write_hooks_schema_fixtures.rs
Normal file
9
codex-rs/hooks/src/bin/write_hooks_schema_fixtures.rs
Normal 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)
|
||||
}
|
||||
135
codex-rs/hooks/src/engine/command_runner.rs
Normal file
135
codex-rs/hooks/src/engine/command_runner.rs
Normal 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
|
||||
}
|
||||
}
|
||||
42
codex-rs/hooks/src/engine/config.rs
Normal file
42
codex-rs/hooks/src/engine/config.rs
Normal 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 {},
|
||||
}
|
||||
162
codex-rs/hooks/src/engine/discovery.rs
Normal file
162
codex-rs/hooks/src/engine/discovery.rs
Normal 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()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
190
codex-rs/hooks/src/engine/dispatcher.rs
Normal file
190
codex-rs/hooks/src/engine/dispatcher.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
109
codex-rs/hooks/src/engine/mod.rs
Normal file
109
codex-rs/hooks/src/engine/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
71
codex-rs/hooks/src/engine/output_parser.rs
Normal file
71
codex-rs/hooks/src/engine/output_parser.rs
Normal 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()
|
||||
}
|
||||
54
codex-rs/hooks/src/engine/schema_loader.rs
Normal file
54
codex-rs/hooks/src/engine/schema_loader.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
2
codex-rs/hooks/src/events/mod.rs
Normal file
2
codex-rs/hooks/src/events/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod session_start;
|
||||
pub mod stop;
|
||||
393
codex-rs/hooks/src/events/session_start.rs
Normal file
393
codex-rs/hooks/src/events/session_start.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
495
codex-rs/hooks/src/events/stop.rs
Normal file
495
codex-rs/hooks/src/events/stop.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
145
codex-rs/hooks/src/legacy_notify.rs
Normal file
145
codex-rs/hooks/src/legacy_notify.rs
Normal 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(¬ification)?;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
360
codex-rs/hooks/src/schema.rs
Normal file
360
codex-rs/hooks/src/schema.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 9494
|
||||
expression: combined
|
||||
---
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"))??;
|
||||
|
||||
|
|
|
|||
4
justfile
4
justfile
|
|
@ -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 -- "$@"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue