diff --git a/.github/actions/macos-code-sign/action.yml b/.github/actions/macos-code-sign/action.yml
index ea4a19a8f5..200b23901f 100644
--- a/.github/actions/macos-code-sign/action.yml
+++ b/.github/actions/macos-code-sign/action.yml
@@ -132,9 +132,11 @@ runs:
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
fi
+ entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist"
+
for binary in codex codex-responses-api-proxy; do
path="codex-rs/target/${TARGET}/release/${binary}"
- codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
+ codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
done
- name: Notarize macOS binaries
diff --git a/.github/actions/macos-code-sign/codex.entitlements.plist b/.github/actions/macos-code-sign/codex.entitlements.plist
new file mode 100644
index 0000000000..d35e43ae58
--- /dev/null
+++ b/.github/actions/macos-code-sign/codex.entitlements.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+
+
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 716fcab0df..d0c6c390d5 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -380,7 +380,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -1802,6 +1802,20 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "codex-code-mode"
+version = "0.0.0"
+dependencies = [
+ "async-trait",
+ "pretty_assertions",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "v8",
+]
+
[[package]]
name = "codex-config"
version = "0.0.0"
@@ -1859,6 +1873,7 @@ dependencies = [
"codex-arg0",
"codex-artifacts",
"codex-async-utils",
+ "codex-code-mode",
"codex-config",
"codex-connectors",
"codex-exec-server",
@@ -3826,7 +3841,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -4071,7 +4086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -5516,7 +5531,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -6279,7 +6294,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -6923,7 +6938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.45.0",
]
[[package]]
@@ -8345,7 +8360,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -9778,7 +9793,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -11224,7 +11239,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml
index 43438a0575..e0c7633733 100644
--- a/codex-rs/Cargo.toml
+++ b/codex-rs/Cargo.toml
@@ -13,6 +13,7 @@ members = [
"feedback",
"features",
"codex-backend-openapi-models",
+ "code-mode",
"cloud-requirements",
"cloud-tasks",
"cloud-tasks-client",
@@ -92,6 +93,7 @@ app_test_support = { path = "app-server/tests/common" }
codex-ansi-escape = { path = "ansi-escape" }
codex-api = { path = "codex-api" }
codex-artifacts = { path = "artifacts" }
+codex-code-mode = { path = "code-mode" }
codex-package-manager = { path = "package-manager" }
codex-app-server = { path = "app-server" }
codex-app-server-client = { path = "app-server-client" }
@@ -376,7 +378,7 @@ ignored = [
"openssl-sys",
"codex-utils-readiness",
"codex-secrets",
- "codex-v8-poc"
+ "codex-v8-poc",
]
[profile.release]
diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json
index ae8e6fed34..903b26b80b 100644
--- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json
+++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json
@@ -2343,13 +2343,27 @@
"enabled": {
"type": "boolean"
},
+ "name": {
+ "description": "Name-based selector.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"path": {
- "type": "string"
+ "anyOf": [
+ {
+ "$ref": "#/definitions/AbsolutePathBuf"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Path-based selector."
}
},
"required": [
- "enabled",
- "path"
+ "enabled"
],
"type": "object"
},
diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json
index 5b06ab539c..b576581cf9 100644
--- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json
+++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json
@@ -514,6 +514,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -1158,6 +1180,7 @@
},
"HookEventName": {
"enum": [
+ "preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
@@ -1623,6 +1646,13 @@
],
"type": "object"
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
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 c24c8ac249..a644549325 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
@@ -5658,6 +5658,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/v2/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -7972,6 +7994,7 @@
},
"HookEventName": {
"enum": [
+ "preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
@@ -9115,6 +9138,13 @@
},
"type": "object"
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"OverriddenMetadata": {
"properties": {
"effectiveValue": true,
@@ -11323,6 +11353,9 @@
"description": {
"type": "string"
},
+ "enabled": {
+ "type": "boolean"
+ },
"interface": {
"anyOf": [
{
@@ -11348,6 +11381,7 @@
},
"required": [
"description",
+ "enabled",
"name",
"path"
],
@@ -11404,13 +11438,27 @@
"enabled": {
"type": "boolean"
},
+ "name": {
+ "description": "Name-based selector.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"path": {
- "type": "string"
+ "anyOf": [
+ {
+ "$ref": "#/definitions/v2/AbsolutePathBuf"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Path-based selector."
}
},
"required": [
- "enabled",
- "path"
+ "enabled"
],
"title": "SkillsConfigWriteParams",
"type": "object"
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 c479da94e4..71e6034529 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
@@ -2251,6 +2251,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -4676,6 +4698,7 @@
},
"HookEventName": {
"enum": [
+ "preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
@@ -5863,6 +5886,13 @@
},
"type": "object"
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"OverriddenMetadata": {
"properties": {
"effectiveValue": true,
@@ -9083,6 +9113,9 @@
"description": {
"type": "string"
},
+ "enabled": {
+ "type": "boolean"
+ },
"interface": {
"anyOf": [
{
@@ -9108,6 +9141,7 @@
},
"required": [
"description",
+ "enabled",
"name",
"path"
],
@@ -9164,13 +9198,27 @@
"enabled": {
"type": "boolean"
},
+ "name": {
+ "description": "Name-based selector.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"path": {
- "type": "string"
+ "anyOf": [
+ {
+ "$ref": "#/definitions/AbsolutePathBuf"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Path-based selector."
}
},
"required": [
- "enabled",
- "path"
+ "enabled"
],
"title": "SkillsConfigWriteParams",
"type": "object"
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json
index 264a901c0f..032743d55d 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json
@@ -112,9 +112,38 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"TurnError": {
"properties": {
"additionalDetails": {
diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json
index 84fea949c8..881c343601 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json
@@ -3,6 +3,7 @@
"definitions": {
"HookEventName": {
"enum": [
+ "preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json
index 7b55420da2..18fdb5008d 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json
@@ -3,6 +3,7 @@
"definitions": {
"HookEventName": {
"enum": [
+ "preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json
index 5fecf50376..1917935a2e 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json
@@ -318,6 +318,9 @@
"description": {
"type": "string"
},
+ "enabled": {
+ "type": "boolean"
+ },
"interface": {
"anyOf": [
{
@@ -343,6 +346,7 @@
},
"required": [
"description",
+ "enabled",
"name",
"path"
],
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json
index 98b485b578..2e0c3605e7 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json
@@ -131,6 +131,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -494,6 +516,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json
index 3fa74811d5..696226a50c 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json
@@ -1,16 +1,36 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
+ "definitions": {
+ "AbsolutePathBuf": {
+ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
+ "type": "string"
+ }
+ },
"properties": {
"enabled": {
"type": "boolean"
},
+ "name": {
+ "description": "Name-based selector.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"path": {
- "type": "string"
+ "anyOf": [
+ {
+ "$ref": "#/definitions/AbsolutePathBuf"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Path-based selector."
}
},
"required": [
- "enabled",
- "path"
+ "enabled"
],
"title": "SkillsConfigWriteParams",
"type": "object"
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json
index 7740421917..5ae0c5a122 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json
@@ -196,6 +196,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -589,6 +611,13 @@
],
"type": "string"
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json
index 55f02fedbc..126d78603a 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json
@@ -134,6 +134,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -520,6 +542,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json
index 300f8d1f30..dfdab228df 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json
@@ -134,6 +134,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -520,6 +542,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json
index 6c6597a660..8f48dee4b7 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json
@@ -134,6 +134,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -520,6 +542,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json
index 35a41983a4..edccc337da 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json
@@ -196,6 +196,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -589,6 +611,13 @@
],
"type": "string"
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json
index 35e03397b0..cc41aac27f 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json
@@ -134,6 +134,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -520,6 +542,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json
index 568c654561..c3b50fee3b 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json
@@ -196,6 +196,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -589,6 +611,13 @@
],
"type": "string"
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json
index 971233fcde..2240150394 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json
@@ -134,6 +134,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -520,6 +542,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json
index 94046cd18d..41b0d2d409 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json
@@ -134,6 +134,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -520,6 +542,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json
index b0220247aa..770cc920cf 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json
@@ -131,6 +131,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -494,6 +516,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json
index cd9f63bb6c..7f1c3e4948 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json
@@ -131,6 +131,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -494,6 +516,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json
index 3cc16db922..761ddc9a62 100644
--- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json
+++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json
@@ -131,6 +131,28 @@
],
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
"type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
+ "properties": {
+ "activeTurnNotSteerable": {
+ "properties": {
+ "turnKind": {
+ "$ref": "#/definitions/NonSteerableTurnKind"
+ }
+ },
+ "required": [
+ "turnKind"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "activeTurnNotSteerable"
+ ],
+ "title": "ActiveTurnNotSteerableCodexErrorInfo",
+ "type": "object"
}
]
},
@@ -494,6 +516,13 @@
}
]
},
+ "NonSteerableTurnKind": {
+ "enum": [
+ "review",
+ "compact"
+ ],
+ "type": "string"
+ },
"PatchApplyStatus": {
"enum": [
"inProgress",
diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts
index 1ff409a41a..20dc3c5191 100644
--- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts
+++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts
@@ -1,6 +1,7 @@
// 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 { NonSteerableTurnKind } from "./NonSteerableTurnKind";
/**
* This translation layer make sure that we expose codex error code in camel case.
@@ -8,4 +9,4 @@
* When an upstream HTTP status is available (for example, from the Responses API or a provider),
* it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
*/
-export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other";
+export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other";
diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts
index a531b78dcf..b75ee3930a 100644
--- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts
+++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
-export type HookEventName = "sessionStart" | "userPromptSubmit" | "stop";
+export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop";
diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts
new file mode 100644
index 0000000000..2624df2ba0
--- /dev/null
+++ b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.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 NonSteerableTurnKind = "review" | "compact";
diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts
index 818e0b05d4..ea37393536 100644
--- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts
+++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SkillInterface } from "./SkillInterface";
-export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, };
+export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, enabled: boolean, };
diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts
index 5a4bcf9bc0..273d593cfd 100644
--- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts
+++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts
@@ -1,5 +1,14 @@
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
-export type SkillsConfigWriteParams = { path: string, enabled: boolean, };
+export type SkillsConfigWriteParams = {
+/**
+ * Path-based selector.
+ */
+path?: AbsolutePathBuf | null,
+/**
+ * Name-based selector.
+ */
+name?: string | null, enabled: boolean, };
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 d9cc4758bc..c649aec06a 100644
--- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts
+++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts
@@ -194,6 +194,7 @@ export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
export type { NetworkRequirements } from "./NetworkRequirements";
+export type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
export type { OverriddenMetadata } from "./OverriddenMetadata";
export type { PatchApplyStatus } from "./PatchApplyStatus";
export type { PatchChangeKind } from "./PatchChangeKind";
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 11dfe29769..48fa56d687 100644
--- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs
+++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs
@@ -74,6 +74,8 @@ pub struct ThreadHistoryBuilder {
turns: Vec,
current_turn: Option,
next_item_index: i64,
+ current_rollout_index: usize,
+ next_rollout_index: usize,
}
impl Default for ThreadHistoryBuilder {
@@ -88,6 +90,8 @@ impl ThreadHistoryBuilder {
turns: Vec::new(),
current_turn: None,
next_item_index: 1,
+ current_rollout_index: 0,
+ next_rollout_index: 0,
}
}
@@ -111,6 +115,19 @@ impl ThreadHistoryBuilder {
self.current_turn.is_some()
}
+ pub fn active_turn_id_if_explicit(&self) -> Option {
+ self.current_turn
+ .as_ref()
+ .filter(|turn| turn.opened_explicitly)
+ .map(|turn| turn.id.clone())
+ }
+
+ pub fn active_turn_start_index(&self) -> Option {
+ self.current_turn
+ .as_ref()
+ .map(|turn| turn.rollout_start_index)
+ }
+
/// Shared reducer for persisted rollout replay and in-memory current-turn
/// tracking used by running thread resume/rejoin.
///
@@ -182,6 +199,8 @@ impl ThreadHistoryBuilder {
}
pub fn handle_rollout_item(&mut self, item: &RolloutItem) {
+ self.current_rollout_index = self.next_rollout_index;
+ self.next_rollout_index += 1;
match item {
RolloutItem::EventMsg(event) => self.handle_event(event),
RolloutItem::Compacted(payload) => self.handle_compacted(payload),
@@ -974,6 +993,7 @@ impl ThreadHistoryBuilder {
status: TurnStatus::Completed,
opened_explicitly: false,
saw_compaction: false,
+ rollout_start_index: self.current_rollout_index,
}
}
@@ -1137,6 +1157,8 @@ struct PendingTurn {
/// True when this turn includes a persisted `RolloutItem::Compacted`, which
/// should keep the turn from being dropped even without normal items.
saw_compaction: bool,
+ /// Index of the rollout item that opened this turn during replay.
+ rollout_start_index: usize,
}
impl PendingTurn {
diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs
index 57017833a6..3e30f81323 100644
--- a/codex-rs/app-server-protocol/src/protocol/v2.rs
+++ b/codex-rs/app-server-protocol/src/protocol/v2.rs
@@ -66,6 +66,7 @@ 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::NonSteerableTurnKind as CoreNonSteerableTurnKind;
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
@@ -128,6 +129,14 @@ macro_rules! v2_enum_from_core {
};
}
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export_to = "v2/")]
+pub enum NonSteerableTurnKind {
+ Review,
+ Compact,
+}
+
/// This translation layer make sure that we expose codex error code in camel case.
///
/// When an upstream HTTP status is available (for example, from the Responses API or a provider),
@@ -167,6 +176,13 @@ pub enum CodexErrorInfo {
#[ts(rename = "httpStatusCode")]
http_status_code: Option,
},
+ /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn
+ /// cannot accept same-turn steering, for example `/review` or manual `/compact`.
+ ActiveTurnNotSteerable {
+ #[serde(rename = "turnKind")]
+ #[ts(rename = "turnKind")]
+ turn_kind: NonSteerableTurnKind,
+ },
Other,
}
@@ -193,11 +209,25 @@ impl From for CodexErrorInfo {
CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => {
CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code }
}
+ CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => {
+ CodexErrorInfo::ActiveTurnNotSteerable {
+ turn_kind: turn_kind.into(),
+ }
+ }
CoreCodexErrorInfo::Other => CodexErrorInfo::Other,
}
}
}
+impl From for NonSteerableTurnKind {
+ fn from(value: CoreNonSteerableTurnKind) -> Self {
+ match value {
+ CoreNonSteerableTurnKind::Review => Self::Review,
+ CoreNonSteerableTurnKind::Compact => Self::Compact,
+ }
+ }
+}
+
#[derive(
Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi,
)]
@@ -347,7 +377,7 @@ v2_enum_from_core!(
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
- SessionStart, UserPromptSubmit, Stop
+ PreToolUse, SessionStart, UserPromptSubmit, Stop
}
);
@@ -3310,6 +3340,7 @@ pub struct SkillSummary {
pub short_description: Option,
pub interface: Option,
pub path: PathBuf,
+ pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -3348,7 +3379,12 @@ pub enum PluginSource {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsConfigWriteParams {
- pub path: PathBuf,
+ /// Path-based selector.
+ #[ts(optional = nullable)]
+ pub path: Option,
+ /// Name-based selector.
+ #[ts(optional = nullable)]
+ pub name: Option,
pub enabled: bool,
}
@@ -7832,6 +7868,22 @@ mod tests {
);
}
+ #[test]
+ fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() {
+ let value = CodexErrorInfo::ActiveTurnNotSteerable {
+ turn_kind: NonSteerableTurnKind::Review,
+ };
+
+ assert_eq!(
+ serde_json::to_value(value).unwrap(),
+ json!({
+ "activeTurnNotSteerable": {
+ "turnKind": "review"
+ }
+ })
+ );
+ }
+
#[test]
fn dynamic_tool_response_serializes_content_items() {
let value = serde_json::to_value(DynamicToolCallResponse {
diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md
index 3248a44424..a536e5c68b 100644
--- a/codex-rs/app-server/README.md
+++ b/codex-rs/app-server/README.md
@@ -125,7 +125,7 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
-- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread.
+- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
@@ -140,7 +140,7 @@ Example with notification opt-out:
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
-- `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input.
+- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); returns `{}` and streams `thread/realtime/*` notifications.
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
@@ -164,10 +164,10 @@ Example with notification opt-out:
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
-- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
+- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
-- `skills/config/write` — write user-level skill config by path.
+- `skills/config/write` — write user-level skill config by name or absolute path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
@@ -240,7 +240,7 @@ Example:
{ "id": 11, "result": { "thread": { "id": "thr_123", … } } }
```
-To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. Pass `ephemeral: true` when the fork should stay in-memory only:
+To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
```json
{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } }
@@ -575,8 +575,8 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi
### Example: Steer an active turn
-Use `turn/steer` to append additional user input to the currently active turn. This does not emit
-`turn/started` and does not accept turn context overrides.
+Use `turn/steer` to append additional user input to the currently active regular turn. This does
+not emit `turn/started` and does not accept turn context overrides.
```json
{ "method": "turn/steer", "id": 32, "params": {
@@ -587,7 +587,9 @@ Use `turn/steer` to append additional user input to the currently active turn. T
{ "id": 32, "result": { "turnId": "turn_456" } }
```
-`expectedTurnId` is required. If there is no active turn (or `expectedTurnId` does not match the active turn), the request fails with an `invalid request` error.
+`expectedTurnId` is required. If there is no active turn, `expectedTurnId` does not match the
+active turn, or the active turn kind does not accept same-turn steering (for example review or
+manual compaction), the request fails with an `invalid request` error.
### Example: Request a code review
@@ -918,6 +920,8 @@ There are additional item-specific events:
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
+- `ActiveTurnNotSteerable { turnKind }`: `turn/start` or `turn/steer` was submitted while the
+ current active turn was not steerable, for example `/review` or manual `/compact`
- `BadRequest`
- `Unauthorized`
- `SandboxError`
@@ -1141,14 +1145,29 @@ The server also emits `skills/changed` notifications when watched local skill fi
}
```
-To enable or disable a skill by path:
+To enable or disable a skill by absolute path:
```json
{
"method": "skills/config/write",
"id": 26,
"params": {
- "path": "/Users/me/.codex/skills/skill-creator/SKILL.md",
+ "path": "/Users/alice/.codex/skills/skill-creator/SKILL.md",
+ "name": null,
+ "enabled": false
+ }
+}
+```
+
+To enable or disable a skill by name:
+
+```json
+{
+ "method": "skills/config/write",
+ "id": 27,
+ "params": {
+ "path": null,
+ "name": "github:yeet",
"enabled": false
}
}
diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs
index 1b02e4bb6c..37ff0ca4f2 100644
--- a/codex-rs/app-server/src/codex_message_processor.rs
+++ b/codex-rs/app-server/src/codex_message_processor.rs
@@ -32,6 +32,7 @@ use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::CancelLoginAccountResponse;
use codex_app_server_protocol::CancelLoginAccountStatus;
use codex_app_server_protocol::ClientRequest;
+use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::CommandExecParams;
@@ -161,6 +162,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::Turn;
+use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
@@ -181,6 +183,7 @@ use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::CodexThread;
use codex_core::Cursor as RolloutCursor;
+use codex_core::ForkSnapshot;
use codex_core::NewThread;
use codex_core::RolloutRecorder;
use codex_core::SessionMeta;
@@ -4037,7 +4040,7 @@ impl CodexMessageProcessor {
} = match self
.thread_manager
.fork_thread(
- usize::MAX,
+ ForkSnapshot::Interrupted,
config,
rollout_path.clone(),
persist_extended_history,
@@ -5670,7 +5673,7 @@ impl CodexMessageProcessor {
interface: outcome.plugin.interface.map(plugin_interface_to_info),
},
description: outcome.plugin.description,
- skills: plugin_skills_to_info(&visible_skills),
+ skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths),
apps: app_summaries,
mcp_servers: outcome.plugin.mcp_server_names,
};
@@ -5685,8 +5688,30 @@ impl CodexMessageProcessor {
request_id: ConnectionRequestId,
params: SkillsConfigWriteParams,
) {
- let SkillsConfigWriteParams { path, enabled } = params;
- let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }];
+ let SkillsConfigWriteParams {
+ path,
+ name,
+ enabled,
+ } = params;
+ let edit = match (path, name) {
+ (Some(path), None) => ConfigEdit::SetSkillConfig {
+ path: path.into_path_buf(),
+ enabled,
+ },
+ (None, Some(name)) if !name.trim().is_empty() => {
+ ConfigEdit::SetSkillConfigByName { name, enabled }
+ }
+ _ => {
+ let error = JSONRPCErrorError {
+ code: INVALID_PARAMS_ERROR_CODE,
+ message: "skills/config/write requires exactly one of path or name".to_string(),
+ data: None,
+ };
+ self.outgoing.send_error(request_id, error).await;
+ return;
+ }
+ };
+ let edits = vec![edit];
let result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
@@ -5694,6 +5719,7 @@ impl CodexMessageProcessor {
match result {
Ok(()) => {
+ self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
self.outgoing
.send_response(
@@ -6131,24 +6157,57 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
- let (code, message) = match err {
+ let (code, message, data) = match err {
SteerInputError::NoActiveTurn(_) => (
INVALID_REQUEST_ERROR_CODE,
"no active turn to steer".to_string(),
+ None,
),
SteerInputError::ExpectedTurnMismatch { expected, actual } => (
INVALID_REQUEST_ERROR_CODE,
format!("expected active turn id `{expected}` but found `{actual}`"),
+ None,
),
+ SteerInputError::ActiveTurnNotSteerable { turn_kind } => {
+ let message = match turn_kind {
+ codex_protocol::protocol::NonSteerableTurnKind::Review => {
+ "cannot steer a review turn".to_string()
+ }
+ codex_protocol::protocol::NonSteerableTurnKind::Compact => {
+ "cannot steer a compact turn".to_string()
+ }
+ };
+ let error = TurnError {
+ message: message.clone(),
+ codex_error_info: Some(
+ AppServerCodexErrorInfo::ActiveTurnNotSteerable {
+ turn_kind: turn_kind.into(),
+ },
+ ),
+ additional_details: None,
+ };
+ let data = match serde_json::to_value(error) {
+ Ok(data) => Some(data),
+ Err(error) => {
+ tracing::error!(
+ ?error,
+ "failed to serialize active-turn-not-steerable turn error"
+ );
+ None
+ }
+ };
+ (INVALID_REQUEST_ERROR_CODE, message, data)
+ }
SteerInputError::EmptyInput => (
INVALID_REQUEST_ERROR_CODE,
"input must not be empty".to_string(),
+ None,
),
};
let error = JSONRPCErrorError {
code,
message,
- data: None,
+ data,
};
self.outgoing.send_error(request_id, error).await;
}
@@ -6450,7 +6509,7 @@ impl CodexMessageProcessor {
} = self
.thread_manager
.fork_thread(
- usize::MAX,
+ ForkSnapshot::Interrupted,
config,
rollout_path,
/*persist_extended_history*/ false,
@@ -7634,7 +7693,10 @@ fn skills_to_info(
.collect()
}
-fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec {
+fn plugin_skills_to_info(
+ skills: &[codex_core::skills::SkillMetadata],
+ disabled_skill_paths: &std::collections::HashSet,
+) -> Vec {
skills
.iter()
.map(|skill| SkillSummary {
@@ -7652,6 +7714,7 @@ fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec Result<()> {
"expected forked thread to include one turn"
);
let turn = &thread.turns[0];
- assert_eq!(turn.status, TurnStatus::Completed);
+ assert_eq!(turn.status, TurnStatus::Interrupted);
assert_eq!(turn.items.len(), 1, "expected user message item");
match &turn.items[0] {
ThreadItem::UserMessage { content, .. } => {
diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs
index d8dbaaa30d..e210783a35 100644
--- a/codex-rs/arg0/src/lib.rs
+++ b/codex-rs/arg0/src/lib.rs
@@ -309,14 +309,16 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result {
- format!("{path_element}{PATH_SEPARATOR}{existing_path}")
- }
- Err(_) => {
- format!("{path_element}")
+ let updated_path_env_var = match std::env::var_os("PATH") {
+ Some(existing_path) => {
+ let mut path_env_var =
+ std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len());
+ path_env_var.push(path);
+ path_env_var.push(PATH_SEPARATOR);
+ path_env_var.push(existing_path);
+ path_env_var
}
+ None => path.as_os_str().to_owned(),
};
unsafe {
diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs
index e37a85dc1d..fb0f62a342 100644
--- a/codex-rs/cloud-requirements/src/lib.rs
+++ b/codex-rs/cloud-requirements/src/lib.rs
@@ -883,6 +883,24 @@ mod tests {
account_id: Option<&str>,
access_token: &str,
refresh_token: &str,
+ ) -> serde_json::Value {
+ chatgpt_auth_json_with_last_refresh(
+ plan_type,
+ chatgpt_user_id,
+ account_id,
+ access_token,
+ refresh_token,
+ "2025-01-01T00:00:00Z",
+ )
+ }
+
+ fn chatgpt_auth_json_with_last_refresh(
+ plan_type: &str,
+ chatgpt_user_id: Option<&str>,
+ account_id: Option<&str>,
+ access_token: &str,
+ refresh_token: &str,
+ last_refresh: &str,
) -> serde_json::Value {
chatgpt_auth_json_with_mode(
plan_type,
@@ -890,6 +908,7 @@ mod tests {
account_id,
access_token,
refresh_token,
+ last_refresh,
None,
)
}
@@ -900,6 +919,7 @@ mod tests {
account_id: Option<&str>,
access_token: &str,
refresh_token: &str,
+ last_refresh: &str,
auth_mode: Option<&str>,
) -> serde_json::Value {
let header = json!({ "alg": "none", "typ": "JWT" });
@@ -925,7 +945,7 @@ mod tests {
"refresh_token": refresh_token,
"account_id": account_id,
},
- "last_refresh": "2025-01-01T00:00:00Z",
+ "last_refresh": last_refresh,
});
if let Some(auth_mode) = auth_mode {
auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string());
@@ -1262,24 +1282,43 @@ enabled = false
#[tokio::test]
async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() {
- let auth = managed_auth_context(
- "business",
- Some("user-12345"),
- Some("account-12345"),
- "stale-access-token",
- "test-refresh-token",
- );
+ let auth_home = tempdir().expect("tempdir");
write_auth_json(
- auth._home.path(),
- chatgpt_auth_json(
+ auth_home.path(),
+ chatgpt_auth_json_with_last_refresh(
+ "business",
+ Some("user-12345"),
+ Some("account-12345"),
+ "stale-access-token",
+ "test-refresh-token",
+ // Keep auth "fresh" so the first request hits unauthorized recovery
+ // instead of AuthManager::auth() proactively reloading from disk.
+ "3025-01-01T00:00:00Z",
+ ),
+ )
+ .expect("write initial auth");
+ let auth_manager = Arc::new(AuthManager::new(
+ auth_home.path().to_path_buf(),
+ false,
+ AuthCredentialsStoreMode::File,
+ ));
+
+ write_auth_json(
+ auth_home.path(),
+ chatgpt_auth_json_with_last_refresh(
"business",
Some("user-12345"),
Some("account-12345"),
"fresh-access-token",
"test-refresh-token",
+ "3025-01-01T00:00:00Z",
),
)
.expect("write refreshed auth");
+ let auth = ManagedAuthContext {
+ _home: auth_home,
+ manager: auth_manager,
+ };
let fetcher = Arc::new(TokenFetcher {
expected_token: "fresh-access-token".to_string(),
@@ -1314,24 +1353,41 @@ enabled = false
#[tokio::test]
async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() {
- let auth = managed_auth_context(
- "business",
- Some("user-12345"),
- Some("account-12345"),
- "stale-access-token",
- "test-refresh-token",
- );
+ let auth_home = tempdir().expect("tempdir");
write_auth_json(
- auth._home.path(),
- chatgpt_auth_json(
+ auth_home.path(),
+ chatgpt_auth_json_with_last_refresh(
+ "business",
+ Some("user-12345"),
+ Some("account-12345"),
+ "stale-access-token",
+ "test-refresh-token",
+ "3025-01-01T00:00:00Z",
+ ),
+ )
+ .expect("write initial auth");
+ let auth_manager = Arc::new(AuthManager::new(
+ auth_home.path().to_path_buf(),
+ false,
+ AuthCredentialsStoreMode::File,
+ ));
+
+ write_auth_json(
+ auth_home.path(),
+ chatgpt_auth_json_with_last_refresh(
"business",
Some("user-99999"),
Some("account-12345"),
"fresh-access-token",
"test-refresh-token",
+ "3025-01-01T00:00:00Z",
),
)
.expect("write refreshed auth");
+ let auth = ManagedAuthContext {
+ _home: auth_home,
+ manager: auth_manager,
+ };
let fetcher = Arc::new(TokenFetcher {
expected_token: "fresh-access-token".to_string(),
@@ -1432,6 +1488,7 @@ enabled = false
Some("account-12345"),
"test-access-token",
"test-refresh-token",
+ "2025-01-01T00:00:00Z",
Some("chatgptAuthTokens"),
),
)
diff --git a/codex-rs/code-mode/BUILD.bazel b/codex-rs/code-mode/BUILD.bazel
new file mode 100644
index 0000000000..bf39d9d5a5
--- /dev/null
+++ b/codex-rs/code-mode/BUILD.bazel
@@ -0,0 +1,6 @@
+load("//:defs.bzl", "codex_rust_crate")
+
+codex_rust_crate(
+ name = "code-mode",
+ crate_name = "codex_code_mode",
+)
diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml
new file mode 100644
index 0000000000..e821ca0e4d
--- /dev/null
+++ b/codex-rs/code-mode/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+edition.workspace = true
+license.workspace = true
+name = "codex-code-mode"
+version.workspace = true
+
+[lib]
+doctest = false
+name = "codex_code_mode"
+path = "src/lib.rs"
+
+[lints]
+workspace = true
+
+[dependencies]
+async-trait = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
+tokio-util = { workspace = true, features = ["rt"] }
+tracing = { workspace = true }
+v8 = { workspace = true }
+
+[dev-dependencies]
+pretty_assertions = { workspace = true }
diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs
new file mode 100644
index 0000000000..c875e2a1b1
--- /dev/null
+++ b/codex-rs/code-mode/src/description.rs
@@ -0,0 +1,555 @@
+use serde::Deserialize;
+use serde::Serialize;
+use serde_json::Value as JsonValue;
+
+use crate::PUBLIC_TOOL_NAME;
+
+const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
+const CODE_MODE_ONLY_PREFACE: &str =
+ "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly";
+const EXEC_DESCRIPTION_TEMPLATE: &str = r#"## exec
+- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console).
+- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
+- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
+- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running.
+- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens.
+- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
+- Tool methods take either string or object as parameter.
+- They return either a structured value or a string based on the description above.
+
+- Global helpers:
+- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
+- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
+- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL.
+- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
+- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
+- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
+- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries.
+- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#;
+const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`.
+- `cell_id` identifies the running `exec` cell to resume.
+- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout.
+- `max_tokens` limits how much new output this wait call returns.
+- `terminate: true` stops the running cell instead of waiting for more output.
+- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
+- If the cell is still running, `wait` may yield again with the same `cell_id`.
+- If the cell has already finished, `wait` returns the completed result and closes the cell."#;
+
+pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:";
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CodeModeToolKind {
+ Function,
+ Freeform,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ToolDefinition {
+ pub name: String,
+ pub description: String,
+ pub kind: CodeModeToolKind,
+ pub input_schema: Option,
+ pub output_schema: Option,
+}
+
+#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+struct CodeModeExecPragma {
+ #[serde(default)]
+ yield_time_ms: Option,
+ #[serde(default)]
+ max_output_tokens: Option,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct ParsedExecSource {
+ pub code: String,
+ pub yield_time_ms: Option,
+ pub max_output_tokens: Option,
+}
+
+pub fn parse_exec_source(input: &str) -> Result {
+ if input.trim().is_empty() {
+ return Err(
+ "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(),
+ );
+ }
+
+ let mut args = ParsedExecSource {
+ code: input.to_string(),
+ yield_time_ms: None,
+ max_output_tokens: None,
+ };
+
+ let mut lines = input.splitn(2, '\n');
+ let first_line = lines.next().unwrap_or_default();
+ let rest = lines.next().unwrap_or_default();
+ let trimmed = first_line.trim_start();
+ let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else {
+ return Ok(args);
+ };
+
+ if rest.trim().is_empty() {
+ return Err(
+ "exec pragma must be followed by JavaScript source on subsequent lines".to_string(),
+ );
+ }
+
+ let directive = pragma.trim();
+ if directive.is_empty() {
+ return Err(
+ "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
+ .to_string(),
+ );
+ }
+
+ let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| {
+ format!(
+ "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}"
+ )
+ })?;
+ let object = value.as_object().ok_or_else(|| {
+ "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
+ .to_string()
+ })?;
+ for key in object.keys() {
+ match key.as_str() {
+ "yield_time_ms" | "max_output_tokens" => {}
+ _ => {
+ return Err(format!(
+ "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`"
+ ));
+ }
+ }
+ }
+
+ let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| {
+ format!(
+ "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}"
+ )
+ })?;
+ if pragma
+ .yield_time_ms
+ .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER)
+ {
+ return Err(
+ "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(),
+ );
+ }
+ if pragma.max_output_tokens.is_some_and(|max_output_tokens| {
+ u64::try_from(max_output_tokens)
+ .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER)
+ .unwrap_or(true)
+ }) {
+ return Err(
+ "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(),
+ );
+ }
+
+ args.code = rest.to_string();
+ args.yield_time_ms = pragma.yield_time_ms;
+ args.max_output_tokens = pragma.max_output_tokens;
+ Ok(args)
+}
+
+pub fn is_code_mode_nested_tool(tool_name: &str) -> bool {
+ tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME
+}
+
+pub fn build_exec_tool_description(
+ enabled_tools: &[(String, String)],
+ code_mode_only: bool,
+) -> String {
+ if !code_mode_only {
+ return EXEC_DESCRIPTION_TEMPLATE.to_string();
+ }
+
+ let mut sections = vec![
+ CODE_MODE_ONLY_PREFACE.to_string(),
+ EXEC_DESCRIPTION_TEMPLATE.to_string(),
+ ];
+
+ if !enabled_tools.is_empty() {
+ let nested_tool_reference = enabled_tools
+ .iter()
+ .map(|(name, nested_description)| {
+ let global_name = normalize_code_mode_identifier(name);
+ format!(
+ "### `{global_name}` (`{name}`)\n{}",
+ nested_description.trim()
+ )
+ })
+ .collect::>()
+ .join("\n\n");
+ sections.push(nested_tool_reference);
+ }
+
+ sections.join("\n\n")
+}
+
+pub fn build_wait_tool_description() -> &'static str {
+ WAIT_DESCRIPTION_TEMPLATE
+}
+
+pub fn normalize_code_mode_identifier(tool_key: &str) -> String {
+ let mut identifier = String::new();
+
+ for (index, ch) in tool_key.chars().enumerate() {
+ let is_valid = if index == 0 {
+ ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
+ } else {
+ ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()
+ };
+
+ if is_valid {
+ identifier.push(ch);
+ } else {
+ identifier.push('_');
+ }
+ }
+
+ if identifier.is_empty() {
+ "_".to_string()
+ } else {
+ identifier
+ }
+}
+
+pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition {
+ if definition.name != PUBLIC_TOOL_NAME {
+ definition.description = append_code_mode_sample_for_definition(&definition);
+ }
+ definition
+}
+
+pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata {
+ EnabledToolMetadata {
+ tool_name: definition.name.clone(),
+ global_name: normalize_code_mode_identifier(&definition.name),
+ description: definition.description.clone(),
+ kind: definition.kind,
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+pub struct EnabledToolMetadata {
+ pub tool_name: String,
+ pub global_name: String,
+ pub description: String,
+ pub kind: CodeModeToolKind,
+}
+
+pub fn append_code_mode_sample(
+ description: &str,
+ tool_name: &str,
+ input_name: &str,
+ input_type: String,
+ output_type: String,
+) -> String {
+ let declaration = format!(
+ "declare const tools: {{ {} }};",
+ render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type)
+ );
+ format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```")
+}
+
+fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String {
+ let input_name = match definition.kind {
+ CodeModeToolKind::Function => "args",
+ CodeModeToolKind::Freeform => "input",
+ };
+ let input_type = match definition.kind {
+ CodeModeToolKind::Function => definition
+ .input_schema
+ .as_ref()
+ .map(render_json_schema_to_typescript)
+ .unwrap_or_else(|| "unknown".to_string()),
+ CodeModeToolKind::Freeform => "string".to_string(),
+ };
+ let output_type = definition
+ .output_schema
+ .as_ref()
+ .map(render_json_schema_to_typescript)
+ .unwrap_or_else(|| "unknown".to_string());
+ append_code_mode_sample(
+ &definition.description,
+ &definition.name,
+ input_name,
+ input_type,
+ output_type,
+ )
+}
+
+fn render_code_mode_tool_declaration(
+ tool_name: &str,
+ input_name: &str,
+ input_type: String,
+ output_type: String,
+) -> String {
+ let tool_name = normalize_code_mode_identifier(tool_name);
+ format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;")
+}
+
+pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
+ render_json_schema_to_typescript_inner(schema)
+}
+
+fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String {
+ match schema {
+ JsonValue::Bool(true) => "unknown".to_string(),
+ JsonValue::Bool(false) => "never".to_string(),
+ JsonValue::Object(map) => {
+ if let Some(value) = map.get("const") {
+ return render_json_schema_literal(value);
+ }
+
+ if let Some(values) = map.get("enum").and_then(JsonValue::as_array) {
+ let rendered = values
+ .iter()
+ .map(render_json_schema_literal)
+ .collect::>();
+ if !rendered.is_empty() {
+ return rendered.join(" | ");
+ }
+ }
+
+ for key in ["anyOf", "oneOf"] {
+ if let Some(variants) = map.get(key).and_then(JsonValue::as_array) {
+ let rendered = variants
+ .iter()
+ .map(render_json_schema_to_typescript_inner)
+ .collect::>();
+ if !rendered.is_empty() {
+ return rendered.join(" | ");
+ }
+ }
+ }
+
+ if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) {
+ let rendered = variants
+ .iter()
+ .map(render_json_schema_to_typescript_inner)
+ .collect::>();
+ if !rendered.is_empty() {
+ return rendered.join(" & ");
+ }
+ }
+
+ if let Some(schema_type) = map.get("type") {
+ if let Some(types) = schema_type.as_array() {
+ let rendered = types
+ .iter()
+ .filter_map(JsonValue::as_str)
+ .map(|schema_type| render_json_schema_type_keyword(map, schema_type))
+ .collect::>();
+ if !rendered.is_empty() {
+ return rendered.join(" | ");
+ }
+ }
+
+ if let Some(schema_type) = schema_type.as_str() {
+ return render_json_schema_type_keyword(map, schema_type);
+ }
+ }
+
+ if map.contains_key("properties")
+ || map.contains_key("additionalProperties")
+ || map.contains_key("required")
+ {
+ return render_json_schema_object(map);
+ }
+
+ if map.contains_key("items") || map.contains_key("prefixItems") {
+ return render_json_schema_array(map);
+ }
+
+ "unknown".to_string()
+ }
+ _ => "unknown".to_string(),
+ }
+}
+
+fn render_json_schema_type_keyword(
+ map: &serde_json::Map,
+ schema_type: &str,
+) -> String {
+ match schema_type {
+ "string" => "string".to_string(),
+ "number" | "integer" => "number".to_string(),
+ "boolean" => "boolean".to_string(),
+ "null" => "null".to_string(),
+ "array" => render_json_schema_array(map),
+ "object" => render_json_schema_object(map),
+ _ => "unknown".to_string(),
+ }
+}
+
+fn render_json_schema_array(map: &serde_json::Map) -> String {
+ if let Some(items) = map.get("items") {
+ let item_type = render_json_schema_to_typescript_inner(items);
+ return format!("Array<{item_type}>");
+ }
+
+ if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) {
+ let item_types = items
+ .iter()
+ .map(render_json_schema_to_typescript_inner)
+ .collect::>();
+ if !item_types.is_empty() {
+ return format!("[{}]", item_types.join(", "));
+ }
+ }
+
+ "unknown[]".to_string()
+}
+
+fn render_json_schema_object(map: &serde_json::Map) -> String {
+ let required = map
+ .get("required")
+ .and_then(JsonValue::as_array)
+ .map(|items| {
+ items
+ .iter()
+ .filter_map(JsonValue::as_str)
+ .collect::>()
+ })
+ .unwrap_or_default();
+ let properties = map
+ .get("properties")
+ .and_then(JsonValue::as_object)
+ .cloned()
+ .unwrap_or_default();
+
+ let mut sorted_properties = properties.iter().collect::>();
+ sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
+ let mut lines = sorted_properties
+ .into_iter()
+ .map(|(name, value)| {
+ let optional = if required.iter().any(|required_name| required_name == name) {
+ ""
+ } else {
+ "?"
+ };
+ let property_name = render_json_schema_property_name(name);
+ let property_type = render_json_schema_to_typescript_inner(value);
+ format!("{property_name}{optional}: {property_type};")
+ })
+ .collect::>();
+
+ if let Some(additional_properties) = map.get("additionalProperties") {
+ let property_type = match additional_properties {
+ JsonValue::Bool(true) => Some("unknown".to_string()),
+ JsonValue::Bool(false) => None,
+ value => Some(render_json_schema_to_typescript_inner(value)),
+ };
+
+ if let Some(property_type) = property_type {
+ lines.push(format!("[key: string]: {property_type};"));
+ }
+ } else if properties.is_empty() {
+ lines.push("[key: string]: unknown;".to_string());
+ }
+
+ if lines.is_empty() {
+ return "{}".to_string();
+ }
+
+ format!("{{ {} }}", lines.join(" "))
+}
+
+fn render_json_schema_property_name(name: &str) -> String {
+ if normalize_code_mode_identifier(name) == name {
+ name.to_string()
+ } else {
+ serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\"")))
+ }
+}
+
+fn render_json_schema_literal(value: &JsonValue) -> String {
+ serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::CodeModeToolKind;
+ use super::ParsedExecSource;
+ use super::ToolDefinition;
+ use super::augment_tool_definition;
+ use super::build_exec_tool_description;
+ use super::normalize_code_mode_identifier;
+ use super::parse_exec_source;
+ use pretty_assertions::assert_eq;
+ use serde_json::json;
+
+ #[test]
+ fn parse_exec_source_without_pragma() {
+ assert_eq!(
+ parse_exec_source("text('hi')").unwrap(),
+ ParsedExecSource {
+ code: "text('hi')".to_string(),
+ yield_time_ms: None,
+ max_output_tokens: None,
+ }
+ );
+ }
+
+ #[test]
+ fn parse_exec_source_with_pragma() {
+ assert_eq!(
+ parse_exec_source("// @exec: {\"yield_time_ms\": 10}\ntext('hi')").unwrap(),
+ ParsedExecSource {
+ code: "text('hi')".to_string(),
+ yield_time_ms: Some(10),
+ max_output_tokens: None,
+ }
+ );
+ }
+
+ #[test]
+ fn normalize_identifier_rewrites_invalid_characters() {
+ assert_eq!(
+ "mcp__ologs__get_profile",
+ normalize_code_mode_identifier("mcp__ologs__get_profile")
+ );
+ assert_eq!(
+ "hidden_dynamic_tool",
+ normalize_code_mode_identifier("hidden-dynamic-tool")
+ );
+ }
+
+ #[test]
+ fn augment_tool_definition_appends_typed_declaration() {
+ let definition = ToolDefinition {
+ name: "hidden_dynamic_tool".to_string(),
+ description: "Test tool".to_string(),
+ kind: CodeModeToolKind::Function,
+ input_schema: Some(json!({
+ "type": "object",
+ "properties": { "city": { "type": "string" } },
+ "required": ["city"],
+ "additionalProperties": false
+ })),
+ output_schema: Some(json!({
+ "type": "object",
+ "properties": { "ok": { "type": "boolean" } },
+ "required": ["ok"]
+ })),
+ };
+
+ let description = augment_tool_definition(definition).description;
+ assert!(description.contains("declare const tools"));
+ assert!(
+ description.contains(
+ "hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;"
+ )
+ );
+ }
+
+ #[test]
+ fn code_mode_only_description_includes_nested_tools() {
+ let description =
+ build_exec_tool_description(&[("foo".to_string(), "bar".to_string())], true);
+ assert!(description.contains("### `foo` (`foo`)"));
+ }
+}
diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs
new file mode 100644
index 0000000000..841e568be3
--- /dev/null
+++ b/codex-rs/code-mode/src/lib.rs
@@ -0,0 +1,30 @@
+mod description;
+mod response;
+mod runtime;
+mod service;
+
+pub use description::CODE_MODE_PRAGMA_PREFIX;
+pub use description::CodeModeToolKind;
+pub use description::ToolDefinition;
+pub use description::append_code_mode_sample;
+pub use description::augment_tool_definition;
+pub use description::build_exec_tool_description;
+pub use description::build_wait_tool_description;
+pub use description::is_code_mode_nested_tool;
+pub use description::normalize_code_mode_identifier;
+pub use description::parse_exec_source;
+pub use description::render_json_schema_to_typescript;
+pub use response::FunctionCallOutputContentItem;
+pub use response::ImageDetail;
+pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS;
+pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL;
+pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS;
+pub use runtime::ExecuteRequest;
+pub use runtime::RuntimeResponse;
+pub use runtime::WaitRequest;
+pub use service::CodeModeService;
+pub use service::CodeModeTurnHost;
+pub use service::CodeModeTurnWorker;
+
+pub const PUBLIC_TOOL_NAME: &str = "exec";
+pub const WAIT_TOOL_NAME: &str = "wait";
diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs
new file mode 100644
index 0000000000..43579fac85
--- /dev/null
+++ b/codex-rs/code-mode/src/response.rs
@@ -0,0 +1,24 @@
+use serde::Deserialize;
+use serde::Serialize;
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum ImageDetail {
+ Auto,
+ Low,
+ High,
+ Original,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum FunctionCallOutputContentItem {
+ InputText {
+ text: String,
+ },
+ InputImage {
+ image_url: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ detail: Option,
+ },
+}
diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs
new file mode 100644
index 0000000000..b77ae82d68
--- /dev/null
+++ b/codex-rs/code-mode/src/runtime/callbacks.rs
@@ -0,0 +1,209 @@
+use crate::response::FunctionCallOutputContentItem;
+
+use super::EXIT_SENTINEL;
+use super::RuntimeEvent;
+use super::RuntimeState;
+use super::value::json_to_v8;
+use super::value::normalize_output_image;
+use super::value::serialize_output_text;
+use super::value::throw_type_error;
+use super::value::v8_value_to_json;
+
+pub(super) fn tool_callback(
+ scope: &mut v8::PinScope<'_, '_>,
+ args: v8::FunctionCallbackArguments,
+ mut retval: v8::ReturnValue,
+) {
+ let tool_name = args.data().to_rust_string_lossy(scope);
+ let input = if args.length() == 0 {
+ Ok(None)
+ } else {
+ v8_value_to_json(scope, args.get(0))
+ };
+ let input = match input {
+ Ok(input) => input,
+ Err(error_text) => {
+ throw_type_error(scope, &error_text);
+ return;
+ }
+ };
+
+ let Some(resolver) = v8::PromiseResolver::new(scope) else {
+ throw_type_error(scope, "failed to create tool promise");
+ return;
+ };
+ let promise = resolver.get_promise(scope);
+
+ let resolver = v8::Global::new(scope, resolver);
+ let Some(state) = scope.get_slot_mut::() else {
+ throw_type_error(scope, "runtime state unavailable");
+ return;
+ };
+ let id = format!("tool-{}", state.next_tool_call_id);
+ state.next_tool_call_id = state.next_tool_call_id.saturating_add(1);
+ let event_tx = state.event_tx.clone();
+ state.pending_tool_calls.insert(id.clone(), resolver);
+ let _ = event_tx.send(RuntimeEvent::ToolCall {
+ id,
+ name: tool_name,
+ input,
+ });
+ retval.set(promise.into());
+}
+
+pub(super) fn text_callback(
+ scope: &mut v8::PinScope<'_, '_>,
+ args: v8::FunctionCallbackArguments,
+ mut retval: v8::ReturnValue,
+) {
+ let value = if args.length() == 0 {
+ v8::undefined(scope).into()
+ } else {
+ args.get(0)
+ };
+ let text = match serialize_output_text(scope, value) {
+ Ok(text) => text,
+ Err(error_text) => {
+ throw_type_error(scope, &error_text);
+ return;
+ }
+ };
+ if let Some(state) = scope.get_slot::() {
+ let _ = state.event_tx.send(RuntimeEvent::ContentItem(
+ FunctionCallOutputContentItem::InputText { text },
+ ));
+ }
+ retval.set(v8::undefined(scope).into());
+}
+
+pub(super) fn image_callback(
+ scope: &mut v8::PinScope<'_, '_>,
+ args: v8::FunctionCallbackArguments,
+ mut retval: v8::ReturnValue,
+) {
+ let value = if args.length() == 0 {
+ v8::undefined(scope).into()
+ } else {
+ args.get(0)
+ };
+ let image_item = match normalize_output_image(scope, value) {
+ Ok(image_item) => image_item,
+ Err(()) => return,
+ };
+ if let Some(state) = scope.get_slot::() {
+ let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
+ }
+ retval.set(v8::undefined(scope).into());
+}
+
+pub(super) fn store_callback(
+ scope: &mut v8::PinScope<'_, '_>,
+ args: v8::FunctionCallbackArguments,
+ _retval: v8::ReturnValue,
+) {
+ let key = match args.get(0).to_string(scope) {
+ Some(key) => key.to_rust_string_lossy(scope),
+ None => {
+ throw_type_error(scope, "store key must be a string");
+ return;
+ }
+ };
+ let value = args.get(1);
+ let serialized = match v8_value_to_json(scope, value) {
+ Ok(Some(value)) => value,
+ Ok(None) => {
+ throw_type_error(
+ scope,
+ &format!("Unable to store {key:?}. Only plain serializable objects can be stored."),
+ );
+ return;
+ }
+ Err(error_text) => {
+ throw_type_error(scope, &error_text);
+ return;
+ }
+ };
+ if let Some(state) = scope.get_slot_mut::() {
+ state.stored_values.insert(key, serialized);
+ }
+}
+
+pub(super) fn load_callback(
+ scope: &mut v8::PinScope<'_, '_>,
+ args: v8::FunctionCallbackArguments,
+ mut retval: v8::ReturnValue,
+) {
+ let key = match args.get(0).to_string(scope) {
+ Some(key) => key.to_rust_string_lossy(scope),
+ None => {
+ throw_type_error(scope, "load key must be a string");
+ return;
+ }
+ };
+ let value = scope
+ .get_slot::()
+ .and_then(|state| state.stored_values.get(&key))
+ .cloned();
+ let Some(value) = value else {
+ retval.set(v8::undefined(scope).into());
+ return;
+ };
+ let Some(value) = json_to_v8(scope, &value) else {
+ throw_type_error(scope, "failed to load stored value");
+ return;
+ };
+ retval.set(value);
+}
+
+pub(super) fn notify_callback(
+ scope: &mut v8::PinScope<'_, '_>,
+ args: v8::FunctionCallbackArguments,
+ mut retval: v8::ReturnValue,
+) {
+ let value = if args.length() == 0 {
+ v8::undefined(scope).into()
+ } else {
+ args.get(0)
+ };
+ let text = match serialize_output_text(scope, value) {
+ Ok(text) => text,
+ Err(error_text) => {
+ throw_type_error(scope, &error_text);
+ return;
+ }
+ };
+ if text.trim().is_empty() {
+ throw_type_error(scope, "notify expects non-empty text");
+ return;
+ }
+ if let Some(state) = scope.get_slot::() {
+ let _ = state.event_tx.send(RuntimeEvent::Notify {
+ call_id: state.tool_call_id.clone(),
+ text,
+ });
+ }
+ retval.set(v8::undefined(scope).into());
+}
+
+pub(super) fn yield_control_callback(
+ scope: &mut v8::PinScope<'_, '_>,
+ _args: v8::FunctionCallbackArguments,
+ _retval: v8::ReturnValue,
+) {
+ if let Some(state) = scope.get_slot::() {
+ let _ = state.event_tx.send(RuntimeEvent::YieldRequested);
+ }
+}
+
+pub(super) fn exit_callback(
+ scope: &mut v8::PinScope<'_, '_>,
+ _args: v8::FunctionCallbackArguments,
+ _retval: v8::ReturnValue,
+) {
+ if let Some(state) = scope.get_slot_mut::() {
+ state.exit_requested = true;
+ }
+ if let Some(error) = v8::String::new(scope, EXIT_SENTINEL) {
+ scope.throw_exception(error.into());
+ }
+}
diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs
new file mode 100644
index 0000000000..371479497b
--- /dev/null
+++ b/codex-rs/code-mode/src/runtime/globals.rs
@@ -0,0 +1,138 @@
+use super::RuntimeState;
+use super::callbacks::exit_callback;
+use super::callbacks::image_callback;
+use super::callbacks::load_callback;
+use super::callbacks::notify_callback;
+use super::callbacks::store_callback;
+use super::callbacks::text_callback;
+use super::callbacks::tool_callback;
+use super::callbacks::yield_control_callback;
+
+pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> {
+ let global = scope.get_current_context().global(scope);
+ let console = v8::String::new(scope, "console")
+ .ok_or_else(|| "failed to allocate global `console`".to_string())?;
+ if global.delete(scope, console.into()) != Some(true) {
+ return Err("failed to remove global `console`".to_string());
+ }
+
+ let tools = build_tools_object(scope)?;
+ let all_tools = build_all_tools_value(scope)?;
+ let text = helper_function(scope, "text", text_callback)?;
+ let image = helper_function(scope, "image", image_callback)?;
+ let store = helper_function(scope, "store", store_callback)?;
+ let load = helper_function(scope, "load", load_callback)?;
+ let notify = helper_function(scope, "notify", notify_callback)?;
+ let yield_control = helper_function(scope, "yield_control", yield_control_callback)?;
+ let exit = helper_function(scope, "exit", exit_callback)?;
+
+ set_global(scope, global, "tools", tools.into())?;
+ set_global(scope, global, "ALL_TOOLS", all_tools)?;
+ set_global(scope, global, "text", text.into())?;
+ set_global(scope, global, "image", image.into())?;
+ set_global(scope, global, "store", store.into())?;
+ set_global(scope, global, "load", load.into())?;
+ set_global(scope, global, "notify", notify.into())?;
+ set_global(scope, global, "yield_control", yield_control.into())?;
+ set_global(scope, global, "exit", exit.into())?;
+ Ok(())
+}
+
+fn build_tools_object<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+) -> Result, String> {
+ let tools = v8::Object::new(scope);
+ let enabled_tools = scope
+ .get_slot::()
+ .map(|state| state.enabled_tools.clone())
+ .unwrap_or_default();
+
+ for tool in enabled_tools {
+ let name = v8::String::new(scope, &tool.global_name)
+ .ok_or_else(|| "failed to allocate tool name".to_string())?;
+ let function = tool_function(scope, &tool.tool_name)?;
+ tools.set(scope, name.into(), function.into());
+ }
+ Ok(tools)
+}
+
+fn build_all_tools_value<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+) -> Result, String> {
+ let enabled_tools = scope
+ .get_slot::()
+ .map(|state| state.enabled_tools.clone())
+ .unwrap_or_default();
+ let array = v8::Array::new(scope, enabled_tools.len() as i32);
+ let name_key = v8::String::new(scope, "name")
+ .ok_or_else(|| "failed to allocate ALL_TOOLS name key".to_string())?;
+ let description_key = v8::String::new(scope, "description")
+ .ok_or_else(|| "failed to allocate ALL_TOOLS description key".to_string())?;
+
+ for (index, tool) in enabled_tools.iter().enumerate() {
+ let item = v8::Object::new(scope);
+ let name = v8::String::new(scope, &tool.global_name)
+ .ok_or_else(|| "failed to allocate ALL_TOOLS name".to_string())?;
+ let description = v8::String::new(scope, &tool.description)
+ .ok_or_else(|| "failed to allocate ALL_TOOLS description".to_string())?;
+
+ if item.set(scope, name_key.into(), name.into()) != Some(true) {
+ return Err("failed to set ALL_TOOLS name".to_string());
+ }
+ if item.set(scope, description_key.into(), description.into()) != Some(true) {
+ return Err("failed to set ALL_TOOLS description".to_string());
+ }
+ if array.set_index(scope, index as u32, item.into()) != Some(true) {
+ return Err("failed to append ALL_TOOLS metadata".to_string());
+ }
+ }
+
+ Ok(array.into())
+}
+
+fn helper_function<'s, F>(
+ scope: &mut v8::PinScope<'s, '_>,
+ name: &str,
+ callback: F,
+) -> Result, String>
+where
+ F: v8::MapFnTo,
+{
+ let name =
+ v8::String::new(scope, name).ok_or_else(|| "failed to allocate helper name".to_string())?;
+ let template = v8::FunctionTemplate::builder(callback)
+ .data(name.into())
+ .build(scope);
+ template
+ .get_function(scope)
+ .ok_or_else(|| "failed to create helper function".to_string())
+}
+
+fn tool_function<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+ tool_name: &str,
+) -> Result, String> {
+ let data = v8::String::new(scope, tool_name)
+ .ok_or_else(|| "failed to allocate tool callback data".to_string())?;
+ let template = v8::FunctionTemplate::builder(tool_callback)
+ .data(data.into())
+ .build(scope);
+ template
+ .get_function(scope)
+ .ok_or_else(|| "failed to create tool function".to_string())
+}
+
+fn set_global<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+ global: v8::Local<'s, v8::Object>,
+ name: &str,
+ value: v8::Local<'s, v8::Value>,
+) -> Result<(), String> {
+ let key = v8::String::new(scope, name)
+ .ok_or_else(|| format!("failed to allocate global `{name}`"))?;
+ if global.set(scope, key.into(), value) == Some(true) {
+ Ok(())
+ } else {
+ Err(format!("failed to set global `{name}`"))
+ }
+}
diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs
new file mode 100644
index 0000000000..df90eda673
--- /dev/null
+++ b/codex-rs/code-mode/src/runtime/mod.rs
@@ -0,0 +1,349 @@
+mod callbacks;
+mod globals;
+mod module_loader;
+mod value;
+
+use std::collections::HashMap;
+use std::sync::OnceLock;
+use std::sync::mpsc as std_mpsc;
+use std::thread;
+
+use serde_json::Value as JsonValue;
+use tokio::sync::mpsc;
+
+use crate::description::EnabledToolMetadata;
+use crate::description::ToolDefinition;
+use crate::description::enabled_tool_metadata;
+use crate::response::FunctionCallOutputContentItem;
+
+pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000;
+pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000;
+pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL: usize = 10_000;
+const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
+
+#[derive(Clone, Debug)]
+pub struct ExecuteRequest {
+ pub tool_call_id: String,
+ pub enabled_tools: Vec,
+ pub source: String,
+ pub stored_values: HashMap,
+ pub yield_time_ms: Option,
+ pub max_output_tokens: Option,
+}
+
+#[derive(Clone, Debug)]
+pub struct WaitRequest {
+ pub cell_id: String,
+ pub yield_time_ms: u64,
+ pub terminate: bool,
+}
+
+#[derive(Debug, PartialEq)]
+pub enum RuntimeResponse {
+ Yielded {
+ cell_id: String,
+ content_items: Vec,
+ },
+ Terminated {
+ cell_id: String,
+ content_items: Vec,
+ },
+ Result {
+ cell_id: String,
+ content_items: Vec,
+ stored_values: HashMap,
+ error_text: Option,
+ },
+}
+
+#[derive(Debug)]
+pub(crate) enum TurnMessage {
+ ToolCall {
+ cell_id: String,
+ id: String,
+ name: String,
+ input: Option,
+ },
+ Notify {
+ cell_id: String,
+ call_id: String,
+ text: String,
+ },
+}
+
+#[derive(Debug)]
+pub(crate) enum RuntimeCommand {
+ ToolResponse { id: String, result: JsonValue },
+ ToolError { id: String, error_text: String },
+ Terminate,
+}
+
+#[derive(Debug)]
+pub(crate) enum RuntimeEvent {
+ Started,
+ ContentItem(FunctionCallOutputContentItem),
+ YieldRequested,
+ ToolCall {
+ id: String,
+ name: String,
+ input: Option,
+ },
+ Notify {
+ call_id: String,
+ text: String,
+ },
+ Result {
+ stored_values: HashMap,
+ error_text: Option,
+ },
+}
+
+pub(crate) fn spawn_runtime(
+ request: ExecuteRequest,
+ event_tx: mpsc::UnboundedSender,
+) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> {
+ let (command_tx, command_rx) = std_mpsc::channel();
+ let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1);
+ let enabled_tools = request
+ .enabled_tools
+ .iter()
+ .map(enabled_tool_metadata)
+ .collect::>();
+ let config = RuntimeConfig {
+ tool_call_id: request.tool_call_id,
+ enabled_tools,
+ source: request.source,
+ stored_values: request.stored_values,
+ };
+
+ thread::spawn(move || {
+ run_runtime(config, event_tx, command_rx, isolate_handle_tx);
+ });
+
+ let isolate_handle = isolate_handle_rx
+ .recv()
+ .map_err(|_| "failed to initialize code mode runtime".to_string())?;
+ Ok((command_tx, isolate_handle))
+}
+
+#[derive(Clone)]
+struct RuntimeConfig {
+ tool_call_id: String,
+ enabled_tools: Vec,
+ source: String,
+ stored_values: HashMap,
+}
+
+pub(super) struct RuntimeState {
+ event_tx: mpsc::UnboundedSender,
+ pending_tool_calls: HashMap>,
+ stored_values: HashMap,
+ enabled_tools: Vec,
+ next_tool_call_id: u64,
+ tool_call_id: String,
+ exit_requested: bool,
+}
+
+pub(super) enum CompletionState {
+ Pending,
+ Completed {
+ stored_values: HashMap,
+ error_text: Option,
+ },
+}
+
+fn initialize_v8() {
+ static PLATFORM: OnceLock> = OnceLock::new();
+
+ let _ = PLATFORM.get_or_init(|| {
+ let platform = v8::new_default_platform(0, false).make_shared();
+ v8::V8::initialize_platform(platform.clone());
+ v8::V8::initialize();
+ platform
+ });
+}
+
+fn run_runtime(
+ config: RuntimeConfig,
+ event_tx: mpsc::UnboundedSender,
+ command_rx: std_mpsc::Receiver,
+ isolate_handle_tx: std_mpsc::SyncSender,
+) {
+ initialize_v8();
+
+ let isolate = &mut v8::Isolate::new(v8::CreateParams::default());
+ let isolate_handle = isolate.thread_safe_handle();
+ if isolate_handle_tx.send(isolate_handle).is_err() {
+ return;
+ }
+ isolate.set_host_import_module_dynamically_callback(module_loader::dynamic_import_callback);
+
+ v8::scope!(let scope, isolate);
+ let context = v8::Context::new(scope, Default::default());
+ let scope = &mut v8::ContextScope::new(scope, context);
+
+ scope.set_slot(RuntimeState {
+ event_tx: event_tx.clone(),
+ pending_tool_calls: HashMap::new(),
+ stored_values: config.stored_values,
+ enabled_tools: config.enabled_tools,
+ next_tool_call_id: 1,
+ tool_call_id: config.tool_call_id,
+ exit_requested: false,
+ });
+
+ if let Err(error_text) = globals::install_globals(scope) {
+ send_result(&event_tx, HashMap::new(), Some(error_text));
+ return;
+ }
+
+ let _ = event_tx.send(RuntimeEvent::Started);
+
+ let pending_promise = match module_loader::evaluate_main_module(scope, &config.source) {
+ Ok(pending_promise) => pending_promise,
+ Err(error_text) => {
+ capture_scope_send_error(scope, &event_tx, Some(error_text));
+ return;
+ }
+ };
+
+ match module_loader::completion_state(scope, pending_promise.as_ref()) {
+ CompletionState::Completed {
+ stored_values,
+ error_text,
+ } => {
+ send_result(&event_tx, stored_values, error_text);
+ return;
+ }
+ CompletionState::Pending => {}
+ }
+
+ let mut pending_promise = pending_promise;
+ loop {
+ let Ok(command) = command_rx.recv() else {
+ break;
+ };
+ match command {
+ RuntimeCommand::Terminate => break,
+ RuntimeCommand::ToolResponse { id, result } => {
+ if let Err(error_text) =
+ module_loader::resolve_tool_response(scope, &id, Ok(result))
+ {
+ capture_scope_send_error(scope, &event_tx, Some(error_text));
+ return;
+ }
+ }
+ RuntimeCommand::ToolError { id, error_text } => {
+ if let Err(runtime_error) =
+ module_loader::resolve_tool_response(scope, &id, Err(error_text))
+ {
+ capture_scope_send_error(scope, &event_tx, Some(runtime_error));
+ return;
+ }
+ }
+ }
+
+ scope.perform_microtask_checkpoint();
+ match module_loader::completion_state(scope, pending_promise.as_ref()) {
+ CompletionState::Completed {
+ stored_values,
+ error_text,
+ } => {
+ send_result(&event_tx, stored_values, error_text);
+ return;
+ }
+ CompletionState::Pending => {}
+ }
+
+ if let Some(promise) = pending_promise.as_ref() {
+ let promise = v8::Local::new(scope, promise);
+ if promise.state() != v8::PromiseState::Pending {
+ pending_promise = None;
+ }
+ }
+ }
+}
+
+fn capture_scope_send_error(
+ scope: &mut v8::PinScope<'_, '_>,
+ event_tx: &mpsc::UnboundedSender,
+ error_text: Option,
+) {
+ let stored_values = scope
+ .get_slot::()
+ .map(|state| state.stored_values.clone())
+ .unwrap_or_default();
+
+ send_result(event_tx, stored_values, error_text);
+}
+
+fn send_result(
+ event_tx: &mpsc::UnboundedSender,
+ stored_values: HashMap,
+ error_text: Option,
+) {
+ let _ = event_tx.send(RuntimeEvent::Result {
+ stored_values,
+ error_text,
+ });
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashMap;
+ use std::time::Duration;
+
+ use pretty_assertions::assert_eq;
+ use tokio::sync::mpsc;
+
+ use super::ExecuteRequest;
+ use super::RuntimeEvent;
+ use super::spawn_runtime;
+
+ fn execute_request(source: &str) -> ExecuteRequest {
+ ExecuteRequest {
+ tool_call_id: "call_1".to_string(),
+ enabled_tools: Vec::new(),
+ source: source.to_string(),
+ stored_values: HashMap::new(),
+ yield_time_ms: Some(1),
+ max_output_tokens: None,
+ }
+ }
+
+ #[tokio::test]
+ async fn terminate_execution_stops_cpu_bound_module() {
+ let (event_tx, mut event_rx) = mpsc::unbounded_channel();
+ let (_runtime_tx, runtime_terminate_handle) =
+ spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap();
+
+ let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
+ .await
+ .unwrap()
+ .unwrap();
+ assert!(matches!(started_event, RuntimeEvent::Started));
+
+ assert!(runtime_terminate_handle.terminate_execution());
+
+ let result_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
+ .await
+ .unwrap()
+ .unwrap();
+ let RuntimeEvent::Result {
+ stored_values,
+ error_text,
+ } = result_event
+ else {
+ panic!("expected runtime result after termination");
+ };
+ assert_eq!(stored_values, HashMap::new());
+ assert!(error_text.is_some());
+
+ assert!(
+ tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
+ .await
+ .unwrap()
+ .is_none()
+ );
+ }
+}
diff --git a/codex-rs/code-mode/src/runtime/module_loader.rs b/codex-rs/code-mode/src/runtime/module_loader.rs
new file mode 100644
index 0000000000..83ce3d347a
--- /dev/null
+++ b/codex-rs/code-mode/src/runtime/module_loader.rs
@@ -0,0 +1,235 @@
+use serde_json::Value as JsonValue;
+
+use super::CompletionState;
+use super::EXIT_SENTINEL;
+use super::RuntimeState;
+use super::value::json_to_v8;
+use super::value::value_to_error_text;
+
+pub(super) fn evaluate_main_module(
+ scope: &mut v8::PinScope<'_, '_>,
+ source_text: &str,
+) -> Result