diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2fed4efe1..64abfaac3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index ea67aec65..a826efc11 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index fcb205db0..a3836b48f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1b80de6ff..1faf8e9ac 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c608fb8e1..0c94f4b70 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json new file mode 100644 index 000000000..e00ba5a00 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json new file mode 100644 index 000000000..49d94c7c1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts index a51bca719..a36d317b2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts @@ -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; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookCompletedEvent.ts b/codex-rs/app-server-protocol/schema/typescript/HookCompletedEvent.ts new file mode 100644 index 000000000..af439c512 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookCompletedEvent.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/HookEventName.ts new file mode 100644 index 000000000..45e6489d1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookEventName.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookExecutionMode.ts b/codex-rs/app-server-protocol/schema/typescript/HookExecutionMode.ts new file mode 100644 index 000000000..61f98564c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookExecutionMode.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookHandlerType.ts b/codex-rs/app-server-protocol/schema/typescript/HookHandlerType.ts new file mode 100644 index 000000000..dc3f087bf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookHandlerType.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookOutputEntry.ts b/codex-rs/app-server-protocol/schema/typescript/HookOutputEntry.ts new file mode 100644 index 000000000..834f0c4e0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookOutputEntry.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookOutputEntryKind.ts b/codex-rs/app-server-protocol/schema/typescript/HookOutputEntryKind.ts new file mode 100644 index 000000000..090dfe387 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookOutputEntryKind.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookRunStatus.ts b/codex-rs/app-server-protocol/schema/typescript/HookRunStatus.ts new file mode 100644 index 000000000..ffca7e0e2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookRunStatus.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookRunSummary.ts b/codex-rs/app-server-protocol/schema/typescript/HookRunSummary.ts new file mode 100644 index 000000000..3725ff81d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookRunSummary.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookScope.ts b/codex-rs/app-server-protocol/schema/typescript/HookScope.ts new file mode 100644 index 000000000..ff6f8bfee --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookScope.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/HookStartedEvent.ts b/codex-rs/app-server-protocol/schema/typescript/HookStartedEvent.ts new file mode 100644 index 000000000..e6387f516 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/HookStartedEvent.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index daf23faa2..18cb9a8b2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -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 }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 54dea84bc..6560ffd5d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookCompletedNotification.ts new file mode 100644 index 000000000..fe4dbfb51 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookCompletedNotification.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts new file mode 100644 index 000000000..d07429a92 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookExecutionMode.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookExecutionMode.ts new file mode 100644 index 000000000..61f98564c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookExecutionMode.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookHandlerType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookHandlerType.ts new file mode 100644 index 000000000..dc3f087bf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookHandlerType.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookOutputEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookOutputEntry.ts new file mode 100644 index 000000000..834f0c4e0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookOutputEntry.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookOutputEntryKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookOutputEntryKind.ts new file mode 100644 index 000000000..090dfe387 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookOutputEntryKind.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookRunStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookRunStatus.ts new file mode 100644 index 000000000..ffca7e0e2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookRunStatus.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts new file mode 100644 index 000000000..68fb4e10a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookScope.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookScope.ts new file mode 100644 index 000000000..ff6f8bfee --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookScope.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookStartedNotification.ts new file mode 100644 index 000000000..1f781ed63 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookStartedNotification.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 50689c33c..aa39c5c3e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 63db91b1b..cbd3773cb 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 1ad159129..38a8c6496 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f28c86e53..d52cef6ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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 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, + pub started_at: i64, + pub completed_at: Option, + pub duration_ms: Option, + pub entries: Vec, +} + +impl From 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, + 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, + pub run: HookRunSummary, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 5e34baef4..d442f3eca 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 49e3fc944..db31bf1ee 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6d3c53f83..f5132fabb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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 { + 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 { + 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 = None; + let mut stop_hook_active = false; + let mut pending_stop_hook_message: Option = 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 = { + let mut sampling_request_input: Vec = { 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 { | EventMsg::ExitedReviewMode(_) | EventMsg::RawResponseItem(_) | EventMsg::ItemStarted(_) + | EventMsg::HookStarted(_) + | EventMsg::HookCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::PlanDelta(_) | EventMsg::ReasoningContentDelta(_) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index e3410ffb9..bf8d970a8 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -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()), diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index af8326bab..c21d7d963 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -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", diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index aff6759da..89068d46f 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -165,6 +165,8 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::ShutdownComplete | EventMsg::DeprecationNotice(_) | EventMsg::ItemStarted(_) + | EventMsg::HookStarted(_) + | EventMsg::HookCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::PlanDelta(_) | EventMsg::ReasoningContentDelta(_) diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index cc4e12804..973501e8d 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) startup_regular_task: Option>>, pub(crate) active_mcp_tool_selection: Option>, pub(crate) active_connector_selection: HashSet, + pub(crate) pending_session_start_source: Option, granted_permissions: Option, } @@ -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, + ) { + self.pending_session_start_source = value; + } + + pub(crate) fn take_pending_session_start_source( + &mut self, + ) -> Option { + self.pending_session_start_source.take() + } } // Sometimes new snapshots don't include credits or plan information. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index f40dce1fd..5c6cd1c47 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -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,47 +935,131 @@ impl EventProcessorWithHumanOutput { serde_json::from_str::(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!( - msg, - EventMsg::ThreadNameUpdated(_) - | EventMsg::TokenCount(_) - | EventMsg::TurnStarted(_) - | EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) - | EventMsg::ListSkillsResponse(_) - | EventMsg::ListRemoteSkillsResponse(_) - | EventMsg::RemoteSkillDownloaded(_) - | EventMsg::RawResponseItem(_) - | EventMsg::UserMessage(_) - | EventMsg::EnteredReviewMode(_) - | EventMsg::ExitedReviewMode(_) - | EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::AgentReasoningRawContentDelta(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::PlanDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::SkillsUpdateAvailable - | EventMsg::UndoCompleted(_) - | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) - | EventMsg::RequestPermissions(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) - ) + 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(_) + | EventMsg::TurnStarted(_) + | EventMsg::ExecApprovalRequest(_) + | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::TerminalInteraction(_) + | EventMsg::ExecCommandOutputDelta(_) + | EventMsg::GetHistoryEntryResponse(_) + | EventMsg::McpListToolsResponse(_) + | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) + | EventMsg::ListRemoteSkillsResponse(_) + | EventMsg::RemoteSkillDownloaded(_) + | EventMsg::RawResponseItem(_) + | EventMsg::UserMessage(_) + | EventMsg::EnteredReviewMode(_) + | EventMsg::ExitedReviewMode(_) + | EventMsg::AgentMessageDelta(_) + | EventMsg::AgentReasoningDelta(_) + | EventMsg::AgentReasoningRawContentDelta(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::AgentMessageContentDelta(_) + | EventMsg::PlanDelta(_) + | EventMsg::ReasoningContentDelta(_) + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::SkillsUpdateAvailable + | EventMsg::UndoCompleted(_) + | EventMsg::UndoStarted(_) + | EventMsg::ThreadRolledBack(_) + | EventMsg::RequestUserInput(_) + | 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, + 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, + } + } } diff --git a/codex-rs/hooks/BUILD.bazel b/codex-rs/hooks/BUILD.bazel index f13a065f2..8afd0d9fe 100644 --- a/codex-rs/hooks/BUILD.bazel +++ b/codex-rs/hooks/BUILD.bazel @@ -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, ) diff --git a/codex-rs/hooks/Cargo.toml b/codex-rs/hooks/Cargo.toml index a49b1166f..aeb91e7fe 100644 --- a/codex-rs/hooks/Cargo.toml +++ b/codex-rs/hooks/Cargo.toml @@ -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"] } diff --git a/codex-rs/hooks/schema/generated/session-start.command.input.schema.json b/codex-rs/hooks/schema/generated/session-start.command.input.schema.json new file mode 100644 index 000000000..385932c36 --- /dev/null +++ b/codex-rs/hooks/schema/generated/session-start.command.input.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json new file mode 100644 index 000000000..478744ca4 --- /dev/null +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/stop.command.input.schema.json b/codex-rs/hooks/schema/generated/stop.command.input.schema.json new file mode 100644 index 000000000..9e500fd83 --- /dev/null +++ b/codex-rs/hooks/schema/generated/stop.command.input.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/stop.command.output.schema.json b/codex-rs/hooks/schema/generated/stop.command.output.schema.json new file mode 100644 index 000000000..f09f8763a --- /dev/null +++ b/codex-rs/hooks/schema/generated/stop.command.output.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/src/bin/write_hooks_schema_fixtures.rs b/codex-rs/hooks/src/bin/write_hooks_schema_fixtures.rs new file mode 100644 index 000000000..303b561bf --- /dev/null +++ b/codex-rs/hooks/src/bin/write_hooks_schema_fixtures.rs @@ -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) +} diff --git a/codex-rs/hooks/src/engine/command_runner.rs b/codex-rs/hooks/src/engine/command_runner.rs new file mode 100644 index 000000000..e0e08c3fa --- /dev/null +++ b/codex-rs/hooks/src/engine/command_runner.rs @@ -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, + pub stdout: String, + pub stderr: String, + pub error: Option, +} + +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 + } +} diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs new file mode 100644 index 000000000..97dcce945 --- /dev/null +++ b/codex-rs/hooks/src/engine/config.rs @@ -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, + #[serde(rename = "Stop", default)] + pub stop: Vec, +} + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct MatcherGroup { + #[serde(default)] + pub matcher: Option, + #[serde(default)] + pub hooks: Vec, +} + +#[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, + #[serde(default)] + r#async: bool, + #[serde(default, rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + Agent {}, +} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs new file mode 100644 index 000000000..33a35bdef --- /dev/null +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -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, + pub warnings: Vec, +} + +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, + warnings: &mut Vec, + display_order: &mut i64, + source_path: &Path, + event_name: codex_protocol::protocol::HookEventName, + matcher: Option<&str>, + group_handlers: Vec, +) { + 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() + )), + } + } +} diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs new file mode 100644 index 000000000..a776d4cf9 --- /dev/null +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -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 { + pub completed: HookCompletedEvent, + pub data: T, +} + +pub(crate) fn select_handlers( + handlers: &[ConfiguredHandler], + event_name: HookEventName, + session_start_source: Option<&str>, +) -> Vec { + 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( + shell: &CommandShell, + handlers: Vec, + input_json: String, + cwd: &Path, + turn_id: Option, + parse: fn(&ConfiguredHandler, CommandRunResult, Option) -> ParsedHandler, +) -> Vec> { + 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, +) -> 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"); + } +} diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs new file mode 100644 index 000000000..838d4ed74 --- /dev/null +++ b/codex-rs/hooks/src/engine/mod.rs @@ -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, +} + +#[derive(Debug, Clone)] +pub(crate) struct ConfiguredHandler { + pub event_name: codex_protocol::protocol::HookEventName, + pub matcher: Option, + pub command: String, + pub timeout_sec: u64, + pub status_message: Option, + 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, + warnings: Vec, + 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 { + crate::events::session_start::preview(&self.handlers, request) + } + + pub(crate) async fn run_session_start( + &self, + request: SessionStartRequest, + turn_id: Option, + ) -> SessionStartOutcome { + crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await + } + + pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec { + 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 + } +} diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs new file mode 100644 index 000000000..6b7908f0e --- /dev/null +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -0,0 +1,71 @@ +#[derive(Debug, Clone)] +pub(crate) struct UniversalOutput { + pub continue_processing: bool, + pub stop_reason: Option, + pub suppress_output: bool, + pub system_message: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct SessionStartOutput { + pub universal: UniversalOutput, + pub additional_context: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct StopOutput { + pub universal: UniversalOutput, + pub should_block: bool, + pub reason: Option, +} + +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 { + 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 { + 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 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(stdout: &str) -> Option +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() +} diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs new file mode 100644 index 000000000..1bf5a9130 --- /dev/null +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -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 = 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"); + } +} diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs new file mode 100644 index 000000000..68252f7cd --- /dev/null +++ b/codex-rs/hooks/src/events/mod.rs @@ -0,0 +1,2 @@ +pub mod session_start; +pub mod stop; diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs new file mode 100644 index 000000000..feb9c708e --- /dev/null +++ b/codex-rs/hooks/src/events/session_start.rs @@ -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, + pub model: String, + pub permission_mode: String, + pub source: SessionStartSource, +} + +#[derive(Debug)] +pub struct SessionStartOutcome { + pub hook_events: Vec, + pub should_stop: bool, + pub stop_reason: Option, + pub additional_context: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct SessionStartHandlerData { + should_stop: bool, + stop_reason: Option, + additional_context_for_model: Option, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &SessionStartRequest, +) -> Vec { + 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, +) -> 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::>(); + + 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, +) -> dispatcher::ParsedHandler { + 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) -> Option { + if chunks.is_empty() { + None + } else { + Some(chunks.join("\n\n")) + } +} + +fn serialization_failure_outcome( + handlers: Vec, + turn_id: Option, + 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, 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, + } + } +} diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs new file mode 100644 index 000000000..1ac4028ad --- /dev/null +++ b/codex-rs/hooks/src/events/stop.rs @@ -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, + pub model: String, + pub permission_mode: String, + pub stop_hook_active: bool, + pub last_assistant_message: Option, +} + +#[derive(Debug)] +pub struct StopOutcome { + pub hook_events: Vec, + pub should_stop: bool, + pub stop_reason: Option, + pub should_block: bool, + pub block_reason: Option, + pub block_message_for_model: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct StopHandlerData { + should_stop: bool, + stop_reason: Option, + should_block: bool, + block_reason: Option, + block_message_for_model: Option, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + _request: &StopRequest, +) -> Vec { + 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, +) -> dispatcher::ParsedHandler { + 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 { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + None +} + +fn serialization_failure_outcome( + handlers: Vec, + turn_id: Option, + 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, 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, + } + } +} diff --git a/codex-rs/hooks/src/legacy_notify.rs b/codex-rs/hooks/src/legacy_notify.rs new file mode 100644 index 000000000..09db9e7e8 --- /dev/null +++ b/codex-rs/hooks/src/legacy_notify.rs @@ -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, + input_messages: Vec, + last_assistant_message: Option, + }, +} + +pub fn legacy_notify_json(payload: &HookPayload) -> Result { + 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) -> 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(()) + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index f21fe469e..c1343ca0f 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -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; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 1648fa046..2d9412a0b 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -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>, + pub feature_enabled: bool, + pub config_layer_stack: Option, + pub shell_program: Option, + pub shell_args: Vec, } #[derive(Clone)] pub struct Hooks { after_agent: Vec, after_tool_use: Vec, + 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 { + self.engine.preview_session_start(request) + } + + pub async fn run_session_start( + &self, + request: SessionStartRequest, + turn_id: Option, + ) -> SessionStartOutcome { + self.engine.run_session_start(request, turn_id).await + } + + pub fn preview_stop( + &self, + request: &StopRequest, + ) -> Vec { + 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 { @@ -70,415 +119,3 @@ pub fn command_from_argv(argv: &[String]) -> Option { 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, 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, 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, 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(()) - } -} diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs new file mode 100644 index 000000000..43f6d9403 --- /dev/null +++ b/codex-rs/hooks/src/schema.rs @@ -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); + +impl NullableString { + fn from_path(path: Option) -> Self { + Self(path.map(|path| path.display().to_string())) + } + + fn from_string(value: Option) -> 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, + #[serde(default)] + pub suppress_output: bool, + #[serde(default)] + pub system_message: Option, +} + +#[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, +} + +#[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, +} + +#[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, + #[serde(default)] + pub reason: Option, +} + +#[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, + transcript_path: Option, + cwd: impl Into, + model: impl Into, + permission_mode: impl Into, + source: impl Into, + ) -> 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, + transcript_path: Option, + cwd: impl Into, + model: impl Into, + permission_mode: impl Into, + stop_hook_active: bool, + last_assistant_message: Option, + ) -> 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::()?, + )?; + write_schema( + &generated_dir.join(SESSION_START_OUTPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(STOP_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(STOP_OUTPUT_FIXTURE), + schema_json::()?, + )?; + + Ok(()) +} + +fn write_schema(path: &Path, json: Vec) -> 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() -> anyhow::Result> +where + T: JsonSchema, +{ + let schema = schema_for_type::(); + let value = serde_json::to_value(schema)?; + let value = canonicalize_json(&value); + Ok(serde_json::to_vec_pretty(&value)?) +} + +fn schema_for_type() -> RootSchema +where + T: JsonSchema, +{ + SchemaSettings::draft07() + .with(|settings| { + settings.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::() +} + +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"); + } + } +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 03547a7e3..df38a1c0c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -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(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f9b7e88eb..67399f511 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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, + #[ts(type = "number")] + pub started_at: i64, + #[ts(type = "number | null")] + pub completed_at: Option, + #[ts(type = "number | null")] + pub duration_ms: Option, + pub entries: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub struct HookStartedEvent { + pub turn_id: Option, + pub run: HookRunSummary, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub struct HookCompletedEvent { + pub turn_id: Option, + pub run: HookRunSummary, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct RealtimeConversationStartedEvent { pub session_id: Option, diff --git a/codex-rs/shell-escalation/src/unix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs index 796431438..eb73250bc 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_server.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_server.rs @@ -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, + } #[async_trait::async_trait] impl ShellCommandExecutor for AfterSpawnAssertingShellCommandExecutor { @@ -559,19 +563,8 @@ mod tests { .expect("session should export shell escalation socket") .parse::()?; 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::()?; 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(()) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index aff0a9f41..1d850de26 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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> = 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 { 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 { match BackendClient::from_auth(base_url, &auth) { Ok(client) => match client.get_rate_limits_many().await { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap new file mode 100644 index 000000000..042b80769 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 9494 +expression: combined +--- + diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap new file mode 100644 index 000000000..27474ef6d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 98e5005c7..080f141fe 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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::(); + 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::(); + 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. diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index e6c6b925f..2efb0f1ae 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -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"))??; diff --git a/justfile b/justfile index 27ba4fc9c..90f63c441 100644 --- a/justfile +++ b/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 -- "$@" diff --git a/package.json b/package.json index b4ca5734b..504b12bee 100644 --- a/package.json +++ b/package.json @@ -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"