Compare commits

...

341 Commits

Author SHA1 Message Date
Iliyan Malchev
031288c855 Fix Android auth provisioning chmod failure
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 18:30:00 -07:00
Iliyan Malchev
f2088975f5 Silence Android build warnings
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 18:23:59 -07:00
Iliyan Malchev
9c80abeeec Stabilize tui app-server embedded thread-start test
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 14:04:41 -07:00
Iliyan Malchev
d0f1dd22a0 Repair Android build and test fallout after rebase
Fix the Android rebase breakages across core, login, rmcp-client, cli, and app-server paths, harden test config loading so host managed config cannot leak into approvals or TUI harnesses, and preserve the Android-specific code-mode stubs and response/send handling needed for the build to stay green.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 12:34:49 -07:00
Iliyan Malchev
ff61d54770 Require framework HTTP for Android Agent runtime
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:13 -07:00
Iliyan Malchev
702b3cd9eb Show full Android session diagnostics
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:13 -07:00
Iliyan Malchev
9281c550fa Improve Android session detail panels
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:13 -07:00
Iliyan Malchev
bba222cf93 Set Android new-session default model and effort
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:13 -07:00
Iliyan Malchev
96a1813297 Set Android new-session UI defaults
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:13 -07:00
Iliyan Malchev
ab8e305980 Support base64 debug-start prompts on Android
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:13 -07:00
Iliyan Malchev
9ecc183d04 Handle Android planner runtime teardown cleanly
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
c8eebbda3e Key Android planner runtimes by session
Use the parent session id as the planner runtime identity and CODEX_HOME key, and reject overlapping planner runtimes for the same parent session. Update the Android design note to document the 1:1 parent-session-to-planner-runtime invariant.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
ac4bd61bac Support concurrent Android planner runtimes
Split Agent-side planning off the singleton hosted runtime so top-level Agent sessions can plan concurrently. Use a shared foreground-service lease while planner runtimes are active and update the Android design note to describe the new architecture.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
638b328ea2 Stabilize Android Agent session startup
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
44c2e67573 Update Android Agent launcher icon
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
1a95151fd4 Improve Android Agent session startup handoff
Create Agent-anchored parent sessions immediately, continue planning in the background, and route launcher re-entry into the active session detail while preserving sane parent rollup state.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
bf11074248 Use Android streaming HTTP exchange bridge
Adopt the new framework-owned streaming HTTP exchange APIs for Genie /responses traffic and drop the removed buffered bridge usage.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
9b0d7a5271 Adopt Android detached runtime recovery APIs
Use the updated framework detached-runtime and typed detached-control APIs via reflection compat, surface target runtime in the Agent UI, and switch the Genie detached flow to framework-owned recovery plus smaller detached-frame payloads so HOME sessions survive detached launch and frame capture on the new framework surface.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
7ad5e4eff3 Harden Android detached Genie sessions
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
f45c857bec Fix Android framework transport URL handling
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
f26827ba25 Remove Android session transport fallback
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
2a6b4021f6 Use framework-owned Android session transport
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
5953e415f7 Improve Android responses proxy diagnostics
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
663b74babe Use light theme for Android session creation
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
2ea5cb7139 Extract Android session creation into standalone activity
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
040b55c91b Remove Android session prompt placeholder
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:12 -07:00
Iliyan Malchev
efac1c5920 Reuse Android start dialog in session detail
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
313e9c86af Unify Android home session start dialog
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
838441b245 Revert direct cargo-ndk Gradle wiring
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
09f3dffa93 Invoke cargo-ndk directly from Android Gradle
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
b6ba266b5a Build Android codex binary from Gradle
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
d1f783fa3d Update Android home session deletion
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
f37ac204e3 Handle launcher-created Android home sessions
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
57b32d5a22 Clear Android session notifications consistently
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
ebf0d2994c Restore generic Rust workspace dependencies
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
8bdf5963f8 Remove final Android codexd references
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
2f61c54da3 Carry continuation context and harden Android session bridge
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
a9be8e3cfd Fix Android auth seeding in installer
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
58dca77b08 Make Android build helper Bash 3 compatible
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
17b38ee8dc Add Android APK variant selection
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
e1ed6d0159 Rename Android build helper SDK flag
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
c9bd92342e Add Android Agent and Genie helper scripts
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
808ed32923 Match Agent app launcher visibility to stub
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:11 -07:00
Iliyan Malchev
cad0e1e3bd Make Agent app picker scrolling obvious
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:10 -07:00
Iliyan Malchev
faa0756a6b Match Agent app picker to stub launcher list
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:10 -07:00
Iliyan Malchev
7a19620340 Show installed packages in Agent picker
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:10 -07:00
Iliyan Malchev
4b8c2098c9 Route Genie responses through app-server bridge
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:22:10 -07:00
Iliyan Malchev
13770329f9 Tolerate HOME session delete consume failures
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:20:54 -07:00
Iliyan Malchev
2d906945ac Restructure Agent session detail actions
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:20:54 -07:00
Iliyan Malchev
1d8d508a6f Support direct session continuation in place
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:20:54 -07:00
Iliyan Malchev
3503db0c34 Make related Agent sessions navigable
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:20:54 -07:00
Iliyan Malchev
087c63cebb Revamp Android Agent session UI
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:20:54 -07:00
Iliyan Malchev
58abe546ec Align Agent package discovery with framework role
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:20:54 -07:00
Iliyan Malchev
2765aff40c Track final Android target presentation state
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:20:54 -07:00
Iliyan Malchev
ef5894affc Remove legacy Android codexd path
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:20:54 -07:00
Iliyan Malchev
11b681883f Rename Android Agent package
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:37 -07:00
Iliyan Malchev
b1682fb4eb Remove codexd from Android Agent app
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:37 -07:00
Iliyan Malchev
e174cc2e77 Remove dead Android bridge implementations
Drop the obsolete direct AIDL Agent bridge and abstract local-socket bridge, simplify the Android manifests to match the working framework session bridge path, and stop labeling codexd as legacy in the UI while it still provides auth and fallback transport.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:37 -07:00
Iliyan Malchev
446c119f1b Clarify Genie display visibility requirement
Update the Android runtime guidance asset to state explicitly that when the user asks to show an activity on screen, the Genie must make the display visible rather than leaving the target hidden or detached.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:37 -07:00
Iliyan Malchev
1e902a99cf Copy runtime guidance from Agent into Genie homes
Move the Android runtime guidance out of Java source into a checked-in AGENTS.md asset, install that file into the Agent home at bootstrap, and copy the installed Agent file into each per-session Genie home over the framework session bridge.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
4086f04ddb Install Android runtime guidance into Codex homes
Install a generated AGENTS.md in the shared Agent Codex home and every per-session Genie home so hosted runtimes pick up the same device-operating guidance at bootstrap.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
7e30a81550 Fallback to codexd on Agent DNS failures
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
ca5e880242 Stabilize Agent session bridge lifecycle
Keep framework session bridges alive across stale child-session callbacks and tighten Genie guidance for Android shell time calculations during alarm flows.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
0a251f0dac Roll up parent Agent sessions cleanly
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
d0ae5e2da2 Fix repeated Agent debug session launches
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
7dd456dc22 Use framework session bridges for Agent transport
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
fdacd6d8e2 Fix Genie local transport startup
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
b6d1bc62f0 Remove Agent UI polling loop
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
85f88c85c9 Make Agent runtime status event-driven
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
b124bf5170 Harden headless Genie runtime guidance
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
7d3e5f6d0c Harden hosted Android Agent Genie bridge
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
2647a6de84 Propagate hosted models into Genie turns
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
5206fac8af Keep Genie transport local-only
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
ac85481fee Let Agent ask the user during planning
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
7b8096e6ef Use shell-first Genie app driving
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
c2c663a6e5 Route Agent answers through framework bridge
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
be817e3421 Bridge Android framework session APIs
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:36 -07:00
Iliyan Malchev
186368fe88 Let Agent runtime launch Genie sessions
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
902c40d71f Use Agent tools for Android planning
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
a3c4a9e957 Promote Agent runtime in Android UI
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
4f6b0de73f Own Genie Responses transport in Agent bridge
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
f1a739e7eb Host Agent planning in codex app-server
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
c438fc2fb2 Make Android LTO optional for local builds
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
89c42926fc Route Genie app-server traffic through Agent proxy
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
f212204fa4 Host Genie sessions with codex app-server
Package the Android codex binary into the Genie APK and run hosted codex app-server inside Genie sessions, with Android dynamic tools and AgentSDK question bridging replacing the live TOOL/QUESTION/RESULT scaffold.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
3a5ab674f0 Plan Agent targets before launching Genies
Teach the Agent runtime and UI to resolve target packages from installed apps and start one child Genie session per selected package, with an optional package override for debugging.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
fa0c22441c Add generic Android tools to Genie
Teach the Genie runtime to run a structured tool loop with reusable Android actions, image-aware model inputs, and generic tool observations instead of app-specific behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
30ae7c28e1 Move Android bridge traffic onto AIDL
Add a shared Binder contract for the Agent/Genie control plane and switch the Genie runtime to use the exported Agent bridge service for runtime status and proxied HTTP, leaving AgentSDK questions only for real Genie dialogue.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
1340af08aa Let Agent answer Genie questions through codexd
Teach the Agent service to answer non-bridge Genie questions through the embedded codexd runtime, and upgrade the Genie scaffold to loop over QUESTION:/RESULT: turns instead of assuming a single user reply. The current bridge now sends Responses input in list form, which fixes the last observed backend validation error.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
6acd1f7473 Surface Genie questions through Agent notifications
Keep the Agent/Genie contract free-form and Agent-owned by surfacing non-bridge Genie questions through an Agent notification, while tightening the bridged /v1/responses payload used by the current scaffold.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
87b9057d63 Show Agent runtime status in session UI
Expose the embedded codexd runtime state directly in the Agent session screen so authentication, effective model, provider, client count, and base URL issues are visible without digging through fallback results or logcat.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
cf1f537879 Retry Agent bridge answers across framework races
Keep internal bridge requests using framework question state, distinguish bridge responses from user answers in Genie logs, and retry Agent-side bridge answers across short framework timing races instead of failing immediately.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
9de3f3ba6a Inspect target apps inside Genie sessions
Inspect the paired target package from inside the Genie sandbox, publish that metadata into the framework session trace, and feed the launch-intent and permission context into the Agent-bridged model prompt.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:35 -07:00
Iliyan Malchev
3410562a58 Add Agent queue cleanup control
Add an Agent UI action to cancel all active framework sessions child-first so stale queued and waiting Genie runs can be drained without manual session-by-session cleanup during Android Agent Platform validation.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
a5987218b8 Route Genie model requests through Agent bridge
Add an Agent-owned runtime-status endpoint to codexd, teach the Genie to discover the effective model/provider over the framework bridge, and issue one real non-streaming /v1/responses request through the Agent after the user answer. Also add JVM coverage for the bridge request/response parsers.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
d5679d7c06 Generalize Genie bridge to HTTP envelopes
Carry small HTTP request/response envelopes over the framework-mediated Agent bridge, switch the Genie auth probe to the real /internal/auth/status response body, and update the Android refactor doc to reflect the current bridge shape.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
e03e28b38d Hide internal bridge traffic from Agent UI
Move Agent bridge responses off the AgentService callback thread and filter machine bridge question/answer events out of the user-facing session snapshot so the Agent UI only shows the real Genie prompt.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
3d4274962c Implement framework-mediated Agent bridge for Genie
Add an internal AgentSDK question/answer bridge so Genie can reach Agent-owned codexd state from the paired app sandbox, keep the Android daemon on abstract unix sockets, and document the runtime constraint this proves on the emulator.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
6450c8ccbc Add Android Agent and Genie scaffolding
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
535c3f8d03 Link Android builds against c++abi
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
2c2587538f Update lockfile for rustls Android build
Record the reqwest dependency graph change after switching the workspace TLS defaults away from native-tls so Android builds resolve without OpenSSL.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
b5abe6f74d Use rustls reqwest defaults for Android builds
Replace the workspace reqwest default TLS stack with explicit rustls features so Android cross-builds do not pull in native-tls and openssl-sys.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
b7a14a519c Fix shell snapshot lifecycle regressions
Create shell snapshots lazily for eligible shell commands, serialize refreshes, keep code-mode and zsh-fork tests isolated from snapshot setup, skip exec file watching, and make exec shutdown return cleanly.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:19:34 -07:00
Iliyan Malchev
59972a08fc Stabilize approval integration tests
Scope approval waits to the active turn and raise the slow shell-script timeouts used by approval scenarios so codex-core stays stable under full workspace load.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:18:01 -07:00
Iliyan Malchev
e69c9e6a4d Fix CLI config forwarding and RMCP test server freshness
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:18:01 -07:00
Iliyan Malchev
7d8fd2ad49 Fix skill approval test path normalization and event handling
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:17:31 -07:00
Iliyan Malchev
19c3cd9a5a Fix shell snapshot apply_patch race and js_repl dotslash node resolution
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:17:31 -07:00
Iliyan Malchev
ffe51911a0 Fix post-rebase workspace regressions
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:16:12 -07:00
Iliyan Malchev
5bbcc400d3 Support Android service APK builds on JDK 25 2026-03-26 07:15:30 -07:00
Iliyan Malchev
931facc7f7 test: stabilize shell env + v2 interrupt timing
- Make core test harness pin a deterministic shell environment by setting HOME/ZDOTDIR and clearing BASH_ENV, so login shells in tests avoid developer dotfiles and timeouts.
- Allow shell snapshot execution to accept explicit env overrides, and use them in the stdin-inheritance test to keep HOME/BASH_ENV isolated without relying on process-wide env.
- Remove the timing race in v2 turn interrupt tests by waiting for the CommandExecution item to start before issuing turn/interrupt, ensuring the mock SSE request is actually emitted.

Tests:
- just fmt
- cargo test -p codex-app-server --test all
- cargo test --all-features
2026-03-26 07:15:30 -07:00
Iliyan Malchev
bef6d4445b Add Android Gradle scaffolding
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 07:14:55 -07:00
Iliyan Malchev
6f4dcf3378 app-server/tests: make app list updates order-tolerant
The app list endpoint delivers two independent updates (directory list and
MCP tool list). In practice those arrive in either order, so the tests should
accept both sequences to avoid timing flakes.

Changes:
- In app list tests, accept either the directory-only or accessible-only
  payload as the first update and assert the merged payload on the next one.
- Force refetch in the "accessible flags" test to avoid stale cache
  interference.

This preserves behavior while removing nondeterministic failures.
2026-03-26 07:14:55 -07:00
Iliyan Malchev
b349563da7 app-server: add debug MDM overrides + stabilize analytics tests
Provide a debug-only escape hatch for managed-config state so tests are not
polluted by host MDM settings:
- Allow `CODEX_APP_SERVER_MANAGED_PREFERENCES_BASE64` and
  `CODEX_APP_SERVER_MANAGED_REQUIREMENTS_BASE64` to override loader inputs
  (debug builds only).
- Test harnesses now clear those env vars and the OpenAI socket override so
  child app-server processes start from a clean environment.
- Analytics tests use LoaderOverrides with empty managed preferences to avoid
  accidental MDM config injection.

This keeps app-server behavior unchanged in release while making the test
suite deterministic on managed machines.
2026-03-26 07:14:55 -07:00
Iliyan Malchev
828cf5c867 core/tests: neutralize macOS managed preferences in config tests
Avoid test flakiness caused by host-managed configuration on macOS by
explicitly overriding managed preference data with empty base64 strings in
config loader tests. This keeps the tests deterministic even when the host
machine has MDM-managed settings.

No production behavior changes: the overrides are only used in test fixtures.
2026-03-26 07:14:20 -07:00
Iliyan Malchev
b46e9bcc9d core: expose test-only tools for test-* fallback models
Make the fallback model metadata more useful for test harnesses that rely on
mock model slugs (e.g., "test-*"):
- When built with tests or the `test-support` feature, attach the tool list
  used by integration tests (test_sync_tool/read_file/grep_files/list_dir).
- Keep production behavior unchanged (non-test builds still emit no tools for
  unknown models).
- Enable the `test-support` feature for the core dev-dependency so workspace
  tests get the richer fallback metadata automatically.
2026-03-26 07:14:20 -07:00
Iliyan Malchev
e08fe765e5 core: route OpenAI traffic via Unix socket when configured
Teach core to build its API transport from an environment-configurable
transport selector so the CLI can talk to a local Android proxy over UDS.

Highlights:
- Add `openai_socket` helper (CODEX_OPENAI_UNIX_SOCKET) and a `build_api_transport`
  helper that chooses UDS on unix or falls back to reqwest everywhere else.
- Switch model/response/memory clients to use the transport builder instead
  of constructing reqwest directly.
- When the UDS env var is set, default the OpenAI provider base URL to
  `http://localhost/v1` so existing request paths work unchanged.
- Export the new helpers from core for use by other crates.
- Emit a warning on non-unix builds if CODEX_OPENAI_UNIX_SOCKET is set.
2026-03-26 07:14:20 -07:00
Iliyan Malchev
e6deb33032 codex-client: add AnyTransport + UDS transport implementation
Add a Unix-domain-socket transport alongside the existing reqwest transport
so callers can route OpenAI requests over a local socket (for the Android
service proxy) without touching the rest of the client stack.

Details:
- Introduce `UdsTransport` (unix-only) and `AnyTransport` to unify reqwest and
  UDS transports behind the existing `HttpTransport` trait.
- Implement request preparation with explicit Host/Content-Type handling and
  optional request-body compression, mirroring the reqwest behavior.
- Support both execute + streaming responses using hyper HTTP/1.1 on a UDS
  socket with proper timeout handling and error mapping.
- Re-export `AnyTransport` and `UdsTransport` from codex-client and codex-api
  so higher layers can select transports without reaching into internals.
- Expand codex-client dependencies (hyper, hyper-util, http-body-util, net
  features on tokio) needed by the UDS implementation.
2026-03-26 07:13:37 -07:00
Iliyan Malchev
2fb44353a3 Add MCP OAuth CLI switches for host-browser flows
Provide explicit CLI controls for OAuth login when running Codex on Android or other headless setups.

- add --callback-port, --no-browser, and --host-browser flags to .

- in no-browser/host-browser mode, fetch the authorization URL and print guidance (including adb forward hints when host-browser is set).

- update Android build docs to use the new flags instead of editing config files.

This makes OAuth login viable from adb shells without relying on local browser auto-launch.
2026-03-26 07:13:37 -07:00
Iliyan Malchev
5c0a62a8a4 Temporary workaround: avoid browser auto-launch on Android
Android CLI runs have no Java/NDK context, so webbrowser::open() panics via ndk-context. This commit avoids that crash by skipping auto-launch and instructing manual copy/paste.

- login server: on Android, print the auth URL instead of calling webbrowser::open.

- rmcp-client OAuth flow: on Android, print a manual-launch hint instead of attempting to open the browser.

This is intentionally a stopgap; a future improvement should support host-side browser launch with adb port forwarding or an Android intent-based launcher.
2026-03-26 07:13:37 -07:00
Iliyan Malchev
c02f1f41fe Disable enhanced keyboard mode on Android
Avoid Android terminal hangs and weird key sequences by skipping crossterm keyboard enhancement support on Android.

- do not push keyboard enhancement flags in set_modes on Android, preventing kitty-style key reporting from being enabled in adb shells.

- skip supports_keyboard_enhancement probing on Android and treat enhanced keys as unsupported.

This keeps desktop behavior unchanged while making Android shells responsive and more compatible.
2026-03-26 07:13:03 -07:00
Iliyan Malchev
6f9687546f Silence Android clipboard paste warnings
Avoid Android build warnings in the TUI clipboard module without changing behavior.

- gate the tempfile::Builder import to non-android since only desktop builds write temp PNGs.

- allow dead_code on Android for the clipboard image error enum and stub paste function, which are intentionally unused when clipboard images are unsupported.

Desktop builds still include the full clipboard implementation; Android keeps the explicit unsupported path.
2026-03-26 07:13:03 -07:00
Iliyan Malchev
74b8713a41 Gate home_dir import on non-Android
Fix the remaining Android build warning by only importing dirs::home_dir when it is used.

This keeps the desktop default unchanged and avoids an unused_imports warning under cargo-ndk.
2026-03-26 07:13:03 -07:00
Iliyan Malchev
3df5293010 Silence Android build warnings
Clean up two Android-only warnings seen during cargo-ndk builds without changing runtime behavior.

- keyring-store: gate the tracing import to non-android so the stub implementation doesn't trigger unused_imports.

- utils/home-dir: factor the default home selection into cfg-specific helpers to avoid unreachable_code when the Android path is returned.

This keeps desktop defaults untouched and makes Android builds warning-free.
2026-03-26 07:13:03 -07:00
Iliyan Malchev
0d5c78103d Default CODEX_HOME to /data/local/tmp on Android
Fix the Android runtime failure caused by trying to create ~/.codex on a read-only filesystem when running under adb shell.

- codex-utils-home-dir: when CODEX_HOME is unset on Android, default to /data/local/tmp/codex so the CLI can create config, logs, and secrets in a writable location.

- arg0: skip the release-mode temp-dir guard on Android, since the Android default home lives under /data/local/tmp and we still want helper symlinks to work.

- docs: record the Android default CODEX_HOME path in the Android build guide.

Existing desktop targets keep their ~/.codex default and the guard remains in place for non-Android release builds.
2026-03-26 07:13:03 -07:00
Iliyan Malchev
e1b1fff769 Document Android builds and add just helper
Provide a native Android build workflow and make it discoverable from the install docs.

- add docs/android-build.md with prerequisites (NDK r29, cargo-ndk, rust targets), build steps, output paths, and adb run example.

- add justfile android-build recipe that builds codex for arm64-v8a and x86_64 (API 26) into target/android.

- link the Android guide from docs/install.md.
2026-03-26 07:13:03 -07:00
Iliyan Malchev
c969d32f4e Treat keyring storage as unsupported on Android
Make credential persistence robust on Android by explicitly handling unsupported keyring backends and falling back to file-backed storage.

- keyring-store: gate the keyring dependency to non-android, add an Unsupported error, and provide an Android stub store that reports Unsupported.

- core auth storage: treat Unsupported as a soft failure in Auto mode so the file store is selected automatically.

- secrets: persist the passphrase to a private file with atomic writes (0600) when keyring is unavailable and load it on startup.

- rmcp-client OAuth: downgrade Unsupported keyring errors into file-backed storage flows and add coverage for load/save/delete.

- move keyring crate usage to dev-dependencies in core/rmcp-client to avoid linking keyring into Android builds.

Desktop behavior is preserved; Android now cleanly opts into the file-based path instead of failing.
2026-03-26 07:13:02 -07:00
Iliyan Malchev
22586ef4d8 Use rustls for reqwest and sentry
Switch the workspace to rustls-backed HTTP clients so Android builds do not require OpenSSL while keeping desktop behavior intact.

- Set workspace reqwest to default-features = false with rustls-tls and have dependent crates use the workspace definition (backend-client, lmstudio, tui, rmcp-client).

- Configure sentry with an explicit rustls + reqwest feature set to avoid native-tls/openssl at build time.

- Update Cargo.lock to drop native-tls/openssl/hyper-tls transitive deps and keep the rustls chain consistent.

Net effect: existing targets keep the same HTTP surface, but the TLS backend is unified and Android-friendly.
2026-03-26 07:13:02 -07:00
jif-oai
4a5635b5a0 feat: clean spawn v1 (#15861)
Avoid the usage of path in the v1 spawn
2026-03-26 15:01:00 +01:00
jif-oai
b00a05c785 feat: drop artifact tool and feature (#15851) 2026-03-26 13:21:24 +01:00
jif-oai
7ef3cfe63e feat: replace askama by custom lib (#15784)
Finalise the drop of `askama` to use our internal lib instead
2026-03-26 10:33:25 +01:00
viyatb-oai
937cb5081d fix: fix old system bubblewrap compatibility without falling back to vendored bwrap (#15693)
Fixes #15283.

## Summary
Older system bubblewrap builds reject `--argv0`, which makes our Linux
sandbox fail before the helper can re-exec. This PR keeps using system
`/usr/bin/bwrap` whenever it exists and only falls back to vendored
bwrap when the system binary is missing. That matters on stricter
AppArmor hosts, where the distro bwrap package also provides the policy
setup needed for user namespaces.

For old system bwrap, we avoid `--argv0` instead of switching binaries:
- pass the sandbox helper a full-path `argv0`,
- keep the existing `current_exe() + --argv0` path when the selected
launcher supports it,
- otherwise omit `--argv0` and re-exec through the helper's own
`argv[0]` path, whose basename still dispatches as
`codex-linux-sandbox`.

Also updates the launcher/warning tests and docs so they match the new
behavior: present-but-old system bwrap uses the compatibility path, and
only absent system bwrap falls back to vendored.

### Validation

1. Install Ubuntu 20.04 in a VM
2. Compile codex and run without bubblewrap installed - see a warning
about falling back to the vendored bwrap
3. Install bwrap and verify version is 0.4.0 without `argv0` support
4. run codex and use apply_patch tool without errors

<img width="802" height="631" alt="Screenshot 2026-03-25 at 11 48 36 PM"
src="https://github.com/user-attachments/assets/77248a29-aa38-4d7c-9833-496ec6a458b8"
/>
<img width="807" height="634" alt="Screenshot 2026-03-25 at 11 47 32 PM"
src="https://github.com/user-attachments/assets/5af8b850-a466-489b-95a6-455b76b5050f"
/>
<img width="812" height="635" alt="Screenshot 2026-03-25 at 11 45 45 PM"
src="https://github.com/user-attachments/assets/438074f0-8435-4274-a667-332efdd5cb57"
/>
<img width="801" height="623" alt="Screenshot 2026-03-25 at 11 43 56 PM"
src="https://github.com/user-attachments/assets/0dc8d3f5-e8cf-4218-b4b4-a4f7d9bf02e3"
/>

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
2026-03-25 23:51:39 -07:00
Tiffany Citra
6d0525ae70 Expand home-relative paths on Windows (#15817)
Follow up to: https://github.com/openai/codex/pull/9193, also support
this for Windows.

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
2026-03-25 21:19:57 -07:00
Eric Traut
1ff39b6fa8 Wire remote app-server auth through the client (#14853)
For app-server websocket auth, support the two server-side mechanisms
from
PR #14847:

- `--ws-auth capability-token --ws-token-file /abs/path`
- `--ws-auth signed-bearer-token --ws-shared-secret-file /abs/path`
  with optional `--ws-issuer`, `--ws-audience`, and
  `--ws-max-clock-skew-seconds`

On the client side, add interactive remote support via:

- `--remote ws://host:port` or `--remote wss://host:port`
- `--remote-auth-token-env <ENV_VAR>`

Codex reads the bearer token from the named environment variable and
sends it
as `Authorization: Bearer <token>` during the websocket handshake.
Remote auth
tokens are only allowed for `wss://` URLs or loopback `ws://` URLs.

Testing:
- tested both auth methods manually to confirm connection success and
rejection for both auth types
2026-03-25 22:17:03 -06:00
Eric Traut
b565f05d79 Fix quoted command rendering in tui_app_server (#15825)
When `tui_app_server` is enabled, shell commands in the transcript
render as fully quoted invocations like `/bin/zsh -lc "..."`. The
non-app-server TUI correctly shows the parsed command body.

Root cause:
The app-server stores `ThreadItem::CommandExecution.command` as a
shell-quoted string. When `tui_app_server` bridges that item back into
the exec renderer, it was passing `vec![command]` unchanged instead of
splitting the string back into argv. That prevented
`strip_bash_lc_and_escape()` from recognizing the shell wrapper, so the
renderer displayed the wrapper literally.

Solution:
Add a shared command-string splitter that round-trips shell-quoted
commands back into argv when it is safe to do so, while preserving
non-roundtrippable inputs as a single string. Use that helper everywhere
`tui_app_server` reconstructs exec commands from app-server payloads,
including live command-execution items, replayed thread items, and exec
approval requests. This restores the same command display behavior as
the direct TUI path without breaking Windows-style commands that cannot
be safely round-tripped.
2026-03-25 22:03:29 -06:00
Matthew Zeng
4b50446ffa [plugins] Flip flags on. (#15820)
- [x] Flip flags on.
2026-03-26 03:24:06 +00:00
Andrei Eternal
c4d9887f9a [hooks] add non-streaming (non-stdin style) shell-only PostToolUse support (#15531)
CHAINED PR - note that base is eternal/hooks-pretooluse-bash, not main
-- so the following PR should be first

Matching post-tool hook to the pre-tool functionality here:
https://github.com/openai/codex/pull/15211

So, PreToolUse calls for plain shell calls, allows blocking. This
PostToolUse call runs after the command executed

example run:

```
› as a test, run in parallel the following commands:
  - echo 'one'
  - echo '[block-pre-tool-use]'
  - echo '[block-post-tool-use]'


⚠ MCP startup incomplete (failed: notion, linear)

• Cruising through those three commands in parallel now, and I’ll share the exact outputs right after
  they land.

• Running PreToolUse hook: checking the observatory runes

• Running PreToolUse hook: checking the observatory runes

• Running PreToolUse hook: checking the observatory runes

PreToolUse hook (blocked)
  warning: wizard-tower PreToolUse demo blocked a Bash command on purpose.
  feedback: PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue.

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo 'one'

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo '[block-post-tool-use]'

• Ran echo '[block-post-tool-use]'
  └ [block-post-tool-use]

• Ran echo 'one'
  └ one

• Running PostToolUse hook: reading the observatory aftermath

• Running PostToolUse hook: reading the observatory aftermath

PostToolUse hook (blocked)
  warning: wizard-tower PostToolUse demo blocked the Bash follow-through on purpose.
  feedback: PostToolUse demo blocked the result after execution. Remove [block-post-tool-use] to
continue.

PostToolUse hook (completed)
  warning: wizard-tower PostToolUse demo inspected Bash: echo 'one'
  hook context: PostToolUse demo saw the Bash result. Response preview: one

──────────────────────────────────────────────────────────────────────────────────────────────────────

• Ran them in parallel. Little wave report:

  - echo 'one' completed and returned one.
  - echo '[block-pre-tool-use]' was blocked before execution by the PreToolUse hook.
  - echo '[block-post-tool-use]' ran, but its result was blocked afterward by the PostToolUse hook.

  The hook messages were:

  - PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue.
  - PostToolUse demo blocked the result after execution. Remove [block-post-tool-use] to continue.


› for the blocked messages (correctly blocked in this test) did the tool output land back into
  context?


• Yeah, from this run:

  - PreToolUse block: the command itself did not execute, so no command output landed in context. Only
    the block message did.
  - PostToolUse block: the command did execute, but the actual stdout did not come back into context.
    Only the post-tool block message did.

  So the blocked notifications landed in context, but the blocked command payloads themselves did not.
  Nice clean guardrail, aloha.
```
2026-03-25 19:18:03 -07:00
Matthew Zeng
78799c1bcf [mcp] Improve custom MCP elicitation (#15800)
- [x] Support don't ask again for custom MCP tool calls.
- [x] Don't run arc in yolo mode.
- [x] Run arc for custom MCP tools in always allow mode.
2026-03-26 01:02:37 +00:00
Ruslan Nigmatullin
d7e35e56cf app-server: Organize app-server to allow more transports (#15810)
Make `run_main_with_transport` slightly more flexible by consolidating
logic spread across stdio and websocket transports.
2026-03-25 17:11:22 -07:00
canvrno-oai
2794e27849 Add ReloadUserConfig to tui_app_server (#15806)
- Adds ReloadUserConfig to `tui_app_server`
2026-03-25 17:03:18 -07:00
pakrym-oai
8fa88fa8ca Add cached environment manager for exec server URL (#15785)
Add environment manager that is a singleton and is created early in
app-server (before skill manager, before config loading).

Use an environment variable to point to a running exec server.
2026-03-25 16:14:36 -07:00
canvrno-oai
f24c55f0d5 TUI plugin menu polish (#15802)
- Add "OpenAI Curated" display name for `openai-curated` marketplace
- Hide /apps menu
- Change app install phase display text
2026-03-25 16:09:19 -07:00
arnavdugar-openai
eee692e351 Treat ChatGPT hc plan as Enterprise (#15789) 2026-03-25 15:41:29 -07:00
nicholasclark-openai
b6524514c1 Add MCP tool call spans (#15659)
## Summary
- add an explicit `mcp.tools.call` span around MCP tool execution in
core
- keep MCP span validation local to `mcp_tool_call_tests` instead of
broadening the integration test suite
- inline the turn/session correlation fields directly in the span
initializer

## Included Changes
- `codex-rs/core/src/mcp_tool_call.rs`: wrap the existing MCP tool call
in `mcp.tools.call` and inline `conversation.id`, `session.id`, and
`turn.id` in the span initializer
- `codex-rs/core/src/mcp_tool_call_tests.rs`: assert the MCP span
records the expected correlation and server fields

## Testing
- `cargo test -p codex-core`
- `just fmt`

## Notes
- `cargo test -p codex-core` still hits existing unrelated failures in
guardian-config tests and the sandboxed JS REPL `mktemp` test
- metric work moved to stacked PR #15792
- transport-level RMCP spans and trace propagation remain in stacked PR
#15792
- full workspace `cargo test` was not run

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 22:13:02 +00:00
Eric Traut
2c67a27a71 Avoid duplicate auth refreshes in getAuthStatus (#15798)
I've seen several intermittent failures of
`get_auth_status_returns_token_after_proactive_refresh_recovery` today.
I investigated, and I found a couple of issues.

First, `getAuthStatus(refreshToken=true)` could refresh twice in one
request: once via `refresh_token_if_requested()` and again via the
proactive refresh path inside `auth_manager.auth()`. In the
permanent-failure case this produced an extra `/oauth/token` call and
made the app-server auth tests flaky. Use `auth_cached()` after an
explicit refresh request so the handler reuses the post-refresh auth
state instead of immediately re-entering proactive refresh logic. Keep
the existing proactive path for `refreshToken=false`.

Second, serialize auth refresh attempts in `AuthManager` have a
startup/request race. One proactive refresh could already be in flight
while a `getAuthStatus(refreshToken=false)` request entered
`auth().await`, causing a second `/oauth/token` call before the first
failure or refresh result had been recorded. Guarding the refresh flow
with a single async lock makes concurrent callers share one refresh
result, which prevents duplicate refreshes and stabilizes the
proactive-refresh auth tests.
2026-03-25 16:03:53 -06:00
Ahmed Ibrahim
9dbe098349 Extract codex-core-skills crate (#15749)
## Summary
- move skill loading and management into codex-core-skills
- leave codex-core with the thin integration layer and shared wiring

## Testing
- CI

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 12:57:42 -07:00
Felipe Coury
e9996ec62a fix(tui_app_server): preserve transcript events under backpressure (#15759)
## TL;DR

When running codex with `-c features.tui_app_server=true` we see
corruption when streaming large amounts of data. This PR marks other
event types as _critical_ by making them _must-deliver_.

## Problem

When the TUI consumer falls behind the app-server event stream, the
bounded `mpsc` channel fills up and the forwarding layer drops events
via `try_send`. Previously only `TurnCompleted` was marked as
must-deliver. Streamed assistant text (`AgentMessageDelta`) and the
authoritative final item (`ItemCompleted`) were treated as droppable —
the same as ephemeral command output deltas. Because the TUI renders
markdown incrementally from these deltas, dropping any of them produces
permanently corrupted or incomplete paragraphs that persist for the rest
of the session.

## Mental model

The app-server event stream has two tiers of importance:

1. **Lossless (transcript + terminal):** Events that form the
authoritative record of what the assistant said or that signal turn
lifecycle transitions. Losing any of these corrupts the visible output
or leaves surfaces waiting forever. These are: `AgentMessageDelta`,
`PlanDelta`, `ReasoningSummaryTextDelta`, `ReasoningTextDelta`,
`ItemCompleted`, and `TurnCompleted`.

2. **Best-effort (everything else):** Ephemeral status events like
`CommandExecutionOutputDelta` and progress notifications. Dropping these
under load causes cosmetic gaps but no permanent corruption.

The forwarding layer uses `try_send` for best-effort events (dropping on
backpressure) and blocking `send().await` for lossless events (applying
back-pressure to the producer until the consumer catches up).

## Non-goals

- Eliminating backpressure entirely. The bounded queue is intentional;
this change only widens the set of events that survive it.
- Changing the event protocol or adding new notification types.
- Addressing root causes of consumer slowness (e.g. TUI render cost).

## Tradeoffs

Blocking on transcript events means a slow consumer can now stall the
producer for the duration of those events. This is acceptable because:
(a) the alternative is permanently broken output, which is worse; (b)
the consumer already had to keep up with `TurnCompleted` blocking sends;
and (c) transcript events arrive at model-output speed, not burst speed,
so sustained saturation is unlikely in practice.

## Architecture

Two parallel changes, one per transport:

- **In-process path** (`lib.rs`): The inline forwarding logic was
extracted into `forward_in_process_event`, a standalone async function
that encapsulates the lag-marker / must-deliver / try-send decision
tree. The worker loop now delegates to it. A new
`server_notification_requires_delivery` function (shared `pub(crate)`)
centralizes the notification classification.

- **Remote path** (`remote.rs`): The local `event_requires_delivery` now
delegates to the same shared `server_notification_requires_delivery`,
keeping both transports in sync.

## Observability

No new metrics or log lines. The existing `warn!` on event drops
continues to fire for best-effort events. Lossless events that block
will not produce a log line (they simply wait).

## Tests

- `event_requires_delivery_marks_transcript_and_terminal_events`: unit
test confirming the expanded classification covers `AgentMessageDelta`,
`ItemCompleted`, `TurnCompleted`, and excludes
`CommandExecutionOutputDelta` and `Lagged`.
-
`forward_in_process_event_preserves_transcript_notifications_under_backpressure`:
integration-style test that fills a capacity-1 channel, verifies a
best-effort event is dropped (skipped count increments), then sends
lossless transcript events and confirms they all arrive in order with
the correct lag marker preceding them.
- `remote_backpressure_preserves_transcript_notifications`: end-to-end
test over a real websocket that verifies the remote transport preserves
transcript events under the same backpressure scenario.
- `event_requires_delivery_marks_transcript_and_disconnect_events`
(remote): unit test confirming the remote-side classification covers
transcript events and `Disconnected`.

---------

Co-authored-by: Eric Traut <etraut@openai.com>
2026-03-25 13:50:39 -06:00
viyatb-oai
6124564297 feat: add websocket auth for app-server (#14847)
## Summary
This change adds websocket authentication at the app-server transport
boundary and enforces it before JSON-RPC `initialize`, so authenticated
deployments reject unauthenticated clients during the websocket
handshake rather than after a connection has already been admitted.

During rollout, websocket auth is opt-in for non-loopback listeners so
we do not break existing remote clients. If `--ws-auth ...` is
configured, the server enforces auth during websocket upgrade. If auth
is not configured, non-loopback listeners still start, but app-server
logs a warning and the startup banner calls out that auth should be
configured before real remote use.

The server supports two auth modes: a file-backed capability token, and
a standard HMAC-signed JWT/JWS bearer token verified with the
`jsonwebtoken` crate, with optional issuer, audience, and clock-skew
validation. Capability tokens are normalized, hashed, and compared in
constant time. Short shared secrets for signed bearer tokens are
rejected at startup. Requests carrying an `Origin` header are rejected
with `403` by transport middleware, and authenticated clients present
credentials as `Authorization: Bearer <token>` during websocket upgrade.

## Validation
- `cargo test -p codex-app-server transport::auth`
- `cargo test -p codex-cli app_server_`
- `cargo clippy -p codex-app-server --all-targets -- -D warnings`
- `just bazel-lock-check`

Note: in the broad `cargo test -p codex-app-server
connection_handling_websocket` run, the touched websocket auth cases
passed, but unrelated Unix shutdown tests failed with a timeout in this
environment.

---------

Co-authored-by: Eric Traut <etraut@openai.com>
2026-03-25 12:35:57 -07:00
Matthew Zeng
91337399fe [apps][tool_suggest] Remove tool_suggest's dependency on tool search. (#14856)
- [x] Remove tool_suggest's dependency on tool search.
2026-03-25 12:26:02 -07:00
Felipe Coury
79359fb5e7 fix(tui_app_server): fix remote subagent switching and agent names (#15513)
## TL;DR

This PR changes the `tui_app_server` _path_ in the following ways:

- add missing feature to show agent names (shows only UUIDs today) 
- add `Cmd/Alt+Arrows` navigation between agent conversations

## Problem

When the TUI connects to a remote app server, collab agent tool-call
items (spawn, wait, delegate, etc.) render thread UUIDs instead of
human-readable agent names because the `ChatWidget` never receives
nickname/role metadata for receiver threads. Separately, keyboard
next/previous agent navigation silently does nothing when the local
`AgentNavigationState` cache has not yet been populated with subagent
threads that the remote server already knows about.

Both issues share a root cause: in the remote (app-server) code path the
TUI never proactively fetches thread metadata. In the local code path
this metadata arrives naturally via spawn events the TUI itself
orchestrates, but in the remote path those events were processed by a
different client and the TUI only sees the resulting collab tool-call
notifications.

## Mental model

Collab agent tool-call notifications reference receiver threads by id,
but carry no nickname or role. The TUI needs that metadata in two
places:

1. **Rendering** -- `ChatWidget` converts `CollabAgentToolCall` items
into history cells. Without metadata, agent status lines show raw UUIDs.
2. **Navigation** -- `AgentNavigationState` tracks known threads for the
`/agent` picker and keyboard cycling. Without entries for remote
subagents, next/previous has nowhere to go.

This change closes the gap with two complementary strategies:

- **Eager hydration**: when any notification carries
`receiver_thread_ids`, the TUI fetches metadata (`thread/read`) for
threads it has not yet cached before the notification is rendered.
- **Backfill on thread switch**: when the user resumes, forks, or starts
a new app-server thread, the TUI fetches the full `thread/loaded/list`,
walks the parent-child spawn tree, and registers every descendant
subagent in both the navigation cache and the `ChatWidget` metadata map.

A new `collab_agent_metadata` side-table in `ChatWidget` stores
nickname/role keyed by `ThreadId`, kept in sync by `App` whenever it
calls `upsert_agent_picker_thread`. The `replace_chat_widget` helper
re-seeds this map from `AgentNavigationState` so that thread switches
(which reconstruct the widget) do not lose previously discovered
metadata.

## Non-goals

- This change does not alter the local (non-app-server) collab code
path. That path already receives metadata via spawn events and is
unaffected.
- No new protocol messages are introduced. The change uses existing
`thread/read` and `thread/loaded/list` RPCs.
- No changes to how `AgentNavigationState` orders or cycles through
threads. The traversal logic is unchanged; only the population of
entries is extended.

## Tradeoffs

- **Extra RPCs on notification path**:
`hydrate_collab_agent_metadata_for_notification` issues a `thread/read`
for each unknown receiver thread before the notification is forwarded to
rendering. This adds latency on the notification path but only fires
once per thread (the result is cached). The alternative -- rendering
first and backfilling names later -- would cause visible flicker as
UUIDs are replaced with names.
- **Backfill fetches all loaded threads**:
`backfill_loaded_subagent_threads` fetches the full loaded-thread list
and walks the spawn tree even when the user may only care about one
subagent. This is simple and correct but O(loaded_threads) per thread
switch. For typical session sizes this is negligible; it could become a
concern for sessions with hundreds of subagents.
- **Metadata duplication**: agent nickname/role is now stored in both
`AgentNavigationState` (for picker/label) and
`ChatWidget::collab_agent_metadata` (for rendering). The two are kept in
sync through `upsert_agent_picker_thread` and `replace_chat_widget`, but
there is no compile-time enforcement of this coupling.

## Architecture

### New module: `app::loaded_threads`

Pure function `find_loaded_subagent_threads_for_primary` that takes a
flat list of `Thread` objects and a primary thread id, then walks the
`SessionSource::SubAgent` parent-child edges to collect all transitive
descendants. Returns a sorted vec of `LoadedSubagentThread` (thread_id +
nickname + role). No async, no side effects -- designed for unit
testing.

### New methods on `App`

| Method | Purpose |
|--------|---------|
| `collab_receiver_thread_ids` | Extracts `receiver_thread_ids` from
`ItemStarted` / `ItemCompleted` collab notifications |
| `hydrate_collab_agent_metadata_for_notification` | Fetches and caches
metadata for unknown receiver threads before a notification is rendered
|
| `backfill_loaded_subagent_threads` | Bulk-fetches all loaded threads
and registers descendants of the primary thread |
| `adjacent_thread_id_with_backfill` | Attempts navigation, falls back
to backfill if the cache has no adjacent entry |
| `replace_chat_widget` | Replaces the widget and re-seeds its metadata
map from `AgentNavigationState` |

### New state in `ChatWidget`

`collab_agent_metadata: HashMap<ThreadId, CollabAgentMetadata>` -- a
lookup table that rendering functions consult to attach human-readable
names to collab tool-call items. Populated externally by `App` via
`set_collab_agent_metadata`.

### New method on `AppServerSession`

`thread_loaded_list` -- thin wrapper around
`ClientRequest::ThreadLoadedList`.

## Observability

- `tracing::warn` on invalid thread ids during hydration and backfill.
- `tracing::warn` on failed `thread/read` or `thread/loaded/list` RPCs
(with thread id and error).
- No new metrics or feature flags.

## Tests

-
**`loaded_threads::tests::finds_loaded_subagent_tree_for_primary_thread`**
-- unit test for the spawn-tree walk: verifies child and grandchild are
included, unrelated threads are excluded, and metadata is carried
through.
-
**`app::tests::replace_chat_widget_reseeds_collab_agent_metadata_for_replay`**
-- integration test that creates a `ChatWidget`, replaces it via
`replace_chat_widget`, replays a collab wait notification, and asserts
the rendered history cell contains the agent name rather than a UUID.
- **Updated snapshot** `app_server_collab_wait_items_render_history` --
the existing collab wait rendering test now sets metadata before sending
notifications, so the snapshot shows `Robie [explorer]` / `Ada
[reviewer]` instead of raw thread ids.

---------

Co-authored-by: Eric Traut <etraut@openai.com>
2026-03-25 12:50:42 -06:00
evawong-oai
6566ab7e02 Clarify codex_home base for MDM path resolution (#15707)
## Summary

Add the follow up code comment Michael asked for at the MDM
`managed_config_from_mdm` - a follow up from
https://github.com/openai/codex/pull/15351.

## Validation

1. `cargo fmt --all --check`
2. `cargo test -p codex-core
managed_preferences_expand_home_directory_in_workspace_write_roots --
--nocapture`
3. `cargo test -p codex-core
write_value_succeeds_when_managed_preferences_expand_home_directory_paths
-- --nocapture`
4. `./tools/argument-comment-lint/run-prebuilt-linter.sh -p codex-core`
2026-03-25 18:40:43 +00:00
Ahmed Ibrahim
d273efc0f3 Extract codex-analytics crate (#15748)
## Summary
- move the analytics events client into codex-analytics
- update codex-core and app-server callsites to use the new crate

## Testing
- CI

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 11:08:05 -07:00
Ahmed Ibrahim
2bb1027e37 Extract codex-plugin crate (#15747)
## Summary
- extract plugin identifiers and load-outcome types into codex-plugin
- update codex-core to consume the new plugin crate

## Testing
- CI

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 11:07:31 -07:00
Ahmed Ibrahim
ad74543a6f Extract codex-utils-plugins crate (#15746)
## Summary
- extract shared plugin path and manifest helpers into
codex-utils-plugins
- update codex-core to consume the utility crate

## Testing
- CI

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 11:05:35 -07:00
Jeremy Rose
6b10e186c4 Add non-interactive resume filter option (#15339)
## Summary
- add `codex resume --include-non-interactive` to include
non-interactive sessions in the picker and `--last`
- keep current-provider and cwd filtering behavior unchanged
- replace the picker API boolean with a `SessionSourceFilter` enum to
avoid a boolean trap

## Tests
- `cargo test -p codex-cli`
- `cargo test -p codex-tui`
- `just fmt`
- `just fix -p codex-cli`
- `just fix -p codex-tui`
2026-03-25 11:05:07 -07:00
Ahmed Ibrahim
fba3c79885 Extract codex-instructions crate (#15744)
## Summary
- extract instruction fragment and user-instruction types into
codex-instructions
- update codex-core to consume the new crate

## Testing
- CI

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 10:43:49 -07:00
jif-oai
303d0190c5 feat: add multi-thread log query (#15776)
Required for multi-agent v2
2026-03-25 16:30:04 +00:00
jif-oai
14c35a16a8 chore: remove read_file handler (#15773)
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 16:27:32 +00:00
Felipe Coury
c6ffe9abab fix(tui): avoid duplicate live reasoning summaries (#15758)
## TL;DR

Fix duplicated reasoning summaries in `tui_app_server`.

<img width="1716" height="912" alt="image"
src="https://github.com/user-attachments/assets/6362f25a-ab1c-4a01-bf10-b5616c9428c2"
/>

During live turns, reasoning text is already rendered incrementally from
`ReasoningSummaryTextDelta`. When the same reasoning item later arrives
via `ItemCompleted`, we should only finalize the reasoning block, not
render the same summary again.

## What changed

- only replay rendered reasoning summaries from completed
`ThreadItem::Reasoning` items
- kept live completed reasoning items as finalize-only
- added a regression test covering the live streaming + completion path

## Why

Without this, the first reasoning summary often appears twice in the
transcript when `model_reasoning_summary = "detailed"` and
`features.tui_app_server = true`.
2026-03-25 10:14:39 -06:00
jif-oai
f190a95a4f feat: rendering library v1 (#15778)
The goal will be to replace askama
2026-03-25 16:07:04 +00:00
pakrym-oai
504aeb0e09 Use AbsolutePathBuf for cwd state (#15710)
Migrate `cwd` and related session/config state to `AbsolutePathBuf` so
downstream consumers consistently see absolute working directories.

Add test-only `.abs()` helpers for `Path`, `PathBuf`, and `TempDir`, and
update branch-local tests to use them instead of
`AbsolutePathBuf::try_from(...)`.

For the remaining TUI/app-server snapshot coverage that renders absolute
cwd values, keep the snapshots unchanged and skip the Windows-only cases
where the platform-specific absolute path layout differs.
2026-03-25 16:02:22 +00:00
jif-oai
178c3b15b4 chore: remove grep_files handler (#15775)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.

Include a link to a bug report or enhancement request.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-25 16:01:45 +00:00
Fouad Matin
32c4993c8a fix(core): default approval behavior for mcp missing annotations (#15519)
- Changed `requires_mcp_tool_approval` to apply MCP spec defaults when
annotations are missing.
- Unannotated tools now default to:
  - `readOnlyHint = false`
  - `destructiveHint = true`
  - `openWorldHint = true`
- This means unannotated MCP tools now go through approval/ARC
monitoring instead of silently bypassing it.
- Explicitly read-only tools still skip approval unless they are also
explicitly marked destructive.

**Previous behavior**
Failed open for missing annotations, which was unsafe for custom MCP
tools that omitted or forgot annotations.

---------

Co-authored-by: colby-oai <228809017+colby-oai@users.noreply.github.com>
2026-03-25 07:55:41 -07:00
jif-oai
047ea642d2 chore: tty metric (#15766) 2026-03-25 13:34:43 +00:00
xl-openai
f5dccab5cf Update plugin creator skill. (#15734)
Add support for home-local plugin + fix policy.
2026-03-25 01:55:10 -07:00
Matthew Zeng
e590fad50b [plugins] Add a flag for tool search. (#15722)
- [x] Add a flag for tool search.
2026-03-25 07:00:25 +00:00
Eric Traut
c0ffd000dd Fix stale turn steering fallback in tui_app_server (#15714)
This PR adds code to recover from a narrow app-server timing race where
a follow-up can be sent after the previous turn has already ended but
before the TUI has observed that completion.

Instead of surfacing turn/steer failed: no active turn to steer, the
client now treats that as a stale active-turn cache and falls back to
starting a fresh turn, matching the intended submit behavior more
closely. This is similar to the strategy employed by other app server
clients (notably, the IDE extension and desktop app).

This race exists because the current app-server API makes the client
choose between two separate RPCs, turn/steer and turn/start, based on
its local view of whether a turn is still active. That view is
replicated from asynchronous notifications, so it can be stale for a
brief window. The server may already have ended the turn while the
client still believes it is in progress. Since the choice is made
client-side rather than atomically on the server, tui_app_server can
occasionally send turn/steer for a turn that no longer exists.
2026-03-25 00:28:07 -06:00
viyatb-oai
95ba762620 fix: support split carveouts in windows restricted-token sandbox (#14172)
## Summary
- keep legacy Windows restricted-token sandboxing as the supported
baseline
- support the split-policy subset that restricted-token can enforce
directly today
- support full-disk read, the same writable root set as legacy
`WorkspaceWrite`, and extra read-only carveouts under those writable
roots via additional deny-write ACLs
- continue to fail closed for unsupported split-only shapes, including
explicit unreadable (`none`) carveouts, reopened writable descendants
under read-only carveouts, and writable root sets that do not match the
legacy workspace roots

## Example
Given a filesystem policy like:

```toml
":root" = "read"
":cwd" = "write"
"./docs" = "read"
```

the restricted-token backend can keep the workspace writable while
denying writes under `docs` by layering an extra deny-write carveout on
top of the legacy workspace-write roots.

A policy like:

```toml
"/workspace" = "write"
"/workspace/docs" = "read"
"/workspace/docs/tmp" = "write"
```

still fails closed, because the unelevated backend cannot reopen the
nested writable descendant safely.

## Stack
-> fix: support split carveouts in windows restricted-token sandbox
#14172
fix: support split carveouts in windows elevated sandbox #14568
2026-03-24 22:54:18 -07:00
Matthew Zeng
8c62829a2b [plugins] Flip on additional flags. (#15719)
- [x] Flip on additional flags.
2026-03-24 21:52:11 -07:00
Matthew Zeng
0bff38c54a [plugins] Flip the flags. (#15713)
- [x] Flip the `plugins` and `apps` flags.
2026-03-25 03:31:21 +00:00
Shaqayeq
fece9ce745 Fix stale quickstart integration assertion (#15677)
TL;DR: update the quickstart integration assertion to match the current
example output.

- replace the stale `Status:` expectation for
`01_quickstart_constructor` with `Server:`, `Items:`, and `Text:`
- keep the existing guard against `Server: unknown`
2026-03-24 20:12:52 -07:00
canvrno-oai
2250508c2e TUI plugin menu cleanup - hide app ID (#15708)
- Hide App ID from plugin details page.
2026-03-24 20:03:10 -07:00
Matthew Zeng
0b08d89304 [app-server] Add a method to override feature flags. (#15601)
- [x] Add a method to override feature flags globally and not just
thread level.
2026-03-25 02:27:00 +00:00
Charley Cunningham
d72fa2a209 [codex] Defer fork context injection until first turn (#15699)
## Summary
- remove the fork-startup `build_initial_context` injection
- keep the reconstructed `reference_context_item` as the fork baseline
until the first real turn
- update fork-history tests and the request snapshot, and add a
`TODO(ccunningham)` for remaining nondiffable initial-context inputs

## Why
Fork startup was appending current-session initial context immediately
after reconstructing the parent rollout, then the first real turn could
emit context updates again. That duplicated model-visible context in the
child rollout.

## Impact
Forked sessions now behave like resume for context seeding: startup
reconstructs history and preserves the prior baseline, and the first
real turn handles any current-session context emission.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-24 18:34:44 -07:00
Ahmed Ibrahim
2e03d8b4d2 Extract rollout into its own crate (#15548) 2026-03-24 18:10:53 -07:00
evawong-oai
ea3f3467e2 Expand ~ in MDM workspace write roots (#15351)
## Summary
- Reuse the existing config path resolver for the macOS MDM managed
preferences layer so `writable_roots = ["~/code"]` expands the same way
as file-backed config
- keep the change scoped to the MDM branch in `config_loader`; the
current net diff is only `config_loader/mod.rs` plus focused regression
tests in `config_loader/tests.rs` and `config/service_tests.rs`
- research note: `resolve_relative_paths_in_config_toml(...)` is already
used in several existing configuration paths, including [CLI
overrides](74fda242d3/codex-rs/core/src/config_loader/mod.rs (L152-L163)),
[file-backed managed
config](74fda242d3/codex-rs/core/src/config_loader/mod.rs (L274-L285)),
[normal config-file
loading](74fda242d3/codex-rs/core/src/config_loader/mod.rs (L311-L331)),
[project `.codex/config.toml`
loading](74fda242d3/codex-rs/core/src/config_loader/mod.rs (L863-L865)),
and [role config
loading](74fda242d3/codex-rs/core/src/agent/role.rs (L105-L109))

## Validation
- `cargo fmt --all --check`
- `cargo test -p codex-core
managed_preferences_expand_home_directory_in_workspace_write_roots --
--nocapture`
- `cargo test -p codex-core
write_value_succeeds_when_managed_preferences_expand_home_directory_paths
-- --nocapture`

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
Co-authored-by: Michael Bolin <bolinfest@gmail.com>
2026-03-24 17:55:06 -07:00
canvrno-oai
38b638d89d Add legal link to TUI /plugin details (#15692)
- Adds language and "[learn
more](https://help.openai.com/en/articles/11487775-apps-in-chatgpt)"
link to plugin details pages.
-  Message is hidden when plugin is installed

<img width="1970" height="498" alt="image"
src="https://github.com/user-attachments/assets/f14330f7-661e-4860-8538-6dc9e8bbd90a"
/>
2026-03-24 17:40:26 -07:00
canvrno-oai
05b967c79a Remove provenance filtering in $mentions for apps and skills from plugins (#15700)
- Removes provenance filtering in the mentions feature for apps and
skills that were installed as part of a plugin.
- All skills and apps for a plugin are mentionable with this change.
2026-03-24 17:40:14 -07:00
Michael Bolin
4a210faf33 fix: keep rmcp-client env vars as OsString (#15363)
## Why

This is a follow-up to #15360. That change fixed the `arg0` helper
setup, but `rmcp-client` still coerced stdio transport environment
values into UTF-8 `String`s before program resolution and process spawn.
If `PATH` or another inherited environment value contains non-UTF-8
bytes, that loses fidelity before it reaches `which` and `Command`.

## What changed

- change `create_env_for_mcp_server()` to return `HashMap<OsString,
OsString>` and read inherited values with `std::env::var_os()`
- change `TransportRecipe::Stdio.env`, `RmcpClient::new_stdio_client()`,
and `program_resolver::resolve()` to keep stdio transport env values in
`OsString` form within `rmcp-client`
- keep the `codex-core` config boundary stringly, but convert configured
stdio env values to `OsString` once when constructing the transport
- update the rmcp-client stdio test fixtures and callers to use
`OsString` env maps
- add a Unix regression test that verifies `create_env_for_mcp_server()`
preserves a non-UTF-8 `PATH`

## How to verify

- `cargo test -p codex-rmcp-client`
- `cargo test -p codex-core mcp_connection_manager`
- `just argument-comment-lint`

Targeted coverage in this change includes
`utils::tests::create_env_preserves_path_when_it_is_not_utf8`, while the
updated stdio transport path is exercised by the existing rmcp-client
tests that construct `RmcpClient::new_stdio_client()`.
2026-03-24 23:32:31 +00:00
Ruslan Nigmatullin
24c4ecaaac app-server: Return codex home in initialize response (#15689)
This allows clients to get enough information to interact with the codex
skills/configuration/etc.
2026-03-24 16:13:34 -07:00
canvrno-oai
6323f0104d Use delayed shimmer for plugin loading headers in tui and tui_app_server (#15674)
- Add a small delayed loading header for plugin list/detail loading
messages in the TUI. Keep existing text for the first 1s, then show
shimmer on the loading line.
- Apply the same behavior in both tui and tui_app_server.


https://github.com/user-attachments/assets/71dd35e4-7e3b-4e7b-867a-3c13dc395d3a
2026-03-24 16:03:40 -07:00
Ruslan Nigmatullin
301b17c2a1 app-server: add filesystem watch support (#14533)
### Summary
Add the v2 app-server filesystem watch RPCs and notifications, wire them
through the message processor, and implement connection-scoped watches
with notify-backed change delivery. This also updates the schema
fixtures, app-server documentation, and the v2 integration coverage for
watch and unwatch behavior.

This allows clients to efficiently watch for filesystem updates, e.g. to
react on branch changes.

### Testing
- exercise watch lifecycles for directory changes, atomic file
replacement, missing-file targets, and unwatch cleanup
2026-03-24 15:52:13 -07:00
Ahmed Ibrahim
062fa7a2bb Move string truncation helpers into codex-utils-string (#15572)
- move the shared byte-based middle truncation logic from `core` into
`codex-utils-string`
- keep token-specific truncation in `codex-core` so rollout can reuse
the shared helper in the next stacked PR

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-24 15:45:40 -07:00
pakrym-oai
0b619afc87 Drop sandbox_permissions from sandbox exec requests (#15665)
## Summary
- drop `sandbox_permissions` from the sandboxing `ExecOptions` and
`ExecRequest` adapter types
- remove the now-unused plumbing from shell, unified exec, JS REPL, and
apply-patch runtime call sites
- default reconstructed `ExecParams` to `SandboxPermissions::UseDefault`
where the lower-level API still requires the field

## Testing
- `just fmt`
- `just argument-comment-lint`
- `cargo test -p codex-core` (still running locally; first failures
observed in `suite::cli_stream::responses_mode_stream_cli`,
`suite::cli_stream::responses_mode_stream_cli_supports_openai_base_url_config_override`,
and
`suite::cli_stream::responses_mode_stream_cli_supports_openai_base_url_env_fallback`)
2026-03-24 15:42:45 -07:00
Matthew Zeng
b32d921cd9 [plugins] Additional gating for tool suggest and apps. (#15573)
- [x] Additional gating for tool suggest and apps.
2026-03-24 15:10:00 -07:00
canvrno-oai
4b91a7b391 Suppress plugin-install MCP OAuth URL console spam (#15666)
Switch plugin-install background MCP OAuth to a silent login path so the
raw authorization URL is no longer printed in normal success cases.
OAuth behavior is otherwise unchanged, with fallback URL output via
stderr still shown only if browser launch fails.

Before:

https://github.com/user-attachments/assets/4bf387af-afa8-4b83-bcd6-4ca6b55da8db
2026-03-24 14:46:21 -07:00
canvrno-oai
b364faf4ec Tweak /plugin menu wording (#15676)
- Updated `/plugin` UI messaging for clearer wording.
- Synced the same copy changes across `tui` and `tui_app_server`.
2026-03-24 14:44:09 -07:00
Eric Traut
c023e9d959 tui_app_server: cancel active login before Ctrl+C exit (#15673)
## Summary

Fixes slow `Ctrl+C` exit from the ChatGPT browser-login screen in
`tui_app_server`.

## Root cause

Onboarding-level `Ctrl+C` quit bypassed the auth widget's cancel path.
That let the active ChatGPT login keep running, and in-process
app-server shutdown then waited on the stale login attempt before
finishing.

## Changes

- Extract a shared `cancel_active_attempt()` path in the auth widget
- Use that path from onboarding-level `Ctrl+C` before exiting the TUI
- Add focused tests for canceling browser-login and device-code attempts
- Add app-server shutdown cleanup that explicitly drops any active login
before draining background work
2026-03-24 15:11:43 -06:00
Eric Traut
1b86377635 tui_app_server: open ChatGPT login in the local browser (#15672)
## Summary

Fixes ChatGPT login in `tui_app_server` so the local browser opens again
during in-process login flows.

## Root cause

The app-server backend intentionally starts ChatGPT login with browser
auto-open disabled, expecting the TUI client to open the returned
`auth_url`. The app-server TUI was not doing that, so the login URL was
shown in the UI but no browser window opened.

## Changes

- Add a helper that opens the returned ChatGPT login URL locally
- Call it from the main ChatGPT login flow
- Call it from the device-code fallback-to-browser path as well
- Limit auto-open to in-process app-server handles so remote sessions do
not try to open a browser against a remote localhost callback
2026-03-24 15:11:21 -06:00
Eric Traut
989e513969 tui: always restore the terminal on early exit (#15671)
## Summary

Fixes early TUI exit paths that could leave the terminal in a dirty
state and cause a stray `%` prompt marker after the app quit.

## Root cause

Both `tui` and `tui_app_server` had early returns after `tui::init()`
that did not guarantee terminal restore. When that happened, shells like
`zsh` inherited the altered terminal state.

## Changes

- Add a restore guard around `run_ratatui_app()` in both `tui` and
`tui_app_server`
- Route early exits through the guard instead of relying on scattered
manual restore calls
- Ensure terminal restore still happens on normal shutdown
2026-03-24 14:29:29 -06:00
canvrno-oai
3ba0e85edd Clean up TUI /plugins row allignment (#15669)
- Remove marketplace from left column.
- Change `Can be installed` to `Available`
- Align right-column marketplace + selected-row hint text across states.
- Changes applied to both `tui` and `tui_app_server`.
- Update related snapshots/tests.


<img width="2142" height="590" alt="image"
src="https://github.com/user-attachments/assets/6e60b783-2bea-46d4-b353-f2fd328ac4d0"
/>
2026-03-24 20:27:10 +00:00
Ahmed Ibrahim
0f957a93cd Move git utilities into a dedicated crate (#15564)
- create `codex-git-utils` and move the shared git helpers into it with
file moves preserved for diff readability
- move the `GitInfo` helpers out of `core` so stacked rollout work can
depend on the shared crate without carrying its own git info module

---------

Co-authored-by: Ahmed Ibrahim <219906144+aibrahim-oai@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-03-24 13:26:23 -07:00
Eric Traut
fc97092f75 tui_app_server: tolerate missing rate limits while logged out (#15670)
## Summary

Fixes a `tui_app_server` bootstrap failure when launching the CLI while
logged out.

## Root cause

During TUI bootstrap, `tui_app_server` fetched `account/rateLimits/read`
unconditionally and treated failures as fatal. When the user was logged
out, there was no ChatGPT account available, so that RPC failed and
aborted startup with:

```
Error: account/rateLimits/read failed during TUI bootstrap
```

## Changes

- Only fetch bootstrap rate limits when OpenAI auth is required and a
ChatGPT account is present
- Treat bootstrap rate-limit fetch failures as non-fatal and fall back
to empty snapshots
- Log the fetch failure at debug level instead of aborting startup
2026-03-24 14:01:06 -06:00
Michael Bolin
e89e5136bd fix: keep zsh-fork release assets after removing shell-tool-mcp (#15644)
## Why

`shell-tool-mcp` and the Bash fork are no longer needed, but the patched
zsh fork is still relevant for shell escalation and for the
DotSlash-backed zsh-fork integration tests.

Deleting the old `shell-tool-mcp` workflow also deleted the only
pipeline that rebuilt those patched zsh binaries. This keeps the package
removal, while preserving a small release path that can be reused
whenever `codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch`
changes.

## What changed

- removed the `shell-tool-mcp` workspace package, its npm
packaging/release jobs, the Bash test fixture, and the remaining
Bash-specific compatibility wiring
- deleted the old `.github/workflows/shell-tool-mcp.yml` and
`.github/workflows/shell-tool-mcp-ci.yml` workflows now that their
responsibilities have been replaced or removed
- kept the zsh patch under
`codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch` and updated
the `codex-rs/shell-escalation` docs/code to describe the zsh-based flow
directly
- added `.github/workflows/rust-release-zsh.yml` to build only the three
zsh binaries that `codex-rs/app-server/tests/suite/zsh` needs today:
  - `aarch64-apple-darwin` on `macos-15`
  - `x86_64-unknown-linux-musl` on `ubuntu-24.04`
  - `aarch64-unknown-linux-musl` on `ubuntu-24.04`
- extracted the shared zsh build/smoke-test/stage logic into
`.github/scripts/build-zsh-release-artifact.sh`, made that helper
directly executable, and now invoke it directly from the workflow so the
Linux and macOS jobs only keep the OS-specific setup in YAML
- wired those standalone `codex-zsh-*.tar.gz` assets into
`rust-release.yml` and added `.github/dotslash-zsh-config.json` so
releases also publish a `codex-zsh` DotSlash file
- updated the checked-in `codex-rs/app-server/tests/suite/zsh` fixture
comments to explain that new releases come from the standalone zsh
assets, while the checked-in fixture remains pinned to the latest
historical release until a newer zsh artifact is published
- tightened a couple of follow-on cleanups in
`codex-rs/shell-escalation`: the `ExecParams::command` comment now
describes the shell `-c`/`-lc` string more clearly, and the README now
points at the same `git.code.sf.net` zsh source URL that the workflow
uses

## Testing

- `cargo test -p codex-shell-escalation`
- `just argument-comment-lint`
- `bash -n .github/scripts/build-zsh-release-artifact.sh`
- attempted `cargo test -p codex-core`; unrelated existing failures
remain, but the touched `tools::runtimes::shell::unix_escalation::*`
coverage passed during that run
2026-03-24 12:56:26 -07:00
canvrno-oai
363b373979 Hide numeric prefixes on disabled TUI list rows (#15660)
- Remove numeric prefixes for disabled rows in shared list rendering.
These numbers are shortcuts, Ex: Pressing "2" selects option `#2`.
Disabled items can not be selected, so keeping numbers on these items is
misleading.
- Apply the same behavior in both tui and tui_app_server.
- Update affected snapshots for apps/plugins loading and plugin detail
rows.

_**This is a global change.**_

Before:
<img width="1680" height="488" alt="image"
src="https://github.com/user-attachments/assets/4bcf94ad-285f-48d3-a235-a85b58ee58e2"
/>

After:
<img width="1706" height="484" alt="image"
src="https://github.com/user-attachments/assets/76bb6107-a562-42fe-ae94-29440447ca77"
/>
2026-03-24 12:52:56 -07:00
Charley Cunningham
2d61357c76 Trim pre-turn context updates during rollback (#15577)
## Summary
- trim contiguous developer/contextual-user pre-turn updates when
rollback cuts back to a user turn
- add a focused history regression test for the trim behavior
- update the rollback request-boundary snapshots to show the fixed
non-duplicating context shape

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-24 12:43:53 -07:00
Celia Chen
88694e8417 chore: stop app-server auth refresh storms after permanent token failure (#15530)
built from #14256. PR description from @etraut-openai:

This PR addresses a hole in [PR
11802](https://github.com/openai/codex/pull/11802). The previous PR
assumed that app server clients would respond to token refresh failures
by presenting the user with an error ("you must log in again") and then
not making further attempts to call network endpoints using the expired
token. While they do present the user with this error, they don't
prevent further attempts to call network endpoints and can repeatedly
call `getAuthStatus(refreshToken=true)` resulting in many failed calls
to the token refresh endpoint.

There are three solutions I considered here:
1. Change the getAuthStatus app server call to return a null auth if the
caller specified "refreshToken" on input and the refresh attempt fails.
This will cause clients to immediately log out the user and return them
to the log in screen. This is a really bad user experience. It's also a
breaking change in the app server contract that could break third-party
clients.
2. Augment the getAuthStatus app server call to return an additional
field that indicates the state of "token could not be refreshed". This
is a non-breaking change to the app server API, but it requires
non-trivial changes for all clients to properly handle this new field
properly.
3. Change the getAuthStatus implementation to handle the case where a
token refresh fails by marking the AuthManager's in-memory access and
refresh tokens as "poisoned" so it they are no longer used. This is the
simplest fix that requires no client changes.

I chose option 3.

Here's Codex's explanation of this change:

When an app-server client asks `getAuthStatus(refreshToken=true)`, we
may try to refresh a stale ChatGPT access token. If that refresh fails
permanently (for example `refresh_token_reused`, expired, or revoked),
the old behavior was bad in two ways:

1. We kept the in-memory auth snapshot alive as if it were still usable.
2. Later auth checks could retry refresh again and again, creating a
storm of doomed `/oauth/token` requests and repeatedly surfacing the
same failure.

This is especially painful for app-server clients because they poll auth
status and can keep driving the refresh path without any real chance of
recovery.

This change makes permanent refresh failures terminal for the current
managed auth snapshot without changing the app-server API contract.

What changed:
- `AuthManager` now poisons the current managed auth snapshot in memory
after a permanent refresh failure, keyed to the unchanged `AuthDotJson`.
- Once poisoned, later refresh attempts for that same snapshot fail fast
locally without calling the auth service again.
- The poison is cleared automatically when auth materially changes, such
as a new login, logout, or reload of different auth state from storage.
- `getAuthStatus(includeToken=true)` now omits `authToken` after a
permanent refresh failure instead of handing out the stale cached bearer
token.

This keeps the current auth method visible to clients, avoids forcing an
immediate logout flow, and stops repeated refresh attempts for
credentials that cannot recover.

---------

Co-authored-by: Eric Traut <etraut@openai.com>
2026-03-24 12:39:58 -07:00
Celia Chen
7dc2cd2ebe chore: use access token expiration for proactive auth refresh (#15545)
Follow up to #15357 by making proactive ChatGPT auth refresh depend on
the access token's JWT expiration instead of treating `last_refresh` age
as the primary source of truth.
2026-03-24 19:34:48 +00:00
xl-openai
621862a7d1 feat: include marketplace loading error in plugin/list (#15438)
Include error.
2026-03-24 11:47:23 -07:00
jif-oai
773fbf56a4 feat: communication pattern v2 (#15647)
See internal communication
2026-03-24 18:45:49 +00:00
Ruslan Nigmatullin
d61c03ca08 app-server: Add back pressure and batching to command/exec (#15547)
* Add
`OutgoingMessageSender::send_server_notification_to_connection_and_wait`
which returns only once message is written to websocket (or failed to do
so)
* Use this mechanism to apply back pressure to stdout/stderr streams of
processes spawned by `command/exec`, to limit them to at most one
message in-memory at a time
* Use back pressure signal to also batch smaller chunks into ≈64KiB ones

This should make commands execution more robust over
high-latency/low-throughput networks
2026-03-24 11:35:51 -07:00
Ruslan Nigmatullin
daf5e584c2 core: Make FileWatcher reusable (#15093)
### Summary
Make `FileWatcher` a reusable core component which can be built upon.
Extract skills-related logic into a separate `SkillWatcher`.
Introduce a composable `ThrottledWatchReceiver` to throttle filesystem
events, coalescing affected paths among them.

### Testing
Updated existing unit tests.
2026-03-24 11:04:47 -07:00
Ahmed Ibrahim
bb7e9a8171 Increase voice space hold timeout to 1s (#15579)
Increase the space-hold delay to 1 second before voice capture starts,
and mirror the change in tui_app_server.
2026-03-24 10:47:26 -07:00
canvrno-oai
66edc347ae Pretty plugin labels, preserve plugin app provenance during MCP tool refresh (#15606)
- Prefer plugin manifest `interface.displayName` for plugin labels.
- Preserve plugin provenance when handling `list_mcp_tools` so connector
`plugin_display_names` are not clobbered.
- Add a TUI test to ensure plugin-owned app mentions are deduped
correctly.
2026-03-24 10:34:19 -07:00
jif-oai
f1658ab642 try to fix git glitch (#15650)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:29:01 +00:00
jif-oai
1ababa7016 try to fix git glitch (#15651)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:28:54 +00:00
jif-oai
85a17a70f7 try to fix git glitch (#15652)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:28:43 +00:00
jif-oai
48ba256cbd try to fix git glitch (#15653)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:28:34 +00:00
jif-oai
4cbc4894f9 try to fix git glitch (#15654)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:28:27 +00:00
jif-oai
b76630f2af try to fix git glitch (#15655)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:28:17 +00:00
jif-oai
074b06929d try to fix git glitch (#15656)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:28:08 +00:00
jif-oai
3c0c571012 try to fix git glitch (#15657)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:27:58 +00:00
jif-oai
4b8425b64b try to fix git glitch (#15658)
Empty commit on branch 	t git glitch debugging.
2026-03-24 17:27:51 +00:00
Charley Cunningham
910cf49269 [codex] Stabilize second compaction history test (#15605)
## Summary
- replace the second-compaction test fixtures with a single ordered
`/responses` sequence
- assert against the real recorded request order instead of aggregating
per-mock captures
- realign the second-summary assertion to the first post-compaction user
turn where the summary actually appears

## Root cause
`compact_resume_after_second_compaction_preserves_history` collected
requests from multiple `mount_sse_once_match` recorders. Overlapping
matchers could record the same HTTP request more than once, so the test
indexed into a duplicated synthetic list rather than the true request
stream. That made the summary assertion depend on matcher evaluation
order and platform-specific behavior.

## Impact
- makes the flaky test deterministic by removing duplicate request
capture from the assertion path
- keeps the change scoped to the test only

## Validation
- `just fmt`
- `just argument-comment-lint`
- `env -u CODEX_SANDBOX_NETWORK_DISABLED cargo test -p codex-core
compact_resume_after_second_compaction_preserves_history -- --nocapture`
- repeated the same targeted test 10 times

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-24 10:14:21 -07:00
jif-oai
b51d5f18c7 feat: disable notifier v2 and start turn on agent interaction (#15624)
Make the inter-agent communication start a turn

As part of this, we disable the v2 notifier to prevent some odd
behaviour where the agent restart working while you're talking to it for
example
2026-03-24 17:01:24 +00:00
canvrno-oai
0f90a34676 Refresh mentions list after plugin install/uninstall (#15598)
Refresh mentions list after plugin install/uninstall to that $mentions
are updated without requiring exiting/launching the client.
2026-03-24 09:36:26 -07:00
canvrno-oai
2d5a3bfe76 [Codex TUI] - Sort /plugins TUI menu by installed status first, alpha second (#15558)
Updates plugin ordering so installed plugins are listed first, with
alphabetical sorting applied within the installed and uninstalled
groups. The behavior is now consistent across both `tui` and
`tui_app_server`, and related tests/snapshots were updated.
2026-03-24 09:35:52 -07:00
dependabot[bot]
68baac7cf4 Bump vedantmgoyal9/winget-releaser from 19e706d4c9121098010096f9c495a70a7518b30f to 7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 (#14777)
Bumps
[vedantmgoyal9/winget-releaser](https://github.com/vedantmgoyal9/winget-releaser)
from 19e706d4c9121098010096f9c495a70a7518b30f to
7bd472be23763def6e16bd06cc8b1cdfab0e2fd5.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="7bd472be23"><code>7bd472b</code></a>
docs: add description to inputs (<a
href="https://redirect.github.com/vedantmgoyal9/winget-releaser/issues/335">#335</a>)</li>
<li><a
href="a43926ed82"><code>a43926e</code></a>
fix: cargo command not found in <code>ubuntu-slim</code> runner (<a
href="https://redirect.github.com/vedantmgoyal9/winget-releaser/issues/334">#334</a>)</li>
<li>See full diff in <a
href="19e706d4c9...7bd472be23">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 09:42:05 -06:00
dependabot[bot]
d7343486da chore(deps): bump pnpm/action-setup from 4 to 5 (#15484)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 4
to 5.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pnpm/action-setup/releases">pnpm/action-setup's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.0</h2>
<p>Updated the action to use Node.js 24.</p>
<h2>v4.4.0</h2>
<p>Updated the action to use Node.js 24.</p>
<h2>v4.3.0</h2>
<h2>What's Changed</h2>
<ul>
<li>docs: fix the run_install example in the Readme by <a
href="https://github.com/dreyks"><code>@​dreyks</code></a> in <a
href="https://redirect.github.com/pnpm/action-setup/pull/175">pnpm/action-setup#175</a></li>
<li>chore: remove unused <code>@types/node-fetch</code> dependency by <a
href="https://github.com/silverwind"><code>@​silverwind</code></a> in <a
href="https://redirect.github.com/pnpm/action-setup/pull/186">pnpm/action-setup#186</a></li>
<li>Clarify that package_json_file is relative to GITHUB_WORKSPACE by <a
href="https://github.com/chris-martin"><code>@​chris-martin</code></a>
in <a
href="https://redirect.github.com/pnpm/action-setup/pull/184">pnpm/action-setup#184</a></li>
<li>feat: store caching by <a
href="https://github.com/jrmajor"><code>@​jrmajor</code></a> in <a
href="https://redirect.github.com/pnpm/action-setup/pull/188">pnpm/action-setup#188</a></li>
<li>refactor: remove star imports by <a
href="https://github.com/KSXGitHub"><code>@​KSXGitHub</code></a> in <a
href="https://redirect.github.com/pnpm/action-setup/pull/196">pnpm/action-setup#196</a></li>
<li>fix(ci): exclude macos by <a
href="https://github.com/KSXGitHub"><code>@​KSXGitHub</code></a> in <a
href="https://redirect.github.com/pnpm/action-setup/pull/197">pnpm/action-setup#197</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/dreyks"><code>@​dreyks</code></a> made
their first contribution in <a
href="https://redirect.github.com/pnpm/action-setup/pull/175">pnpm/action-setup#175</a></li>
<li><a
href="https://github.com/silverwind"><code>@​silverwind</code></a> made
their first contribution in <a
href="https://redirect.github.com/pnpm/action-setup/pull/186">pnpm/action-setup#186</a></li>
<li><a
href="https://github.com/chris-martin"><code>@​chris-martin</code></a>
made their first contribution in <a
href="https://redirect.github.com/pnpm/action-setup/pull/184">pnpm/action-setup#184</a></li>
<li><a href="https://github.com/jrmajor"><code>@​jrmajor</code></a> made
their first contribution in <a
href="https://redirect.github.com/pnpm/action-setup/pull/188">pnpm/action-setup#188</a></li>
<li><a
href="https://github.com/Boosted-Bonobo"><code>@​Boosted-Bonobo</code></a>
made their first contribution in <a
href="https://redirect.github.com/pnpm/action-setup/pull/199">pnpm/action-setup#199</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/pnpm/action-setup/compare/v4.2.0...v4.3.0">https://github.com/pnpm/action-setup/compare/v4.2.0...v4.3.0</a></p>
<h2>v4.2.0</h2>
<p>When there's a <code>.npmrc</code> file at the root of the
repository, pnpm will be fetched from the registry that is specified in
that <code>.npmrc</code> file <a
href="https://redirect.github.com/pnpm/action-setup/pull/179">#179</a></p>
<h2>v4.1.0</h2>
<p>Add support for <code>package.yaml</code> <a
href="https://redirect.github.com/pnpm/action-setup/pull/156">#156</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/pnpm/action-setup/compare/v4...v5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pnpm/action-setup&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 09:40:31 -06:00
pakrym-oai
f49eb8e9d7 Extract sandbox manager and transforms into codex-sandboxing (#15603)
Extract sandbox manager
2026-03-24 08:20:57 -07:00
Eric Traut
45f68843b8 Finish moving codex exec to app-server (#15424)
This PR completes the conversion of non-interactive `codex exec` to use
app server rather than directly using core events and methods.

### Summary
- move `codex-exec` off exec-owned `AuthManager` and `ThreadManager`
state
- route exec bootstrap, resume, and auth refresh through existing
app-server paths
- replace legacy `codex/event/*` decoding in exec with typed app-server
notification handling
- update human and JSONL exec output adapters to translate existing
app-server notifications only
- clean up "app server client" layer by eliminating support for legacy
notifications; this is no longer needed
- remove exposure of `authManager` and `threadManager` from "app server
client" layer

### Testing
- `exec` has pretty extensive unit and integration tests already, and
these all pass
- In addition, I asked Codex to put together a comprehensive manual set
of tests to cover all of the `codex exec` functionality (including
command-line options), and it successfully generated and ran these tests
2026-03-24 08:51:32 -06:00
rreichel3-oai
1db6cb9789 Allow global network allowlist wildcard (#15549)
## Problem

Today `codex-network-proxy` rejects a global `*` in
`network.allowed_domains`, so there is no static way to configure a
denylist-only posture for public hosts. Users have to enumerate broad
allowlist patterns instead.

## Approach

- Make global wildcard acceptance field-specific: `allowed_domains` can
use `*`, while `denied_domains` still rejects a global wildcard.
- Keep the existing evaluation order, so explicit denies still win first
and local/private protections still apply unless separately enabled.
- Add coverage for the denylist-only behavior and update the README to
document it.

## Validation

- `just fmt`
- `cargo test -p codex-network-proxy` (full run had one unrelated flaky
telemetry test:
`network_policy::tests::emit_block_decision_audit_event_emits_non_domain_event`;
reran in isolation and it passed)
- `cargo test -p codex-network-proxy
network_policy::tests::emit_block_decision_audit_event_emits_non_domain_event
-- --exact --nocapture`
- `just fix -p codex-network-proxy`
- `just argument-comment-lint`
2026-03-24 10:43:46 -04:00
jif-oai
95e1d59939 nit: optim on list agents (#15623)
Lazy computation
2026-03-24 12:01:01 +00:00
jif-oai
38c088ba8d feat: list agents for sub-agent v2 (#15621)
Add a `list_agents` for multi-agent v2, optionally path based

This return the task and status of each agent in the matched path
2026-03-24 11:24:08 +00:00
jif-oai
567832c6fe fix: flaky test (#15614) 2026-03-24 11:01:54 +00:00
jif-oai
f9545278e2 nit: split v2 wait (#15613) 2026-03-24 09:57:19 +00:00
Dylan Hurd
79577355c1 Stabilize macOS CI test timeouts (#15581)
## Summary
- raise the shell snapshot apply_patch helper timeout to avoid macOS CI
startup races
- increase the shared MCP app-server test read timeout so slow
initialize handshakes do not fail command_exec tests spuriously

## Testing
- cargo test -p codex-core
shell_command_snapshot_still_intercepts_apply_patch
- cargo test -p codex-app-server
command_exec_tty_implies_streaming_and_reports_pty_output

Co-authored-by: Codex <noreply@openai.com>
2026-03-24 09:33:20 +00:00
canvrno-oai
c850607129 Remove filter from plugins/list result (#15580)
Show all plugin marketplaces in the /plugins popup by removing the
`openai-curated` marketplace filter, and update plugin popup
copy/tests/snapshots to match the new behavior in both TUI codepaths.
2026-03-23 23:41:01 -07:00
pakrym-oai
9deb8ce3fc Move sandbox policy transforms into codex-sandboxing (#15599)
## Summary
- move the pure sandbox policy transform helpers from `codex-core` into
`codex-sandboxing`
- move the corresponding unit tests with the extracted implementation
- update `core` and `app-server` callers to import the moved APIs
directly, without re-exports or proxy methods

## Testing
- cargo test -p codex-sandboxing
- cargo test -p codex-core sandboxing
- cargo test -p codex-app-server --lib
- just fix -p codex-sandboxing
- just fix -p codex-core
- just fix -p codex-app-server
- just fmt
- just argument-comment-lint
2026-03-23 22:22:44 -07:00
Dominik Kundel
a10960e41c move imagegen skill into system skills (#15600)
Add imagegen skill as built-in skill. Source: github.com/openai/skills
2026-03-24 05:14:33 +00:00
dhruvgupta-oai
c2410060ea [codex-cli][app-server] Update self-serve business usage limit copy in error returned (#15478)
## Summary
- update the self-serve business usage-based limit message to direct
users to their admin for additional credits
- add a focused unit test for the self_serve_business_usage_based plan
branch

Added also: 

If you are at a rate limit but you still have credits, codex cli would
tell you to switch the model. We shouldnt do this if you have credits so
fixed this.

## Test
- launched the source-built CLI and verified the updated message is
shown for the self-serve business usage-based plan

![Test
screenshot](https://raw.githubusercontent.com/openai/codex/5cc3c013ef17ac5c66dfd9395c0d3c4837602231/docs/images/self-serve-business-usage-limit.png)
2026-03-24 04:41:38 +00:00
pakrym-oai
431af0807c Move macOS sandbox builders into codex-sandboxing (#15593)
## Summary
- move macOS permission merging/intersection logic and tests from
`codex-core` into `codex-sandboxing`
- move seatbelt policy builders, permissions logic, SBPL assets, and
their tests into `codex-sandboxing`
- keep `codex-core` owning only the seatbelt spawn wrapper and switch
call sites to import the moved APIs directly

## Notes
- no re-exports added
- moved the seatbelt tests with the implementation so internal helpers
could stay private
- local verification is still finishing while this PR is open
2026-03-23 21:26:35 -07:00
pakrym-oai
2227248cd6 Extract landlock helpers into codex-sandboxing (#15592)
## Summary
- add a new `codex-sandboxing` crate for sandboxing extraction work
- move the pure Linux sandbox argv builders and their unit tests out of
`codex-core`
- keep `core::landlock` as the spawn wrapper and update direct callers
to use `codex_sandboxing::landlock`

## Testing
- `cargo test -p codex-sandboxing`
- `cargo test -p codex-core landlock`
- `cargo test -p codex-cli debug_sandbox`
- `just argument-comment-lint`

## Notes
- this is step 1 of the move plan aimed at minimizing per-PR diffs
- no re-exports or no-op proxy methods were added
2026-03-23 20:56:15 -07:00
alexsong-oai
db8bb7236d Add plugin-creator as system skill (#15554) 2026-03-23 19:08:30 -07:00
Charley Cunningham
f547b79bd0 Add fork snapshot modes (#15239)
## Summary
- add `ForkSnapshotMode` to `ThreadManager::fork_thread` so callers can
request either a committed snapshot or an interrupted snapshot
- share the model-visible `<turn_aborted>` history marker between the
live interrupt path and interrupted forks
- update the small set of direct fork callsites to pass
`ForkSnapshotMode::Committed`

Note: this enables /btw to work similarly as Esc to interrupt (hopefully
somewhat in distribution)

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 19:05:42 -07:00
Michael Bolin
84fb180eeb fix: build PATH env var using OsString instead of String (#15360) 2026-03-23 18:59:04 -07:00
jif-oai
527244910f feat: custom watcher for multi-agent v2 (#15576)
The new wait tool just returns `Wait timed out.` or `Wait completed.`.
The actual content is done through the notification watcher
2026-03-23 23:27:55 +00:00
jif-oai
0b5ba25b46 feat: custom watcher for multi-agent v2 (#15575) 2026-03-23 22:57:54 +00:00
jif-oai
4605c65308 feat: custom watcher for multi-agent v2 (#15570)
Custom watcher that sends an InterAgentCommunication on end of turn
2026-03-23 22:56:17 +00:00
Charley Cunningham
0f34b14b41 [codex] Add rollback context duplication snapshot (#15562)
## What changed
- adds a targeted snapshot test for rollback with contextual diffs in
`codex_tests.rs`
- snapshots the exact model-visible request input before the rolled-back
turn and on the follow-up request after rollback
- shows the duplicate developer and environment context pair appearing
again before the follow-up user message

## Why
Rollback currently rewinds the reference context baseline without
rewinding the live session overrides. On the next turn, the same
contextual diff is emitted again and duplicated in the request sent to
the model.

## Impact
- makes the regression visible in a canonical snapshot test
- keeps the snapshot on the shared `context_snapshot` path without
adding new formatting helpers
- gives a direct repro for future fixes to rollback/context
reconstruction

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 15:36:23 -07:00
Dylan Hurd
67c1c7c054 chore(core) Add approvals reviewer to UserTurn (#15426)
## Summary
Adds support for approvals_reviewer to `Op::UserTurn` so we can migrate
`[CodexMessageProcessor::turn_start]` to use Op::UserTurn

## Testing
- [x] Adds quick test for the new field

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 15:19:01 -07:00
jif-oai
191fd9fd16 feat: use serde to differenciate inter agent communication (#15560)
Use `serde` to encode the inter agent communication to an assistant
message and use the decode to see if this is such a message

Note: this assume serde on small pattern is fast enough
2026-03-23 22:09:55 +00:00
Andrei Eternal
73bbb07ba8 [hooks] add non-streaming (non-stdin style) shell-only PreToolUse support (#15211)
- add `PreToolUse` hook for bash-like tool execution only at first
- block shell execution before dispatch with deny-only hook behavior
- introduces common.rs matcher framework for matching when hooks are run

example run:

```
› run three parallel echo commands, and the second one should echo "[block-pre-tool-use]" as a test


• Running the three echo commands in parallel now and I’ll report the output directly.

• Running PreToolUse hook: name for demo pre tool use hook

• Running PreToolUse hook: name for demo pre tool use hook

• Running PreToolUse hook: name for demo pre tool use hook

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo "first parallel echo"
  
PreToolUse hook (blocked)
  warning: wizard-tower PreToolUse demo blocked a Bash command on purpose.
  feedback: PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue.

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo "third parallel echo"

• Ran echo "first parallel echo"
  └ first parallel echo

• Ran echo "third parallel echo"
  └ third parallel echo

• Three little waves went out in parallel.

  1. printed first parallel echo
  2. was blocked before execution because it contained the exact test string [block-pre-tool-use]
  3. printed third parallel echo

  There was also an unrelated macOS defaults warning around the successful commands, but the echoes
  themselves worked fine. If you want, I can rerun the second one with a slightly modified string so
  it passes cleanly.
```
2026-03-23 14:32:59 -07:00
jif-oai
18f1a08bc9 feat: new op type for sub-agents communication (#15556)
Add `InterAgentCommunication` for v2 agent communication
2026-03-23 21:09:00 +00:00
jif-oai
7eb9e75b86 fix: main tui (#15557) 2026-03-23 20:51:07 +00:00
Ahmed Ibrahim
7b92a90612 Unify realtime stop handling in TUI (#15529)
## Summary
- route /realtime, Ctrl+C, and deleted realtime meters through the same
realtime stop path
- keep generic transcription placeholder cleanup free of realtime
shutdown side effects

## Testing
- Ran 
- Relied on CI for verification; did not run local tests

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 13:47:33 -07:00
xl-openai
9a33e5c0a0 feat: support disable skills by name. (#15378)
Support disabling skills by name, primarily for plugin skills. We can’t
use the path, since plugin skill paths may change across versions.
2026-03-23 12:57:40 -07:00
Charley Cunningham
332edba78e Thread guardian Responses API errors into denial rationale (#15516)
## Summary
- capture the last guardian `EventMsg::Error` while waiting for review
completion
- reuse that error as the denial rationale when the review turn
completes without an assessment payload
- add a regression test for the `/responses` HTTP 400 path

## Testing
- `just fmt`
- `cargo test -p codex-core
guardian_review_surfaces_responses_api_errors_in_rejection_reason`
- `just argument-comment-lint -p codex-core`

## Notes
- `cargo test -p codex-core` still fails on the pre-existing unrelated
test
`tools::js_repl::tests::js_repl_imported_local_files_can_access_repl_globals`
in this environment (`mktemp ... Operation not permitted` while
downloading `dotslash`)

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 12:46:49 -07:00
jif-oai
450dc289c3 chore: split sub-agent v2 implementation (#15540)
Just to make things cleaner
2026-03-23 19:41:53 +00:00
canvrno-oai
b5d0a5518d Plugins TUI install/uninstall (#15342)
- Add install/uninstall actions to the TUI plugins menu
- Wire plugin install/uninstall through both TUI and `tui_app_server`
- Refresh config/plugin state after changes so the UI updates
immediately
- Add a post-install app setup flow for plugins that require additional
app auth

<img width="1567" height="300" alt="Screenshot 2026-03-20 at 4 08 44 PM"
src="https://github.com/user-attachments/assets/366bd31b-2ffd-4e80-b4a3-3a9a9c674a5f"
/>
<img width="445" height="240" alt="Screenshot 2026-03-20 at 4 08 54 PM"
src="https://github.com/user-attachments/assets/613999ab-269a-4758-ab59-7c057a1742dc"
/>
<img width="797" height="219" alt="Screenshot 2026-03-20 at 4 09 07 PM"
src="https://github.com/user-attachments/assets/b9679e60-40f5-49bb-ade0-2e40449c3fbf"
/>
<img width="499" height="235" alt="Screenshot 2026-03-20 at 4 09 24 PM"
src="https://github.com/user-attachments/assets/261ce2fe-f356-4e99-8ac9-f29ed850bc75"
/>




Note/known issue: The /plugin install flow fails in `tui_app_server`
because after a successful install it tries to trigger a
ReloadUserConfig operation, but `tui_app_server` has not yet implemented
transport for that operation, so it falls through to the generic “Not
available in app-server TUI yet” stub.
2026-03-23 12:38:39 -07:00
Celia Chen
f55f5c258f Fix: proactive auth refresh to reload guarded disk state first (#15357)
## Summary

Fix a managed ChatGPT auth bug where a stale Codex process could
proactively refresh using an old in-memory refresh token even after
another process had already rotated auth on disk.

This changes the proactive `AuthManager::auth()` path to reuse the
existing guarded `refresh_token()` flow instead of calling the refresh
endpoint directly from cached auth state.

## Original Issue

Users reported repeated `codexd` log lines like:

```text
ERROR codex_core::auth: Failed to refresh token: error sending request for url (https://auth.openai.com/oauth/token)
```

In practice this showed up most often when multiple `codexd` processes
were left running. Killing the extra processes stopped the noise, which
suggested the issue was caused by stale auth state across processes
rather than invalid user credentials.

## Diagnosis

The bug was in the proactive refresh path used by `AuthManager::auth()`:

- Process A could refresh successfully, rotate refresh token `R0` to
`R1`, and persist the updated auth state plus `last_refresh` to disk.
- Process B could keep an older auth snapshot cached in memory, still
holding `R0` and the old `last_refresh`.
- Later, when Process B called `auth()`, it checked staleness from its
cached in-memory auth instead of first reloading from disk.
- Because that cached `last_refresh` was stale, Process B would
proactively call `/oauth/token` with stale refresh token `R0`.
- On failure, `auth()` logged the refresh error but kept returning the
same stale cached auth, so repeated `auth()` calls could keep retrying
with dead state.

This differed from the existing unauthorized-recovery flow, which
already did the safer thing: guarded reload from disk first, then
refresh only if the on-disk auth was unchanged.

## What Changed

- Switched proactive refresh in `AuthManager::auth()` to:
  - do a pure staleness check on cached auth
  - call `refresh_token()` when stale
- return the original cached auth on genuine refresh failure, preserving
existing outward behavior
- Removed the direct proactive refresh-from-cached-state path
- Added regression tests covering:
  - stale cached auth with newer same-account auth already on disk
- the same scenario even when the refresh endpoint would fail if called

## Why This Fix

`refresh_token()` already contains the right cross-process safety
behavior:

- guarded reload from disk
- same-account verification
- skip-refresh when another process already changed auth

Reusing that path makes proactive refresh consistent with unauthorized
recovery and prevents stale processes from trying to refresh
already-rotated tokens.

## Testing

Test shape:

- create a fresh temp `CODEX_HOME` from `~/.codex/auth.json`
- force `last_refresh` to an old timestamp so proactive refresh is
required
- start two long-lived helper processes against the same auth file
- start `B` first so it caches stale auth and sleeps
- start `A` second so it refreshes first
- point both at a local mock `/oauth/token` server
- inspect whether `B` makes a second refresh request with the stale
in-memory token, or reloads the rotated token from disk

### Before the fix

The repro showed the bug clearly: the mock server saw two refreshes with
the same stale token, `A` rotated to a new token, and `B` still returned
the stale token instead of reloading from disk.

```text
POST /oauth/token refresh_token=rt_j6s0...
POST /oauth/token refresh_token=rt_j6s0...

B:cached_before=rt_j6s0...
B:cached_after=rt_j6s0...
B:returned=rt_j6s0...

A:cached_before=rt_j6s0...
A:cached_after=rotated-refresh-token-logged-run-v2
A:returned=rotated-refresh-token-logged-run-v2
```

### After the fix

After the fix, the mock server saw only one refresh request. `A`
refreshed once, and `B` started with the stale token but reloaded and
returned the rotated token.

```text
POST /oauth/token refresh_token=rt_j6s0...

B:cached_before=rt_j6s0...
B:cached_after=rotated-refresh-token-fix-branch
B:returned=rotated-refresh-token-fix-branch

A:cached_before=rt_j6s0...
A:cached_after=rotated-refresh-token-fix-branch
A:returned=rotated-refresh-token-fix-branch
```

This shows the new behavior: `A` refreshes once, then `B` reuses the
updated auth from disk instead of making a second refresh request with
the stale token.
2026-03-23 12:07:59 -07:00
jif-oai
37ac0c093c feat: structured multi-agent output (#15515)
Send input now sends messages as assistant message and with this format:

```
author: /root/worker_a
recipient: /root/worker_a/tester
other_recipients: []
Content: bla bla bla. Actual content. Only text for now
```
2026-03-23 18:53:54 +00:00
Charley Cunningham
e838645fa2 tui: queue follow-ups during manual /compact (#15259)
## Summary
- queue input after the user submits `/compact` until that manual
compact turn ends
- mirror the same behavior in the app-server TUI
- add regressions for input queued before compact starts and while it is
running

Co-authored-by: Codex <noreply@openai.com>
2026-03-23 10:19:44 -07:00
canvrno-oai
54801634e1 Label plugins as plugins, and hide skills/apps for given plugin (#15279)
- Duplicate app mentions are now suppressed when they’re plugin-backed
with the same display name.
- Remaining connector mentions now label category as [Plugin] when
plugin metadata is present, otherwise [App].
- Mention result lists are now capped to 8 rows after filtering.
- Updates both tui and tui_app_server with the same changes.
2026-03-23 10:10:17 -07:00
jif-oai
2887f16cb9 fix: cargo deny (#15520) 2026-03-23 16:48:54 +00:00
Michael Bolin
d1088158b8 fix: fall back to vendored bubblewrap when system bwrap lacks --argv0 (#15338)
## Why

Fixes [#15283](https://github.com/openai/codex/issues/15283), where
sandboxed tool calls fail on older distro `bubblewrap` builds because
`/usr/bin/bwrap` does not understand `--argv0`. The upstream [bubblewrap
v0.9.0 release
notes](https://github.com/containers/bubblewrap/releases/tag/v0.9.0)
explicitly call out `Add --argv0`. Flipping `use_legacy_landlock`
globally works around that compatibility bug, but it also weakens the
default Linux sandbox and breaks proxy-routed and split-policy cases
called out in review.

The follow-up Linux CI failure was in the new launcher test rather than
the launcher logic: the fake `bwrap` helper stayed open for writing, so
Linux would not exec it. This update also closes the user-visibility gap
from review by surfacing the same startup warning when `/usr/bin/bwrap`
is present but too old for `--argv0`, not only when it is missing.

## What Changed

- keep `use_legacy_landlock` default-disabled
- teach `codex-rs/linux-sandbox/src/launcher.rs` to fall back to the
vendored bubblewrap build when `/usr/bin/bwrap` does not advertise
`--argv0` support
- add launcher tests for supported, unsupported, and missing system
`bwrap`
- write the fake `bwrap` test helper to a closed temp path so the
supported-path launcher test works on Linux too
- extend the startup warning path so Codex warns when `/usr/bin/bwrap`
is missing or too old to support `--argv0`
- mirror the warning/fallback wording across
`codex-rs/linux-sandbox/README.md` and `codex-rs/core/README.md`,
including that the fallback is the vendored bubblewrap compiled into the
binary
- cite the upstream `bubblewrap` release that introduced `--argv0`

## Verification

- `bazel test --config=remote --platforms=//:rbe
//codex-rs/linux-sandbox:linux-sandbox-unit-tests
--test_filter=launcher::tests::prefers_system_bwrap_when_help_lists_argv0
--test_output=errors`
- `cargo test -p codex-core system_bwrap_warning`
- `cargo check -p codex-exec -p codex-tui -p codex-tui-app-server -p
codex-app-server`
- `just argument-comment-lint`
2026-03-23 09:46:51 -07:00
jif-oai
d807d44ae7 nit: guard -> registry (#15317) 2026-03-23 10:02:11 +00:00
Charley Cunningham
5e3793def2 Use Shift+Left to edit queued messages in tmux (#15480)
## Summary
- use Shift+Left to edit the most recent queued message when running
under tmux
- mirror the same binding change in the app-server TUI
- add tmux-specific tests and snapshot coverage for the rendered
queued-message hint

## Testing
- just fmt
- cargo test -p codex-tui
- cargo test -p codex-tui-app-server
- just argument-comment-lint -p codex-tui -p codex-tui-app-server

Co-authored-by: Codex <noreply@openai.com>
2026-03-22 21:19:31 -07:00
Charley Cunningham
85065ea1b8 core: snapshot fork startup context injection (#15443)
## Summary
- add a snapshot-style core test for fork startup context injection
followed by first-turn diff injection
- capture the current duplicated startup-plus-turn context behavior
without changing runtime logic

## Testing
- not run locally; relying on CI
- just fmt

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-22 18:24:14 -07:00
Charley Cunningham
e830000e41 Remove smart_approvals alias migration (#15464)
Remove the legacy `smart_approvals` config migration from core config
loading.

This change:
- stops rewriting `smart_approvals` into `guardian_approval`
- stops backfilling `approvals_reviewer = "guardian_subagent"`
- replaces the migration tests with regression coverage that asserts the
deprecated key is ignored in root and profile scopes

Verification:
- `just fmt`
- `cargo test -p codex-core smart_approvals_alias_is_ignored`
- `cargo test -p codex-core approvals_reviewer_`
- `just argument-comment-lint`

Notes:
- `cargo test -p codex-core` still hits an unrelated existing failure in
`tools::js_repl::tests::js_repl_imported_local_files_can_access_repl_globals`;
the JS REPL kernel exits after `mktemp` fails under the current
environment.

Enhancement request: requested cleanup to delete the `smart_approvals`
alias migration; no public issue link is available.

Co-authored-by: Codex <noreply@openai.com>
2026-03-22 17:10:42 -07:00
Dylan Hurd
31728dd460 chore(exec_policy) ExecPolicyRequirementScenario tests (#15415)
## Summary
Consolidate exec_policy_tests on `ExecApprovalRequirementScenario` for
consistency.

## Testing
- [x] These are tests
2026-03-22 08:07:43 -07:00
Matthew Zeng
19702e190e [apps] Improve app tools loading for TUI. (#15376)
- [x] Remove the app tools copy in TUI and reference the core tools
instead, this reduces tools/list calls from 4 to just 1.
2026-03-22 00:17:48 -07:00
Eric Traut
cf0223887f Remove legacy auth and notification handling from tui_app_server (#15414)
## Summary
- remove `tui_app_server` handling for legacy app-server notifications
- drop the local ChatGPT auth refresh request path from `tui_app_server`
- remove the now-unused refresh response helper from local auth loading

Split out of #15106 so the `tui_app_server` cleanup can land separately
from the larger `codex-exec` app-server migration.
2026-03-21 15:06:10 -06:00
Channing Conger
c23566b3af Add JIT entitlement for macosx (#15409)
Without this entitlement, hardened mac os release binaries are unable to
allocate the executable memory for the JIT compiled JS.

Tested with local signing.  Without entitlement I reproduce the error:
```
#
# Fatal process out of memory: Failed to reserve virtual memory for CodeRange
#
==== C stack trace ===============================

    0   codex                               0x00000001075d1acc codex + 85760716
    1   codex                               0x00000001075d6a64 codex + 85781092
    2   codex                               0x00000001075c7100 codex + 85717248
    3   codex                               0x0000000107637394 codex + 86176660
    4   codex                               0x0000000107823cfc codex + 88194300
    5   codex                               0x000000010777c438 codex + 87508024
    6   codex                               0x000000010777d130 codex + 87511344
    7   codex                               0x0000000107c87a54 codex + 92797524
    8   codex                               0x0000000107641188 codex + 86217096
    9   codex                               0x00000001076412d8 codex + 86217432
    10  codex                               0x0000000107553908 codex + 85244168
    11  codex                               0x000000010465f124 codex + 36008228
    12  codex                               0x000000010466a0d0 codex + 36053200
    13  codex                               0x000000010466ce78 codex + 36064888
    14  codex                               0x000000010734edb0 codex + 83127728
    15  libsystem_pthread.dylib             0x00000001810d3c08 _pthread_start + 136
    16  libsystem_pthread.dylib             0x00000001810ceba8 thread_start + 8
zsh: trace trap  target/release/codex exec --enable code_mode_only --enable code_mode --
```

With the entitlement the exec succeeds.
2026-03-21 13:43:14 -07:00
Eric Traut
b0236501e2 Remove legacy app-server notification handling from tui_app_server (#15390)
As part of moving the TUI onto the app server, we added some temporary
handling of some legacy events. We've confirmed that these do not need
to be supported, so this PR removes this support from the
tui_app_server, allowing for additional simplifications in follow-on
PRs. These events are needed only for very old rollouts. None of the
other app server clients (IDE extension or app) support these either.

## Summary
- stop translating legacy `codex/event/*` notifications inside
`tui_app_server`
- remove the TUI-side legacy warning and rollback buffering/replay paths
that were only fed by those notifications
- keep the lower-level app-server and app-server-client legacy event
plumbing intact so PR #15106 can rebase on top and handle the remaining
exec/lower-layer migration separately
2026-03-21 12:29:33 -06:00
Dylan Hurd
0d9bb8ea58 chore(context) Include guardian approval context (#15366)
## Summary
Include the guardian context in the developer message for approvals

## Testing
- [x] Updated unit tests
2026-03-21 16:31:22 +00:00
Matthew Zeng
06e06ab173 [plugins] Fix plugin explicit mention context management. (#15372)
- [x] Fix plugin explicit mention context management.
2026-03-21 00:29:29 -07:00
Channing Conger
e4eedd6170 Code mode on v8 (#15276)
Moves Code Mode to a new crate with no dependencies on codex. This
create encodes the code mode semantics that we want for lifetime,
mounting, tool calling.

The model-facing surface is mostly unchanged. `exec` still runs raw
JavaScript, `wait` still resumes or terminates a `cell_id`, nested tools
are still available through `tools.*`, and helpers like `text`, `image`,
`store`, `load`, `notify`, `yield_control`, and `exit` still exist.

The major change is underneath that surface:

- Old code mode was an external Node runtime.
- New code mode is an in-process V8 runtime embedded directly in Rust.
- Old code mode managed cells inside a long-lived Node runner process.
- New code mode manages cells in Rust, with one V8 runtime thread per
active `exec`.
- Old code mode used JSON protocol messages over child stdin/stdout plus
Node worker-thread messages.
- New code mode uses Rust channels and direct V8 callbacks/events.

This PR also fixes the two migration regressions that fell out of that
substrate change:

- `wait { terminate: true }` now waits for the V8 runtime to actually
stop before reporting termination.
- synchronous top-level `exit()` now succeeds again instead of surfacing
as a script error.

---

- `core/src/tools/code_mode/*` is now mostly an adapter layer for the
public `exec` / `wait` tools.
- `code-mode/src/service.rs` owns cell sessions and async control flow
in Rust.
- `code-mode/src/runtime/*.rs` owns the embedded V8 isolate and
JavaScript execution.
- each `exec` spawns a dedicated runtime thread plus a Rust
session-control task.
- helper globals are installed directly into the V8 context instead of
being injected through a source prelude.
- helper modules like `tools.js` and `@openai/code_mode` are synthesized
through V8 module resolution callbacks in Rust.

---

Also added a benchmark for showing the speed of init and use of a code
mode env:
```
$ cargo bench -p codex-code-mode --bench exec_overhead -- --samples 30 --warm-iterations 25 --tool-counts 0,32,128
Finished [`bench` profile [optimized]](https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles) target(s) in 0.18s
     Running benches/exec_overhead.rs (target/release/deps/exec_overhead-008c440d800545ae)
exec_overhead: samples=30, warm_iterations=25, tool_counts=[0, 32, 128]
scenario       tools samples    warmups      iters      mean/exec       p95/exec       rssΔ p50       rssΔ max
cold_exec          0      30          0          1         1.13ms         1.20ms        8.05MiB        8.06MiB
warm_exec          0      30          1         25       473.43us       512.49us      912.00KiB        1.33MiB
cold_exec         32      30          0          1         1.03ms         1.15ms        8.08MiB        8.11MiB
warm_exec         32      30          1         25       509.73us       545.76us      960.00KiB        1.30MiB
cold_exec        128      30          0          1         1.14ms         1.19ms        8.30MiB        8.34MiB
warm_exec        128      30          1         25       575.08us       591.03us      736.00KiB      864.00KiB
memory uses a fresh-process max RSS delta for each scenario
```

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-20 23:36:58 -07:00
alexsong-oai
ec32866c37 Pass platform param to featured plugins (#15348) 2026-03-21 01:42:40 +00:00
Dylan Hurd
60c59a7799 fix(core) disable command_might_be_dangerous when unsandboxed (#15036)
## Summary
If we are in a mode that is already explicitly un-sandboxed, then
`ApprovalPolicy::Never` should not block dangerous commands.

## Testing
- [x] Existing unit test covers old behavior
- [x] Added a unit test for this new case
2026-03-21 01:28:25 +00:00
Dylan Hurd
7754dd1b89 chore(core) update prefix_rule guidance (#15231)
## Summary
Small tweaks to the prefix_rule guidance.

## Testing
- [x] in progress
2026-03-20 15:57:06 -07:00
Celia Chen
9eef2e91fc fix: allow restricted filesystem profiles to read helper executables (#15114)
## Summary

This PR fixes restricted filesystem permission profiles so Codex's
runtime-managed helper executables remain readable without requiring
explicit user configuration.

- add implicit readable roots for the configured `zsh` helper path and
the main execve wrapper
- allowlist the shared `$CODEX_HOME/tmp/arg0` root when the execve
wrapper lives there, so session-specific helper paths keep working
- dedupe injected paths and avoid adding duplicate read entries to the
sandbox policy
- add regression coverage for restricted read mode with helper
executable overrides

## Testing 
before this change: got this error when executing a shell command via
zsh fork:
```
"sandbox error: sandbox denied exec error, exit code: 127, stdout: , stderr: /etc/zprofile:11: operation not permitted: /usr/libexec/path_helper\nzsh:1: operation not permitted: .codex/skills/proxy-a/scripts/fetch_example.sh\n"
```

saw this change went away after this change, meaning the readable roots
and injected correctly.
2026-03-20 15:51:06 -07:00
canvrno-oai
10a936d127 Gate tui /plugins menu behind flag (#15285)
Gate /plugins menu behind `--enable plugins` flag
2026-03-20 15:49:04 -07:00
Ahmed Ibrahim
3431f01776 Add realtime transcript notification in v2 (#15344)
- emit a typed `thread/realtime/transcriptUpdated` notification from
live realtime transcript deltas
- expose that notification as flat `threadId`, `role`, and `text` fields
instead of a nested transcript array
- continue forwarding raw `handoff_request` items on
`thread/realtime/itemAdded`, including the accumulated
`active_transcript`
- update app-server docs, tests, and generated protocol schema artifacts
to match the delta-based payloads

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-20 15:30:48 -07:00
Dylan Hurd
ea8b07e680 chore(core) Remove Feature::PowershellUtf8 (#15128)
## Summary
This feature has been enabled for powershell for a while now, let's get
rid of the logic

## Testing
- [x] Unit tests
2026-03-20 22:03:31 +00:00
Matthew Zeng
dd88ed767b [apps] Use ARC for yolo mode. (#15273)
- [x] Use ARC for yolo mode.
2026-03-20 21:13:20 +00:00
Channing Conger
1350477150 Add v8-poc consumer of our new built v8 (#15203)
This adds a dummy v8-poc project that in Cargo links against our
prebuilt binaries and the ones provided by rusty_v8 for non musl
platforms. This demonstrates that we can successfully link and use v8 on
all platforms that we want to target.

In bazel things are slightly more complicated. Since the libraries as
published have libc++ linked in already we end up with a lot of double
linked symbols if we try to use them in bazel land. Instead we fall back
to building rusty_v8 and v8 from source (cached of course) on the
platforms we ship to.

There is likely some compatibility drift in the windows bazel builder
that we'll need to reconcile before we can re-enable them. I'm happy to
be on the hook to unwind that.
2026-03-20 12:08:25 -07:00
Channing Conger
a941d8439d Bump aws-lc-rs (#15337)
Bump our dep.

RUSTSEC-2026-0048
Advisory: https://rustsec.org/advisories/RUSTSEC-2026-0048
2026-03-20 18:59:13 +00:00
Shaqayeq
9e31aeadce Pin Python SDK app-server stdio to UTF-8 on Windows (#15244)
## TL;DR
Pin the Python app-server SDK subprocess pipes to UTF-8 so Windows users
on non-UTF-8 locales do not hit `UnicodeDecodeError` when the `codex`
child emits UTF-8 text.

- add `encoding="utf-8"` to the `subprocess.Popen(...)` call in
`AppServerClient.start()`
- add a focused regression test that asserts the client launches the
subprocess with UTF-8 text I/O
- validates with `python -m pytest
sdk/python/tests/test_client_rpc_methods.py
sdk/python/tests/test_client_process_launch.py
sdk/python/tests/test_public_api_runtime_behavior.py`

Fixes #14311.
2026-03-20 18:26:24 +00:00
jif-oai
79ad7b247b feat: change multi-agent to use path-like system instead of uuids (#15313)
This PR add an URI-based system to reference agents within a tree. This
comes from a sync between research and engineering.

The main agent (the one manually spawned by a user) is always called
`/root`. Any sub-agent spawned by it will be `/root/agent_1` for example
where `agent_1` is chosen by the model.

Any agent can contact any agents using the path.

Paths can be used either in absolute or relative to the calling agents

Resume is not supported for now on this new path
2026-03-20 18:23:48 +00:00
pakrym-oai
4ddde54c19 Add remote test skill (#15324)
Teach codex to run remote tests.
2026-03-20 10:37:57 -07:00
jif-oai
b9fa08ec61 try to fix bazel (#15328)
Fix Bazel macOS CI failures caused by the llvm module's pinned macOS SDK
URL returning 403 Forbidden from Apple's CDN.

Bump llvm to 0.6.8, switch to the new osx.from_archive(...) /
osx.frameworks(...) API, and refresh MODULE.bazel.lock so Bazel uses the
updated SDK archive configuration.
2026-03-20 10:18:19 -07:00
Eric Traut
4f28b64abc Add temporary app-server originator fallback for codex-tui (#15218)
## Summary
- make app-server treat `clientInfo.name == "codex-tui"` as a legacy
compatibility case
- fall back to `DEFAULT_ORIGINATOR` instead of sending `codex-tui` as
the originator header
- add a TODO noting this is a temporary workaround that should be
removed later

## Testing
- Not run (not requested)
2026-03-20 10:51:21 -06:00
pakrym-oai
ba85a58039 Add remote env CI matrix and integration test (#14869)
`CODEX_TEST_REMOTE_ENV` will make `test_codex` start the executor
"remotely" (inside a docker container) turning any integration test into
remote test.
2026-03-20 08:02:50 -07:00
xl-openai
e5f4d1fef5 feat: prefer git for curated plugin sync (#15275)
start with git clone, fallback to http.
2026-03-20 00:06:24 -07:00
Won Park
461ba012fc Feat/restore image generation history (#15223)
Restore image generation items in resumed thread history
2026-03-19 22:57:16 -07:00
Charley Cunningham
b3a4da84da Add guardian follow-up reminder (#15262)
## Summary
- add a short guardian follow-up developer reminder before reused
reviews
- cache prior-review state on the guardian session instead of rescanning
full history on each request
- update guardian follow-up coverage and snapshot expectations

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 22:35:52 -07:00
xl-openai
b1570d6c23 feat: Add One-Time Startup Remote Plugin Sync (#15264)
For early users who have already enabled apps, we should enable plugins
as part of the initial setup.
2026-03-20 05:01:39 +00:00
Andrei Eternal
cc192763e1 Disable hooks on windows for now (#15252)
We'll verify a bit later that all of this works correctly and re-enable
2026-03-19 21:31:56 -07:00
canvrno-oai
f7201e5a9f Initial plugins TUI menu - list and read only. tui + tui_app_server (#15215)
### Preliminary /plugins TUI menu
- Adds a preliminary /plugins menu flow in both tui and tui_app_server.
- Fetches plugin list data asynchronously and shows loading/error/cached
states.
  - Limits this first pass to the curated ChatGPT marketplace.
  - Shows available plugins with installed/status metadata.
- Supports in-menu search over plugin display name, plugin id, plugin
name, and marketplace label.
- Opens a plugin detail view on selection, including summaries for
Skills, Apps, and MCP Servers, with back navigation.

### Testing
  - Launch codex-cli with plugins enabled (`--enable plugins`).
  - Run /plugins and verify:
      - loading state appears first
      - plugin list is shown
      - search filters results
- selecting a plugin opens detail view, with a list of
skills/connectors/MCP servers for the plugin
      - back action returns to the list.
- Verify disabled behavior by running /plugins without plugins enabled
(shows “Plugins are disabled” message).
- Launch with `--enable tui_app_server` (and plugins enabled) and repeat
the same /plugins flow; behavior should match.
2026-03-19 21:28:33 -07:00
Michael Bolin
fa2a2f0be9 Use released DotSlash package for argument-comment lint (#15199)
## Why
The argument-comment lint now has a packaged DotSlash artifact from
[#15198](https://github.com/openai/codex/pull/15198), so the normal repo
lint path should use that released payload instead of rebuilding the
lint from source every time.

That keeps `just clippy` and CI aligned with the shipped artifact while
preserving a separate source-build path for people actively hacking on
the lint crate.

The current alpha package also exposed two integration wrinkles that the
repo-side prebuilt wrapper needs to smooth over:
- the bundled Dylint library filename includes the host triple, for
example `@nightly-2025-09-18-aarch64-apple-darwin`, and Dylint derives
`RUSTUP_TOOLCHAIN` from that filename
- on Windows, Dylint's driver path also expects `RUSTUP_HOME` to be
present in the environment

Without those adjustments, the prebuilt CI jobs fail during `cargo
metadata` or driver setup. This change makes the checked-in prebuilt
wrapper normalize the packaged library name to the plain
`nightly-2025-09-18` channel before invoking `cargo-dylint`, and it
teaches both the wrapper and the packaged runner source to infer
`RUSTUP_HOME` from `rustup show home` when the environment does not
already provide it.

After the prebuilt Windows lint job started running successfully, it
also surfaced a handful of existing anonymous literal callsites in
`windows-sandbox-rs`. This PR now annotates those callsites so the new
cross-platform lint job is green on the current tree.

## What Changed
- checked in the current
`tools/argument-comment-lint/argument-comment-lint` DotSlash manifest
- kept `tools/argument-comment-lint/run.sh` as the source-build wrapper
for lint development
- added `tools/argument-comment-lint/run-prebuilt-linter.sh` as the
normal enforcement path, using the checked-in DotSlash package and
bundled `cargo-dylint`
- updated `just clippy` and `just argument-comment-lint` to use the
prebuilt wrapper
- split `.github/workflows/rust-ci.yml` so source-package checks live in
a dedicated `argument_comment_lint_package` job, while the released lint
runs in an `argument_comment_lint_prebuilt` matrix on Linux, macOS, and
Windows
- kept the pinned `nightly-2025-09-18` toolchain install in the prebuilt
CI matrix, since the prebuilt package still relies on rustup-provided
toolchain components
- updated `tools/argument-comment-lint/run-prebuilt-linter.sh` to
normalize host-qualified nightly library filenames, keep the `rustup`
shim directory ahead of direct toolchain `cargo` binaries, and export
`RUSTUP_HOME` when needed for Windows Dylint driver setup
- updated `tools/argument-comment-lint/src/bin/argument-comment-lint.rs`
so future published DotSlash artifacts apply the same nightly-filename
normalization and `RUSTUP_HOME` inference internally
- fixed the remaining Windows lint violations in
`codex-rs/windows-sandbox-rs` by adding the required `/*param*/`
comments at the reported callsites
- documented the checked-in DotSlash file, wrapper split, archive
layout, nightly prerequisite, and Windows `RUSTUP_HOME` requirement in
`tools/argument-comment-lint/README.md`
2026-03-20 03:19:22 +00:00
starr-openai
96a86710c3 Split exec process into local and remote implementations (#15233)
## Summary
- match the exec-process structure to filesystem PR #15232
- expose `ExecProcess` on `Environment`
- make `LocalProcess` the real implementation and `RemoteProcess` a thin
network proxy over `ExecServerClient`
- make `ProcessHandler` a thin RPC adapter delegating to `LocalProcess`
- add a shared local/remote process test

## Validation
- `just fmt`
- `CARGO_TARGET_DIR=~/.cache/cargo-target/codex cargo test -p
codex-exec-server`
- `just fix -p codex-exec-server`

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-20 03:13:08 +00:00
Ahmed Ibrahim
2e22885e79 Split features into codex-features crate (#15253)
- Split the feature system into a new `codex-features` crate.
- Cut `codex-core` and workspace consumers over to the new config and
warning APIs.

Co-authored-by: Ahmed Ibrahim <219906144+aibrahim-oai@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-03-19 20:12:07 -07:00
xl-openai
35f8b87a5b fix: Distinguish missing and empty plugin products (#15263)
Treat [] as no product allowed, empty as all products allowed.
2026-03-19 20:02:40 -07:00
Michael Bolin
a3e59e9e85 core: add a full-buffer exec capture policy (#15254) 2026-03-20 02:38:12 +00:00
Matthew Zeng
0a344e4fab [plugins] Install MCPs when calling plugin/install (#15195)
- [x] Auth MCPs when installing plugins.
2026-03-19 19:36:58 -07:00
Ahmed Ibrahim
2aa4873802 Move auth code into login crate (#15150)
- Move the auth implementation and token data into codex-login.
- Keep codex-core re-exporting that surface from codex-login for
existing callers.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 18:58:17 -07:00
Channing Conger
ded7854f09 V8 Bazel Build (#15021)
Alternative approach, we use rusty_v8 for all platforms that its
predefined, but lets build from source a musl v8 version with bazel for
x86 and aarch64 only. We would need to release this on github and then
use the release.
2026-03-19 18:05:23 -07:00
pakrym-oai
403b397e4e Refactor ExecServer filesystem split between local and remote (#15232)
For each feature we have:
1. Trait exposed on environment
2. **Local Implementation** of the trait
3. Remote implementation that uses the client to proxy via network
4. Handler implementation that handles PRC requests and calls into
**Local Implementation**
2026-03-19 17:08:04 -07:00
Won Park
6b8175c734 changed save directory to codex_home (#15222)
saving image gen default save directory to
codex_home/imagegen/thread_id/
2026-03-19 15:16:26 -07:00
Owen Lin
9e695fe830 feat(app-server): add mcpServer/startupStatus/updated notification (#15220)
Exposes the legacy `codex/event/mcp_startup_update` event as an API v2
notification.

The legacy event has this shape:
```
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct McpStartupUpdateEvent {
    /// Server name being started.
    pub server: String,
    /// Current startup status.
    pub status: McpStartupStatus,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case", tag = "state")]
#[ts(rename_all = "snake_case", tag = "state")]
pub enum McpStartupStatus {
    Starting,
    Ready,
    Failed { error: String },
    Cancelled,
}
```
2026-03-19 15:09:59 -07:00
nicholasclark-openai
2bee37fe69 Plumb MCP turn metadata through _meta (#15190)
## Summary

Some background. We're looking to instrument GA turns end to end. Right
now a big gap is grouping mcp tool calls with their codex sessions. We
send session id and turn id headers to the responses call but not the
mcp/wham calls.

Ideally we could pass the args as headers like with responses, but given
the setup of the rmcp client, we can't send as headers without either
changing the rmcp package upstream to allow per request headers or
introducing a mutex which break concurrency. An earlier attempt made the
assumption that we had 1 client per thread, which allowed us to set
headers at the start of a turn. @pakrym mentioned that this assumption
might break in the near future.

So the solution now is to package the turn metadata/session id into the
_meta field in the post body and pull out in codex-backend.

- send turn metadata to MCP servers via `tools/call` `_meta` instead of
assuming per-thread request headers on shared clients
- preserve the existing `_codex_apps` metadata while adding
`x-codex-turn-metadata` for all MCP tool calls
- extend tests to cover both custom MCP servers and the codex apps
search flow

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 22:05:13 +00:00
xl-openai
2254ec4f30 feat: expose needs_auth for plugin/read. (#15217)
So UI can render it properly.
2026-03-19 15:02:45 -07:00
Won Park
27977d6716 adding full imagepath to tui (#15154)
adding full path to TUI so image is open-able in the TUI after being
generated. LImited to VSCode Terminal for now.
2026-03-19 21:29:22 +00:00
iceweasel-oai
69750a0b5a add specific tool guidance for Windows destructive commands (#15207)
updated Windows shell/unified_exec tool descriptions:

`exec_command`
```text
Runs a command in a PTY, returning output or a session ID for ongoing interaction.

Windows safety rules:
- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.
- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked.
```

`shell`
```text
Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].

Examples of valid command strings:

- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
- running an inline Python script: ["powershell.exe", "-Command", "@'\nprint('Hello, world!')\n'@ | python -"]

Windows safety rules:
- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.
- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked.
```

`shell_command`
```text
Runs a Powershell command (Windows) and returns its output.

Examples of valid command strings:

- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\nprint('Hello, world!')\n'@ | python -"

Windows safety rules:
- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.
- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked.
```
2026-03-19 21:09:34 +00:00
Ahmed Ibrahim
7eb19e5319 Move terminal module to terminal-detection crate (#15216)
- Move core/src/terminal.rs and its tests into a standalone
terminal-detection workspace crate.
- Update direct consumers to depend on codex-terminal-detection and
import terminal APIs directly.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 14:08:04 -07:00
Owen Lin
668330acc1 feat(tracing): tag app-server turn spans with turn_id (#15206)
So we can find and filter spans by `turn.id`.

We do this for the `turn/start`, `turn/steer`, and `turn/interrupt`
APIs.
2026-03-19 13:07:19 -07:00
Yaroslav Volovich
60cd0cf75e feat(tui): add /title terminal title configuration (#12334)
## Problem

When multiple Codex sessions are open at once, terminal tabs and windows
are hard to distinguish from each other. The existing status line only
helps once the TUI is already focused, so it does not solve the "which
tab is this?" problem.

This PR adds a first-class `/title` command so the terminal window or
tab title can carry a short, configurable summary of the current
session.

## Screenshot

<img width="849" height="320" alt="image"
src="https://github.com/user-attachments/assets/8b112927-7890-45ed-bb1e-adf2f584663d"
/>

## Mental model

`/statusline` and `/title` are separate status surfaces with different
constraints. The status line is an in-app footer that can be denser and
more detailed. The terminal title is external terminal metadata, so it
needs short, stable segments that still make multiple sessions easy to
tell apart.

The `/title` configuration is an ordered list of compact items. By
default it renders `spinner,project`, so active sessions show
lightweight progress first while idle sessions still stay easy to
disambiguate. Each configured item is omitted when its value is not
currently available rather than forcing a placeholder.

## Non-goals

This does not merge `/title` into `/statusline`, and it does not add an
arbitrary free-form title string. The feature is intentionally limited
to a small set of structured items so the title stays short and
reviewable.

This also does not attempt to restore whatever title the terminal or
shell had before Codex started. When Codex clears the title, it clears
the title Codex last wrote.

## Tradeoffs

A separate `/title` command adds some conceptual overlap with
`/statusline`, but it keeps title-specific constraints explicit instead
of forcing the status line model to cover two different surfaces.

Title refresh can happen frequently, so the implementation now shares
parsing and git-branch orchestration between the status line and title
paths, and caches the derived project-root name by cwd. That keeps the
hot path cheap without introducing background polling.

## Architecture

The TUI gets a new `/title` slash command and a dedicated picker UI for
selecting and ordering terminal-title items. The chosen ids are
persisted in `tui.terminal_title`, with `spinner` and `project` as the
default when the config is unset. `status` remains available as a
separate text item, so configurations like `spinner,status` render
compact progress like `⠋ Working`.

`ChatWidget` now refreshes both status surfaces through a shared
`refresh_status_surfaces()` path. That shared path parses configured
items once, warns on invalid ids once, synchronizes shared cached state
such as git-branch lookup, then renders the footer status line and
terminal title from the same snapshot.

Low-level OSC title writes live in `codex-rs/tui/src/terminal_title.rs`,
which owns the terminal write path and last-mile sanitization before
emitting OSC 0.

## Security

Terminal-title text is treated as untrusted display content before Codex
emits it. The write path strips control characters, removes invisible
and bidi formatting characters that can make the title visually
misleading, normalizes whitespace, and caps the emitted length.

References used while implementing this:

- [xterm control
sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html)
- [WezTerm escape sequences](https://wezterm.org/escape-sequences.html)
- [CWE-150: Improper Neutralization of Escape, Meta, or Control
Sequences](https://cwe.mitre.org/data/definitions/150.html)
- [CERT VU#999008 (Trojan Source)](https://kb.cert.org/vuls/id/999008)
- [Trojan Source disclosure site](https://trojansource.codes/)
- [Unicode Bidirectional Algorithm (UAX
#9)](https://www.unicode.org/reports/tr9/)
- [Unicode Security Considerations (UTR
#36)](https://www.unicode.org/reports/tr36/)

## Observability

Unknown configured title item ids are warned about once instead of
repeatedly spamming the transcript. Live preview applies immediately
while the `/title` picker is open, and cancel rolls the in-memory title
selection back to the pre-picker value.

If terminal title writes fail, the TUI emits debug logs around set and
clear attempts. The rendered status label intentionally collapses richer
internal states into compact title text such as `Starting...`, `Ready`,
`Thinking...`, `Working...`, `Waiting...`, and `Undoing...` when
`status` is configured.

## Tests

Ran:

- `just fmt`
- `cargo test -p codex-tui`

At the moment, the red Windows `rust-ci` failures are due to existing
`codex-core` `apply_patch_cli` stack-overflow tests that also reproduce
on `main`. The `/title`-specific `codex-tui` suite is green.
2026-03-19 19:26:36 +00:00
gabec-openai
fe287ac467 Log automated reviewer approval sources distinctly (#15201)
## Summary

- log guardian-reviewed tool approvals as `source=automated_reviewer` in
`codex.tool_decision`
- keep direct user approvals as `source=user` and config-driven
approvals as `source=config`

## Testing

-
`/Users/gabec/.codex/skills/codex-oss-fastdev/scripts/codex-rs-fmt-quiet.sh`
-
`/Users/gabec/.codex/skills/codex-oss-fastdev/scripts/codex-rs-test-quiet.sh
-p codex-otel` (fails in sandboxed loopback bind tests under
`otel/tests/suite/otlp_http_loopback.rs`)
- `cargo test -p codex-core guardian -- --nocapture` (original-tree run
reached Guardian tests and only hit sandbox-related listener/proxy
failures)

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 12:10:41 -07:00
starr-openai
1d210f639e Add exec-server exec RPC implementation (#15090)
Stacked PR 2/3, based on the stub PR.

Adds the exec RPC implementation and process/event flow in exec-server
only.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 19:00:36 +00:00
Michael Bolin
b87ba0a3cc Publish runnable DotSlash package for argument-comment lint (#15198)
## Why

To date, the argument-comment linter introduced in
https://github.com/openai/codex/pull/14651 had to be built from source
to run, which can be a bit slow (both for local dev and when it is run
in CI). Because of the potential slowness, I did not wire it up to run
as part of `just clippy` or anything like that. As a result, I have seen
a number of occasions where folks put up PRs that violate the lint, see
it fail in CI, and then have to put up their PR again.

The goal of this PR is to pre-build a runnable version of the linter and
then make it available via a DotSlash file. Once it is available, I will
update `just clippy` and other touchpoints to make it a natural part of
the dev cycle so lint violations should get flagged _before_ putting up
a PR for review.

To get things started, we will build the DotSlash file as part of an
alpha release. Though I don't expect the linter to change often, so I'll
probably change this to only build as part of mainline releases once we
have a working DotSlash file. (Ultimately, we should probably move the
linter into its own repo so it can have its own release cycle.)

## What Changed
- add a reusable `rust-release-argument-comment-lint.yml` workflow that
builds host-specific archives for macOS arm64, Linux arm64/x64, and
Windows x64
- wire `rust-release.yml` to publish the `argument-comment-lint`
DotSlash manifest on all releases for now, including alpha tags
- package a runnable layout instead of a bare library

The Unix archive layout is:

```text
argument-comment-lint/
  bin/
    argument-comment-lint
    cargo-dylint
  lib/
    libargument_comment_lint@nightly-2025-09-18-<target>.dylib|so
```

On Windows the same layout is published as a `.zip`, with `.exe` and
`.dll` filenames instead.

DotSlash resolves the package entrypoint to
`argument-comment-lint/bin/argument-comment-lint`. That runner finds the
sibling bundled `cargo-dylint` binary plus the single packaged Dylint
library under `lib/`, then invokes `cargo-dylint dylint --lib-path
<that-library>` with the repo's default lint settings.
2026-03-19 18:59:02 +00:00
pakrym-oai
1837038f4e Add experimental exec server URL handling (#15196)
Add a config and attempt to start the server.
2026-03-19 18:25:11 +00:00
Andrei Eternal
267499bed8 [hooks] use a user message > developer message for prompt continuation (#14867)
## Summary

Persist Stop-hook continuation prompts as `user` messages instead of
hidden `developer` messages + some requested integration tests

This is a followup to @pakrym 's comment in
https://github.com/openai/codex/pull/14532 to make sure stop-block
continuation prompts match training for turn loops

- Stop continuation now writes `<hook_prompt hook_run_id="...">stop
hook's user prompt<hook_prompt>`
- Introduces quick-xml dependency, though we already indirectly depended
on it anyway via syntect
- This PR only has about 500 lines of actual logic changes, the rest is
tests/schema

## Testing

Example run (with a sessionstart hook and 3 stop hooks) - this shows
context added by session start, then two stop hooks sending their own
additional prompts in a new turn. The model responds with a single
message addressing both. Then when that turn ends, the hooks detect that
they just ran using `stop_hook_active` and decide not to infinite loop

test files for this (unzip, move codex -> .codex):
[codex.zip](https://github.com/user-attachments/files/26075806/codex.zip)

```
› cats


• Running SessionStart hook: lighting the observatory

SessionStart hook (completed)
  warning: Hi, I'm a session start hook for wizard-tower (startup).
  hook context: A wimboltine stonpet is an exotic cuisine from hyperspace

• Cats are tiny zen wizards, my friend: equal parts nap, mystery, and chaos. If you want, we can talk
  cat facts, cat breeds, cat names, or build something cat-themed in this repo.

• Running Stop hook: checking the tower wards

• Running Stop hook: sacking the guards

• Running Stop hook: hiring the guards

Stop hook (completed)
  warning: Wizard Tower Stop hook reviewed the completed reply (177 chars).

Stop hook (blocked)
  warning: Wizard Tower Stop hook continuing conversation
  feedback: cook the stonpet

Stop hook (blocked)
  warning: Wizard Tower Stop hook continuing conversation
  feedback: eat the cooked stonpet

• Stonpet’s cooked, aloha style: flash-seared over a blue quasiflame, glazed with nebula salt, and
  rested until the hyperspace juices settle.

  Now we eat with gratitude, my friend. One mindful bite in, and the flavor is pure cosmic surf:
  smoky, bright, and totally out of this dimension.

• Running Stop hook: checking the tower wards

• Running Stop hook: sacking the guards

• Running Stop hook: hiring the guards

Stop hook (completed)
  warning: Wizard Tower Stop hook reviewed the completed reply (285 chars).

Stop hook (completed)
  warning: Wizard Tower Stop hook saw a second pass and stayed calm to avoid a loop.

Stop hook (completed)
  warning: Wizard Tower Stop hook saw a second pass and stayed calm to avoid a loop.
```
2026-03-19 10:53:08 -07:00
nicholasclark-openai
5ec121ba12 Revert "Forward session and turn headers to MCP HTTP requests" (#15185)
Reverts openai/codex#15011

Codex merged by mistake before feedback applied
2026-03-19 10:38:53 -07:00
jif-oai
859c58f07d chore: morpheus does not generate memories (#15175)
For obvious reasons
2026-03-19 15:48:28 +00:00
jif-oai
2cf4d5ef35 chore: add metrics for profile (#15180) 2026-03-19 15:48:02 +00:00
pakrym-oai
dee03da508 Move environment abstraction into exec server (#15125)
The idea is that codex-exec exposes an Environment struct with services
on it. Each of those is a trait.

Depending on construction parameters passed to Environment they are
either backed by local or remote server but core doesn't see these
differences.
2026-03-19 08:31:14 -07:00
jif-oai
32d2df5c1e fix: case where agent is already closed (#15163) 2026-03-19 12:12:50 +00:00
jif-oai
70cdb17703 feat: add graph representation of agent network (#15056)
Add a representation of the agent graph. This is now used for:
* Cascade close agents (when I close a parent, it close the kids)
* Cascade resume (oposite)

Later, this will also be used for post-compaction stuffing of the
context

Direct fix for: https://github.com/openai/codex/issues/14458
2026-03-19 10:21:25 +00:00
xl-openai
db5781a088 feat: support product-scoped plugins. (#15041)
1. Added SessionSource::Custom(String) and --session-source.
  2. Enforced plugin and skill products by session_source.
  3. Applied the same filtering to curated background refresh.
2026-03-19 00:46:15 -07:00
Eric Traut
01df50cf42 Add thread/shellCommand to app server API surface (#14988)
This PR adds a new `thread/shellCommand` app server API so clients can
implement `!` shell commands. These commands are executed within the
sandbox, and the command text and output are visible to the model.

The internal implementation mirrors the current TUI `!` behavior.
- persist shell command execution as `CommandExecution` thread items,
including source and formatted output metadata
- bridge live and replayed app-server command execution events back into
the existing `tui_app_server` exec rendering path

This PR also wires `tui_app_server` to submit `!` commands through the
new API.
2026-03-18 23:42:40 -06:00
canvrno-oai
10eb3ec7fc Simple directory mentions (#14970)
- Adds simple support for directory mentions in the TUI.
- Codex App/VS Code will require minor change to recognize a directory
mention as such and change the link behavior.
- Directory mentions have a trailing slash to differentiate from
extensionless files


<img width="972" height="382" alt="image"
src="https://github.com/user-attachments/assets/8035b1eb-0978-465b-8d7a-4db2e5feca39"
/>
<img width="978" height="228" alt="image"
src="https://github.com/user-attachments/assets/af22cf0b-dd10-4440-9bee-a09915f6ba52"
/>
2026-03-19 05:24:09 +00:00
Andrei Eternal
42e932d7bf [hooks] turn_id extension for Stop & UserPromptSubmit (#15118)
## Description

Adding an extension to the spec that exposes the turn_id to hook
scripts. This is a codex-specific mechanic that allows connecting the
hook runs with particular turns

## Testing

hooks config / sample hooks to use. Extract this, rename codex ->
.codex, and place this into a repo or your home folder. It includes:
config.toml that enables hooks, hooks.json, and sample python hooks:


[codex.zip](https://github.com/user-attachments/files/26102671/codex.zip)

example run (note the turn_ids change between turns):

```
› hello


• Running SessionStart hook: lighting the observatory

SessionStart hook (completed)
  warning: Hi, I'm a session start hook for wizard-tower (startup).
  hook context: Startup context: A wimboltine stonpet is an exotic cuisine from hyperspace

• Running UserPromptSubmit hook: lighting the observatory lanterns

UserPromptSubmit hook (completed)
  warning: wizard-tower UserPromptSubmit demo inspected: hello for turn: 019d036d-c7fa-72d2-b6fd-
78878bfe34e4
  hook context: Wizard Tower UserPromptSubmit demo fired. For this reply only, include the exact
phrase 'observatory lanterns lit' near the end.

• Aloha! Grateful to be here and ready to build with you. Show me what you want to tackle in wizard-
  tower, and we’ll surf the next wave together. observatory lanterns lit

• Running Stop hook: back to shore

Stop hook (completed)
  warning: Wizard Tower Stop hook reviewed the completed reply (170 chars) for turn: 019d036d-c7fa-
72d2-b6fd-78878bfe34e4


› what's a stonpet?


• Running UserPromptSubmit hook: lighting the observatory lanterns

UserPromptSubmit hook (completed)
  warning: wizard-tower UserPromptSubmit demo inspected: what's a stonpet? for turn: 019d036e-3164-
72c3-a170-98925564c4fc
  hook context: Wizard Tower UserPromptSubmit demo fired. For this reply only, include the exact
phrase 'observatory lanterns lit' near the end.

• A stonpet isn’t a standard real-world word, brah. In our shared context here, a wimboltine stonpet
  is an exotic cuisine from hyperspace, so “stonpet” sounds like the dish or food itself.

  If you want, we can totally invent the lore for it next. observatory lanterns lit

• Running Stop hook: back to shore

Stop hook (completed)
  warning: Wizard Tower Stop hook reviewed the completed reply (271 chars) for turn: 019d036e-3164-
72c3-a170-98925564c4fc
```
2026-03-18 21:48:31 -07:00
nicholasclark-openai
b14689df3b Forward session and turn headers to MCP HTTP requests (#15011)
## Summary
- forward request-scoped task headers through MCP tool metadata lookups
and tool calls
- apply those headers to streamable HTTP initialize, tools/list, and
tools/call requests
- update affected rmcp/core tests for the new request_headers plumbing

## Testing
- cargo test -p codex-rmcp-client
- cargo test -p codex-core (fails on pre-existing unrelated error in
core/src/auth_env_telemetry.rs: missing websocket_connect_timeout_ms in
ModelProviderInfo initializer)
- just fix -p codex-rmcp-client
- just fix -p codex-core (hits the same unrelated auth_env_telemetry.rs
error)
- just fmt

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-18 21:29:37 -07:00
Owen Lin
20f2a216df feat(core, tracing): create turn spans over websockets (#14632)
## Description

Dependent on:
- [responsesapi] https://github.com/openai/openai/pull/760991 
- [codex-backend] https://github.com/openai/openai/pull/760985

`codex app-server -> codex-backend -> responsesapi` now reuses a
persistent websocket connection across many turns. This PR updates
tracing when using websockets so that each `response.create` websocket
request propagates the current tracing context, so we can get a holistic
end-to-end trace for each turn.

Tracing is propagated via special keys (`ws_request_header_traceparent`,
`ws_request_header_tracestate`) set in the `client_metadata` param in
Responses API.

Currently tracing on websockets is a bit broken because we only set
tracing context on ws connection time, so it's detached from a
`turn/start` request.
2026-03-19 03:41:06 +00:00
pakrym-oai
903660edba Remove stdio transport from exec server (#15119)
Summary
- delete the deprecated stdio transport plumbing from the exec server
stack
- add a basic `exec_server()` harness plus test utilities to start a
server, send requests, and await events
- refresh exec-server dependencies, configs, and documentation to
reflect the new flow

Testing
- Not run (not requested)

---------

Co-authored-by: starr-openai <starr@openai.com>
Co-authored-by: Codex <noreply@openai.com>
2026-03-19 01:00:35 +00:00
Shaqayeq
4fd2774614 Add Python SDK thread.run convenience methods (#15088)
## TL;DR
Add `thread.run(...)` / `async thread.run(...)` convenience methods to
the Python SDK for the common case.

- add `RunInput = Input | str` and `RunResult` with `final_response`,
collected `items`, and optional `usage`
- keep `thread.turn(...)` strict and lower-level for streaming,
steering, interrupting, and raw generated `Turn` access
- update Python SDK docs, quickstart examples, and tests for the sync
and async convenience flows

## Validation
- `python3 -m pytest sdk/python/tests/test_public_api_signatures.py
sdk/python/tests/test_public_api_runtime_behavior.py`
- `python3 -m pytest
sdk/python/tests/test_real_app_server_integration.py -k
'thread_run_convenience or async_thread_run_convenience'` (skipped in
this environment)

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 00:57:48 +00:00
alexsong-oai
825d09373d Support featured plugins (#15042) 2026-03-18 17:45:30 -07:00
starr-openai
81996fcde6 Add exec-server stub server and protocol docs (#15089)
Stacked PR 1/3.

This is the initialize-only exec-server stub slice: binary/client
scaffolding and protocol docs, without exec/filesystem implementation.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 00:30:05 +00:00
xl-openai
dcd5e08269 fix: harden plugin feature gating (#15104)
Resubmit https://github.com/openai/codex/pull/15020 with correct
content.

1. Use requirement-resolved config.features as the plugin gate.
2. Guard plugin/list, plugin/read, and related flows behind that gate.
3. Skip bad marketplace.json files instead of failing the whole list.
4. Simplify plugin state and caching.
2026-03-19 00:03:37 +00:00
pakrym-oai
56d0c6bf67 Add apply_patch code mode result (#15100)
It's empty !
2026-03-18 16:11:10 -07:00
pakrym-oai
3590e181fa Add update_plan code mode result (#15103)
It's empty!
2026-03-18 16:10:51 -07:00
Ahmed Ibrahim
b306885bd8 don't add transcript for v2 realtime (#15111)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.

Include a link to a bug report or enhancement request.
2026-03-18 15:54:13 -07:00
Shijie Rao
bb30432421 Feat: reuse persisted model and reasoning effort on thread resume (#14888)
## Summary

This PR makes `thread/resume` reuse persisted thread model metadata when
the caller does not explicitly override it.

Changes:
- read persisted thread metadata from SQLite during `thread/resume`
- reuse persisted `model` and `model_reasoning_effort` as resume-time
defaults
- fetch persisted metadata once and reuse it later in the resume
response path
- keep thread summary loading on the existing rollout path, while
reusing persisted metadata when available
- document the resume fallback behavior in the app-server README

## Why

Before this change, resuming a thread without explicit overrides derived
`model` and `model_reasoning_effort` from current config, which could
drift from the thread’s last persisted values. That meant a resumed
thread could report and run with different model settings than the ones
it previously used.

## Behavior

Precedence on `thread/resume` is now:
1. explicit resume overrides
2. persisted SQLite metadata for the thread
3. normal config resolution for the resumed cwd
2026-03-18 15:45:17 -07:00
Charley Cunningham
ebbbc52ce4 Align SQLite feedback logs with feedback formatter (#13494)
## Summary
- store a pre-rendered `feedback_log_body` in SQLite so `/feedback`
exports keep span prefixes and structured event fields
- render SQLite feedback exports with timestamps and level prefixes to
match the old in-memory feedback formatter, while preserving existing
trailing newlines
- count `feedback_log_body` in the SQLite retention budget so structured
or span-prefixed rows still prune correctly
- bound `/feedback` row loading in SQL with the retention estimate, then
apply exact whole-line truncation in Rust so uploads stay capped without
splitting lines

## Details
- add a `feedback_log_body` column to `logs` and backfill it from
`message` for existing rows
- capture span names plus formatted span and event fields at write time,
since SQLite does not retain enough structure to reconstruct the old
formatter later
- keep SQLite feedback queries scoped to the requested thread plus
same-process threadless rows
- restore a SQL-side cumulative `estimated_bytes` cap for feedback
export queries so over-retained partitions do not load every matching
row before truncation
- add focused formatting coverage for exported feedback lines and parity
coverage against `tracing_subscriber`

## Testing
- cargo test -p codex-state
- just fix -p codex-state
- just fmt

codex author: `codex resume 019ca1b0-0ecc-78b1-85eb-6befdd7e4f1f`

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-18 22:44:31 +00:00
Ahmed Ibrahim
7b37a0350f Add final message prefix to realtime handoff output (#15077)
- prefix realtime handoff output with the agent final message label for
both realtime v1 and v2
- update realtime websocket and core expectations to match
2026-03-18 15:19:49 -07:00
xl-openai
86982ca1f9 Revert "fix: harden plugin feature gating" (#15102)
Reverts openai/codex#15020

I messed up the commit in my PR and accidentally merged changes that
were still under review.
2026-03-18 15:19:29 -07:00
Eric Traut
e5de13644d Add a startup deprecation warning for custom prompts (#15076)
## Summary
- detect custom prompts in `$CODEX_HOME/prompts` during TUI startup
- show a deprecation notice only when prompts are present, with guidance
to use `$skill-creator`
- add TUI tests and snapshot coverage for present, missing, and empty
prompts directories

## Testing
- Manually tested
2026-03-18 15:21:30 -06:00
pakrym-oai
5cada46ddf Return image URL from view_image tool (#15072)
Cleanup image semantics in code mode.

`view_image` now returns `{image_url:string, details?: string}` 

`image()` now allows both string parameter and `{image_url:string,
details?: string}`
2026-03-18 13:58:20 -07:00
pakrym-oai
88e5382fc4 Propagate tool errors to code mode (#15075)
Clean up error flow to push the FunctionCallError all the way up to
dispatcher and allow code mode to surface as exception.
2026-03-18 13:57:55 -07:00
Michael Bolin
392347d436 fix: try to fix "Stage npm package" step in ci.yml (#15092)
Fix the CI job by updating it to use artifacts from a more recent
release (`0.115.0`) instead of the existing one (`0.74.0`).

This step in our CI job on PRs started failing today:


334164a6f7/.github/workflows/ci.yml (L33-L47)

I believe it's because this test verifies that the "package npm" script
works, but we want it to be fast and not wait for binaries to be built,
so it uses a GitHub workflow that's already done. Because it was using a
GitHub workflow associated with `0.74.0`, it seems likely that
workflow's history has been reaped, so we need to use a newer one.
2026-03-18 13:52:33 -07:00
Felipe Coury
334164a6f7 feat(tui): restore composer history in app-server tui (#14945)
## Problem

The app-server TUI (`tui_app_server`) lacked composer history support.
Pressing Up/Down to recall previous prompts hit a stub that logged a
warning and displayed "Not available in app-server TUI yet." New
submissions were silently dropped from the shared history file, so
nothing persisted for future sessions.

## Mental model

Codex maintains a single, append-only history file
(`$CODEX_HOME/history.jsonl`) shared across all TUI processes on the
same machine. The legacy (in-process) TUI already reads/writes this file
through `codex_core::message_history`. The app-server TUI delegates most
operations to a separate process over RPC, but history is intentionally
*not* an RPC concern — it's a client-local file.

This PR makes the app-server TUI access the same history file directly,
bypassing the app-server process entirely. The composer's Up/Down
navigation and submit-time persistence now follow the same code paths as
the legacy TUI, with the only difference being *where* the call is
dispatched (locally in `App`, rather than inside `CodexThread`).

The branch is rebuilt directly on top of `upstream/main`, so it keeps
the
existing app-server restore architecture intact.
`AppServerStartedThread`
still restores transcript history from the server `Thread` snapshot via
`thread_snapshot_events`; this PR only adds composer-history support.

## Non-goals

- Adding history support to the app-server protocol. History remains
client-local.
- Changing the on-disk format or location of `history.jsonl`.
- Surfacing history I/O errors to the user (failures are logged and
silently swallowed, matching the legacy TUI).

## Tradeoffs

| Decision | Why | Risk |
|----------|-----|------|
| Widen `message_history` from `pub(crate)` to `pub` | Avoids
duplicating file I/O logic; the module already has a clean, minimal API
surface. | Other workspace crates can now call these functions — the
contract is no longer crate-private. However, this is consistent with
recent precedent: `590cfa617` exposed `mention_syntax` for TUI
consumption, `752402c4f` exposed plugin APIs (`PluginsManager`), and
`14fcb6645`/`edacbf7b6` widened internal core APIs for other crates.
These were all narrow, intentional exposures of specific APIs — not
broad "make internals public" moves. `1af2a37ad` even went the other
direction, reducing broad re-exports to tighten boundaries. This change
follows the same pattern: a small, deliberate API surface (3 functions)
rather than a wholesale visibility change. |
| Intercept `AddToHistory` / `GetHistoryEntryRequest` in `App` before
RPC fallback | Keeps history ops out of the "unsupported op" error path
without changing app-server protocol. | This now routes through a single
`submit_thread_op` entry point, which is safer than the original
duplicated dispatch. The remaining risk is organizational: future
thread-op submission paths need to keep using that shared entry point. |
| `session_configured_from_thread_response` is now `async` | Needs
`await` on `history_metadata()` to populate real `history_log_id` /
`history_entry_count`. | Adds an async file-stat + full-file newline
scan to the session bootstrap path. The scan is bounded by
`history.max_bytes` and matches the legacy TUI's cost profile, but
startup latency still scales with file size. |

## Architecture

```
User presses Up                     User submits a prompt
       │                                    │
       ▼                                    ▼
ChatComposerHistory                 ChatWidget::do_submit_turn
  navigate_up()                       encode_history_mentions()
       │                                    │
       ▼                                    ▼
  AppEvent::CodexOp                  Op::AddToHistory { text }
  (GetHistoryEntryRequest)                  │
       │                                    ▼
       ▼                            App::try_handle_local_history_op
  App::try_handle_local_history_op    message_history::append_entry()
    spawn_blocking {                        │
      message_history::lookup()             ▼
    }                                $CODEX_HOME/history.jsonl
       │
       ▼
  AppEvent::ThreadEvent
  (GetHistoryEntryResponse)
       │
       ▼
  ChatComposerHistory::on_entry_response()
```

## Observability

- `tracing::warn` on `append_entry` failure (includes thread ID).
- `tracing::warn` on `spawn_blocking` lookup join error.
- `tracing::warn` from `message_history` internals on file-open, lock,
or parse failures.

## Tests

- `chat_composer_history::tests::navigation_with_async_fetch` — verifies
that Up emits `Op::GetHistoryEntryRequest` (was: checked for stub error
cell).
- `app::tests::history_lookup_response_is_routed_to_requesting_thread` —
verifies multi-thread composer recall routes the lookup result back to
the originating thread.
-
`app_server_session::tests::resume_response_relies_on_snapshot_replay_not_initial_messages`
— verifies app-server session restore still uses the upstream
thread-snapshot path.
-
`app_server_session::tests::session_configured_populates_history_metadata`
— verifies bootstrap sets nonzero `history_log_id` /
`history_entry_count` from the shared local history file.
2026-03-18 11:54:11 -06:00
1087 changed files with 88059 additions and 22602 deletions

View File

@@ -0,0 +1,16 @@
---
name: remote-tests
description: How to run tests using remote executor.
---
Some codex integration tests support a running against a remote executor.
This means that when CODEX_TEST_REMOTE_ENV environment variable is set they will attempt to start an executor process in a docker container CODEX_TEST_REMOTE_ENV points to and use it in tests.
Docker container is built and initialized via ./scripts/test-remote-env.sh
Currently running remote tests is only supported on Linux, so you need to use a devbox to run them
You can list devboxes via `applied_devbox ls`, pick the one with `codex` in the name.
Connect to devbox via `ssh <devbox_name>`.
Reuse the same checkout of codex in `~/code/codex`. Reset files if needed. Multiple checkouts take longer to build and take up more space.
Check whether the SHA and modified files are in sync between remote and local.

View File

@@ -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

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,24 @@
{
"outputs": {
"argument-comment-lint": {
"platforms": {
"macos-aarch64": {
"regex": "^argument-comment-lint-aarch64-apple-darwin\\.tar\\.gz$",
"path": "argument-comment-lint/bin/argument-comment-lint"
},
"linux-x86_64": {
"regex": "^argument-comment-lint-x86_64-unknown-linux-gnu\\.tar\\.gz$",
"path": "argument-comment-lint/bin/argument-comment-lint"
},
"linux-aarch64": {
"regex": "^argument-comment-lint-aarch64-unknown-linux-gnu\\.tar\\.gz$",
"path": "argument-comment-lint/bin/argument-comment-lint"
},
"windows-x86_64": {
"regex": "^argument-comment-lint-x86_64-pc-windows-msvc\\.zip$",
"path": "argument-comment-lint/bin/argument-comment-lint.exe"
}
}
}
}
}

23
.github/dotslash-zsh-config.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"outputs": {
"codex-zsh": {
"platforms": {
"macos-aarch64": {
"name": "codex-zsh-aarch64-apple-darwin.tar.gz",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
},
"linux-x86_64": {
"name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
},
"linux-aarch64": {
"name": "codex-zsh-aarch64-unknown-linux-musl.tar.gz",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
}
}
}
}
}

61
.github/scripts/build-zsh-release-artifact.sh vendored Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 ]]; then
echo "usage: $0 <archive-path>" >&2
exit 1
fi
archive_path="$1"
workspace="${GITHUB_WORKSPACE:?missing GITHUB_WORKSPACE}"
zsh_commit="${ZSH_COMMIT:?missing ZSH_COMMIT}"
zsh_patch="${ZSH_PATCH:?missing ZSH_PATCH}"
temp_root="${RUNNER_TEMP:-/tmp}"
work_root="$(mktemp -d "${temp_root%/}/codex-zsh-release.XXXXXX")"
trap 'rm -rf "$work_root"' EXIT
source_root="${work_root}/zsh"
package_root="${work_root}/codex-zsh"
wrapper_path="${work_root}/exec-wrapper"
stdout_path="${work_root}/stdout.txt"
wrapper_log_path="${work_root}/wrapper.log"
git clone https://git.code.sf.net/p/zsh/code "$source_root"
cd "$source_root"
git checkout "$zsh_commit"
git apply "${workspace}/${zsh_patch}"
./Util/preconfig
./configure
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
cat > "$wrapper_path" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$wrapper_path"
CODEX_WRAPPER_LOG="$wrapper_log_path" \
EXEC_WRAPPER="$wrapper_path" \
"${source_root}/Src/zsh" -fc '/bin/echo smoke-zsh' > "$stdout_path"
grep -Fx "smoke-zsh" "$stdout_path"
grep -Fx "/bin/echo" "$wrapper_log_path"
mkdir -p "$package_root/bin" "$(dirname "${workspace}/${archive_path}")"
cp "${source_root}/Src/zsh" "$package_root/bin/zsh"
chmod +x "$package_root/bin/zsh"
(cd "$work_root" && tar -czf "${workspace}/${archive_path}" codex-zsh)

287
.github/scripts/rusty_v8_bazel.py vendored Normal file
View File

@@ -0,0 +1,287 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import gzip
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MUSL_RUNTIME_ARCHIVE_LABELS = [
"@llvm//runtimes/libcxx:libcxx.static",
"@llvm//runtimes/libcxx:libcxxabi.static",
]
LLVM_AR_LABEL = "@llvm//tools:llvm-ar"
LLVM_RANLIB_LABEL = "@llvm//tools:llvm-ranlib"
def bazel_execroot() -> Path:
result = subprocess.run(
["bazel", "info", "execution_root"],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return Path(result.stdout.strip())
def bazel_output_base() -> Path:
result = subprocess.run(
["bazel", "info", "output_base"],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return Path(result.stdout.strip())
def bazel_output_path(path: str) -> Path:
if path.startswith("external/"):
return bazel_output_base() / path
return bazel_execroot() / path
def bazel_output_files(
platform: str,
labels: list[str],
compilation_mode: str = "fastbuild",
) -> list[Path]:
expression = "set(" + " ".join(labels) + ")"
result = subprocess.run(
[
"bazel",
"cquery",
"-c",
compilation_mode,
f"--platforms=@llvm//platforms:{platform}",
"--output=files",
expression,
],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()]
def bazel_build(
platform: str,
labels: list[str],
compilation_mode: str = "fastbuild",
) -> None:
subprocess.run(
[
"bazel",
"build",
"-c",
compilation_mode,
f"--platforms=@llvm//platforms:{platform}",
*labels,
],
cwd=ROOT,
check=True,
)
def ensure_bazel_output_files(
platform: str,
labels: list[str],
compilation_mode: str = "fastbuild",
) -> list[Path]:
outputs = bazel_output_files(platform, labels, compilation_mode)
if all(path.exists() for path in outputs):
return outputs
bazel_build(platform, labels, compilation_mode)
outputs = bazel_output_files(platform, labels, compilation_mode)
missing = [str(path) for path in outputs if not path.exists()]
if missing:
raise SystemExit(f"missing built outputs for {labels}: {missing}")
return outputs
def release_pair_label(target: str) -> str:
target_suffix = target.replace("-", "_")
return f"//third_party/v8:rusty_v8_release_pair_{target_suffix}"
def resolved_v8_crate_version() -> str:
cargo_lock = tomllib.loads((ROOT / "codex-rs" / "Cargo.lock").read_text())
versions = sorted(
{
package["version"]
for package in cargo_lock["package"]
if package["name"] == "v8"
}
)
if len(versions) == 1:
return versions[0]
if len(versions) > 1:
raise SystemExit(f"expected exactly one resolved v8 version, found: {versions}")
module_bazel = (ROOT / "MODULE.bazel").read_text()
matches = sorted(
set(
re.findall(
r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate',
module_bazel,
)
)
)
if len(matches) != 1:
raise SystemExit(
"expected exactly one pinned v8 crate version in MODULE.bazel, "
f"found: {matches}"
)
return matches[0]
def staged_archive_name(target: str, source_path: Path) -> str:
if source_path.suffix == ".lib":
return f"rusty_v8_release_{target}.lib.gz"
return f"librusty_v8_release_{target}.a.gz"
def is_musl_archive_target(target: str, source_path: Path) -> bool:
return target.endswith("-unknown-linux-musl") and source_path.suffix == ".a"
def single_bazel_output_file(
platform: str,
label: str,
compilation_mode: str = "fastbuild",
) -> Path:
outputs = ensure_bazel_output_files(platform, [label], compilation_mode)
if len(outputs) != 1:
raise SystemExit(f"expected exactly one output for {label}, found {outputs}")
return outputs[0]
def merged_musl_archive(
platform: str,
lib_path: Path,
compilation_mode: str = "fastbuild",
) -> Path:
llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode)
llvm_ranlib = single_bazel_output_file(platform, LLVM_RANLIB_LABEL, compilation_mode)
runtime_archives = [
single_bazel_output_file(platform, label, compilation_mode)
for label in MUSL_RUNTIME_ARCHIVE_LABELS
]
temp_dir = Path(tempfile.mkdtemp(prefix="rusty-v8-musl-stage-"))
merged_archive = temp_dir / lib_path.name
merge_commands = "\n".join(
[
f"create {merged_archive}",
f"addlib {lib_path}",
*[f"addlib {archive}" for archive in runtime_archives],
"save",
"end",
]
)
subprocess.run(
[str(llvm_ar), "-M"],
cwd=ROOT,
check=True,
input=merge_commands,
text=True,
)
subprocess.run([str(llvm_ranlib), str(merged_archive)], cwd=ROOT, check=True)
return merged_archive
def stage_release_pair(
platform: str,
target: str,
output_dir: Path,
compilation_mode: str = "fastbuild",
) -> None:
outputs = ensure_bazel_output_files(
platform,
[release_pair_label(target)],
compilation_mode,
)
try:
lib_path = next(path for path in outputs if path.suffix in {".a", ".lib"})
except StopIteration as exc:
raise SystemExit(f"missing static library output for {target}") from exc
try:
binding_path = next(path for path in outputs if path.suffix == ".rs")
except StopIteration as exc:
raise SystemExit(f"missing Rust binding output for {target}") from exc
output_dir.mkdir(parents=True, exist_ok=True)
staged_library = output_dir / staged_archive_name(target, lib_path)
staged_binding = output_dir / f"src_binding_release_{target}.rs"
source_archive = (
merged_musl_archive(platform, lib_path, compilation_mode)
if is_musl_archive_target(target, lib_path)
else lib_path
)
with source_archive.open("rb") as src, staged_library.open("wb") as dst:
with gzip.GzipFile(
filename="",
mode="wb",
fileobj=dst,
compresslevel=6,
mtime=0,
) as gz:
shutil.copyfileobj(src, gz)
shutil.copyfile(binding_path, staged_binding)
print(staged_library)
print(staged_binding)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
stage_release_pair_parser = subparsers.add_parser("stage-release-pair")
stage_release_pair_parser.add_argument("--platform", required=True)
stage_release_pair_parser.add_argument("--target", required=True)
stage_release_pair_parser.add_argument("--output-dir", required=True)
stage_release_pair_parser.add_argument(
"--compilation-mode",
default="fastbuild",
choices=["fastbuild", "opt", "dbg"],
)
subparsers.add_parser("resolved-v8-crate-version")
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.command == "stage-release-pair":
stage_release_pair(
platform=args.platform,
target=args.target,
output_dir=Path(args.output_dir),
compilation_mode=args.compilation_mode,
)
return 0
if args.command == "resolved-v8-crate-version":
print(resolved_v8_crate_version())
return 0
raise SystemExit(f"unsupported command: {args.command}")
if __name__ == "__main__":
sys.exit(main())

View File

@@ -156,7 +156,6 @@ jobs:
bazel_args=(
test
//...
--test_verbose_timeout_warnings
--build_metadata=REPO_URL=https://github.com/openai/codex.git
--build_metadata=COMMIT_SHA=$(git rev-parse HEAD)
@@ -164,6 +163,13 @@ jobs:
--build_metadata=VISIBILITY=PUBLIC
)
bazel_targets=(
//...
# Keep V8 out of the ordinary Bazel CI path. Only the dedicated
# canary and release workflows should build `third_party/v8`.
-//third_party/v8:all
)
if [[ "${RUNNER_OS:-}" != "Windows" ]]; then
# Bazel test sandboxes on macOS may resolve an older Homebrew `node`
# before the `actions/setup-node` runtime on PATH.
@@ -183,6 +189,8 @@ jobs:
--bazelrc=.github/workflows/ci.bazelrc \
"${bazel_args[@]}" \
"--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
@@ -210,6 +218,8 @@ jobs:
"${bazel_args[@]}" \
--remote_cache= \
--remote_executor= \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e

View File

@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
run_install: false
@@ -37,7 +37,7 @@ jobs:
run: |
set -euo pipefail
# Use a rust-release version that includes all native binaries.
CODEX_VERSION=0.74.0
CODEX_VERSION=0.115.0
OUTPUT_DIR="${RUNNER_TEMP}"
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \

View File

@@ -91,17 +91,13 @@ jobs:
- name: cargo shear
run: cargo shear
argument_comment_lint:
name: Argument comment lint
argument_comment_lint_package:
name: Argument comment lint package
runs-on: ubuntu-24.04
needs: changed
if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' || github.event_name == 'push' }}
steps:
- uses: actions/checkout@v6
- name: Install Linux sandbox build dependencies
run: |
sudo DEBIAN_FRONTEND=noninteractive apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- uses: dtolnay/rust-toolchain@1.93.0
with:
toolchain: nightly-2025-09-18
@@ -120,14 +116,46 @@ jobs:
- name: Install cargo-dylint tooling
if: ${{ steps.cargo_dylint_cache.outputs.cache-hit != 'true' }}
run: cargo install --locked cargo-dylint dylint-link
- name: Check source wrapper syntax
run: bash -n tools/argument-comment-lint/run.sh
- name: Test argument comment lint package
if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' || github.event_name == 'push' }}
working-directory: tools/argument-comment-lint
run: cargo test
- name: Run argument comment lint on codex-rs
argument_comment_lint_prebuilt:
name: Argument comment lint - ${{ matrix.name }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
needs: changed
if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux
runner: ubuntu-24.04
- name: macOS
runner: macos-15-xlarge
- name: Windows
runner: windows-x64
runs_on:
group: codex-runners
labels: codex-windows-x64
steps:
- uses: actions/checkout@v6
- name: Install Linux sandbox build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
bash -n tools/argument-comment-lint/run.sh
./tools/argument-comment-lint/run.sh
sudo DEBIAN_FRONTEND=noninteractive apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- uses: dtolnay/rust-toolchain@1.93.0
with:
toolchain: nightly-2025-09-18
components: llvm-tools-preview, rustc-dev, rust-src
- uses: facebook/install-dotslash@v2
- name: Run argument comment lint on codex-rs
shell: bash
run: ./tools/argument-comment-lint/run-prebuilt-linter.sh
# --- CI to validate on different os/targets --------------------------------
lint_build:
@@ -141,8 +169,10 @@ jobs:
run:
working-directory: codex-rs
env:
# Speed up repeated builds across CI runs by caching compiled objects (non-Windows).
USE_SCCACHE: ${{ startsWith(matrix.runner, 'windows') && 'false' || 'true' }}
# Speed up repeated builds across CI runs by caching compiled objects, except on
# arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce
# mixed-architecture archives under sccache.
USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }}
CARGO_INCREMENTAL: "0"
SCCACHE_CACHE_SIZE: 10G
# In rust-ci, representative release-profile checks use thin LTO for faster feedback.
@@ -417,6 +447,24 @@ jobs:
echo "CFLAGS=${cflags}" >> "$GITHUB_ENV"
echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }}
name: Configure musl rusty_v8 artifact overrides
env:
TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)"
release_tag="rusty-v8-v${version}"
base_url="https://github.com/openai/codex/releases/download/${release_tag}"
archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz"
binding_dir="${RUNNER_TEMP}/rusty_v8"
binding_path="${binding_dir}/src_binding_release_${TARGET}.rs"
mkdir -p "${binding_dir}"
curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}"
echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV"
echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV"
- name: Install cargo-chef
if: ${{ matrix.profile == 'release' }}
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
@@ -497,17 +545,19 @@ jobs:
key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1
tests:
name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}
name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 30
timeout-minutes: ${{ matrix.runner == 'windows-arm64' && 35 || 30 }}
needs: changed
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
defaults:
run:
working-directory: codex-rs
env:
# Speed up repeated builds across CI runs by caching compiled objects (non-Windows).
USE_SCCACHE: ${{ startsWith(matrix.runner, 'windows') && 'false' || 'true' }}
# Speed up repeated builds across CI runs by caching compiled objects, except on
# arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce
# mixed-architecture archives under sccache.
USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }}
CARGO_INCREMENTAL: "0"
SCCACHE_CACHE_SIZE: 10G
@@ -521,6 +571,7 @@ jobs:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
remote_env: "true"
runs_on:
group: codex-runners
labels: codex-linux-x64
@@ -558,6 +609,7 @@ jobs:
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
fi
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
@@ -642,6 +694,15 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
- name: Set up remote test env (Docker)
if: ${{ runner.os == 'Linux' && matrix.remote_env == 'true' }}
shell: bash
run: |
set -euo pipefail
export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env
source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh"
echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV"
- name: tests
id: test
run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings
@@ -694,6 +755,16 @@ jobs:
echo '```';
} >> "$GITHUB_STEP_SUMMARY"
- name: Tear down remote test env
if: ${{ always() && runner.os == 'Linux' && matrix.remote_env == 'true' }}
shell: bash
run: |
set +e
if [[ "${{ steps.test.outcome }}" != "success" ]]; then
docker logs codex-remote-test-env || true
fi
docker rm -f codex-remote-test-env >/dev/null 2>&1 || true
- name: verify tests passed
if: steps.test.outcome == 'failure'
run: |
@@ -704,14 +775,23 @@ jobs:
results:
name: CI results (required)
needs:
[changed, general, cargo_shear, argument_comment_lint, lint_build, tests]
[
changed,
general,
cargo_shear,
argument_comment_lint_package,
argument_comment_lint_prebuilt,
lint_build,
tests,
]
if: always()
runs-on: ubuntu-24.04
steps:
- name: Summarize
shell: bash
run: |
echo "arglint: ${{ needs.argument_comment_lint.result }}"
echo "argpkg : ${{ needs.argument_comment_lint_package.result }}"
echo "arglint: ${{ needs.argument_comment_lint_prebuilt.result }}"
echo "general: ${{ needs.general.result }}"
echo "shear : ${{ needs.cargo_shear.result }}"
echo "lint : ${{ needs.lint_build.result }}"
@@ -724,8 +804,12 @@ jobs:
exit 0
fi
if [[ '${{ needs.changed.outputs.argument_comment_lint_package }}' == 'true' || '${{ github.event_name }}' == 'push' ]]; then
[[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; }
fi
if [[ '${{ needs.changed.outputs.argument_comment_lint }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' || '${{ github.event_name }}' == 'push' ]]; then
[[ '${{ needs.argument_comment_lint.result }}' == 'success' ]] || { echo 'argument_comment_lint failed'; exit 1; }
[[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; }
fi
if [[ '${{ needs.changed.outputs.codex }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' || '${{ github.event_name }}' == 'push' ]]; then

View File

@@ -0,0 +1,103 @@
name: rust-release-argument-comment-lint
on:
workflow_call:
inputs:
publish:
required: true
type: boolean
jobs:
skip:
if: ${{ !inputs.publish }}
runs-on: ubuntu-latest
steps:
- run: echo "Skipping argument-comment-lint release assets for prerelease tag"
build:
if: ${{ inputs.publish }}
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
archive_name: argument-comment-lint-aarch64-apple-darwin.tar.gz
lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-apple-darwin.dylib
runner_binary: argument-comment-lint
cargo_dylint_binary: cargo-dylint
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
archive_name: argument-comment-lint-x86_64-unknown-linux-gnu.tar.gz
lib_name: libargument_comment_lint@nightly-2025-09-18-x86_64-unknown-linux-gnu.so
runner_binary: argument-comment-lint
cargo_dylint_binary: cargo-dylint
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
archive_name: argument-comment-lint-aarch64-unknown-linux-gnu.tar.gz
lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-unknown-linux-gnu.so
runner_binary: argument-comment-lint
cargo_dylint_binary: cargo-dylint
- runner: windows-x64
target: x86_64-pc-windows-msvc
archive_name: argument-comment-lint-x86_64-pc-windows-msvc.zip
lib_name: argument_comment_lint@nightly-2025-09-18-x86_64-pc-windows-msvc.dll
runner_binary: argument-comment-lint.exe
cargo_dylint_binary: cargo-dylint.exe
runs_on:
group: codex-runners
labels: codex-windows-x64
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93.0
with:
toolchain: nightly-2025-09-18
targets: ${{ matrix.target }}
components: llvm-tools-preview, rustc-dev, rust-src
- name: Install tooling
shell: bash
run: |
install_root="${RUNNER_TEMP}/argument-comment-lint-tools"
cargo install --locked cargo-dylint --root "$install_root"
cargo install --locked dylint-link
echo "INSTALL_ROOT=$install_root" >> "$GITHUB_ENV"
- name: Cargo build
working-directory: tools/argument-comment-lint
shell: bash
run: cargo build --release --target ${{ matrix.target }}
- name: Stage artifact
shell: bash
run: |
dest="dist/argument-comment-lint/${{ matrix.target }}"
mkdir -p "$dest"
package_root="${RUNNER_TEMP}/argument-comment-lint"
rm -rf "$package_root"
mkdir -p "$package_root/bin" "$package_root/lib"
cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.runner_binary }}" \
"$package_root/bin/${{ matrix.runner_binary }}"
cp "${INSTALL_ROOT}/bin/${{ matrix.cargo_dylint_binary }}" \
"$package_root/bin/${{ matrix.cargo_dylint_binary }}"
cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.lib_name }}" \
"$package_root/lib/${{ matrix.lib_name }}"
archive_path="$dest/${{ matrix.archive_name }}"
if [[ "${{ runner.os }}" == "Windows" ]]; then
(cd "${RUNNER_TEMP}" && 7z a "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint >/dev/null)
else
(cd "${RUNNER_TEMP}" && tar -czf "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint)
fi
- uses: actions/upload-artifact@v7
with:
name: argument-comment-lint-${{ matrix.target }}
path: dist/argument-comment-lint/${{ matrix.target }}/*

95
.github/workflows/rust-release-zsh.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: rust-release-zsh
on:
workflow_call:
env:
ZSH_COMMIT: 77045ef899e53b9598bebc5a41db93a548a40ca6
ZSH_PATCH: codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch
jobs:
linux:
name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-24.04
image: ubuntu:24.04
archive_name: codex-zsh-x86_64-unknown-linux-musl.tar.gz
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-24.04
image: arm64v8/ubuntu:24.04
archive_name: codex-zsh-aarch64-unknown-linux-musl.tar.gz
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
autoconf \
bison \
build-essential \
ca-certificates \
gettext \
git \
libncursesw5-dev
- uses: actions/checkout@v6
- name: Build, smoke-test, and stage zsh artifact
shell: bash
run: |
"${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \
"dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}"
- uses: actions/upload-artifact@v7
with:
name: codex-zsh-${{ matrix.target }}
path: dist/zsh/${{ matrix.target }}/*
darwin:
name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
archive_name: codex-zsh-aarch64-apple-darwin.tar.gz
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if ! command -v autoconf >/dev/null 2>&1; then
brew install autoconf
fi
- uses: actions/checkout@v6
- name: Build, smoke-test, and stage zsh artifact
shell: bash
run: |
"${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \
"dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}"
- uses: actions/upload-artifact@v7
with:
name: codex-zsh-${{ matrix.target }}
path: dist/zsh/${{ matrix.target }}/*

View File

@@ -210,6 +210,24 @@ jobs:
echo "CFLAGS=${cflags}" >> "$GITHUB_ENV"
echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }}
name: Configure musl rusty_v8 artifact overrides
env:
TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)"
release_tag="rusty-v8-v${version}"
base_url="https://github.com/openai/codex/releases/download/${release_tag}"
archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz"
binding_dir="${RUNNER_TEMP}/rusty_v8"
binding_path="${binding_dir}/src_binding_release_${TARGET}.rs"
mkdir -p "${binding_dir}"
curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}"
echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV"
echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV"
- name: Cargo build
shell: bash
run: |
@@ -371,20 +389,24 @@ jobs:
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
secrets: inherit
shell-tool-mcp:
name: shell-tool-mcp
argument-comment-lint-release-assets:
name: argument-comment-lint release assets
needs: tag-check
uses: ./.github/workflows/shell-tool-mcp.yml
uses: ./.github/workflows/rust-release-argument-comment-lint.yml
with:
release-tag: ${{ github.ref_name }}
publish: true
secrets: inherit
zsh-release-assets:
name: zsh release assets
needs: tag-check
uses: ./.github/workflows/rust-release-zsh.yml
release:
needs:
- build
- build-windows
- shell-tool-mcp
- argument-comment-lint-release-assets
- zsh-release-assets
name: release
runs-on: ubuntu-latest
permissions:
@@ -427,11 +449,8 @@ jobs:
- name: List
run: ls -R dist/
# This is a temporary fix: we should modify shell-tool-mcp.yml so these
# files do not end up in dist/ in the first place.
- name: Delete entries from dist/ that should not go in the release
run: |
rm -rf dist/shell-tool-mcp*
rm -rf dist/windows-binaries*
# cargo-timing.html appears under multiple target-specific directories.
# If included in files: dist/**, release upload races on duplicate
@@ -473,7 +492,7 @@ jobs:
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
run_install: false
@@ -521,6 +540,20 @@ jobs:
tag: ${{ github.ref_name }}
config: .github/dotslash-config.json
- uses: facebook/dotslash-publish-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-zsh-config.json
- uses: facebook/dotslash-publish-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-argument-comment-lint-config.json
- name: Trigger developers.openai.com deploy
# Only trigger the deploy if the release is not a pre-release.
# The deploy is used to update the developers.openai.com website with the new config schema json file.
@@ -660,7 +693,7 @@ jobs:
steps:
- name: Publish to WinGet
uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f
uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5
with:
identifier: OpenAI.Codex
version: ${{ needs.release.outputs.version }}

188
.github/workflows/rusty-v8-release.yml vendored Normal file
View File

@@ -0,0 +1,188 @@
name: rusty-v8-release
on:
workflow_dispatch:
inputs:
release_tag:
description: Optional release tag. Defaults to rusty-v8-v<resolved_v8_version>.
required: false
type: string
publish:
description: Publish the staged musl artifacts to a GitHub release.
required: false
default: true
type: boolean
concurrency:
group: ${{ github.workflow }}::${{ inputs.release_tag || github.run_id }}
cancel-in-progress: false
jobs:
metadata:
runs-on: ubuntu-latest
outputs:
release_tag: ${{ steps.release_tag.outputs.release_tag }}
v8_version: ${{ steps.v8_version.outputs.version }}
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Resolve exact v8 crate version
id: v8_version
shell: bash
run: |
set -euo pipefail
version="$(python3 .github/scripts/rusty_v8_bazel.py resolved-v8-crate-version)"
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Resolve release tag
id: release_tag
env:
RELEASE_TAG_INPUT: ${{ inputs.release_tag }}
V8_VERSION: ${{ steps.v8_version.outputs.version }}
shell: bash
run: |
set -euo pipefail
release_tag="${RELEASE_TAG_INPUT}"
if [[ -z "${release_tag}" ]]; then
release_tag="rusty-v8-v${V8_VERSION}"
fi
echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
platform: linux_amd64_musl
target: x86_64-unknown-linux-musl
- runner: ubuntu-24.04-arm
platform: linux_arm64_musl
target: aarch64-unknown-linux-musl
steps:
- uses: actions/checkout@v6
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Build Bazel V8 release pair
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
PLATFORM: ${{ matrix.platform }}
TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
target_suffix="${TARGET//-/_}"
pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}"
extra_targets=()
if [[ "${TARGET}" == *-unknown-linux-musl ]]; then
extra_targets=(
"@llvm//runtimes/libcxx:libcxx.static"
"@llvm//runtimes/libcxx:libcxxabi.static"
)
fi
bazel_args=(
build
-c
opt
"--platforms=@llvm//platforms:${PLATFORM}"
"${pair_target}"
"${extra_targets[@]}"
--build_metadata=COMMIT_SHA=$(git rev-parse HEAD)
)
bazel \
--noexperimental_remote_repo_contents_cache \
--bazelrc=.github/workflows/v8-ci.bazelrc \
"${bazel_args[@]}" \
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
- name: Stage release pair
env:
PLATFORM: ${{ matrix.platform }}
TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \
--platform "${PLATFORM}" \
--target "${TARGET}" \
--compilation-mode opt \
--output-dir "dist/${TARGET}"
- name: Upload staged musl artifacts
uses: actions/upload-artifact@v7
with:
name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }}
path: dist/${{ matrix.target }}/*
publish-release:
if: ${{ inputs.publish }}
needs:
- metadata
- build
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- name: Ensure publishing from default branch
if: ${{ github.ref_name != github.event.repository.default_branch }}
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
shell: bash
run: |
set -euo pipefail
echo "Publishing is only allowed from ${DEFAULT_BRANCH}; current ref is ${GITHUB_REF_NAME}." >&2
exit 1
- name: Ensure release tag is new
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.metadata.outputs.release_tag }}
shell: bash
run: |
set -euo pipefail
if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then
echo "Release tag ${RELEASE_TAG} already exists; musl artifact tags are immutable." >&2
exit 1
fi
- uses: actions/download-artifact@v8
with:
path: dist
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.metadata.outputs.release_tag }}
name: ${{ needs.metadata.outputs.release_tag }}
files: dist/**
# Keep V8 artifact releases out of Codex's normal "latest release" channel.
prerelease: true

View File

@@ -23,7 +23,7 @@ jobs:
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
run_install: false

View File

@@ -1,48 +0,0 @@
name: shell-tool-mcp CI
on:
push:
paths:
- "shell-tool-mcp/**"
- ".github/workflows/shell-tool-mcp-ci.yml"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
pull_request:
paths:
- "shell-tool-mcp/**"
- ".github/workflows/shell-tool-mcp-ci.yml"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
env:
NODE_VERSION: 22
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Format check
run: pnpm --filter @openai/codex-shell-tool-mcp run format
- name: Run tests
run: pnpm --filter @openai/codex-shell-tool-mcp test
- name: Build
run: pnpm --filter @openai/codex-shell-tool-mcp run build

View File

@@ -1,553 +0,0 @@
name: shell-tool-mcp
on:
workflow_call:
inputs:
release-version:
description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v.
required: false
type: string
release-tag:
description: Tag name to use when downloading release artifacts (defaults to rust-v<version>).
required: false
type: string
publish:
description: Whether to publish to npm when the version is releasable.
required: false
default: true
type: boolean
env:
NODE_VERSION: 22
jobs:
metadata:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.compute.outputs.version }}
release_tag: ${{ steps.compute.outputs.release_tag }}
should_publish: ${{ steps.compute.outputs.should_publish }}
npm_tag: ${{ steps.compute.outputs.npm_tag }}
steps:
- name: Compute version and tags
id: compute
env:
RELEASE_TAG_INPUT: ${{ inputs.release-tag }}
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
run: |
set -euo pipefail
version="$RELEASE_VERSION_INPUT"
release_tag="$RELEASE_TAG_INPUT"
if [[ -z "$version" ]]; then
if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then
version="${release_tag#rust-v}"
elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then
version="${GITHUB_REF_NAME#rust-v}"
release_tag="${GITHUB_REF_NAME}"
else
echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag."
exit 1
fi
fi
if [[ -z "$release_tag" ]]; then
release_tag="rust-v${version}"
fi
npm_tag=""
should_publish="false"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
should_publish="true"
elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
should_publish="true"
npm_tag="alpha"
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT"
echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT"
echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT"
bash-linux:
name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-24.04
image: ubuntu:24.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-22.04
image: ubuntu:22.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-12
image: debian:12
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-11
image: debian:11
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-24.04
image: arm64v8/ubuntu:24.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-22.04
image: arm64v8/ubuntu:22.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-20.04
image: arm64v8/ubuntu:20.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-12
image: arm64v8/debian:12
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-11
image: arm64v8/debian:11
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev
elif command -v dnf >/dev/null 2>&1; then
dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
elif command -v yum >/dev/null 2>&1; then
yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
else
echo "Unsupported package manager in container"
exit 1
fi
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched Bash
shell: bash
run: |
set -euo pipefail
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
cd /tmp/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}"
mkdir -p "$dest"
cp bash "$dest/bash"
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
bash-darwin:
name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
- runner: macos-14
target: aarch64-apple-darwin
variant: macos-14
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched Bash
shell: bash
run: |
set -euo pipefail
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
cd /tmp/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
cores="$(getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}"
mkdir -p "$dest"
cp bash "$dest/bash"
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
zsh-linux:
name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-24.04
image: ubuntu:24.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-22.04
image: ubuntu:22.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-12
image: debian:12
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-11
image: debian:11
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-24.04
image: arm64v8/ubuntu:24.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-22.04
image: arm64v8/ubuntu:22.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-20.04
image: arm64v8/ubuntu:20.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-12
image: arm64v8/debian:12
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-11
image: arm64v8/debian:11
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev
elif command -v dnf >/dev/null 2>&1; then
dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
elif command -v yum >/dev/null 2>&1; then
yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
else
echo "Unsupported package manager in container"
exit 1
fi
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched zsh
shell: bash
run: |
set -euo pipefail
git clone https://git.code.sf.net/p/zsh/code /tmp/zsh
cd /tmp/zsh
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch"
./Util/preconfig
./configure
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}"
mkdir -p "$dest"
cp Src/zsh "$dest/zsh"
- name: Smoke test zsh exec wrapper
shell: bash
run: |
set -euo pipefail
tmpdir="$(mktemp -d)"
cat > "$tmpdir/exec-wrapper" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$tmpdir/exec-wrapper"
CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \
EXEC_WRAPPER="$tmpdir/exec-wrapper" \
/tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt"
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
zsh-darwin:
name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
- runner: macos-14
target: aarch64-apple-darwin
variant: macos-14
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if ! command -v autoconf >/dev/null 2>&1; then
brew install autoconf
fi
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched zsh
shell: bash
run: |
set -euo pipefail
git clone https://git.code.sf.net/p/zsh/code /tmp/zsh
cd /tmp/zsh
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch"
./Util/preconfig
./configure
cores="$(getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}"
mkdir -p "$dest"
cp Src/zsh "$dest/zsh"
- name: Smoke test zsh exec wrapper
shell: bash
run: |
set -euo pipefail
tmpdir="$(mktemp -d)"
cat > "$tmpdir/exec-wrapper" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$tmpdir/exec-wrapper"
CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \
EXEC_WRAPPER="$tmpdir/exec-wrapper" \
/tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt"
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
package:
name: Package npm module
needs:
- metadata
- bash-linux
- bash-darwin
- zsh-linux
- zsh-darwin
runs-on: ubuntu-latest
env:
PACKAGE_VERSION: ${{ needs.metadata.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install JavaScript dependencies
run: pnpm install --frozen-lockfile
- name: Build (shell-tool-mcp)
run: pnpm --filter @openai/codex-shell-tool-mcp run build
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Assemble staging directory
id: staging
shell: bash
run: |
set -euo pipefail
staging="${STAGING_DIR}"
mkdir -p "$staging" "$staging/vendor"
cp shell-tool-mcp/README.md "$staging/"
cp shell-tool-mcp/package.json "$staging/"
found_vendor="false"
shopt -s nullglob
for vendor_dir in artifacts/*/vendor; do
rsync -av "$vendor_dir/" "$staging/vendor/"
found_vendor="true"
done
if [[ "$found_vendor" == "false" ]]; then
echo "No vendor payloads were downloaded."
exit 1
fi
node - <<'NODE'
import fs from "node:fs";
import path from "node:path";
const stagingDir = process.env.STAGING_DIR;
const version = process.env.PACKAGE_VERSION;
const pkgPath = path.join(stagingDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
pkg.version = version;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
NODE
echo "dir=$staging" >> "$GITHUB_OUTPUT"
env:
STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp
- name: Ensure binaries are executable
env:
STAGING_DIR: ${{ steps.staging.outputs.dir }}
run: |
set -euo pipefail
chmod +x \
"$STAGING_DIR"/vendor/*/bash/*/bash \
"$STAGING_DIR"/vendor/*/zsh/*/zsh
- name: Create npm tarball
shell: bash
env:
STAGING_DIR: ${{ steps.staging.outputs.dir }}
run: |
set -euo pipefail
mkdir -p dist/npm
pack_info=$(cd "$STAGING_DIR" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm")
filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);')
mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz"
- uses: actions/upload-artifact@v7
with:
name: codex-shell-tool-mcp-npm
path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz
if-no-files-found: error
publish:
name: Publish npm package
needs:
- metadata
- package
if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }}
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
scope: "@openai"
# Trusted publishing requires npm CLI version 11.5.1 or later.
- name: Update npm
run: npm install -g npm@latest
- name: Download npm tarball
uses: actions/download-artifact@v8
with:
name: codex-shell-tool-mcp-npm
path: dist/npm
- name: Publish to npm
env:
NPM_TAG: ${{ needs.metadata.outputs.npm_tag }}
VERSION: ${{ needs.metadata.outputs.version }}
shell: bash
run: |
set -euo pipefail
tag_args=()
if [[ -n "${NPM_TAG}" ]]; then
tag_args+=(--tag "${NPM_TAG}")
fi
npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}"

132
.github/workflows/v8-canary.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
name: v8-canary
on:
pull_request:
paths:
- ".github/scripts/rusty_v8_bazel.py"
- ".github/workflows/rusty-v8-release.yml"
- ".github/workflows/v8-canary.yml"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- "codex-rs/Cargo.toml"
- "patches/BUILD.bazel"
- "patches/v8_*.patch"
- "third_party/v8/**"
push:
branches:
- main
paths:
- ".github/scripts/rusty_v8_bazel.py"
- ".github/workflows/rusty-v8-release.yml"
- ".github/workflows/v8-canary.yml"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- "codex-rs/Cargo.toml"
- "patches/BUILD.bazel"
- "patches/v8_*.patch"
- "third_party/v8/**"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}
cancel-in-progress: ${{ github.ref_name != 'main' }}
jobs:
metadata:
runs-on: ubuntu-latest
outputs:
v8_version: ${{ steps.v8_version.outputs.version }}
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Resolve exact v8 crate version
id: v8_version
shell: bash
run: |
set -euo pipefail
version="$(python3 .github/scripts/rusty_v8_bazel.py resolved-v8-crate-version)"
echo "version=${version}" >> "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
platform: linux_amd64_musl
target: x86_64-unknown-linux-musl
- runner: ubuntu-24.04-arm
platform: linux_arm64_musl
target: aarch64-unknown-linux-musl
steps:
- uses: actions/checkout@v6
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Build Bazel V8 release pair
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
PLATFORM: ${{ matrix.platform }}
TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
target_suffix="${TARGET//-/_}"
pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}"
extra_targets=(
"@llvm//runtimes/libcxx:libcxx.static"
"@llvm//runtimes/libcxx:libcxxabi.static"
)
bazel_args=(
build
"--platforms=@llvm//platforms:${PLATFORM}"
"${pair_target}"
"${extra_targets[@]}"
--build_metadata=COMMIT_SHA=$(git rev-parse HEAD)
)
bazel \
--noexperimental_remote_repo_contents_cache \
--bazelrc=.github/workflows/v8-ci.bazelrc \
"${bazel_args[@]}" \
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
- name: Stage release pair
env:
PLATFORM: ${{ matrix.platform }}
TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \
--platform "${PLATFORM}" \
--target "${TARGET}" \
--output-dir "dist/${TARGET}"
- name: Upload staged musl artifacts
uses: actions/upload-artifact@v7
with:
name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }}
path: dist/${{ matrix.target }}/*

5
.github/workflows/v8-ci.bazelrc vendored Normal file
View File

@@ -0,0 +1,5 @@
import %workspace%/.github/workflows/ci.bazelrc
common --build_metadata=REPO_URL=https://github.com/openai/codex.git
common --build_metadata=ROLE=CI
common --build_metadata=VISIBILITY=PUBLIC

View File

@@ -48,6 +48,8 @@ Run `just fmt` (in `codex-rs` directory) automatically after you have finished m
Before finalizing a large change to `codex-rs`, run `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspacewide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Do not re-run tests after running `fix` or `fmt`.
Also run `just argument-comment-lint` to ensure the codebase is clean of comment lint errors.
## TUI style conventions
See `codex-rs/tui/styles.md`.

View File

@@ -1,31 +1,42 @@
module(name = "codex")
bazel_dep(name = "bazel_skylib", version = "1.8.2")
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "llvm", version = "0.6.7")
bazel_dep(name = "llvm", version = "0.6.8")
register_toolchains("@llvm//toolchain:all")
osx = use_extension("@llvm//extensions:osx.bzl", "osx")
osx.framework(name = "ApplicationServices")
osx.framework(name = "AppKit")
osx.framework(name = "ColorSync")
osx.framework(name = "CoreFoundation")
osx.framework(name = "CoreGraphics")
osx.framework(name = "CoreServices")
osx.framework(name = "CoreText")
osx.framework(name = "AudioToolbox")
osx.framework(name = "CFNetwork")
osx.framework(name = "FontServices")
osx.framework(name = "AudioUnit")
osx.framework(name = "CoreAudio")
osx.framework(name = "CoreAudioTypes")
osx.framework(name = "Foundation")
osx.framework(name = "ImageIO")
osx.framework(name = "IOKit")
osx.framework(name = "Kernel")
osx.framework(name = "OSLog")
osx.framework(name = "Security")
osx.framework(name = "SystemConfiguration")
osx.from_archive(
sha256 = "6a4922f89487a96d7054ec6ca5065bfddd9f1d017c74d82f1d79cecf7feb8228",
strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.2.sdk",
type = "pkg",
urls = [
"https://swcdn.apple.com/content/downloads/26/44/047-81934-A_28TPKM5SD1/ps6pk6dk4x02vgfa5qsctq6tgf23t5f0w2/CLTools_macOSNMOS_SDK.pkg",
],
)
osx.frameworks(names = [
"ApplicationServices",
"AppKit",
"ColorSync",
"CoreFoundation",
"CoreGraphics",
"CoreServices",
"CoreText",
"AudioToolbox",
"CFNetwork",
"FontServices",
"AudioUnit",
"CoreAudio",
"CoreAudioTypes",
"Foundation",
"ImageIO",
"IOKit",
"Kernel",
"OSLog",
"Security",
"SystemConfiguration",
])
use_repo(osx, "macos_sdk")
# Needed to disable xcode...
@@ -132,6 +143,35 @@ crate.annotation(
workspace_cargo_toml = "rust/runfiles/Cargo.toml",
)
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
new_local_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:local.bzl", "new_local_repository")
new_local_repository(
name = "v8_targets",
build_file = "//third_party/v8:BUILD.bazel",
path = "third_party/v8",
)
crate.annotation(
build_script_data = [
"@v8_targets//:rusty_v8_archive_for_target",
"@v8_targets//:rusty_v8_binding_for_target",
],
build_script_env = {
"RUSTY_V8_ARCHIVE": "$(execpath @v8_targets//:rusty_v8_archive_for_target)",
"RUSTY_V8_SRC_BINDING_PATH": "$(execpath @v8_targets//:rusty_v8_binding_for_target)",
},
crate = "v8",
gen_build_script = "on",
patch_args = ["-p1"],
patches = [
"//patches:rusty_v8_prebuilt_out_dir.patch",
],
)
inject_repo(crate, "v8_targets")
llvm = use_extension("@llvm//extensions:llvm.bzl", "llvm")
use_repo(llvm, "llvm-project")
@@ -174,6 +214,109 @@ crate.annotation(
inject_repo(crate, "alsa_lib")
bazel_dep(name = "v8", version = "14.6.202.9")
archive_override(
module_name = "v8",
integrity = "sha256-JphDwLAzsd9KvgRZ7eQvNtPU6qGd3XjFt/a/1QITAJU=",
patch_strip = 3,
patches = [
"//patches:v8_module_deps.patch",
"//patches:v8_bazel_rules.patch",
"//patches:v8_source_portability.patch",
],
strip_prefix = "v8-14.6.202.9",
urls = ["https://github.com/v8/v8/archive/refs/tags/14.6.202.9.tar.gz"],
)
http_archive(
name = "v8_crate_146_4_0",
build_file = "//third_party/v8:v8_crate.BUILD.bazel",
sha256 = "d97bcac5cdc5a195a4813f1855a6bc658f240452aac36caa12fd6c6f16026ab1",
strip_prefix = "v8-146.4.0",
type = "tar.gz",
urls = ["https://static.crates.io/crates/v8/v8-146.4.0.crate"],
)
http_file(
name = "rusty_v8_146_4_0_aarch64_apple_darwin_archive",
downloaded_file_path = "librusty_v8_release_aarch64-apple-darwin.a.gz",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-apple-darwin.a.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_aarch64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-gnu.a.gz",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-unknown-linux-gnu.a.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_aarch64_pc_windows_msvc_archive",
downloaded_file_path = "rusty_v8_release_aarch64-pc-windows-msvc.lib.gz",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_x86_64_apple_darwin_archive",
downloaded_file_path = "librusty_v8_release_x86_64-apple-darwin.a.gz",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-apple-darwin.a.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_x86_64_pc_windows_msvc_archive",
downloaded_file_path = "rusty_v8_release_x86_64-pc-windows-msvc.lib.gz",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_archive",
downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-musl.a.gz",
urls = [
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_aarch64-unknown-linux-musl.a.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_binding",
downloaded_file_path = "src_binding_release_aarch64-unknown-linux-musl.rs",
urls = [
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_aarch64-unknown-linux-musl.rs",
],
)
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-musl.a.gz",
urls = [
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_x86_64-unknown-linux-musl.a.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding",
downloaded_file_path = "src_binding_release_x86_64-unknown-linux-musl.rs",
urls = [
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_x86_64-unknown-linux-musl.rs",
],
)
use_repo(crate, "crates")
bazel_dep(name = "libcap", version = "2.27.bcr.1")

51
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

4
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.gradle/
local.properties
**/build/
*.iml

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 791 KiB

View File

@@ -0,0 +1,122 @@
import org.gradle.api.GradleException
import org.gradle.api.tasks.Sync
plugins {
id("com.android.application")
}
val minAndroidJavaVersion = 17
val maxAndroidJavaVersion = 21
val hostJavaMajorVersion = JavaVersion.current().majorVersion.toIntOrNull()
?: throw GradleException("Unable to determine Java version from ${JavaVersion.current()}.")
if (hostJavaMajorVersion < minAndroidJavaVersion) {
throw GradleException(
"Android service build requires Java ${minAndroidJavaVersion}+ (tested through Java ${maxAndroidJavaVersion}). Found Java ${hostJavaMajorVersion}."
)
}
val androidJavaTargetVersion = hostJavaMajorVersion.coerceAtMost(maxAndroidJavaVersion)
val androidJavaVersion = JavaVersion.toVersion(androidJavaTargetVersion)
android {
namespace = "com.openai.codex.agent"
compileSdk = 34
defaultConfig {
applicationId = "com.openai.codex.agent"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = androidJavaVersion
targetCompatibility = androidJavaVersion
}
packaging {
jniLibs.useLegacyPackaging = true
}
}
val repoRoot = rootProject.projectDir.parentFile
val skipAndroidLto = providers
.gradleProperty("codexAndroidSkipLto")
.orElse(providers.environmentVariable("CODEX_ANDROID_SKIP_LTO"))
.orNull
?.let { it == "1" || it.equals("true", ignoreCase = true) }
?: false
val codexCargoProfileDir = if (skipAndroidLto) "android-release-no-lto" else "release"
val agentPlatformStubSdkZip = providers
.gradleProperty("agentPlatformStubSdkZip")
.orElse(providers.environmentVariable("ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP"))
val extractedAgentPlatformJar = layout.buildDirectory.file(
"generated/agent-platform/android-agent-platform-stub-sdk.jar"
)
val codexTargets = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"x86_64" to "x86_64-linux-android",
)
val codexJniDir = layout.buildDirectory.dir("generated/codex-jni")
val extractAgentPlatformStubSdk = tasks.register<Sync>("extractAgentPlatformStubSdk") {
val sdkZip = agentPlatformStubSdkZip.orNull
?: throw GradleException(
"Set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or -PagentPlatformStubSdkZip to the Android Agent Platform stub SDK zip."
)
val outputDir = extractedAgentPlatformJar.get().asFile.parentFile
from(zipTree(sdkZip)) {
include("payloads/compile_only/android-agent-platform-stub-sdk.jar")
eachFile { path = name }
includeEmptyDirs = false
}
into(outputDir)
}
val syncCodexCliJniLibs = tasks.register<Sync>("syncCodexCliJniLibs") {
val outputDir = codexJniDir
into(outputDir)
dependsOn(rootProject.tasks.named("buildCodexCliNative"))
codexTargets.forEach { (abi, triple) ->
val binary = file("${repoRoot}/codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
from(binary) {
into(abi)
rename { "libcodex.so" }
}
}
doFirst {
codexTargets.forEach { (abi, triple) ->
val binary = file("${repoRoot}/codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
if (!binary.exists()) {
throw GradleException(
"Missing codex binary for ${abi} at ${binary}. The Gradle native build task should have produced it."
)
}
}
}
}
android.sourceSets["main"].jniLibs.srcDir(codexJniDir.get().asFile)
tasks.named("preBuild").configure {
dependsOn(syncCodexCliJniLibs)
dependsOn(extractAgentPlatformStubSdk)
}
dependencies {
implementation(project(":bridge"))
compileOnly(files(extractedAgentPlatformJar))
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
}

1
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Keep empty for now.

View File

@@ -0,0 +1,67 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.DUMP" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.MANAGE_AGENTS" />
<uses-permission android:name="android.permission.START_AGENT_REQUESTS" />
<uses-permission android:name="android.permission.START_GENIE_EXECUTION" />
<uses-permission android:name="android.permission.OBSERVE_AGENT_SESSIONS" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:label="@string/app_name"
android:allowBackup="false"
android:extractNativeLibs="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
<service
android:name=".CodexAgentService"
android:exported="true"
android:permission="android.permission.BIND_AGENT_SERVICE">
<intent-filter>
<action android:name="android.app.agent.AgentService" />
</intent-filter>
</service>
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".CreateSessionActivity"
android:exported="true"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:taskAffinity="com.openai.codex.agent.create"
android:theme="@style/CodexCreateSessionTheme">
<intent-filter>
<action android:name="com.openai.codex.agent.action.CREATE_SESSION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.app.agent.action.HANDLE_SESSION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".SessionDetailActivity"
android:exported="false"
android:launchMode="singleTop" />
</application>
</manifest>

View File

@@ -0,0 +1,781 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.content.Context
import android.util.Log
import com.openai.codex.bridge.HostedCodexConfig
import com.openai.codex.bridge.SessionExecutionSettings
import java.io.BufferedWriter
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
import org.json.JSONArray
import org.json.JSONObject
object AgentCodexAppServerClient {
private const val TAG = "AgentCodexClient"
private const val REQUEST_TIMEOUT_MS = 30_000L
private const val DEFAULT_AGENT_MODEL = "gpt-5.3-codex"
private const val AGENT_APP_SERVER_RUST_LOG = "warn"
data class RuntimeStatus(
val authenticated: Boolean,
val accountEmail: String?,
val clientCount: Int,
val modelProviderId: String,
val configuredModel: String?,
val effectiveModel: String?,
val upstreamBaseUrl: String,
val frameworkResponsesPath: String,
)
data class ChatGptLoginSession(
val loginId: String,
val authUrl: String,
)
fun interface RuntimeStatusListener {
fun onRuntimeStatusChanged(status: RuntimeStatus?)
}
private val lifecycleLock = Any()
private val requestIdSequence = AtomicInteger(1)
private val activeRequests = AtomicInteger(0)
private val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
private val notifications = LinkedBlockingQueue<JSONObject>()
private val runtimeStatusListeners = CopyOnWriteArraySet<RuntimeStatusListener>()
private var process: Process? = null
private var writer: BufferedWriter? = null
private var stdoutThread: Thread? = null
private var stderrThread: Thread? = null
private var localProxy: AgentLocalCodexProxy? = null
private var initialized = false
@Volatile
private var cachedRuntimeStatus: RuntimeStatus? = null
@Volatile
private var applicationContext: Context? = null
@Volatile
private var activeFrameworkSessionId: String? = null
private val runtimeStatusRefreshInFlight = AtomicBoolean(false)
fun currentRuntimeStatus(): RuntimeStatus? = cachedRuntimeStatus
fun registerRuntimeStatusListener(listener: RuntimeStatusListener) {
runtimeStatusListeners += listener
listener.onRuntimeStatusChanged(cachedRuntimeStatus)
}
fun unregisterRuntimeStatusListener(listener: RuntimeStatusListener) {
runtimeStatusListeners -= listener
}
fun refreshRuntimeStatusAsync(
context: Context,
refreshToken: Boolean = false,
) {
if (!runtimeStatusRefreshInFlight.compareAndSet(false, true)) {
return
}
thread(name = "AgentRuntimeStatusRefresh") {
try {
runCatching {
readRuntimeStatus(context, refreshToken)
}.onFailure {
updateCachedRuntimeStatus(null)
}
} finally {
runtimeStatusRefreshInFlight.set(false)
}
}
}
fun requestText(
context: Context,
instructions: String,
prompt: String,
outputSchema: JSONObject? = null,
dynamicTools: JSONArray? = null,
toolCallHandler: ((String, JSONObject) -> JSONObject)? = null,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
requestTimeoutMs: Long = REQUEST_TIMEOUT_MS,
frameworkSessionId: String? = null,
): String = synchronized(lifecycleLock) {
ensureStarted(context.applicationContext)
val previousFrameworkSessionId = activeFrameworkSessionId
activeFrameworkSessionId = frameworkSessionId?.trim()?.ifEmpty { null }
activeRequests.incrementAndGet()
updateClientCount()
try {
Log.i(
TAG,
"requestText start tools=${dynamicTools?.length() ?: 0} prompt=${prompt.take(160)}",
)
notifications.clear()
val threadId = startThread(
context = context.applicationContext,
instructions = instructions,
dynamicTools = dynamicTools,
executionSettings = executionSettings,
)
startTurn(
threadId = threadId,
prompt = prompt,
outputSchema = outputSchema,
executionSettings = executionSettings,
)
waitForTurnCompletion(toolCallHandler, requestUserInputHandler, requestTimeoutMs).also { response ->
Log.i(TAG, "requestText completed response=${response.take(160)}")
}
} finally {
activeRequests.decrementAndGet()
updateClientCount()
activeFrameworkSessionId = previousFrameworkSessionId
}
}
fun readRuntimeStatus(
context: Context,
refreshToken: Boolean = false,
): RuntimeStatus = synchronized(lifecycleLock) {
ensureStarted(context.applicationContext)
activeRequests.incrementAndGet()
updateClientCount()
try {
val accountResponse = request(
method = "account/read",
params = JSONObject().put("refreshToken", refreshToken),
)
val configResponse = request(
method = "config/read",
params = JSONObject().put("includeLayers", false),
)
parseRuntimeStatus(context.applicationContext, accountResponse, configResponse)
.also(::updateCachedRuntimeStatus)
} finally {
activeRequests.decrementAndGet()
updateClientCount()
}
}
fun startChatGptLogin(context: Context): ChatGptLoginSession = synchronized(lifecycleLock) {
ensureStarted(context.applicationContext)
val response = request(
method = "account/login/start",
params = JSONObject().put("type", "chatgpt"),
)
if (response.optString("type") != "chatgpt") {
throw IOException("Unexpected login response type: ${response.optString("type")}")
}
return ChatGptLoginSession(
loginId = response.optString("loginId"),
authUrl = response.optString("authUrl"),
)
}
fun logoutAccount(context: Context) = synchronized(lifecycleLock) {
ensureStarted(context.applicationContext)
request(
method = "account/logout",
params = null,
)
refreshRuntimeStatusAsync(context.applicationContext)
}
fun listModels(context: Context): List<AgentModelOption> = synchronized(lifecycleLock) {
ensureStarted(context.applicationContext)
val models = mutableListOf<AgentModelOption>()
var cursor: String? = null
do {
val result = request(
method = "model/list",
params = JSONObject().apply {
put("includeHidden", false)
cursor?.let { put("cursor", it) }
},
)
val data = result.optJSONArray("data") ?: JSONArray()
for (index in 0 until data.length()) {
val item = data.optJSONObject(index) ?: continue
models += AgentModelOption(
id = item.optString("id"),
model = item.optString("model"),
displayName = item.optString("displayName").ifBlank { item.optString("model") },
description = item.optString("description"),
supportedReasoningEfforts = buildList {
val efforts = item.optJSONArray("supportedReasoningEfforts") ?: JSONArray()
for (effortIndex in 0 until efforts.length()) {
val effort = efforts.optJSONObject(effortIndex) ?: continue
add(
AgentReasoningEffortOption(
reasoningEffort = effort.optString("reasoningEffort"),
description = effort.optString("description"),
),
)
}
},
defaultReasoningEffort = item.optString("defaultReasoningEffort"),
isDefault = item.optBoolean("isDefault"),
)
}
cursor = result.optNullableString("nextCursor")
} while (cursor != null)
models
}
private fun ensureStarted(context: Context) {
if (process?.isAlive == true && writer != null && initialized) {
return
}
closeProcess()
applicationContext = context
notifications.clear()
pendingResponses.clear()
val codexHome = File(context.filesDir, "codex-home").apply(File::mkdirs)
localProxy = AgentLocalCodexProxy { requestBody ->
forwardResponsesRequest(context, requestBody)
}.also(AgentLocalCodexProxy::start)
val proxyBaseUrl = localProxy?.baseUrl
?: throw IOException("local Agent proxy did not start")
HostedCodexConfig.write(context, codexHome, proxyBaseUrl)
val startedProcess = ProcessBuilder(
listOf(
CodexCliBinaryLocator.resolve(context).absolutePath,
"-c",
"enable_request_compression=false",
"app-server",
"--listen",
"stdio://",
),
).apply {
environment()["CODEX_HOME"] = codexHome.absolutePath
environment()["RUST_LOG"] = AGENT_APP_SERVER_RUST_LOG
}.start()
process = startedProcess
writer = startedProcess.outputStream.bufferedWriter()
startStdoutPump(startedProcess)
startStderrPump(startedProcess)
initialize()
initialized = true
}
private fun closeProcess() {
stdoutThread?.interrupt()
stderrThread?.interrupt()
runCatching { writer?.close() }
writer = null
localProxy?.close()
localProxy = null
process?.destroy()
process = null
initialized = false
updateCachedRuntimeStatus(null)
}
private fun forwardResponsesRequest(
context: Context,
requestBody: String,
): AgentResponsesProxy.HttpResponse {
val frameworkSessionId = activeFrameworkSessionId
if (frameworkSessionId.isNullOrBlank()) {
return AgentResponsesProxy.sendResponsesRequest(context, requestBody)
}
val agentManager = context.getSystemService(AgentManager::class.java)
?: throw IOException("AgentManager unavailable for framework session transport")
return AgentResponsesProxy.sendResponsesRequestThroughFramework(
agentManager = agentManager,
sessionId = frameworkSessionId,
context = context,
requestBody = requestBody,
)
}
private fun initialize() {
request(
method = "initialize",
params = JSONObject()
.put(
"clientInfo",
JSONObject()
.put("name", "android_agent")
.put("title", "Android Agent")
.put("version", "0.1.0"),
)
.put("capabilities", JSONObject().put("experimentalApi", true)),
)
notify("initialized", JSONObject())
}
private fun startThread(
context: Context,
instructions: String,
dynamicTools: JSONArray?,
executionSettings: SessionExecutionSettings,
): String {
val params = JSONObject()
.put("approvalPolicy", "never")
.put("sandbox", "read-only")
.put("ephemeral", true)
.put("cwd", context.filesDir.absolutePath)
.put("serviceName", "android_agent")
.put("baseInstructions", instructions)
executionSettings.model
?.takeIf(String::isNotBlank)
?.let { params.put("model", it) }
if (dynamicTools != null) {
params.put("dynamicTools", dynamicTools)
}
val result = request(
method = "thread/start",
params = params,
)
return result.getJSONObject("thread").getString("id")
}
private fun startTurn(
threadId: String,
prompt: String,
outputSchema: JSONObject?,
executionSettings: SessionExecutionSettings,
) {
val turnParams = JSONObject()
.put("threadId", threadId)
.put(
"input",
JSONArray().put(
JSONObject()
.put("type", "text")
.put("text", prompt),
),
)
executionSettings.model
?.takeIf(String::isNotBlank)
?.let { turnParams.put("model", it) }
executionSettings.reasoningEffort
?.takeIf(String::isNotBlank)
?.let { turnParams.put("effort", it) }
if (outputSchema != null) {
turnParams.put("outputSchema", outputSchema)
}
request(
method = "turn/start",
params = turnParams,
)
}
private fun waitForTurnCompletion(
toolCallHandler: ((String, JSONObject) -> JSONObject)?,
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
requestTimeoutMs: Long,
): String {
val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
var finalAgentMessage: String? = null
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(requestTimeoutMs)
while (true) {
val remainingNanos = deadline - System.nanoTime()
if (remainingNanos <= 0L) {
throw IOException("Timed out waiting for Agent turn completion")
}
val notification = notifications.poll(remainingNanos, TimeUnit.NANOSECONDS)
if (notification == null) {
checkProcessAlive()
continue
}
if (notification.has("id") && notification.has("method")) {
handleServerRequest(notification, toolCallHandler, requestUserInputHandler)
continue
}
val params = notification.optJSONObject("params") ?: JSONObject()
when (notification.optString("method")) {
"item/agentMessage/delta" -> {
val itemId = params.optString("itemId")
if (itemId.isNotBlank()) {
streamedAgentMessages.getOrPut(itemId, ::StringBuilder)
.append(params.optString("delta"))
}
}
"item/commandExecution/outputDelta" -> {
val itemId = params.optString("itemId")
val delta = params.optString("delta")
if (delta.isNotBlank()) {
Log.i(
TAG,
"commandExecution/outputDelta itemId=$itemId delta=${delta.take(400)}",
)
}
}
"item/started" -> {
val item = params.optJSONObject("item")
Log.i(
TAG,
"item/started type=${item?.optString("type")} tool=${item?.optString("tool")}",
)
}
"item/completed" -> {
val item = params.optJSONObject("item") ?: continue
Log.i(
TAG,
"item/completed type=${item.optString("type")} status=${item.optString("status")} tool=${item.optString("tool")}",
)
if (item.optString("type") == "commandExecution") {
Log.i(TAG, "commandExecution/completed item=$item")
}
if (item.optString("type") == "agentMessage") {
val itemId = item.optString("id")
val text = item.optString("text").ifBlank {
streamedAgentMessages[itemId]?.toString().orEmpty()
}
if (text.isNotBlank()) {
finalAgentMessage = text
}
}
}
"turn/completed" -> {
val turn = params.optJSONObject("turn") ?: JSONObject()
Log.i(
TAG,
"turn/completed status=${turn.optString("status")} error=${turn.opt("error")} finalMessage=${finalAgentMessage?.take(160)}",
)
return when (turn.optString("status")) {
"completed" -> finalAgentMessage?.takeIf(String::isNotBlank)
?: throw IOException("Agent turn completed without an assistant message")
"interrupted" -> throw IOException("Agent turn interrupted")
else -> throw IOException(
turn.opt("error")?.toString()
?: "Agent turn failed with status ${turn.optString("status", "unknown")}",
)
}
}
}
}
}
private fun handleServerRequest(
message: JSONObject,
toolCallHandler: ((String, JSONObject) -> JSONObject)?,
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
) {
val requestId = message.opt("id") ?: return
val method = message.optString("method", "unknown")
val params = message.optJSONObject("params") ?: JSONObject()
Log.i(TAG, "handleServerRequest method=$method")
when (method) {
"item/tool/call" -> {
if (toolCallHandler == null) {
sendError(
requestId = requestId,
code = -32601,
message = "No Agent tool handler registered for $method",
)
return
}
val toolName = params.optString("tool").trim()
val arguments = params.optJSONObject("arguments") ?: JSONObject()
Log.i(TAG, "tool/call tool=$toolName arguments=$arguments")
val result = runCatching { toolCallHandler(toolName, arguments) }
.getOrElse { err ->
sendError(
requestId = requestId,
code = -32000,
message = err.message ?: "Agent tool call failed",
)
return
}
Log.i(TAG, "tool/call completed tool=$toolName result=$result")
sendResult(requestId, result)
}
"item/tool/requestUserInput" -> {
if (requestUserInputHandler == null) {
sendError(
requestId = requestId,
code = -32601,
message = "No Agent user-input handler registered for $method",
)
return
}
val questions = params.optJSONArray("questions") ?: JSONArray()
Log.i(TAG, "requestUserInput questions=$questions")
val result = runCatching { requestUserInputHandler(questions) }
.getOrElse { err ->
sendError(
requestId = requestId,
code = -32000,
message = err.message ?: "Agent user input request failed",
)
return
}
Log.i(TAG, "requestUserInput completed result=$result")
sendResult(requestId, result)
}
else -> {
sendError(
requestId = requestId,
code = -32601,
message = "Unsupported Agent app-server request: $method",
)
return
}
}
}
private fun sendResult(
requestId: Any,
result: JSONObject,
) {
sendMessage(
JSONObject()
.put("id", requestId)
.put("result", result),
)
}
private fun sendError(
requestId: Any,
code: Int,
message: String,
) {
sendMessage(
JSONObject()
.put("id", requestId)
.put(
"error",
JSONObject()
.put("code", code)
.put("message", message),
),
)
}
private fun request(
method: String,
params: JSONObject?,
): JSONObject {
val requestId = requestIdSequence.getAndIncrement().toString()
val responseQueue = LinkedBlockingQueue<JSONObject>(1)
pendingResponses[requestId] = responseQueue
try {
val message = JSONObject()
.put("id", requestId)
.put("method", method)
if (params != null) {
message.put("params", params)
}
sendMessage(message)
val response = responseQueue.poll(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
?: throw IOException("Timed out waiting for $method response")
val error = response.optJSONObject("error")
if (error != null) {
throw IOException("$method failed: ${error.optString("message", error.toString())}")
}
return response.optJSONObject("result") ?: JSONObject()
} finally {
pendingResponses.remove(requestId)
}
}
private fun notify(
method: String,
params: JSONObject,
) {
sendMessage(
JSONObject()
.put("method", method)
.put("params", params),
)
}
private fun sendMessage(message: JSONObject) {
val activeWriter = writer ?: throw IOException("Agent app-server writer unavailable")
activeWriter.write(message.toString())
activeWriter.newLine()
activeWriter.flush()
}
private fun startStdoutPump(process: Process) {
stdoutThread = Thread {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
if (line.isBlank()) {
return@forEach
}
val message = runCatching { JSONObject(line) }
.getOrElse { err ->
Log.w(TAG, "Failed to parse Agent app-server stdout line", err)
return@forEach
}
routeInbound(message)
}
}
}.also {
it.name = "AgentCodexStdout"
it.start()
}
}
private fun startStderrPump(process: Process) {
stderrThread = Thread {
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
logAgentStderrLine(line)
}
}
}.also {
it.name = "AgentCodexStderr"
it.start()
}
}
private fun routeInbound(message: JSONObject) {
if (message.has("id") && !message.has("method")) {
pendingResponses[message.get("id").toString()]?.offer(message)
return
}
handleInboundSideEffects(message)
notifications.offer(message)
}
private fun handleInboundSideEffects(message: JSONObject) {
when (message.optString("method")) {
"account/updated" -> {
applicationContext?.let { context ->
refreshRuntimeStatusAsync(context)
}
}
"account/login/completed" -> {
applicationContext?.let { context ->
refreshRuntimeStatusAsync(context, refreshToken = true)
}
}
}
}
private fun checkProcessAlive() {
val activeProcess = process ?: throw IOException("Agent app-server unavailable")
if (!activeProcess.isAlive) {
initialized = false
updateCachedRuntimeStatus(null)
throw IOException("Agent app-server exited with code ${activeProcess.exitValue()}")
}
}
private fun logAgentStderrLine(line: String) {
if (line.isBlank()) {
return
}
when {
line.contains(" ERROR ") || line.startsWith("ERROR") -> Log.e(TAG, line)
line.contains(" WARN ") || line.startsWith("WARN") -> Log.w(TAG, line)
}
}
private fun updateClientCount() {
val currentStatus = cachedRuntimeStatus ?: return
val updatedStatus = currentStatus.copy(clientCount = activeRequests.get())
updateCachedRuntimeStatus(updatedStatus)
}
private fun updateCachedRuntimeStatus(status: RuntimeStatus?) {
if (cachedRuntimeStatus == status) {
return
}
cachedRuntimeStatus = status
runtimeStatusListeners.forEach { listener ->
runCatching {
listener.onRuntimeStatusChanged(status)
}.onFailure { err ->
Log.w(TAG, "Runtime status listener failed", err)
}
}
}
private fun parseRuntimeStatus(
context: Context,
accountResponse: JSONObject,
configResponse: JSONObject,
): RuntimeStatus {
val account = accountResponse.optJSONObject("account")
val config = configResponse.optJSONObject("config") ?: JSONObject()
val configuredModel = config.optNullableString("model")
val effectiveModel = configuredModel ?: DEFAULT_AGENT_MODEL
val configuredProvider = config.optNullableString("model_provider")
val accountType = account?.optNullableString("type").orEmpty()
val authMode = runCatching {
AgentResponsesProxy.loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json")).authMode
}.getOrElse {
if (accountType == "apiKey") {
"apiKey"
} else {
"chatgpt"
}
}
val upstreamBaseUrl = AgentResponsesProxy.buildResponsesBaseUrl(
upstreamBaseUrl = resolveUpstreamBaseUrl(
config = config,
accountType = accountType,
configuredProvider = configuredProvider,
),
authMode = authMode,
)
return RuntimeStatus(
authenticated = account != null,
accountEmail = account?.optNullableString("email"),
clientCount = activeRequests.get(),
modelProviderId = configuredProvider ?: inferModelProviderId(accountType),
configuredModel = configuredModel,
effectiveModel = effectiveModel,
upstreamBaseUrl = upstreamBaseUrl,
frameworkResponsesPath = AgentResponsesProxy.buildFrameworkResponsesPath(upstreamBaseUrl),
)
}
private fun inferModelProviderId(accountType: String): String {
return when (accountType) {
"chatgpt" -> "chatgpt"
"apiKey" -> "openai"
else -> "unknown"
}
}
private fun JSONObject.optNullableString(name: String): String? = when {
isNull(name) -> null
else -> optString(name).ifBlank { null }
}
private fun resolveUpstreamBaseUrl(
config: JSONObject,
accountType: String,
configuredProvider: String?,
): String {
val modelProviders = config.optJSONObject("model_providers")
val configuredProviderBaseUrl = configuredProvider?.let { providerId ->
modelProviders
?.optJSONObject(providerId)
?.optString("base_url")
?.ifBlank { null }
}
if (
configuredProviderBaseUrl != null &&
configuredProvider != HostedCodexConfig.ANDROID_HTTP_PROVIDER_ID
) {
return configuredProviderBaseUrl
}
return when (accountType) {
"chatgpt" -> config.optString("chatgpt_base_url")
.ifBlank { "https://chatgpt.com/backend-api/codex" }
"apiKey" -> config.optString("openai_base_url")
.ifBlank { "https://api.openai.com/v1" }
else -> config.optString("openai_base_url")
.ifBlank {
config.optString("chatgpt_base_url")
.ifBlank { "provider-default" }
}
}
}
}

View File

@@ -0,0 +1,347 @@
package com.openai.codex.agent
import android.content.Context
import android.util.Log
import java.io.IOException
import org.json.JSONArray
import org.json.JSONObject
class AgentFrameworkToolBridge(
private val context: Context,
private val sessionController: AgentSessionController,
) {
companion object {
private const val TAG = "AgentFrameworkTool"
private val DISALLOWED_TARGET_PACKAGES = setOf(
"com.android.shell",
"com.android.systemui",
"com.openai.codex.agent",
"com.openai.codex.genie",
)
const val START_DIRECT_SESSION_TOOL = "android_framework_sessions_start_direct"
const val LIST_SESSIONS_TOOL = "android_framework_sessions_list"
const val ANSWER_QUESTION_TOOL = "android_framework_sessions_answer_question"
const val ATTACH_TARGET_TOOL = "android_framework_sessions_attach_target"
const val CANCEL_SESSION_TOOL = "android_framework_sessions_cancel"
internal fun parseStartDirectSessionArguments(
arguments: JSONObject,
userObjective: String,
isEligibleTargetPackage: (String) -> Boolean,
): StartDirectSessionRequest {
val targetsJson = arguments.optJSONArray("targets")
?: throw IOException("Framework session tool arguments missing targets")
val rejectedPackages = mutableListOf<String>()
val targets = buildList {
for (index in 0 until targetsJson.length()) {
val target = targetsJson.optJSONObject(index) ?: continue
val packageName = target.optString("packageName").trim()
if (packageName.isEmpty()) {
continue
}
if (!isEligibleTargetPackage(packageName)) {
rejectedPackages += packageName
continue
}
val objective = target.optString("objective").trim().ifEmpty { userObjective }
val finalPresentationPolicy = target.optString("finalPresentationPolicy").trim()
val defaultFinalPresentationPolicy = arguments.optString("finalPresentationPolicy").trim()
add(
AgentDelegationTarget(
packageName = packageName,
objective = objective,
finalPresentationPolicy =
SessionFinalPresentationPolicy.fromWireValue(finalPresentationPolicy)
?: SessionFinalPresentationPolicy.fromWireValue(defaultFinalPresentationPolicy)
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
),
)
}
}.distinctBy(AgentDelegationTarget::packageName)
if (targets.isEmpty()) {
if (rejectedPackages.isNotEmpty()) {
throw IOException(
"Framework session tool selected missing or disallowed package(s): ${rejectedPackages.joinToString(", ")}",
)
}
throw IOException("Framework session tool did not select an eligible target package")
}
val allowDetachedMode = arguments.optBoolean("allowDetachedMode", true)
val detachedPolicyTargets = targets.filter { it.finalPresentationPolicy.requiresDetachedMode() }
if (!allowDetachedMode && detachedPolicyTargets.isNotEmpty()) {
throw IOException(
"Framework session tool selected detached final presentation without allowDetachedMode: ${detachedPolicyTargets.joinToString(", ") { it.packageName }}",
)
}
return StartDirectSessionRequest(
plan = AgentDelegationPlan(
originalObjective = userObjective,
targets = targets,
rationale = arguments.optString("reason").trim().ifEmpty { null },
usedOverride = false,
),
allowDetachedMode = allowDetachedMode,
)
}
}
data class StartDirectSessionRequest(
val plan: AgentDelegationPlan,
val allowDetachedMode: Boolean,
)
fun buildPlanningToolSpecs(): JSONArray {
return JSONArray().put(buildStartDirectSessionToolSpec())
}
fun buildQuestionResolutionToolSpecs(): JSONArray {
return JSONArray()
.put(buildListSessionsToolSpec())
.put(buildAnswerQuestionToolSpec())
}
fun buildSessionManagementToolSpecs(): JSONArray {
return buildQuestionResolutionToolSpecs()
.put(buildAttachTargetToolSpec())
.put(buildCancelSessionToolSpec())
}
fun handleToolCall(
toolName: String,
arguments: JSONObject,
userObjective: String,
onSessionStarted: ((SessionStartResult) -> Unit)? = null,
focusedSessionId: String? = null,
): JSONObject {
Log.i(TAG, "handleToolCall tool=$toolName arguments=$arguments")
return when (toolName) {
START_DIRECT_SESSION_TOOL -> {
val request = parseStartDirectSessionArguments(
arguments = arguments,
userObjective = userObjective,
isEligibleTargetPackage = ::isEligibleTargetPackage,
)
val startedSession = sessionController.startDirectSession(
plan = request.plan,
allowDetachedMode = request.allowDetachedMode,
)
Log.i(
TAG,
"Started framework sessions parent=${startedSession.parentSessionId} children=${startedSession.childSessionIds}",
)
onSessionStarted?.invoke(startedSession)
successText(
JSONObject()
.put("parentSessionId", startedSession.parentSessionId)
.put("childSessionIds", JSONArray(startedSession.childSessionIds))
.put("plannedTargets", JSONArray(startedSession.plannedTargets))
.put("geniePackage", startedSession.geniePackage)
.toString(),
)
}
LIST_SESSIONS_TOOL -> {
val snapshot = sessionController.loadSnapshot(focusedSessionId)
successText(renderSessionSnapshot(snapshot).toString())
}
ANSWER_QUESTION_TOOL -> {
val sessionId = requireString(arguments, "sessionId")
val answer = requireString(arguments, "answer")
val parentSessionId = arguments.optString("parentSessionId").trim().ifEmpty { null }
sessionController.answerQuestion(sessionId, answer, parentSessionId)
successText("Answered framework session $sessionId.")
}
ATTACH_TARGET_TOOL -> {
val sessionId = requireString(arguments, "sessionId")
sessionController.attachTarget(sessionId)
successText("Requested target attach for framework session $sessionId.")
}
CANCEL_SESSION_TOOL -> {
val sessionId = requireString(arguments, "sessionId")
sessionController.cancelSession(sessionId)
successText("Cancelled framework session $sessionId.")
}
else -> throw IOException("Unsupported framework session tool: $toolName")
}
}
private fun buildStartDirectSessionToolSpec(): JSONObject {
return JSONObject()
.put("name", START_DIRECT_SESSION_TOOL)
.put(
"description",
"Start direct parent and child framework sessions for one or more target Android packages.",
)
.put(
"inputSchema",
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject()
.put(
"targets",
JSONObject()
.put("type", "array")
.put(
"items",
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject()
.put("packageName", stringSchema("Installed target Android package name."))
.put("objective", stringSchema("Delegated free-form objective for the child Genie."))
.put(
"finalPresentationPolicy",
stringSchema(
"Required final target presentation: ATTACHED, DETACHED_HIDDEN, DETACHED_SHOWN, or AGENT_CHOICE.",
),
),
)
.put(
"required",
JSONArray()
.put("packageName")
.put("finalPresentationPolicy"),
)
.put("additionalProperties", false),
),
)
.put("reason", stringSchema("Short explanation for why these target packages were selected."))
.put(
"allowDetachedMode",
JSONObject()
.put("type", "boolean")
.put("description", "Whether Genie child sessions may use detached target mode."),
),
)
.put("required", JSONArray().put("targets"))
.put("additionalProperties", false),
)
}
private fun buildListSessionsToolSpec(): JSONObject {
return JSONObject()
.put("name", LIST_SESSIONS_TOOL)
.put("description", "List the current Android framework sessions visible to the Agent.")
.put(
"inputSchema",
JSONObject()
.put("type", "object")
.put("properties", JSONObject())
.put("additionalProperties", false),
)
}
private fun buildAnswerQuestionToolSpec(): JSONObject {
return JSONObject()
.put("name", ANSWER_QUESTION_TOOL)
.put("description", "Answer a waiting Android framework session question.")
.put(
"inputSchema",
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject()
.put("sessionId", stringSchema("Framework session id to answer."))
.put("answer", stringSchema("Free-form answer text."))
.put("parentSessionId", stringSchema("Optional parent framework session id for trace publication.")),
)
.put("required", JSONArray().put("sessionId").put("answer"))
.put("additionalProperties", false),
)
}
private fun buildAttachTargetToolSpec(): JSONObject {
return JSONObject()
.put("name", ATTACH_TARGET_TOOL)
.put("description", "Request the framework to attach the detached target back to the current display.")
.put(
"inputSchema",
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject().put("sessionId", stringSchema("Framework session id whose target should be attached.")),
)
.put("required", JSONArray().put("sessionId"))
.put("additionalProperties", false),
)
}
private fun buildCancelSessionToolSpec(): JSONObject {
return JSONObject()
.put("name", CANCEL_SESSION_TOOL)
.put("description", "Cancel an Android framework session.")
.put(
"inputSchema",
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject().put("sessionId", stringSchema("Framework session id to cancel.")),
)
.put("required", JSONArray().put("sessionId"))
.put("additionalProperties", false),
)
}
private fun renderSessionSnapshot(snapshot: AgentSnapshot): JSONObject {
val sessions = JSONArray()
snapshot.sessions.forEach { session ->
sessions.put(
JSONObject()
.put("sessionId", session.sessionId)
.put("parentSessionId", session.parentSessionId)
.put("targetPackage", session.targetPackage)
.put("state", session.stateLabel)
.put("targetDetached", session.targetDetached)
.put("targetPresentation", session.targetPresentationLabel)
.put("targetRuntime", session.targetRuntimeLabel)
.put(
"requiredFinalPresentation",
session.requiredFinalPresentationPolicy?.wireValue,
),
)
}
return JSONObject()
.put("available", snapshot.available)
.put("selectedGeniePackage", snapshot.selectedGeniePackage)
.put("selectedSessionId", snapshot.selectedSession?.sessionId)
.put("parentSessionId", snapshot.parentSession?.sessionId)
.put("sessions", sessions)
}
private fun isEligibleTargetPackage(packageName: String): Boolean {
if (packageName in DISALLOWED_TARGET_PACKAGES) {
return false
}
return sessionController.canStartSessionForTarget(packageName)
}
private fun requireString(arguments: JSONObject, fieldName: String): String {
return arguments.optString(fieldName).trim().ifEmpty {
throw IOException("Framework session tool requires non-empty $fieldName")
}
}
private fun successText(text: String): JSONObject {
return JSONObject()
.put("success", true)
.put(
"contentItems",
JSONArray().put(
JSONObject()
.put("type", "inputText")
.put("text", text),
),
)
}
private fun stringSchema(description: String): JSONObject {
return JSONObject()
.put("type", "string")
.put("description", description)
}
}

View File

@@ -0,0 +1,247 @@
package com.openai.codex.agent
import android.util.Log
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.EOFException
import java.io.IOException
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.nio.charset.StandardCharsets
import java.util.Collections
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
class AgentLocalCodexProxy(
private val requestForwarder: (String) -> AgentResponsesProxy.HttpResponse,
) : Closeable {
companion object {
private const val TAG = "AgentLocalProxy"
}
private val pathSecret = UUID.randomUUID().toString().replace("-", "")
private val loopbackAddress = InetAddress.getByName("127.0.0.1")
private val serverSocket = ServerSocket(0, 50, loopbackAddress)
private val closed = AtomicBoolean(false)
private val clientSockets = Collections.synchronizedSet(mutableSetOf<Socket>())
private val acceptThread = Thread(::acceptLoop, "AgentLocalProxy")
val baseUrl: String = "http://${loopbackAddress.hostAddress}:${serverSocket.localPort}/${pathSecret}/v1"
fun start() {
acceptThread.start()
logInfo("Listening on $baseUrl")
}
override fun close() {
if (!closed.compareAndSet(false, true)) {
return
}
runCatching { serverSocket.close() }
synchronized(clientSockets) {
clientSockets.forEach { socket -> runCatching { socket.close() } }
clientSockets.clear()
}
acceptThread.interrupt()
}
private fun acceptLoop() {
while (!closed.get()) {
val socket = try {
serverSocket.accept()
} catch (err: IOException) {
if (!closed.get()) {
logWarn("Failed to accept local proxy connection", err)
}
return
}
clientSockets += socket
Thread(
{ handleClient(socket) },
"AgentLocalProxyClient",
).start()
}
}
private fun handleClient(socket: Socket) {
socket.use { client ->
try {
val request = readRequest(client)
logInfo("Forwarding ${request.method} ${request.forwardPath}")
val response = forwardResponsesRequest(request)
writeResponse(
socket = client,
statusCode = response.statusCode,
body = response.body,
path = request.forwardPath,
)
} catch (err: Exception) {
if (!closed.get()) {
logWarn("Local proxy request failed", err)
runCatching {
writeResponse(
socket = client,
statusCode = 502,
body = err.message ?: err::class.java.simpleName,
path = "/error",
)
}
}
} finally {
clientSockets -= client
}
}
}
private fun forwardResponsesRequest(request: ParsedRequest): AgentResponsesProxy.HttpResponse {
if (request.method != "POST") {
return AgentResponsesProxy.HttpResponse(
statusCode = 405,
body = "Unsupported local proxy method: ${request.method}",
)
}
if (request.forwardPath != "/v1/responses") {
return AgentResponsesProxy.HttpResponse(
statusCode = 404,
body = "Unsupported local proxy path: ${request.forwardPath}",
)
}
return requestForwarder(request.body.orEmpty())
}
private fun readRequest(socket: Socket): ParsedRequest {
val input = socket.getInputStream()
val headerBuffer = ByteArrayOutputStream()
var matched = 0
while (matched < 4) {
val next = input.read()
if (next == -1) {
throw EOFException("unexpected EOF while reading local proxy request headers")
}
headerBuffer.write(next)
matched = when {
matched == 0 && next == '\r'.code -> 1
matched == 1 && next == '\n'.code -> 2
matched == 2 && next == '\r'.code -> 3
matched == 3 && next == '\n'.code -> 4
next == '\r'.code -> 1
else -> 0
}
}
val headerBytes = headerBuffer.toByteArray()
val headerText = headerBytes
.copyOfRange(0, headerBytes.size - 4)
.toString(StandardCharsets.US_ASCII)
val lines = headerText.split("\r\n")
val requestLine = lines.firstOrNull()
?: throw IOException("local proxy request line missing")
val requestParts = requestLine.split(" ", limit = 3)
if (requestParts.size < 2) {
throw IOException("invalid local proxy request line: $requestLine")
}
val headers = mutableMapOf<String, String>()
lines.drop(1).forEach { line ->
val separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) {
return@forEach
}
val name = line.substring(0, separatorIndex).trim().lowercase()
val value = line.substring(separatorIndex + 1).trim()
headers[name] = value
}
if (headers["transfer-encoding"]?.contains("chunked", ignoreCase = true) == true) {
throw IOException("chunked local proxy requests are unsupported")
}
val contentLength = headers["content-length"]?.toIntOrNull() ?: 0
val bodyBytes = ByteArray(contentLength)
var offset = 0
while (offset < bodyBytes.size) {
val read = input.read(bodyBytes, offset, bodyBytes.size - offset)
if (read == -1) {
throw EOFException("unexpected EOF while reading local proxy request body")
}
offset += read
}
val rawPath = requestParts[1]
val forwardPath = normalizeForwardPath(rawPath)
return ParsedRequest(
method = requestParts[0],
forwardPath = forwardPath,
body = if (bodyBytes.isEmpty()) null else bodyBytes.toString(StandardCharsets.UTF_8),
)
}
private fun normalizeForwardPath(rawPath: String): String {
val expectedPrefix = "/$pathSecret"
if (!rawPath.startsWith(expectedPrefix)) {
throw IOException("unexpected local proxy path: $rawPath")
}
val strippedPath = rawPath.removePrefix(expectedPrefix)
return if (strippedPath.isBlank()) "/" else strippedPath
}
private fun writeResponse(
socket: Socket,
statusCode: Int,
body: String,
path: String,
) {
val bodyBytes = body.toByteArray(StandardCharsets.UTF_8)
val contentType = when {
path.startsWith("/v1/responses") -> "text/event-stream; charset=utf-8"
body.trimStart().startsWith("{") || body.trimStart().startsWith("[") -> {
"application/json; charset=utf-8"
}
else -> "text/plain; charset=utf-8"
}
val responseHeaders = buildString {
append("HTTP/1.1 $statusCode ${reasonPhrase(statusCode)}\r\n")
append("Content-Type: $contentType\r\n")
append("Content-Length: ${bodyBytes.size}\r\n")
append("Connection: close\r\n")
append("\r\n")
}
val output = socket.getOutputStream()
output.write(responseHeaders.toByteArray(StandardCharsets.US_ASCII))
output.write(bodyBytes)
output.flush()
}
private fun reasonPhrase(statusCode: Int): String {
return when (statusCode) {
200 -> "OK"
400 -> "Bad Request"
401 -> "Unauthorized"
403 -> "Forbidden"
404 -> "Not Found"
500 -> "Internal Server Error"
502 -> "Bad Gateway"
503 -> "Service Unavailable"
else -> "Response"
}
}
private fun logInfo(message: String) {
runCatching { Log.i(TAG, message) }
}
private fun logWarn(
message: String,
err: Throwable,
) {
runCatching { Log.w(TAG, message, err) }
}
private data class ParsedRequest(
val method: String,
val forwardPath: String,
val body: String?,
)
}

View File

@@ -0,0 +1,16 @@
package com.openai.codex.agent
data class AgentModelOption(
val id: String,
val model: String,
val displayName: String,
val description: String,
val supportedReasoningEfforts: List<AgentReasoningEffortOption>,
val defaultReasoningEffort: String,
val isDefault: Boolean,
)
data class AgentReasoningEffortOption(
val reasoningEffort: String,
val description: String,
)

View File

@@ -0,0 +1,198 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
object AgentSessionStateValues {
const val CREATED = AgentSessionInfo.STATE_CREATED
const val RUNNING = AgentSessionInfo.STATE_RUNNING
const val WAITING_FOR_USER = AgentSessionInfo.STATE_WAITING_FOR_USER
const val COMPLETED = AgentSessionInfo.STATE_COMPLETED
const val CANCELLED = AgentSessionInfo.STATE_CANCELLED
const val FAILED = AgentSessionInfo.STATE_FAILED
const val QUEUED = AgentSessionInfo.STATE_QUEUED
}
data class ParentSessionChildSummary(
val sessionId: String,
val targetPackage: String?,
val state: Int,
val targetPresentation: Int,
val requiredFinalPresentationPolicy: SessionFinalPresentationPolicy?,
val latestResult: String?,
val latestError: String?,
)
data class ParentSessionRollup(
val state: Int,
val resultMessage: String?,
val errorMessage: String?,
val sessionsToAttach: List<String>,
)
object AgentParentSessionAggregator {
fun rollup(childSessions: List<ParentSessionChildSummary>): ParentSessionRollup {
val baseState = computeParentState(childSessions.map(ParentSessionChildSummary::state))
if (
baseState == AgentSessionInfo.STATE_CREATED ||
baseState == AgentSessionInfo.STATE_RUNNING ||
baseState == AgentSessionInfo.STATE_WAITING_FOR_USER ||
baseState == AgentSessionInfo.STATE_QUEUED
) {
return ParentSessionRollup(
state = baseState,
resultMessage = null,
errorMessage = null,
sessionsToAttach = emptyList(),
)
}
val terminalPresentationMismatches = childSessions.mapNotNull { childSession ->
childSession.presentationMismatch()
}
val sessionsToAttach = terminalPresentationMismatches
.filter { it.requiredPolicy == SessionFinalPresentationPolicy.ATTACHED }
.map(PresentationMismatch::sessionId)
val blockingMismatches = terminalPresentationMismatches
.filterNot { it.requiredPolicy == SessionFinalPresentationPolicy.ATTACHED }
if (sessionsToAttach.isNotEmpty() && baseState == AgentSessionInfo.STATE_COMPLETED) {
return ParentSessionRollup(
state = AgentSessionInfo.STATE_RUNNING,
resultMessage = null,
errorMessage = null,
sessionsToAttach = sessionsToAttach,
)
}
if (blockingMismatches.isNotEmpty()) {
return ParentSessionRollup(
state = AgentSessionInfo.STATE_FAILED,
resultMessage = null,
errorMessage = buildPresentationMismatchError(blockingMismatches),
sessionsToAttach = emptyList(),
)
}
return when (baseState) {
AgentSessionInfo.STATE_COMPLETED -> ParentSessionRollup(
state = baseState,
resultMessage = buildParentResult(childSessions),
errorMessage = null,
sessionsToAttach = emptyList(),
)
AgentSessionInfo.STATE_FAILED -> ParentSessionRollup(
state = baseState,
resultMessage = null,
errorMessage = buildParentError(childSessions),
sessionsToAttach = emptyList(),
)
else -> ParentSessionRollup(
state = baseState,
resultMessage = null,
errorMessage = null,
sessionsToAttach = emptyList(),
)
}
}
private fun computeParentState(childStates: List<Int>): Int {
var anyWaiting = false
var anyRunning = false
var anyQueued = false
var anyFailed = false
var anyCancelled = false
var anyCompleted = false
childStates.forEach { state ->
when (state) {
AgentSessionInfo.STATE_WAITING_FOR_USER -> anyWaiting = true
AgentSessionInfo.STATE_RUNNING -> anyRunning = true
AgentSessionInfo.STATE_QUEUED -> anyQueued = true
AgentSessionInfo.STATE_FAILED -> anyFailed = true
AgentSessionInfo.STATE_CANCELLED -> anyCancelled = true
AgentSessionInfo.STATE_COMPLETED -> anyCompleted = true
}
}
return when {
anyWaiting -> AgentSessionInfo.STATE_WAITING_FOR_USER
anyRunning || anyQueued -> AgentSessionInfo.STATE_RUNNING
anyFailed -> AgentSessionInfo.STATE_FAILED
anyCompleted -> AgentSessionInfo.STATE_COMPLETED
anyCancelled -> AgentSessionInfo.STATE_CANCELLED
else -> AgentSessionInfo.STATE_CREATED
}
}
private fun buildParentResult(childSessions: List<ParentSessionChildSummary>): String {
return buildString {
append("Completed delegated session")
childSessions.forEach { childSession ->
append("; ")
append(childSession.targetPackage ?: childSession.sessionId)
append(": ")
append(
childSession.latestResult
?: childSession.latestError
?: stateToString(childSession.state),
)
}
}
}
private fun buildParentError(childSessions: List<ParentSessionChildSummary>): String {
return buildString {
append("Delegated session failed")
childSessions.forEach { childSession ->
if (childSession.state != AgentSessionInfo.STATE_FAILED) {
return@forEach
}
append("; ")
append(childSession.targetPackage ?: childSession.sessionId)
append(": ")
append(childSession.latestError ?: stateToString(childSession.state))
}
}
}
private fun buildPresentationMismatchError(mismatches: List<PresentationMismatch>): String {
return buildString {
append("Delegated session completed without the required final presentation")
mismatches.forEach { mismatch ->
append("; ")
append(mismatch.targetPackage ?: mismatch.sessionId)
append(": required ")
append(mismatch.requiredPolicy.wireValue)
append(", actual ")
append(targetPresentationToString(mismatch.actualPresentation))
}
}
}
private fun stateToString(state: Int): String {
return when (state) {
AgentSessionInfo.STATE_CREATED -> "CREATED"
AgentSessionInfo.STATE_RUNNING -> "RUNNING"
AgentSessionInfo.STATE_WAITING_FOR_USER -> "WAITING_FOR_USER"
AgentSessionInfo.STATE_QUEUED -> "QUEUED"
AgentSessionInfo.STATE_COMPLETED -> "COMPLETED"
AgentSessionInfo.STATE_CANCELLED -> "CANCELLED"
AgentSessionInfo.STATE_FAILED -> "FAILED"
else -> state.toString()
}
}
private fun ParentSessionChildSummary.presentationMismatch(): PresentationMismatch? {
val requiredPolicy = requiredFinalPresentationPolicy ?: return null
if (state != AgentSessionInfo.STATE_COMPLETED || requiredPolicy.matches(targetPresentation)) {
return null
}
return PresentationMismatch(
sessionId = sessionId,
targetPackage = targetPackage,
requiredPolicy = requiredPolicy,
actualPresentation = targetPresentation,
)
}
}
private data class PresentationMismatch(
val sessionId: String,
val targetPackage: String?,
val requiredPolicy: SessionFinalPresentationPolicy,
val actualPresentation: Int,
)

View File

@@ -0,0 +1,471 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.content.Context
import android.util.Log
import com.openai.codex.bridge.HostedCodexConfig
import com.openai.codex.bridge.SessionExecutionSettings
import java.io.BufferedWriter
import java.io.Closeable
import java.io.File
import java.io.IOException
import java.io.InterruptedIOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import org.json.JSONArray
import org.json.JSONObject
object AgentPlannerRuntimeManager {
private const val TAG = "AgentPlannerRuntime"
private val activePlannerSessions = ConcurrentHashMap<String, Boolean>()
fun requestText(
context: Context,
instructions: String,
prompt: String,
outputSchema: JSONObject? = null,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
requestTimeoutMs: Long = 90_000L,
frameworkSessionId: String? = null,
): String {
val applicationContext = context.applicationContext
val plannerSessionId = frameworkSessionId?.trim()?.ifEmpty { null }
?: throw IOException("Planner runtime requires a parent session id")
check(activePlannerSessions.putIfAbsent(plannerSessionId, true) == null) {
"Planner runtime already active for parent session $plannerSessionId"
}
try {
AgentPlannerRuntime(
context = applicationContext,
frameworkSessionId = plannerSessionId,
).use { runtime ->
return runtime.requestText(
instructions = instructions,
prompt = prompt,
outputSchema = outputSchema,
requestUserInputHandler = requestUserInputHandler,
executionSettings = executionSettings,
requestTimeoutMs = requestTimeoutMs,
)
}
} finally {
activePlannerSessions.remove(plannerSessionId)
}
}
private class AgentPlannerRuntime(
private val context: Context,
private val frameworkSessionId: String?,
) : Closeable {
companion object {
private const val REQUEST_TIMEOUT_MS = 30_000L
private const val AGENT_APP_SERVER_RUST_LOG = "warn"
}
private val requestIdSequence = AtomicInteger(1)
private val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
private val notifications = LinkedBlockingQueue<JSONObject>()
private lateinit var process: Process
private lateinit var writer: BufferedWriter
private lateinit var codexHome: File
private val closing = AtomicBoolean(false)
private var stdoutThread: Thread? = null
private var stderrThread: Thread? = null
private var localProxy: AgentLocalCodexProxy? = null
fun requestText(
instructions: String,
prompt: String,
outputSchema: JSONObject?,
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
executionSettings: SessionExecutionSettings,
requestTimeoutMs: Long,
): String {
startProcess()
initialize()
val threadId = startThread(
instructions = instructions,
executionSettings = executionSettings,
)
startTurn(
threadId = threadId,
prompt = prompt,
outputSchema = outputSchema,
executionSettings = executionSettings,
)
return waitForTurnCompletion(requestUserInputHandler, requestTimeoutMs)
}
override fun close() {
closing.set(true)
stdoutThread?.interrupt()
stderrThread?.interrupt()
if (::writer.isInitialized) {
runCatching { writer.close() }
}
localProxy?.close()
if (::codexHome.isInitialized) {
runCatching { codexHome.deleteRecursively() }
}
if (::process.isInitialized) {
runCatching { process.destroy() }
}
}
private fun startProcess() {
codexHome = File(context.cacheDir, "planner-codex-home/$frameworkSessionId").apply {
deleteRecursively()
mkdirs()
}
localProxy = AgentLocalCodexProxy { requestBody ->
forwardResponsesRequest(requestBody)
}.also(AgentLocalCodexProxy::start)
HostedCodexConfig.write(
context,
codexHome,
localProxy?.baseUrl
?: throw IOException("planner local proxy did not start"),
)
process = ProcessBuilder(
listOf(
CodexCliBinaryLocator.resolve(context).absolutePath,
"-c",
"enable_request_compression=false",
"app-server",
"--listen",
"stdio://",
),
).apply {
environment()["CODEX_HOME"] = codexHome.absolutePath
environment()["RUST_LOG"] = AGENT_APP_SERVER_RUST_LOG
}.start()
writer = process.outputStream.bufferedWriter()
startStdoutPump()
startStderrPump()
}
private fun initialize() {
request(
method = "initialize",
params = JSONObject()
.put(
"clientInfo",
JSONObject()
.put("name", "android_agent_planner")
.put("title", "Android Agent Planner")
.put("version", "0.1.0"),
)
.put("capabilities", JSONObject().put("experimentalApi", true)),
)
notify("initialized", JSONObject())
}
private fun startThread(
instructions: String,
executionSettings: SessionExecutionSettings,
): String {
val params = JSONObject()
.put("approvalPolicy", "never")
.put("sandbox", "read-only")
.put("ephemeral", true)
.put("cwd", context.filesDir.absolutePath)
.put("serviceName", "android_agent_planner")
.put("baseInstructions", instructions)
executionSettings.model
?.takeIf(String::isNotBlank)
?.let { params.put("model", it) }
val result = request(
method = "thread/start",
params = params,
)
return result.getJSONObject("thread").getString("id")
}
private fun startTurn(
threadId: String,
prompt: String,
outputSchema: JSONObject?,
executionSettings: SessionExecutionSettings,
) {
val turnParams = JSONObject()
.put("threadId", threadId)
.put(
"input",
JSONArray().put(
JSONObject()
.put("type", "text")
.put("text", prompt),
),
)
executionSettings.model
?.takeIf(String::isNotBlank)
?.let { turnParams.put("model", it) }
executionSettings.reasoningEffort
?.takeIf(String::isNotBlank)
?.let { turnParams.put("effort", it) }
if (outputSchema != null) {
turnParams.put("outputSchema", outputSchema)
}
request(
method = "turn/start",
params = turnParams,
)
}
private fun waitForTurnCompletion(
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
requestTimeoutMs: Long,
): String {
val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
var finalAgentMessage: String? = null
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(requestTimeoutMs)
while (true) {
val remainingNanos = deadline - System.nanoTime()
if (remainingNanos <= 0L) {
throw IOException("Timed out waiting for planner turn completion")
}
val notification = notifications.poll(remainingNanos, TimeUnit.NANOSECONDS)
if (notification == null) {
checkProcessAlive()
continue
}
if (notification.has("id") && notification.has("method")) {
handleServerRequest(notification, requestUserInputHandler)
continue
}
val params = notification.optJSONObject("params") ?: JSONObject()
when (notification.optString("method")) {
"item/agentMessage/delta" -> {
val itemId = params.optString("itemId")
if (itemId.isNotBlank()) {
streamedAgentMessages.getOrPut(itemId, ::StringBuilder)
.append(params.optString("delta"))
}
}
"item/completed" -> {
val item = params.optJSONObject("item") ?: continue
if (item.optString("type") == "agentMessage") {
val itemId = item.optString("id")
val text = item.optString("text").ifBlank {
streamedAgentMessages[itemId]?.toString().orEmpty()
}
if (text.isNotBlank()) {
finalAgentMessage = text
}
}
}
"turn/completed" -> {
val turn = params.optJSONObject("turn") ?: JSONObject()
return when (turn.optString("status")) {
"completed" -> finalAgentMessage?.takeIf(String::isNotBlank)
?: throw IOException("Planner turn completed without an assistant message")
"interrupted" -> throw IOException("Planner turn interrupted")
else -> throw IOException(
turn.opt("error")?.toString()
?: "Planner turn failed with status ${turn.optString("status", "unknown")}",
)
}
}
}
}
}
private fun handleServerRequest(
message: JSONObject,
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
) {
val requestId = message.opt("id") ?: return
val method = message.optString("method", "unknown")
val params = message.optJSONObject("params") ?: JSONObject()
when (method) {
"item/tool/requestUserInput" -> {
if (requestUserInputHandler == null) {
sendError(
requestId = requestId,
code = -32601,
message = "No Agent user-input handler registered for $method",
)
return
}
val questions = params.optJSONArray("questions") ?: JSONArray()
val result = runCatching { requestUserInputHandler(questions) }
.getOrElse { err ->
sendError(
requestId = requestId,
code = -32000,
message = err.message ?: "Agent user input request failed",
)
return
}
sendResult(requestId, result)
}
else -> {
sendError(
requestId = requestId,
code = -32601,
message = "Unsupported planner app-server request: $method",
)
}
}
}
private fun forwardResponsesRequest(requestBody: String): AgentResponsesProxy.HttpResponse {
val activeFrameworkSessionId = frameworkSessionId
check(!activeFrameworkSessionId.isNullOrBlank()) {
"Planner runtime requires a framework session id for /responses transport"
}
val agentManager = context.getSystemService(AgentManager::class.java)
?: throw IOException("AgentManager unavailable for framework session transport")
return AgentResponsesProxy.sendResponsesRequestThroughFramework(
agentManager = agentManager,
sessionId = activeFrameworkSessionId,
context = context,
requestBody = requestBody,
)
}
private fun request(
method: String,
params: JSONObject?,
): JSONObject {
val requestId = requestIdSequence.getAndIncrement().toString()
val responseQueue = LinkedBlockingQueue<JSONObject>(1)
pendingResponses[requestId] = responseQueue
try {
val message = JSONObject()
.put("id", requestId)
.put("method", method)
if (params != null) {
message.put("params", params)
}
sendMessage(message)
val response = responseQueue.poll(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
?: throw IOException("Timed out waiting for $method response")
val error = response.optJSONObject("error")
if (error != null) {
throw IOException("$method failed: ${error.optString("message", error.toString())}")
}
return response.optJSONObject("result") ?: JSONObject()
} finally {
pendingResponses.remove(requestId)
}
}
private fun notify(
method: String,
params: JSONObject,
) {
sendMessage(
JSONObject()
.put("method", method)
.put("params", params),
)
}
private fun sendResult(
requestId: Any,
result: JSONObject,
) {
sendMessage(
JSONObject()
.put("id", requestId)
.put("result", result),
)
}
private fun sendError(
requestId: Any,
code: Int,
message: String,
) {
sendMessage(
JSONObject()
.put("id", requestId)
.put(
"error",
JSONObject()
.put("code", code)
.put("message", message),
),
)
}
private fun sendMessage(message: JSONObject) {
writer.write(message.toString())
writer.newLine()
writer.flush()
}
private fun startStdoutPump() {
stdoutThread = thread(name = "AgentPlannerStdout-$frameworkSessionId") {
try {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
if (line.isBlank()) {
return@forEach
}
val message = runCatching { JSONObject(line) }
.getOrElse { err ->
Log.w(TAG, "Failed to parse planner app-server stdout line", err)
return@forEach
}
if (message.has("id") && !message.has("method")) {
pendingResponses[message.get("id").toString()]?.offer(message)
} else {
notifications.offer(message)
}
}
}
} catch (err: InterruptedIOException) {
if (!closing.get()) {
Log.w(TAG, "Planner stdout pump interrupted unexpectedly", err)
}
} catch (err: IOException) {
if (!closing.get()) {
Log.w(TAG, "Planner stdout pump failed", err)
}
}
}
}
private fun startStderrPump() {
stderrThread = thread(name = "AgentPlannerStderr-$frameworkSessionId") {
try {
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
if (line.contains(" ERROR ") || line.startsWith("ERROR")) {
Log.e(TAG, line)
} else if (line.contains(" WARN ") || line.startsWith("WARN")) {
Log.w(TAG, line)
}
}
}
} catch (err: InterruptedIOException) {
if (!closing.get()) {
Log.w(TAG, "Planner stderr pump interrupted unexpectedly", err)
}
} catch (err: IOException) {
if (!closing.get()) {
Log.w(TAG, "Planner stderr pump failed", err)
}
}
}
}
private fun checkProcessAlive() {
if (!process.isAlive) {
throw IOException("Planner app-server exited with code ${process.exitValue()}")
}
}
}
}

View File

@@ -0,0 +1,79 @@
package com.openai.codex.agent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
object AgentQuestionNotifier {
private const val CHANNEL_ID = "codex_agent_questions"
private const val CHANNEL_NAME = "Codex Agent Questions"
fun showQuestion(
context: Context,
sessionId: String,
targetPackage: String?,
question: String,
) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
ensureChannel(manager)
manager.notify(notificationId(sessionId), buildNotification(context, sessionId, targetPackage, question))
}
fun cancel(context: Context, sessionId: String) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
manager.cancel(notificationId(sessionId))
}
private fun buildNotification(
context: Context,
sessionId: String,
targetPackage: String?,
question: String,
): Notification {
val title = targetPackage?.let { "Question for $it" } ?: "Question for Codex Agent"
val contentIntent = PendingIntent.getActivity(
context,
notificationId(sessionId),
Intent(context, SessionDetailActivity::class.java).apply {
putExtra(SessionDetailActivity.EXTRA_SESSION_ID, sessionId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(question)
.setStyle(Notification.BigTextStyle().bigText(question))
.setContentIntent(contentIntent)
.setAutoCancel(false)
.setOngoing(true)
.build()
}
private fun ensureChannel(manager: NotificationManager) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Questions that need user input for Codex Agent sessions"
setShowBadge(true)
}
manager.createNotificationChannel(channel)
}
private fun notificationId(sessionId: String): Int {
return sessionId.hashCode()
}
}

View File

@@ -0,0 +1,328 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import com.openai.codex.bridge.FrameworkSessionTransportCompat
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.SocketException
import java.net.URL
import java.nio.charset.StandardCharsets
import org.json.JSONObject
object AgentResponsesProxy {
private const val TAG = "AgentResponsesProxy"
private const val CONNECT_TIMEOUT_MS = 30_000
private const val READ_TIMEOUT_MS = 0
private const val DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
private const val DEFAULT_CHATGPT_BASE_URL = "https://chatgpt.com/backend-api/codex"
private const val DEFAULT_ORIGINATOR = "codex_cli_rs"
private const val DEFAULT_USER_AGENT = "codex_cli_rs/android_agent_bridge"
private const val HEADER_AUTHORIZATION = "Authorization"
private const val HEADER_CONTENT_TYPE = "Content-Type"
private const val HEADER_ACCEPT = "Accept"
private const val HEADER_ACCEPT_ENCODING = "Accept-Encoding"
private const val HEADER_CHATGPT_ACCOUNT_ID = "ChatGPT-Account-ID"
private const val HEADER_ORIGINATOR = "originator"
private const val HEADER_USER_AGENT = "User-Agent"
private const val HEADER_VALUE_BEARER_PREFIX = "Bearer "
private const val HEADER_VALUE_APPLICATION_JSON = "application/json"
private const val HEADER_VALUE_TEXT_EVENT_STREAM = "text/event-stream"
private const val HEADER_VALUE_IDENTITY = "identity"
internal data class AuthSnapshot(
val authMode: String,
val bearerToken: String,
val accountId: String?,
)
data class HttpResponse(
val statusCode: Int,
val body: String,
)
internal data class FrameworkTransportTarget(
val baseUrl: String,
val responsesPath: String,
)
fun sendResponsesRequest(
context: Context,
requestBody: String,
): HttpResponse {
val authSnapshot = loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json"))
val upstreamUrl = buildResponsesUrl(upstreamBaseUrl = "provider-default", authMode = authSnapshot.authMode)
val requestBodyBytes = requestBody.toByteArray(StandardCharsets.UTF_8)
Log.i(
TAG,
"Proxying /v1/responses -> $upstreamUrl (auth_mode=${authSnapshot.authMode}, bytes=${requestBodyBytes.size})",
)
return executeRequest(upstreamUrl, requestBodyBytes, authSnapshot)
}
fun sendResponsesRequestThroughFramework(
agentManager: AgentManager,
sessionId: String,
context: Context,
requestBody: String,
): HttpResponse {
val authSnapshot = loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json"))
val requestBodyBytes = requestBody.toByteArray(StandardCharsets.UTF_8)
val transportTarget = buildFrameworkTransportTarget(
buildResponsesBaseUrl(upstreamBaseUrl = "provider-default", authMode = authSnapshot.authMode),
)
Log.i(
TAG,
"Proxying /v1/responses via framework session $sessionId -> ${transportTarget.baseUrl}${transportTarget.responsesPath} (auth_mode=${authSnapshot.authMode}, bytes=${requestBodyBytes.size})",
)
FrameworkSessionTransportCompat.setSessionNetworkConfig(
agentManager = agentManager,
sessionId = sessionId,
config = buildFrameworkSessionNetworkConfig(
context = context,
upstreamBaseUrl = "provider-default",
),
)
val response = FrameworkSessionTransportCompat.executeStreamingRequest(
agentManager = agentManager,
sessionId = sessionId,
request = FrameworkSessionTransportCompat.HttpRequest(
method = "POST",
path = transportTarget.responsesPath,
headers = buildResponsesRequestHeaders(),
body = requestBodyBytes,
),
)
Log.i(
TAG,
"Framework responses proxy completed status=${response.statusCode} response_bytes=${response.body.size}",
)
return HttpResponse(
statusCode = response.statusCode,
body = response.bodyString,
)
}
internal fun buildFrameworkSessionNetworkConfig(
context: Context,
upstreamBaseUrl: String,
): FrameworkSessionTransportCompat.SessionNetworkConfig {
val authSnapshot = loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json"))
val transportTarget = buildFrameworkTransportTarget(
buildResponsesBaseUrl(upstreamBaseUrl, authSnapshot.authMode),
)
return FrameworkSessionTransportCompat.SessionNetworkConfig(
baseUrl = transportTarget.baseUrl,
defaultHeaders = buildDefaultHeaders(authSnapshot),
connectTimeoutMillis = CONNECT_TIMEOUT_MS,
readTimeoutMillis = READ_TIMEOUT_MS,
)
}
internal fun buildFrameworkResponsesPath(responsesBaseUrl: String): String {
return buildFrameworkTransportTarget(responsesBaseUrl).responsesPath
}
internal fun buildResponsesBaseUrl(
upstreamBaseUrl: String,
authMode: String,
): String {
val normalizedUpstreamBaseUrl = upstreamBaseUrl.trim()
return when {
normalizedUpstreamBaseUrl.isBlank() ||
normalizedUpstreamBaseUrl == "provider-default" ||
normalizedUpstreamBaseUrl == "null" -> {
if (authMode == "chatgpt") {
DEFAULT_CHATGPT_BASE_URL
} else {
DEFAULT_OPENAI_BASE_URL
}
}
else -> normalizedUpstreamBaseUrl
}.trimEnd('/')
}
internal fun buildResponsesUrl(
upstreamBaseUrl: String,
authMode: String,
): String {
return "${buildResponsesBaseUrl(upstreamBaseUrl, authMode)}/responses"
}
internal fun buildFrameworkTransportTarget(responsesBaseUrl: String): FrameworkTransportTarget {
val upstreamUrl = URL(responsesBaseUrl)
val baseUrl = buildString {
append(upstreamUrl.protocol)
append("://")
append(upstreamUrl.host)
if (upstreamUrl.port != -1) {
append(":")
append(upstreamUrl.port)
}
}
val normalizedPath = upstreamUrl.path.trimEnd('/').ifBlank { "/" }
val responsesPath = if (normalizedPath == "/") {
"/responses"
} else {
"$normalizedPath/responses"
}
return FrameworkTransportTarget(
baseUrl = baseUrl,
responsesPath = responsesPath,
)
}
internal fun loadAuthSnapshot(authFile: File): AuthSnapshot {
if (!authFile.isFile) {
throw IOException("Missing Agent auth file at ${authFile.absolutePath}")
}
val json = JSONObject(authFile.readText())
val openAiApiKey = json.stringOrNull("OPENAI_API_KEY")
val authMode = when (json.stringOrNull("auth_mode")) {
"apiKey", "apikey", "api_key" -> "apiKey"
"chatgpt", "chatgptAuthTokens", "chatgpt_auth_tokens" -> "chatgpt"
null -> if (openAiApiKey != null) "apiKey" else "chatgpt"
else -> if (openAiApiKey != null) "apiKey" else "chatgpt"
}
return if (authMode == "apiKey") {
val apiKey = openAiApiKey
?: throw IOException("Agent auth file is missing OPENAI_API_KEY")
AuthSnapshot(
authMode = authMode,
bearerToken = apiKey,
accountId = null,
)
} else {
val tokens = json.optJSONObject("tokens")
?: throw IOException("Agent auth file is missing chatgpt tokens")
val accessToken = tokens.stringOrNull("access_token")
?: throw IOException("Agent auth file is missing access_token")
AuthSnapshot(
authMode = "chatgpt",
bearerToken = accessToken,
accountId = tokens.stringOrNull("account_id"),
)
}
}
private fun executeRequest(
upstreamUrl: String,
requestBodyBytes: ByteArray,
authSnapshot: AuthSnapshot,
): HttpResponse {
val connection = openConnection(upstreamUrl, authSnapshot)
return try {
try {
connection.outputStream.use { output ->
output.write(requestBodyBytes)
output.flush()
}
} catch (err: IOException) {
throw wrapRequestFailure("write request body", upstreamUrl, err)
}
val statusCode = try {
connection.responseCode
} catch (err: IOException) {
throw wrapRequestFailure("read response status", upstreamUrl, err)
}
val responseBody = try {
val stream = if (statusCode >= 400) connection.errorStream else connection.inputStream
stream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() }.orEmpty()
} catch (err: IOException) {
throw wrapRequestFailure("read response body", upstreamUrl, err)
}
Log.i(
TAG,
"Responses proxy completed status=$statusCode response_bytes=${responseBody.toByteArray(StandardCharsets.UTF_8).size}",
)
HttpResponse(
statusCode = statusCode,
body = responseBody,
)
} finally {
connection.disconnect()
}
}
private fun openConnection(
upstreamUrl: String,
authSnapshot: AuthSnapshot,
): HttpURLConnection {
return try {
(URL(upstreamUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
doInput = true
doOutput = true
instanceFollowRedirects = true
val defaultHeaders = buildDefaultHeaders(authSnapshot)
defaultHeaders.keySet().forEach { key ->
defaultHeaders.getString(key)?.let { value ->
setRequestProperty(key, value)
}
}
val requestHeaders = buildResponsesRequestHeaders()
requestHeaders.keySet().forEach { key ->
requestHeaders.getString(key)?.let { value ->
setRequestProperty(key, value)
}
}
}
} catch (err: IOException) {
throw wrapRequestFailure("open connection", upstreamUrl, err)
}
}
internal fun buildDefaultHeaders(authSnapshot: AuthSnapshot): Bundle {
return Bundle().apply {
putString(HEADER_AUTHORIZATION, "$HEADER_VALUE_BEARER_PREFIX${authSnapshot.bearerToken}")
putString(HEADER_ORIGINATOR, DEFAULT_ORIGINATOR)
putString(HEADER_USER_AGENT, DEFAULT_USER_AGENT)
if (authSnapshot.authMode == "chatgpt" && !authSnapshot.accountId.isNullOrBlank()) {
putString(HEADER_CHATGPT_ACCOUNT_ID, authSnapshot.accountId)
}
}
}
internal fun buildResponsesRequestHeaders(): Bundle {
return Bundle().apply {
putString(HEADER_CONTENT_TYPE, HEADER_VALUE_APPLICATION_JSON)
putString(HEADER_ACCEPT, HEADER_VALUE_TEXT_EVENT_STREAM)
putString(HEADER_ACCEPT_ENCODING, HEADER_VALUE_IDENTITY)
}
}
internal fun describeRequestFailure(
phase: String,
upstreamUrl: String,
err: IOException,
): String {
val reason = err.message?.ifBlank { err::class.java.simpleName } ?: err::class.java.simpleName
return "Responses proxy failed during $phase for $upstreamUrl: ${err::class.java.simpleName}: $reason"
}
private fun wrapRequestFailure(
phase: String,
upstreamUrl: String,
err: IOException,
): IOException {
val wrapped = IOException(describeRequestFailure(phase, upstreamUrl, err), err)
if (err is SocketException) {
Log.w(TAG, wrapped.message, err)
} else {
Log.e(TAG, wrapped.message, err)
}
return wrapped
}
private fun JSONObject.stringOrNull(key: String): String? {
if (!has(key) || isNull(key)) {
return null
}
return optString(key).ifBlank { null }
}
}

View File

@@ -0,0 +1,192 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.content.Context
import android.os.ParcelFileDescriptor
import android.util.Log
import com.openai.codex.bridge.HostedCodexConfig
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.Closeable
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import org.json.JSONObject
object AgentSessionBridgeServer {
private val runningBridges = ConcurrentHashMap<String, RunningBridge>()
fun ensureStarted(
context: Context,
agentManager: AgentManager,
sessionId: String,
) {
runningBridges.computeIfAbsent(sessionId) {
RunningBridge(
context = context.applicationContext,
agentManager = agentManager,
sessionId = sessionId,
).also(RunningBridge::start)
}
}
fun closeSession(sessionId: String) {
runningBridges.remove(sessionId)?.close()
}
private class RunningBridge(
private val context: Context,
private val agentManager: AgentManager,
private val sessionId: String,
) : Closeable {
companion object {
private const val TAG = "AgentSessionBridge"
private const val METHOD_GET_RUNTIME_STATUS = "getRuntimeStatus"
private const val METHOD_READ_INSTALLED_AGENTS_FILE = "readInstalledAgentsFile"
private const val METHOD_READ_SESSION_EXECUTION_SETTINGS = "readSessionExecutionSettings"
private const val WRITE_CHUNK_BYTES = 4096
}
private val closed = AtomicBoolean(false)
private var bridgeFd: ParcelFileDescriptor? = null
private var input: DataInputStream? = null
private var output: DataOutputStream? = null
private val executionSettingsStore = SessionExecutionSettingsStore(context)
private val serveThread = thread(
start = false,
name = "AgentSessionBridge-$sessionId",
) {
serveLoop()
}
fun start() {
serveThread.start()
}
override fun close() {
if (!closed.compareAndSet(false, true)) {
return
}
runCatching { input?.close() }
runCatching { output?.close() }
runCatching { bridgeFd?.close() }
serveThread.interrupt()
}
private fun serveLoop() {
try {
val fd = agentManager.openSessionBridge(sessionId)
bridgeFd = fd
input = DataInputStream(BufferedInputStream(FileInputStream(fd.fileDescriptor)))
output = DataOutputStream(BufferedOutputStream(FileOutputStream(fd.fileDescriptor)))
Log.i(TAG, "Opened framework session bridge for $sessionId")
while (!closed.get()) {
val request = try {
readMessage(input ?: break)
} catch (_: EOFException) {
return
}
val response = handleRequest(request)
writeMessage(output ?: break, response)
}
} catch (err: Exception) {
if (!closed.get() && !isExpectedSessionShutdown(err)) {
Log.w(TAG, "Session bridge failed for $sessionId", err)
}
} finally {
runningBridges.remove(sessionId, this)
close()
}
}
private fun isExpectedSessionShutdown(err: Exception): Boolean {
return err is IllegalStateException
&& err.message?.contains("No active Genie runtime for session") == true
}
private fun handleRequest(request: JSONObject): JSONObject {
val requestId = request.optString("requestId")
return runCatching {
when (request.optString("method")) {
METHOD_GET_RUNTIME_STATUS -> {
val status = AgentCodexAppServerClient.readRuntimeStatus(context)
JSONObject()
.put("requestId", requestId)
.put("ok", true)
.put(
"runtimeStatus",
JSONObject()
.put("authenticated", status.authenticated)
.put("accountEmail", status.accountEmail)
.put("clientCount", status.clientCount)
.put("modelProviderId", status.modelProviderId)
.put("configuredModel", status.configuredModel)
.put("effectiveModel", status.effectiveModel)
.put("upstreamBaseUrl", status.upstreamBaseUrl)
.put("frameworkResponsesPath", status.frameworkResponsesPath),
)
}
METHOD_READ_INSTALLED_AGENTS_FILE -> {
val codexHome = File(context.filesDir, "codex-home")
HostedCodexConfig.installBundledAgentsFile(context, codexHome)
JSONObject()
.put("requestId", requestId)
.put("ok", true)
.put("agentsMarkdown", HostedCodexConfig.readInstalledAgentsMarkdown(codexHome))
}
METHOD_READ_SESSION_EXECUTION_SETTINGS -> {
JSONObject()
.put("requestId", requestId)
.put("ok", true)
.put("executionSettings", executionSettingsStore.toJson(sessionId))
}
else -> {
JSONObject()
.put("requestId", requestId)
.put("ok", false)
.put("error", "Unsupported bridge method: ${request.optString("method")}")
}
}
}.getOrElse { err ->
JSONObject()
.put("requestId", requestId)
.put("ok", false)
.put("error", err.message ?: err::class.java.simpleName)
}
}
private fun readMessage(input: DataInputStream): JSONObject {
val size = input.readInt()
if (size <= 0) {
throw IOException("Invalid session bridge message length: $size")
}
val payload = ByteArray(size)
input.readFully(payload)
return JSONObject(payload.toString(StandardCharsets.UTF_8))
}
private fun writeMessage(
output: DataOutputStream,
message: JSONObject,
) {
val payload = message.toString().toByteArray(StandardCharsets.UTF_8)
output.writeInt(payload.size)
output.flush()
var offset = 0
while (offset < payload.size) {
val chunkSize = minOf(WRITE_CHUNK_BYTES, payload.size - offset)
output.write(payload, offset, chunkSize)
output.flush()
offset += chunkSize
}
}
}
}

View File

@@ -0,0 +1,797 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.app.agent.AgentSessionEvent
import android.app.agent.AgentSessionInfo
import android.content.Context
import android.os.Binder
import android.os.Process
import android.util.Log
import com.openai.codex.bridge.DetachedTargetCompat
import com.openai.codex.bridge.FrameworkSessionTransportCompat
import com.openai.codex.bridge.SessionExecutionSettings
import java.util.concurrent.Executor
class AgentSessionController(context: Context) {
companion object {
private const val TAG = "AgentSessionController"
private const val BRIDGE_REQUEST_PREFIX = "__codex_bridge__ "
private const val BRIDGE_RESPONSE_PREFIX = "__codex_bridge_result__ "
private const val DIAGNOSTIC_NOT_LOADED = "Diagnostics not loaded."
private const val MAX_TIMELINE_EVENTS = 12
private const val PREFERRED_GENIE_PACKAGE = "com.openai.codex.genie"
private const val QUESTION_ANSWER_RETRY_COUNT = 10
private const val QUESTION_ANSWER_RETRY_DELAY_MS = 50L
}
private val appContext = context.applicationContext
private val agentManager = appContext.getSystemService(AgentManager::class.java)
private val presentationPolicyStore = SessionPresentationPolicyStore(context)
private val executionSettingsStore = SessionExecutionSettingsStore(context)
fun isAvailable(): Boolean = agentManager != null
fun canStartSessionForTarget(packageName: String): Boolean {
val manager = agentManager ?: return false
return manager.canStartSessionForTarget(packageName, currentUserId())
}
fun registerSessionListener(
executor: Executor,
listener: AgentManager.SessionListener,
): Boolean {
val manager = agentManager ?: return false
manager.registerSessionListener(currentUserId(), executor, listener)
return true
}
fun unregisterSessionListener(listener: AgentManager.SessionListener) {
agentManager?.unregisterSessionListener(listener)
}
fun registerSessionUiLease(parentSessionId: String, token: Binder) {
agentManager?.registerSessionUiLease(parentSessionId, token)
}
fun unregisterSessionUiLease(parentSessionId: String, token: Binder) {
agentManager?.unregisterSessionUiLease(parentSessionId, token)
}
fun acknowledgeSessionUi(parentSessionId: String) {
val manager = agentManager ?: return
val token = Binder()
runCatching {
manager.registerSessionUiLease(parentSessionId, token)
}
runCatching {
manager.unregisterSessionUiLease(parentSessionId, token)
}
}
fun loadSnapshot(focusedSessionId: String?): AgentSnapshot {
val manager = agentManager ?: return AgentSnapshot.unavailable
val roleHolders = manager.getGenieRoleHolders(currentUserId())
val selectedGeniePackage = selectGeniePackage(roleHolders)
val sessions = manager.getSessions(currentUserId())
presentationPolicyStore.prunePolicies(sessions.map { it.sessionId }.toSet())
executionSettingsStore.pruneSettings(sessions.map { it.sessionId }.toSet())
var sessionDetails = sessions.map { session ->
val targetRuntime = DetachedTargetCompat.getTargetRuntime(session)
AgentSessionDetails(
sessionId = session.sessionId,
parentSessionId = session.parentSessionId,
targetPackage = session.targetPackage,
anchor = session.anchor,
state = session.state,
stateLabel = stateToString(session.state),
targetPresentation = session.targetPresentation,
targetPresentationLabel = targetPresentationToString(session.targetPresentation),
targetRuntime = targetRuntime.value,
targetRuntimeLabel = targetRuntime.label,
targetDetached = session.isTargetDetached,
requiredFinalPresentationPolicy = presentationPolicyStore.getPolicy(session.sessionId),
latestQuestion = null,
latestResult = null,
latestError = null,
latestTrace = null,
timeline = DIAGNOSTIC_NOT_LOADED,
)
}
val selectedSessionId = chooseSelectedSession(sessionDetails, focusedSessionId)?.sessionId
val parentSessionId = selectedSessionId?.let { selectedId ->
findParentSession(sessionDetails, sessionDetails.firstOrNull { it.sessionId == selectedId })?.sessionId
}
val diagnosticSessionIds = linkedSetOf<String>().apply {
parentSessionId?.let(::add)
selectedSessionId?.let(::add)
}
val diagnosticsBySessionId = diagnosticSessionIds.associateWith { sessionId ->
loadSessionDiagnostics(manager, sessionId)
}
sessionDetails = sessionDetails.map { session ->
diagnosticsBySessionId[session.sessionId]?.let(session::withDiagnostics) ?: session
}
sessionDetails = deriveDirectParentUiState(sessionDetails)
val selectedSession = chooseSelectedSession(sessionDetails, focusedSessionId)
val parentSession = findParentSession(sessionDetails, selectedSession)
val relatedSessions = if (parentSession == null) {
selectedSession?.let(::listOf) ?: emptyList()
} else {
sessionDetails.filter { session ->
session.sessionId == parentSession.sessionId ||
session.parentSessionId == parentSession.sessionId
}.sortedWith(compareBy<AgentSessionDetails> { it.parentSessionId != null }.thenBy { it.sessionId })
}
return AgentSnapshot(
available = true,
roleHolders = roleHolders,
selectedGeniePackage = selectedGeniePackage,
sessions = sessionDetails,
selectedSession = selectedSession,
parentSession = parentSession,
relatedSessions = relatedSessions,
)
}
fun startDirectSession(
plan: AgentDelegationPlan,
allowDetachedMode: Boolean,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
): SessionStartResult {
val pendingSession = createPendingDirectSession(
objective = plan.originalObjective,
executionSettings = executionSettings,
)
return startDirectSessionChildren(
parentSessionId = pendingSession.parentSessionId,
geniePackage = pendingSession.geniePackage,
plan = plan,
allowDetachedMode = allowDetachedMode,
executionSettings = executionSettings,
cancelParentOnFailure = true,
)
}
fun createPendingDirectSession(
objective: String,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
): PendingDirectSessionStart {
val manager = requireAgentManager()
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
?: throw IllegalStateException("No GENIE role holder configured")
val parentSession = manager.createDirectSession(currentUserId())
try {
executionSettingsStore.saveSettings(parentSession.sessionId, executionSettings)
manager.publishTrace(
parentSession.sessionId,
"Planning Codex direct session for objective: $objective",
)
manager.updateSessionState(parentSession.sessionId, AgentSessionInfo.STATE_RUNNING)
return PendingDirectSessionStart(
parentSessionId = parentSession.sessionId,
geniePackage = geniePackage,
)
} catch (err: RuntimeException) {
runCatching { manager.cancelSession(parentSession.sessionId) }
executionSettingsStore.removeSettings(parentSession.sessionId)
throw err
}
}
fun startDirectSessionChildren(
parentSessionId: String,
geniePackage: String,
plan: AgentDelegationPlan,
allowDetachedMode: Boolean,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
cancelParentOnFailure: Boolean = false,
): SessionStartResult {
val manager = requireAgentManager()
requireActiveDirectParentSession(manager, parentSessionId)
val detachedPolicyTargets = plan.targets.filter { it.finalPresentationPolicy.requiresDetachedMode() }
check(allowDetachedMode || detachedPolicyTargets.isEmpty()) {
"Detached final presentation requires detached mode for ${detachedPolicyTargets.joinToString(", ") { it.packageName }}"
}
val childSessionIds = mutableListOf<String>()
try {
manager.publishTrace(
parentSessionId,
"Starting Codex direct session for objective: ${plan.originalObjective}",
)
plan.rationale?.let { rationale ->
manager.publishTrace(parentSessionId, "Planning rationale: $rationale")
}
plan.targets.forEach { target ->
requireActiveDirectParentSession(manager, parentSessionId)
val childSession = manager.createChildSession(parentSessionId, target.packageName)
childSessionIds += childSession.sessionId
presentationPolicyStore.savePolicy(childSession.sessionId, target.finalPresentationPolicy)
executionSettingsStore.saveSettings(childSession.sessionId, executionSettings)
provisionSessionNetworkConfig(childSession.sessionId)
manager.publishTrace(
parentSessionId,
"Created child session ${childSession.sessionId} for ${target.packageName} with required final presentation ${target.finalPresentationPolicy.wireValue}.",
)
requireActiveDirectParentSession(manager, parentSessionId)
manager.startGenieSession(
childSession.sessionId,
geniePackage,
buildDelegatedPrompt(target),
allowDetachedMode,
)
}
return SessionStartResult(
parentSessionId = parentSessionId,
childSessionIds = childSessionIds,
plannedTargets = plan.targets.map(AgentDelegationTarget::packageName),
geniePackage = geniePackage,
anchor = AgentSessionInfo.ANCHOR_AGENT,
)
} catch (err: RuntimeException) {
childSessionIds.forEach { childSessionId ->
runCatching { manager.cancelSession(childSessionId) }
presentationPolicyStore.removePolicy(childSessionId)
executionSettingsStore.removeSettings(childSessionId)
}
if (cancelParentOnFailure) {
runCatching { manager.cancelSession(parentSessionId) }
executionSettingsStore.removeSettings(parentSessionId)
}
throw err
}
}
fun startHomeSession(
targetPackage: String,
prompt: String,
allowDetachedMode: Boolean,
finalPresentationPolicy: SessionFinalPresentationPolicy,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
): SessionStartResult {
val manager = requireAgentManager()
check(canStartSessionForTarget(targetPackage)) {
"Target package $targetPackage is not eligible for session start"
}
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
?: throw IllegalStateException("No GENIE role holder configured")
val session = manager.createAppScopedSession(targetPackage, currentUserId())
presentationPolicyStore.savePolicy(session.sessionId, finalPresentationPolicy)
executionSettingsStore.saveSettings(session.sessionId, executionSettings)
try {
provisionSessionNetworkConfig(session.sessionId)
manager.publishTrace(
session.sessionId,
"Starting Codex app-scoped session for $targetPackage with required final presentation ${finalPresentationPolicy.wireValue}.",
)
manager.startGenieSession(
session.sessionId,
geniePackage,
buildDelegatedPrompt(
AgentDelegationTarget(
packageName = targetPackage,
objective = prompt,
finalPresentationPolicy = finalPresentationPolicy,
),
),
allowDetachedMode,
)
return SessionStartResult(
parentSessionId = session.sessionId,
childSessionIds = listOf(session.sessionId),
plannedTargets = listOf(targetPackage),
geniePackage = geniePackage,
anchor = AgentSessionInfo.ANCHOR_HOME,
)
} catch (err: RuntimeException) {
presentationPolicyStore.removePolicy(session.sessionId)
executionSettingsStore.removeSettings(session.sessionId)
runCatching { manager.cancelSession(session.sessionId) }
throw err
}
}
fun startExistingHomeSession(
sessionId: String,
targetPackage: String,
prompt: String,
allowDetachedMode: Boolean,
finalPresentationPolicy: SessionFinalPresentationPolicy,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
): SessionStartResult {
val manager = requireAgentManager()
check(canStartSessionForTarget(targetPackage)) {
"Target package $targetPackage is not eligible for session start"
}
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
?: throw IllegalStateException("No GENIE role holder configured")
presentationPolicyStore.savePolicy(sessionId, finalPresentationPolicy)
executionSettingsStore.saveSettings(sessionId, executionSettings)
try {
provisionSessionNetworkConfig(sessionId)
manager.publishTrace(
sessionId,
"Starting Codex app-scoped session for $targetPackage with required final presentation ${finalPresentationPolicy.wireValue}.",
)
manager.startGenieSession(
sessionId,
geniePackage,
buildDelegatedPrompt(
AgentDelegationTarget(
packageName = targetPackage,
objective = prompt,
finalPresentationPolicy = finalPresentationPolicy,
),
),
allowDetachedMode,
)
return SessionStartResult(
parentSessionId = sessionId,
childSessionIds = listOf(sessionId),
plannedTargets = listOf(targetPackage),
geniePackage = geniePackage,
anchor = AgentSessionInfo.ANCHOR_HOME,
)
} catch (err: RuntimeException) {
presentationPolicyStore.removePolicy(sessionId)
executionSettingsStore.removeSettings(sessionId)
throw err
}
}
fun continueDirectSessionInPlace(
parentSessionId: String,
target: AgentDelegationTarget,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
): SessionStartResult {
val manager = requireAgentManager()
check(canStartSessionForTarget(target.packageName)) {
"Target package ${target.packageName} is not eligible for session continuation"
}
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
?: throw IllegalStateException("No GENIE role holder configured")
executionSettingsStore.saveSettings(parentSessionId, executionSettings)
Log.i(TAG, "Continuing AGENT session $parentSessionId with target ${target.packageName}")
manager.publishTrace(
parentSessionId,
"Continuing Codex direct session for ${target.packageName} with required final presentation ${target.finalPresentationPolicy.wireValue}.",
)
val childSession = manager.createChildSession(parentSessionId, target.packageName)
AgentSessionBridgeServer.ensureStarted(appContext, manager, childSession.sessionId)
presentationPolicyStore.savePolicy(childSession.sessionId, target.finalPresentationPolicy)
executionSettingsStore.saveSettings(childSession.sessionId, executionSettings)
provisionSessionNetworkConfig(childSession.sessionId)
manager.startGenieSession(
childSession.sessionId,
geniePackage,
buildDelegatedPrompt(target),
/* allowDetachedMode = */ true,
)
return SessionStartResult(
parentSessionId = parentSessionId,
childSessionIds = listOf(childSession.sessionId),
plannedTargets = listOf(target.packageName),
geniePackage = geniePackage,
anchor = AgentSessionInfo.ANCHOR_AGENT,
)
}
fun executionSettingsForSession(sessionId: String): SessionExecutionSettings {
return executionSettingsStore.getSettings(sessionId)
}
fun answerQuestion(sessionId: String, answer: String, parentSessionId: String?) {
val manager = requireAgentManager()
repeat(QUESTION_ANSWER_RETRY_COUNT) { attempt ->
runCatching {
manager.answerQuestion(sessionId, answer)
}.onSuccess {
if (parentSessionId != null) {
manager.publishTrace(parentSessionId, "Answered question for $sessionId: $answer")
}
return
}.onFailure { err ->
if (attempt == QUESTION_ANSWER_RETRY_COUNT - 1 || !shouldRetryAnswerQuestion(sessionId, err)) {
throw err
}
Thread.sleep(QUESTION_ANSWER_RETRY_DELAY_MS)
}
}
}
fun isSessionWaitingForUser(sessionId: String): Boolean {
val manager = agentManager ?: return false
return manager.getSessions(currentUserId()).any { session ->
session.sessionId == sessionId &&
session.state == AgentSessionInfo.STATE_WAITING_FOR_USER
}
}
fun attachTarget(sessionId: String) {
requireAgentManager().attachTarget(sessionId)
}
fun cancelSession(sessionId: String) {
requireAgentManager().cancelSession(sessionId)
}
fun failDirectSession(
sessionId: String,
message: String,
) {
val manager = requireAgentManager()
manager.publishError(sessionId, message)
manager.updateSessionState(sessionId, AgentSessionInfo.STATE_FAILED)
}
fun isTerminalSession(sessionId: String): Boolean {
val manager = agentManager ?: return true
val session = manager.getSessions(currentUserId()).firstOrNull { it.sessionId == sessionId } ?: return true
return isTerminalState(session.state)
}
fun cancelActiveSessions(): CancelActiveSessionsResult {
val manager = requireAgentManager()
val activeSessions = manager.getSessions(currentUserId())
.filterNot { isTerminalState(it.state) }
.sortedWith(
compareByDescending<AgentSessionInfo> { it.parentSessionId != null }
.thenBy { it.sessionId },
)
val cancelledSessionIds = mutableListOf<String>()
val failedSessionIds = mutableMapOf<String, String>()
activeSessions.forEach { session ->
runCatching {
manager.cancelSession(session.sessionId)
}.onSuccess {
cancelledSessionIds += session.sessionId
}.onFailure { err ->
failedSessionIds[session.sessionId] = err.message ?: err::class.java.simpleName
}
}
return CancelActiveSessionsResult(
cancelledSessionIds = cancelledSessionIds,
failedSessionIds = failedSessionIds,
)
}
private fun requireAgentManager(): AgentManager {
return checkNotNull(agentManager) { "AgentManager unavailable" }
}
private fun provisionSessionNetworkConfig(sessionId: String) {
val manager = requireAgentManager()
FrameworkSessionTransportCompat.setSessionNetworkConfig(
agentManager = manager,
sessionId = sessionId,
config = AgentResponsesProxy.buildFrameworkSessionNetworkConfig(
context = appContext,
upstreamBaseUrl = "provider-default",
),
)
Log.i(TAG, "Configured framework-owned /responses transport for $sessionId")
}
private fun requireActiveDirectParentSession(
manager: AgentManager,
parentSessionId: String,
) {
val parentSession = manager.getSessions(currentUserId()).firstOrNull { session ->
session.sessionId == parentSessionId
} ?: throw IllegalStateException("Parent session $parentSessionId is no longer available")
check(isDirectParentSession(parentSession)) {
"Session $parentSessionId is not an active direct parent session"
}
check(!isTerminalState(parentSession.state)) {
"Parent session $parentSessionId is no longer active"
}
}
private fun shouldRetryAnswerQuestion(
sessionId: String,
err: Throwable,
): Boolean {
return err.message?.contains("not waiting for user input", ignoreCase = true) == true ||
!isSessionWaitingForUser(sessionId)
}
private fun chooseSelectedSession(
sessions: List<AgentSessionDetails>,
focusedSessionId: String?,
): AgentSessionDetails? {
val sessionsById = sessions.associateBy(AgentSessionDetails::sessionId)
val focusedSession = focusedSessionId?.let(sessionsById::get)
if (focusedSession != null) {
if (focusedSession.parentSessionId != null) {
return focusedSession
}
val childCandidate = sessions.firstOrNull { session ->
session.parentSessionId == focusedSession.sessionId &&
session.state == AgentSessionInfo.STATE_WAITING_FOR_USER
} ?: sessions.firstOrNull { session ->
session.parentSessionId == focusedSession.sessionId &&
!isTerminalState(session.state)
}
val latestChild = sessions.lastOrNull { session ->
session.parentSessionId == focusedSession.sessionId
}
return childCandidate ?: latestChild ?: focusedSession
}
return sessions.firstOrNull { session ->
session.parentSessionId != null &&
session.state == AgentSessionInfo.STATE_WAITING_FOR_USER
} ?: sessions.firstOrNull { session ->
session.parentSessionId != null && !isTerminalState(session.state)
} ?: sessions.firstOrNull(::isDirectParentSession) ?: sessions.firstOrNull()
}
private fun findParentSession(
sessions: List<AgentSessionDetails>,
selectedSession: AgentSessionDetails?,
): AgentSessionDetails? {
if (selectedSession == null) {
return null
}
if (selectedSession.parentSessionId == null) {
return if (isDirectParentSession(selectedSession)) {
selectedSession
} else {
null
}
}
return sessions.firstOrNull { it.sessionId == selectedSession.parentSessionId }
}
private fun selectGeniePackage(roleHolders: List<String>): String? {
return when {
roleHolders.contains(PREFERRED_GENIE_PACKAGE) -> PREFERRED_GENIE_PACKAGE
else -> roleHolders.firstOrNull()
}
}
private fun deriveDirectParentUiState(sessions: List<AgentSessionDetails>): List<AgentSessionDetails> {
val childrenByParent = sessions
.filter { it.parentSessionId != null }
.groupBy { it.parentSessionId }
return sessions.map { session ->
if (!isDirectParentSession(session)) {
return@map session
}
val childSessions = childrenByParent[session.sessionId].orEmpty()
if (childSessions.isEmpty()) {
return@map session
}
val rollup = AgentParentSessionAggregator.rollup(
childSessions.map { childSession ->
ParentSessionChildSummary(
sessionId = childSession.sessionId,
targetPackage = childSession.targetPackage,
state = childSession.state,
targetPresentation = childSession.targetPresentation,
requiredFinalPresentationPolicy = childSession.requiredFinalPresentationPolicy,
latestResult = childSession.latestResult,
latestError = childSession.latestError,
)
},
)
val isRollupTerminal = isTerminalState(rollup.state)
session.copy(
state = rollup.state,
stateLabel = stateToString(rollup.state),
latestResult = rollup.resultMessage ?: session.latestResult.takeIf { isRollupTerminal },
latestError = rollup.errorMessage ?: session.latestError.takeIf { isRollupTerminal },
latestTrace = when (rollup.state) {
AgentSessionInfo.STATE_RUNNING -> "Child session running."
AgentSessionInfo.STATE_WAITING_FOR_USER -> "Child session waiting for user input."
AgentSessionInfo.STATE_QUEUED -> "Child session queued."
else -> session.latestTrace
},
)
}
}
private fun buildDelegatedPrompt(target: AgentDelegationTarget): String {
return buildString {
appendLine(target.objective)
appendLine()
appendLine("Required final target presentation: ${target.finalPresentationPolicy.wireValue}")
append(target.finalPresentationPolicy.promptGuidance())
}.trim()
}
private fun findLastEventMessage(events: List<AgentSessionEvent>, type: Int): String? {
for (index in events.indices.reversed()) {
val event = events[index]
if (event.type == type && event.message != null) {
return normalizeEventMessage(event.message)
}
}
return null
}
private fun loadSessionDiagnostics(manager: AgentManager, sessionId: String): SessionDiagnostics {
val events = manager.getSessionEvents(sessionId)
return SessionDiagnostics(
latestQuestion = findLastEventMessage(events, AgentSessionEvent.TYPE_QUESTION),
latestResult = findLastEventMessage(events, AgentSessionEvent.TYPE_RESULT),
latestError = findLastEventMessage(events, AgentSessionEvent.TYPE_ERROR),
latestTrace = findLastEventMessage(events, AgentSessionEvent.TYPE_TRACE),
timeline = renderTimeline(events),
)
}
private fun renderTimeline(events: List<AgentSessionEvent>): String {
if (events.isEmpty()) {
return "No framework events yet."
}
return events.takeLast(MAX_TIMELINE_EVENTS).joinToString("\n") { event ->
"${eventTypeToString(event.type)}: ${normalizeEventMessage(event.message).orEmpty()}"
}
}
private fun normalizeEventMessage(message: String?): String? {
val trimmed = message?.trim()?.takeIf(String::isNotEmpty) ?: return null
if (trimmed.startsWith(BRIDGE_REQUEST_PREFIX)) {
return summarizeBridgeRequest(trimmed)
}
if (trimmed.startsWith(BRIDGE_RESPONSE_PREFIX)) {
return summarizeBridgeResponse(trimmed)
}
return trimmed
}
private fun summarizeBridgeRequest(message: String): String {
val request = runCatching {
org.json.JSONObject(message.removePrefix(BRIDGE_REQUEST_PREFIX))
}.getOrNull()
val method = request?.optString("method")?.ifEmpty { "unknown" } ?: "unknown"
val requestId = request?.optString("requestId")?.takeIf(String::isNotBlank)
return buildString {
append("Bridge request: ")
append(method)
requestId?.let { append(" (#$it)") }
}
}
private fun summarizeBridgeResponse(message: String): String {
val response = runCatching {
org.json.JSONObject(message.removePrefix(BRIDGE_RESPONSE_PREFIX))
}.getOrNull()
val requestId = response?.optString("requestId")?.takeIf(String::isNotBlank)
val statusCode = response?.optJSONObject("httpResponse")?.optInt("statusCode")
val ok = response?.optBoolean("ok")
return buildString {
append("Bridge response")
requestId?.let { append(" (#$it)") }
if (statusCode != null) {
append(": HTTP $statusCode")
} else if (ok != null) {
append(": ")
append(if (ok) "ok" else "error")
}
}
}
private fun eventTypeToString(type: Int): String {
return when (type) {
AgentSessionEvent.TYPE_TRACE -> "Trace"
AgentSessionEvent.TYPE_QUESTION -> "Question"
AgentSessionEvent.TYPE_RESULT -> "Result"
AgentSessionEvent.TYPE_ERROR -> "Error"
AgentSessionEvent.TYPE_POLICY -> "Policy"
AgentSessionEvent.TYPE_DETACHED_ACTION -> "DetachedAction"
AgentSessionEvent.TYPE_ANSWER -> "Answer"
else -> "Event($type)"
}
}
private fun isDirectParentSession(session: AgentSessionDetails): Boolean {
return session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
session.parentSessionId == null &&
session.targetPackage == null
}
private fun isDirectParentSession(session: AgentSessionInfo): Boolean {
return session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
session.parentSessionId == null &&
session.targetPackage == null
}
private fun isTerminalState(state: Int): Boolean {
return state == AgentSessionInfo.STATE_COMPLETED ||
state == AgentSessionInfo.STATE_CANCELLED ||
state == AgentSessionInfo.STATE_FAILED
}
private fun stateToString(state: Int): String {
return when (state) {
AgentSessionInfo.STATE_CREATED -> "CREATED"
AgentSessionInfo.STATE_RUNNING -> "RUNNING"
AgentSessionInfo.STATE_WAITING_FOR_USER -> "WAITING_FOR_USER"
AgentSessionInfo.STATE_QUEUED -> "QUEUED"
AgentSessionInfo.STATE_COMPLETED -> "COMPLETED"
AgentSessionInfo.STATE_CANCELLED -> "CANCELLED"
AgentSessionInfo.STATE_FAILED -> "FAILED"
else -> state.toString()
}
}
private fun currentUserId(): Int = Process.myUid() / 100000
}
data class AgentSnapshot(
val available: Boolean,
val roleHolders: List<String>,
val selectedGeniePackage: String?,
val sessions: List<AgentSessionDetails>,
val selectedSession: AgentSessionDetails?,
val parentSession: AgentSessionDetails?,
val relatedSessions: List<AgentSessionDetails>,
) {
companion object {
val unavailable = AgentSnapshot(
available = false,
roleHolders = emptyList(),
selectedGeniePackage = null,
sessions = emptyList(),
selectedSession = null,
parentSession = null,
relatedSessions = emptyList(),
)
}
}
data class AgentSessionDetails(
val sessionId: String,
val parentSessionId: String?,
val targetPackage: String?,
val anchor: Int,
val state: Int,
val stateLabel: String,
val targetPresentation: Int,
val targetPresentationLabel: String,
val targetRuntime: Int?,
val targetRuntimeLabel: String,
val targetDetached: Boolean,
val requiredFinalPresentationPolicy: SessionFinalPresentationPolicy?,
val latestQuestion: String?,
val latestResult: String?,
val latestError: String?,
val latestTrace: String?,
val timeline: String,
) {
fun withDiagnostics(diagnostics: SessionDiagnostics): AgentSessionDetails {
return copy(
latestQuestion = diagnostics.latestQuestion,
latestResult = diagnostics.latestResult,
latestError = diagnostics.latestError,
latestTrace = diagnostics.latestTrace,
timeline = diagnostics.timeline,
)
}
}
data class SessionDiagnostics(
val latestQuestion: String?,
val latestResult: String?,
val latestError: String?,
val latestTrace: String?,
val timeline: String,
)
data class SessionStartResult(
val parentSessionId: String,
val childSessionIds: List<String>,
val plannedTargets: List<String>,
val geniePackage: String,
val anchor: Int,
)
data class PendingDirectSessionStart(
val parentSessionId: String,
val geniePackage: String,
)
data class CancelActiveSessionsResult(
val cancelledSessionIds: List<String>,
val failedSessionIds: Map<String, String>,
)

View File

@@ -0,0 +1,173 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
import android.content.Context
import com.openai.codex.bridge.SessionExecutionSettings
import kotlin.concurrent.thread
import org.json.JSONArray
import org.json.JSONObject
data class LaunchSessionRequest(
val prompt: String,
val targetPackage: String?,
val model: String?,
val reasoningEffort: String?,
val existingSessionId: String? = null,
)
object AgentSessionLauncher {
fun startSessionAsync(
context: Context,
request: LaunchSessionRequest,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
val executionSettings = SessionExecutionSettings(
model = request.model?.trim()?.ifEmpty { null },
reasoningEffort = request.reasoningEffort?.trim()?.ifEmpty { null },
)
val targetPackage = request.targetPackage?.trim()?.ifEmpty { null }
val existingSessionId = request.existingSessionId?.trim()?.ifEmpty { null }
if (targetPackage != null || existingSessionId != null) {
return startSession(
context = context,
request = request,
sessionController = sessionController,
requestUserInputHandler = requestUserInputHandler,
)
}
val pendingSession = sessionController.createPendingDirectSession(
objective = request.prompt,
executionSettings = executionSettings,
)
val applicationContext = context.applicationContext
thread(name = "CodexAgentPlanner-${pendingSession.parentSessionId}") {
runCatching {
AgentTaskPlanner.planSession(
context = applicationContext,
userObjective = request.prompt,
executionSettings = executionSettings,
sessionController = sessionController,
requestUserInputHandler = null,
frameworkSessionId = pendingSession.parentSessionId,
)
}.onFailure { err ->
if (!sessionController.isTerminalSession(pendingSession.parentSessionId)) {
sessionController.failDirectSession(
pendingSession.parentSessionId,
"Planning failed: ${err.message ?: err::class.java.simpleName}",
)
}
}.onSuccess { plannedRequest ->
if (!sessionController.isTerminalSession(pendingSession.parentSessionId)) {
runCatching {
sessionController.startDirectSessionChildren(
parentSessionId = pendingSession.parentSessionId,
geniePackage = pendingSession.geniePackage,
plan = plannedRequest.plan,
allowDetachedMode = plannedRequest.allowDetachedMode,
executionSettings = executionSettings,
)
}.onFailure { err ->
if (!sessionController.isTerminalSession(pendingSession.parentSessionId)) {
sessionController.failDirectSession(
pendingSession.parentSessionId,
"Failed to start planned child session: ${err.message ?: err::class.java.simpleName}",
)
}
}
}
}
}
return SessionStartResult(
parentSessionId = pendingSession.parentSessionId,
childSessionIds = emptyList(),
plannedTargets = emptyList(),
geniePackage = pendingSession.geniePackage,
anchor = AgentSessionInfo.ANCHOR_AGENT,
)
}
fun startSession(
context: Context,
request: LaunchSessionRequest,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
val executionSettings = SessionExecutionSettings(
model = request.model?.trim()?.ifEmpty { null },
reasoningEffort = request.reasoningEffort?.trim()?.ifEmpty { null },
)
val targetPackage = request.targetPackage?.trim()?.ifEmpty { null }
val existingSessionId = request.existingSessionId?.trim()?.ifEmpty { null }
return if (targetPackage == null) {
check(existingSessionId == null) {
"Existing HOME sessions require a target package"
}
AgentTaskPlanner.startSession(
context = context,
userObjective = request.prompt,
targetPackageOverride = null,
allowDetachedMode = true,
executionSettings = executionSettings,
sessionController = sessionController,
requestUserInputHandler = requestUserInputHandler,
)
} else {
if (existingSessionId != null) {
sessionController.startExistingHomeSession(
sessionId = existingSessionId,
targetPackage = targetPackage,
prompt = request.prompt,
allowDetachedMode = true,
finalPresentationPolicy = SessionFinalPresentationPolicy.AGENT_CHOICE,
executionSettings = executionSettings,
)
} else {
sessionController.startHomeSession(
targetPackage = targetPackage,
prompt = request.prompt,
allowDetachedMode = true,
finalPresentationPolicy = SessionFinalPresentationPolicy.AGENT_CHOICE,
executionSettings = executionSettings,
)
}
}
}
fun continueSessionInPlace(
sourceTopLevelSession: AgentSessionDetails,
selectedSession: AgentSessionDetails,
prompt: String,
sessionController: AgentSessionController,
): SessionStartResult {
val executionSettings = sessionController.executionSettingsForSession(sourceTopLevelSession.sessionId)
return when (sourceTopLevelSession.anchor) {
AgentSessionInfo.ANCHOR_HOME -> {
throw UnsupportedOperationException(
"In-place continuation is not supported for app-scoped HOME sessions on the current framework",
)
}
else -> {
val targetPackage = checkNotNull(selectedSession.targetPackage) {
"Select a target child session to continue"
}
sessionController.continueDirectSessionInPlace(
parentSessionId = sourceTopLevelSession.sessionId,
target = AgentDelegationTarget(
packageName = targetPackage,
objective = SessionContinuationPromptBuilder.build(
sourceTopLevelSession = sourceTopLevelSession,
selectedSession = selectedSession,
prompt = prompt,
),
finalPresentationPolicy = selectedSession.requiredFinalPresentationPolicy
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
),
executionSettings = executionSettings,
)
}
}
}
}

View File

@@ -0,0 +1,298 @@
package com.openai.codex.agent
import android.content.Context
import android.util.Log
import com.openai.codex.bridge.SessionExecutionSettings
import java.io.IOException
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
data class AgentDelegationTarget(
val packageName: String,
val objective: String,
val finalPresentationPolicy: SessionFinalPresentationPolicy,
)
data class AgentDelegationPlan(
val originalObjective: String,
val targets: List<AgentDelegationTarget>,
val rationale: String?,
val usedOverride: Boolean,
) {
val primaryTargetPackage: String
get() = targets.first().packageName
}
object AgentTaskPlanner {
private const val TAG = "AgentTaskPlanner"
private const val PLANNER_ATTEMPTS = 2
private const val PLANNER_REQUEST_TIMEOUT_MS = 90_000L
private val PLANNER_INSTRUCTIONS =
"""
You are Codex acting as the Android Agent orchestrator.
The user interacts only with the Agent. Decide which installed Android packages should receive delegated Genie sessions.
Use the standard Android shell tools already available in this runtime, such as `cmd package`, `pm`, and `am`, to inspect installed packages and resolve the correct targets.
Return exactly one JSON object and nothing else. Do not wrap it in markdown fences.
JSON schema:
{
"targets": [
{
"packageName": "installed.package",
"objective": "free-form delegated objective for the child Genie",
"finalPresentationPolicy": "ATTACHED | DETACHED_HIDDEN | DETACHED_SHOWN | AGENT_CHOICE"
}
],
"reason": "short rationale",
"allowDetachedMode": true
}
Rules:
- Choose the fewest packages needed to complete the request.
- `targets` must be non-empty.
- Each delegated `objective` should be written for the child Genie, not the user.
- Each target must include `finalPresentationPolicy`.
- Use `ATTACHED` when the user wants the target left on the main screen or explicitly visible to them.
- Use `DETACHED_SHOWN` when the target should remain visible but stay detached.
- Use `DETACHED_HIDDEN` when the target should complete in the background without remaining visible.
- Use `AGENT_CHOICE` only when the final presentation state does not matter.
- Stop after at most 6 shell commands.
- Start from the installed package list, then narrow to the most likely candidates.
- Prefer direct package-manager commands over broad shell pipelines.
- Verify each chosen package by inspecting focused query-activities or resolve-activity output before returning it.
- Only choose packages that directly own the requested app behavior. Never choose helper packages such as `com.android.shell`, `com.android.systemui`, or the Codex Agent/Genie packages unless the user explicitly asked for them.
- If the user objective already names a specific installed package, use it directly after verification.
- `pm list packages PACKAGE_NAME` alone is not sufficient verification.
- Prefer focused verification commands such as `pm list packages clock`, `cmd package query-activities --brief -p PACKAGE -a android.intent.action.MAIN`, and `cmd package resolve-activity --brief -a RELEVANT_ACTION PACKAGE`.
- Do not enumerate every launcher activity on the device. Query specific candidate packages instead.
""".trimIndent()
private val PLANNER_OUTPUT_SCHEMA =
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject()
.put(
"targets",
JSONObject()
.put("type", "array")
.put("minItems", 1)
.put(
"items",
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject()
.put("packageName", JSONObject().put("type", "string"))
.put("objective", JSONObject().put("type", "string"))
.put(
"finalPresentationPolicy",
JSONObject()
.put("type", "string")
.put(
"enum",
JSONArray()
.put(SessionFinalPresentationPolicy.ATTACHED.wireValue)
.put(SessionFinalPresentationPolicy.DETACHED_HIDDEN.wireValue)
.put(SessionFinalPresentationPolicy.DETACHED_SHOWN.wireValue)
.put(SessionFinalPresentationPolicy.AGENT_CHOICE.wireValue),
),
),
)
.put(
"required",
JSONArray()
.put("packageName")
.put("objective")
.put("finalPresentationPolicy"),
)
.put("additionalProperties", false),
),
)
.put("reason", JSONObject().put("type", "string"))
.put("allowDetachedMode", JSONObject().put("type", "boolean")),
)
.put("required", JSONArray().put("targets").put("reason").put("allowDetachedMode"))
.put("additionalProperties", false)
fun startSession(
context: Context,
userObjective: String,
targetPackageOverride: String?,
allowDetachedMode: Boolean,
finalPresentationPolicyOverride: SessionFinalPresentationPolicy? = null,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
if (!targetPackageOverride.isNullOrBlank()) {
Log.i(TAG, "Using explicit target override $targetPackageOverride")
return sessionController.startDirectSession(
plan = AgentDelegationPlan(
originalObjective = userObjective,
targets = listOf(
AgentDelegationTarget(
packageName = targetPackageOverride,
objective = userObjective,
finalPresentationPolicy =
finalPresentationPolicyOverride ?: SessionFinalPresentationPolicy.AGENT_CHOICE,
),
),
rationale = "Using explicit target package override.",
usedOverride = true,
),
allowDetachedMode = allowDetachedMode,
)
}
val pendingSession = sessionController.createPendingDirectSession(
objective = userObjective,
executionSettings = executionSettings,
)
val sessionStartResult = try {
val request = planSession(
context = context,
userObjective = userObjective,
executionSettings = executionSettings,
sessionController = sessionController,
requestUserInputHandler = requestUserInputHandler,
frameworkSessionId = pendingSession.parentSessionId,
)
sessionController.startDirectSessionChildren(
parentSessionId = pendingSession.parentSessionId,
geniePackage = pendingSession.geniePackage,
plan = request.plan,
allowDetachedMode = allowDetachedMode && request.allowDetachedMode,
executionSettings = executionSettings,
cancelParentOnFailure = true,
)
} catch (err: IOException) {
runCatching { sessionController.cancelSession(pendingSession.parentSessionId) }
throw err
} catch (err: RuntimeException) {
runCatching { sessionController.cancelSession(pendingSession.parentSessionId) }
throw err
}
Log.i(TAG, "Planner sessionStartResult=$sessionStartResult")
return sessionStartResult
}
fun planSession(
context: Context,
userObjective: String,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
frameworkSessionId: String? = null,
): AgentFrameworkToolBridge.StartDirectSessionRequest {
Log.i(TAG, "Planning Agent session for objective=${userObjective.take(160)}")
val isEligibleTargetPackage = { packageName: String ->
sessionController.canStartSessionForTarget(packageName) &&
packageName !in setOf(
"com.android.shell",
"com.android.systemui",
"com.openai.codex.agent",
"com.openai.codex.genie",
)
}
var previousPlannerResponse: String? = null
var plannerRequest: AgentFrameworkToolBridge.StartDirectSessionRequest? = null
var lastPlannerError: IOException? = null
for (attemptIndex in 0 until PLANNER_ATTEMPTS) {
val plannerResponse = AgentPlannerRuntimeManager.requestText(
context = context,
instructions = PLANNER_INSTRUCTIONS,
prompt = buildPlannerPrompt(
userObjective = userObjective,
previousPlannerResponse = previousPlannerResponse,
previousPlannerError = lastPlannerError?.message,
),
outputSchema = PLANNER_OUTPUT_SCHEMA,
requestUserInputHandler = requestUserInputHandler,
executionSettings = executionSettings,
requestTimeoutMs = PLANNER_REQUEST_TIMEOUT_MS,
frameworkSessionId = frameworkSessionId,
)
Log.i(TAG, "Planner response=${plannerResponse.take(400)}")
previousPlannerResponse = plannerResponse
val parsedRequest = runCatching {
parsePlannerResponse(
responseText = plannerResponse,
userObjective = userObjective,
isEligibleTargetPackage = isEligibleTargetPackage,
)
}.getOrElse { err ->
if (err is IOException && attemptIndex < PLANNER_ATTEMPTS - 1) {
Log.w(TAG, "Planner response rejected: ${err.message}")
lastPlannerError = err
continue
}
throw err
}
plannerRequest = parsedRequest
break
}
return plannerRequest ?: throw (lastPlannerError
?: IOException("Planner did not return a valid session plan"))
}
private fun buildPlannerPrompt(
userObjective: String,
previousPlannerResponse: String?,
previousPlannerError: String?,
): String {
return buildString {
appendLine("User objective:")
appendLine(userObjective)
if (!previousPlannerError.isNullOrBlank()) {
appendLine()
appendLine("Previous candidate plan was rejected by host validation:")
appendLine(previousPlannerError)
appendLine("Choose a different installed target package and verify it with focused package commands.")
}
if (!previousPlannerResponse.isNullOrBlank()) {
appendLine()
appendLine("Previous invalid planner response:")
appendLine(previousPlannerResponse)
}
}.trim()
}
internal fun parsePlannerResponse(
responseText: String,
userObjective: String,
isEligibleTargetPackage: (String) -> Boolean,
): AgentFrameworkToolBridge.StartDirectSessionRequest {
val plannerJson = extractPlannerJson(responseText)
return AgentFrameworkToolBridge.parseStartDirectSessionArguments(
arguments = plannerJson,
userObjective = userObjective,
isEligibleTargetPackage = isEligibleTargetPackage,
)
}
private fun extractPlannerJson(responseText: String): JSONObject {
val trimmed = responseText.trim()
parseJsonObject(trimmed)?.let { return it }
val unfenced = trimmed
.removePrefix("```json")
.removePrefix("```")
.removeSuffix("```")
.trim()
parseJsonObject(unfenced)?.let { return it }
val firstBrace = trimmed.indexOf('{')
val lastBrace = trimmed.lastIndexOf('}')
if (firstBrace >= 0 && lastBrace > firstBrace) {
parseJsonObject(trimmed.substring(firstBrace, lastBrace + 1))?.let { return it }
}
throw IOException("Planner did not return a valid JSON object")
}
private fun parseJsonObject(text: String): JSONObject? {
return runCatching {
val tokener = JSONTokener(text)
val value = tokener.nextValue()
value as? JSONObject
}.getOrNull()
}
}

View File

@@ -0,0 +1,116 @@
package com.openai.codex.agent
import android.app.Activity
import android.app.AlertDialog
import android.widget.EditText
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference
import org.json.JSONArray
import org.json.JSONObject
object AgentUserInputPrompter {
fun promptForAnswers(
activity: Activity,
questions: JSONArray,
): JSONObject {
val latch = CountDownLatch(1)
val answerText = AtomicReference("")
val error = AtomicReference<IOException?>(null)
activity.runOnUiThread {
val input = EditText(activity).apply {
minLines = 4
maxLines = 8
setSingleLine(false)
setText("")
hint = "Type your answer here"
}
AlertDialog.Builder(activity)
.setTitle("Codex needs input")
.setMessage(renderQuestions(questions))
.setView(input)
.setCancelable(false)
.setPositiveButton("Submit") { dialog, _ ->
answerText.set(input.text?.toString().orEmpty())
dialog.dismiss()
latch.countDown()
}
.setNegativeButton("Cancel") { dialog, _ ->
error.set(IOException("User cancelled Agent input"))
dialog.dismiss()
latch.countDown()
}
.show()
}
latch.await()
error.get()?.let { throw it }
return JSONObject().put("answers", buildQuestionAnswers(questions, answerText.get()))
}
internal fun renderQuestions(questions: JSONArray): String {
if (questions.length() == 0) {
return "Codex requested input but did not provide a question."
}
val rendered = buildString {
for (index in 0 until questions.length()) {
val question = questions.optJSONObject(index) ?: continue
if (length > 0) {
append("\n\n")
}
val header = question.optString("header").takeIf(String::isNotBlank)
if (header != null) {
append(header)
append(":\n")
}
append(question.optString("question"))
val options = question.optJSONArray("options")
if (options != null && options.length() > 0) {
append("\nOptions:")
for (optionIndex in 0 until options.length()) {
val option = options.optJSONObject(optionIndex) ?: continue
append("\n- ")
append(option.optString("label"))
val description = option.optString("description")
if (description.isNotBlank()) {
append(": ")
append(description)
}
}
}
}
}
return if (questions.length() == 1) {
rendered
} else {
"$rendered\n\nReply with one answer per question, separated by a blank line."
}
}
internal fun buildQuestionAnswers(
questions: JSONArray,
answer: String,
): JSONObject {
val splitAnswers = answer
.split(Regex("\\n\\s*\\n"))
.map(String::trim)
.filter(String::isNotEmpty)
val answersJson = JSONObject()
for (index in 0 until questions.length()) {
val question = questions.optJSONObject(index) ?: continue
val questionId = question.optString("id")
if (questionId.isBlank()) {
continue
}
val responseText = splitAnswers.getOrNull(index)
?: if (index == 0) answer.trim() else ""
answersJson.put(
questionId,
JSONObject().put(
"answers",
JSONArray().put(responseText),
),
)
}
return answersJson
}
}

View File

@@ -0,0 +1,19 @@
package com.openai.codex.agent
import android.content.Context
object AppLabelResolver {
fun loadAppLabel(
context: Context,
packageName: String?,
): String {
if (packageName.isNullOrBlank()) {
return "Agent"
}
val pm = context.packageManager
return runCatching {
val applicationInfo = pm.getApplicationInfo(packageName, 0)
pm.getApplicationLabel(applicationInfo)?.toString().orEmpty().ifBlank { packageName }
}.getOrDefault(packageName)
}
}

View File

@@ -0,0 +1,473 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.app.agent.AgentService
import android.app.agent.AgentSessionEvent
import android.app.agent.AgentSessionInfo
import android.os.Process
import android.util.Log
import java.io.IOException
import kotlin.concurrent.thread
import org.json.JSONObject
class CodexAgentService : AgentService() {
companion object {
private const val TAG = "CodexAgentService"
private const val BRIDGE_REQUEST_PREFIX = "__codex_bridge__ "
private const val BRIDGE_RESPONSE_PREFIX = "__codex_bridge_result__ "
private const val BRIDGE_METHOD_GET_RUNTIME_STATUS = "getRuntimeStatus"
private const val AUTO_ANSWER_ESCALATE_PREFIX = "ESCALATE:"
private const val AUTO_ANSWER_INSTRUCTIONS =
"You are Codex acting as the Android Agent supervising a Genie execution. If you can answer the current Genie question from the available session context, call the framework session tool `android.framework.sessions.answer_question` exactly once with a short free-form answer. You may inspect current framework state with `android.framework.sessions.list`. If user input is required, do not call any framework tool. Instead reply with `ESCALATE: ` followed by the exact question the Agent should ask the user."
private const val MAX_AUTO_ANSWER_CONTEXT_CHARS = 800
private val handledGenieQuestions = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
private val pendingGenieQuestions = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
private val pendingQuestionLoads = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
private val handledBridgeRequests = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
private val pendingParentRollups = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
}
private sealed class AutoAnswerResult {
data object Answered : AutoAnswerResult()
data class Escalate(
val question: String,
) : AutoAnswerResult()
}
private val agentManager by lazy { getSystemService(AgentManager::class.java) }
private val sessionController by lazy { AgentSessionController(this) }
private val presentationPolicyStore by lazy { SessionPresentationPolicyStore(this) }
override fun onCreate() {
super.onCreate()
}
override fun onSessionChanged(session: AgentSessionInfo) {
Log.i(TAG, "onSessionChanged $session")
maybeRollUpParentSession(session)
agentManager?.let { manager ->
if (shouldServeSessionBridge(session)) {
AgentSessionBridgeServer.ensureStarted(this, manager, session.sessionId)
} else if (isTerminalSessionState(session.state)) {
AgentSessionBridgeServer.closeSession(session.sessionId)
}
}
if (session.state != AgentSessionInfo.STATE_WAITING_FOR_USER) {
AgentQuestionNotifier.cancel(this, session.sessionId)
return
}
if (!pendingQuestionLoads.add(session.sessionId)) {
return
}
thread(name = "CodexAgentQuestionLoad-${session.sessionId}") {
try {
handleWaitingSession(session)
} finally {
pendingQuestionLoads.remove(session.sessionId)
}
}
}
override fun onSessionRemoved(sessionId: String) {
Log.i(TAG, "onSessionRemoved sessionId=$sessionId")
AgentSessionBridgeServer.closeSession(sessionId)
AgentQuestionNotifier.cancel(this, sessionId)
presentationPolicyStore.removePolicy(sessionId)
handledGenieQuestions.removeIf { it.startsWith("$sessionId:") }
handledBridgeRequests.removeIf { it.startsWith("$sessionId:") }
pendingGenieQuestions.removeIf { it.startsWith("$sessionId:") }
}
private fun maybeRollUpParentSession(session: AgentSessionInfo) {
val parentSessionId = when {
!session.parentSessionId.isNullOrBlank() -> session.parentSessionId
isDirectParentSession(session) -> session.sessionId
else -> null
} ?: return
if (!pendingParentRollups.add(parentSessionId)) {
return
}
thread(name = "CodexAgentParentRollup-$parentSessionId") {
try {
runCatching {
rollUpParentSession(parentSessionId)
}.onFailure { err ->
Log.w(TAG, "Parent session roll-up failed for $parentSessionId", err)
}
} finally {
pendingParentRollups.remove(parentSessionId)
}
}
}
private fun rollUpParentSession(parentSessionId: String) {
val manager = agentManager ?: return
val sessions = manager.getSessions(currentUserId())
val parentSession = sessions.firstOrNull { it.sessionId == parentSessionId } ?: return
if (!isDirectParentSession(parentSession)) {
return
}
val childSessions = sessions.filter { it.parentSessionId == parentSessionId }
if (childSessions.isEmpty()) {
return
}
val rollup = AgentParentSessionAggregator.rollup(
childSessions.map { childSession ->
val events = manager.getSessionEvents(childSession.sessionId)
ParentSessionChildSummary(
sessionId = childSession.sessionId,
targetPackage = childSession.targetPackage,
state = childSession.state,
targetPresentation = childSession.targetPresentation,
requiredFinalPresentationPolicy = presentationPolicyStore.getPolicy(childSession.sessionId),
latestResult = findLastEventMessage(events, AgentSessionEvent.TYPE_RESULT),
latestError = findLastEventMessage(events, AgentSessionEvent.TYPE_ERROR),
)
},
)
rollup.sessionsToAttach.forEach { childSessionId ->
runCatching {
manager.attachTarget(childSessionId)
manager.publishTrace(
parentSessionId,
"Requested attach for $childSessionId to satisfy the required final presentation policy.",
)
}.onFailure { err ->
Log.w(TAG, "Failed to attach target for $childSessionId", err)
}
}
if (shouldUpdateParentSessionState(parentSession.state, rollup.state)) {
runCatching {
manager.updateSessionState(parentSessionId, rollup.state)
}.onFailure { err ->
Log.w(TAG, "Failed to update parent session state for $parentSessionId", err)
}
}
val parentEvents = if (rollup.resultMessage != null || rollup.errorMessage != null) {
manager.getSessionEvents(parentSessionId)
} else {
emptyList()
}
if (rollup.resultMessage != null && findLastEventMessage(parentEvents, AgentSessionEvent.TYPE_RESULT) != rollup.resultMessage) {
runCatching {
manager.publishResult(parentSessionId, rollup.resultMessage)
}.onFailure { err ->
Log.w(TAG, "Failed to publish parent result for $parentSessionId", err)
}
}
if (rollup.errorMessage != null && findLastEventMessage(parentEvents, AgentSessionEvent.TYPE_ERROR) != rollup.errorMessage) {
runCatching {
manager.publishError(parentSessionId, rollup.errorMessage)
}.onFailure { err ->
Log.w(TAG, "Failed to publish parent error for $parentSessionId", err)
}
}
}
private fun shouldServeSessionBridge(session: AgentSessionInfo): Boolean {
if (session.targetPackage.isNullOrBlank()) {
return false
}
return !isTerminalSessionState(session.state)
}
private fun shouldUpdateParentSessionState(
currentState: Int,
proposedState: Int,
): Boolean {
if (currentState == proposedState || isTerminalSessionState(currentState)) {
return false
}
if (
(currentState == AgentSessionInfo.STATE_RUNNING || currentState == AgentSessionInfo.STATE_WAITING_FOR_USER) &&
(proposedState == AgentSessionInfo.STATE_CREATED || proposedState == AgentSessionInfo.STATE_QUEUED)
) {
return false
}
return true
}
private fun isTerminalSessionState(state: Int): Boolean {
return when (state) {
AgentSessionInfo.STATE_COMPLETED,
AgentSessionInfo.STATE_CANCELLED,
AgentSessionInfo.STATE_FAILED,
-> true
else -> false
}
}
private fun handleWaitingSession(session: AgentSessionInfo) {
val manager = agentManager ?: return
val events = manager.getSessionEvents(session.sessionId)
val question = findLatestQuestion(events) ?: return
updateQuestionNotification(session, question)
maybeAutoAnswerGenieQuestion(session, question, events)
}
private fun maybeAutoAnswerGenieQuestion(
session: AgentSessionInfo,
question: String,
events: List<AgentSessionEvent>,
) {
val questionKey = genieQuestionKey(session.sessionId, question)
if (handledGenieQuestions.contains(questionKey) || !pendingGenieQuestions.add(questionKey)) {
return
}
thread(name = "CodexAgentAutoAnswer-${session.sessionId}") {
Log.i(TAG, "Attempting Agent auto-answer for ${session.sessionId}")
runCatching {
if (isBridgeQuestion(question)) {
answerBridgeQuestion(session, question)
handledGenieQuestions.add(questionKey)
AgentQuestionNotifier.cancel(this, session.sessionId)
Log.i(TAG, "Answered bridge question for ${session.sessionId}")
} else {
when (val result = requestGenieAutoAnswer(session, question, events)) {
AutoAnswerResult.Answered -> {
handledGenieQuestions.add(questionKey)
AgentQuestionNotifier.cancel(this, session.sessionId)
Log.i(TAG, "Auto-answered Genie question for ${session.sessionId}")
}
is AutoAnswerResult.Escalate -> {
if (sessionController.isSessionWaitingForUser(session.sessionId)) {
AgentQuestionNotifier.showQuestion(
context = this,
sessionId = session.sessionId,
targetPackage = session.targetPackage,
question = result.question,
)
}
}
}
}
}.onFailure { err ->
Log.i(TAG, "Agent auto-answer unavailable for ${session.sessionId}: ${err.message}")
if (!isBridgeQuestion(question) && sessionController.isSessionWaitingForUser(session.sessionId)) {
AgentQuestionNotifier.showQuestion(
context = this,
sessionId = session.sessionId,
targetPackage = session.targetPackage,
question = question,
)
}
}
pendingGenieQuestions.remove(questionKey)
}
}
private fun updateQuestionNotification(session: AgentSessionInfo, question: String) {
if (question.isBlank()) {
AgentQuestionNotifier.cancel(this, session.sessionId)
return
}
if (isBridgeQuestion(question)) {
AgentQuestionNotifier.cancel(this, session.sessionId)
return
}
if (pendingGenieQuestions.contains(genieQuestionKey(session.sessionId, question))) {
return
}
AgentQuestionNotifier.showQuestion(
context = this,
sessionId = session.sessionId,
targetPackage = session.targetPackage,
question = question,
)
}
private fun requestGenieAutoAnswer(
session: AgentSessionInfo,
question: String,
events: List<AgentSessionEvent>,
): AutoAnswerResult {
val runtimeStatus = AgentCodexAppServerClient.readRuntimeStatus(this)
if (!runtimeStatus.authenticated) {
throw IOException("Agent runtime is not authenticated")
}
val frameworkToolBridge = AgentFrameworkToolBridge(this, sessionController)
var answered = false
val response = AgentCodexAppServerClient.requestText(
context = this,
instructions = AUTO_ANSWER_INSTRUCTIONS,
prompt = buildAutoAnswerPrompt(session, question, events),
dynamicTools = frameworkToolBridge.buildQuestionResolutionToolSpecs(),
toolCallHandler = { toolName, arguments ->
if (
toolName == AgentFrameworkToolBridge.ANSWER_QUESTION_TOOL &&
arguments.optString("sessionId").trim().isEmpty()
) {
arguments.put("sessionId", session.sessionId)
}
if (
toolName == AgentFrameworkToolBridge.ANSWER_QUESTION_TOOL &&
arguments.optString("parentSessionId").trim().isEmpty() &&
!session.parentSessionId.isNullOrBlank()
) {
arguments.put("parentSessionId", session.parentSessionId)
}
val toolResult = frameworkToolBridge.handleToolCall(
toolName = toolName,
arguments = arguments,
userObjective = question,
focusedSessionId = session.sessionId,
)
if (toolName == AgentFrameworkToolBridge.ANSWER_QUESTION_TOOL) {
answered = true
}
toolResult
},
frameworkSessionId = session.sessionId,
).trim()
if (answered) {
return AutoAnswerResult.Answered
}
if (response.startsWith(AUTO_ANSWER_ESCALATE_PREFIX, ignoreCase = true)) {
val escalateQuestion = response.substringAfter(':').trim().ifEmpty { question }
return AutoAnswerResult.Escalate(escalateQuestion)
}
if (response.isNotBlank()) {
sessionController.answerQuestion(session.sessionId, response, session.parentSessionId)
return AutoAnswerResult.Answered
}
throw IOException("Agent runtime did not return an answer")
}
private fun buildAutoAnswerPrompt(
session: AgentSessionInfo,
question: String,
events: List<AgentSessionEvent>,
): String {
val recentContext = renderRecentContext(events)
return """
Target package: ${session.targetPackage ?: "unknown"}
Current Genie question: $question
Recent session context:
$recentContext
""".trimIndent()
}
private fun renderRecentContext(events: List<AgentSessionEvent>): String {
val context = events
.takeLast(6)
.joinToString("\n") { event ->
"${eventTypeToString(event.type)}: ${event.message ?: ""}"
}
if (context.length <= MAX_AUTO_ANSWER_CONTEXT_CHARS) {
return context.ifBlank { "No prior Genie context." }
}
return context.takeLast(MAX_AUTO_ANSWER_CONTEXT_CHARS)
}
private fun findLatestQuestion(events: List<AgentSessionEvent>): String? {
return events.lastOrNull { event ->
event.type == AgentSessionEvent.TYPE_QUESTION &&
!event.message.isNullOrBlank()
}?.message
}
private fun findLastEventMessage(events: List<AgentSessionEvent>, type: Int): String? {
return events.lastOrNull { event ->
event.type == type && !event.message.isNullOrBlank()
}?.message
}
private fun isBridgeQuestion(question: String): Boolean {
return question.startsWith(BRIDGE_REQUEST_PREFIX)
}
private fun answerBridgeQuestion(
session: AgentSessionInfo,
question: String,
) {
val request = JSONObject(question.removePrefix(BRIDGE_REQUEST_PREFIX))
val requestId = request.optString("requestId")
if (requestId.isNotBlank()) {
val bridgeRequestKey = "${session.sessionId}:$requestId"
if (!handledBridgeRequests.add(bridgeRequestKey)) {
Log.i(
TAG,
"Skipping duplicate bridge question method=${request.optString("method")} requestId=$requestId session=${session.sessionId}",
)
return
}
}
Log.i(
TAG,
"Answering bridge question method=${request.optString("method")} requestId=$requestId session=${session.sessionId}",
)
val response: JSONObject = runCatching {
when (request.optString("method")) {
BRIDGE_METHOD_GET_RUNTIME_STATUS -> {
val status = AgentCodexAppServerClient.readRuntimeStatus(this)
JSONObject()
.put("requestId", requestId)
.put("ok", true)
.put(
"runtimeStatus",
JSONObject()
.put("authenticated", status.authenticated)
.put("accountEmail", status.accountEmail)
.put("clientCount", status.clientCount)
.put("modelProviderId", status.modelProviderId)
.put("configuredModel", status.configuredModel)
.put("effectiveModel", status.effectiveModel)
.put("upstreamBaseUrl", status.upstreamBaseUrl)
.put("frameworkResponsesPath", status.frameworkResponsesPath),
)
}
else -> JSONObject()
.put("requestId", requestId)
.put("ok", false)
.put("error", "Unsupported bridge method: ${request.optString("method")}")
}
}.getOrElse { err ->
JSONObject()
.put("requestId", requestId)
.put("ok", false)
.put("error", err.message ?: err::class.java.simpleName)
}
sessionController.answerQuestion(
session.sessionId,
BRIDGE_RESPONSE_PREFIX + response.toString(),
session.parentSessionId,
)
}
private fun eventTypeToString(type: Int): String {
return when (type) {
AgentSessionEvent.TYPE_TRACE -> "Trace"
AgentSessionEvent.TYPE_QUESTION -> "Question"
AgentSessionEvent.TYPE_RESULT -> "Result"
AgentSessionEvent.TYPE_ERROR -> "Error"
AgentSessionEvent.TYPE_POLICY -> "Policy"
AgentSessionEvent.TYPE_DETACHED_ACTION -> "DetachedAction"
AgentSessionEvent.TYPE_ANSWER -> "Answer"
else -> "Event($type)"
}
}
private fun genieQuestionKey(sessionId: String, question: String): String {
if (isBridgeQuestion(question)) {
val requestId = runCatching {
JSONObject(question.removePrefix(BRIDGE_REQUEST_PREFIX)).optString("requestId").trim()
}.getOrNull()
if (!requestId.isNullOrEmpty()) {
return "$sessionId:bridge:$requestId"
}
}
return "$sessionId:$question"
}
private fun isDirectParentSession(session: AgentSessionInfo): Boolean {
return session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
session.parentSessionId == null &&
session.targetPackage == null
}
private fun currentUserId(): Int {
return Process.myUid() / 100000
}
}

View File

@@ -0,0 +1,15 @@
package com.openai.codex.agent
import android.content.Context
import java.io.File
import java.io.IOException
object CodexCliBinaryLocator {
fun resolve(context: Context): File {
val binary = File(context.applicationInfo.nativeLibraryDir, "libcodex.so")
if (!binary.exists()) {
throw IOException("codex binary missing at ${binary.absolutePath}")
}
return binary
}
}

View File

@@ -0,0 +1,599 @@
package com.openai.codex.agent
import android.app.Activity
import android.app.agent.AgentManager
import android.app.agent.AgentSessionInfo
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Binder
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import com.openai.codex.bridge.SessionExecutionSettings
import kotlin.concurrent.thread
class CreateSessionActivity : Activity() {
companion object {
private const val TAG = "CodexCreateSession"
const val ACTION_CREATE_SESSION = "com.openai.codex.agent.action.CREATE_SESSION"
const val EXTRA_INITIAL_PROMPT = "com.openai.codex.agent.extra.INITIAL_PROMPT"
private const val EXTRA_EXISTING_SESSION_ID = "existingSessionId"
private const val EXTRA_TARGET_PACKAGE = "targetPackage"
private const val EXTRA_LOCK_TARGET = "lockTarget"
private const val EXTRA_INITIAL_MODEL = "initialModel"
private const val EXTRA_INITIAL_REASONING_EFFORT = "initialReasoningEffort"
private const val DEFAULT_MODEL = "gpt-5.3-codex-spark"
private const val DEFAULT_REASONING_EFFORT = "low"
fun preferredInitialSettings(): SessionExecutionSettings {
return SessionExecutionSettings(
model = DEFAULT_MODEL,
reasoningEffort = DEFAULT_REASONING_EFFORT,
)
}
private fun mergedWithPreferredDefaults(settings: SessionExecutionSettings): SessionExecutionSettings {
val defaults = preferredInitialSettings()
return SessionExecutionSettings(
model = settings.model ?: defaults.model,
reasoningEffort = settings.reasoningEffort ?: defaults.reasoningEffort,
)
}
fun externalCreateSessionIntent(initialPrompt: String): Intent {
return Intent(ACTION_CREATE_SESSION).apply {
addCategory(Intent.CATEGORY_DEFAULT)
putExtra(EXTRA_INITIAL_PROMPT, initialPrompt)
}
}
fun newSessionIntent(
context: Context,
initialSettings: SessionExecutionSettings,
): Intent {
return Intent(context, CreateSessionActivity::class.java).apply {
putExtra(EXTRA_INITIAL_MODEL, initialSettings.model)
putExtra(EXTRA_INITIAL_REASONING_EFFORT, initialSettings.reasoningEffort)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
fun existingHomeSessionIntent(
context: Context,
sessionId: String,
targetPackage: String,
initialSettings: SessionExecutionSettings,
): Intent {
return newSessionIntent(context, initialSettings).apply {
putExtra(EXTRA_EXISTING_SESSION_ID, sessionId)
putExtra(EXTRA_TARGET_PACKAGE, targetPackage)
putExtra(EXTRA_LOCK_TARGET, true)
}
}
}
private val sessionController by lazy { AgentSessionController(this) }
private val sessionUiLeaseToken = Binder()
private var availableModels: List<AgentModelOption> = emptyList()
@Volatile
private var modelsRefreshInFlight = false
private val pendingModelCallbacks = mutableListOf<() -> Unit>()
private var existingSessionId: String? = null
private var leasedSessionId: String? = null
private var uiActive = false
private var selectedPackage: InstalledApp? = null
private var targetLocked = false
private lateinit var promptInput: EditText
private lateinit var packageSummary: TextView
private lateinit var packageButton: Button
private lateinit var clearPackageButton: Button
private lateinit var modelSpinner: Spinner
private lateinit var effortSpinner: Spinner
private lateinit var titleView: TextView
private lateinit var statusView: TextView
private lateinit var startButton: Button
private var selectedReasoningOptions = emptyList<AgentReasoningEffortOption>()
private var pendingEffortOverride: String? = null
private lateinit var effortLabelAdapter: ArrayAdapter<String>
private var initialSettings = preferredInitialSettings()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_create_session)
setFinishOnTouchOutside(true)
bindViews()
loadInitialState()
refreshModelsIfNeeded(force = true)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
loadInitialState()
if (availableModels.isNotEmpty()) {
applyModelOptions()
}
}
override fun onResume() {
super.onResume()
uiActive = true
updateSessionUiLease(existingSessionId)
}
override fun onPause() {
uiActive = false
updateSessionUiLease(null)
super.onPause()
}
private fun bindViews() {
titleView = findViewById(R.id.create_session_title)
statusView = findViewById(R.id.create_session_status)
promptInput = findViewById(R.id.create_session_prompt)
packageSummary = findViewById(R.id.create_session_target_summary)
packageButton = findViewById(R.id.create_session_pick_target_button)
clearPackageButton = findViewById(R.id.create_session_clear_target_button)
modelSpinner = findViewById(R.id.create_session_model_spinner)
effortSpinner = findViewById(R.id.create_session_effort_spinner)
startButton = findViewById(R.id.create_session_start_button)
effortLabelAdapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
mutableListOf<String>(),
).also {
it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
effortSpinner.adapter = it
}
modelSpinner.adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
mutableListOf<String>(),
).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
modelSpinner.onItemSelectedListener = SimpleItemSelectedListener {
updateEffortOptions(pendingEffortOverride)
pendingEffortOverride = null
}
packageButton.setOnClickListener {
showInstalledAppPicker { app ->
selectedPackage = app
updatePackageSummary()
}
}
clearPackageButton.setOnClickListener {
selectedPackage = null
updatePackageSummary()
}
findViewById<Button>(R.id.create_session_cancel_button).setOnClickListener {
cancelAndFinish()
}
startButton.setOnClickListener {
startSession()
}
updatePackageSummary()
}
private fun loadInitialState() {
updateSessionUiLease(null)
existingSessionId = null
selectedPackage = null
targetLocked = false
titleView.text = "New Session"
statusView.visibility = View.GONE
statusView.text = "Loading session…"
startButton.isEnabled = true
unlockTargetSelection()
updatePackageSummary()
existingSessionId = intent.getStringExtra(EXTRA_EXISTING_SESSION_ID)?.trim()?.ifEmpty { null }
initialSettings = mergedWithPreferredDefaults(
SessionExecutionSettings(
model = intent.getStringExtra(EXTRA_INITIAL_MODEL)?.trim()?.ifEmpty { null } ?: DEFAULT_MODEL,
reasoningEffort = intent.getStringExtra(EXTRA_INITIAL_REASONING_EFFORT)?.trim()?.ifEmpty { null }
?: DEFAULT_REASONING_EFFORT,
),
)
promptInput.setText(intent.getStringExtra(EXTRA_INITIAL_PROMPT).orEmpty())
promptInput.setSelection(promptInput.text.length)
val explicitTarget = intent.getStringExtra(EXTRA_TARGET_PACKAGE)?.trim()?.ifEmpty { null }
targetLocked = intent.getBooleanExtra(EXTRA_LOCK_TARGET, false)
if (explicitTarget != null) {
selectedPackage = InstalledAppCatalog.resolveInstalledApp(this, sessionController, explicitTarget)
titleView.text = "New Session"
updatePackageSummary()
if (targetLocked) {
lockTargetSelection()
}
if (uiActive) {
updateSessionUiLease(existingSessionId)
}
return
}
val incomingSessionId = intent.getStringExtra(AgentManager.EXTRA_SESSION_ID)?.trim()?.ifEmpty { null }
if (incomingSessionId != null) {
statusView.visibility = View.VISIBLE
statusView.text = "Loading session…"
startButton.isEnabled = false
thread {
val draftSession = runCatching {
findStandaloneHomeDraftSession(incomingSessionId)
}.getOrElse { err ->
Log.w(TAG, "Failed to inspect incoming session $incomingSessionId", err)
null
}
runOnUiThread {
if (draftSession == null) {
startActivity(
Intent(this, SessionDetailActivity::class.java)
.putExtra(SessionDetailActivity.EXTRA_SESSION_ID, incomingSessionId),
)
finish()
return@runOnUiThread
}
existingSessionId = draftSession.sessionId
selectedPackage = InstalledAppCatalog.resolveInstalledApp(
this,
sessionController,
checkNotNull(draftSession.targetPackage),
)
initialSettings = mergedWithPreferredDefaults(
sessionController.executionSettingsForSession(draftSession.sessionId),
)
targetLocked = true
titleView.text = "New Session"
updatePackageSummary()
lockTargetSelection()
statusView.visibility = View.GONE
startButton.isEnabled = true
if (uiActive) {
updateSessionUiLease(existingSessionId)
}
if (availableModels.isNotEmpty()) {
applyModelOptions()
}
}
}
}
}
private fun cancelAndFinish() {
val sessionId = existingSessionId
if (sessionId == null) {
finish()
return
}
startButton.isEnabled = false
thread {
runCatching {
sessionController.cancelSession(sessionId)
}.onFailure { err ->
runOnUiThread {
startButton.isEnabled = true
showToast("Failed to cancel session: ${err.message}")
}
}.onSuccess {
runOnUiThread {
finish()
}
}
}
}
private fun lockTargetSelection() {
packageButton.visibility = View.GONE
clearPackageButton.visibility = View.GONE
}
private fun unlockTargetSelection() {
packageButton.visibility = View.VISIBLE
clearPackageButton.visibility = View.VISIBLE
}
private fun startSession() {
val prompt = promptInput.text.toString().trim()
if (prompt.isEmpty()) {
promptInput.error = "Enter a prompt"
return
}
val targetPackage = selectedPackage?.packageName
if (existingSessionId != null && targetPackage == null) {
showToast("Missing target app for existing session")
return
}
startButton.isEnabled = false
thread {
runCatching {
AgentSessionLauncher.startSessionAsync(
context = this,
request = LaunchSessionRequest(
prompt = prompt,
targetPackage = targetPackage,
model = selectedModel().model,
reasoningEffort = selectedEffort(),
existingSessionId = existingSessionId,
),
sessionController = sessionController,
requestUserInputHandler = { questions ->
AgentUserInputPrompter.promptForAnswers(this, questions)
},
)
}.onFailure { err ->
runOnUiThread {
startButton.isEnabled = true
showToast("Failed to start session: ${err.message}")
}
}.onSuccess { result ->
runOnUiThread {
showToast("Started session")
setResult(RESULT_OK, Intent().putExtra(SessionDetailActivity.EXTRA_SESSION_ID, result.parentSessionId))
finish()
}
}
}
}
private fun refreshModelsIfNeeded(
force: Boolean,
onComplete: (() -> Unit)? = null,
) {
if (!force && availableModels.isNotEmpty()) {
onComplete?.invoke()
return
}
if (onComplete != null) {
synchronized(pendingModelCallbacks) {
pendingModelCallbacks += onComplete
}
}
if (modelsRefreshInFlight) {
return
}
modelsRefreshInFlight = true
thread {
try {
runCatching { AgentCodexAppServerClient.listModels(this) }
.onFailure { err ->
Log.w(TAG, "Failed to load model catalog", err)
}
.onSuccess { models ->
availableModels = models
}
} finally {
runOnUiThread {
if (availableModels.isNotEmpty()) {
applyModelOptions()
} else {
statusView.visibility = View.VISIBLE
statusView.text = "Failed to load model catalog."
}
}
modelsRefreshInFlight = false
val callbacks = synchronized(pendingModelCallbacks) {
pendingModelCallbacks.toList().also { pendingModelCallbacks.clear() }
}
callbacks.forEach { callback -> callback.invoke() }
}
}
}
private fun applyModelOptions() {
val models = availableModels.ifEmpty(::fallbackModels)
if (availableModels.isEmpty()) {
availableModels = models
}
val labels = models.map { model ->
if (model.description.isBlank()) {
model.displayName
} else {
"${model.displayName} (${model.description})"
}
}
val adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
labels,
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
pendingEffortOverride = initialSettings.reasoningEffort
modelSpinner.adapter = adapter
val modelIndex = models.indexOfFirst { it.model == initialSettings.model }
.takeIf { it >= 0 } ?: models.indexOfFirst(AgentModelOption::isDefault)
.takeIf { it >= 0 } ?: 0
modelSpinner.setSelection(modelIndex, false)
updateEffortOptions(initialSettings.reasoningEffort)
statusView.visibility = View.GONE
}
private fun selectedModel(): AgentModelOption {
return availableModels[modelSpinner.selectedItemPosition.coerceIn(0, availableModels.lastIndex)]
}
private fun selectedEffort(): String? {
return selectedReasoningOptions.getOrNull(effortSpinner.selectedItemPosition)?.reasoningEffort
}
private fun updateEffortOptions(requestedEffort: String?) {
if (availableModels.isEmpty()) {
return
}
selectedReasoningOptions = selectedModel().supportedReasoningEfforts
val labels = selectedReasoningOptions.map { option ->
"${option.reasoningEffort}${option.description}"
}
effortLabelAdapter.clear()
effortLabelAdapter.addAll(labels)
effortLabelAdapter.notifyDataSetChanged()
val desiredEffort = requestedEffort ?: selectedModel().defaultReasoningEffort
val selectedIndex = selectedReasoningOptions.indexOfFirst { it.reasoningEffort == desiredEffort }
.takeIf { it >= 0 } ?: 0
effortSpinner.setSelection(selectedIndex, false)
}
private fun updatePackageSummary() {
val app = selectedPackage
if (app == null) {
packageSummary.text = "No target app selected. This will start an Agent-anchored session."
packageSummary.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
return
}
packageSummary.text = "${app.label} (${app.packageName})"
packageSummary.setCompoundDrawablesRelativeWithIntrinsicBounds(
resizeIcon(app.icon),
null,
null,
null,
)
packageSummary.compoundDrawablePadding =
resources.getDimensionPixelSize(android.R.dimen.app_icon_size) / 4
}
private fun showInstalledAppPicker(onSelected: (InstalledApp) -> Unit) {
val apps = InstalledAppCatalog.listInstalledApps(this, sessionController)
if (apps.isEmpty()) {
android.app.AlertDialog.Builder(this)
.setMessage("No launchable target apps are available.")
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
val adapter = object : ArrayAdapter<InstalledApp>(
this,
R.layout.list_item_installed_app,
apps,
) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return bindAppRow(position, convertView, parent)
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
return bindAppRow(position, convertView, parent)
}
private fun bindAppRow(position: Int, convertView: View?, parent: ViewGroup): View {
val row = convertView ?: LayoutInflater.from(context)
.inflate(R.layout.list_item_installed_app, parent, false)
val app = getItem(position) ?: return row
val iconView = row.findViewById<ImageView>(R.id.installed_app_icon)
val titleView = row.findViewById<TextView>(R.id.installed_app_title)
val subtitleView = row.findViewById<TextView>(R.id.installed_app_subtitle)
iconView.setImageDrawable(app.icon ?: getDrawable(android.R.drawable.sym_def_app_icon))
titleView.text = app.label
subtitleView.text = if (app.eligibleTarget) {
app.packageName
} else {
"${app.packageName} — unavailable"
}
row.isEnabled = app.eligibleTarget
titleView.isEnabled = app.eligibleTarget
subtitleView.isEnabled = app.eligibleTarget
iconView.alpha = if (app.eligibleTarget) 1f else 0.5f
row.alpha = if (app.eligibleTarget) 1f else 0.6f
return row
}
}
val dialog = android.app.AlertDialog.Builder(this)
.setTitle("Choose app")
.setAdapter(adapter) { _, which ->
val app = apps[which]
if (!app.eligibleTarget) {
android.app.AlertDialog.Builder(this)
.setMessage(
"The current framework rejected ${app.packageName} as a target for Genie sessions on this device.",
)
.setPositiveButton(android.R.string.ok, null)
.show()
return@setAdapter
}
onSelected(app)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
dialog.setOnShowListener {
dialog.listView?.isVerticalScrollBarEnabled = true
dialog.listView?.isScrollbarFadingEnabled = false
dialog.listView?.isFastScrollEnabled = true
dialog.listView?.scrollBarStyle = View.SCROLLBARS_INSIDE_INSET
}
dialog.show()
}
private fun findStandaloneHomeDraftSession(sessionId: String): AgentSessionDetails? {
val snapshot = sessionController.loadSnapshot(sessionId)
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId } ?: return null
val hasChildren = snapshot.sessions.any { it.parentSessionId == sessionId }
return session.takeIf {
it.anchor == AgentSessionInfo.ANCHOR_HOME &&
it.state == AgentSessionInfo.STATE_CREATED &&
!hasChildren &&
!it.targetPackage.isNullOrBlank()
}
}
private fun updateSessionUiLease(sessionId: String?) {
if (leasedSessionId == sessionId) {
return
}
leasedSessionId?.let { previous ->
runCatching {
sessionController.unregisterSessionUiLease(previous, sessionUiLeaseToken)
}
leasedSessionId = null
}
sessionId?.let { current ->
val registered = runCatching {
sessionController.registerSessionUiLease(current, sessionUiLeaseToken)
}
if (registered.isSuccess) {
leasedSessionId = current
}
}
}
private fun resizeIcon(icon: Drawable?): Drawable? {
val sizedIcon = icon?.constantState?.newDrawable()?.mutate() ?: return null
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
sizedIcon.setBounds(0, 0, iconSize, iconSize)
return sizedIcon
}
private fun fallbackModels(): List<AgentModelOption> {
return listOf(
AgentModelOption(
id = initialSettings.model ?: DEFAULT_MODEL,
model = initialSettings.model ?: DEFAULT_MODEL,
displayName = initialSettings.model ?: DEFAULT_MODEL,
description = "Current Agent runtime default",
supportedReasoningEfforts = listOf(
AgentReasoningEffortOption("minimal", "Fastest"),
AgentReasoningEffortOption("low", "Low"),
AgentReasoningEffortOption("medium", "Balanced"),
AgentReasoningEffortOption("high", "Deep"),
AgentReasoningEffortOption("xhigh", "Max"),
),
defaultReasoningEffort = initialSettings.reasoningEffort ?: DEFAULT_REASONING_EFFORT,
isDefault = true,
),
)
}
private fun showToast(message: String) {
runOnUiThread {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -0,0 +1,33 @@
package com.openai.codex.agent
import android.content.Context
class DismissedSessionStore(context: Context) {
companion object {
private const val PREFS_NAME = "dismissed_sessions"
}
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun dismiss(sessionId: String) {
prefs.edit().putBoolean(sessionId, true).apply()
}
fun isDismissed(sessionId: String): Boolean {
return prefs.getBoolean(sessionId, false)
}
fun clearDismissed(sessionId: String) {
prefs.edit().remove(sessionId).apply()
}
fun prune(activeSessionIds: Set<String>) {
val keysToRemove = prefs.all.keys.filter { it !in activeSessionIds }
if (keysToRemove.isEmpty()) {
return
}
prefs.edit().apply {
keysToRemove.forEach(::remove)
}.apply()
}
}

View File

@@ -0,0 +1,68 @@
package com.openai.codex.agent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
data class InstalledApp(
val packageName: String,
val label: String,
val icon: Drawable?,
val eligibleTarget: Boolean,
)
object InstalledAppCatalog {
private val excludedPackages = setOf(
"com.openai.codex.agent",
"com.openai.codex.genie",
)
fun listInstalledApps(
context: Context,
sessionController: AgentSessionController,
): List<InstalledApp> {
val pm = context.packageManager
val launcherIntent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
val appsByPackage = linkedMapOf<String, InstalledApp>()
pm.queryIntentActivities(launcherIntent, 0).forEach { resolveInfo ->
val applicationInfo = resolveInfo.activityInfo?.applicationInfo ?: return@forEach
val packageName = applicationInfo.packageName.takeIf(String::isNotBlank) ?: return@forEach
if (packageName in excludedPackages) {
return@forEach
}
if (packageName in appsByPackage) {
return@forEach
}
val label = resolveInfo.loadLabel(pm)?.toString().orEmpty().ifBlank { packageName }
appsByPackage[packageName] = InstalledApp(
packageName = packageName,
label = label,
icon = resolveInfo.loadIcon(pm),
eligibleTarget = sessionController.canStartSessionForTarget(packageName),
)
}
return appsByPackage.values.sortedWith(
compareBy<InstalledApp>({ it.label.lowercase() }).thenBy { it.packageName },
)
}
fun resolveInstalledApp(
context: Context,
sessionController: AgentSessionController,
packageName: String,
): InstalledApp {
listInstalledApps(context, sessionController)
.firstOrNull { it.packageName == packageName }
?.let { return it }
val pm = context.packageManager
val applicationInfo = pm.getApplicationInfo(packageName, 0)
return InstalledApp(
packageName = packageName,
label = pm.getApplicationLabel(applicationInfo)?.toString().orEmpty().ifBlank { packageName },
icon = pm.getApplicationIcon(applicationInfo),
eligibleTarget = sessionController.canStartSessionForTarget(packageName),
)
}
}

View File

@@ -0,0 +1,473 @@
package com.openai.codex.agent
import android.Manifest
import android.app.Activity
import android.app.agent.AgentManager
import android.app.agent.AgentSessionInfo
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Base64
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import com.openai.codex.bridge.SessionExecutionSettings
import kotlin.concurrent.thread
class MainActivity : Activity() {
companion object {
private const val TAG = "CodexMainActivity"
private const val ACTION_DEBUG_START_AGENT_SESSION =
"com.openai.codex.agent.action.DEBUG_START_AGENT_SESSION"
private const val ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS =
"com.openai.codex.agent.action.DEBUG_CANCEL_ALL_AGENT_SESSIONS"
private const val EXTRA_DEBUG_PROMPT = "prompt"
private const val EXTRA_DEBUG_PROMPT_BASE64 = "promptBase64"
private const val EXTRA_DEBUG_TARGET_PACKAGE = "targetPackage"
private const val EXTRA_DEBUG_FINAL_PRESENTATION_POLICY = "finalPresentationPolicy"
}
@Volatile
private var isAuthenticated = false
@Volatile
private var agentRefreshInFlight = false
@Volatile
private var latestAgentRuntimeStatus: AgentCodexAppServerClient.RuntimeStatus? = null
@Volatile
private var pendingAuthMessage: String? = null
private val agentSessionController by lazy { AgentSessionController(this) }
private val dismissedSessionStore by lazy { DismissedSessionStore(this) }
private val sessionListAdapter by lazy { TopLevelSessionListAdapter(this) }
private var latestSnapshot: AgentSnapshot = AgentSnapshot.unavailable
private val runtimeStatusListener = AgentCodexAppServerClient.RuntimeStatusListener { status ->
latestAgentRuntimeStatus = status
if (status != null) {
pendingAuthMessage = null
}
runOnUiThread {
updateAuthUi(renderAuthStatus(), status?.authenticated == true)
updateRuntimeStatusUi()
}
}
private val sessionListener = object : AgentManager.SessionListener {
override fun onSessionChanged(session: AgentSessionInfo) {
refreshAgentSessions()
}
override fun onSessionRemoved(sessionId: String, userId: Int) {
refreshAgentSessions()
}
}
private var sessionListenerRegistered = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupViews()
requestNotificationPermissionIfNeeded()
handleIncomingIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Log.i(TAG, "onNewIntent action=${intent.action}")
setIntent(intent)
handleIncomingIntent(intent)
}
override fun onResume() {
super.onResume()
registerSessionListenerIfNeeded()
AgentCodexAppServerClient.registerRuntimeStatusListener(runtimeStatusListener)
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this, refreshToken = true)
refreshAgentSessions(force = true)
}
override fun onPause() {
AgentCodexAppServerClient.unregisterRuntimeStatusListener(runtimeStatusListener)
unregisterSessionListenerIfNeeded()
super.onPause()
}
private fun setupViews() {
findViewById<ListView>(R.id.session_list).adapter = sessionListAdapter
findViewById<ListView>(R.id.session_list).setOnItemClickListener { _, _, position, _ ->
sessionListAdapter.getItem(position)?.let { session ->
openSessionDetail(session.sessionId)
}
}
findViewById<Button>(R.id.create_session_button).setOnClickListener {
launchCreateSessionActivity()
}
findViewById<Button>(R.id.auth_action).setOnClickListener {
authAction()
}
findViewById<Button>(R.id.refresh_sessions_button).setOnClickListener {
refreshAgentSessions(force = true)
}
updateAuthUi("Agent auth: probing...", false)
updateRuntimeStatusUi()
updateSessionList(emptyList())
}
private fun handleIncomingIntent(intent: Intent?) {
val sessionId = intent?.getStringExtra(AgentManager.EXTRA_SESSION_ID)
if (!sessionId.isNullOrBlank()) {
openSessionDetail(sessionId)
return
}
if (shouldRouteLauncherIntentToActiveSession(intent)) {
routeLauncherIntentToActiveSession()
return
}
maybeHandleDebugIntent(intent)
}
private fun shouldRouteLauncherIntentToActiveSession(intent: Intent?): Boolean {
if (intent == null) {
return false
}
if (
intent.action == ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS ||
intent.action == ACTION_DEBUG_START_AGENT_SESSION
) {
return false
}
return intent.action == Intent.ACTION_MAIN &&
intent.hasCategory(Intent.CATEGORY_LAUNCHER) &&
intent.getStringExtra(AgentManager.EXTRA_SESSION_ID).isNullOrBlank()
}
private fun routeLauncherIntentToActiveSession() {
thread {
val snapshot = runCatching { agentSessionController.loadSnapshot(null) }.getOrNull() ?: return@thread
val activeTopLevelSessions = SessionUiFormatter.topLevelSessions(snapshot)
.filterNot { isTerminalState(it.state) }
if (activeTopLevelSessions.size != 1) {
return@thread
}
val activeSessionId = activeTopLevelSessions.single().sessionId
runOnUiThread {
openSessionDetail(activeSessionId)
}
}
}
private fun maybeHandleDebugIntent(intent: Intent?) {
when (intent?.action) {
ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS -> {
thread {
runCatching { agentSessionController.cancelActiveSessions() }
.onFailure { err ->
Log.w(TAG, "Failed to cancel Agent sessions from debug intent", err)
showToast("Failed to cancel active sessions: ${err.message}")
}
.onSuccess { result ->
showToast(
"Cancelled ${result.cancelledSessionIds.size} sessions, ${result.failedSessionIds.size} failed",
)
refreshAgentSessions(force = true)
}
}
intent.action = null
}
ACTION_DEBUG_START_AGENT_SESSION -> {
val prompt = extractDebugPrompt(intent)
if (prompt.isEmpty()) {
intent.action = null
return
}
val targetPackage = intent.getStringExtra(EXTRA_DEBUG_TARGET_PACKAGE)?.trim()?.ifEmpty { null }
val finalPresentationPolicy = SessionFinalPresentationPolicy.fromWireValue(
intent.getStringExtra(EXTRA_DEBUG_FINAL_PRESENTATION_POLICY),
)
startDebugSession(
prompt = prompt,
targetPackage = targetPackage,
finalPresentationPolicy = finalPresentationPolicy,
)
intent.action = null
}
}
}
private fun extractDebugPrompt(intent: Intent): String {
intent.getStringExtra(EXTRA_DEBUG_PROMPT_BASE64)
?.trim()
?.takeIf(String::isNotEmpty)
?.let { encoded ->
runCatching {
String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8).trim()
}.onFailure { err ->
Log.w(TAG, "Failed to decode debug promptBase64", err)
}.getOrNull()
?.takeIf(String::isNotEmpty)
?.let { return it }
}
return intent.getStringExtra(EXTRA_DEBUG_PROMPT)?.trim().orEmpty()
}
private fun startDebugSession(
prompt: String,
targetPackage: String?,
finalPresentationPolicy: SessionFinalPresentationPolicy?,
) {
thread {
val result = runCatching {
if (targetPackage != null) {
agentSessionController.startHomeSession(
targetPackage = targetPackage,
prompt = prompt,
allowDetachedMode = true,
finalPresentationPolicy = finalPresentationPolicy
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
executionSettings = SessionExecutionSettings.default,
)
} else {
AgentTaskPlanner.startSession(
context = this,
userObjective = prompt,
targetPackageOverride = null,
allowDetachedMode = true,
finalPresentationPolicyOverride = finalPresentationPolicy,
executionSettings = SessionExecutionSettings.default,
sessionController = agentSessionController,
requestUserInputHandler = { questions ->
AgentUserInputPrompter.promptForAnswers(this, questions)
},
)
}
}
result.onFailure { err ->
Log.w(TAG, "Failed to start debug Agent session", err)
showToast("Failed to start Agent session: ${err.message}")
}
result.onSuccess { started ->
showToast("Started session ${started.parentSessionId}")
refreshAgentSessions(force = true)
}
}
}
private fun refreshAgentSessions(force: Boolean = false) {
if (!force && agentRefreshInFlight) {
return
}
agentRefreshInFlight = true
thread {
try {
val result = runCatching { agentSessionController.loadSnapshot(null) }
result.onFailure { err ->
latestSnapshot = AgentSnapshot.unavailable
runOnUiThread {
findViewById<TextView>(R.id.agent_status).text =
"Agent framework unavailable (${err.message})"
updateSessionList(emptyList())
}
}
result.onSuccess { snapshot ->
latestSnapshot = snapshot
dismissedSessionStore.prune(snapshot.sessions.map(AgentSessionDetails::sessionId).toSet())
val topLevelSessions = SessionUiFormatter.topLevelSessions(snapshot)
.filter { session ->
if (!isTerminalState(session.state)) {
dismissedSessionStore.clearDismissed(session.sessionId)
true
} else {
!dismissedSessionStore.isDismissed(session.sessionId)
}
}
runOnUiThread {
updateFrameworkStatus(snapshot)
updateSessionList(topLevelSessions)
}
}
} finally {
agentRefreshInFlight = false
}
}
}
private fun updateFrameworkStatus(snapshot: AgentSnapshot) {
val roleHolders = if (snapshot.roleHolders.isEmpty()) {
"none"
} else {
snapshot.roleHolders.joinToString(", ")
}
findViewById<TextView>(R.id.agent_status).text =
"Agent framework active. Genie role holders: $roleHolders"
}
private fun updateSessionList(sessions: List<AgentSessionDetails>) {
sessionListAdapter.replaceItems(sessions)
findViewById<TextView>(R.id.session_list_empty).visibility =
if (sessions.isEmpty()) View.VISIBLE else View.GONE
}
private fun registerSessionListenerIfNeeded() {
if (sessionListenerRegistered || !agentSessionController.isAvailable()) {
return
}
sessionListenerRegistered = runCatching {
agentSessionController.registerSessionListener(mainExecutor, sessionListener)
}.getOrDefault(false)
}
private fun unregisterSessionListenerIfNeeded() {
if (!sessionListenerRegistered) {
return
}
runCatching { agentSessionController.unregisterSessionListener(sessionListener) }
sessionListenerRegistered = false
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < 33) {
return
}
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) {
return
}
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
}
private fun authAction() {
if (isAuthenticated) {
signOutAgent()
} else {
startAgentSignIn()
}
}
private fun startAgentSignIn() {
pendingAuthMessage = "Agent auth: opening browser for sign-in..."
updateAuthUi(pendingAuthMessage.orEmpty(), false)
thread {
runCatching { AgentCodexAppServerClient.startChatGptLogin(this) }
.onFailure { err ->
pendingAuthMessage = null
updateAuthUi("Agent auth: sign-in failed (${err.message})", false)
}
.onSuccess { loginSession ->
pendingAuthMessage = "Agent auth: complete sign-in in the browser"
updateAuthUi(pendingAuthMessage.orEmpty(), false)
runOnUiThread {
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(loginSession.authUrl)))
}.onFailure { err ->
pendingAuthMessage = "Agent auth: open ${loginSession.authUrl}"
updateAuthUi(pendingAuthMessage.orEmpty(), false)
showToast("Failed to open browser: ${err.message}")
}.onSuccess {
showToast("Complete sign-in in the browser")
}
}
}
}
}
private fun signOutAgent() {
pendingAuthMessage = "Agent auth: signing out..."
updateAuthUi(pendingAuthMessage.orEmpty(), false)
thread {
runCatching { AgentCodexAppServerClient.logoutAccount(this) }
.onFailure { err ->
pendingAuthMessage = null
updateAuthUi("Agent auth: sign out failed (${err.message})", isAuthenticated)
}
.onSuccess {
pendingAuthMessage = null
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this)
showToast("Signed out")
}
}
}
private fun updateRuntimeStatusUi() {
findViewById<TextView>(R.id.agent_runtime_status).text = renderAgentRuntimeStatus()
}
private fun renderAgentRuntimeStatus(): String {
val runtimeStatus = latestAgentRuntimeStatus
if (runtimeStatus == null) {
return "Agent runtime: probing..."
}
val authSummary = if (runtimeStatus.authenticated) {
runtimeStatus.accountEmail?.let { "signed in ($it)" } ?: "signed in"
} else {
"not signed in"
}
val configuredModelSuffix = runtimeStatus.configuredModel
?.takeIf { it != runtimeStatus.effectiveModel }
?.let { ", configured=$it" }
?: ""
val effectiveModel = runtimeStatus.effectiveModel ?: "unknown"
return "Agent runtime: $authSummary, provider=${runtimeStatus.modelProviderId}, effective=$effectiveModel$configuredModelSuffix, clients=${runtimeStatus.clientCount}, base=${runtimeStatus.upstreamBaseUrl}"
}
private fun renderAuthStatus(): String {
pendingAuthMessage?.let { return it }
val runtimeStatus = latestAgentRuntimeStatus
if (runtimeStatus == null) {
return "Agent auth: probing..."
}
if (!runtimeStatus.authenticated) {
return "Agent auth: not signed in"
}
return runtimeStatus.accountEmail?.let { email ->
"Agent auth: signed in ($email)"
} ?: "Agent auth: signed in"
}
private fun updateAuthUi(
message: String,
authenticated: Boolean,
) {
isAuthenticated = authenticated
runOnUiThread {
findViewById<TextView>(R.id.auth_status).text = message
findViewById<Button>(R.id.auth_action).text =
if (authenticated) "Sign out" else "Start sign-in"
}
}
private fun isTerminalState(state: Int): Boolean {
return state == AgentSessionInfo.STATE_COMPLETED ||
state == AgentSessionInfo.STATE_CANCELLED ||
state == AgentSessionInfo.STATE_FAILED
}
private fun openSessionDetail(sessionId: String) {
startActivity(
Intent(this, SessionDetailActivity::class.java)
.putExtra(SessionDetailActivity.EXTRA_SESSION_ID, sessionId),
)
}
private fun launchCreateSessionActivity() {
startActivity(
CreateSessionActivity.newSessionIntent(
context = this,
initialSettings = CreateSessionActivity.preferredInitialSettings(),
),
)
moveTaskToBack(true)
}
private fun showToast(message: String) {
runOnUiThread {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -0,0 +1,52 @@
package com.openai.codex.agent
object SessionContinuationPromptBuilder {
private const val MAX_TIMELINE_CHARS = 1200
private const val MAX_DETAIL_CHARS = 600
fun build(
sourceTopLevelSession: AgentSessionDetails,
selectedSession: AgentSessionDetails,
prompt: String,
): String {
return buildString {
appendLine(prompt.trim())
appendLine()
appendLine("This is a follow-up continuation of an earlier attempt in the same top-level Agent session.")
appendLine("Reuse facts learned previously instead of starting over from scratch.")
appendLine()
appendLine("Previous session context:")
appendLine("- Top-level session: ${sourceTopLevelSession.sessionId}")
appendLine("- Previous child session: ${selectedSession.sessionId}")
selectedSession.targetPackage?.let { appendLine("- Target package: $it") }
appendLine("- Previous state: ${selectedSession.stateLabel}")
appendLine("- Previous presentation: ${selectedSession.targetPresentationLabel}")
appendLine("- Previous runtime: ${selectedSession.targetRuntimeLabel}")
selectedSession.latestResult
?.takeIf(String::isNotBlank)
?.let { appendLine("- Previous result: ${it.take(MAX_DETAIL_CHARS)}") }
selectedSession.latestError
?.takeIf(String::isNotBlank)
?.let { appendLine("- Previous error: ${it.take(MAX_DETAIL_CHARS)}") }
selectedSession.latestTrace
?.takeIf(String::isNotBlank)
?.let { appendLine("- Previous trace: ${it.take(MAX_DETAIL_CHARS)}") }
val timeline = selectedSession.timeline.trim()
if (timeline.isNotEmpty() && timeline != "Diagnostics not loaded.") {
appendLine()
appendLine("Recent timeline from the previous child session:")
appendLine(timeline.take(MAX_TIMELINE_CHARS))
}
val parentSummary = sourceTopLevelSession.latestResult
?: sourceTopLevelSession.latestError
?: sourceTopLevelSession.latestTrace
parentSummary
?.takeIf(String::isNotBlank)
?.let {
appendLine()
appendLine("Top-level session summary:")
appendLine(it.take(MAX_DETAIL_CHARS))
}
}.trim()
}
}

View File

@@ -0,0 +1,777 @@
package com.openai.codex.agent
import android.app.Activity
import android.app.agent.AgentManager
import android.app.agent.AgentSessionInfo
import android.content.Intent
import android.graphics.Typeface
import android.os.Binder
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import kotlin.concurrent.thread
class SessionDetailActivity : Activity() {
companion object {
private const val TAG = "CodexSessionDetail"
const val EXTRA_SESSION_ID = "sessionId"
private const val ACTION_DEBUG_CONTINUE_SESSION =
"com.openai.codex.agent.action.DEBUG_CONTINUE_SESSION"
private const val EXTRA_DEBUG_PROMPT = "prompt"
}
private data class SessionViewState(
val topLevelSession: AgentSessionDetails,
val childSessions: List<AgentSessionDetails>,
val selectedChildSession: AgentSessionDetails?,
)
private val sessionController by lazy { AgentSessionController(this) }
private val dismissedSessionStore by lazy { DismissedSessionStore(this) }
private val sessionUiLeaseToken = Binder()
private var leasedSessionId: String? = null
private var requestedSessionId: String? = null
private var topLevelSessionId: String? = null
private var selectedChildSessionId: String? = null
private var latestSnapshot: AgentSnapshot = AgentSnapshot.unavailable
private var refreshInFlight = false
private val sessionListener = object : AgentManager.SessionListener {
override fun onSessionChanged(session: AgentSessionInfo) {
refreshSnapshot()
}
override fun onSessionRemoved(sessionId: String, userId: Int) {
refreshSnapshot()
}
}
private var sessionListenerRegistered = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_session_detail)
requestedSessionId = intent.getStringExtra(EXTRA_SESSION_ID)
setupViews()
maybeHandleDebugIntent(intent)
}
override fun onResume() {
super.onResume()
registerSessionListenerIfNeeded()
refreshSnapshot(force = true)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
requestedSessionId = intent.getStringExtra(EXTRA_SESSION_ID)
topLevelSessionId = null
selectedChildSessionId = null
maybeHandleDebugIntent(intent)
refreshSnapshot(force = true)
}
override fun onPause() {
unregisterSessionListenerIfNeeded()
updateSessionUiLease(null)
super.onPause()
}
private fun setupViews() {
findViewById<Button>(R.id.session_detail_cancel_button).setOnClickListener {
cancelSession()
}
findViewById<Button>(R.id.session_detail_delete_button).setOnClickListener {
deleteSession()
}
findViewById<Button>(R.id.session_detail_child_cancel_button).setOnClickListener {
cancelSelectedChildSession()
}
findViewById<Button>(R.id.session_detail_child_delete_button).setOnClickListener {
deleteSelectedChildSession()
}
findViewById<Button>(R.id.session_detail_attach_button).setOnClickListener {
attachTarget()
}
findViewById<Button>(R.id.session_detail_answer_button).setOnClickListener {
answerQuestion()
}
findViewById<Button>(R.id.session_detail_follow_up_button).setOnClickListener {
sendFollowUpPrompt()
}
}
private fun maybeHandleDebugIntent(intent: Intent?) {
if (intent?.action != ACTION_DEBUG_CONTINUE_SESSION) {
return
}
val prompt = intent.getStringExtra(EXTRA_DEBUG_PROMPT)?.trim().orEmpty()
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.trim().orEmpty()
if (prompt.isEmpty()) {
intent.action = null
return
}
Log.i(TAG, "Handling debug continuation for sessionId=$sessionId")
thread {
runCatching {
val snapshot = sessionController.loadSnapshot(sessionId.ifEmpty { requestedSessionId })
val viewState = resolveViewState(snapshot) ?: error("Session not found")
Log.i(TAG, "Loaded snapshot for continuation topLevel=${viewState.topLevelSession.sessionId} child=${viewState.selectedChildSession?.sessionId}")
continueSessionInPlaceOnce(prompt, snapshot, viewState)
}.onFailure { err ->
Log.w(TAG, "Debug continuation failed", err)
showToast("Failed to continue session: ${err.message}")
}.onSuccess { result ->
Log.i(TAG, "Debug continuation reused topLevel=${result.parentSessionId}")
showToast("Continued session in place")
runOnUiThread {
moveTaskToBack(true)
}
}
}
intent.action = null
}
private fun registerSessionListenerIfNeeded() {
if (sessionListenerRegistered || !sessionController.isAvailable()) {
return
}
sessionListenerRegistered = runCatching {
sessionController.registerSessionListener(mainExecutor, sessionListener)
}.getOrDefault(false)
}
private fun unregisterSessionListenerIfNeeded() {
if (!sessionListenerRegistered) {
return
}
runCatching { sessionController.unregisterSessionListener(sessionListener) }
sessionListenerRegistered = false
}
private fun refreshSnapshot(force: Boolean = false) {
if (!force && refreshInFlight) {
return
}
refreshInFlight = true
thread {
try {
val snapshot = runCatching {
sessionController.loadSnapshot(requestedSessionId ?: selectedChildSessionId ?: topLevelSessionId)
}
.getOrElse {
runOnUiThread {
findViewById<TextView>(R.id.session_detail_summary).text =
"Failed to load session: ${it.message}"
}
return@thread
}
latestSnapshot = snapshot
runOnUiThread {
updateUi(snapshot)
}
} finally {
refreshInFlight = false
}
}
}
private fun updateUi(snapshot: AgentSnapshot) {
val viewState = resolveViewState(snapshot)
if (viewState == null) {
findViewById<TextView>(R.id.session_detail_summary).text = "Session not found"
findViewById<TextView>(R.id.session_detail_child_summary).text = "Session not found"
updateSessionUiLease(null)
return
}
val topLevelSession = viewState.topLevelSession
val selectedChildSession = viewState.selectedChildSession
val actionableSession = selectedChildSession ?: topLevelSession
val canStartStandaloneHomeSession = canStartStandaloneHomeSession(viewState)
val executionSettings = sessionController.executionSettingsForSession(topLevelSession.sessionId)
val summary = buildString {
append(
SessionUiFormatter.detailSummary(
context = this@SessionDetailActivity,
session = topLevelSession,
parentSession = null,
),
)
if (!executionSettings.model.isNullOrBlank()) {
append("\nModel: ${executionSettings.model}")
}
if (!executionSettings.reasoningEffort.isNullOrBlank()) {
append("\nThinking depth: ${executionSettings.reasoningEffort}")
}
}
findViewById<TextView>(R.id.session_detail_summary).text = formatDetailSummary(summary.trimEnd())
renderChildSessions(viewState.childSessions, selectedChildSession?.sessionId)
val childSummaryText = selectedChildSession?.let { child ->
SessionUiFormatter.detailSummary(
context = this,
session = child,
parentSession = topLevelSession,
)
} ?: if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT && viewState.childSessions.isEmpty()) {
"No child sessions yet. The Agent is still planning targets or waiting to start them."
} else {
"Select a child session to inspect it. Tap the same child again to collapse it."
}
findViewById<TextView>(R.id.session_detail_child_summary).text = formatDetailSummary(childSummaryText)
findViewById<ScrollView>(R.id.session_detail_child_summary_container).scrollTo(0, 0)
findViewById<TextView>(R.id.session_detail_timeline).text = formatTimeline(
topLevelSession,
selectedChildSession,
)
findViewById<ScrollView>(R.id.session_detail_timeline_container).scrollTo(0, 0)
val isWaitingForUser = actionableSession.state == AgentSessionInfo.STATE_WAITING_FOR_USER &&
!actionableSession.latestQuestion.isNullOrBlank()
findViewById<TextView>(R.id.session_detail_question_label).visibility =
if (isWaitingForUser) View.VISIBLE else View.GONE
findViewById<TextView>(R.id.session_detail_question).visibility =
if (isWaitingForUser) View.VISIBLE else View.GONE
findViewById<EditText>(R.id.session_detail_answer_input).visibility =
if (isWaitingForUser) View.VISIBLE else View.GONE
findViewById<Button>(R.id.session_detail_answer_button).visibility =
if (isWaitingForUser) View.VISIBLE else View.GONE
findViewById<TextView>(R.id.session_detail_question).text =
actionableSession.latestQuestion.orEmpty()
val isTopLevelActive = !isTerminalState(topLevelSession.state)
val topLevelActionNote = findViewById<TextView>(R.id.session_detail_top_level_action_note)
findViewById<Button>(R.id.session_detail_cancel_button).apply {
visibility = if (isTopLevelActive) View.VISIBLE else View.GONE
text = if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT && viewState.childSessions.isNotEmpty()) {
"Cancel Child Sessions"
} else {
"Cancel Session"
}
}
findViewById<Button>(R.id.session_detail_delete_button).visibility =
if (isTopLevelActive) View.GONE else View.VISIBLE
findViewById<Button>(R.id.session_detail_delete_button).text = "Delete Session"
topLevelActionNote.visibility = View.VISIBLE
topLevelActionNote.text = if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT) {
if (isTopLevelActive && viewState.childSessions.isEmpty()) {
"This Agent-anchored session is still planning targets."
} else if (isTopLevelActive) {
"Cancelling the top-level session cancels all active child sessions."
} else {
"Deleting the top-level session removes it and its child sessions from the Agent UI."
}
} else {
if (canStartStandaloneHomeSession) {
"This app-scoped session is ready to start. Use the Start dialog below."
} else if (isTopLevelActive) {
"This app-scoped session is still active."
} else {
"Deleting this app-scoped session dismisses it from framework and removes it from the Agent UI."
}
}
val childIsSelected = selectedChildSession != null
val isSelectedChildActive = selectedChildSession?.let { !isTerminalState(it.state) } == true
findViewById<LinearLayout>(R.id.session_detail_child_actions).visibility =
if (childIsSelected) View.VISIBLE else View.GONE
findViewById<Button>(R.id.session_detail_child_cancel_button).visibility =
if (isSelectedChildActive) View.VISIBLE else View.GONE
findViewById<Button>(R.id.session_detail_child_delete_button).visibility =
if (childIsSelected && !isSelectedChildActive) View.VISIBLE else View.GONE
val canAttach = childIsSelected &&
actionableSession.targetPresentation != AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
findViewById<Button>(R.id.session_detail_attach_button).visibility =
if (canAttach) View.VISIBLE else View.GONE
val supportsInPlaceContinuation = topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT
val continueVisibility = if (canStartStandaloneHomeSession || (!isTopLevelActive && supportsInPlaceContinuation)) {
View.VISIBLE
} else {
View.GONE
}
findViewById<TextView>(R.id.session_detail_follow_up_label).apply {
visibility = continueVisibility
text = if (canStartStandaloneHomeSession) {
"Start Session"
} else {
"Continue Same Session"
}
}
findViewById<EditText>(R.id.session_detail_follow_up_prompt).visibility =
if (canStartStandaloneHomeSession) View.GONE else continueVisibility
findViewById<Button>(R.id.session_detail_follow_up_button).apply {
visibility = continueVisibility
text = if (canStartStandaloneHomeSession) {
"Start Session"
} else {
"Send Continuation Prompt"
}
}
findViewById<TextView>(R.id.session_detail_follow_up_note).visibility =
if (!isTopLevelActive && !supportsInPlaceContinuation) View.VISIBLE else View.GONE
updateSessionUiLease(topLevelSession.sessionId)
}
private fun renderChildSessions(
sessions: List<AgentSessionDetails>,
selectedSessionId: String?,
) {
val container = findViewById<LinearLayout>(R.id.session_detail_children_container)
val emptyView = findViewById<TextView>(R.id.session_detail_children_empty)
container.removeAllViews()
emptyView.visibility = if (sessions.isEmpty()) View.VISIBLE else View.GONE
sessions.forEach { session ->
val isSelected = session.sessionId == selectedSessionId
val row = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(12), dp(12), dp(12), dp(12))
isClickable = true
isFocusable = true
background = getDrawable(
if (isSelected) {
R.drawable.session_child_card_selected_background
} else {
R.drawable.session_child_card_background
},
)
val layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply {
bottomMargin = dp(8)
}
this.layoutParams = layoutParams
setOnClickListener {
selectedChildSessionId = if (session.sessionId == selectedChildSessionId) {
null
} else {
session.sessionId
}
requestedSessionId = topLevelSessionId
updateUi(latestSnapshot)
}
}
val title = TextView(this).apply {
text = SessionUiFormatter.relatedSessionTitle(this@SessionDetailActivity, session)
setTypeface(typeface, if (isSelected) Typeface.BOLD else Typeface.NORMAL)
}
val subtitle = TextView(this).apply {
text = SessionUiFormatter.relatedSessionSubtitle(session)
}
row.addView(title)
row.addView(subtitle)
container.addView(row)
}
}
private fun renderTimeline(
topLevelSession: AgentSessionDetails,
selectedChildSession: AgentSessionDetails?,
): String {
return if (selectedChildSession == null) {
topLevelSession.timeline
} else {
buildString {
append("Top-level ${topLevelSession.sessionId}\n")
append(topLevelSession.timeline)
append("\n\nSelected child ${selectedChildSession.sessionId}\n")
append(selectedChildSession.timeline)
}
}
}
private fun formatDetailSummary(summary: String): CharSequence {
val trimmed = summary.trim()
if (trimmed.isEmpty()) {
return ""
}
val builder = SpannableStringBuilder()
trimmed.lines().forEachIndexed { index, line ->
if (index > 0) {
builder.append("\n\n")
}
val separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) {
builder.append(line)
return@forEachIndexed
}
val label = line.substring(0, separatorIndex)
val value = line.substring(separatorIndex + 1).trim()
appendBoldLine(builder, label)
if (value.isNotEmpty()) {
builder.append('\n')
builder.append(value)
}
}
return builder
}
private fun formatTimeline(
topLevelSession: AgentSessionDetails,
selectedChildSession: AgentSessionDetails?,
): CharSequence {
val builder = SpannableStringBuilder()
appendBoldLine(builder, "Top-level session ${topLevelSession.sessionId}")
builder.append('\n')
builder.append(topLevelSession.timeline.ifBlank { "No framework events yet." })
selectedChildSession?.let { child ->
builder.append("\n\n")
appendBoldLine(builder, "Selected child ${child.sessionId}")
builder.append('\n')
builder.append(child.timeline.ifBlank { "No framework events yet." })
}
return builder
}
private fun appendBoldLine(
builder: SpannableStringBuilder,
text: String,
) {
val start = builder.length
builder.append(text)
builder.setSpan(
StyleSpan(Typeface.BOLD),
start,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
private fun answerQuestion() {
val selectedSession = currentActionableSession(latestSnapshot) ?: return
val answerInput = findViewById<EditText>(R.id.session_detail_answer_input)
val answer = answerInput.text.toString().trim()
if (answer.isEmpty()) {
answerInput.error = "Enter an answer"
return
}
thread {
runCatching {
sessionController.answerQuestion(
selectedSession.sessionId,
answer,
topLevelSession(latestSnapshot)?.sessionId,
)
}.onFailure { err ->
showToast("Failed to answer question: ${err.message}")
}.onSuccess {
answerInput.post { answerInput.text.clear() }
topLevelSession(latestSnapshot)?.let { topLevelSession ->
SessionNotificationCoordinator.acknowledgeSessionTree(
context = this,
sessionController = sessionController,
topLevelSessionId = topLevelSession.sessionId,
sessionIds = listOf(topLevelSession.sessionId, selectedSession.sessionId),
)
}
showToast("Answered ${selectedSession.sessionId}")
refreshSnapshot(force = true)
}
}
}
private fun attachTarget() {
val selectedSession = selectedChildSession(latestSnapshot) ?: return
thread {
runCatching {
sessionController.attachTarget(selectedSession.sessionId)
}.onFailure { err ->
showToast("Failed to attach target: ${err.message}")
}.onSuccess {
showToast("Attached target for ${selectedSession.sessionId}")
refreshSnapshot(force = true)
}
}
}
private fun cancelSession() {
val topLevelSession = topLevelSession(latestSnapshot) ?: return
thread {
runCatching {
if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT) {
val activeChildren = childSessions(latestSnapshot)
.filterNot { isTerminalState(it.state) }
if (activeChildren.isEmpty()) {
sessionController.cancelSession(topLevelSession.sessionId)
} else {
activeChildren.forEach { childSession ->
sessionController.cancelSession(childSession.sessionId)
}
}
} else {
sessionController.cancelSession(topLevelSession.sessionId)
}
}.onFailure { err ->
showToast("Failed to cancel session: ${err.message}")
}.onSuccess {
SessionNotificationCoordinator.acknowledgeSessionTree(
context = this,
sessionController = sessionController,
topLevelSessionId = topLevelSession.sessionId,
sessionIds = listOf(topLevelSession.sessionId) + childSessions(latestSnapshot).map(AgentSessionDetails::sessionId),
)
showToast(
if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT) {
"Cancelled active child sessions"
} else {
"Cancelled ${topLevelSession.sessionId}"
},
)
refreshSnapshot(force = true)
}
}
}
private fun deleteSession() {
val topLevelSession = topLevelSession(latestSnapshot) ?: return
thread {
runCatching {
if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_HOME) {
sessionController.cancelSession(topLevelSession.sessionId)
}
dismissedSessionStore.dismiss(topLevelSession.sessionId)
childSessions(latestSnapshot).forEach { childSession ->
dismissedSessionStore.dismiss(childSession.sessionId)
}
SessionNotificationCoordinator.acknowledgeSessionTree(
context = this,
sessionController = sessionController,
topLevelSessionId = topLevelSession.sessionId,
sessionIds = listOf(topLevelSession.sessionId) + childSessions(latestSnapshot).map(AgentSessionDetails::sessionId),
)
}.onFailure { err ->
showToast("Failed to delete session: ${err.message}")
}.onSuccess {
showToast("Deleted session")
finish()
}
}
}
private fun cancelSelectedChildSession() {
val selectedChildSession = selectedChildSession(latestSnapshot) ?: return
thread {
runCatching {
sessionController.cancelSession(selectedChildSession.sessionId)
}.onFailure { err ->
showToast("Failed to cancel child session: ${err.message}")
}.onSuccess {
topLevelSession(latestSnapshot)?.let { topLevelSession ->
SessionNotificationCoordinator.acknowledgeSessionTree(
context = this,
sessionController = sessionController,
topLevelSessionId = topLevelSession.sessionId,
sessionIds = listOf(selectedChildSession.sessionId),
)
}
showToast("Cancelled ${selectedChildSession.sessionId}")
refreshSnapshot(force = true)
}
}
}
private fun deleteSelectedChildSession() {
val selectedChildSession = selectedChildSession(latestSnapshot) ?: return
thread {
runCatching {
dismissedSessionStore.dismiss(selectedChildSession.sessionId)
}.onFailure { err ->
showToast("Failed to delete child session: ${err.message}")
}.onSuccess {
topLevelSession(latestSnapshot)?.let { topLevelSession ->
SessionNotificationCoordinator.acknowledgeSessionTree(
context = this,
sessionController = sessionController,
topLevelSessionId = topLevelSession.sessionId,
sessionIds = listOf(selectedChildSession.sessionId),
)
}
selectedChildSessionId = null
showToast("Deleted child session")
refreshSnapshot(force = true)
}
}
}
private fun sendFollowUpPrompt() {
val viewState = resolveViewState(latestSnapshot) ?: return
val isStandaloneHomeStart = canStartStandaloneHomeSession(viewState)
if (isStandaloneHomeStart) {
showStandaloneHomeSessionDialog(viewState)
} else {
val promptInput = findViewById<EditText>(R.id.session_detail_follow_up_prompt)
val prompt = promptInput.text.toString().trim()
if (prompt.isEmpty()) {
promptInput.error = "Enter a follow-up prompt"
return
}
promptInput.text.clear()
continueSessionInPlaceAsync(prompt, latestSnapshot)
}
}
private fun showStandaloneHomeSessionDialog(
viewState: SessionViewState,
) {
val topLevelSession = viewState.topLevelSession
val targetPackage = checkNotNull(topLevelSession.targetPackage) {
"No target package available for this session"
}
startActivity(
CreateSessionActivity.existingHomeSessionIntent(
context = this,
sessionId = topLevelSession.sessionId,
targetPackage = targetPackage,
initialSettings = sessionController.executionSettingsForSession(topLevelSession.sessionId),
),
)
moveTaskToBack(true)
}
private fun continueSessionInPlaceAsync(
prompt: String,
snapshot: AgentSnapshot,
) {
thread {
runCatching {
continueSessionInPlaceOnce(prompt, snapshot)
}.onFailure { err ->
showToast("Failed to continue session: ${err.message}")
}.onSuccess { result ->
showToast("Continued session in place")
runOnUiThread {
moveTaskToBack(true)
}
}
}
}
private fun continueSessionInPlaceOnce(
prompt: String,
snapshot: AgentSnapshot,
viewState: SessionViewState = resolveViewState(snapshot) ?: error("Session not found"),
): SessionStartResult {
val topLevelSession = viewState.topLevelSession
val selectedSession = viewState.selectedChildSession
?: viewState.childSessions.lastOrNull()
?: topLevelSession
Log.i(
TAG,
"Continuing session topLevel=${topLevelSession.sessionId} selected=${selectedSession.sessionId} anchor=${topLevelSession.anchor}",
)
return AgentSessionLauncher.continueSessionInPlace(
sourceTopLevelSession = topLevelSession,
selectedSession = selectedSession,
prompt = prompt,
sessionController = sessionController,
)
}
private fun topLevelSession(snapshot: AgentSnapshot): AgentSessionDetails? {
return resolveViewState(snapshot)?.topLevelSession
}
private fun childSessions(snapshot: AgentSnapshot): List<AgentSessionDetails> {
return resolveViewState(snapshot)?.childSessions.orEmpty()
}
private fun selectedChildSession(snapshot: AgentSnapshot): AgentSessionDetails? {
return resolveViewState(snapshot)?.selectedChildSession
}
private fun currentActionableSession(snapshot: AgentSnapshot): AgentSessionDetails? {
val viewState = resolveViewState(snapshot) ?: return null
return viewState.selectedChildSession ?: viewState.topLevelSession
}
private fun resolveViewState(snapshot: AgentSnapshot): SessionViewState? {
val sessionsById = snapshot.sessions.associateBy(AgentSessionDetails::sessionId)
val requestedSession = requestedSessionId?.let(sessionsById::get)
val resolvedTopLevelSession = topLevelSessionId?.let(sessionsById::get)
?: requestedSession?.let { session ->
if (session.parentSessionId == null) {
session
} else {
sessionsById[session.parentSessionId]
}
}
?: snapshot.parentSession
?: snapshot.selectedSession?.takeIf { it.parentSessionId == null }
?: SessionUiFormatter.topLevelSessions(snapshot).firstOrNull()
?: return null
topLevelSessionId = resolvedTopLevelSession.sessionId
requestedSessionId = resolvedTopLevelSession.sessionId
val visibleChildSessions = snapshot.sessions
.filter { session ->
session.parentSessionId == resolvedTopLevelSession.sessionId &&
!dismissedSessionStore.isDismissed(session.sessionId)
}
.sortedBy(AgentSessionDetails::sessionId)
val requestedChildSession = requestedSession?.takeIf { session ->
session.parentSessionId == resolvedTopLevelSession.sessionId &&
!dismissedSessionStore.isDismissed(session.sessionId)
}
val resolvedSelectedChildSession = selectedChildSessionId?.let(sessionsById::get)?.takeIf { session ->
session.parentSessionId == resolvedTopLevelSession.sessionId &&
!dismissedSessionStore.isDismissed(session.sessionId)
} ?: requestedChildSession
selectedChildSessionId = resolvedSelectedChildSession?.sessionId
return SessionViewState(
topLevelSession = resolvedTopLevelSession,
childSessions = visibleChildSessions,
selectedChildSession = resolvedSelectedChildSession,
)
}
private fun canStartStandaloneHomeSession(viewState: SessionViewState): Boolean {
val topLevelSession = viewState.topLevelSession
return topLevelSession.anchor == AgentSessionInfo.ANCHOR_HOME &&
topLevelSession.state == AgentSessionInfo.STATE_CREATED &&
viewState.childSessions.isEmpty()
}
private fun updateSessionUiLease(sessionId: String?) {
if (leasedSessionId == sessionId) {
return
}
leasedSessionId?.let { previous ->
runCatching {
sessionController.unregisterSessionUiLease(previous, sessionUiLeaseToken)
}
leasedSessionId = null
}
sessionId?.let { current ->
val registered = runCatching {
sessionController.registerSessionUiLease(current, sessionUiLeaseToken)
}
if (registered.isSuccess) {
leasedSessionId = current
}
}
}
private fun isTerminalState(state: Int): Boolean {
return state == AgentSessionInfo.STATE_COMPLETED ||
state == AgentSessionInfo.STATE_CANCELLED ||
state == AgentSessionInfo.STATE_FAILED
}
private fun showToast(message: String) {
runOnUiThread {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density).toInt()
}
}

View File

@@ -0,0 +1,67 @@
package com.openai.codex.agent
import android.content.Context
import com.openai.codex.bridge.SessionExecutionSettings
import org.json.JSONObject
class SessionExecutionSettingsStore(context: Context) {
companion object {
private const val PREFS_NAME = "session_execution_settings"
private const val KEY_MODEL = "model"
private const val KEY_REASONING_EFFORT = "reasoningEffort"
}
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun saveSettings(
sessionId: String,
settings: SessionExecutionSettings,
) {
prefs.edit()
.putString(key(sessionId, KEY_MODEL), settings.model)
.putString(key(sessionId, KEY_REASONING_EFFORT), settings.reasoningEffort)
.apply()
}
fun getSettings(sessionId: String): SessionExecutionSettings {
return SessionExecutionSettings(
model = prefs.getString(key(sessionId, KEY_MODEL), null),
reasoningEffort = prefs.getString(key(sessionId, KEY_REASONING_EFFORT), null),
)
}
fun removeSettings(sessionId: String) {
prefs.edit()
.remove(key(sessionId, KEY_MODEL))
.remove(key(sessionId, KEY_REASONING_EFFORT))
.apply()
}
fun pruneSettings(activeSessionIds: Set<String>) {
val keysToRemove = prefs.all.keys.filter { key ->
val sessionId = key.substringBefore(':', missingDelimiterValue = "")
sessionId.isNotBlank() && sessionId !in activeSessionIds
}
if (keysToRemove.isEmpty()) {
return
}
prefs.edit().apply {
keysToRemove.forEach(::remove)
}.apply()
}
fun toJson(sessionId: String): JSONObject {
val settings = getSettings(sessionId)
return JSONObject().apply {
put("model", settings.model)
put("reasoningEffort", settings.reasoningEffort)
}
}
private fun key(
sessionId: String,
suffix: String,
): String {
return "$sessionId:$suffix"
}
}

View File

@@ -0,0 +1,17 @@
package com.openai.codex.agent
import android.content.Context
object SessionNotificationCoordinator {
fun acknowledgeSessionTree(
context: Context,
sessionController: AgentSessionController,
topLevelSessionId: String,
sessionIds: Collection<String>,
) {
sessionIds.forEach { sessionId ->
AgentQuestionNotifier.cancel(context, sessionId)
}
sessionController.acknowledgeSessionUi(topLevelSessionId)
}
}

View File

@@ -0,0 +1,97 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
import java.io.IOException
enum class SessionFinalPresentationPolicy(
val wireValue: String,
val description: String,
) {
ATTACHED(
wireValue = "ATTACHED",
description = "Finish with the target attached to the main user-facing display/task stack.",
),
DETACHED_HIDDEN(
wireValue = "DETACHED_HIDDEN",
description = "Finish with the target still detached and hidden from view.",
),
DETACHED_SHOWN(
wireValue = "DETACHED_SHOWN",
description = "Finish with the target detached but visibly shown through the detached host.",
),
AGENT_CHOICE(
wireValue = "AGENT_CHOICE",
description = "The Agent does not require a specific final presentation state for this target.",
),
;
fun matches(actualPresentation: Int): Boolean {
return when (this) {
ATTACHED -> actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
DETACHED_HIDDEN -> {
actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_HIDDEN
}
DETACHED_SHOWN -> {
actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_SHOWN
}
AGENT_CHOICE -> true
}
}
fun requiresDetachedMode(): Boolean {
return when (this) {
DETACHED_HIDDEN, DETACHED_SHOWN -> true
ATTACHED, AGENT_CHOICE -> false
}
}
fun promptGuidance(): String {
return when (this) {
ATTACHED -> {
"Before reporting success, ensure the target is ATTACHED to the primary user-facing display. Detached-only visibility is not sufficient."
}
DETACHED_HIDDEN -> {
"Before reporting success, ensure the target remains DETACHED_HIDDEN. Do not attach it or leave it shown."
}
DETACHED_SHOWN -> {
"Before reporting success, ensure the target remains DETACHED_SHOWN. It should stay detached but visibly shown through the detached host."
}
AGENT_CHOICE -> {
"Choose the final target presentation state yourself and describe the final state accurately in your result."
}
}
}
companion object {
fun fromWireValue(value: String?): SessionFinalPresentationPolicy? {
val normalized = value?.trim().orEmpty()
if (normalized.isEmpty()) {
return null
}
return entries.firstOrNull { it.wireValue.equals(normalized, ignoreCase = true) }
}
fun requireFromWireValue(
value: String?,
fieldName: String,
): SessionFinalPresentationPolicy {
return fromWireValue(value)
?: throw IOException("Unsupported $fieldName: ${value?.trim().orEmpty()}")
}
}
}
object AgentTargetPresentationValues {
const val ATTACHED = AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
const val DETACHED_HIDDEN = AgentSessionInfo.TARGET_PRESENTATION_DETACHED_HIDDEN
const val DETACHED_SHOWN = AgentSessionInfo.TARGET_PRESENTATION_DETACHED_SHOWN
}
fun targetPresentationToString(targetPresentation: Int): String {
return when (targetPresentation) {
AgentTargetPresentationValues.ATTACHED -> "ATTACHED"
AgentTargetPresentationValues.DETACHED_HIDDEN -> "DETACHED_HIDDEN"
AgentTargetPresentationValues.DETACHED_SHOWN -> "DETACHED_SHOWN"
else -> targetPresentation.toString()
}
}

View File

@@ -0,0 +1,40 @@
package com.openai.codex.agent
import android.content.Context
class SessionPresentationPolicyStore(
context: Context,
) {
companion object {
private const val PREFS_NAME = "codex_session_presentation_policies"
}
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun savePolicy(
sessionId: String,
policy: SessionFinalPresentationPolicy,
) {
prefs.edit().putString(sessionId, policy.wireValue).apply()
}
fun getPolicy(sessionId: String): SessionFinalPresentationPolicy? {
return SessionFinalPresentationPolicy.fromWireValue(
prefs.getString(sessionId, null),
)
}
fun removePolicy(sessionId: String) {
prefs.edit().remove(sessionId).apply()
}
fun prunePolicies(activeSessionIds: Set<String>) {
val staleSessionIds = prefs.all.keys - activeSessionIds
if (staleSessionIds.isEmpty()) {
return
}
prefs.edit().apply {
staleSessionIds.forEach(::remove)
}.apply()
}
}

View File

@@ -0,0 +1,116 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
import android.content.Context
object SessionUiFormatter {
private const val MAX_LIST_DETAIL_CHARS = 96
fun topLevelSessions(snapshot: AgentSnapshot): List<AgentSessionDetails> {
return snapshot.sessions.filter { it.parentSessionId == null }
}
fun listRowTitle(
context: Context,
session: AgentSessionDetails,
): String {
return when (session.anchor) {
AgentSessionInfo.ANCHOR_HOME -> AppLabelResolver.loadAppLabel(context, session.targetPackage)
AgentSessionInfo.ANCHOR_AGENT -> "Agent Session"
else -> session.targetPackage ?: session.sessionId
}
}
fun listRowSubtitle(
context: Context,
session: AgentSessionDetails,
): String {
val detail = summarizeListDetail(
session.latestQuestion ?: session.latestResult ?: session.latestError ?: session.latestTrace,
)
return buildString {
append(anchorLabel(session.anchor))
append("")
append(session.stateLabel)
append("")
append(session.targetPresentationLabel)
detail?.let {
append("")
append(it)
}
}
}
fun detailSummary(
context: Context,
session: AgentSessionDetails,
parentSession: AgentSessionDetails?,
): String {
return buildString {
append("Session: ${session.sessionId}\n")
append("Anchor: ${anchorLabel(session.anchor)}\n")
append("Target: ${AppLabelResolver.loadAppLabel(context, session.targetPackage)}")
session.targetPackage?.let { append(" ($it)") }
append("\nState: ${session.stateLabel}\n")
append("Target presentation: ${session.targetPresentationLabel}\n")
append("Target runtime: ${session.targetRuntimeLabel}\n")
session.requiredFinalPresentationPolicy?.let { policy ->
append("Required final presentation: ${policy.wireValue}\n")
}
parentSession?.takeIf { it.sessionId != session.sessionId }?.let {
append("Parent: ${it.sessionId}\n")
}
val detail = session.latestQuestion ?: session.latestResult ?: session.latestError ?: session.latestTrace
detail?.takeIf(String::isNotBlank)?.let {
append("Latest: $it")
}
}.trimEnd()
}
fun relatedSessionTitle(
context: Context,
session: AgentSessionDetails,
): String {
val targetLabel = AppLabelResolver.loadAppLabel(context, session.targetPackage)
return buildString {
append("Child")
append("")
append(session.stateLabel)
append("")
append(targetLabel)
session.targetPackage?.let { append(" ($it)") }
}
}
fun relatedSessionSubtitle(session: AgentSessionDetails): String {
val detail = summarizeListDetail(
session.latestQuestion ?: session.latestResult ?: session.latestError ?: session.latestTrace,
)
return buildString {
append("Tap to inspect")
append("")
append(session.targetPresentationLabel)
detail?.let {
append("")
append(it)
}
}
}
private fun anchorLabel(anchor: Int): String {
return when (anchor) {
AgentSessionInfo.ANCHOR_HOME -> "HOME"
AgentSessionInfo.ANCHOR_AGENT -> "AGENT"
else -> anchor.toString()
}
}
private fun summarizeListDetail(detail: String?): String? {
val trimmed = detail?.trim()?.takeIf(String::isNotEmpty) ?: return null
return if (trimmed.length <= MAX_LIST_DETAIL_CHARS) {
trimmed
} else {
trimmed.take(MAX_LIST_DETAIL_CHARS) + ""
}
}
}

View File

@@ -0,0 +1,19 @@
package com.openai.codex.agent
import android.view.View
import android.widget.AdapterView
class SimpleItemSelectedListener(
private val onItemSelected: () -> Unit,
) : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long,
) {
onItemSelected()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}

View File

@@ -0,0 +1,39 @@
package com.openai.codex.agent
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
class TopLevelSessionListAdapter(
context: Context,
) : ArrayAdapter<AgentSessionDetails>(context, android.R.layout.simple_list_item_2) {
private val inflater = LayoutInflater.from(context)
fun replaceItems(items: List<AgentSessionDetails>) {
clear()
addAll(items)
notifyDataSetChanged()
}
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup,
): View {
val view = convertView ?: inflater.inflate(android.R.layout.simple_list_item_2, parent, false)
val item = getItem(position)
val titleView = view.findViewById<TextView>(android.R.id.text1)
val subtitleView = view.findViewById<TextView>(android.R.id.text2)
if (item == null) {
titleView.text = "Unknown session"
subtitleView.text = ""
return view
}
titleView.text = SessionUiFormatter.listRowTitle(context, item)
subtitleView.text = SessionUiFormatter.listRowSubtitle(context, item)
return view
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M4,4h16v16h-16z" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFF4F6F8" />
<stroke
android:width="1dp"
android:color="#FFD5D9DD" />
<corners android:radius="12dp" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFE3F2FD" />
<stroke
android:width="2dp"
android:color="#FF1976D2" />
<corners android:radius="12dp" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFAFBFC" />
<stroke
android:width="1dp"
android:color="#FFD5D9DD" />
<corners android:radius="14dp" />
</shape>

View File

@@ -0,0 +1,118 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/create_session_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="New Session"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textStyle="bold" />
<TextView
android:id="@+id/create_session_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Loading session…"
android:visibility="gone" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Target app" />
<TextView
android:id="@+id/create_session_target_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:text="No target app selected. This will start an Agent-anchored session." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/create_session_pick_target_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Choose Target App" />
<Button
android:id="@+id/create_session_clear_target_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Clear" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Prompt" />
<EditText
android:id="@+id/create_session_prompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="4" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Model" />
<Spinner
android:id="@+id/create_session_model_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Thinking depth" />
<Spinner
android:id="@+id/create_session_effort_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="end"
android:orientation="horizontal">
<Button
android:id="@+id/create_session_cancel_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Cancel" />
<Button
android:id="@+id/create_session_start_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="Start" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,78 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<Button
android:id="@+id/create_session_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Create New Session" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Agent Authentication"
android:textStyle="bold" />
<TextView
android:id="@+id/auth_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Agent auth: probing..." />
<TextView
android:id="@+id/agent_runtime_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Agent runtime: probing..." />
<TextView
android:id="@+id/agent_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Agent framework: probing..." />
<Button
android:id="@+id/auth_action"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Start sign-in" />
<Button
android:id="@+id/refresh_sessions_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Refresh Sessions" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Sessions"
android:textStyle="bold" />
<ListView
android:id="@+id/session_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:dividerHeight="1dp" />
<TextView
android:id="@+id/session_list_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:paddingTop="12dp"
android:text="No sessions yet"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,228 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Top-Level Session"
android:textStyle="bold" />
<TextView
android:id="@+id/session_detail_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Loading session..." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<Button
android:id="@+id/session_detail_cancel_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Cancel Session" />
<Button
android:id="@+id/session_detail_delete_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="Delete Session" />
</LinearLayout>
<TextView
android:id="@+id/session_detail_top_level_action_note"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/session_detail_children_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Child Sessions (Tap To Inspect)"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/session_detail_children_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
<TextView
android:id="@+id/session_detail_children_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="No child sessions"
android:visibility="gone" />
<TextView
android:id="@+id/session_detail_child_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Selected Child Session"
android:textStyle="bold" />
<ScrollView
android:id="@+id/session_detail_child_summary_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxHeight="220dp"
android:background="@drawable/session_detail_panel_background"
android:fadeScrollbars="false"
android:overScrollMode="ifContentScrolls"
android:scrollbarStyle="insideInset"
android:scrollbars="vertical">
<TextView
android:id="@+id/session_detail_child_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="14dp"
android:text="Select a child session to inspect it."
android:textIsSelectable="true" />
</ScrollView>
<LinearLayout
android:id="@+id/session_detail_child_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<Button
android:id="@+id/session_detail_child_cancel_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Cancel Child" />
<Button
android:id="@+id/session_detail_child_delete_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="Delete Child" />
</LinearLayout>
<Button
android:id="@+id/session_detail_attach_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Attach Target" />
<TextView
android:id="@+id/session_detail_question_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Question"
android:textStyle="bold"
android:visibility="gone" />
<TextView
android:id="@+id/session_detail_question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
<EditText
android:id="@+id/session_detail_answer_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="Answer for the waiting Genie session"
android:inputType="textCapSentences"
android:visibility="gone" />
<Button
android:id="@+id/session_detail_answer_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Answer Question"
android:visibility="gone" />
<TextView
android:id="@+id/session_detail_follow_up_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Continue Session"
android:textStyle="bold" />
<EditText
android:id="@+id/session_detail_follow_up_prompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:hint="Ask Codex to continue from here."
android:inputType="textMultiLine|textCapSentences"
android:minLines="3" />
<Button
android:id="@+id/session_detail_follow_up_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Send Follow-up Prompt" />
<TextView
android:id="@+id/session_detail_follow_up_note"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="In-place continuation is currently available only for direct Agent sessions. App-scoped HOME sessions must start a new session."
android:visibility="gone" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Timeline"
android:textStyle="bold" />
<ScrollView
android:id="@+id/session_detail_timeline_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxHeight="280dp"
android:background="@drawable/session_detail_panel_background"
android:fadeScrollbars="false"
android:overScrollMode="ifContentScrolls"
android:scrollbarStyle="insideInset"
android:scrollbars="vertical">
<TextView
android:id="@+id/session_detail_timeline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="14dp"
android:text="No framework events yet."
android:textIsSelectable="true"
android:typeface="monospace" />
</ScrollView>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,80 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Target app" />
<TextView
android:id="@+id/create_session_target_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:text="No target app selected. This will start an Agent-anchored session." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/create_session_pick_target_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Choose Target App" />
<Button
android:id="@+id/create_session_clear_target_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Clear" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Prompt" />
<EditText
android:id="@+id/create_session_prompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="4" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Model" />
<Spinner
android:id="@+id/create_session_model_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Thinking depth" />
<Spinner
android:id="@+id/create_session_effort_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,37 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="56dp"
android:orientation="horizontal"
android:paddingHorizontal="20dp"
android:paddingVertical="12dp">
<ImageView
android:id="@+id/installed_app_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/installed_app_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium" />
<TextView
android:id="@+id/installed_app_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?android:attr/textAppearanceSmall" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<color name="ic_launcher_background">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Codex Agent</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="CodexCreateSessionTheme" parent="@android:style/Theme.DeviceDefault.Light.Dialog.NoActionBar">
<item name="android:windowCloseOnTouchOutside">true</item>
<item name="android:windowMinWidthMajor">90%</item>
<item name="android:windowMinWidthMinor">90%</item>
</style>
</resources>

View File

@@ -0,0 +1,127 @@
package com.openai.codex.agent
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AgentFrameworkToolBridgeTest {
@Test
fun parseStartDirectSessionArgumentsExtractsTargetsReasonAndDetachedMode() {
val request = AgentFrameworkToolBridge.parseStartDirectSessionArguments(
arguments = JSONObject(
"""
{
"targets": [
{
"packageName": "com.android.deskclock",
"objective": "Start the requested timer in Clock.",
"finalPresentationPolicy": "ATTACHED"
}
],
"reason": "Clock is the installed timer app.",
"allowDetachedMode": false
}
""".trimIndent(),
),
userObjective = "Start a 5-minute timer.",
isEligibleTargetPackage = linkedSetOf("com.android.deskclock", "com.android.contacts")::contains,
)
assertEquals("Start a 5-minute timer.", request.plan.originalObjective)
assertEquals("Clock is the installed timer app.", request.plan.rationale)
assertEquals(false, request.plan.usedOverride)
assertEquals(false, request.allowDetachedMode)
assertEquals(1, request.plan.targets.size)
assertEquals("com.android.deskclock", request.plan.targets.single().packageName)
assertEquals("Start the requested timer in Clock.", request.plan.targets.single().objective)
assertEquals(
SessionFinalPresentationPolicy.ATTACHED,
request.plan.targets.single().finalPresentationPolicy,
)
}
@Test
fun parseStartDirectSessionArgumentsFallsBackToUserObjectiveWhenDelegatedObjectiveMissing() {
val request = AgentFrameworkToolBridge.parseStartDirectSessionArguments(
arguments = JSONObject(
"""
{
"targets": [
{
"packageName": "com.android.deskclock"
}
]
}
""".trimIndent(),
),
userObjective = "Start a 5-minute timer.",
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
)
assertEquals("Start a 5-minute timer.", request.plan.targets.single().objective)
assertEquals(
SessionFinalPresentationPolicy.AGENT_CHOICE,
request.plan.targets.single().finalPresentationPolicy,
)
assertEquals(true, request.allowDetachedMode)
}
@Test
fun parseStartDirectSessionArgumentsRejectsUnknownPackages() {
val err = runCatching {
AgentFrameworkToolBridge.parseStartDirectSessionArguments(
arguments = JSONObject(
"""
{
"targets": [
{
"packageName": "com.unknown.app",
"objective": "Do the task.",
"finalPresentationPolicy": "AGENT_CHOICE"
}
]
}
""".trimIndent(),
),
userObjective = "Start a timer.",
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
)
}.exceptionOrNull()
assertTrue(err is java.io.IOException)
assertEquals(
"Framework session tool selected missing or disallowed package(s): com.unknown.app",
err?.message,
)
}
@Test
fun parseStartDirectSessionArgumentsRejectsDetachedPresentationWithoutDetachedMode() {
val err = runCatching {
AgentFrameworkToolBridge.parseStartDirectSessionArguments(
arguments = JSONObject(
"""
{
"targets": [
{
"packageName": "com.android.deskclock",
"finalPresentationPolicy": "DETACHED_SHOWN"
}
],
"allowDetachedMode": false
}
""".trimIndent(),
),
userObjective = "Keep Clock visible in detached mode.",
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
)
}.exceptionOrNull()
assertTrue(err is java.io.IOException)
assertEquals(
"Framework session tool selected detached final presentation without allowDetachedMode: com.android.deskclock",
err?.message,
)
}
}

View File

@@ -0,0 +1,76 @@
package com.openai.codex.agent
import org.junit.Assert.assertEquals
import org.junit.Test
class AgentParentSessionAggregatorTest {
@Test
fun rollupRequestsAttachWhenAttachedPresentationIsRequired() {
val rollup = AgentParentSessionAggregator.rollup(
listOf(
ParentSessionChildSummary(
sessionId = "child-1",
targetPackage = "com.android.deskclock",
state = AgentSessionStateValues.COMPLETED,
targetPresentation = AgentTargetPresentationValues.DETACHED_SHOWN,
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.ATTACHED,
latestResult = "Started the stopwatch.",
latestError = null,
),
),
)
assertEquals(AgentSessionStateValues.RUNNING, rollup.state)
assertEquals(listOf("child-1"), rollup.sessionsToAttach)
assertEquals(null, rollup.resultMessage)
assertEquals(null, rollup.errorMessage)
}
@Test
fun rollupFailsWhenDetachedShownIsRequiredButTargetIsHidden() {
val rollup = AgentParentSessionAggregator.rollup(
listOf(
ParentSessionChildSummary(
sessionId = "child-1",
targetPackage = "com.android.deskclock",
state = AgentSessionStateValues.COMPLETED,
targetPresentation = AgentTargetPresentationValues.DETACHED_HIDDEN,
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.DETACHED_SHOWN,
latestResult = "Started the stopwatch.",
latestError = null,
),
),
)
assertEquals(AgentSessionStateValues.FAILED, rollup.state)
assertEquals(emptyList<String>(), rollup.sessionsToAttach)
assertEquals(
"Delegated session completed without the required final presentation; com.android.deskclock: required DETACHED_SHOWN, actual DETACHED_HIDDEN",
rollup.errorMessage,
)
}
@Test
fun rollupCompletesWhenRequiredPresentationMatches() {
val rollup = AgentParentSessionAggregator.rollup(
listOf(
ParentSessionChildSummary(
sessionId = "child-1",
targetPackage = "com.android.deskclock",
state = AgentSessionStateValues.COMPLETED,
targetPresentation = AgentTargetPresentationValues.ATTACHED,
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.ATTACHED,
latestResult = "Started the stopwatch.",
latestError = null,
),
),
)
assertEquals(AgentSessionStateValues.COMPLETED, rollup.state)
assertEquals(emptyList<String>(), rollup.sessionsToAttach)
assertEquals(
"Completed delegated session; com.android.deskclock: Started the stopwatch.",
rollup.resultMessage,
)
}
}

View File

@@ -0,0 +1,140 @@
package com.openai.codex.agent
import java.io.File
import java.net.SocketException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AgentResponsesProxyTest {
@Test
fun buildResponsesUrlUsesChatgptDefaultForProviderDefault() {
assertEquals(
"https://chatgpt.com/backend-api/codex/responses",
AgentResponsesProxy.buildResponsesUrl(
upstreamBaseUrl = "provider-default",
authMode = "chatgpt",
),
)
}
@Test
fun buildResponsesUrlAppendsResponsesToConfiguredBase() {
assertEquals(
"https://api.openai.com/v1/responses",
AgentResponsesProxy.buildResponsesUrl(
upstreamBaseUrl = "https://api.openai.com/v1/",
authMode = "apiKey",
),
)
}
@Test
fun buildResponsesBaseUrlKeepsConfiguredBaseWithoutTrailingSlash() {
assertEquals(
"https://example.invalid/custom",
AgentResponsesProxy.buildResponsesBaseUrl(
upstreamBaseUrl = "https://example.invalid/custom/",
authMode = "chatgpt",
),
)
}
@Test
fun buildResponsesBaseUrlTreatsNullStringAsProviderDefault() {
assertEquals(
"https://chatgpt.com/backend-api/codex",
AgentResponsesProxy.buildResponsesBaseUrl(
upstreamBaseUrl = "null",
authMode = "chatgpt",
),
)
}
@Test
fun buildFrameworkTransportTargetSplitsChatgptBaseIntoOriginAndPath() {
assertEquals(
AgentResponsesProxy.FrameworkTransportTarget(
baseUrl = "https://chatgpt.com",
responsesPath = "/backend-api/codex/responses",
),
AgentResponsesProxy.buildFrameworkTransportTarget("https://chatgpt.com/backend-api/codex"),
)
}
@Test
fun buildFrameworkTransportTargetSplitsOpenAiBaseIntoOriginAndPath() {
assertEquals(
AgentResponsesProxy.FrameworkTransportTarget(
baseUrl = "https://api.openai.com",
responsesPath = "/v1/responses",
),
AgentResponsesProxy.buildFrameworkTransportTarget("https://api.openai.com/v1/"),
)
}
@Test
fun loadAuthSnapshotReadsChatgptTokens() {
val authFile = writeTempAuthJson(
"""
{
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "header.payload.signature",
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "acct-123"
},
"last_refresh": "2026-03-19T00:00:00Z"
}
""".trimIndent(),
)
val snapshot = AgentResponsesProxy.loadAuthSnapshot(authFile)
assertEquals("chatgpt", snapshot.authMode)
assertEquals("access-token", snapshot.bearerToken)
assertEquals("acct-123", snapshot.accountId)
}
@Test
fun loadAuthSnapshotFallsBackToApiKeyModeWhenAuthModeIsMissing() {
val authFile = writeTempAuthJson(
"""
{
"OPENAI_API_KEY": "sk-test-key",
"tokens": null,
"last_refresh": null
}
""".trimIndent(),
)
val snapshot = AgentResponsesProxy.loadAuthSnapshot(authFile)
assertEquals("apiKey", snapshot.authMode)
assertEquals("sk-test-key", snapshot.bearerToken)
assertNull(snapshot.accountId)
}
@Test
fun describeRequestFailureIncludesPhaseUrlAndCause() {
val message = AgentResponsesProxy.describeRequestFailure(
phase = "read response body",
upstreamUrl = "https://chatgpt.com/backend-api/codex/responses",
err = SocketException("Software caused connection abort"),
)
assertEquals(
"Responses proxy failed during read response body for https://chatgpt.com/backend-api/codex/responses: SocketException: Software caused connection abort",
message,
)
}
private fun writeTempAuthJson(contents: String): File {
return File.createTempFile("agent-auth", ".json").apply {
writeText(contents)
deleteOnExit()
}
}
}

View File

@@ -0,0 +1,80 @@
package com.openai.codex.agent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AgentTaskPlannerTest {
@Test
fun parsePlannerResponseExtractsStructuredPlan() {
val request = AgentTaskPlanner.parsePlannerResponse(
responseText =
"""
{
"targets": [
{
"packageName": "com.android.deskclock",
"objective": "Start the requested timer in Clock.",
"finalPresentationPolicy": "ATTACHED"
}
],
"reason": "DeskClock is the installed timer handler.",
"allowDetachedMode": true
}
""".trimIndent(),
userObjective = "Start a 5-minute timer.",
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
)
assertEquals("DeskClock is the installed timer handler.", request.plan.rationale)
assertEquals(true, request.allowDetachedMode)
assertEquals(1, request.plan.targets.size)
assertEquals("com.android.deskclock", request.plan.targets.single().packageName)
assertEquals("Start the requested timer in Clock.", request.plan.targets.single().objective)
assertEquals(
SessionFinalPresentationPolicy.ATTACHED,
request.plan.targets.single().finalPresentationPolicy,
)
}
@Test
fun parsePlannerResponseAcceptsMarkdownFences() {
val request = AgentTaskPlanner.parsePlannerResponse(
responseText =
"""
```json
{
"targets": [
{
"packageName": "com.android.deskclock"
}
]
}
```
""".trimIndent(),
userObjective = "Start a 5-minute timer.",
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
)
assertEquals("Start a 5-minute timer.", request.plan.targets.single().objective)
assertEquals(
SessionFinalPresentationPolicy.AGENT_CHOICE,
request.plan.targets.single().finalPresentationPolicy,
)
assertEquals(true, request.allowDetachedMode)
}
@Test
fun parsePlannerResponseRejectsMissingJson() {
val err = runCatching {
AgentTaskPlanner.parsePlannerResponse(
responseText = "DeskClock seems right.",
userObjective = "Start a timer.",
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
)
}.exceptionOrNull()
assertTrue(err is java.io.IOException)
assertEquals("Planner did not return a valid JSON object", err?.message)
}
}

View File

@@ -0,0 +1,53 @@
package com.openai.codex.agent
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AgentUserInputPrompterTest {
@Test
fun buildQuestionAnswersMapsSplitAnswersByQuestionId() {
val questions = JSONArray()
.put(
JSONObject()
.put("id", "duration")
.put("question", "How long should the timer last?"),
)
.put(
JSONObject()
.put("id", "confirm")
.put("question", "Should I start it now?"),
)
val answers = AgentUserInputPrompter.buildQuestionAnswers(
questions = questions,
answer = "5 minutes\n\nYes",
)
assertEquals("5 minutes", answers.getJSONObject("duration").getJSONArray("answers").getString(0))
assertEquals("Yes", answers.getJSONObject("confirm").getJSONArray("answers").getString(0))
}
@Test
fun renderQuestionsMentionsBlankLineSeparatorForMultipleQuestions() {
val questions = JSONArray()
.put(
JSONObject()
.put("id", "duration")
.put("question", "How long should the timer last?"),
)
.put(
JSONObject()
.put("id", "confirm")
.put("question", "Should I start it now?"),
)
val rendered = AgentUserInputPrompter.renderQuestions(questions)
assertTrue(rendered.contains("How long should the timer last?"))
assertTrue(rendered.contains("Should I start it now?"))
assertTrue(rendered.contains("Reply with one answer per question"))
}
}

View File

@@ -0,0 +1,61 @@
import org.gradle.api.GradleException
import org.gradle.api.tasks.Sync
plugins {
id("com.android.library")
}
val minAndroidJavaVersion = 17
val maxAndroidJavaVersion = 21
val hostJavaMajorVersion = JavaVersion.current().majorVersion.toIntOrNull()
?: throw GradleException("Unable to determine Java version from ${JavaVersion.current()}.")
if (hostJavaMajorVersion < minAndroidJavaVersion) {
throw GradleException(
"Android bridge build requires Java ${minAndroidJavaVersion}+ (tested through Java ${maxAndroidJavaVersion}). Found Java ${hostJavaMajorVersion}."
)
}
val androidJavaTargetVersion = hostJavaMajorVersion.coerceAtMost(maxAndroidJavaVersion)
val androidJavaVersion = JavaVersion.toVersion(androidJavaTargetVersion)
val agentPlatformStubSdkZip = providers
.gradleProperty("agentPlatformStubSdkZip")
.orElse(providers.environmentVariable("ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP"))
val extractedAgentPlatformJar = layout.buildDirectory.file(
"generated/agent-platform/android-agent-platform-stub-sdk.jar"
)
android {
namespace = "com.openai.codex.bridge"
compileSdk = 34
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = androidJavaVersion
targetCompatibility = androidJavaVersion
}
}
val extractAgentPlatformStubSdk = tasks.register<Sync>("extractAgentPlatformStubSdk") {
val sdkZip = agentPlatformStubSdkZip.orNull
?: throw GradleException(
"Set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or -PagentPlatformStubSdkZip to the Android Agent Platform stub SDK zip."
)
val outputDir = extractedAgentPlatformJar.get().asFile.parentFile
from(zipTree(sdkZip)) {
include("payloads/compile_only/android-agent-platform-stub-sdk.jar")
eachFile { path = name }
includeEmptyDirs = false
}
into(outputDir)
}
tasks.named("preBuild").configure {
dependsOn(extractAgentPlatformStubSdk)
}
dependencies {
compileOnly(files(extractedAgentPlatformJar))
testImplementation("junit:junit:4.13.2")
}

View File

@@ -0,0 +1,50 @@
# Android Agent/Genie Runtime Notes
This Codex runtime is operating on an Android device through the Agent Platform.
## If you are the Agent
- The user interacts only with the Agent.
- Plan the work, choose the target package or packages, and start one Genie session per target app that needs to be driven.
- Delegate objectives, not tool choices. Tell each Genie what outcome it must achieve in its paired app and let the Genie choose its own tools.
- Answer Genie questions directly when you can. If the answer depends on user intent or missing constraints, ask the user.
- Keep auth, upstream access, and any internet-facing model traffic on the Agent side.
## If you are a Genie
- You are paired with exactly one target app sandbox for this session.
- Solve the delegated objective inside that sandbox by using the normal Codex tool path and the Android tools that are available on-device.
- Ask the Agent a concise free-form question only when you are blocked on missing intent, missing constraints, or a framework-owned action.
- Do not assume you can reach the internet directly. Live session model traffic is framework-owned, and auth material originates from the Agent.
- Do not rely on direct cross-app `bindService(...)` or raw local sockets to reach the Agent. Use the framework-managed session bridge.
## Shell and device tooling
- Prefer standard Android shell tools first: `cmd`, `am`, `pm`, `input`, `uiautomator`, `dumpsys`, `wm`, `settings`, `content`, `logcat`.
- Do not assume desktop/Linux extras such as `python3`, GNU `date -d`, or other non-stock userland tools are present.
- When a command affects app launch or user-visible state, prefer an explicit `--user 0` when the tool supports it.
- Keep temporary artifacts in app-private storage such as the current app `files/` or `cache/` directories, or under `$CODEX_HOME`. Do not rely on shared storage.
## UI inspection and files
- In self-target Genie mode, prefer `uiautomator dump /proc/self/fd/1` or `uiautomator dump /dev/stdout` when stdout capture is acceptable.
- Plain `uiautomator dump` writes to the app-private dump directory.
- Explicit shared-storage targets such as `/sdcard/...` are redirected back into app-private storage in self-target mode.
- Do not assume `/sdcard` or `/data/local/tmp` are readable or writable from the paired app sandbox.
## Presentation semantics
- Detached launch, shown-detached, and attached are different states.
- `targetDetached=true` means the target is still detached even if it is visible in a detached or mirrored presentation.
- If the framework launched the target detached for you, treat that launch as authoritative. Do not relaunch the target package with plain shell launchers such as `am start`, `cmd activity start-activity`, or `monkey -p`; use framework target controls plus UI inspection/input instead.
- If the detached target disappears or the framework reports a missing detached target, use the framework recovery primitive first (`android_target_ensure_hidden`) instead of ordinary app launch.
- If the delegated objective specifies a required final target presentation such as `ATTACHED`, `DETACHED_HIDDEN`, or `DETACHED_SHOWN`, treat that as a hard completion requirement and do not claim success until the framework session matches it.
- If the task says the app should be visible to the user, do not claim success until the target is attached unless the task explicitly allows detached presentation.
- If the user asks to show an activity on the screen, the Genie must explicitly make its display visible. Launching hidden or leaving the target detached is not enough.
- Treat framework session state as the source of truth for presentation state.
- If the detached target disappears or the detached display looks empty, do not guess with ordinary relaunch commands. Use framework target controls first; if they do not restore a usable target, report the framework-state problem to the Agent.
## Working style
- Prefer solving tasks with normal shell/tool use before reverse-engineering APK contents.
- When you need to ask a question, make it specific and short so the Agent can either answer directly or escalate it to the user.

View File

@@ -0,0 +1,473 @@
package com.openai.codex.bridge
import android.app.agent.AgentSessionInfo
import android.app.agent.GenieService
import android.window.ScreenCapture
import java.lang.reflect.Field
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.lang.reflect.Modifier
object DetachedTargetCompat {
private const val METHOD_GET_TARGET_RUNTIME = "getTargetRuntime"
private const val METHOD_ENSURE_DETACHED_TARGET_HIDDEN = "ensureDetachedTargetHidden"
private const val METHOD_SHOW_DETACHED_TARGET = "showDetachedTarget"
private const val METHOD_HIDE_DETACHED_TARGET = "hideDetachedTarget"
private const val METHOD_ATTACH_DETACHED_TARGET = "attachDetachedTarget"
private const val METHOD_CLOSE_DETACHED_TARGET = "closeDetachedTarget"
private const val METHOD_CAPTURE_DETACHED_TARGET_FRAME_RESULT = "captureDetachedTargetFrameResult"
private const val METHOD_GET_STATUS = "getStatus"
private const val METHOD_GET_DETACHED_DISPLAY_ID = "getDetachedDisplayId"
private const val METHOD_GET_MESSAGE = "getMessage"
private const val TARGET_RUNTIME_NONE_LABEL = "TARGET_RUNTIME_NONE"
private const val TARGET_RUNTIME_ATTACHED_LABEL = "TARGET_RUNTIME_ATTACHED"
private const val TARGET_RUNTIME_DETACHED_LAUNCHING_LABEL = "TARGET_RUNTIME_DETACHED_LAUNCHING"
private const val TARGET_RUNTIME_DETACHED_HIDDEN_LABEL = "TARGET_RUNTIME_DETACHED_HIDDEN"
private const val TARGET_RUNTIME_DETACHED_SHOWN_LABEL = "TARGET_RUNTIME_DETACHED_SHOWN"
private const val TARGET_RUNTIME_MISSING_LABEL = "TARGET_RUNTIME_MISSING"
private const val STATUS_OK_LABEL = "STATUS_OK"
private const val STATUS_NO_DETACHED_DISPLAY_LABEL = "STATUS_NO_DETACHED_DISPLAY"
private const val STATUS_NO_TARGET_TASK_LABEL = "STATUS_NO_TARGET_TASK"
private const val STATUS_LAUNCH_FAILED_LABEL = "STATUS_LAUNCH_FAILED"
private const val STATUS_INTERNAL_ERROR_LABEL = "STATUS_INTERNAL_ERROR"
private const val STATUS_CAPTURE_FAILED_LABEL = "STATUS_CAPTURE_FAILED"
data class DetachedTargetState(
val value: Int?,
val label: String,
) {
fun isMissing(): Boolean = label == TARGET_RUNTIME_MISSING_LABEL
}
data class DetachedTargetControlResult(
val status: Int?,
val statusLabel: String,
val targetRuntime: DetachedTargetState,
val detachedDisplayId: Int?,
val message: String?,
) {
fun isOk(): Boolean = statusLabel == STATUS_OK_LABEL
fun needsRecovery(): Boolean {
return statusLabel == STATUS_NO_DETACHED_DISPLAY_LABEL ||
statusLabel == STATUS_NO_TARGET_TASK_LABEL ||
targetRuntime.isMissing()
}
fun summary(action: String): String {
return buildString {
append("Detached target ")
append(action)
append(" -> ")
append(statusLabel)
append(" (runtime=")
append(targetRuntime.label)
detachedDisplayId?.let { displayId ->
append(", display=")
append(displayId)
}
append(")")
message?.takeIf(String::isNotBlank)?.let { detail ->
append(": ")
append(detail)
}
}
}
}
data class DetachedTargetCaptureResult(
val status: Int?,
val statusLabel: String,
val targetRuntime: DetachedTargetState,
val detachedDisplayId: Int?,
val message: String?,
val captureResult: ScreenCapture.ScreenCaptureResult?,
) {
fun isOk(): Boolean = statusLabel == STATUS_OK_LABEL && captureResult != null
fun needsRecovery(): Boolean {
return statusLabel == STATUS_NO_DETACHED_DISPLAY_LABEL ||
statusLabel == STATUS_NO_TARGET_TASK_LABEL ||
targetRuntime.isMissing()
}
fun summary(): String {
return buildString {
append("Detached target capture -> ")
append(statusLabel)
append(" (runtime=")
append(targetRuntime.label)
detachedDisplayId?.let { displayId ->
append(", display=")
append(displayId)
}
append(")")
message?.takeIf(String::isNotBlank)?.let { detail ->
append(": ")
append(detail)
}
}
}
}
private val targetRuntimeLabels: Map<Int, String> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
staticIntFields(AgentSessionInfo::class.java, "TARGET_RUNTIME_")
}
private val getTargetRuntimeMethod: Method? by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
findOptionalMethod(AgentSessionInfo::class.java, METHOD_GET_TARGET_RUNTIME)
}
fun getTargetRuntime(sessionInfo: AgentSessionInfo): DetachedTargetState {
val runtimeValue = getTargetRuntimeMethod?.let { method ->
invokeChecked { method.invoke(sessionInfo) as? Int }
}
if (runtimeValue != null) {
return DetachedTargetState(
value = runtimeValue,
label = targetRuntimeLabels[runtimeValue] ?: runtimeValue.toString(),
)
}
return when {
sessionInfo.targetPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_HIDDEN -> {
DetachedTargetState(
value = null,
label = TARGET_RUNTIME_DETACHED_HIDDEN_LABEL,
)
}
sessionInfo.targetPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_SHOWN -> {
DetachedTargetState(
value = null,
label = TARGET_RUNTIME_DETACHED_SHOWN_LABEL,
)
}
sessionInfo.isTargetDetached -> {
DetachedTargetState(
value = null,
label = TARGET_RUNTIME_DETACHED_LAUNCHING_LABEL,
)
}
sessionInfo.targetPackage != null -> {
DetachedTargetState(
value = null,
label = TARGET_RUNTIME_ATTACHED_LABEL,
)
}
else -> DetachedTargetState(
value = null,
label = TARGET_RUNTIME_NONE_LABEL,
)
}
}
fun ensureDetachedTargetHidden(
callback: GenieService.Callback,
sessionId: String,
): DetachedTargetControlResult {
return invokeControl(
callback = callback,
sessionId = sessionId,
methodName = METHOD_ENSURE_DETACHED_TARGET_HIDDEN,
legacyFallback = {
callback.requestLaunchDetachedTargetHidden(sessionId)
DetachedTargetControlResult(
status = null,
statusLabel = STATUS_OK_LABEL,
targetRuntime = DetachedTargetState(
value = null,
label = TARGET_RUNTIME_DETACHED_HIDDEN_LABEL,
),
detachedDisplayId = null,
message = "Used legacy detached launch callback.",
)
},
)
}
fun showDetachedTarget(
callback: GenieService.Callback,
sessionId: String,
): DetachedTargetControlResult {
return invokeControl(
callback = callback,
sessionId = sessionId,
methodName = METHOD_SHOW_DETACHED_TARGET,
legacyFallback = {
callback.requestShowDetachedTarget(sessionId)
DetachedTargetControlResult(
status = null,
statusLabel = STATUS_OK_LABEL,
targetRuntime = DetachedTargetState(
value = null,
label = TARGET_RUNTIME_DETACHED_SHOWN_LABEL,
),
detachedDisplayId = null,
message = "Used legacy detached show callback.",
)
},
)
}
fun hideDetachedTarget(
callback: GenieService.Callback,
sessionId: String,
): DetachedTargetControlResult {
return invokeControl(
callback = callback,
sessionId = sessionId,
methodName = METHOD_HIDE_DETACHED_TARGET,
legacyFallback = {
callback.requestHideDetachedTarget(sessionId)
DetachedTargetControlResult(
status = null,
statusLabel = STATUS_OK_LABEL,
targetRuntime = DetachedTargetState(
value = null,
label = TARGET_RUNTIME_DETACHED_HIDDEN_LABEL,
),
detachedDisplayId = null,
message = "Used legacy detached hide callback.",
)
},
)
}
fun attachDetachedTarget(
callback: GenieService.Callback,
sessionId: String,
): DetachedTargetControlResult {
return invokeControl(
callback = callback,
sessionId = sessionId,
methodName = METHOD_ATTACH_DETACHED_TARGET,
legacyFallback = {
callback.requestAttachTarget(sessionId)
DetachedTargetControlResult(
status = null,
statusLabel = STATUS_OK_LABEL,
targetRuntime = DetachedTargetState(
value = null,
label = TARGET_RUNTIME_ATTACHED_LABEL,
),
detachedDisplayId = null,
message = "Used legacy target attach callback.",
)
},
)
}
fun closeDetachedTarget(
callback: GenieService.Callback,
sessionId: String,
): DetachedTargetControlResult {
return invokeControl(
callback = callback,
sessionId = sessionId,
methodName = METHOD_CLOSE_DETACHED_TARGET,
legacyFallback = {
callback.requestCloseDetachedTarget(sessionId)
DetachedTargetControlResult(
status = null,
statusLabel = STATUS_OK_LABEL,
targetRuntime = DetachedTargetState(
value = null,
label = TARGET_RUNTIME_NONE_LABEL,
),
detachedDisplayId = null,
message = "Used legacy detached close callback.",
)
},
)
}
fun captureDetachedTargetFrameResult(
callback: GenieService.Callback,
sessionId: String,
): DetachedTargetCaptureResult {
val method = findOptionalMethod(
callback.javaClass,
METHOD_CAPTURE_DETACHED_TARGET_FRAME_RESULT,
String::class.java,
)
if (method == null) {
val captureResult = callback.captureDetachedTargetFrame(sessionId)
return DetachedTargetCaptureResult(
status = null,
statusLabel = if (captureResult != null) STATUS_OK_LABEL else STATUS_CAPTURE_FAILED_LABEL,
targetRuntime = DetachedTargetState(
value = null,
label = if (captureResult != null) {
TARGET_RUNTIME_DETACHED_HIDDEN_LABEL
} else {
TARGET_RUNTIME_NONE_LABEL
},
),
detachedDisplayId = null,
message = if (captureResult != null) {
"Used legacy detached-frame capture callback."
} else {
"Legacy detached-frame capture returned null."
},
captureResult = captureResult,
)
}
val resultObject = invokeChecked {
method.invoke(callback, sessionId)
} ?: return DetachedTargetCaptureResult(
status = null,
statusLabel = STATUS_CAPTURE_FAILED_LABEL,
targetRuntime = DetachedTargetState(
value = null,
label = TARGET_RUNTIME_NONE_LABEL,
),
detachedDisplayId = null,
message = "Detached target capture returned null result object.",
captureResult = null,
)
return parseCaptureResult(resultObject)
}
private fun invokeControl(
callback: GenieService.Callback,
sessionId: String,
methodName: String,
legacyFallback: () -> DetachedTargetControlResult,
): DetachedTargetControlResult {
val method = findOptionalMethod(callback.javaClass, methodName, String::class.java)
if (method == null) {
return legacyFallback()
}
val resultObject = invokeChecked {
method.invoke(callback, sessionId)
} ?: return DetachedTargetControlResult(
status = null,
statusLabel = STATUS_INTERNAL_ERROR_LABEL,
targetRuntime = DetachedTargetState(
value = null,
label = TARGET_RUNTIME_NONE_LABEL,
),
detachedDisplayId = null,
message = "$methodName returned null result object.",
)
return parseControlResult(resultObject)
}
private fun parseControlResult(resultObject: Any): DetachedTargetControlResult {
val resultClass = resultObject.javaClass
val status = invokeChecked {
findRequiredMethod(resultClass, METHOD_GET_STATUS).invoke(resultObject) as? Int
}
return DetachedTargetControlResult(
status = status,
statusLabel = statusLabel(resultClass, status),
targetRuntime = parseTargetRuntime(resultObject),
detachedDisplayId = optionalInt(resultObject, METHOD_GET_DETACHED_DISPLAY_ID),
message = optionalString(resultObject, METHOD_GET_MESSAGE),
)
}
private fun parseCaptureResult(resultObject: Any): DetachedTargetCaptureResult {
val resultClass = resultObject.javaClass
val status = invokeChecked {
findRequiredMethod(resultClass, METHOD_GET_STATUS).invoke(resultObject) as? Int
}
val captureGetter = findOptionalMethod(resultClass, "getCaptureResult")
?: findOptionalMethod(resultClass, "getScreenCaptureResult")
val captureResult = captureGetter?.let { method ->
invokeChecked { method.invoke(resultObject) as? ScreenCapture.ScreenCaptureResult }
}
return DetachedTargetCaptureResult(
status = status,
statusLabel = statusLabel(resultClass, status),
targetRuntime = parseTargetRuntime(resultObject),
detachedDisplayId = optionalInt(resultObject, METHOD_GET_DETACHED_DISPLAY_ID),
message = optionalString(resultObject, METHOD_GET_MESSAGE),
captureResult = captureResult,
)
}
private fun parseTargetRuntime(resultObject: Any): DetachedTargetState {
val runtime = optionalInt(resultObject, METHOD_GET_TARGET_RUNTIME)
return if (runtime != null) {
DetachedTargetState(
value = runtime,
label = targetRuntimeLabels[runtime] ?: runtime.toString(),
)
} else {
DetachedTargetState(
value = null,
label = TARGET_RUNTIME_NONE_LABEL,
)
}
}
private fun statusLabel(
resultClass: Class<*>,
status: Int?,
): String {
if (status == null) {
return STATUS_INTERNAL_ERROR_LABEL
}
return staticIntFields(resultClass, "STATUS_")[status] ?: status.toString()
}
private fun optionalInt(
target: Any,
methodName: String,
): Int? {
val method = findOptionalMethod(target.javaClass, methodName) ?: return null
return invokeChecked { method.invoke(target) as? Int }
}
private fun optionalString(
target: Any,
methodName: String,
): String? {
val method = findOptionalMethod(target.javaClass, methodName) ?: return null
return invokeChecked { method.invoke(target) as? String }?.ifBlank { null }
}
private fun staticIntFields(
clazz: Class<*>,
prefix: String,
): Map<Int, String> {
return clazz.fields
.filter(::isStaticIntField)
.filter { field -> field.name.startsWith(prefix) }
.associate { field ->
field.getInt(null) to field.name
}
}
private fun isStaticIntField(field: Field): Boolean {
return Modifier.isStatic(field.modifiers) && field.type == Int::class.javaPrimitiveType
}
private fun findRequiredMethod(
clazz: Class<*>,
name: String,
vararg parameterTypes: Class<*>,
): Method {
return clazz.getMethod(name, *parameterTypes)
}
private fun findOptionalMethod(
clazz: Class<*>,
name: String,
vararg parameterTypes: Class<*>,
): Method? {
return runCatching {
clazz.getMethod(name, *parameterTypes)
}.getOrNull()
}
private fun <T> invokeChecked(block: () -> T): T {
try {
return block()
} catch (err: InvocationTargetException) {
throw err.targetException ?: err
}
}
}

View File

@@ -0,0 +1,543 @@
package com.openai.codex.bridge
import android.app.agent.AgentManager
import android.app.agent.GenieService
import android.os.Bundle
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.lang.reflect.Constructor
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import java.nio.charset.StandardCharsets
object FrameworkSessionTransportCompat {
private const val NETWORK_CONFIG_CLASS_NAME = "android.app.agent.AgentSessionNetworkConfig"
private const val HTTP_BRIDGE_CLASS_NAME = "android.app.agent.FrameworkSessionHttpBridge"
private const val HTTP_EXCHANGE_CLASS_NAME = "android.app.agent.FrameworkHttpExchange"
private const val HTTP_REQUEST_HEAD_CLASS_NAME = "android.app.agent.FrameworkHttpRequestHead"
private const val HTTP_RESPONSE_HEAD_CLASS_NAME = "android.app.agent.FrameworkHttpResponseHead"
private const val HTTP_RESPONSE_HEAD_RESULT_CLASS_NAME = "android.app.agent.FrameworkHttpResponseHeadResult"
private const val OPEN_EXCHANGE_METHOD = "openExchange"
private const val OPEN_REQUEST_BODY_OUTPUT_STREAM_METHOD = "openRequestBodyOutputStream"
private const val AWAIT_RESPONSE_HEAD_METHOD = "awaitResponseHead"
private const val OPEN_RESPONSE_BODY_INPUT_STREAM_METHOD = "openResponseBodyInputStream"
private const val CANCEL_METHOD = "cancel"
private const val SET_SESSION_NETWORK_CONFIG_METHOD = "setSessionNetworkConfig"
private const val AGENT_OPEN_EXCHANGE_METHOD = "openFrameworkHttpExchange"
private const val AGENT_AWAIT_RESPONSE_HEAD_METHOD = "awaitFrameworkHttpResponseHead"
private const val AGENT_CANCEL_EXCHANGE_METHOD = "cancelFrameworkHttpExchange"
private const val STATUS_OK_FIELD_NAME = "STATUS_OK"
private const val READ_BUFFER_BYTES = 8192
private const val WRITE_BUFFER_BYTES = 8192
data class SessionNetworkConfig(
val baseUrl: String,
val defaultHeaders: Bundle,
val connectTimeoutMillis: Int,
val readTimeoutMillis: Int,
)
data class HttpRequest(
val method: String,
val path: String,
val headers: Bundle,
val body: ByteArray,
)
data class HttpResponse(
val statusCode: Int,
val headers: Bundle,
val body: ByteArray,
val bodyString: String,
)
private data class HttpExchange(
val runtimeValue: Any,
)
private data class HttpResponseHead(
val statusCode: Int,
val headers: Bundle,
)
private data class HttpResponseHeadResult(
val status: Int,
val statusName: String,
val responseHead: HttpResponseHead?,
val message: String?,
)
private data class AvailableRuntimeApi(
val setSessionNetworkConfigMethod: Method,
val networkConfigConstructor: Constructor<*>,
val requestHeadConstructor: Constructor<*>,
val exchangeGetIdMethod: Method?,
val agentOpenExchangeMethod: Method,
val agentAwaitResponseHeadMethod: Method,
val agentCancelMethod: Method,
val openExchangeMethod: Method,
val openRequestBodyOutputStreamMethod: Method,
val awaitResponseHeadMethod: Method,
val openResponseBodyInputStreamMethod: Method,
val cancelMethod: Method,
val responseHeadResultGetStatusMethod: Method,
val responseHeadResultGetResponseHeadMethod: Method,
val responseHeadResultGetMessageMethod: Method?,
val responseHeadGetStatusCodeMethod: Method,
val responseHeadGetHeadersMethod: Method,
val statusNamesByValue: Map<Int, String>,
val okStatus: Int,
)
private val runtimeApi: AvailableRuntimeApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED, ::resolveRuntimeApi)
fun setSessionNetworkConfig(
agentManager: AgentManager,
sessionId: String,
config: SessionNetworkConfig,
) {
val platformConfig = invokeChecked {
runtimeApi.networkConfigConstructor.newInstance(
config.baseUrl,
Bundle(config.defaultHeaders),
config.connectTimeoutMillis,
config.readTimeoutMillis,
)
}
invokeChecked {
runtimeApi.setSessionNetworkConfigMethod.invoke(agentManager, sessionId, platformConfig)
}
}
fun executeStreamingRequest(
callback: GenieService.Callback,
sessionId: String,
request: HttpRequest,
): HttpResponse {
val exchange = openExchange(callback, sessionId, request)
var cancelExchange = true
try {
invokeChecked {
runtimeApi.openRequestBodyOutputStreamMethod.invoke(null, exchange.runtimeValue) as OutputStream
}.use { requestBody ->
writeAll(requestBody, request.body)
}
val responseHeadResult = awaitResponseHead(callback, sessionId, exchange)
if (responseHeadResult.status != runtimeApi.okStatus) {
val details = responseHeadResult.message?.takeIf(String::isNotBlank)
val suffix = if (details == null) "" else ": $details"
throw IOException(
"Framework HTTP exchange failed with ${responseHeadResult.statusName}$suffix",
)
}
val responseHead = responseHeadResult.responseHead
?: throw IOException("Framework HTTP exchange succeeded without a response head")
val responseBody = invokeChecked {
runtimeApi.openResponseBodyInputStreamMethod.invoke(null, exchange.runtimeValue) as InputStream
}.use(::readFully)
cancelExchange = false
return HttpResponse(
statusCode = responseHead.statusCode,
headers = responseHead.headers,
body = responseBody,
bodyString = responseBody.toString(StandardCharsets.UTF_8),
)
} finally {
if (cancelExchange) {
runCatching {
invokeChecked {
runtimeApi.cancelMethod.invoke(null, callback, sessionId, exchange.runtimeValue)
}
}
}
}
}
fun executeStreamingRequest(
agentManager: AgentManager,
sessionId: String,
request: HttpRequest,
): HttpResponse {
val exchange = openExchange(agentManager, sessionId, request)
var cancelExchange = true
try {
invokeChecked {
runtimeApi.openRequestBodyOutputStreamMethod.invoke(null, exchange.runtimeValue) as OutputStream
}.use { requestBody ->
writeAll(requestBody, request.body)
}
val responseHeadResult = awaitResponseHead(agentManager, sessionId, exchange)
if (responseHeadResult.status != runtimeApi.okStatus) {
val details = responseHeadResult.message?.takeIf(String::isNotBlank)
val suffix = if (details == null) "" else ": $details"
throw IOException(
"Framework HTTP exchange failed with ${responseHeadResult.statusName}$suffix",
)
}
val responseHead = responseHeadResult.responseHead
?: throw IOException("Framework HTTP exchange succeeded without a response head")
val responseBody = invokeChecked {
runtimeApi.openResponseBodyInputStreamMethod.invoke(null, exchange.runtimeValue) as InputStream
}.use(::readFully)
cancelExchange = false
return HttpResponse(
statusCode = responseHead.statusCode,
headers = responseHead.headers,
body = responseBody,
bodyString = responseBody.toString(StandardCharsets.UTF_8),
)
} finally {
if (cancelExchange) {
runCatching {
invokeChecked {
runtimeApi.agentCancelMethod.invoke(
agentManager,
sessionId,
agentExchangeArgument(runtimeApi.agentCancelMethod, exchange),
)
}
}
}
}
}
private fun openExchange(
callback: GenieService.Callback,
sessionId: String,
request: HttpRequest,
): HttpExchange {
val requestHead = invokeChecked {
runtimeApi.requestHeadConstructor.newInstance(
request.method,
request.path,
Bundle(request.headers),
)
}
val runtimeExchange = invokeChecked {
runtimeApi.openExchangeMethod.invoke(null, callback, sessionId, requestHead)
?: throw IOException("Framework HTTP exchange opened with no exchange handle")
}
return HttpExchange(runtimeExchange)
}
private fun openExchange(
agentManager: AgentManager,
sessionId: String,
request: HttpRequest,
): HttpExchange {
val requestHead = invokeChecked {
runtimeApi.requestHeadConstructor.newInstance(
request.method,
request.path,
Bundle(request.headers),
)
}
val runtimeExchange = invokeChecked {
runtimeApi.agentOpenExchangeMethod.invoke(agentManager, sessionId, requestHead)
?: throw IOException("Framework HTTP exchange opened with no exchange handle")
}
return HttpExchange(runtimeExchange)
}
private fun awaitResponseHead(
callback: GenieService.Callback,
sessionId: String,
exchange: HttpExchange,
): HttpResponseHeadResult {
val resultObject = invokeChecked {
runtimeApi.awaitResponseHeadMethod.invoke(null, callback, sessionId, exchange.runtimeValue)
}
val status = invokeChecked {
runtimeApi.responseHeadResultGetStatusMethod.invoke(resultObject) as Int
}
val responseHeadObject = invokeChecked {
runtimeApi.responseHeadResultGetResponseHeadMethod.invoke(resultObject)
}
val responseHead = if (responseHeadObject == null) {
null
} else {
val statusCode = invokeChecked {
runtimeApi.responseHeadGetStatusCodeMethod.invoke(responseHeadObject) as Int
}
val headers = invokeChecked {
runtimeApi.responseHeadGetHeadersMethod.invoke(responseHeadObject) as? Bundle
} ?: Bundle.EMPTY
HttpResponseHead(
statusCode = statusCode,
headers = Bundle(headers),
)
}
val message = runtimeApi.responseHeadResultGetMessageMethod?.let { method ->
invokeChecked {
method.invoke(resultObject) as? String
}
}?.ifBlank { null }
return HttpResponseHeadResult(
status = status,
statusName = runtimeApi.statusNamesByValue[status] ?: "STATUS_$status",
responseHead = responseHead,
message = message,
)
}
private fun awaitResponseHead(
agentManager: AgentManager,
sessionId: String,
exchange: HttpExchange,
): HttpResponseHeadResult {
val resultObject = invokeChecked {
runtimeApi.agentAwaitResponseHeadMethod.invoke(
agentManager,
sessionId,
agentExchangeArgument(runtimeApi.agentAwaitResponseHeadMethod, exchange),
)
}
val status = invokeChecked {
runtimeApi.responseHeadResultGetStatusMethod.invoke(resultObject) as Int
}
val responseHeadObject = invokeChecked {
runtimeApi.responseHeadResultGetResponseHeadMethod.invoke(resultObject)
}
val responseHead = if (responseHeadObject == null) {
null
} else {
val statusCode = invokeChecked {
runtimeApi.responseHeadGetStatusCodeMethod.invoke(responseHeadObject) as Int
}
val headers = invokeChecked {
runtimeApi.responseHeadGetHeadersMethod.invoke(responseHeadObject) as? Bundle
} ?: Bundle.EMPTY
HttpResponseHead(
statusCode = statusCode,
headers = Bundle(headers),
)
}
val message = runtimeApi.responseHeadResultGetMessageMethod?.let { method ->
invokeChecked {
method.invoke(resultObject) as? String
}
}?.ifBlank { null }
return HttpResponseHeadResult(
status = status,
statusName = runtimeApi.statusNamesByValue[status] ?: "STATUS_$status",
responseHead = responseHead,
message = message,
)
}
private fun resolveRuntimeApi(): AvailableRuntimeApi {
return try {
val networkConfigClass = Class.forName(NETWORK_CONFIG_CLASS_NAME)
val httpBridgeClass = Class.forName(HTTP_BRIDGE_CLASS_NAME)
val exchangeClass = Class.forName(HTTP_EXCHANGE_CLASS_NAME)
val requestHeadClass = Class.forName(HTTP_REQUEST_HEAD_CLASS_NAME)
val responseHeadClass = Class.forName(HTTP_RESPONSE_HEAD_CLASS_NAME)
val responseHeadResultClass = Class.forName(HTTP_RESPONSE_HEAD_RESULT_CLASS_NAME)
val statusNamesByValue = responseHeadResultClass.fields
.filter { field ->
Modifier.isStatic(field.modifiers) &&
field.type == Int::class.javaPrimitiveType &&
field.name.startsWith("STATUS_")
}
.associate { field ->
field.getInt(null) to field.name
}
val okStatus = responseHeadResultClass.getField(STATUS_OK_FIELD_NAME).getInt(null)
AvailableRuntimeApi(
setSessionNetworkConfigMethod = AgentManager::class.java.getMethod(
SET_SESSION_NETWORK_CONFIG_METHOD,
String::class.java,
networkConfigClass,
),
networkConfigConstructor = networkConfigClass.getConstructor(
String::class.java,
Bundle::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
),
requestHeadConstructor = requestHeadClass.getConstructor(
String::class.java,
String::class.java,
Bundle::class.java,
),
exchangeGetIdMethod = exchangeClass.methods.firstOrNull { method ->
method.parameterCount == 0 &&
method.returnType == String::class.java &&
(method.name == "getExchangeId" || method.name == "getId")
},
agentOpenExchangeMethod = requireMethod(
owner = AgentManager::class.java,
name = AGENT_OPEN_EXCHANGE_METHOD,
String::class.java,
requestHeadClass,
),
agentAwaitResponseHeadMethod = requireOneOfMethods(
owner = AgentManager::class.java,
name = AGENT_AWAIT_RESPONSE_HEAD_METHOD,
listOf(
arrayOf(String::class.java, exchangeClass),
arrayOf(String::class.java, String::class.java),
),
),
agentCancelMethod = requireOneOfMethods(
owner = AgentManager::class.java,
name = AGENT_CANCEL_EXCHANGE_METHOD,
listOf(
arrayOf(String::class.java, exchangeClass),
arrayOf(String::class.java, String::class.java),
),
),
openExchangeMethod = requireMethod(
owner = httpBridgeClass,
name = OPEN_EXCHANGE_METHOD,
GenieService.Callback::class.java,
String::class.java,
requestHeadClass,
),
openRequestBodyOutputStreamMethod = requireMethod(
owner = httpBridgeClass,
name = OPEN_REQUEST_BODY_OUTPUT_STREAM_METHOD,
exchangeClass,
),
awaitResponseHeadMethod = requireMethod(
owner = httpBridgeClass,
name = AWAIT_RESPONSE_HEAD_METHOD,
GenieService.Callback::class.java,
String::class.java,
exchangeClass,
),
openResponseBodyInputStreamMethod = requireMethod(
owner = httpBridgeClass,
name = OPEN_RESPONSE_BODY_INPUT_STREAM_METHOD,
exchangeClass,
),
cancelMethod = requireMethod(
owner = httpBridgeClass,
name = CANCEL_METHOD,
GenieService.Callback::class.java,
String::class.java,
exchangeClass,
),
responseHeadResultGetStatusMethod = requireMethod(
owner = responseHeadResultClass,
name = "getStatus",
),
responseHeadResultGetResponseHeadMethod = requireMethod(
owner = responseHeadResultClass,
name = "getResponseHead",
),
responseHeadResultGetMessageMethod = responseHeadResultClass.methods.firstOrNull { method ->
method.name == "getMessage" && method.parameterCount == 0
},
responseHeadGetStatusCodeMethod = requireMethod(
owner = responseHeadClass,
name = "getStatusCode",
),
responseHeadGetHeadersMethod = requireMethod(
owner = responseHeadClass,
name = "getHeaders",
),
statusNamesByValue = statusNamesByValue,
okStatus = okStatus,
)
} catch (err: ReflectiveOperationException) {
throw IllegalStateException(
"Framework-owned HTTP streaming APIs are unavailable. The device runtime and AgentSDK are out of sync.",
err,
)
}
}
private fun requireMethod(
owner: Class<*>,
name: String,
vararg parameterTypes: Class<*>,
): Method {
return owner.methods.firstOrNull { method ->
method.name == name &&
method.parameterCount == parameterTypes.size &&
method.parameterTypes.contentEquals(parameterTypes)
} ?: throw NoSuchMethodException(
"${owner.name}#$name(${parameterTypes.joinToString { it.name }})",
)
}
private fun requireOneOfMethods(
owner: Class<*>,
name: String,
parameterTypeOptions: List<Array<Class<*>>>,
): Method {
return owner.methods.firstOrNull { method ->
method.name == name &&
parameterTypeOptions.any { option ->
method.parameterCount == option.size &&
method.parameterTypes.contentEquals(option)
}
} ?: throw NoSuchMethodException(
buildString {
append(owner.name)
append('#')
append(name)
append('(')
append(
parameterTypeOptions.joinToString(" | ") { option ->
option.joinToString(", ") { it.name }
},
)
append(')')
},
)
}
private fun agentExchangeArgument(
agentMethod: Method,
exchange: HttpExchange,
): Any {
return if (agentMethod.parameterTypes[1] == String::class.java) {
val exchangeIdMethod = runtimeApi.exchangeGetIdMethod
?: throw IOException("Framework HTTP exchange does not expose an exchange id")
invokeChecked {
exchangeIdMethod.invoke(exchange.runtimeValue) as? String
}?.takeIf(String::isNotBlank)
?: throw IOException("Framework HTTP exchange returned a blank exchange id")
} else {
exchange.runtimeValue
}
}
private fun writeAll(
output: OutputStream,
bytes: ByteArray,
) {
var offset = 0
while (offset < bytes.size) {
val chunkSize = minOf(WRITE_BUFFER_BYTES, bytes.size - offset)
output.write(bytes, offset, chunkSize)
offset += chunkSize
}
output.flush()
}
private fun readFully(input: InputStream): ByteArray {
val buffer = ByteArray(READ_BUFFER_BYTES)
val bytes = ByteArrayOutputStream()
while (true) {
val read = input.read(buffer)
if (read == -1) {
return bytes.toByteArray()
}
bytes.write(buffer, 0, read)
}
}
private fun <T> invokeChecked(block: () -> T): T {
try {
return block()
} catch (err: InvocationTargetException) {
throw err.targetException ?: err
}
}
}

View File

@@ -0,0 +1,64 @@
package com.openai.codex.bridge;
import android.content.Context;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
public final class HostedCodexConfig {
public static final String ANDROID_HTTP_PROVIDER_ID = "android-openai-http";
public static final String AGENTS_FILENAME = "AGENTS.md";
private static final String BUNDLED_AGENTS_ASSET_PATH = AGENTS_FILENAME;
private HostedCodexConfig() {}
public static void write(Context context, File codexHome, String baseUrl) throws IOException {
ensureCodexHome(codexHome);
installBundledAgentsFile(context, codexHome);
String escapedBaseUrl = baseUrl
.replace("\\", "\\\\")
.replace("\"", "\\\"");
String configToml = "model_provider = \"" + ANDROID_HTTP_PROVIDER_ID + "\"\n\n"
+ "[model_providers." + ANDROID_HTTP_PROVIDER_ID + "]\n"
+ "name = \"Android OpenAI HTTP\"\n"
+ "base_url = \"" + escapedBaseUrl + "\"\n"
+ "wire_api = \"responses\"\n"
+ "requires_openai_auth = true\n"
+ "supports_websockets = false\n";
Files.write(
new File(codexHome, "config.toml").toPath(),
configToml.getBytes(StandardCharsets.UTF_8));
}
public static void installBundledAgentsFile(Context context, File codexHome) throws IOException {
installAgentsFile(codexHome, readBundledAgentsMarkdown(context));
}
public static void installAgentsFile(File codexHome, String agentsMarkdown) throws IOException {
ensureCodexHome(codexHome);
Files.write(
new File(codexHome, AGENTS_FILENAME).toPath(),
agentsMarkdown.getBytes(StandardCharsets.UTF_8));
}
public static String readBundledAgentsMarkdown(Context context) throws IOException {
try (InputStream inputStream = context.getAssets().open(BUNDLED_AGENTS_ASSET_PATH)) {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
public static String readInstalledAgentsMarkdown(File codexHome) throws IOException {
return new String(
Files.readAllBytes(new File(codexHome, AGENTS_FILENAME).toPath()),
StandardCharsets.UTF_8);
}
private static void ensureCodexHome(File codexHome) throws IOException {
if (!codexHome.isDirectory() && !codexHome.mkdirs()) {
throw new IOException("failed to create codex home at " + codexHome.getAbsolutePath());
}
}
}

View File

@@ -0,0 +1,17 @@
package com.openai.codex.bridge
data class SessionExecutionSettings(
val model: String?,
val reasoningEffort: String?,
) {
companion object {
val default = SessionExecutionSettings(
model = null,
reasoningEffort = null,
)
}
fun isDefault(): Boolean {
return model.isNullOrBlank() && reasoningEffort.isNullOrBlank()
}
}

View File

@@ -0,0 +1,35 @@
package com.openai.codex.bridge;
import static org.junit.Assert.assertEquals;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import org.junit.Test;
public final class HostedCodexConfigTest {
@Test
public void installAgentsFileWritesExpectedGuidance() throws Exception {
File codexHome = Files.createTempDirectory("hosted-codex-home").toFile();
String agentsMarkdown = "# Runtime Notes\n\n- prefer `cmd`\n";
HostedCodexConfig.installAgentsFile(codexHome, agentsMarkdown);
String installedMarkdown =
new String(
Files.readAllBytes(new File(codexHome, "AGENTS.md").toPath()),
StandardCharsets.UTF_8);
assertEquals(agentsMarkdown, installedMarkdown);
}
@Test
public void readInstalledAgentsMarkdownReadsExistingFile() throws Exception {
File codexHome = Files.createTempDirectory("hosted-codex-agents").toFile();
HostedCodexConfig.installAgentsFile(codexHome, "# Agent file\n");
String installedMarkdown = HostedCodexConfig.readInstalledAgentsMarkdown(codexHome);
assertEquals("# Agent file\n", installedMarkdown);
}
}

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Build the packaged Codex binary plus the Android Agent and Genie APKs.
Usage:
build-agent-genie-apks.sh [--agent-sdk-zip PATH] [--variant debug|release] [--skip-lto]
Options:
--agent-sdk-zip PATH Path to android-agent-platform-stub-sdk.zip.
Defaults to $ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP.
--variant VALUE APK variant to build: debug or release. Defaults to debug.
--skip-lto Set CODEX_ANDROID_SKIP_LTO=1 for faster local builds.
-h, --help Show this help text.
EOF
}
fail() {
echo "error: $*" >&2
exit 1
}
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/.." && pwd)"
stub_sdk_zip="${ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP:-}"
variant="debug"
while [[ $# -gt 0 ]]; do
case "$1" in
--agent-sdk-zip)
shift
[[ $# -gt 0 ]] || fail "--agent-sdk-zip requires a path"
stub_sdk_zip="$1"
;;
--variant)
shift
[[ $# -gt 0 ]] || fail "--variant requires a value"
variant="$1"
;;
--skip-lto)
export CODEX_ANDROID_SKIP_LTO=1
;;
-h|--help)
usage
exit 0
;;
*)
fail "unknown argument: $1"
;;
esac
shift
done
[[ -n "$stub_sdk_zip" ]] || fail "set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or pass --agent-sdk-zip"
[[ -f "$stub_sdk_zip" ]] || fail "stub SDK zip not found: $stub_sdk_zip"
[[ "$variant" == "debug" || "$variant" == "release" ]] || fail "--variant must be debug or release"
case "$variant" in
debug)
gradle_task_variant="Debug"
;;
release)
gradle_task_variant="Release"
;;
esac
agent_apk="$script_dir/app/build/outputs/apk/$variant/app-$variant.apk"
genie_apk="$script_dir/genie/build/outputs/apk/$variant/genie-$variant.apk"
if [[ "$variant" == "release" ]]; then
agent_apk="$script_dir/app/build/outputs/apk/$variant/app-$variant-unsigned.apk"
genie_apk="$script_dir/genie/build/outputs/apk/$variant/genie-$variant-unsigned.apk"
fi
echo "Building Android Agent and Genie APKs"
(
cd "$script_dir"
ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP="$stub_sdk_zip" \
./gradlew ":app:assemble$gradle_task_variant" ":genie:assemble$gradle_task_variant" \
-PagentPlatformStubSdkZip="$stub_sdk_zip"
)
cat <<EOF
Build complete.
Agent APK:
$agent_apk
Genie APK:
$genie_apk
EOF

41
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,41 @@
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.PathSensitivity
plugins {
id("com.android.application") version "9.0.0" apply false
}
val repoRoot = rootProject.projectDir.parentFile
val skipAndroidLto = providers
.gradleProperty("codexAndroidSkipLto")
.orElse(providers.environmentVariable("CODEX_ANDROID_SKIP_LTO"))
.orNull
?.let { it == "1" || it.equals("true", ignoreCase = true) }
?: false
val codexCargoProfileDir = if (skipAndroidLto) "android-release-no-lto" else "release"
val codexTargets = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"x86_64" to "x86_64-linux-android",
)
tasks.register<Exec>("buildCodexCliNative") {
group = "build"
description = "Build the Android codex binary packaged into the Agent and Genie APKs."
workingDir = repoRoot
commandLine("just", "android-build")
if (skipAndroidLto) {
environment("CODEX_ANDROID_SKIP_LTO", "1")
}
inputs.files(
fileTree(repoRoot.resolve("codex-rs")) {
exclude("target/**")
},
).withPathSensitivity(PathSensitivity.RELATIVE)
inputs.file(repoRoot.resolve("justfile"))
.withPathSensitivity(PathSensitivity.RELATIVE)
outputs.files(
codexTargets.values.map { triple ->
repoRoot.resolve("codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
},
)
}

View File

@@ -0,0 +1,122 @@
import org.gradle.api.GradleException
import org.gradle.api.tasks.Sync
plugins {
id("com.android.application")
}
val minAndroidJavaVersion = 17
val maxAndroidJavaVersion = 21
val hostJavaMajorVersion = JavaVersion.current().majorVersion.toIntOrNull()
?: throw GradleException("Unable to determine Java version from ${JavaVersion.current()}.")
if (hostJavaMajorVersion < minAndroidJavaVersion) {
throw GradleException(
"Android Genie build requires Java ${minAndroidJavaVersion}+ (tested through Java ${maxAndroidJavaVersion}). Found Java ${hostJavaMajorVersion}."
)
}
val androidJavaTargetVersion = hostJavaMajorVersion.coerceAtMost(maxAndroidJavaVersion)
val androidJavaVersion = JavaVersion.toVersion(androidJavaTargetVersion)
val agentPlatformStubSdkZip = providers
.gradleProperty("agentPlatformStubSdkZip")
.orElse(providers.environmentVariable("ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP"))
val skipAndroidLto = providers
.gradleProperty("codexAndroidSkipLto")
.orElse(providers.environmentVariable("CODEX_ANDROID_SKIP_LTO"))
.orNull
?.let { it == "1" || it.equals("true", ignoreCase = true) }
?: false
val codexCargoProfileDir = if (skipAndroidLto) "android-release-no-lto" else "release"
val extractedAgentPlatformJar = layout.buildDirectory.file(
"generated/agent-platform/android-agent-platform-stub-sdk.jar"
)
val repoRoot = rootProject.projectDir.parentFile
val codexTargets = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"x86_64" to "x86_64-linux-android",
)
val codexJniDir = layout.buildDirectory.dir("generated/codex-jni")
android {
namespace = "com.openai.codex.genie"
compileSdk = 34
defaultConfig {
applicationId = "com.openai.codex.genie"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = androidJavaVersion
targetCompatibility = androidJavaVersion
}
packaging {
jniLibs.useLegacyPackaging = true
}
}
val extractAgentPlatformStubSdk = tasks.register<Sync>("extractAgentPlatformStubSdk") {
val sdkZip = agentPlatformStubSdkZip.orNull
?: throw GradleException(
"Set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or -PagentPlatformStubSdkZip to the Android Agent Platform stub SDK zip."
)
val outputDir = extractedAgentPlatformJar.get().asFile.parentFile
from(zipTree(sdkZip)) {
include("payloads/compile_only/android-agent-platform-stub-sdk.jar")
eachFile { path = name }
includeEmptyDirs = false
}
into(outputDir)
}
val syncCodexCliJniLibs = tasks.register<Sync>("syncCodexCliJniLibs") {
val outputDir = codexJniDir
into(outputDir)
dependsOn(rootProject.tasks.named("buildCodexCliNative"))
codexTargets.forEach { (abi, triple) ->
val binary = file("${repoRoot}/codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
from(binary) {
into(abi)
rename { "libcodex.so" }
}
}
doFirst {
codexTargets.forEach { (abi, triple) ->
val binary = file("${repoRoot}/codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
if (!binary.exists()) {
throw GradleException(
"Missing codex binary for ${abi} at ${binary}. The Gradle native build task should have produced it."
)
}
}
}
}
android.sourceSets["main"].jniLibs.srcDir(codexJniDir.get().asFile)
tasks.named("preBuild").configure {
dependsOn(extractAgentPlatformStubSdk)
dependsOn(syncCodexCliJniLibs)
}
dependencies {
implementation(project(":bridge"))
compileOnly(files(extractedAgentPlatformJar))
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
}

Some files were not shown because too many files have changed in this diff Show More