Compare commits

..

144 Commits

Author SHA1 Message Date
Iliyan Malchev
c122ff283f Retry Android desktop bridge startup on bind races
Co-authored-by: Codex <noreply@openai.com>
2026-03-30 11:01:08 -07:00
Iliyan Malchev
08b3c19b8c Keep Android desktop attaches alive
Co-authored-by: Codex <noreply@openai.com>
2026-03-30 11:00:43 -07:00
Iliyan Malchev
bf9dcb7560 Keep Android remote attaches alive across follow-up turns
Co-authored-by: Codex <noreply@openai.com>
2026-03-30 08:13:36 -07:00
Iliyan Malchev
2456260f1c Queue Android Genie desktop proxy callbacks\n\nCo-authored-by: Codex <noreply@openai.com> 2026-03-30 07:59:53 -07:00
Iliyan Malchev
89152756f7 Preserve paused Android child sessions after planner resets
Co-authored-by: Codex <noreply@openai.com>
2026-03-30 07:45:29 -07:00
Iliyan Malchev
6b57b70b2c Keep remote app-server sessions alive across websocket pings
Co-authored-by: Codex <noreply@openai.com>
2026-03-30 07:45:07 -07:00
Iliyan Malchev
9afddb665f Make Android Genie failures recoverable
Treat detached-session relaunch violations and recoverable hosted I/O failures as non-terminal when a fresh hosted runtime can still be bootstrapped. Document the new recovery semantics in the Android design notes.

Co-authored-by: Codex <noreply@openai.com>
2026-03-29 22:22:17 -07:00
Iliyan Malchev
0f4ddd4ba2 Keep attached staged Android Genies idle until first prompt
Co-authored-by: Codex <noreply@openai.com>
2026-03-29 15:22:25 -07:00
Iliyan Malchev
e2ae1d588b Fix Android desktop session attach follow-ups
Co-authored-by: Codex <noreply@openai.com>
2026-03-29 13:55:00 -07:00
Iliyan Malchev
9218662e6c Recover Android desktop bridge after cold start
Co-authored-by: Codex <noreply@openai.com>
2026-03-29 13:06:25 -07:00
Iliyan Malchev
b0f34a5a3c Mirror Android framework events in attached sessions
Co-authored-by: Codex <noreply@openai.com>
2026-03-29 13:06:07 -07:00
Iliyan Malchev
64de95c1e9 Keep Android attach sessions alive for burst input
Co-authored-by: Codex <noreply@openai.com>
2026-03-29 09:40:52 -07:00
Iliyan Malchev
ef28c7a648 Bootstrap Android desktop control without surfacing Agent UI
Co-authored-by: Codex <noreply@openai.com>
2026-03-29 07:21:17 -07:00
Iliyan Malchev
79696c734b Cancel Android parent session trees through the framework
Co-authored-by: Codex <noreply@openai.com>
2026-03-29 07:21:05 -07:00
Iliyan Malchev
545e30f2c5 Hold Android planner children for desktop inspection
Co-authored-by: Codex <noreply@openai.com>
2026-03-29 07:20:59 -07:00
Iliyan Malchev
aa310977a7 Add Android desktop session control
Implement the adb-backed Android desktop control flow for Agent/Genie sessions, including session listing, draft creation, live attach, and desktop-driven follow-up control.

Add idle-runtime attach for CREATED drafts so a session can be created first, attached from the desktop, and then driven from the attached Codex TUI before any initial prompt is sent.

Co-authored-by: Codex <noreply@openai.com>
2026-03-27 16:32:23 -07:00
Iliyan Malchev
8c4afd7516 Add Codex session transfer script
Add a reusable helper to copy filtered Codex rollouts, rewrite saved cwd metadata, and merge matching history entries between hosts.

Co-authored-by: Codex <noreply@openai.com>
2026-03-27 09:45:35 -07:00
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
2295 changed files with 207293 additions and 73041 deletions

View File

@@ -20,6 +20,9 @@ common:windows --host_platform=//:local_windows
common --@rules_cc//cc/toolchains/args/archiver_flags:use_libtool_on_macos=False
common --@llvm//config:experimental_stub_libgcc_s
# We need to use the sh toolchain on windows so we don't send host bash paths to the linux executor.
common:windows --@rules_rust//rust/settings:experimental_use_sh_toolchain_for_bootstrap_process_wrapper
# TODO(zbarsky): rules_rust doesn't implement this flag properly with remote exec...
# common --@rules_rust//rust/settings:pipelined_compilation
@@ -57,99 +60,3 @@ common:remote --jobs=800
# Enable pipelined compilation since we are not bound by local CPU count.
#common:remote --@rules_rust//rust/settings:pipelined_compilation
# GitHub Actions CI configs.
common:ci --remote_download_minimal
common:ci --keep_going
common:ci --verbose_failures
common:ci --build_metadata=REPO_URL=https://github.com/openai/codex.git
common:ci --build_metadata=ROLE=CI
common:ci --build_metadata=VISIBILITY=PUBLIC
# Disable disk cache in CI since we have a remote one and aren't using persistent workers.
common:ci --disk_cache=
# Shared config for the main Bazel CI workflow.
common:ci-bazel --config=ci
common:ci-bazel --build_metadata=TAG_workflow=bazel
# Shared config for Bazel-backed Rust linting.
build:clippy --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect
build:clippy --output_groups=+clippy_checks
build:clippy --@rules_rust//rust/settings:clippy.toml=//codex-rs:clippy.toml
# Keep this deny-list in sync with `codex-rs/Cargo.toml` `[workspace.lints.clippy]`.
# Cargo applies those lint levels to member crates that opt into `[lints] workspace = true`
# in their own `Cargo.toml`, but `rules_rust` Bazel clippy does not read Cargo lint levels.
# `clippy.toml` can configure lint behavior, but it cannot set allow/warn/deny/forbid levels.
build:clippy --@rules_rust//rust/settings:clippy_flag=-Dwarnings
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::expect_used
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::identity_op
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_clamp
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_filter
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_find
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_flatten
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_map
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_memcpy
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_non_exhaustive
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_ok_or
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_range_contains
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_retain
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_strip
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_try_fold
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_unwrap_or
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_borrow
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_borrowed_reference
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_collect
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_late_init
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_option_as_deref
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_question_mark
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_update
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::redundant_clone
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::redundant_closure
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::redundant_closure_for_method_calls
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::redundant_static_lifetimes
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::trivially_copy_pass_by_ref
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::uninlined_format_args
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unnecessary_filter_map
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unnecessary_lazy_evaluations
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unnecessary_sort_by
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unnecessary_to_owned
build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unwrap_used
# Shared config for Bazel-backed argument-comment-lint.
build:argument-comment-lint --aspects=//tools/argument-comment-lint:lint_aspect.bzl%rust_argument_comment_lint_aspect
build:argument-comment-lint --output_groups=argument_comment_lint_checks
build:argument-comment-lint --@rules_rust//rust/toolchain/channel=nightly
# Rearrange caches on Windows so they're on the same volume as the checkout.
common:ci-windows --config=ci-bazel
common:ci-windows --build_metadata=TAG_os=windows
common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache
common:ci-windows --repository_cache=D:/a/.cache/bazel-repo-cache
# We prefer to run the build actions entirely remotely so we can dial up the concurrency.
# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform.
# On linux, we can do a full remote build/test, by targeting the right (x86/arm) runners, so we have coverage of both.
# Linux crossbuilds don't work until we untangle the libc constraint mess.
common:ci-linux --config=ci-bazel
common:ci-linux --build_metadata=TAG_os=linux
common:ci-linux --config=remote
common:ci-linux --strategy=remote
common:ci-linux --platforms=//:rbe
# On mac, we can run all the build actions remotely but test actions locally.
common:ci-macos --config=ci-bazel
common:ci-macos --build_metadata=TAG_os=macos
common:ci-macos --config=remote
common:ci-macos --strategy=remote
common:ci-macos --strategy=TestRunner=darwin-sandbox,local
# Linux-only V8 CI config.
common:ci-v8 --config=ci
common:ci-v8 --build_metadata=TAG_workflow=v8
common:ci-v8 --build_metadata=TAG_os=linux
common:ci-v8 --config=remote
common:ci-v8 --strategy=remote
# Optional per-user local overrides.
try-import %workspace%/user.bazelrc

View File

@@ -3,4 +3,4 @@
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new,*meriyah.umd.min.js
check-hidden = true
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
ignore-words-list = ratatui,ser,iTerm,iterm2,iterm,te,TE,PASE,SEH
ignore-words-list = ratatui,ser,iTerm,iterm2,iterm,te,TE

View File

@@ -1,6 +1,6 @@
---
name: babysit-pr
description: Babysit a GitHub pull request after creation by continuously polling review comments, CI checks/workflow runs, and mergeability state until the PR is merged/closed or user help is required. Diagnose failures, retry likely flaky failures up to 3 times, auto-fix/push branch-related issues when appropriate, and keep watching open PRs so fresh review feedback is surfaced promptly. Use when the user asks Codex to monitor a PR, watch CI, handle review comments, or keep an eye on failures and feedback on an open PR.
description: Babysit a GitHub pull request after creation by continuously polling CI checks/workflow runs, new review comments, and mergeability state until the PR is ready to merge (or merged/closed). Diagnose failures, retry likely flaky failures up to 3 times, auto-fix/push branch-related issues when appropriate, and stop only when user help is required (for example CI infrastructure issues, exhausted flaky retries, or ambiguous/blocking situations). Use when the user asks Codex to monitor a PR, watch CI, handle review comments, or keep an eye on failures and feedback on an open PR.
---
# PR Babysitter
@@ -9,8 +9,8 @@ description: Babysit a GitHub pull request after creation by continuously pollin
Babysit a PR persistently until one of these terminal outcomes occurs:
- The PR is merged or closed.
- CI is successful, there are no unaddressed review comments surfaced by the watcher, required review approval is not blocking merge, and there are no potential merge conflicts (PR is mergeable / not reporting conflict risk).
- A situation requires user help (for example CI infrastructure issues, repeated flaky failures after retry budget is exhausted, permission problems, or ambiguity that cannot be resolved safely).
- Optional handoff milestone: the PR is currently green + mergeable + review-clean. Treat this as a progress state, not a watcher stop, so late-arriving review comments are still surfaced promptly while the PR remains open.
Do not stop merely because a single snapshot returns `idle` while checks are still pending.
@@ -24,20 +24,19 @@ Accept any of the following:
## Core Workflow
1. When the user asks to "monitor"/"watch"/"babysit" a PR, start with the watcher's continuous mode (`--watch`) unless you are intentionally doing a one-shot diagnostic snapshot.
2. Run the watcher script to snapshot PR/review/CI state (or consume each streamed snapshot from `--watch`).
2. Run the watcher script to snapshot PR/CI/review state (or consume each streamed snapshot from `--watch`).
3. Inspect the `actions` list in the JSON response.
4. If `diagnose_ci_failure` is present, inspect failed run logs and classify the failure.
5. If the failure is likely caused by the current branch, patch code locally, commit, and push.
6. If `process_review_comment` is present, inspect surfaced review items and decide whether to address them.
7. If a review item is actionable and correct, patch code locally, commit, push, and then mark the associated review thread/comment as resolved once the fix is on GitHub.
8. If a review item from another author is non-actionable, already addressed, or not valid, post one reply on the comment/thread explaining that decision (for example answering the question or explaining why no change is needed). If the watcher later surfaces your own reply, treat that self-authored item as already handled and do not reply again.
9. If the failure is likely flaky/unrelated and `retry_failed_checks` is present, rerun failed jobs with `--retry-failed-now`.
10. If both actionable review feedback and `retry_failed_checks` are present, prioritize review feedback first; a new commit will retrigger CI, so avoid rerunning flaky checks on the old SHA unless you intentionally defer the review change.
11. On every loop, look for newly surfaced review feedback before acting on CI failures or mergeability state, then verify mergeability / merge-conflict status (for example via `gh pr view`) alongside CI.
12. After any push or rerun action, immediately return to step 1 and continue polling on the updated SHA/state.
13. If you had been using `--watch` before pausing to patch/commit/push, relaunch `--watch` yourself in the same turn immediately after the push (do not wait for the user to re-invoke the skill).
14. Repeat polling until `stop_pr_closed` appears or a user-help-required blocker is reached. A green + review-clean + mergeable PR is a progress milestone, not a reason to stop the watcher while the PR is still open.
15. Maintain terminal/session ownership: while babysitting is active, keep consuming watcher output in the same turn; do not leave a detached `--watch` process running and then end the turn as if monitoring were complete.
7. If a review item is actionable and correct, patch code locally, commit, and push.
8. If the failure is likely flaky/unrelated and `retry_failed_checks` is present, rerun failed jobs with `--retry-failed-now`.
9. If both actionable review feedback and `retry_failed_checks` are present, prioritize review feedback first; a new commit will retrigger CI, so avoid rerunning flaky checks on the old SHA unless you intentionally defer the review change.
10. On every loop, verify mergeability / merge-conflict status (for example via `gh pr view`) in addition to CI and review state.
11. After any push or rerun action, immediately return to step 1 and continue polling on the updated SHA/state.
12. If you had been using `--watch` before pausing to patch/commit/push, relaunch `--watch` yourself in the same turn immediately after the push (do not wait for the user to re-invoke the skill).
13. Repeat polling until the PR is green + review-clean + mergeable, `stop_pr_closed` appears, or a user-help-required blocker is reached.
14. Maintain terminal/session ownership: while babysitting is active, keep consuming watcher output in the same turn; do not leave a detached `--watch` process running and then end the turn as if monitoring were complete.
## Commands
@@ -95,11 +94,10 @@ When you agree with a comment and it is actionable:
1. Patch code locally.
2. Commit with `codex: address PR review feedback (#<n>)`.
3. Push to the PR head branch.
4. After the push succeeds, mark the associated GitHub review thread/comment as resolved.
5. Resume watching on the new SHA immediately (do not stop after reporting the push).
6. If monitoring was running in `--watch` mode, restart `--watch` immediately after the push in the same turn; do not wait for the user to ask again.
4. Resume watching on the new SHA immediately (do not stop after reporting the push).
5. If monitoring was running in `--watch` mode, restart `--watch` immediately after the push in the same turn; do not wait for the user to ask again.
If you disagree or the comment is non-actionable/already addressed, reply once directly on the GitHub comment/thread so the reviewer gets an explicit answer, then continue the watcher loop. If the watcher later surfaces your own reply because the authenticated operator is treated as a trusted review author, treat that self-authored item as already handled and do not reply again.
If you disagree or the comment is non-actionable/already addressed, record it as handled by continuing the watcher loop (the script de-duplicates surfaced items via state after surfacing them).
If a code review comment/thread is already marked as resolved in GitHub, treat it as non-actionable and safely ignore it unless new unresolved follow-up feedback appears.
## Git Safety Rules
@@ -126,14 +124,13 @@ Use this loop in a live Codex session:
3. First check whether the PR is now merged or otherwise closed; if so, report that terminal state and stop polling immediately.
4. Check CI summary, new review items, and mergeability/conflict status.
5. Diagnose CI failures and classify branch-related vs flaky/unrelated.
6. For each surfaced review item from another author, either reply once with an explanation if it is non-actionable or patch/commit/push and then resolve it if it is actionable. If a later snapshot surfaces your own reply, treat it as informational and continue without responding again.
7. Process actionable review comments before flaky reruns when both are present; if a review fix requires a commit, push it and skip rerunning failed checks on the old SHA.
8. Retry failed checks only when `retry_failed_checks` is present and you are not about to replace the current SHA with a review/CI fix commit.
9. If you pushed a commit, resolved a review thread, replied to a review comment, or triggered a rerun, report the action briefly and continue polling (do not stop).
10. After a review-fix push, proactively restart continuous monitoring (`--watch`) in the same turn unless a strict stop condition has already been reached.
11. If everything is passing, mergeable, not blocked on required review approval, and there are no unaddressed review items, report that the PR is currently ready to merge but keep the watcher running so new review comments are surfaced quickly while the PR remains open.
12. If blocked on a user-help-required issue (infra outage, exhausted flaky retries, unclear reviewer request, permissions), report the blocker and stop.
13. Otherwise sleep according to the polling cadence below and repeat.
6. Process actionable review comments before flaky reruns when both are present; if a review fix requires a commit, push it and skip rerunning failed checks on the old SHA.
7. Retry failed checks only when `retry_failed_checks` is present and you are not about to replace the current SHA with a review/CI fix commit.
8. If you pushed a commit or triggered a rerun, report the action briefly and continue polling (do not stop).
9. After a review-fix push, proactively restart continuous monitoring (`--watch`) in the same turn unless a strict stop condition has already been reached.
10. If everything is passing, mergeable, not blocked on required review approval, and there are no unaddressed review items, report success and stop.
11. If blocked on a user-help-required issue (infra outage, exhausted flaky retries, unclear reviewer request, permissions), report the blocker and stop.
12. Otherwise sleep according to the polling cadence below and repeat.
When the user explicitly asks to monitor/watch/babysit a PR, prefer `--watch` so polling continues autonomously in one command. Use repeated `--once` snapshots only for debugging, local testing, or when the user explicitly asks for a one-shot check.
Do not stop to ask the user whether to continue polling; continue autonomously until a strict stop condition is met or the user explicitly interrupts.
@@ -141,18 +138,19 @@ Do not hand control back to the user after a review-fix push just because a new
If a `--watch` process is still running and no strict stop condition has been reached, the babysitting task is still in progress; keep streaming/consuming watcher output instead of ending the turn.
## Polling Cadence
Keep review polling aggressive and continue monitoring even after CI turns green:
Use adaptive polling and continue monitoring even after CI turns green:
- While CI is not green (pending/running/queued or failing): poll every 1 minute.
- After CI turns green: keep polling at the base cadence while the PR remains open so newly posted review comments are surfaced promptly instead of waiting on a long green-state backoff.
- Reset the cadence immediately whenever anything changes (new commit/SHA, check status changes, new review comments, mergeability changes, review decision changes).
- If CI stops being green again (new commit, rerun, or regression): stay on the base polling cadence.
- After CI turns green: start at every 1 minute, then back off exponentially when there is no change (for example 1m, 2m, 4m, 8m, 16m, 32m), capping at every 1 hour.
- Reset the green-state polling interval back to 1 minute whenever anything changes (new commit/SHA, check status changes, new review comments, mergeability changes, review decision changes).
- If CI stops being green again (new commit, rerun, or regression): return to 1-minute polling.
- If any poll shows the PR is merged or otherwise closed: stop polling immediately and report the terminal state.
## Stop Conditions (Strict)
Stop only when one of the following is true:
- PR merged or closed (stop as soon as a poll/snapshot confirms this).
- PR is ready to merge: CI succeeded, no surfaced unaddressed review comments, not blocked on required review approval, and no merge conflict risk.
- User intervention is required and Codex cannot safely proceed alone.
Keep polling when:
@@ -161,14 +159,14 @@ Keep polling when:
- CI is still running/queued.
- Review state is quiet but CI is not terminal.
- CI is green but mergeability is unknown/pending.
- CI is green and mergeable, but the PR is still open and you are waiting for possible new review comments or merge-conflict changes.
- The PR is green but blocked on review approval (`REVIEW_REQUIRED` / similar); continue polling at the base cadence and surface any new review comments without asking for confirmation to keep watching.
- CI is green and mergeable, but the PR is still open and you are waiting for possible new review comments or merge-conflict changes per the green-state cadence.
- The PR is green but blocked on review approval (`REVIEW_REQUIRED` / similar); continue polling on the green-state cadence and surface any new review comments without asking for confirmation to keep watching.
## Output Expectations
Provide concise progress updates while monitoring and a final summary that includes:
- During long unchanged monitoring periods, avoid emitting a full update on every poll; summarize only status changes plus occasional heartbeat updates.
- Treat push confirmations, intermediate CI snapshots, ready-to-merge snapshots, and review-action updates as progress updates only; do not emit the final summary or end the babysitting session unless a strict stop condition is met.
- Treat push confirmations, intermediate CI snapshots, and review-action updates as progress updates only; do not emit the final summary or end the babysitting session unless a strict stop condition is met.
- A user request to "monitor" is not satisfied by a couple of sample polls; remain in the loop until a strict stop condition or an explicit user interruption.
- A review-fix commit + push is not a completion event; immediately resume live monitoring (`--watch`) in the same turn and continue reporting progress updates.
- When CI first transitions to all green for the current SHA, emit a one-time celebratory progress update (do not repeat it on every green poll). Preferred style: `🚀 CI is all green! 33/33 passed. Still on watch for review approval.`

View File

@@ -1,4 +1,4 @@
interface:
display_name: "PR Babysitter"
short_description: "Watch PR review comments, CI, and merge conflicts"
default_prompt: "Babysit the current PR: monitor reviewer comments, CI, and merge-conflict status (prefer the watchers --watch mode for live monitoring); surface new review feedback before acting on CI or mergeability work, fix valid issues, push updates, and rerun flaky failures up to 3 times. Keep exactly one watcher session active for the PR (do not leave duplicate --watch terminals running). If you pause monitoring to patch review/CI feedback, restart --watch yourself immediately after the push in the same turn. If a watcher is still running and no strict stop condition has been reached, the task is still in progress: keep consuming watcher output and sending progress updates instead of ending the turn. Do not treat a green + mergeable PR as a terminal stop while it is still open; continue polling autonomously after any push/rerun so newly posted review comments are surfaced until a strict terminal stop condition is reached or the user interrupts."
short_description: "Watch PR CI, reviews, and merge conflicts"
default_prompt: "Babysit the current PR: monitor CI, reviewer comments, and merge-conflict status (prefer the watchers --watch mode for live monitoring); fix valid issues, push updates, and rerun flaky failures up to 3 times. Keep exactly one watcher session active for the PR (do not leave duplicate --watch terminals running). If you pause monitoring to patch review/CI feedback, restart --watch yourself immediately after the push in the same turn. If a watcher is still running and no strict stop condition has been reached, the task is still in progress: keep consuming watcher output and sending progress updates instead of ending the turn. Continue polling autonomously after any push/rerun until a strict terminal stop condition is reached or the user interrupts."

View File

@@ -45,6 +45,7 @@ MERGE_CONFLICT_OR_BLOCKING_STATES = {
"DRAFT",
"UNKNOWN",
}
GREEN_STATE_MAX_POLL_SECONDS = 60 * 60
class GhCommandError(RuntimeError):
@@ -577,7 +578,7 @@ def recommend_actions(pr, checks_summary, failed_runs, new_review_items, retries
return unique_actions(actions)
if is_pr_ready_to_merge(pr, checks_summary, new_review_items):
actions.append("ready_to_merge")
actions.append("stop_ready_to_merge")
return unique_actions(actions)
if new_review_items:
@@ -605,6 +606,12 @@ def collect_snapshot(args):
if not state.get("started_at"):
state["started_at"] = int(time.time())
# `gh pr checks -R <repo>` requires an explicit PR/branch/url argument.
# After resolving `--pr auto`, reuse the concrete PR number.
checks = get_pr_checks(str(pr["number"]), repo=pr["repo"])
checks_summary = summarize_checks(checks)
workflow_runs = get_workflow_runs_for_sha(pr["repo"], pr["head_sha"])
failed_runs = failed_runs_from_workflow_runs(workflow_runs, pr["head_sha"])
authenticated_login = get_authenticated_login()
new_review_items = fetch_new_review_items(
pr,
@@ -612,15 +619,6 @@ def collect_snapshot(args):
fresh_state=fresh_state,
authenticated_login=authenticated_login,
)
# Surface review feedback before drilling into CI and mergeability details.
# That keeps the babysitter responsive to new comments even when other
# actions are also available.
# `gh pr checks -R <repo>` requires an explicit PR/branch/url argument.
# After resolving `--pr auto`, reuse the concrete PR number.
checks = get_pr_checks(str(pr["number"]), repo=pr["repo"])
checks_summary = summarize_checks(checks)
workflow_runs = get_workflow_runs_for_sha(pr["repo"], pr["head_sha"])
failed_runs = failed_runs_from_workflow_runs(workflow_runs, pr["head_sha"])
retries_used = current_retry_count(state, pr["head_sha"])
actions = recommend_actions(
@@ -763,6 +761,7 @@ def run_watch(args):
if (
"stop_pr_closed" in actions
or "stop_exhausted_retries" in actions
or "stop_ready_to_merge" in actions
):
print_event("stop", {"actions": snapshot.get("actions"), "pr": snapshot.get("pr")})
return 0
@@ -770,13 +769,13 @@ def run_watch(args):
current_change_key = snapshot_change_key(snapshot)
changed = current_change_key != last_change_key
green = is_ci_green(snapshot)
pr = snapshot.get("pr") or {}
pr_open = not bool(pr.get("closed")) and not bool(pr.get("merged"))
if not green or pr_open:
if not green:
poll_seconds = args.poll_seconds
elif changed or last_change_key is None:
poll_seconds = args.poll_seconds
else:
poll_seconds = min(poll_seconds * 2, GREEN_STATE_MAX_POLL_SECONDS)
last_change_key = current_change_key
time.sleep(poll_seconds)

View File

@@ -1,155 +0,0 @@
import argparse
import importlib.util
from pathlib import Path
import pytest
MODULE_PATH = Path(__file__).with_name("gh_pr_watch.py")
MODULE_SPEC = importlib.util.spec_from_file_location("gh_pr_watch", MODULE_PATH)
gh_pr_watch = importlib.util.module_from_spec(MODULE_SPEC)
assert MODULE_SPEC.loader is not None
MODULE_SPEC.loader.exec_module(gh_pr_watch)
def sample_pr():
return {
"number": 123,
"url": "https://github.com/openai/codex/pull/123",
"repo": "openai/codex",
"head_sha": "abc123",
"head_branch": "feature",
"state": "OPEN",
"merged": False,
"closed": False,
"mergeable": "MERGEABLE",
"merge_state_status": "CLEAN",
"review_decision": "",
}
def sample_checks(**overrides):
checks = {
"pending_count": 0,
"failed_count": 0,
"passed_count": 12,
"all_terminal": True,
}
checks.update(overrides)
return checks
def test_collect_snapshot_fetches_review_items_before_ci(monkeypatch, tmp_path):
call_order = []
pr = sample_pr()
monkeypatch.setattr(gh_pr_watch, "resolve_pr", lambda *args, **kwargs: pr)
monkeypatch.setattr(gh_pr_watch, "load_state", lambda path: ({}, True))
monkeypatch.setattr(
gh_pr_watch,
"get_authenticated_login",
lambda: call_order.append("auth") or "octocat",
)
monkeypatch.setattr(
gh_pr_watch,
"fetch_new_review_items",
lambda *args, **kwargs: call_order.append("review") or [],
)
monkeypatch.setattr(
gh_pr_watch,
"get_pr_checks",
lambda *args, **kwargs: call_order.append("checks") or [],
)
monkeypatch.setattr(
gh_pr_watch,
"summarize_checks",
lambda checks: call_order.append("summarize") or sample_checks(),
)
monkeypatch.setattr(
gh_pr_watch,
"get_workflow_runs_for_sha",
lambda *args, **kwargs: call_order.append("workflow") or [],
)
monkeypatch.setattr(
gh_pr_watch,
"failed_runs_from_workflow_runs",
lambda *args, **kwargs: call_order.append("failed_runs") or [],
)
monkeypatch.setattr(
gh_pr_watch,
"recommend_actions",
lambda *args, **kwargs: call_order.append("recommend") or ["idle"],
)
monkeypatch.setattr(gh_pr_watch, "save_state", lambda *args, **kwargs: None)
args = argparse.Namespace(
pr="123",
repo=None,
state_file=str(tmp_path / "watcher-state.json"),
max_flaky_retries=3,
)
gh_pr_watch.collect_snapshot(args)
assert call_order.index("review") < call_order.index("checks")
assert call_order.index("review") < call_order.index("workflow")
def test_recommend_actions_prioritizes_review_comments():
actions = gh_pr_watch.recommend_actions(
sample_pr(),
sample_checks(failed_count=1),
[{"run_id": 99}],
[{"kind": "review_comment", "id": "1"}],
0,
3,
)
assert actions == [
"process_review_comment",
"diagnose_ci_failure",
"retry_failed_checks",
]
def test_run_watch_keeps_polling_open_ready_to_merge_pr(monkeypatch):
sleeps = []
events = []
snapshot = {
"pr": sample_pr(),
"checks": sample_checks(),
"failed_runs": [],
"new_review_items": [],
"actions": ["ready_to_merge"],
"retry_state": {
"current_sha_retries_used": 0,
"max_flaky_retries": 3,
},
}
monkeypatch.setattr(
gh_pr_watch,
"collect_snapshot",
lambda args: (snapshot, Path("/tmp/codex-babysit-pr-state.json")),
)
monkeypatch.setattr(
gh_pr_watch,
"print_event",
lambda event, payload: events.append((event, payload)),
)
class StopWatch(Exception):
pass
def fake_sleep(seconds):
sleeps.append(seconds)
if len(sleeps) >= 2:
raise StopWatch
monkeypatch.setattr(gh_pr_watch.time, "sleep", fake_sleep)
with pytest.raises(StopWatch):
gh_pr_watch.run_watch(argparse.Namespace(poll_seconds=30))
assert sleeps == [30, 30]
assert [event for event, _ in events] == ["snapshot", "snapshot"]

View File

@@ -1,133 +0,0 @@
name: setup-bazel-ci
description: Prepare a Bazel CI runner with shared caches and optional test prerequisites.
inputs:
target:
description: Target triple used for cache namespacing.
required: true
install-test-prereqs:
description: Install Node.js and DotSlash for Bazel-backed test jobs.
required: false
default: "false"
outputs:
cache-hit:
description: Whether the Bazel repository cache key was restored exactly.
value: ${{ steps.cache_bazel_repository_restore.outputs.cache-hit }}
runs:
using: composite
steps:
- name: Set up Node.js for js_repl tests
if: inputs.install-test-prereqs == 'true'
uses: actions/setup-node@v6
with:
node-version-file: codex-rs/node-version.txt
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
if: inputs.install-test-prereqs == 'true'
uses: facebook/install-dotslash@v2
- name: Make DotSlash available in PATH (Unix)
if: inputs.install-test-prereqs == 'true' && runner.os != 'Windows'
shell: bash
run: cp "$(which dotslash)" /usr/local/bin
- name: Make DotSlash available in PATH (Windows)
if: inputs.install-test-prereqs == 'true' && runner.os == 'Windows'
shell: pwsh
run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe"
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v3
# Restore bazel repository cache so we don't have to redownload all the external dependencies
# on every CI run.
- name: Restore bazel repository cache
id: cache_bazel_repository_restore
uses: actions/cache/restore@v5
with:
path: |
~/.cache/bazel-repo-cache
key: bazel-cache-${{ inputs.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
restore-keys: |
bazel-cache-${{ inputs.target }}
- name: Configure Bazel output root (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Use the shortest available drive to reduce argv/path length issues,
# but avoid the drive root because some Windows test launchers mis-handle
# MANIFEST paths there.
$hasDDrive = Test-Path 'D:\'
$bazelOutputUserRoot = if ($hasDDrive) { 'D:\b' } else { 'C:\b' }
$repoContentsCache = Join-Path $env:RUNNER_TEMP "bazel-repo-contents-cache-$env:GITHUB_RUN_ID-$env:GITHUB_JOB"
"BAZEL_OUTPUT_USER_ROOT=$bazelOutputUserRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"BAZEL_REPO_CONTENTS_CACHE=$repoContentsCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
if (-not $hasDDrive) {
$repositoryCache = Join-Path $env:USERPROFILE '.cache\bazel-repo-cache'
"BAZEL_REPOSITORY_CACHE=$repositoryCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
}
- name: Expose MSVC SDK environment (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Bazel exec-side Rust build scripts do not reliably inherit the MSVC developer
# shell on GitHub-hosted Windows runners, so discover the latest VS install and
# ask `VsDevCmd.bat` to materialize the x64/x64 compiler + SDK environment.
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path $vswhere)) {
throw "vswhere.exe not found"
}
$installPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null
if (-not $installPath) {
throw "Could not locate a Visual Studio installation with VC tools"
}
$vsDevCmd = Join-Path $installPath 'Common7\Tools\VsDevCmd.bat'
if (-not (Test-Path $vsDevCmd)) {
throw "VsDevCmd.bat not found at $vsDevCmd"
}
# Keep the export surface explicit: these are the paths and SDK roots that the
# MSVC toolchain probes need later when Bazel runs Windows exec-platform build
# scripts such as `aws-lc-sys`.
$varsToExport = @(
'INCLUDE',
'LIB',
'LIBPATH',
'PATH',
'UCRTVersion',
'UniversalCRTSdkDir',
'VCINSTALLDIR',
'VCToolsInstallDir',
'WindowsLibPath',
'WindowsSdkBinPath',
'WindowsSdkDir',
'WindowsSDKLibVersion',
'WindowsSDKVersion'
)
# `VsDevCmd.bat` is a batch file, so invoke it under `cmd.exe`, suppress its
# banner, then dump the resulting environment with `set`. Re-export only the
# approved keys into `GITHUB_ENV` so later steps inherit the same MSVC context.
$envLines = & cmd.exe /c ('"{0}" -no_logo -arch=x64 -host_arch=x64 >nul && set' -f $vsDevCmd)
foreach ($line in $envLines) {
if ($line -notmatch '^(.*?)=(.*)$') {
continue
}
$name = $matches[1]
$value = $matches[2]
if ($varsToExport -contains $name) {
"$name=$value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
}
}
- name: Enable Git long paths (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: git config --global core.longpaths true

View File

@@ -1,115 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ci_config=ci-linux
case "${RUNNER_OS:-}" in
macOS)
ci_config=ci-macos
;;
Windows)
ci_config=ci-windows
;;
esac
bazel_lint_args=("$@")
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
has_host_platform_override=0
for arg in "${bazel_lint_args[@]}"; do
if [[ "$arg" == --host_platform=* ]]; then
has_host_platform_override=1
break
fi
done
if [[ $has_host_platform_override -eq 0 ]]; then
# The nightly Windows lint toolchain is registered with an MSVC exec
# platform even though the lint target platform stays on `windows-gnullvm`.
# Override the host platform here so the exec-side helper binaries actually
# match the registered toolchain set.
bazel_lint_args+=("--host_platform=//:local_windows_msvc")
fi
# Native Windows lint runs need exec-side Rust helper binaries and proc-macros
# to use rust-lld instead of the C++ linker path. The default `none`
# preference resolves to `cc` when a cc_toolchain is present, which currently
# routes these exec actions through clang++ with an argument shape it cannot
# consume.
bazel_lint_args+=("--@rules_rust//rust/settings:toolchain_linker_preference=rust")
# Some Rust top-level targets are still intentionally incompatible with the
# local Windows MSVC exec platform. Skip those explicit targets so the native
# lint aspect can run across the compatible crate graph instead of failing the
# whole build after analysis.
bazel_lint_args+=("--skip_incompatible_explicit_targets")
fi
bazel_startup_args=()
if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then
bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}")
fi
run_bazel() {
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
MSYS2_ARG_CONV_EXCL='*' bazel "$@"
return
fi
bazel "$@"
}
run_bazel_with_startup_args() {
if [[ ${#bazel_startup_args[@]} -gt 0 ]]; then
run_bazel "${bazel_startup_args[@]}" "$@"
return
fi
run_bazel "$@"
}
read_query_labels() {
local query="$1"
local query_stdout
local query_stderr
query_stdout="$(mktemp)"
query_stderr="$(mktemp)"
if ! run_bazel_with_startup_args \
--noexperimental_remote_repo_contents_cache \
query \
--keep_going \
--output=label \
"$query" >"$query_stdout" 2>"$query_stderr"; then
cat "$query_stderr" >&2
rm -f "$query_stdout" "$query_stderr"
exit 1
fi
cat "$query_stdout"
rm -f "$query_stdout" "$query_stderr"
}
final_build_targets=(//codex-rs/...)
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
# Bazel's local Windows platform currently lacks a default test toolchain for
# `rust_test`, so target the concrete Rust crate rules directly. The lint
# aspect still walks their crate graph, which preserves incremental reuse for
# non-test code while avoiding non-Rust wrapper targets such as platform_data.
final_build_targets=()
while IFS= read -r label; do
[[ -n "$label" ]] || continue
final_build_targets+=("$label")
done < <(read_query_labels 'kind("rust_(library|binary|proc_macro) rule", //codex-rs/...)')
if [[ ${#final_build_targets[@]} -eq 0 ]]; then
echo "Failed to discover Windows Bazel lint targets." >&2
exit 1
fi
fi
./.github/scripts/run-bazel-ci.sh \
-- \
build \
"${bazel_lint_args[@]}" \
-- \
"${final_build_targets[@]}"

View File

@@ -1,252 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
print_failed_bazel_test_logs=0
use_node_test_env=0
remote_download_toplevel=0
while [[ $# -gt 0 ]]; do
case "$1" in
--print-failed-test-logs)
print_failed_bazel_test_logs=1
shift
;;
--use-node-test-env)
use_node_test_env=1
shift
;;
--remote-download-toplevel)
remote_download_toplevel=1
shift
;;
--)
shift
break
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [[ $# -eq 0 ]]; then
echo "Usage: $0 [--print-failed-test-logs] [--use-node-test-env] [--remote-download-toplevel] -- <bazel args> -- <targets>" >&2
exit 1
fi
bazel_startup_args=()
if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then
bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}")
fi
run_bazel() {
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
MSYS2_ARG_CONV_EXCL='*' bazel "$@"
return
fi
bazel "$@"
}
ci_config=ci-linux
case "${RUNNER_OS:-}" in
macOS)
ci_config=ci-macos
;;
Windows)
ci_config=ci-windows
;;
esac
print_bazel_test_log_tails() {
local console_log="$1"
local testlogs_dir
local -a bazel_info_cmd=(bazel)
if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_info_cmd+=("${bazel_startup_args[@]}")
fi
testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" info bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
local failed_targets=()
while IFS= read -r target; do
failed_targets+=("$target")
done < <(
grep -E '^FAIL: //' "$console_log" \
| sed -E 's#^FAIL: (//[^ ]+).*#\1#' \
| sort -u
)
if [[ ${#failed_targets[@]} -eq 0 ]]; then
echo "No failed Bazel test targets were found in console output."
return
fi
for target in "${failed_targets[@]}"; do
local rel_path="${target#//}"
rel_path="${rel_path/:/\/}"
local test_log="${testlogs_dir}/${rel_path}/test.log"
echo "::group::Bazel test log tail for ${target}"
if [[ -f "$test_log" ]]; then
tail -n 200 "$test_log"
else
echo "Missing test log: $test_log"
fi
echo "::endgroup::"
done
}
bazel_args=()
bazel_targets=()
found_target_separator=0
for arg in "$@"; do
if [[ "$arg" == "--" && $found_target_separator -eq 0 ]]; then
found_target_separator=1
continue
fi
if [[ $found_target_separator -eq 0 ]]; then
bazel_args+=("$arg")
else
bazel_targets+=("$arg")
fi
done
if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then
echo "Expected Bazel args and targets separated by --" >&2
exit 1
fi
if [[ $use_node_test_env -eq 1 && "${RUNNER_OS:-}" != "Windows" ]]; then
# Bazel test sandboxes on macOS may resolve an older Homebrew `node`
# before the `actions/setup-node` runtime on PATH.
node_bin="$(which node)"
bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}")
fi
post_config_bazel_args=()
if [[ $remote_download_toplevel -eq 1 ]]; then
# Override the CI config's remote_download_minimal setting when callers need
# the built artifact to exist on disk after the command completes.
post_config_bazel_args+=(--remote_download_toplevel)
fi
if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then
# Windows self-hosted runners can run multiple Bazel jobs concurrently. Give
# each job its own repo contents cache so they do not fight over the shared
# path configured in `ci-windows`.
post_config_bazel_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}")
fi
if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then
post_config_bazel_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}")
fi
if [[ -n "${CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR:-}" ]]; then
post_config_bazel_args+=(
"--execution_log_compact_file=${CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR}/execution-log-${bazel_args[0]}-${GITHUB_JOB:-local}-$$.zst"
)
fi
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
windows_action_env_vars=(
INCLUDE
LIB
LIBPATH
PATH
UCRTVersion
UniversalCRTSdkDir
VCINSTALLDIR
VCToolsInstallDir
WindowsLibPath
WindowsSdkBinPath
WindowsSdkDir
WindowsSDKLibVersion
WindowsSDKVersion
)
for env_var in "${windows_action_env_vars[@]}"; do
if [[ -n "${!env_var:-}" ]]; then
post_config_bazel_args+=("--action_env=${env_var}" "--host_action_env=${env_var}")
fi
done
fi
bazel_console_log="$(mktemp)"
trap 'rm -f "$bazel_console_log"' EXIT
bazel_cmd=(bazel)
if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_cmd+=("${bazel_startup_args[@]}")
fi
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
echo "BuildBuddy API key is available; using remote Bazel configuration."
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
# seen in CI (for example "is not a symlink" or permission errors while
# materializing external repos such as rules_perl). We still use BuildBuddy for
# remote execution/cache; this only disables the startup-level repo contents cache.
bazel_run_args=(
"${bazel_args[@]}"
"--config=${ci_config}"
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
)
if (( ${#post_config_bazel_args[@]} > 0 )); then
bazel_run_args+=("${post_config_bazel_args[@]}")
fi
set +e
run_bazel "${bazel_cmd[@]:1}" \
--noexperimental_remote_repo_contents_cache \
"${bazel_run_args[@]}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
else
echo "BuildBuddy API key is not available; using local Bazel configuration."
# Keep fork/community PRs on Bazel but disable remote services that are
# configured in .bazelrc and require auth.
#
# Flag docs:
# - Command-line reference: https://bazel.build/reference/command-line-reference
# - Remote caching overview: https://bazel.build/remote/caching
# - Remote execution overview: https://bazel.build/remote/rbe
# - Build Event Protocol overview: https://bazel.build/remote/bep
#
# --noexperimental_remote_repo_contents_cache:
# disable remote repo contents cache enabled in .bazelrc startup options.
# https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache
# --remote_cache= and --remote_executor=:
# clear remote cache/execution endpoints configured in .bazelrc.
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor
bazel_run_args=(
"${bazel_args[@]}"
--remote_cache=
--remote_executor=
)
if (( ${#post_config_bazel_args[@]} > 0 )); then
bazel_run_args+=("${post_config_bazel_args[@]}")
fi
set +e
run_bazel "${bazel_cmd[@]:1}" \
--noexperimental_remote_repo_contents_cache \
"${bazel_run_args[@]}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
fi
if [[ ${bazel_status:-0} -ne 0 ]]; then
if [[ $print_failed_bazel_test_logs -eq 1 ]]; then
print_bazel_test_log_tails "$bazel_console_log"
fi
exit "$bazel_status"
fi

View File

@@ -1,234 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import sys
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CARGO_TOML = ROOT / "codex-rs" / "Cargo.toml"
DEFAULT_BAZELRC = ROOT / ".bazelrc"
BAZEL_CLIPPY_FLAG_PREFIX = "build:clippy --@rules_rust//rust/settings:clippy_flag="
BAZEL_SPECIAL_FLAGS = {"-Dwarnings"}
VALID_LEVELS = {"allow", "warn", "deny", "forbid"}
LONG_FLAG_RE = re.compile(
r"^--(?P<level>allow|warn|deny|forbid)=clippy::(?P<lint>[a-z0-9_]+)$"
)
SHORT_FLAG_RE = re.compile(r"^-(?P<level>[AWDF])clippy::(?P<lint>[a-z0-9_]+)$")
SHORT_LEVEL_NAMES = {
"A": "allow",
"W": "warn",
"D": "deny",
"F": "forbid",
}
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Verify that Bazel clippy flags in .bazelrc stay in sync with "
"codex-rs/Cargo.toml [workspace.lints.clippy]."
)
)
parser.add_argument(
"--cargo-toml",
type=Path,
default=DEFAULT_CARGO_TOML,
help="Path to the workspace Cargo.toml to inspect.",
)
parser.add_argument(
"--bazelrc",
type=Path,
default=DEFAULT_BAZELRC,
help="Path to the .bazelrc file to inspect.",
)
args = parser.parse_args()
cargo_toml = args.cargo_toml.resolve()
bazelrc = args.bazelrc.resolve()
cargo_lints = load_workspace_clippy_lints(cargo_toml)
bazel_lints = load_bazel_clippy_lints(bazelrc)
missing = sorted(cargo_lints.keys() - bazel_lints.keys())
extra = sorted(bazel_lints.keys() - cargo_lints.keys())
mismatched = sorted(
lint
for lint in cargo_lints.keys() & bazel_lints.keys()
if cargo_lints[lint] != bazel_lints[lint]
)
if missing or extra or mismatched:
print_sync_error(
cargo_toml=cargo_toml,
bazelrc=bazelrc,
cargo_lints=cargo_lints,
bazel_lints=bazel_lints,
missing=missing,
extra=extra,
mismatched=mismatched,
)
return 1
print(
"Bazel clippy flags in "
f"{display_path(bazelrc)} match "
f"{display_path(cargo_toml)} [workspace.lints.clippy]."
)
return 0
def load_workspace_clippy_lints(cargo_toml: Path) -> dict[str, str]:
workspace = tomllib.loads(cargo_toml.read_text())["workspace"]
clippy_lints = workspace["lints"]["clippy"]
parsed: dict[str, str] = {}
for lint, level in clippy_lints.items():
if not isinstance(level, str):
raise SystemExit(
f"expected string lint level for clippy::{lint} in {cargo_toml}, got {level!r}"
)
normalized = level.strip().lower()
if normalized not in VALID_LEVELS:
raise SystemExit(
f"unsupported lint level {level!r} for clippy::{lint} in {cargo_toml}"
)
parsed[lint] = normalized
return parsed
def load_bazel_clippy_lints(bazelrc: Path) -> dict[str, str]:
parsed: dict[str, str] = {}
line_numbers: dict[str, int] = {}
for lineno, line in enumerate(bazelrc.read_text().splitlines(), start=1):
if not line.startswith(BAZEL_CLIPPY_FLAG_PREFIX):
continue
flag = line.removeprefix(BAZEL_CLIPPY_FLAG_PREFIX).strip()
if flag in BAZEL_SPECIAL_FLAGS:
continue
parsed_flag = parse_bazel_lint_flag(flag)
if parsed_flag is None:
continue
lint, level = parsed_flag
if lint in parsed:
raise SystemExit(
f"duplicate Bazel clippy entry for clippy::{lint} at "
f"{bazelrc}:{line_numbers[lint]} and {bazelrc}:{lineno}"
)
parsed[lint] = level
line_numbers[lint] = lineno
return parsed
def parse_bazel_lint_flag(flag: str) -> tuple[str, str] | None:
long_match = LONG_FLAG_RE.match(flag)
if long_match:
return long_match["lint"], long_match["level"]
short_match = SHORT_FLAG_RE.match(flag)
if short_match:
return short_match["lint"], SHORT_LEVEL_NAMES[short_match["level"]]
return None
def print_sync_error(
*,
cargo_toml: Path,
bazelrc: Path,
cargo_lints: dict[str, str],
bazel_lints: dict[str, str],
missing: list[str],
extra: list[str],
mismatched: list[str],
) -> None:
cargo_toml_display = display_path(cargo_toml)
bazelrc_display = display_path(bazelrc)
example_manifest = find_workspace_lints_example_manifest()
print(
"ERROR: Bazel clippy flags are out of sync with Cargo workspace clippy lints.",
file=sys.stderr,
)
print(file=sys.stderr)
print(
f"Cargo defines the source of truth in {cargo_toml_display} "
"[workspace.lints.clippy].",
file=sys.stderr,
)
if example_manifest is not None:
print(
"Cargo applies those lint levels to member crates that opt into "
f"`[lints] workspace = true`, for example {example_manifest}.",
file=sys.stderr,
)
print(
"Bazel clippy does not ingest Cargo lint levels automatically, and "
"`clippy.toml` can configure lint behavior but cannot set allow/warn/deny/forbid.",
file=sys.stderr,
)
print(
f"Update {bazelrc_display} so its `build:clippy` "
"`clippy_flag` entries match Cargo.",
file=sys.stderr,
)
if missing:
print(file=sys.stderr)
print("Missing Bazel entries:", file=sys.stderr)
for lint in missing:
print(f" {render_bazelrc_line(lint, cargo_lints[lint])}", file=sys.stderr)
if mismatched:
print(file=sys.stderr)
print("Mismatched lint levels:", file=sys.stderr)
for lint in mismatched:
cargo_level = cargo_lints[lint]
bazel_level = bazel_lints[lint]
print(
f" clippy::{lint}: Cargo has {cargo_level}, Bazel has {bazel_level}",
file=sys.stderr,
)
print(
f" expected: {render_bazelrc_line(lint, cargo_level)}",
file=sys.stderr,
)
if extra:
print(file=sys.stderr)
print("Extra Bazel entries with no Cargo counterpart:", file=sys.stderr)
for lint in extra:
print(f" {render_bazelrc_line(lint, bazel_lints[lint])}", file=sys.stderr)
def render_bazelrc_line(lint: str, level: str) -> str:
return f"{BAZEL_CLIPPY_FLAG_PREFIX}--{level}=clippy::{lint}"
def display_path(path: Path) -> str:
try:
return str(path.relative_to(ROOT))
except ValueError:
return str(path)
def find_workspace_lints_example_manifest() -> str | None:
for cargo_toml in sorted((ROOT / "codex-rs").glob("**/Cargo.toml")):
if cargo_toml == DEFAULT_CARGO_TOML:
continue
data = tomllib.loads(cargo_toml.read_text())
if data.get("lints", {}).get("workspace") is True:
return str(cargo_toml.relative_to(ROOT))
return None
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,388 +0,0 @@
#!/usr/bin/env python3
"""Verify that codex-rs Cargo manifests follow workspace manifest policy.
Checks:
- Crates inherit `[workspace.package]` metadata.
- Crates opt into `[lints] workspace = true`.
- Crate names follow the codex-rs directory naming conventions.
- Workspace manifests do not introduce workspace crate feature toggles.
"""
from __future__ import annotations
import sys
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
CARGO_RS_ROOT = ROOT / "codex-rs"
WORKSPACE_PACKAGE_FIELDS = ("version", "edition", "license")
TOP_LEVEL_NAME_EXCEPTIONS = {
"windows-sandbox-rs": "codex-windows-sandbox",
}
UTILITY_NAME_EXCEPTIONS = {
"path-utils": "codex-utils-path",
}
MANIFEST_FEATURE_EXCEPTIONS = {}
OPTIONAL_DEPENDENCY_EXCEPTIONS = set()
INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS = {}
def main() -> int:
internal_package_names = workspace_package_names()
used_manifest_feature_exceptions: set[str] = set()
used_optional_dependency_exceptions: set[tuple[str, str, str]] = set()
used_internal_dependency_feature_exceptions: set[tuple[str, str, str]] = set()
failures_by_path: dict[str, list[str]] = {}
for path in manifests_to_verify():
if errors := manifest_errors(
path,
internal_package_names,
used_manifest_feature_exceptions,
used_optional_dependency_exceptions,
used_internal_dependency_feature_exceptions,
):
failures_by_path[manifest_key(path)] = errors
add_unused_exception_errors(
failures_by_path,
used_manifest_feature_exceptions,
used_optional_dependency_exceptions,
used_internal_dependency_feature_exceptions,
)
if not failures_by_path:
return 0
print(
"Cargo manifests under codex-rs must inherit workspace package metadata, "
"opt into workspace lints, and avoid introducing new workspace crate "
"features."
)
print(
"Workspace crate features are disallowed because our Bazel build setup "
"does not honor them today, which can let issues hidden behind feature "
"gates go unnoticed, and because they add extra crate build "
"permutations we want to avoid."
)
print(
"Cargo only applies `codex-rs/Cargo.toml` `[workspace.lints.clippy]` "
"entries to a crate when that crate declares:"
)
print()
print("[lints]")
print("workspace = true")
print()
print(
"Without that opt-in, `cargo clippy` can miss violations that Bazel clippy "
"catches."
)
print()
print(
"Package-name checks apply to `codex-rs/<crate>/Cargo.toml` and "
"`codex-rs/utils/<crate>/Cargo.toml`."
)
print(
"Workspace crate features are forbidden; add a targeted exception here "
"only if there is a deliberate temporary migration in flight."
)
print()
for path in sorted(failures_by_path):
errors = failures_by_path[path]
print(f"{path}:")
for error in errors:
print(f" - {error}")
return 1
def manifest_errors(
path: Path,
internal_package_names: set[str],
used_manifest_feature_exceptions: set[str],
used_optional_dependency_exceptions: set[tuple[str, str, str]],
used_internal_dependency_feature_exceptions: set[tuple[str, str, str]],
) -> list[str]:
manifest = load_manifest(path)
package = manifest.get("package")
if not isinstance(package, dict) and path != CARGO_RS_ROOT / "Cargo.toml":
return []
errors = []
if isinstance(package, dict):
for field in WORKSPACE_PACKAGE_FIELDS:
if not is_workspace_reference(package.get(field)):
errors.append(f"set `{field}.workspace = true` in `[package]`")
lints = manifest.get("lints")
if not (isinstance(lints, dict) and lints.get("workspace") is True):
errors.append("add `[lints]` with `workspace = true`")
expected_name = expected_package_name(path)
if expected_name is not None:
actual_name = package.get("name")
if actual_name != expected_name:
errors.append(
f"set `[package].name` to `{expected_name}` (found `{actual_name}`)"
)
path_key = manifest_key(path)
features = manifest.get("features")
if features is not None:
normalized_features = normalize_feature_mapping(features)
expected_features = MANIFEST_FEATURE_EXCEPTIONS.get(path_key)
if expected_features is None:
errors.append(
"remove `[features]`; new workspace crate features are not allowed"
)
else:
used_manifest_feature_exceptions.add(path_key)
if normalized_features != expected_features:
errors.append(
"limit `[features]` to the existing exception list while "
"workspace crate features are being removed "
f"(expected {render_feature_mapping(expected_features)})"
)
for section_name, dependencies in dependency_sections(manifest):
for dependency_name, dependency in dependencies.items():
if not isinstance(dependency, dict):
continue
if dependency.get("optional") is True:
exception_key = (path_key, section_name, dependency_name)
if exception_key in OPTIONAL_DEPENDENCY_EXCEPTIONS:
used_optional_dependency_exceptions.add(exception_key)
else:
errors.append(
"remove `optional = true` from "
f"`{dependency_entry_label(section_name, dependency_name)}`; "
"new optional dependencies are not allowed because they "
"create crate features"
)
if not is_internal_dependency(path, dependency_name, dependency, internal_package_names):
continue
dependency_features = dependency.get("features")
if dependency_features is not None:
normalized_dependency_features = normalize_string_list(
dependency_features
)
exception_key = (path_key, section_name, dependency_name)
expected_dependency_features = (
INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS.get(exception_key)
)
if expected_dependency_features is None:
errors.append(
"remove `features = [...]` from workspace dependency "
f"`{dependency_entry_label(section_name, dependency_name)}`; "
"new workspace crate feature activations are not allowed"
)
else:
used_internal_dependency_feature_exceptions.add(exception_key)
if normalized_dependency_features != expected_dependency_features:
errors.append(
"limit workspace dependency features on "
f"`{dependency_entry_label(section_name, dependency_name)}` "
"to the existing exception list while workspace crate "
"features are being removed "
f"(expected {render_string_list(expected_dependency_features)})"
)
if dependency.get("default-features") is False:
errors.append(
"remove `default-features = false` from workspace dependency "
f"`{dependency_entry_label(section_name, dependency_name)}`; "
"new workspace crate feature toggles are not allowed"
)
return errors
def expected_package_name(path: Path) -> str | None:
parts = path.relative_to(CARGO_RS_ROOT).parts
if len(parts) == 2 and parts[1] == "Cargo.toml":
directory = parts[0]
return TOP_LEVEL_NAME_EXCEPTIONS.get(
directory,
directory if directory.startswith("codex-") else f"codex-{directory}",
)
if len(parts) == 3 and parts[0] == "utils" and parts[2] == "Cargo.toml":
directory = parts[1]
return UTILITY_NAME_EXCEPTIONS.get(directory, f"codex-utils-{directory}")
return None
def is_workspace_reference(value: object) -> bool:
return isinstance(value, dict) and value.get("workspace") is True
def manifest_key(path: Path) -> str:
return str(path.relative_to(ROOT))
def normalize_feature_mapping(value: object) -> dict[str, tuple[str, ...]] | None:
if not isinstance(value, dict):
return None
normalized = {}
for key, features in value.items():
if not isinstance(key, str):
return None
normalized_features = normalize_string_list(features)
if normalized_features is None:
return None
normalized[key] = normalized_features
return normalized
def normalize_string_list(value: object) -> tuple[str, ...] | None:
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
return None
return tuple(value)
def render_feature_mapping(features: dict[str, tuple[str, ...]]) -> str:
entries = [
f"{name} = {render_string_list(items)}" for name, items in features.items()
]
return ", ".join(entries)
def render_string_list(items: tuple[str, ...]) -> str:
return "[" + ", ".join(f'"{item}"' for item in items) + "]"
def dependency_sections(manifest: dict) -> list[tuple[str, dict]]:
sections = []
for section_name in ("dependencies", "dev-dependencies", "build-dependencies"):
dependencies = manifest.get(section_name)
if isinstance(dependencies, dict):
sections.append((section_name, dependencies))
workspace = manifest.get("workspace")
if isinstance(workspace, dict):
workspace_dependencies = workspace.get("dependencies")
if isinstance(workspace_dependencies, dict):
sections.append(("workspace.dependencies", workspace_dependencies))
target = manifest.get("target")
if not isinstance(target, dict):
return sections
for target_name, tables in target.items():
if not isinstance(tables, dict):
continue
for section_name in ("dependencies", "dev-dependencies", "build-dependencies"):
dependencies = tables.get(section_name)
if isinstance(dependencies, dict):
sections.append((f"target.{target_name}.{section_name}", dependencies))
return sections
def dependency_entry_label(section_name: str, dependency_name: str) -> str:
return f"[{section_name}].{dependency_name}"
def is_internal_dependency(
manifest_path: Path,
dependency_name: str,
dependency: dict,
internal_package_names: set[str],
) -> bool:
package_name = dependency.get("package", dependency_name)
if isinstance(package_name, str) and package_name in internal_package_names:
return True
dependency_path = dependency.get("path")
if not isinstance(dependency_path, str):
return False
resolved_dependency_path = (manifest_path.parent / dependency_path).resolve()
try:
resolved_dependency_path.relative_to(CARGO_RS_ROOT)
except ValueError:
return False
return True
def add_unused_exception_errors(
failures_by_path: dict[str, list[str]],
used_manifest_feature_exceptions: set[str],
used_optional_dependency_exceptions: set[tuple[str, str, str]],
used_internal_dependency_feature_exceptions: set[tuple[str, str, str]],
) -> None:
for path_key in sorted(
set(MANIFEST_FEATURE_EXCEPTIONS) - used_manifest_feature_exceptions
):
add_failure(
failures_by_path,
path_key,
"remove the stale `[features]` exception from "
"`MANIFEST_FEATURE_EXCEPTIONS`",
)
for path_key, section_name, dependency_name in sorted(
OPTIONAL_DEPENDENCY_EXCEPTIONS - used_optional_dependency_exceptions
):
add_failure(
failures_by_path,
path_key,
"remove the stale optional-dependency exception for "
f"`{dependency_entry_label(section_name, dependency_name)}` from "
"`OPTIONAL_DEPENDENCY_EXCEPTIONS`",
)
for path_key, section_name, dependency_name in sorted(
set(INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS)
- used_internal_dependency_feature_exceptions
):
add_failure(
failures_by_path,
path_key,
"remove the stale internal dependency feature exception for "
f"`{dependency_entry_label(section_name, dependency_name)}` from "
"`INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS`",
)
def add_failure(failures_by_path: dict[str, list[str]], path_key: str, error: str) -> None:
failures_by_path.setdefault(path_key, []).append(error)
def workspace_package_names() -> set[str]:
package_names = set()
for path in cargo_manifests():
manifest = load_manifest(path)
package = manifest.get("package")
if not isinstance(package, dict):
continue
package_name = package.get("name")
if isinstance(package_name, str):
package_names.add(package_name)
return package_names
def load_manifest(path: Path) -> dict:
return tomllib.loads(path.read_text())
def cargo_manifests() -> list[Path]:
return sorted(
path
for path in CARGO_RS_ROOT.rglob("Cargo.toml")
if path != CARGO_RS_ROOT / "Cargo.toml"
)
def manifests_to_verify() -> list[Path]:
return [CARGO_RS_ROOT / "Cargo.toml", *cargo_manifests()]
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,33 +0,0 @@
# Workflow Strategy
The workflows in this directory are split so that pull requests get fast, review-friendly signal while `main` still gets the full cross-platform verification pass.
## Pull Requests
- `bazel.yml` is the main pre-merge verification path for Rust code.
It runs Bazel `test` and Bazel `clippy` on the supported Bazel targets.
- `rust-ci.yml` keeps the Cargo-native PR checks intentionally small:
- `cargo fmt --check`
- `cargo shear`
- `argument-comment-lint` on Linux, macOS, and Windows
- `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes
The PR workflow still keeps the Linux lint lane on the default-targets-only invocation for now, but the released linter runs on Linux, macOS, and Windows before merge.
## Post-Merge On `main`
- `bazel.yml` also runs on pushes to `main`.
This re-verifies the merged Bazel path and helps keep the BuildBuddy caches warm.
- `rust-ci-full.yml` is the full Cargo-native verification workflow.
It keeps the heavier checks off the PR path while still validating them after merge:
- the full Cargo `clippy` matrix
- the full Cargo `nextest` matrix
- release-profile Cargo builds
- cross-platform `argument-comment-lint`
- Linux remote-env tests
## Rule Of Thumb
- If a build/test/clippy check can be expressed in Bazel, prefer putting the PR-time version in `bazel.yml`.
- Keep `rust-ci.yml` fast enough that it usually does not dominate PR latency.
- Reserve `rust-ci-full.yml` for heavyweight Cargo-native coverage that Bazel does not replace yet.

View File

@@ -1,4 +1,4 @@
name: Bazel
name: Bazel (experimental)
# Note this workflow was originally derived from:
# https://github.com/cerisier/toolchains_llvm_bootstrapped/blob/main/.github/workflows/ci.yaml
@@ -17,7 +17,6 @@ concurrency:
cancel-in-progress: ${{ github.ref_name != 'main' }}
jobs:
test:
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -40,146 +39,193 @@ jobs:
# - os: ubuntu-24.04-arm
# target: aarch64-unknown-linux-gnu
# Windows
- os: windows-latest
target: x86_64-pc-windows-gnullvm
# TODO: Enable Windows once we fix the toolchain issues there.
#- os: windows-latest
# target: x86_64-pc-windows-gnullvm
runs-on: ${{ matrix.os }}
# Configure a human readable name for each job
name: Local Bazel build on ${{ matrix.os }} for ${{ matrix.target }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up Bazel CI
id: setup_bazel
uses: ./.github/actions/setup-bazel-ci
- name: Set up Node.js for js_repl tests
uses: actions/setup-node@v6
with:
target: ${{ matrix.target }}
install-test-prereqs: "true"
node-version-file: codex-rs/node-version.txt
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
uses: facebook/install-dotslash@v2
- name: Make DotSlash available in PATH (Unix)
if: runner.os != 'Windows'
run: cp "$(which dotslash)" /usr/local/bin
- name: Make DotSlash available in PATH (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe"
# Install Bazel via Bazelisk
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v3
- name: Check MODULE.bazel.lock is up to date
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: ./scripts/check-module-bazel-lock.sh
- name: Set up Bazel execution logs
shell: bash
# TODO(mbolin): Bring this back once we have caching working. Currently,
# we never seem to get a cache hit but we still end up paying the cost of
# uploading at the end of the build, which takes over a minute!
#
# Cache build and external artifacts so that the next ci build is incremental.
# Because github action caches cannot be updated after a build, we need to
# store the contents of each build in a unique cache key, then fall back to loading
# it on the next ci run. We use hashFiles(...) in the key and restore-keys- with
# the prefix to load the most recent cache for the branch on a cache miss. You
# should customize the contents of hashFiles to capture any bazel input sources,
# although this doesn't need to be perfect. If none of the input sources change
# then a cache hit will load an existing cache and bazel won't have to do any work.
# In the case of a cache miss, you want the fallback cache to contain most of the
# previously built artifacts to minimize build time. The more precise you are with
# hashFiles sources the less work bazel will have to do.
# - name: Mount bazel caches
# uses: actions/cache@v5
# with:
# path: |
# ~/.cache/bazel-repo-cache
# ~/.cache/bazel-repo-contents-cache
# key: bazel-cache-${{ matrix.os }}-${{ hashFiles('**/BUILD.bazel', '**/*.bzl', 'MODULE.bazel') }}
# restore-keys: |
# bazel-cache-${{ matrix.os }}
- name: Configure Bazel startup args (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
mkdir -p "${RUNNER_TEMP}/bazel-execution-logs"
echo "CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR=${RUNNER_TEMP}/bazel-execution-logs" >> "${GITHUB_ENV}"
# Use a very short path to reduce argv/path length issues.
"BAZEL_STARTUP_ARGS=--output_user_root=C:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: bazel test //...
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
set -o pipefail
bazel_console_log="$(mktemp)"
print_failed_bazel_test_logs() {
local console_log="$1"
local testlogs_dir
testlogs_dir="$(bazel $BAZEL_STARTUP_ARGS info bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
local failed_targets=()
while IFS= read -r target; do
failed_targets+=("$target")
done < <(
grep -E '^FAIL: //' "$console_log" \
| sed -E 's#^FAIL: (//[^ ]+).*#\1#' \
| sort -u
)
if [[ ${#failed_targets[@]} -eq 0 ]]; then
echo "No failed Bazel test targets were found in console output."
return
fi
for target in "${failed_targets[@]}"; do
local rel_path="${target#//}"
rel_path="${rel_path/:/\/}"
local test_log="${testlogs_dir}/${rel_path}/test.log"
echo "::group::Bazel test log tail for ${target}"
if [[ -f "$test_log" ]]; then
tail -n 200 "$test_log"
else
echo "Missing test log: $test_log"
fi
echo "::endgroup::"
done
}
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)
--build_metadata=ROLE=CI
--build_metadata=VISIBILITY=PUBLIC
)
bazel_targets=(
//...
# Keep standalone V8 library targets out of the ordinary Bazel CI
# path. V8 consumers under `//codex-rs/...` still participate
# transitively through `//...`.
# 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
)
./.github/scripts/run-bazel-ci.sh \
--print-failed-test-logs \
--use-node-test-env \
-- \
test \
--test_tag_filters=-argument-comment-lint \
--test_verbose_timeout_warnings \
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
-- \
"${bazel_targets[@]}"
if [[ "${RUNNER_OS:-}" != "Windows" ]]; then
# Bazel test sandboxes on macOS may resolve an older Homebrew `node`
# before the `actions/setup-node` runtime on PATH.
node_bin="$(which node)"
bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}")
fi
- name: Upload Bazel execution logs
if: always() && !cancelled()
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: bazel-execution-logs-test-${{ matrix.target }}
path: ${{ runner.temp }}/bazel-execution-logs
if-no-files-found: ignore
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
echo "BuildBuddy API key is available; using remote Bazel configuration."
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
# seen in CI (for example "is not a symlink" or permission errors while
# materializing external repos such as rules_perl). We still use BuildBuddy for
# remote execution/cache; this only disables the startup-level repo contents cache.
set +e
bazel $BAZEL_STARTUP_ARGS \
--noexperimental_remote_repo_contents_cache \
--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
else
echo "BuildBuddy API key is not available; using local Bazel configuration."
# Keep fork/community PRs on Bazel but disable remote services that are
# configured in .bazelrc and require auth.
#
# Flag docs:
# - Command-line reference: https://bazel.build/reference/command-line-reference
# - Remote caching overview: https://bazel.build/remote/caching
# - Remote execution overview: https://bazel.build/remote/rbe
# - Build Event Protocol overview: https://bazel.build/remote/bep
#
# --noexperimental_remote_repo_contents_cache:
# disable remote repo contents cache enabled in .bazelrc startup options.
# https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache
# --remote_cache= and --remote_executor=:
# clear remote cache/execution endpoints configured in .bazelrc.
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor
set +e
bazel $BAZEL_STARTUP_ARGS \
--noexperimental_remote_repo_contents_cache \
"${bazel_args[@]}" \
--remote_cache= \
--remote_executor= \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
fi
# Save bazel repository cache explicitly; make non-fatal so cache uploading
# never fails the overall job. Only save when key wasn't hit.
- name: Save bazel repository cache
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cache/bazel-repo-cache
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
clippy:
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# Keep Linux lint coverage on x64 and add the arm64 macOS path that
# the Bazel test job already exercises. Add Windows gnullvm as well
# so PRs get Bazel-native lint signal on the same Windows toolchain
# that the Bazel test job uses.
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- os: macos-15-xlarge
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-gnullvm
runs-on: ${{ matrix.os }}
name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Bazel CI
id: setup_bazel
uses: ./.github/actions/setup-bazel-ci
with:
target: ${{ matrix.target }}
- name: Set up Bazel execution logs
shell: bash
run: |
mkdir -p "${RUNNER_TEMP}/bazel-execution-logs"
echo "CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR=${RUNNER_TEMP}/bazel-execution-logs" >> "${GITHUB_ENV}"
- name: bazel build --config=clippy //codex-rs/...
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
# Keep the initial Bazel clippy scope on codex-rs and out of the
# V8 proof-of-concept target for now.
./.github/scripts/run-bazel-ci.sh \
-- \
build \
--config=clippy \
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
--build_metadata=TAG_job=clippy \
-- \
//codex-rs/... \
-//codex-rs/v8-poc:all
- name: Upload Bazel execution logs
if: always() && !cancelled()
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: bazel-execution-logs-clippy-${{ matrix.target }}
path: ${{ runner.temp }}/bazel-execution-logs
if-no-files-found: ignore
# Save bazel repository cache explicitly; make non-fatal so cache uploading
# never fails the overall job. Only save when key wasn't hit.
- name: Save bazel repository cache
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cache/bazel-repo-cache
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
if [[ ${bazel_status:-0} -ne 0 ]]; then
print_failed_bazel_test_logs "$bazel_console_log"
exit "$bazel_status"
fi

View File

@@ -8,7 +8,7 @@ jobs:
name: Blob size policy
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -14,13 +14,13 @@ jobs:
working-directory: ./codex-rs
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
uses: dtolnay/rust-toolchain@stable
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2
uses: EmbarkStudios/cargo-deny-action@v2
with:
rust-version: stable
manifest-path: ./codex-rs/Cargo.toml

27
.github/workflows/ci.bazelrc vendored Normal file
View File

@@ -0,0 +1,27 @@
common --remote_download_minimal
common --keep_going
common --verbose_failures
# Disable disk cache since we have remote one and aren't using persistent workers.
common --disk_cache=
# Rearrange caches on Windows so they're on the same volume as the checkout.
common:windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache
common:windows --repository_cache=D:/a/.cache/bazel-repo-cache
# We prefer to run the build actions entirely remotely so we can dial up the concurrency.
# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform.
# On linux, we can do a full remote build/test, by targeting the right (x86/arm) runners, so we have coverage of both.
# Linux crossbuilds don't work until we untangle the libc constraint mess.
common:linux --config=remote
common:linux --strategy=remote
common:linux --platforms=//:rbe
# On mac, we can run all the build actions remotely but test actions locally.
common:macos --config=remote
common:macos --strategy=remote
common:macos --strategy=TestRunner=darwin-sandbox,local
# On windows we cannot cross-build the tests but run them locally due to what appears to be a Bazel bug
# (windows vs unix path confusion)

View File

@@ -12,21 +12,15 @@ jobs:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Verify codex-rs Cargo manifests inherit workspace settings
run: python3 .github/scripts/verify_cargo_workspace_manifests.py
- name: Verify Bazel clippy flags match Cargo workspace lints
run: python3 .github/scripts/verify_bazel_clippy_lints.py
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@v6
with:
node-version: 22
@@ -34,7 +28,7 @@ jobs:
run: pnpm install --frozen-lockfile
# stage_npm_packages.py requires DotSlash when staging releases.
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- uses: facebook/install-dotslash@v2
- name: Stage npm package
id: stage_npm_package
@@ -53,7 +47,7 @@ jobs:
echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT"
- name: Upload staged npm package artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
uses: actions/upload-artifact@v7
with:
name: codex-npm-staging
path: ${{ steps.stage_npm_package.outputs.pack_output }}

View File

@@ -18,7 +18,7 @@ jobs:
if: ${{ github.repository_owner == 'openai' }}
runs-on: ubuntu-latest
steps:
- uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
- uses: contributor-assistant/github-action@v2.6.1
# Run on close only if the PR was merged. This will lock the PR to preserve
# the CLA agreement. We don't want to lock PRs that have been closed without
# merging because the contributor may want to respond with additional comments.

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Close inactive PRs from contributors
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1
- name: Codespell

View File

@@ -19,7 +19,7 @@ jobs:
reason: ${{ steps.normalize-all.outputs.reason }}
has_matches: ${{ steps.normalize-all.outputs.has_matches }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Prepare Codex inputs
env:
@@ -61,7 +61,7 @@ jobs:
# .github/prompts/issue-deduplicator.txt file is obsolete and removed.
- id: codex-all
name: Find duplicates (pass 1, all issues)
uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1
uses: openai/codex-action@main
with:
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
allow-users: "*"
@@ -155,7 +155,7 @@ jobs:
reason: ${{ steps.normalize-open.outputs.reason }}
has_matches: ${{ steps.normalize-open.outputs.has_matches }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Prepare Codex inputs
env:
@@ -195,7 +195,7 @@ jobs:
- id: codex-open
name: Find duplicates (pass 2, open issues)
uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1
uses: openai/codex-action@main
with:
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
allow-users: "*"
@@ -342,7 +342,7 @@ jobs:
issues: write
steps:
- name: Comment on issue
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
env:
CODEX_OUTPUT: ${{ needs.select-final.outputs.codex_output }}
with:

View File

@@ -17,10 +17,10 @@ jobs:
outputs:
codex_output: ${{ steps.codex.outputs.final-message }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- id: codex
uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1
uses: openai/codex-action@main
with:
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
allow-users: "*"
@@ -52,15 +52,13 @@ jobs:
6. code-review — Issues related to the code review feature or functionality.
7. safety-check - Issues related to cyber risk detection or trusted access verification.
8. auth - Problems related to authentication, login, or access tokens.
9. exec - Problems related to the "codex exec" command or functionality.
10. hooks - Problems related to event hooks
11. context - Problems related to compaction, context windows, or available context reporting.
12. skills - Problems related to skills or plugins
13. custom-model - Problems that involve using custom model providers, local models, or OSS models.
14. rate-limits - Problems related to token limits, rate limits, or token usage reporting.
15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions.
16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs.
17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues.
9. codex-exec - Problems related to the "codex exec" command or functionality.
10. context-management - Problems related to compaction, context windows, or available context reporting.
11. custom-model - Problems that involve using custom model providers, local models, or OSS models.
12. rate-limits - Problems related to token limits, rate limits, or token usage reporting.
13. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions.
14. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs.
15. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues.
Issue number: ${{ github.event.issue.number }}

View File

@@ -1,775 +0,0 @@
name: rust-ci-full
on:
push:
branches:
- main
workflow_dispatch:
# CI builds in debug (dev) for faster signal.
jobs:
# --- CI that doesn't need specific targets ---------------------------------
general:
name: Format / etc
runs-on: ubuntu-24.04
defaults:
run:
working-directory: codex-rs
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
with:
components: rustfmt
- name: cargo fmt
run: cargo fmt -- --config imports_granularity=Item --check
cargo_shear:
name: cargo shear
runs-on: ubuntu-24.04
defaults:
run:
working-directory: codex-rs
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: cargo-shear
version: 1.5.1
- name: cargo shear
run: cargo shear
argument_comment_lint_package:
name: Argument comment lint package
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
with:
toolchain: nightly-2025-09-18
components: llvm-tools-preview, rustc-dev, rust-src
- name: Cache cargo-dylint tooling
id: cargo_dylint_cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cargo/bin/cargo-dylint
~/.cargo/bin/dylint-link
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: argument-comment-lint-${{ runner.os }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml', '.github/workflows/rust-ci-full.yml') }}
- name: Install cargo-dylint tooling
if: ${{ steps.cargo_dylint_cache.outputs.cache-hit != 'true' }}
run: cargo install --locked cargo-dylint dylint-link
- name: Check Python wrapper syntax
run: python3 -m py_compile tools/argument-comment-lint/wrapper_common.py tools/argument-comment-lint/run.py tools/argument-comment-lint/run-prebuilt-linter.py tools/argument-comment-lint/test_wrapper_common.py
- name: Test Python wrapper helpers
run: python3 -m unittest discover -s tools/argument-comment-lint -p 'test_*.py'
- name: Test argument comment lint package
working-directory: tools/argument-comment-lint
run: cargo test
argument_comment_lint_prebuilt:
name: Argument comment lint - ${{ matrix.name }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 30
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-bazel-ci
with:
target: ${{ runner.os }}
install-test-prereqs: true
- name: Install Linux sandbox build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
sudo DEBIAN_FRONTEND=noninteractive apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- name: Run argument comment lint on codex-rs via Bazel
if: ${{ runner.os != 'Windows' }}
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)"
./.github/scripts/run-bazel-ci.sh \
-- \
build \
--config=argument-comment-lint \
--keep_going \
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
-- \
${bazel_targets}
- name: Run argument comment lint on codex-rs via Bazel
if: ${{ runner.os == 'Windows' }}
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
./.github/scripts/run-argument-comment-lint-bazel.sh \
--config=argument-comment-lint \
--platforms=//:local_windows \
--keep_going \
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
# --- CI to validate on different os/targets --------------------------------
lint_build:
name: Lint/Build — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 30
defaults:
run:
working-directory: codex-rs
env:
# 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.
CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: dev
- runner: macos-15-xlarge
target: x86_64-apple-darwin
profile: dev
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-arm64
# Also run representative release builds on Mac and Linux because
# there could be release-only build errors we want to catch.
# Hopefully this also pre-populates the build cache to speed up
# releases.
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: release
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
profile: release
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: release
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: release
runs_on:
group: codex-runners
labels: codex-windows-arm64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
packages=(pkg-config libcap-dev)
if [[ "${{ matrix.target }}" == 'x86_64-unknown-linux-musl' || "${{ matrix.target }}" == 'aarch64-unknown-linux-musl' ]]; then
packages+=(libubsan1)
fi
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}"
fi
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
with:
targets: ${{ matrix.target }}
components: clippy
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Use hermetic Cargo home (musl)
shell: bash
run: |
set -euo pipefail
cargo_home="${GITHUB_WORKSPACE}/.cargo-home"
mkdir -p "${cargo_home}/bin"
echo "CARGO_HOME=${cargo_home}" >> "$GITHUB_ENV"
echo "${cargo_home}/bin" >> "$GITHUB_PATH"
: > "${cargo_home}/config.toml"
- name: Compute lockfile hash
id: lockhash
working-directory: codex-rs
shell: bash
run: |
set -euo pipefail
echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
# Explicit cache restore: split cargo home vs target, so we can
# avoid caching the large target dir on the gnu-dev job.
- name: Restore cargo home cache
id: cache_cargo_home_restore
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/.cargo-home/bin/
${{ github.workspace }}/.cargo-home/registry/index/
${{ github.workspace }}/.cargo-home/registry/cache/
${{ github.workspace }}/.cargo-home/git/db/
key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
restore-keys: |
cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
# Install and restore sccache cache
- name: Install sccache
if: ${{ env.USE_SCCACHE == 'true' }}
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: sccache
version: 0.7.5
- name: Configure sccache backend
if: ${{ env.USE_SCCACHE == 'true' }}
shell: bash
run: |
set -euo pipefail
if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "Using sccache GitHub backend"
else
echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV"
echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV"
echo "Using sccache local disk + actions/cache fallback"
fi
- name: Enable sccache wrapper
if: ${{ env.USE_SCCACHE == 'true' }}
shell: bash
run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Restore sccache cache (fallback)
if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }}
id: cache_sccache_restore
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
restore-keys: |
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Disable sccache wrapper (musl)
shell: bash
run: |
set -euo pipefail
echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Prepare APT cache directories (musl)
shell: bash
run: |
set -euo pipefail
sudo mkdir -p /var/cache/apt/archives /var/lib/apt/lists
sudo chown -R "$USER:$USER" /var/cache/apt /var/lib/apt/lists
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Restore APT cache (musl)
id: cache_apt_restore
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
/var/cache/apt
key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
with:
version: 0.14.0
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools
env:
DEBIAN_FRONTEND: noninteractive
TARGET: ${{ matrix.target }}
APT_UPDATE_ARGS: -o Acquire::Retries=3
APT_INSTALL_ARGS: --no-install-recommends
shell: bash
run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Configure rustc UBSan wrapper (musl host)
shell: bash
run: |
set -euo pipefail
ubsan=""
if command -v ldconfig >/dev/null 2>&1; then
ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')"
fi
wrapper_root="${RUNNER_TEMP:-/tmp}"
wrapper="${wrapper_root}/rustc-ubsan-wrapper"
cat > "${wrapper}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "${ubsan}" ]]; then
export LD_PRELOAD="${ubsan}\${LD_PRELOAD:+:\${LD_PRELOAD}}"
fi
exec "\$1" "\${@:2}"
EOF
chmod +x "${wrapper}"
echo "RUSTC_WRAPPER=${wrapper}" >> "$GITHUB_ENV"
echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Clear sanitizer flags (musl)
shell: bash
run: |
set -euo pipefail
# Clear global Rust flags so host/proc-macro builds don't pull in UBSan.
echo "RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV"
# Override any runner-level Cargo config rustflags as well.
echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV"
sanitize_flags() {
local input="$1"
input="${input//-fsanitize=undefined/}"
input="${input//-fno-sanitize-recover=undefined/}"
input="${input//-fno-sanitize-trap=undefined/}"
echo "$input"
}
cflags="$(sanitize_flags "${CFLAGS-}")"
cxxflags="$(sanitize_flags "${CXXFLAGS-}")"
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
with:
tool: cargo-chef
version: 0.1.71
- name: Pre-warm dependency cache (cargo-chef)
if: ${{ matrix.profile == 'release' }}
shell: bash
run: |
set -euo pipefail
RECIPE="${RUNNER_TEMP}/chef-recipe.json"
cargo chef prepare --recipe-path "$RECIPE"
cargo chef cook --recipe-path "$RECIPE" --target ${{ matrix.target }} --release
- name: cargo clippy
run: cargo clippy --target ${{ matrix.target }} --tests --profile ${{ matrix.profile }} --timings -- -D warnings
- name: Upload Cargo timings (clippy)
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
# Save caches explicitly; make non-fatal so cache packaging
# never fails the overall job. Only save when key wasn't hit.
- name: Save cargo home cache
if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/.cargo-home/bin/
${{ github.workspace }}/.cargo-home/registry/index/
${{ github.workspace }}/.cargo-home/registry/cache/
${{ github.workspace }}/.cargo-home/git/db/
key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
- name: Save sccache cache (fallback)
if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true'
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
- name: sccache stats
if: always() && env.USE_SCCACHE == 'true'
continue-on-error: true
run: sccache --show-stats || true
- name: sccache summary
if: always() && env.USE_SCCACHE == 'true'
shell: bash
run: |
{
echo "### sccache stats — ${{ matrix.target }} (${{ matrix.profile }})";
echo;
echo '```';
sccache --show-stats || true;
echo '```';
} >> "$GITHUB_STEP_SUMMARY"
- name: Save APT cache (musl)
if: always() && !cancelled() && (matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl') && steps.cache_apt_restore.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
/var/cache/apt
key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1
tests:
name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
# Perhaps we can bring this back down to 30m once we finish the cutover
# from tui_app_server/ to tui/. Incidentally, windows-arm64 was the main
# offender for exceeding the timeout.
timeout-minutes: 45
defaults:
run:
working-directory: codex-rs
env:
# 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
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: dev
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
remote_env: "true"
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-arm64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js for js_repl tests
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version-file: codex-rs/node-version.txt
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
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
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
with:
targets: ${{ matrix.target }}
- name: Compute lockfile hash
id: lockhash
working-directory: codex-rs
shell: bash
run: |
set -euo pipefail
echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Restore cargo home cache
id: cache_cargo_home_restore
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
restore-keys: |
cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
- name: Install sccache
if: ${{ env.USE_SCCACHE == 'true' }}
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: sccache
version: 0.7.5
- name: Configure sccache backend
if: ${{ env.USE_SCCACHE == 'true' }}
shell: bash
run: |
set -euo pipefail
if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "Using sccache GitHub backend"
else
echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV"
echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV"
echo "Using sccache local disk + actions/cache fallback"
fi
- name: Enable sccache wrapper
if: ${{ env.USE_SCCACHE == 'true' }}
shell: bash
run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Restore sccache cache (fallback)
if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }}
id: cache_sccache_restore
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
restore-keys: |
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: nextest
version: 0.9.103
- name: Enable unprivileged user namespaces (Linux)
if: runner.os == 'Linux'
run: |
# Required for bubblewrap to work on Linux CI runners.
sudo sysctl -w kernel.unprivileged_userns_clone=1
# Ubuntu 24.04+ can additionally gate unprivileged user namespaces
# behind AppArmor.
if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then
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 --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings
env:
RUST_BACKTRACE: 1
NEXTEST_STATUS_LEVEL: leak
- name: Upload Cargo timings (nextest)
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: cargo-timings-rust-ci-nextest-${{ matrix.target }}-${{ matrix.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- name: Save cargo home cache
if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
- name: Save sccache cache (fallback)
if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true'
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
- name: sccache stats
if: always() && env.USE_SCCACHE == 'true'
continue-on-error: true
run: sccache --show-stats || true
- name: sccache summary
if: always() && env.USE_SCCACHE == 'true'
shell: bash
run: |
{
echo "### sccache stats — ${{ matrix.target }} (tests)";
echo;
echo '```';
sccache --show-stats || true;
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: |
echo "Tests failed. See logs for details."
exit 1
# --- Gatherer job for the full post-merge workflow --------------------------
results:
name: Full CI results
needs:
[
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 "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 }}"
echo "tests : ${{ needs.tests.result }}"
[[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; }
[[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; }
[[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; }
[[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; }
[[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; }
[[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; }
- name: sccache summary note
if: always()
run: |
echo "Per-job sccache stats are attached to each matrix job's Step Summary."

View File

@@ -1,10 +1,15 @@
name: rust-ci
on:
pull_request: {}
push:
branches:
- main
workflow_dispatch:
# CI builds in debug (dev) for faster signal.
jobs:
# --- Detect what changed so the fast PR workflow only runs relevant jobs ----
# --- Detect what changed to detect which tests to run (always runs) -------------------------------------
changed:
name: Detect changed areas
runs-on: ubuntu-24.04
@@ -14,7 +19,7 @@ jobs:
codex: ${{ steps.detect.outputs.codex }}
workflows: ${{ steps.detect.outputs.workflows }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Detect changed paths (no external action)
@@ -28,10 +33,11 @@ jobs:
HEAD_SHA='${{ github.event.pull_request.head.sha }}'
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# List files changed between base and PR head
mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA" "$HEAD_SHA")
else
# On manual runs, default to the full fast-PR bundle.
files=("codex-rs/force" "tools/argument-comment-lint/force" ".github/force")
# On push / manual runs, default to running everything
files=("codex-rs/force" ".github/force")
fi
codex=false
@@ -41,7 +47,7 @@ jobs:
for f in "${files[@]}"; do
[[ $f == codex-rs/* ]] && codex=true
[[ $f == codex-rs/* || $f == tools/argument-comment-lint/* || $f == justfile ]] && argument_comment_lint=true
[[ $f == tools/argument-comment-lint/* || $f == .github/workflows/rust-ci.yml || $f == .github/workflows/rust-ci-full.yml ]] && argument_comment_lint_package=true
[[ $f == tools/argument-comment-lint/* || $f == .github/workflows/rust-ci.yml ]] && argument_comment_lint_package=true
[[ $f == .github/* ]] && workflows=true
done
@@ -50,18 +56,18 @@ jobs:
echo "codex=$codex" >> "$GITHUB_OUTPUT"
echo "workflows=$workflows" >> "$GITHUB_OUTPUT"
# --- Fast Cargo-native PR checks -------------------------------------------
# --- CI that doesn't need specific targets ---------------------------------
general:
name: Format / etc
runs-on: ubuntu-24.04
needs: changed
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' }}
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
defaults:
run:
working-directory: codex-rs
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93.0
with:
components: rustfmt
- name: cargo fmt
@@ -71,13 +77,13 @@ jobs:
name: cargo shear
runs-on: ubuntu-24.04
needs: changed
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' }}
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
defaults:
run:
working-directory: codex-rs
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93.0
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: cargo-shear
@@ -89,23 +95,16 @@ jobs:
name: Argument comment lint package
runs-on: ubuntu-24.04
needs: changed
if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' }}
if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' || github.event_name == 'push' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- name: Install nightly argument-comment-lint toolchain
shell: bash
run: |
rustup toolchain install nightly-2025-09-18 \
--profile minimal \
--component llvm-tools-preview \
--component rustc-dev \
--component rust-src \
--no-self-update
rustup default nightly-2025-09-18
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93.0
with:
toolchain: nightly-2025-09-18
components: llvm-tools-preview, rustc-dev, rust-src
- name: Cache cargo-dylint tooling
id: cargo_dylint_cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/cargo-dylint
@@ -113,14 +112,12 @@ jobs:
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: argument-comment-lint-${{ runner.os }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml', '.github/workflows/rust-ci-full.yml') }}
key: argument-comment-lint-${{ runner.os }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml') }}
- name: Install cargo-dylint tooling
if: ${{ steps.cargo_dylint_cache.outputs.cache-hit != 'true' }}
run: cargo install --locked cargo-dylint dylint-link
- name: Check Python wrapper syntax
run: python3 -m py_compile tools/argument-comment-lint/wrapper_common.py tools/argument-comment-lint/run.py tools/argument-comment-lint/run-prebuilt-linter.py tools/argument-comment-lint/test_wrapper_common.py
- name: Test Python wrapper helpers
run: python3 -m unittest discover -s tools/argument-comment-lint -p 'test_*.py'
- name: Check source wrapper syntax
run: bash -n tools/argument-comment-lint/run.sh
- name: Test argument comment lint package
working-directory: tools/argument-comment-lint
run: cargo test
@@ -128,63 +125,651 @@ jobs:
argument_comment_lint_prebuilt:
name: Argument comment lint - ${{ matrix.name }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: ${{ matrix.timeout_minutes }}
needs: changed
if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' }}
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
timeout_minutes: 30
- name: macOS
runner: macos-15-xlarge
timeout_minutes: 30
- name: Windows
runner: windows-x64
timeout_minutes: 30
runs_on:
group: codex-runners
labels: codex-windows-x64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-bazel-ci
with:
target: ${{ runner.os }}
install-test-prereqs: true
- uses: actions/checkout@v6
- name: Install Linux sandbox build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
sudo DEBIAN_FRONTEND=noninteractive apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- name: Run argument comment lint on codex-rs via Bazel
if: ${{ runner.os != 'Windows' }}
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
- 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:
name: Lint/Build — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 30
needs: changed
# Keep job-level if to avoid spinning up runners when not needed
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, 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.
CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: dev
- runner: macos-15-xlarge
target: x86_64-apple-darwin
profile: dev
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-arm64
# Also run representative release builds on Mac and Linux because
# there could be release-only build errors we want to catch.
# Hopefully this also pre-populates the build cache to speed up
# releases.
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: release
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
profile: release
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: release
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: release
runs_on:
group: codex-runners
labels: codex-windows-arm64
steps:
- uses: actions/checkout@v6
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)"
./.github/scripts/run-bazel-ci.sh \
-- \
build \
--config=argument-comment-lint \
--keep_going \
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
-- \
${bazel_targets}
- name: Run argument comment lint on codex-rs via Bazel
if: ${{ runner.os == 'Windows' }}
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
packages=(pkg-config libcap-dev)
if [[ "${{ matrix.target }}" == 'x86_64-unknown-linux-musl' || "${{ matrix.target }}" == 'aarch64-unknown-linux-musl' ]]; then
packages+=(libubsan1)
fi
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}"
fi
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}
components: clippy
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Use hermetic Cargo home (musl)
shell: bash
run: |
./.github/scripts/run-argument-comment-lint-bazel.sh \
--config=argument-comment-lint \
--platforms=//:local_windows \
--keep_going \
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
set -euo pipefail
cargo_home="${GITHUB_WORKSPACE}/.cargo-home"
mkdir -p "${cargo_home}/bin"
echo "CARGO_HOME=${cargo_home}" >> "$GITHUB_ENV"
echo "${cargo_home}/bin" >> "$GITHUB_PATH"
: > "${cargo_home}/config.toml"
- name: Compute lockfile hash
id: lockhash
working-directory: codex-rs
shell: bash
run: |
set -euo pipefail
echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
# Explicit cache restore: split cargo home vs target, so we can
# avoid caching the large target dir on the gnu-dev job.
- name: Restore cargo home cache
id: cache_cargo_home_restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/.cargo-home/bin/
${{ github.workspace }}/.cargo-home/registry/index/
${{ github.workspace }}/.cargo-home/registry/cache/
${{ github.workspace }}/.cargo-home/git/db/
key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
restore-keys: |
cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
# Install and restore sccache cache
- name: Install sccache
if: ${{ env.USE_SCCACHE == 'true' }}
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: sccache
version: 0.7.5
- name: Configure sccache backend
if: ${{ env.USE_SCCACHE == 'true' }}
shell: bash
run: |
set -euo pipefail
if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "Using sccache GitHub backend"
else
echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV"
echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV"
echo "Using sccache local disk + actions/cache fallback"
fi
- name: Enable sccache wrapper
if: ${{ env.USE_SCCACHE == 'true' }}
shell: bash
run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Restore sccache cache (fallback)
if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }}
id: cache_sccache_restore
uses: actions/cache/restore@v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
restore-keys: |
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Disable sccache wrapper (musl)
shell: bash
run: |
set -euo pipefail
echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Prepare APT cache directories (musl)
shell: bash
run: |
set -euo pipefail
sudo mkdir -p /var/cache/apt/archives /var/lib/apt/lists
sudo chown -R "$USER:$USER" /var/cache/apt /var/lib/apt/lists
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Restore APT cache (musl)
id: cache_apt_restore
uses: actions/cache/restore@v5
with:
path: |
/var/cache/apt
key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
with:
version: 0.14.0
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools
env:
DEBIAN_FRONTEND: noninteractive
TARGET: ${{ matrix.target }}
APT_UPDATE_ARGS: -o Acquire::Retries=3
APT_INSTALL_ARGS: --no-install-recommends
shell: bash
run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Configure rustc UBSan wrapper (musl host)
shell: bash
run: |
set -euo pipefail
ubsan=""
if command -v ldconfig >/dev/null 2>&1; then
ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')"
fi
wrapper_root="${RUNNER_TEMP:-/tmp}"
wrapper="${wrapper_root}/rustc-ubsan-wrapper"
cat > "${wrapper}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "${ubsan}" ]]; then
export LD_PRELOAD="${ubsan}\${LD_PRELOAD:+:\${LD_PRELOAD}}"
fi
exec "\$1" "\${@:2}"
EOF
chmod +x "${wrapper}"
echo "RUSTC_WRAPPER=${wrapper}" >> "$GITHUB_ENV"
echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Clear sanitizer flags (musl)
shell: bash
run: |
set -euo pipefail
# Clear global Rust flags so host/proc-macro builds don't pull in UBSan.
echo "RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV"
# Override any runner-level Cargo config rustflags as well.
echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV"
sanitize_flags() {
local input="$1"
input="${input//-fsanitize=undefined/}"
input="${input//-fno-sanitize-recover=undefined/}"
input="${input//-fno-sanitize-trap=undefined/}"
echo "$input"
}
cflags="$(sanitize_flags "${CFLAGS-}")"
cxxflags="$(sanitize_flags "${CXXFLAGS-}")"
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
with:
tool: cargo-chef
version: 0.1.71
- name: Pre-warm dependency cache (cargo-chef)
if: ${{ matrix.profile == 'release' }}
shell: bash
run: |
set -euo pipefail
RECIPE="${RUNNER_TEMP}/chef-recipe.json"
cargo chef prepare --recipe-path "$RECIPE"
cargo chef cook --recipe-path "$RECIPE" --target ${{ matrix.target }} --release --all-features
- name: cargo clippy
run: cargo clippy --target ${{ matrix.target }} --all-features --tests --profile ${{ matrix.profile }} --timings -- -D warnings
- name: Upload Cargo timings (clippy)
if: always()
uses: actions/upload-artifact@v7
with:
name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
# Save caches explicitly; make non-fatal so cache packaging
# never fails the overall job. Only save when key wasn't hit.
- name: Save cargo home cache
if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/.cargo-home/bin/
${{ github.workspace }}/.cargo-home/registry/index/
${{ github.workspace }}/.cargo-home/registry/cache/
${{ github.workspace }}/.cargo-home/git/db/
key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
- name: Save sccache cache (fallback)
if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
- name: sccache stats
if: always() && env.USE_SCCACHE == 'true'
continue-on-error: true
run: sccache --show-stats || true
- name: sccache summary
if: always() && env.USE_SCCACHE == 'true'
shell: bash
run: |
{
echo "### sccache stats — ${{ matrix.target }} (${{ matrix.profile }})";
echo;
echo '```';
sccache --show-stats || true;
echo '```';
} >> "$GITHUB_STEP_SUMMARY"
- name: Save APT cache (musl)
if: always() && !cancelled() && (matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl') && steps.cache_apt_restore.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
path: |
/var/cache/apt
key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1
tests:
name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
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, 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
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: dev
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
remote_env: "true"
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-arm64
steps:
- uses: actions/checkout@v6
- name: Set up Node.js for js_repl tests
uses: actions/setup-node@v6
with:
node-version-file: codex-rs/node-version.txt
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
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
uses: facebook/install-dotslash@v2
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}
- name: Compute lockfile hash
id: lockhash
working-directory: codex-rs
shell: bash
run: |
set -euo pipefail
echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Restore cargo home cache
id: cache_cargo_home_restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
restore-keys: |
cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
- name: Install sccache
if: ${{ env.USE_SCCACHE == 'true' }}
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: sccache
version: 0.7.5
- name: Configure sccache backend
if: ${{ env.USE_SCCACHE == 'true' }}
shell: bash
run: |
set -euo pipefail
if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "Using sccache GitHub backend"
else
echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV"
echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV"
echo "Using sccache local disk + actions/cache fallback"
fi
- name: Enable sccache wrapper
if: ${{ env.USE_SCCACHE == 'true' }}
shell: bash
run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Restore sccache cache (fallback)
if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }}
id: cache_sccache_restore
uses: actions/cache/restore@v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
restore-keys: |
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: nextest
version: 0.9.103
- name: Enable unprivileged user namespaces (Linux)
if: runner.os == 'Linux'
run: |
# Required for bubblewrap to work on Linux CI runners.
sudo sysctl -w kernel.unprivileged_userns_clone=1
# Ubuntu 24.04+ can additionally gate unprivileged user namespaces
# behind AppArmor.
if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then
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
env:
RUST_BACKTRACE: 1
NEXTEST_STATUS_LEVEL: leak
- name: Upload Cargo timings (nextest)
if: always()
uses: actions/upload-artifact@v7
with:
name: cargo-timings-rust-ci-nextest-${{ matrix.target }}-${{ matrix.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- name: Save cargo home cache
if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
- name: Save sccache cache (fallback)
if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
- name: sccache stats
if: always() && env.USE_SCCACHE == 'true'
continue-on-error: true
run: sccache --show-stats || true
- name: sccache summary
if: always() && env.USE_SCCACHE == 'true'
shell: bash
run: |
{
echo "### sccache stats — ${{ matrix.target }} (tests)";
echo;
echo '```';
sccache --show-stats || true;
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: |
echo "Tests failed. See logs for details."
exit 1
# --- Gatherer job that you mark as the ONLY required status -----------------
results:
@@ -196,6 +781,8 @@ jobs:
cargo_shear,
argument_comment_lint_package,
argument_comment_lint_prebuilt,
lint_build,
tests,
]
if: always()
runs-on: ubuntu-24.04
@@ -207,23 +794,32 @@ jobs:
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 }}"
echo "tests : ${{ needs.tests.result }}"
# If nothing relevant changed (PR touching only root README, etc.),
# declare success regardless of other jobs.
if [[ '${{ needs.changed.outputs.argument_comment_lint }}' != 'true' && '${{ needs.changed.outputs.codex }}' != 'true' && '${{ needs.changed.outputs.workflows }}' != 'true' ]]; then
if [[ '${{ needs.changed.outputs.argument_comment_lint }}' != 'true' && '${{ needs.changed.outputs.codex }}' != 'true' && '${{ needs.changed.outputs.workflows }}' != 'true' && '${{ github.event_name }}' != 'push' ]]; then
echo 'No relevant changes -> CI not required.'
exit 0
fi
if [[ '${{ needs.changed.outputs.argument_comment_lint_package }}' == 'true' ]]; then
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' ]]; then
if [[ '${{ needs.changed.outputs.argument_comment_lint }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' || '${{ github.event_name }}' == 'push' ]]; then
[[ '${{ 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' ]]; then
if [[ '${{ needs.changed.outputs.codex }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' || '${{ github.event_name }}' == 'push' ]]; then
[[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; }
[[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; }
[[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; }
[[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; }
fi
- name: sccache summary note
if: always()
run: |
echo "Per-job sccache stats are attached to each matrix job's Step Summary."

View File

@@ -53,9 +53,9 @@ jobs:
labels: codex-windows-x64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- uses: dtolnay/rust-toolchain@1.93.0
with:
toolchain: nightly-2025-09-18
targets: ${{ matrix.target }}
@@ -97,7 +97,7 @@ jobs:
(cd "${RUNNER_TEMP}" && tar -czf "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint)
fi
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- uses: actions/upload-artifact@v7
with:
name: argument-comment-lint-${{ matrix.target }}
path: dist/argument-comment-lint/${{ matrix.target }}/*

View File

@@ -18,7 +18,7 @@ jobs:
if: github.repository == 'openai/codex'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
@@ -43,7 +43,7 @@ jobs:
curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/core/models.json
- name: Open pull request (if changed)
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
uses: peter-evans/create-pull-request@v8
with:
commit-message: "Update models.json"
title: "Update models.json"

View File

@@ -67,7 +67,7 @@ jobs:
labels: codex-windows-arm64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Print runner specs (Windows)
shell: powershell
run: |
@@ -82,7 +82,7 @@ jobs:
Write-Host "Total RAM: $ramGiB GiB"
Write-Host "Disk usage:"
Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}}
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}
@@ -92,7 +92,7 @@ jobs:
cargo build --target ${{ matrix.target }} --release --timings ${{ matrix.build_args }}
- name: Upload Cargo timings
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
uses: actions/upload-artifact@v7
with:
name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
@@ -112,7 +112,7 @@ jobs:
fi
- name: Upload Windows binaries
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
uses: actions/upload-artifact@v7
with:
name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }}
path: |
@@ -147,16 +147,16 @@ jobs:
labels: codex-windows-arm64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Download prebuilt Windows primary binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
uses: actions/download-artifact@v8
with:
name: windows-binaries-${{ matrix.target }}-primary
path: codex-rs/target/${{ matrix.target }}/release
- name: Download prebuilt Windows helper binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
uses: actions/download-artifact@v8
with:
name: windows-binaries-${{ matrix.target }}-helpers
path: codex-rs/target/${{ matrix.target }}/release
@@ -193,7 +193,7 @@ jobs:
cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe"
- name: Install DotSlash
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
uses: facebook/install-dotslash@v2
- name: Compress artifacts
shell: bash
@@ -257,7 +257,7 @@ jobs:
"${GITHUB_WORKSPACE}/.github/workflows/zstd" -T0 -19 "$dest/$base"
done
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}
path: |

View File

@@ -45,7 +45,7 @@ jobs:
git \
libncursesw5-dev
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Build, smoke-test, and stage zsh artifact
shell: bash
@@ -53,7 +53,7 @@ jobs:
"${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \
"dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}"
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- uses: actions/upload-artifact@v7
with:
name: codex-zsh-${{ matrix.target }}
path: dist/zsh/${{ matrix.target }}/*
@@ -81,7 +81,7 @@ jobs:
brew install autoconf
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Build, smoke-test, and stage zsh artifact
shell: bash
@@ -89,7 +89,7 @@ jobs:
"${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \
"dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}"
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- uses: actions/upload-artifact@v7
with:
name: codex-zsh-${{ matrix.target }}
path: dist/zsh/${{ matrix.target }}/*

View File

@@ -19,8 +19,8 @@ jobs:
tag-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@c2b55edffaf41a251c410bb32bed22afefa800f1 # 1.92
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.92
- name: Validate tag matches Cargo.toml version
shell: bash
run: |
@@ -79,7 +79,7 @@ jobs:
target: aarch64-unknown-linux-gnu
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Print runner specs (Linux)
if: ${{ runner.os == 'Linux' }}
shell: bash
@@ -125,7 +125,7 @@ jobs:
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1
fi
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}
@@ -235,7 +235,7 @@ jobs:
cargo build --target ${{ matrix.target }} --release --timings --bin codex --bin codex-responses-api-proxy
- name: Upload Cargo timings
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
uses: actions/upload-artifact@v7
with:
name: cargo-timings-rust-release-${{ matrix.target }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
@@ -374,7 +374,7 @@ jobs:
zstd -T0 -19 --rm "$dest/$base"
done
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}
# Upload the per-binary .zst files as well as the new .tar.gz
@@ -420,7 +420,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
- name: Generate release notes from tag commit message
id: release_notes
@@ -442,7 +442,7 @@ jobs:
echo "path=${notes_path}" >> "${GITHUB_OUTPUT}"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- uses: actions/download-artifact@v8
with:
path: dist
@@ -492,12 +492,12 @@ jobs:
fi
- name: Setup pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js for npm packaging
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@v6
with:
node-version: 22
@@ -505,7 +505,7 @@ jobs:
run: pnpm install --frozen-lockfile
# stage_npm_packages.py requires DotSlash when staging releases.
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- uses: facebook/install-dotslash@v2
- name: Stage npm packages
env:
GH_TOKEN: ${{ github.token }}
@@ -523,7 +523,7 @@ jobs:
cp scripts/install/install.ps1 dist/install.ps1
- name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@v2
with:
name: ${{ steps.release_name.outputs.name }}
tag_name: ${{ github.ref_name }}
@@ -533,21 +533,21 @@ jobs:
# (e.g. -alpha, -beta). Otherwise publish a normal release.
prerelease: ${{ contains(steps.release_name.outputs.name, '-') }}
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- uses: facebook/dotslash-publish-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-config.json
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- 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@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- uses: facebook/dotslash-publish-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -582,7 +582,7 @@ jobs:
steps:
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@v6
with:
node-version: 22
registry-url: "https://registry.npmjs.org"

View File

@@ -25,10 +25,10 @@ jobs:
v8_version: ${{ steps.v8_version.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@v6
with:
python-version: "3.12"
@@ -75,13 +75,13 @@ jobs:
target: aarch64-unknown-linux-musl
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@6ecf4fd8b7d1f9721785f1dd656a689acf9add47 # v3
uses: bazelbuild/setup-bazelisk@v3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@v6
with:
python-version: "3.12"
@@ -116,8 +116,8 @@ jobs:
bazel \
--noexperimental_remote_repo_contents_cache \
--bazelrc=.github/workflows/v8-ci.bazelrc \
"${bazel_args[@]}" \
--config=ci-v8 \
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
- name: Stage release pair
@@ -135,7 +135,7 @@ jobs:
--output-dir "dist/${TARGET}"
- name: Upload staged musl artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
uses: actions/upload-artifact@v7
with:
name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }}
path: dist/${{ matrix.target }}/*
@@ -174,12 +174,12 @@ jobs:
exit 1
fi
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- uses: actions/download-artifact@v8
with:
path: dist
- name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.metadata.outputs.release_tag }}
name: ${{ needs.metadata.outputs.release_tag }}

View File

@@ -13,7 +13,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
- name: Install Linux bwrap build dependencies
shell: bash
@@ -23,82 +23,21 @@ jobs:
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- name: Setup pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Set up Bazel CI
id: setup_bazel
uses: ./.github/actions/setup-bazel-ci
with:
target: x86_64-unknown-linux-gnu
- uses: dtolnay/rust-toolchain@1.93.0
- name: Build codex with Bazel
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
set -euo pipefail
# Use the shared CI wrapper so fork PRs fall back cleanly when
# BuildBuddy credentials are unavailable. This workflow needs the
# built `codex` binary on disk afterwards, so ask the wrapper to
# override CI's default remote_download_minimal behavior.
./.github/scripts/run-bazel-ci.sh \
--remote-download-toplevel \
-- \
build \
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
--build_metadata=TAG_job=sdk \
-- \
//codex-rs/cli:codex
# Resolve the exact output file using the same wrapper/config path as
# the build instead of guessing which Bazel convenience symlink is
# available on the runner.
cquery_output="$(
./.github/scripts/run-bazel-ci.sh \
-- \
cquery \
--output=files \
-- \
//codex-rs/cli:codex \
| grep -E '^(/|bazel-out/)' \
| tail -n 1
)"
if [[ "${cquery_output}" = /* ]]; then
codex_bazel_output_path="${cquery_output}"
else
codex_bazel_output_path="${GITHUB_WORKSPACE}/${cquery_output}"
fi
if [[ -z "${codex_bazel_output_path}" ]]; then
echo "Bazel did not report an output path for //codex-rs/cli:codex." >&2
exit 1
fi
if [[ ! -e "${codex_bazel_output_path}" ]]; then
echo "Unable to locate the Bazel-built codex binary at ${codex_bazel_output_path}." >&2
exit 1
fi
# Stage the binary into the workspace and point the SDK tests at that
# stable path. The tests spawn `codex` directly many times, so using a
# normal executable path is more reliable than invoking Bazel for each
# test process.
install_dir="${GITHUB_WORKSPACE}/.tmp/sdk-ci"
mkdir -p "${install_dir}"
install -m 755 "${codex_bazel_output_path}" "${install_dir}/codex"
echo "CODEX_EXEC_PATH=${install_dir}/codex" >> "$GITHUB_ENV"
- name: Warm up Bazel-built codex
shell: bash
run: |
set -euo pipefail
"${CODEX_EXEC_PATH}" --version
- name: build codex
run: cargo build --bin codex
working-directory: codex-rs
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -111,12 +50,3 @@ jobs:
- name: Test SDK packages
run: pnpm -r --filter ./sdk/typescript run test
- name: Save bazel repository cache
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cache/bazel-repo-cache
key: bazel-cache-x86_64-unknown-linux-gnu-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}

View File

@@ -38,10 +38,10 @@ jobs:
v8_version: ${{ steps.v8_version.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@v6
with:
python-version: "3.12"
@@ -72,13 +72,13 @@ jobs:
target: aarch64-unknown-linux-musl
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@6ecf4fd8b7d1f9721785f1dd656a689acf9add47 # v3
uses: bazelbuild/setup-bazelisk@v3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@v6
with:
python-version: "3.12"
@@ -108,8 +108,8 @@ jobs:
bazel \
--noexperimental_remote_repo_contents_cache \
--bazelrc=.github/workflows/v8-ci.bazelrc \
"${bazel_args[@]}" \
--config=ci-v8 \
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
- name: Stage release pair
@@ -126,7 +126,7 @@ jobs:
--output-dir "dist/${TARGET}"
- name: Upload staged musl artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
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

1
.gitignore vendored
View File

@@ -10,7 +10,6 @@ node_modules
# build
dist/
bazel-*
user.bazelrc
build/
out/
storybook-static/

View File

@@ -15,10 +15,8 @@ In the codex-rs folder where the rust code lives:
- When you cannot make that API change and still need a small positional-literal callsite in Rust, follow the `argument_comment_lint` convention:
- Use an exact `/*param_name*/` comment before opaque literal arguments such as `None`, booleans, and numeric literals when passing them by position.
- Do not add these comments for string or char literals unless the comment adds real clarity; those literals are intentionally exempt from the lint.
- The parameter name in the comment must exactly match the callee signature.
- You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not.
- If you add one of these comments, the parameter name must exactly match the callee signature.
- When possible, make `match` statements exhaustive and avoid wildcard arms.
- Newly added traits should include doc comments that explain their role and how implementations are expected to use them.
- When writing tests, prefer comparing the equality of entire objects over fields one by one.
- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable.
- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`.
@@ -42,7 +40,6 @@ In the codex-rs folder where the rust code lives:
`codex-rs/tui/src/bottom_pane/mod.rs`, and similarly central orchestration modules.
- When extracting code from a large module, move the related tests and module/type docs toward
the new implementation so the invariants stay close to the code that owns them.
- When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected.
Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests:
@@ -51,18 +48,7 @@ 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`.
## The `codex-core` crate
Over time, the `codex-core` crate (defined in `codex-rs/core/`) has become bloated because it is the largest crate, so it is often easier to add something new to `codex-core` rather than refactor out the library code you need so your new code neither takes a dependency on, nor contributes to the size of, `codex-core`.
To that end: **resist adding code to codex-core**!
Particularly when introducing a new concept/feature/API, before adding to `codex-core`, consider whether:
- There is an existing crate other than `codex-core` that is an appropriate place for your new code to live.
- It is time to introduce a new crate to the Cargo workspace for your new functionality. Refactor existing code as necessary to make this happen.
Likewise, when reviewing code, do not hesitate to push back on PRs that would unnecessarily add code to `codex-core`.
Also run `just argument-comment-lint` to ensure the codebase is clean of comment lint errors.
## TUI style conventions
@@ -70,6 +56,8 @@ See `codex-rs/tui/styles.md`.
## TUI code conventions
- When a change lands in `codex-rs/tui` and `codex-rs/tui_app_server` has a parallel implementation of the same behavior, reflect the change in `codex-rs/tui_app_server` too unless there is a documented reason not to.
- Use concise styling helpers from ratatuis Stylize trait.
- Basic spans: use "text".into()
- Styled spans: use "text".red(), "text".green(), "text".magenta(), "text".dim(), etc.

View File

@@ -17,19 +17,12 @@ platform(
platform(
name = "local_windows",
constraint_values = [
# We just need to pick one of the ABIs. Do the same one we target.
"@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm",
],
parents = ["@platforms//host"],
)
platform(
name = "local_windows_msvc",
constraint_values = [
"@rules_rs//rs/experimental/platforms/constraints:windows_msvc",
],
parents = ["@platforms//host"],
)
alias(
name = "rbe",
actual = "@rbe_platform",

View File

@@ -3,35 +3,16 @@ 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.8")
# The upstream LLVM archive contains a few unix-only symlink entries and is
# missing a couple of MinGW compatibility archives that windows-gnullvm needs
# during extraction and linking, so patch it until upstream grows native support.
single_version_override(
module_name = "llvm",
patch_strip = 1,
patches = [
"//patches:llvm_windows_symlink_extract.patch",
],
)
# Abseil picks a MinGW pthread TLS path that does not match our hermetic
# windows-gnullvm toolchain; force it onto the portable C++11 thread-local path.
single_version_override(
module_name = "abseil-cpp",
patch_strip = 1,
patches = [
"//patches:abseil_windows_gnullvm_thread_identity.patch",
],
)
register_toolchains("@llvm//toolchain:all")
osx = use_extension("@llvm//extensions:osx.bzl", "osx")
osx.from_archive(
sha256 = "1bde70c0b1c2ab89ff454acbebf6741390d7b7eb149ca2a3ca24cc9203a408b7",
strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.4.sdk",
sha256 = "6a4922f89487a96d7054ec6ca5065bfddd9f1d017c74d82f1d79cecf7feb8228",
strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.2.sdk",
type = "pkg",
urls = [
"https://swcdn.apple.com/content/downloads/32/53/047-96692-A_OAHIHT53YB/ybtshxmrcju8m2qvw3w5elr4rajtg1x3y3/CLTools_macOSNMOS_SDK.pkg",
"https://swcdn.apple.com/content/downloads/26/44/047-81934-A_28TPKM5SD1/ps6pk6dk4x02vgfa5qsctq6tgf23t5f0w2/CLTools_macOSNMOS_SDK.pkg",
],
)
osx.frameworks(names = [
@@ -63,78 +44,10 @@ bazel_dep(name = "apple_support", version = "2.1.0")
bazel_dep(name = "rules_cc", version = "0.2.16")
bazel_dep(name = "rules_platform", version = "0.1.0")
bazel_dep(name = "rules_rs", version = "0.0.43")
# `rules_rs` 0.0.43 does not model `windows-gnullvm` as a distinct Windows exec
# platform, so patch it until upstream grows that support for both x86_64 and
# aarch64.
single_version_override(
module_name = "rules_rs",
patch_strip = 1,
patches = [
"//patches:rules_rs_windows_gnullvm_exec.patch",
"//patches:rules_rs_delete_git_worktree_pointer.patch",
],
version = "0.0.43",
)
rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_rust")
# Build-script probe binaries inherit CFLAGS/CXXFLAGS from Bazel's C++
# toolchain. On `windows-gnullvm`, llvm-mingw does not ship
# `libssp_nonshared`, so strip the forwarded stack-protector flags there.
rules_rust.patch(
patches = [
"//patches:rules_rust_windows_gnullvm_build_script.patch",
"//patches:rules_rust_windows_exec_msvc_build_script_env.patch",
"//patches:rules_rust_windows_bootstrap_process_wrapper_linker.patch",
"//patches:rules_rust_windows_msvc_direct_link_args.patch",
"//patches:rules_rust_windows_exec_bin_target.patch",
"//patches:rules_rust_windows_exec_std.patch",
"//patches:rules_rust_windows_exec_rustc_dev_rlib.patch",
"//patches:rules_rust_repository_set_exec_constraints.patch",
],
strip = 1,
)
use_repo(rules_rust, "rules_rust")
nightly_rust = use_extension(
"@rules_rs//rs/experimental:rules_rust_reexported_extensions.bzl",
"rust",
)
nightly_rust.toolchain(
versions = ["nightly/2025-09-18"],
dev_components = True,
edition = "2024",
)
# Keep Windows exec tools on MSVC so Bazel helper binaries link correctly, but
# lint crate targets as `windows-gnullvm` to preserve the repo's actual cfgs.
nightly_rust.repository_set(
name = "rust_windows_x86_64",
dev_components = True,
edition = "2024",
exec_triple = "x86_64-pc-windows-msvc",
exec_compatible_with = [
"@platforms//cpu:x86_64",
"@platforms//os:windows",
"@rules_rs//rs/experimental/platforms/constraints:windows_msvc",
],
target_compatible_with = [
"@platforms//cpu:x86_64",
"@platforms//os:windows",
"@rules_rs//rs/experimental/platforms/constraints:windows_msvc",
],
target_triple = "x86_64-pc-windows-msvc",
versions = ["nightly/2025-09-18"],
)
nightly_rust.repository_set(
name = "rust_windows_x86_64",
target_compatible_with = [
"@platforms//cpu:x86_64",
"@platforms//os:windows",
"@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm",
],
target_triple = "x86_64-pc-windows-gnullvm",
)
use_repo(nightly_rust, "rust_toolchains")
toolchains = use_extension("@rules_rs//rs/experimental/toolchains:module_extension.bzl", "toolchains")
toolchains.toolchain(
edition = "2024",
@@ -143,7 +56,6 @@ toolchains.toolchain(
use_repo(toolchains, "default_rust_toolchains")
register_toolchains("@default_rust_toolchains//:all")
register_toolchains("@rust_toolchains//:all")
crate = use_extension("@rules_rs//rs:extensions.bzl", "crate")
crate.from_cargo(
@@ -153,33 +65,10 @@ crate.from_cargo(
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"aarch64-apple-darwin",
# Keep both Windows ABIs in the generated Cargo metadata: the V8
# experiment still consumes release assets that only exist under the
# MSVC names while targeting the GNU toolchain.
"aarch64-pc-windows-msvc",
"aarch64-pc-windows-gnullvm",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"x86_64-pc-windows-gnullvm",
],
use_experimental_platforms = True,
)
crate.from_cargo(
name = "argument_comment_lint_crates",
cargo_lock = "//tools/argument-comment-lint:Cargo.lock",
cargo_toml = "//tools/argument-comment-lint:Cargo.toml",
platform_triples = [
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"aarch64-apple-darwin",
"aarch64-pc-windows-msvc",
"aarch64-pc-windows-gnullvm",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"x86_64-pc-windows-gnullvm",
],
use_experimental_platforms = True,
@@ -200,19 +89,10 @@ crate.annotation(
patch_args = ["-p1"],
patches = [
"//patches:aws-lc-sys_memcmp_check.patch",
"//patches:aws-lc-sys_windows_msvc_prebuilt_nasm.patch",
"//patches:aws-lc-sys_windows_msvc_memcmp_probe.patch",
],
)
crate.annotation(
# The build script only validates embedded source/version metadata.
crate = "rustc_apfloat",
gen_build_script = "off",
)
inject_repo(crate, "zstd")
use_repo(crate, "argument_comment_lint_crates")
bazel_dep(name = "bzip2", version = "1.0.8.bcr.3")

83
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

144
android/AGENTS.md Normal file
View File

@@ -0,0 +1,144 @@
# Android Agent/Genie
This file applies to the Android subtree under `android/`. It is developer-facing
context for working on the Android Agent/Genie implementation in this repo.
Do not confuse this with the runtime guidance asset at
`android/bridge/src/main/assets/AGENTS.md`, which is copied into Codex homes on
device for live Agent/Genie sessions.
## Module layout
- `android/app`: Agent app
- `android/genie`: Genie app
- `android/bridge`: shared Android bridge/runtime compatibility layer
- `android/build-agent-genie-apks.sh`: helper for building both APKs
- `android/install-and-provision-agent-genie.sh`: helper for adb install, role
assignment, and auth seeding
## Default SDK input
When building the Android APKs, use the branch-local Android Agent Platform stub
SDK at:
`$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip`
The Android build already accepts this through either:
- environment: `ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP`
- script flag: `android/build-agent-genie-apks.sh --agent-sdk-zip ...`
- Gradle property: `-PagentPlatformStubSdkZip=...`
Treat this path as the default SDK source unless the user explicitly says
otherwise.
## Authoritative design references
Read these first when recovering Android Agent/Genie context:
- local architecture/status doc:
`docs/android-agent-genie-refactor.md`
- SDK docs inside the stub SDK zip:
- `README.md`
- `AGENT_GENIE_DESIGN.md`
- `CONSUMER_GUIDE.md`
Useful inspection commands:
```bash
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" README.md | sed -n '1,220p'
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" AGENT_GENIE_DESIGN.md | sed -n '1,260p'
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" CONSUMER_GUIDE.md | sed -n '1,260p'
```
## Key platform contract to preserve
The current Android work in this repo assumes the same contract described by the
stub SDK docs and the local refactor doc:
- Agent and Genie are separate APKs.
- The framework-managed per-session bridge is the app-private Agent<->Genie
control plane.
- The framework-owned streaming HTTP exchange is the transport for active
`/responses` traffic in both top-level Agent planner sessions and live Genie
child sessions.
- Genie is headless and should not depend on direct internet access.
- Detached target handling must use framework-authoritative presentation/runtime
state and typed detached-target recovery APIs rather than guessed relaunches.
- App-scoped HOME drafts are real framework `STATE_CREATED` sessions created
before `startGenieSession(...)`; if you expose that flow outside the on-device
UI, remember that provisional HOME sessions are expected to hold a
session-UI lease until they are started or cancelled.
- Desktop draft attach is now implemented by bootstrapping an idle app-server
runtime before the first turn:
- HOME drafts start Genie with an internal idle-bootstrap sentinel and become
attachable immediately after bridge bootstrap.
- direct AGENT drafts spin up an idle planner app-server host inside the
Agent process.
- the first prompt can then be typed in the attached desktop TUI instead of
being supplied to `sessions start`.
- Attached runtime completion semantics are intentionally non-terminal:
- attached Genie turns remain live after `turn/completed` so the same desktop
TUI can send follow-up prompts.
- attached direct AGENT planner sessions stay live after planning completes.
- child Genie sessions spawned by an attached planner are launched in idle
desktop-attach mode instead of immediately consuming their delegated prompt.
- those idle child sessions still receive Agent-provisioned bridge state
first, stage the delegated objective as runtime context, and remain
attachable while the planner stays attached.
- if the planner detaches before the user manually starts the child, the
staged delegated objective is released automatically as a fallback.
- after a child turn completes, planner-held child sessions remain attachable
until the planner attach detaches.
- once the planner detaches, those held child sessions are allowed to settle
to their terminal framework state and the parent roll-up can complete.
- Recoverable hosted-runtime failures are also intentionally non-terminal when a
fresh app-server thread can still be bootstrapped:
- recoverable app-server / bridge I/O failures during an attached Genie turn
close only the current desktop attach, then restart the Genie into a fresh
attachable idle thread with staged recovery context
- recoverable I/O failures during an unattached Genie run first retry
automatically with staged recovery context, then pause into an attachable
idle recovery thread if automatic retries are exhausted
- only failures that prevent bootstrapping any new hosted runtime at all
should still terminate the Genie session
- Parent-session cancellation is tree-scoped for direct AGENT sessions:
cancelling the parent from the desktop bridge, framework tool bridge, or the
detail UI must cancel the parent and all child Genie sessions through the
framework `cancelSession(...)` path, even when some of those sessions are
already terminal.
## External reference implementations
There are standalone stub apps outside this repo that are useful for
understanding the intended Android API usage:
- Agent stub root:
`$HOME/code/omix/AgentStub`
- Genie stub root:
`$HOME/code/omix/GenieStub`
Especially useful files:
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/ValidationAgentService.java`
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/AgentOrchestrationService.java`
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/SessionActivity.java`
- `$HOME/code/omix/AgentStub/README-standalone.md`
- `$HOME/code/omix/GenieStub/src/com/example/geniestub/ValidationGenieService.java`
- `$HOME/code/omix/GenieStub/README-standalone.md`
Use these as contract/reference implementations for session lifecycle, detached
target control, question flow, and framework HTTP exchange usage.
## Recovery checklist
When returning to Android Agent/Genie work after interruption:
1. Read `docs/android-agent-genie-refactor.md` for the current architecture and
recent implementation status.
2. Re-read the three markdown files inside the stub SDK zip if the framework
contract matters for the change.
3. Check `git log --oneline -- android docs/android-agent-genie-refactor.md` to
see the latest Android-specific changes.
4. If behavior is ambiguous, compare against the AgentStub/GenieStub reference
implementations before changing repo code.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 791 KiB

View File

@@ -0,0 +1,123 @@
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"))
implementation("org.java-websocket:Java-WebSocket:1.5.7")
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,80 @@
<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>
<service
android:name=".DesktopAttachKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<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" />
<receiver
android:name=".DesktopBridgeBootstrapReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.openai.codex.agent.action.BOOTSTRAP_DESKTOP_BRIDGE" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,780 @@
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("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.cancelSessionTree(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,865 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.content.Context
import android.util.Log
import com.openai.codex.bridge.FrameworkEventBridge
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.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
import org.json.JSONArray
import org.json.JSONObject
internal class AgentPlannerDesktopSessionHost(
private val context: Context,
private val sessionController: AgentSessionController,
private val sessionId: String,
private val onClosed: () -> Unit,
) : Closeable {
companion object {
private const val TAG = "AgentPlannerDesktop"
private const val REQUEST_TIMEOUT_MS = 30_000L
private const val POLL_TIMEOUT_MS = 250L
private const val REMOTE_REQUEST_ID_PREFIX = "remote:"
private const val REMOTE_SERVER_VERSION = "0.1.0"
private const val DEFAULT_HOSTED_MODEL = "gpt-5.3-codex"
private val DISALLOWED_TARGET_PACKAGES = setOf(
"com.android.shell",
"com.android.systemui",
"com.openai.codex.agent",
"com.openai.codex.genie",
)
}
private data class DesktopProxy(
val connectionId: String,
val onMessage: (String) -> Unit,
val onClosed: (String?) -> Unit,
)
private data class RemoteProxyState(
val connectionId: String,
val optOutNotificationMethods: Set<String>,
)
private data class RemotePendingRequest(
val connectionId: String,
val remoteRequestId: Any,
)
private data class PendingDesktopMessage(
val connectionId: String,
val message: String,
)
private val requestIdSequence = AtomicInteger(1)
private val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
private val remotePendingRequests = ConcurrentHashMap<String, RemotePendingRequest>()
private val inboundMessages = LinkedBlockingQueue<JSONObject>()
private val pendingDesktopMessages = LinkedBlockingQueue<PendingDesktopMessage>()
private val writerLock = Any()
private val proxyLock = Any()
private val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
private val closing = AtomicBoolean(false)
private lateinit var process: Process
private lateinit var writer: BufferedWriter
private lateinit var codexHome: File
private lateinit var executionSettings: SessionExecutionSettings
private var stdoutThread: Thread? = null
private var stderrThread: Thread? = null
private var eventLoopThread: Thread? = null
private var desktopDispatchThread: Thread? = null
private var localProxy: AgentLocalCodexProxy? = null
private var runtimeStatus: AgentCodexAppServerClient.RuntimeStatus? = null
private var finalAgentMessage: String? = null
private var currentObjective: String? = null
private var pendingDirectSessionStart: PendingDirectSessionStart? = null
@Volatile
private var currentThreadId: String? = null
@Volatile
private var currentDesktopProxy: DesktopProxy? = null
@Volatile
private var remoteProxyState: RemoteProxyState? = null
@Volatile
private var lastReportedFrameworkEventCount = 0
fun start() {
executionSettings = sessionController.executionSettingsForSession(sessionId)
runtimeStatus = runCatching {
AgentCodexAppServerClient.readRuntimeStatus(context)
}.getOrNull()
startProcess()
initialize()
currentThreadId = startThread()
desktopDispatchThread = thread(
start = true,
name = "AgentPlannerDesktopDispatch-$sessionId",
) {
dispatchDesktopMessages()
}
eventLoopThread = thread(
start = true,
name = "AgentPlannerDesktopEventLoop-$sessionId",
) {
eventLoop()
}
}
override fun close() {
if (!closing.compareAndSet(false, true)) {
return
}
DesktopInspectionRegistry.markPlannerDetached(sessionId)
val proxy = synchronized(proxyLock) {
currentDesktopProxy.also {
currentDesktopProxy = null
}
}
runCatching {
proxy?.onClosed("Planner desktop session closed")
}
stdoutThread?.interrupt()
stderrThread?.interrupt()
eventLoopThread?.interrupt()
desktopDispatchThread?.interrupt()
synchronized(writerLock) {
runCatching { writer.close() }
}
localProxy?.close()
if (::codexHome.isInitialized) {
runCatching { codexHome.deleteRecursively() }
}
if (::process.isInitialized) {
process.destroy()
}
onClosed()
}
fun activeThreadId(): String? = currentThreadId
fun openDesktopProxy(
onMessage: (String) -> Unit,
onClosed: (String?) -> Unit,
): String? {
val threadId = currentThreadId ?: return null
if (threadId.isBlank()) {
return null
}
val connectionId = java.util.UUID.randomUUID().toString()
val replacement = synchronized(proxyLock) {
currentDesktopProxy.also {
currentDesktopProxy = DesktopProxy(connectionId, onMessage, onClosed)
}
}
runCatching {
replacement?.onClosed("Replaced by a newer desktop attach")
}
DesktopInspectionRegistry.markPlannerAttached(sessionId)
return connectionId
}
fun sendDesktopProxyInput(
connectionId: String,
message: String,
): Boolean {
val proxy = currentDesktopProxy
if (proxy?.connectionId != connectionId) {
return false
}
handleRemoteProxyMessage(message)
return true
}
fun closeDesktopProxy(
connectionId: String,
reason: String? = null,
detachPlanner: Boolean = false,
) {
val proxy = synchronized(proxyLock) {
currentDesktopProxy?.takeIf { it.connectionId == connectionId }?.also {
currentDesktopProxy = null
}
} ?: return
if (remoteProxyState?.connectionId == connectionId) {
remoteProxyState = null
lastReportedFrameworkEventCount = 0
}
if (detachPlanner) {
DesktopInspectionRegistry.markPlannerDetached(sessionId)
}
runCatching {
proxy.onClosed(reason)
}
}
private fun startProcess() {
codexHome = File(context.cacheDir, "planner-desktop-codex-home/$sessionId").apply {
deleteRecursively()
mkdirs()
}
localProxy = AgentLocalCodexProxy { requestBody ->
forwardResponsesRequest(requestBody)
}.also(AgentLocalCodexProxy::start)
HostedCodexConfig.write(
context,
codexHome,
localProxy?.baseUrl ?: throw IOException("planner desktop 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"] = "warn"
}.start()
writer = process.outputStream.bufferedWriter()
startStdoutPump()
startStderrPump()
}
private fun initialize() {
request(
method = "initialize",
params = JSONObject()
.put(
"clientInfo",
JSONObject()
.put("name", "android_agent_planner_desktop")
.put("title", "Android Agent Planner Desktop")
.put("version", "0.1.0"),
)
.put("capabilities", JSONObject().put("experimentalApi", true)),
)
notify("initialized", JSONObject())
}
private fun startThread(): String {
val params = JSONObject()
.put("approvalPolicy", "never")
.put("sandbox", "read-only")
.put("cwd", context.filesDir.absolutePath)
.put("serviceName", "android_agent_planner")
.put("baseInstructions", AgentTaskPlanner.plannerInstructions())
.put(
"model",
executionSettings.model
?.takeIf(String::isNotBlank)
?: DEFAULT_HOSTED_MODEL,
)
val result = request(
method = "thread/start",
params = params,
)
return result.getJSONObject("thread").getString("id")
}
private fun eventLoop() {
try {
while (!closing.get()) {
val message = inboundMessages.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS)
if (message == null) {
maybeEmitFrameworkEvents()
if (!process.isAlive) {
throw IOException("Planner app-server exited with code ${process.exitValue()}")
}
continue
}
if (message.has("method") && message.has("id")) {
handleServerRequest(message)
continue
}
if (message.has("method") && handleNotification(message)) {
return
}
}
} catch (err: Exception) {
if (!closing.get()) {
Log.w(TAG, "Planner desktop runtime failed for $sessionId", err)
}
} finally {
close()
}
}
private fun handleServerRequest(message: JSONObject) {
val requestId = message.opt("id") ?: return
val method = message.optString("method")
when (method) {
"item/tool/requestUserInput" -> {
sendError(
requestId = requestId,
code = -32601,
message = "Planner desktop attach does not support request_user_input yet",
)
}
else -> {
sendError(
requestId = requestId,
code = -32601,
message = "Unsupported planner app-server request: $method",
)
}
}
}
private fun handleNotification(message: JSONObject): Boolean {
val method = message.optString("method")
val params = message.optJSONObject("params") ?: JSONObject()
return when (method) {
"turn/started" -> {
finalAgentMessage = null
streamedAgentMessages.clear()
if (pendingDirectSessionStart == null) {
val objective = currentObjective?.takeIf(String::isNotBlank)
if (objective != null) {
pendingDirectSessionStart = sessionController.prepareDirectSessionDraftForStart(
sessionId = sessionId,
objective = objective,
executionSettings = executionSettings,
)
}
}
false
}
"item/agentMessage/delta" -> {
val itemId = params.optString("itemId")
if (itemId.isNotBlank()) {
streamedAgentMessages.getOrPut(itemId, ::StringBuilder)
.append(params.optString("delta"))
}
false
}
"item/completed" -> {
val item = params.optJSONObject("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
}
}
false
}
"turn/completed" -> handleTurnCompleted(params.optJSONObject("turn") ?: JSONObject())
else -> false
}
}
private fun handleTurnCompleted(turn: JSONObject): Boolean {
return when (turn.optString("status")) {
"completed" -> {
val objective = currentObjective?.takeIf(String::isNotBlank)
?: run {
publishTrace("Planner turn completed without a captured objective.")
return false
}
val pending = pendingDirectSessionStart
?: run {
publishTrace("Planner turn completed before the direct session moved to RUNNING.")
return false
}
val plannerText = finalAgentMessage?.takeIf(String::isNotBlank)
?: run {
publishTrace("Planner turn completed without an assistant message.")
return false
}
val plannerRequest = runCatching {
AgentTaskPlanner.parsePlannerResponse(
responseText = plannerText,
userObjective = objective,
isEligibleTargetPackage = ::isEligibleTargetPackage,
)
}.getOrElse { err ->
publishTrace("Planner output rejected: ${err.message ?: err::class.java.simpleName}")
return false
}
runCatching {
sessionController.startDirectSessionChildren(
parentSessionId = sessionId,
geniePackage = pending.geniePackage,
plan = plannerRequest.plan,
allowDetachedMode = plannerRequest.allowDetachedMode,
executionSettings = executionSettings,
)
}.onFailure { err ->
sessionController.failDirectSession(
sessionId,
"Failed to start planned child session: ${err.message ?: err::class.java.simpleName}",
)
publishTrace("Planner child start failed: ${err.message ?: err::class.java.simpleName}")
}.onSuccess { result ->
val heldForInspection = currentDesktopProxy != null &&
DesktopInspectionRegistry.holdChildrenForAttachedPlanner(
parentSessionId = sessionId,
childSessionIds = result.childSessionIds,
)
if (heldForInspection) {
val childSummary = if (result.childSessionIds.size == 1) {
"child session ${result.childSessionIds.single()} is paused and attachable"
} else {
"child sessions ${result.childSessionIds.joinToString(", ")} are paused and attachable"
}
publishTrace(
"Planner completed; $childSummary. Attach ${if (result.childSessionIds.size == 1) "it" else "one of them"} from the desktop to continue while this planner remains attached.",
)
} else {
publishTrace(
"Planner completed; started child sessions ${result.childSessionIds.joinToString(", ")}.",
)
}
return false
}
false
}
"interrupted" -> {
publishTrace("Planner turn interrupted; desktop attach remains active.")
false
}
else -> {
publishTrace(
turn.opt("error")?.toString()
?: "Planner turn failed with status ${turn.optString("status", "unknown")}",
)
false
}
}
}
private fun isEligibleTargetPackage(packageName: String): Boolean {
return sessionController.canStartSessionForTarget(packageName) && packageName !in DISALLOWED_TARGET_PACKAGES
}
private fun publishTrace(message: String) {
val agentManager = context.getSystemService(AgentManager::class.java) ?: return
runCatching {
agentManager.publishTrace(sessionId, message)
}.onFailure { err ->
Log.w(TAG, "Failed to publish planner desktop trace for $sessionId", err)
}
}
private fun forwardResponsesRequest(requestBody: String): AgentResponsesProxy.HttpResponse {
val agentManager = context.getSystemService(AgentManager::class.java)
?: throw IOException("AgentManager unavailable for framework session transport")
return AgentResponsesProxy.sendResponsesRequestThroughFramework(
agentManager = agentManager,
sessionId = sessionId,
context = context,
requestBody = requestBody,
)
}
private fun request(
method: String,
params: JSONObject,
): JSONObject {
val requestId = "host-${requestIdSequence.getAndIncrement()}"
val responseQueue = LinkedBlockingQueue<JSONObject>(1)
pendingResponses[requestId] = responseQueue
try {
sendMessage(
JSONObject()
.put("id", requestId)
.put("method", method)
.put("params", params),
)
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) {
synchronized(writerLock) {
writer.write(message.toString())
writer.newLine()
writer.flush()
}
}
private fun startStdoutPump() {
stdoutThread = thread(name = "AgentPlannerDesktopStdout-$sessionId") {
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 desktop stdout line", err)
return@forEach
}
routeInbound(line, message)
}
}
} catch (err: InterruptedIOException) {
if (!closing.get()) {
Log.w(TAG, "Planner desktop stdout interrupted unexpectedly", err)
}
} catch (err: IOException) {
if (!closing.get()) {
Log.w(TAG, "Planner desktop stdout failed", err)
}
}
}
}
private fun startStderrPump() {
stderrThread = thread(name = "AgentPlannerDesktopStderr-$sessionId") {
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 desktop stderr interrupted unexpectedly", err)
}
} catch (err: IOException) {
if (!closing.get()) {
Log.w(TAG, "Planner desktop stderr failed", err)
}
}
}
}
private fun routeInbound(
rawMessage: String,
message: JSONObject,
) {
if (message.has("id") && !message.has("method")) {
val requestId = message.get("id").toString()
pendingResponses[requestId]?.offer(message)
val remoteRequest = remotePendingRequests.remove(requestId)
if (remoteRequest != null) {
sendDesktopMessage(
JSONObject(message.toString())
.put("id", remoteRequest.remoteRequestId)
.toString(),
remoteRequest.connectionId,
)
}
return
}
if (message.has("method") && !message.has("id")) {
forwardRemoteNotification(rawMessage, message)
}
inboundMessages.offer(message)
}
private fun handleRemoteProxyMessage(message: String) {
val json = runCatching { JSONObject(message) }
.getOrElse { err ->
sendDesktopMessage(
errorResponse(
requestId = null,
code = -32700,
message = err.message ?: "Invalid remote JSON-RPC message",
),
)
return
}
when {
json.has("method") && json.has("id") -> handleRemoteProxyRequest(json)
json.has("method") -> handleRemoteProxyNotification(json)
else -> Unit
}
}
private fun handleRemoteProxyRequest(message: JSONObject) {
val method = message.optString("method")
val remoteRequestId = message.opt("id") ?: return
when (method) {
"initialize" -> {
val params = message.optJSONObject("params") ?: JSONObject()
val optOut = params
.optJSONObject("capabilities")
?.optJSONArray("optOutNotificationMethods")
?.toStringSet()
.orEmpty()
val connectionId = checkNotNull(currentDesktopProxy?.connectionId) {
"Desktop proxy is unavailable during initialize"
}
remoteProxyState = RemoteProxyState(
connectionId = connectionId,
optOutNotificationMethods = optOut,
)
lastReportedFrameworkEventCount = 0
sendDesktopMessage(
JSONObject()
.put("id", remoteRequestId)
.put(
"result",
JSONObject()
.put("userAgent", "android_agent_planner_bridge/$REMOTE_SERVER_VERSION")
.put("codexHome", codexHome.absolutePath)
.put("platformFamily", "unix")
.put("platformOs", "android"),
)
.toString(),
connectionId,
)
}
"account/read" -> {
sendDesktopMessage(
JSONObject()
.put("id", remoteRequestId)
.put("result", buildRemoteAccountReadResult())
.toString(),
)
}
"turn/start" -> {
val params = message.optJSONObject("params") ?: JSONObject()
currentObjective = extractTurnPrompt(params)
forwardRemoteRequest(message, remoteRequestId)
}
else -> {
forwardRemoteRequest(message, remoteRequestId)
}
}
}
private fun forwardRemoteRequest(
message: JSONObject,
remoteRequestId: Any,
) {
val connectionId = currentDesktopProxy?.connectionId
if (connectionId.isNullOrBlank()) {
sendDesktopMessage(
errorResponse(remoteRequestId, -32000, "Remote desktop session is not attached"),
)
return
}
val forwardedRequestId = "$REMOTE_REQUEST_ID_PREFIX$connectionId:${message.get("id")}"
remotePendingRequests[forwardedRequestId] = RemotePendingRequest(
connectionId = connectionId,
remoteRequestId = remoteRequestId,
)
sendMessage(
JSONObject(message.toString())
.put("id", forwardedRequestId),
)
}
private fun handleRemoteProxyNotification(message: JSONObject) {
when (message.optString("method")) {
"initialized" -> maybeEmitFrameworkEvents()
else -> sendMessage(JSONObject(message.toString()))
}
}
private fun maybeEmitFrameworkEvents() {
val proxyState = remoteProxyState ?: return
if (proxyState.connectionId != currentDesktopProxy?.connectionId) {
return
}
if (
proxyState.optOutNotificationMethods.contains(
FrameworkEventBridge.THREAD_FRAMEWORK_EVENT_METHOD,
)
) {
return
}
val threadId = currentThreadId ?: return
val agentManager = context.getSystemService(AgentManager::class.java) ?: return
val events = runCatching {
agentManager.getSessionEvents(sessionId)
}.getOrElse { err ->
Log.w(TAG, "Failed to load framework events for planner $sessionId", err)
return
}
if (lastReportedFrameworkEventCount > events.size) {
lastReportedFrameworkEventCount = 0
}
for (index in lastReportedFrameworkEventCount until events.size) {
val notification = FrameworkEventBridge.buildThreadFrameworkEventNotification(
threadId = threadId,
event = events[index],
) ?: continue
sendDesktopMessage(notification, proxyState.connectionId)
}
lastReportedFrameworkEventCount = events.size
}
private fun buildRemoteAccountReadResult(): JSONObject {
val authenticated = runtimeStatus?.authenticated == true
val account = if (authenticated) {
JSONObject().put("type", "apiKey")
} else {
JSONObject.NULL
}
return JSONObject()
.put("account", account)
.put("requiresOpenaiAuth", true)
}
private fun extractTurnPrompt(params: JSONObject): String? {
val input = params.optJSONArray("input") ?: return null
val text = buildString {
for (index in 0 until input.length()) {
val item = input.optJSONObject(index) ?: continue
if (item.optString("type") != "text") {
continue
}
val value = item.optString("text").trim()
if (value.isEmpty()) {
continue
}
if (isNotEmpty()) {
append('\n')
}
append(value)
}
}.trim()
return text.ifEmpty { null }
}
private fun forwardRemoteNotification(
rawMessage: String,
message: JSONObject,
) {
val proxyState = remoteProxyState ?: return
if (proxyState.connectionId != currentDesktopProxy?.connectionId) {
return
}
val method = message.optString("method")
if (proxyState.optOutNotificationMethods.contains(method)) {
return
}
sendDesktopMessage(rawMessage, proxyState.connectionId)
}
private fun sendDesktopMessage(
message: String,
connectionId: String? = currentDesktopProxy?.connectionId,
) {
val proxy = currentDesktopProxy
if (proxy == null || connectionId == null || proxy.connectionId != connectionId) {
return
}
pendingDesktopMessages.offer(
PendingDesktopMessage(
connectionId = connectionId,
message = message,
),
)
}
private fun errorResponse(
requestId: Any?,
code: Int,
message: String,
): String {
return JSONObject()
.put("id", requestId)
.put(
"error",
JSONObject()
.put("code", code)
.put("message", message),
)
.toString()
}
private fun JSONArray.toStringSet(): Set<String> {
return buildSet {
for (index in 0 until length()) {
optString(index).takeIf(String::isNotBlank)?.let(::add)
}
}
}
private fun dispatchDesktopMessages() {
while (!closing.get()) {
val pending = try {
pendingDesktopMessages.take()
} catch (_: InterruptedException) {
return
}
val proxy = currentDesktopProxy
if (proxy == null || proxy.connectionId != pending.connectionId) {
continue
}
runCatching {
proxy.onMessage(pending.message)
}.onFailure { err ->
Log.w(TAG, "Failed to deliver planner desktop message for $sessionId", err)
closeDesktopProxy(
connectionId = pending.connectionId,
reason = err.message ?: err::class.java.simpleName,
detachPlanner = false,
)
}
}
}
}

View File

@@ -0,0 +1,518 @@
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>()
private val desktopPlannerSessions = ConcurrentHashMap<String, AgentPlannerDesktopSessionHost>()
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)
}
}
fun ensureIdleDesktopSession(
context: Context,
sessionController: AgentSessionController,
sessionId: String,
) {
check(!activePlannerSessions.containsKey(sessionId)) {
"Planner runtime already active for parent session $sessionId"
}
desktopPlannerSessions.computeIfAbsent(sessionId) {
AgentPlannerDesktopSessionHost(
context = context.applicationContext,
sessionController = sessionController,
sessionId = sessionId,
onClosed = {
desktopPlannerSessions.remove(sessionId)
},
).also(AgentPlannerDesktopSessionHost::start)
}
}
fun activeThreadId(sessionId: String): String? = desktopPlannerSessions[sessionId]?.activeThreadId()
fun openDesktopProxy(
sessionId: String,
onMessage: (String) -> Unit,
onClosed: (String?) -> Unit,
): String? = desktopPlannerSessions[sessionId]?.openDesktopProxy(onMessage, onClosed)
fun sendDesktopProxyInput(
sessionId: String,
connectionId: String,
message: String,
): Boolean = desktopPlannerSessions[sessionId]?.sendDesktopProxyInput(connectionId, message) ?: false
fun closeDesktopProxy(
sessionId: String,
connectionId: String,
reason: String? = null,
detachPlanner: Boolean = false,
) {
desktopPlannerSessions[sessionId]?.closeDesktopProxy(connectionId, reason, detachPlanner)
}
fun closeSession(sessionId: String) {
desktopPlannerSessions.remove(sessionId)?.close()
}
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("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,399 @@
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.LinkedBlockingQueue
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import org.json.JSONObject
object AgentSessionBridgeServer {
private val runningBridges = ConcurrentHashMap<String, RunningBridge>()
private const val TAG = "AgentSessionBridge"
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()
}
fun activeThreadId(sessionId: String): String? = runningBridges[sessionId]?.activeThreadId()
fun openDesktopProxy(
sessionId: String,
onMessage: (String) -> Unit,
onClosed: (String?) -> Unit,
): String? = runningBridges[sessionId]?.openDesktopProxy(onMessage, onClosed)
fun sendDesktopProxyInput(
sessionId: String,
connectionId: String,
message: String,
): Boolean = runningBridges[sessionId]?.sendDesktopProxyInput(connectionId, message) ?: false
fun closeDesktopProxy(
sessionId: String,
connectionId: String,
reason: String? = null,
) {
runningBridges[sessionId]?.closeDesktopProxy(connectionId, reason)
}
private class RunningBridge(
private val context: Context,
private val agentManager: AgentManager,
private val sessionId: String,
) : Closeable {
companion object {
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 METHOD_READ_DESKTOP_INSPECTION_HOLD = "readDesktopInspectionHold"
private const val METHOD_REGISTER_APP_SERVER_THREAD = "registerAppServerThread"
private const val WRITE_CHUNK_BYTES = 4096
private const val KIND_REQUEST = "request"
private const val KIND_RESPONSE = "response"
private const val KIND_REMOTE_CLIENT_MESSAGE = "remoteAppServerClientMessage"
private const val KIND_REMOTE_SERVER_MESSAGE = "remoteAppServerServerMessage"
private const val KIND_REMOTE_CLOSED = "remoteAppServerClosed"
}
private data class DesktopProxy(
val connectionId: String,
val onMessage: (String) -> Unit,
val onClosed: (String?) -> Unit,
)
private data class PendingDesktopMessage(
val connectionId: String,
val message: String,
)
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 writerLock = Any()
private val proxyLock = Any()
private val pendingDesktopMessages = LinkedBlockingQueue<PendingDesktopMessage>()
@Volatile
private var currentDesktopProxy: DesktopProxy? = null
@Volatile
private var currentThreadId: String? = null
private val serveThread = thread(
start = false,
name = "AgentSessionBridge-$sessionId",
) {
serveLoop()
}
private val desktopDispatchThread = thread(
start = false,
name = "AgentSessionBridgeDesktop-$sessionId",
) {
dispatchDesktopMessages()
}
fun start() {
serveThread.start()
desktopDispatchThread.start()
}
override fun close() {
if (!closed.compareAndSet(false, true)) {
return
}
val proxy = synchronized(proxyLock) {
currentDesktopProxy.also {
currentDesktopProxy = null
}
}
runCatching {
proxy?.onClosed("Agent session bridge closed")
}
runCatching { input?.close() }
runCatching { output?.close() }
runCatching { bridgeFd?.close() }
serveThread.interrupt()
desktopDispatchThread.interrupt()
}
fun activeThreadId(): String? = currentThreadId
fun openDesktopProxy(
onMessage: (String) -> Unit,
onClosed: (String?) -> Unit,
): String? {
val threadId = currentThreadId ?: return null
val connectionId = UUID.randomUUID().toString()
val replacement = synchronized(proxyLock) {
currentDesktopProxy.also {
currentDesktopProxy = DesktopProxy(connectionId, onMessage, onClosed)
}
}
runCatching {
replacement?.onClosed("Replaced by a newer desktop attach")
}
return connectionId
}
fun sendDesktopProxyInput(
connectionId: String,
message: String,
): Boolean {
val proxy = currentDesktopProxy
if (proxy?.connectionId != connectionId) {
return false
}
sendBridgeMessage(
JSONObject()
.put("kind", KIND_REMOTE_CLIENT_MESSAGE)
.put("connectionId", connectionId)
.put("message", message),
)
return true
}
fun closeDesktopProxy(
connectionId: String,
reason: String? = null,
) {
val proxy = synchronized(proxyLock) {
currentDesktopProxy?.takeIf { it.connectionId == connectionId }?.also {
currentDesktopProxy = null
}
} ?: return
sendBridgeMessage(
JSONObject()
.put("kind", KIND_REMOTE_CLOSED)
.put("connectionId", connectionId)
.put("reason", reason),
)
runCatching {
proxy.onClosed(reason)
}
}
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 message = try {
readMessage(input ?: break)
} catch (_: EOFException) {
return
}
when (message.optString("kind", KIND_REQUEST)) {
KIND_REQUEST -> {
val response = handleRequest(message)
sendBridgeMessage(response)
}
KIND_REMOTE_SERVER_MESSAGE -> {
handleRemoteServerMessage(message)
}
KIND_REMOTE_CLOSED -> {
handleRemoteClosed(message)
}
else -> {
Log.w(TAG, "Ignoring unsupported Agent bridge message for $sessionId: $message")
}
}
}
} 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("kind", KIND_RESPONSE)
.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("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
.put("agentsMarkdown", HostedCodexConfig.readInstalledAgentsMarkdown(codexHome))
}
METHOD_READ_SESSION_EXECUTION_SETTINGS -> {
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
.put("executionSettings", executionSettingsStore.toJson(sessionId))
}
METHOD_READ_DESKTOP_INSPECTION_HOLD -> {
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
.put("inspectionHold", DesktopInspectionRegistry.isSessionHeldForInspection(sessionId))
}
METHOD_REGISTER_APP_SERVER_THREAD -> {
currentThreadId = request.optString("threadId").trim().ifEmpty { null }
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
}
else -> {
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", false)
.put("error", "Unsupported bridge method: ${request.optString("method")}")
}
}
}.getOrElse { err ->
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", false)
.put("error", err.message ?: err::class.java.simpleName)
}
}
private fun handleRemoteServerMessage(message: JSONObject) {
val connectionId = message.optString("connectionId")
val proxy = currentDesktopProxy
if (proxy == null || proxy.connectionId != connectionId) {
return
}
pendingDesktopMessages.offer(
PendingDesktopMessage(
connectionId = connectionId,
message = message.optString("message"),
),
)
}
private fun handleRemoteClosed(message: JSONObject) {
val connectionId = message.optString("connectionId")
val reason = message.optString("reason").ifBlank { null }
val proxy = synchronized(proxyLock) {
currentDesktopProxy?.takeIf { it.connectionId == connectionId }?.also {
currentDesktopProxy = null
}
} ?: return
runCatching {
proxy.onClosed(reason)
}
}
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
}
}
private fun sendBridgeMessage(message: JSONObject) {
synchronized(writerLock) {
writeMessage(output ?: throw IOException("Session bridge output unavailable"), message)
}
}
private fun dispatchDesktopMessages() {
while (!closed.get()) {
val pending = try {
pendingDesktopMessages.take()
} catch (_: InterruptedException) {
return
}
val proxy = currentDesktopProxy
if (proxy == null || proxy.connectionId != pending.connectionId) {
continue
}
runCatching {
proxy.onMessage(pending.message)
}.onFailure { err ->
Log.w(TAG, "Desktop proxy delivery failed for $sessionId", err)
closeDesktopProxy(pending.connectionId, err.message ?: err::class.java.simpleName)
}
}
}
}
}

View File

@@ -0,0 +1,983 @@
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.DesktopSessionBootstrap
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 enum class ChildSessionLaunchMode {
IMMEDIATE,
IDLE_ATTACH,
}
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 parentSessionId = createDirectSessionDraft(executionSettings)
try {
return prepareDirectSessionDraftForStart(
sessionId = parentSessionId,
objective = objective,
executionSettings = executionSettings,
)
} catch (err: RuntimeException) {
runCatching { cancelSession(parentSessionId) }
executionSettingsStore.removeSettings(parentSessionId)
throw err
}
}
fun createDirectSessionDraft(executionSettings: SessionExecutionSettings = SessionExecutionSettings.default): String {
val manager = requireAgentManager()
val parentSession = manager.createDirectSession(currentUserId())
try {
executionSettingsStore.saveSettings(parentSession.sessionId, executionSettings)
return parentSession.sessionId
} catch (err: RuntimeException) {
runCatching { manager.cancelSession(parentSession.sessionId) }
executionSettingsStore.removeSettings(parentSession.sessionId)
throw err
}
}
fun prepareDirectSessionDraftForStart(
sessionId: String,
objective: String,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
): PendingDirectSessionStart {
val manager = requireAgentManager()
val session = manager.getSessions(currentUserId()).firstOrNull { it.sessionId == sessionId }
?: throw IllegalArgumentException("Unknown direct draft session: $sessionId")
check(isDirectParentSession(session)) {
"Session $sessionId is not an AGENT parent draft"
}
check(session.state == AgentSessionInfo.STATE_CREATED) {
"Session $sessionId is not in CREATED state"
}
check(
manager.getSessions(currentUserId()).none { childSession ->
childSession.parentSessionId == sessionId
},
) {
"Session $sessionId already has child sessions"
}
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
?: throw IllegalStateException("No GENIE role holder configured")
executionSettingsStore.saveSettings(sessionId, executionSettings)
manager.publishTrace(
sessionId,
"Planning Codex direct session for objective: $objective",
)
manager.updateSessionState(sessionId, AgentSessionInfo.STATE_RUNNING)
return PendingDirectSessionStart(
parentSessionId = sessionId,
geniePackage = geniePackage,
)
}
fun createHomeSessionDraft(
targetPackage: String,
finalPresentationPolicy: SessionFinalPresentationPolicy,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
): String {
val manager = requireAgentManager()
check(canStartSessionForTarget(targetPackage)) {
"Target package $targetPackage is not eligible for session start"
}
val session = manager.createAppScopedSession(targetPackage, currentUserId())
try {
presentationPolicyStore.savePolicy(session.sessionId, finalPresentationPolicy)
executionSettingsStore.saveSettings(session.sessionId, executionSettings)
return session.sessionId
} catch (err: RuntimeException) {
presentationPolicyStore.removePolicy(session.sessionId)
executionSettingsStore.removeSettings(session.sessionId)
runCatching { manager.cancelSession(session.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 childLaunchMode = childSessionLaunchMode(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
AgentSessionBridgeServer.ensureStarted(appContext, manager, childSession.sessionId)
presentationPolicyStore.savePolicy(childSession.sessionId, target.finalPresentationPolicy)
executionSettingsStore.saveSettings(childSession.sessionId, executionSettings)
provisionSessionNetworkConfig(childSession.sessionId)
if (childLaunchMode == ChildSessionLaunchMode.IDLE_ATTACH) {
DesktopInspectionRegistry.holdChildrenForAttachedPlanner(parentSessionId, listOf(childSession.sessionId))
manager.publishTrace(
childSession.sessionId,
"Planner launched this Genie in idle desktop-attach mode. The delegated objective is staged, but the first turn will wait until the attached client sends a prompt or the planner detaches.",
)
}
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,
childSessionStartupPrompt(target, childLaunchMode),
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()
val session = manager.getSessions(currentUserId()).firstOrNull { it.sessionId == sessionId }
?: throw IllegalArgumentException("Unknown HOME draft session: $sessionId")
check(
session.anchor == AgentSessionInfo.ANCHOR_HOME &&
session.parentSessionId == null &&
session.targetPackage == targetPackage,
) {
"Session $sessionId is not a HOME draft for $targetPackage"
}
check(session.state == AgentSessionInfo.STATE_CREATED) {
"Session $sessionId is not in CREATED state"
}
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 startExistingHomeSessionIdle(
sessionId: String,
targetPackage: String,
allowDetachedMode: Boolean,
finalPresentationPolicy: SessionFinalPresentationPolicy,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
): SessionStartResult {
val manager = requireAgentManager()
val session = manager.getSessions(currentUserId()).firstOrNull { it.sessionId == sessionId }
?: throw IllegalArgumentException("Unknown HOME draft session: $sessionId")
check(
session.anchor == AgentSessionInfo.ANCHOR_HOME &&
session.parentSessionId == null &&
session.targetPackage == targetPackage,
) {
"Session $sessionId is not a HOME draft for $targetPackage"
}
check(session.state == AgentSessionInfo.STATE_CREATED) {
"Session $sessionId is not in CREATED state"
}
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 idle Codex app-scoped desktop attach session for $targetPackage.",
)
manager.startGenieSession(
sessionId,
geniePackage,
DesktopSessionBootstrap.idleAttachPrompt(),
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()
val childLaunchMode = childSessionLaunchMode(parentSessionId)
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)
if (childLaunchMode == ChildSessionLaunchMode.IDLE_ATTACH) {
DesktopInspectionRegistry.holdChildrenForAttachedPlanner(parentSessionId, listOf(childSession.sessionId))
manager.publishTrace(
childSession.sessionId,
"Planner launched this Genie in idle desktop-attach mode. The delegated objective is staged, but the first turn will wait until the attached client sends a prompt or the planner detaches.",
)
}
manager.startGenieSession(
childSession.sessionId,
geniePackage,
childSessionStartupPrompt(target, childLaunchMode),
/* 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 cancelSessionTree(sessionId: String) {
val manager = requireAgentManager()
val sessions = manager.getSessions(currentUserId())
val session = sessions.firstOrNull { it.sessionId == sessionId }
?: throw IllegalArgumentException("Unknown session: $sessionId")
if (isDirectParentSession(session)) {
sessions.asSequence()
.filter { childSession ->
childSession.parentSessionId == sessionId
}
.forEach { childSession ->
manager.cancelSession(childSession.sessionId)
}
}
manager.cancelSession(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 childSessionLaunchMode(parentSessionId: String): ChildSessionLaunchMode {
return if (DesktopInspectionRegistry.isPlannerAttached(parentSessionId)) {
ChildSessionLaunchMode.IDLE_ATTACH
} else {
ChildSessionLaunchMode.IMMEDIATE
}
}
private fun childSessionStartupPrompt(
target: AgentDelegationTarget,
launchMode: ChildSessionLaunchMode,
): String {
val delegatedPrompt = buildDelegatedPrompt(target)
return when (launchMode) {
ChildSessionLaunchMode.IMMEDIATE -> delegatedPrompt
ChildSessionLaunchMode.IDLE_ATTACH -> DesktopSessionBootstrap.idleAttachPrompt(delegatedPrompt)
}
}
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,274 @@
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 CreateSessionRequest(
val targetPackage: String?,
val model: String?,
val reasoningEffort: String?,
)
data class LaunchSessionRequest(
val prompt: String,
val targetPackage: String?,
val model: String?,
val reasoningEffort: String?,
val existingSessionId: String? = null,
)
data class StartSessionRequest(
val sessionId: String,
val prompt: String,
)
data class SessionDraftResult(
val sessionId: String,
val anchor: Int,
)
object AgentSessionLauncher {
fun createSessionDraft(
request: CreateSessionRequest,
sessionController: AgentSessionController,
): SessionDraftResult {
val executionSettings = SessionExecutionSettings(
model = request.model?.trim()?.ifEmpty { null },
reasoningEffort = request.reasoningEffort?.trim()?.ifEmpty { null },
)
val targetPackage = request.targetPackage?.trim()?.ifEmpty { null }
return if (targetPackage == null) {
SessionDraftResult(
sessionId = sessionController.createDirectSessionDraft(executionSettings),
anchor = AgentSessionInfo.ANCHOR_AGENT,
)
} else {
SessionDraftResult(
sessionId = sessionController.createHomeSessionDraft(
targetPackage = targetPackage,
finalPresentationPolicy = SessionFinalPresentationPolicy.AGENT_CHOICE,
executionSettings = executionSettings,
),
anchor = AgentSessionInfo.ANCHOR_HOME,
)
}
}
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 draftSession = createSessionDraft(
request = CreateSessionRequest(
targetPackage = null,
model = executionSettings.model,
reasoningEffort = executionSettings.reasoningEffort,
),
sessionController = sessionController,
)
return startSessionDraftAsync(
context = context,
request = StartSessionRequest(
sessionId = draftSession.sessionId,
prompt = request.prompt,
),
sessionController = sessionController,
requestUserInputHandler = requestUserInputHandler,
)
}
fun startSessionDraftAsync(
context: Context,
request: StartSessionRequest,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
val sessionId = request.sessionId.trim()
require(sessionId.isNotEmpty()) { "Missing session id" }
val prompt = request.prompt.trim()
require(prompt.isNotEmpty()) { "Missing prompt" }
val snapshot = sessionController.loadSnapshot(sessionId)
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId }
?: throw IllegalArgumentException("Unknown session: $sessionId")
if (
session.anchor == AgentSessionInfo.ANCHOR_HOME &&
session.parentSessionId == null &&
!session.targetPackage.isNullOrBlank()
) {
return sessionController.startExistingHomeSession(
sessionId = sessionId,
targetPackage = checkNotNull(session.targetPackage),
prompt = prompt,
allowDetachedMode = true,
finalPresentationPolicy = session.requiredFinalPresentationPolicy
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
executionSettings = sessionController.executionSettingsForSession(sessionId),
)
}
check(
session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
session.parentSessionId == null &&
session.targetPackage == null,
) {
"Session $sessionId is not a startable draft"
}
check(AgentPlannerRuntimeManager.activeThreadId(sessionId) == null) {
"Session $sessionId is already attached to an idle planner runtime; send the first prompt through the attached client"
}
val executionSettings = sessionController.executionSettingsForSession(sessionId)
val pendingSession = sessionController.prepareDirectSessionDraftForStart(
sessionId = sessionId,
objective = prompt,
executionSettings = executionSettings,
)
val applicationContext = context.applicationContext
thread(name = "CodexAgentPlanner-${pendingSession.parentSessionId}") {
runCatching {
AgentTaskPlanner.planSession(
context = applicationContext,
userObjective = 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,300 @@
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)
internal fun plannerInstructions(): String = PLANNER_INSTRUCTIONS
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,489 @@
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 (isTerminalSessionState(session.state) && !DesktopInspectionRegistry.isPlannerAttached(session.sessionId)) {
AgentPlannerRuntimeManager.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)
AgentPlannerRuntimeManager.closeSession(sessionId)
DesktopInspectionRegistry.removeSession(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),
)
},
)
val deferTerminalRollup =
DesktopInspectionRegistry.isPlannerAttached(parentSessionId) &&
isTerminalSessionState(rollup.state)
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 (!deferTerminalRollup && 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 (!deferTerminalRollup && (rollup.resultMessage != null || rollup.errorMessage != null)) {
manager.getSessionEvents(parentSessionId)
} else {
emptyList()
}
if (
!deferTerminalRollup &&
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 (
!deferTerminalRollup &&
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,36 @@
package com.openai.codex.agent
import android.content.Context
import android.content.Intent
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
object DesktopAttachKeepAliveManager {
private const val TAG = "DesktopAttachKeepAlive"
private val activeConnections = ConcurrentHashMap.newKeySet<String>()
fun acquire(
connectionId: String,
) {
if (!activeConnections.add(connectionId)) {
return
}
Log.i(TAG, "Acquired desktop attach keepalive id=$connectionId count=${activeConnections.size}")
}
fun release(
context: Context,
connectionId: String,
) {
if (!activeConnections.remove(connectionId)) {
return
}
Log.i(TAG, "Released desktop attach keepalive id=$connectionId count=${activeConnections.size}")
if (activeConnections.isEmpty()) {
context.startService(
Intent(context, DesktopAttachKeepAliveService::class.java)
.setAction(DesktopAttachKeepAliveService.ACTION_STOP),
)
}
}
}

View File

@@ -0,0 +1,83 @@
package com.openai.codex.agent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
class DesktopAttachKeepAliveService : Service() {
companion object {
private const val TAG = "DesktopAttachKeepAlive"
const val ACTION_START = "com.openai.codex.agent.action.START_DESKTOP_ATTACH_KEEPALIVE"
const val ACTION_STOP = "com.openai.codex.agent.action.STOP_DESKTOP_ATTACH_KEEPALIVE"
private const val CHANNEL_ID = "codex_desktop_attach"
private const val CHANNEL_NAME = "Codex Desktop Attach"
private const val NOTIFICATION_ID = 0x43445841
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "Stopping desktop attach keepalive service")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
val manager = getSystemService(NotificationManager::class.java)
if (manager != null) {
ensureChannel(manager)
startForeground(NOTIFICATION_ID, buildNotification())
Log.i(TAG, "Started desktop attach keepalive service")
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(): Notification {
val contentIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Codex desktop attach active")
.setContentText("Keeping the Agent bridge alive for attached desktop sessions.")
.setContentIntent(contentIntent)
.setOngoing(true)
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
private fun ensureChannel(manager: NotificationManager) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Keeps the Codex Agent desktop bridge alive while a desktop session is attached."
setShowBadge(false)
},
)
}
}

View File

@@ -0,0 +1,28 @@
package com.openai.codex.agent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class DesktopBridgeBootstrapReceiver : BroadcastReceiver() {
companion object {
const val ACTION_BOOTSTRAP_DESKTOP_BRIDGE =
"com.openai.codex.agent.action.BOOTSTRAP_DESKTOP_BRIDGE"
const val EXTRA_AUTH_TOKEN = "com.openai.codex.agent.extra.DESKTOP_BRIDGE_AUTH_TOKEN"
}
override fun onReceive(
context: Context,
intent: Intent,
) {
if (intent.action != ACTION_BOOTSTRAP_DESKTOP_BRIDGE) {
return
}
intent.getStringExtra(EXTRA_AUTH_TOKEN)
?.trim()
?.takeIf(String::isNotEmpty)
?.let { token ->
DesktopBridgeServer.ensureStarted(context.applicationContext, token)
}
}
}

View File

@@ -0,0 +1,654 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
import android.content.Context
import android.os.Binder
import android.os.SystemClock
import android.util.Log
import java.net.InetAddress
import java.net.InetSocketAddress
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import org.json.JSONArray
import org.json.JSONObject
object DesktopBridgeServer {
private const val TAG = "DesktopBridgeServer"
private const val LISTEN_PORT = 48765
private const val CONTROL_PATH = "/control"
private const val SESSION_PATH_PREFIX = "/session/"
private const val DEFAULT_MODEL = "gpt-5.3-codex-spark"
private const val DEFAULT_REASONING_EFFORT = "low"
private const val ATTACH_TOKEN_TTL_MS = 60_000L
private const val ATTACH_THREAD_WAIT_MS = 5_000L
private const val ATTACH_THREAD_POLL_MS = 100L
private const val BRIDGE_STARTUP_WAIT_MS = 5_000L
private const val BRIDGE_STARTUP_RETRY_DELAY_MS = 100L
private val authorizedTokens = ConcurrentHashMap.newKeySet<String>()
private val attachTokens = ConcurrentHashMap<String, AttachedSessionTarget>()
private val createdHomeSessionUiLeases = ConcurrentHashMap<String, Binder>()
@Volatile
private var server: AgentDesktopBridgeSocketServer? = null
private data class AttachedSessionTarget(
val sessionId: String,
val expiresAtElapsedRealtimeMs: Long,
val keepAliveId: String,
)
fun ensureStarted(
context: Context,
authToken: String,
) {
authorizedTokens += authToken
val existing = synchronized(this) { server }
if (existing != null && existing.isStarted()) {
return
}
synchronized(this) {
val running = server
if (running != null && running.isStarted()) {
return
}
if (running != null) {
Log.w(TAG, "Desktop bridge reference exists but is not ready; restarting")
runCatching { running.stop(100) }
server = null
}
val startupDeadline = SystemClock.elapsedRealtime() + BRIDGE_STARTUP_WAIT_MS
while (SystemClock.elapsedRealtime() < startupDeadline) {
val candidate = AgentDesktopBridgeSocketServer(context.applicationContext)
candidate.setReuseAddr(true)
server = candidate
candidate.start()
if (candidate.awaitStartup(BRIDGE_STARTUP_WAIT_MS)) {
Log.i(TAG, "Desktop bridge listening on ws://127.0.0.1:$LISTEN_PORT$CONTROL_PATH")
return
}
val startupFailure = candidate.startupFailureMessage()
runCatching { candidate.stop(100) }
if (server === candidate) {
server = null
}
if (
startupFailure?.contains("Address already in use", ignoreCase = true) == true &&
SystemClock.elapsedRealtime() + BRIDGE_STARTUP_RETRY_DELAY_MS < startupDeadline
) {
SystemClock.sleep(BRIDGE_STARTUP_RETRY_DELAY_MS)
continue
}
if (startupFailure != null) {
Log.w(TAG, "Desktop bridge failed to start after bootstrap: $startupFailure")
} else {
Log.w(TAG, "Desktop bridge failed to start within ${BRIDGE_STARTUP_WAIT_MS}ms; clearing state")
}
return
}
Log.w(TAG, "Desktop bridge startup retries exhausted within ${BRIDGE_STARTUP_WAIT_MS}ms")
}
}
private class AgentDesktopBridgeSocketServer(
private val context: Context,
) : WebSocketServer(InetSocketAddress(InetAddress.getByName("127.0.0.1"), LISTEN_PORT)) {
private val sessionController = AgentSessionController(context)
private val startupLatch = CountDownLatch(1)
@Volatile
private var started = false
@Volatile
private var startupFailure: Exception? = null
fun awaitStartup(timeoutMs: Long): Boolean {
startupLatch.await(timeoutMs, TimeUnit.MILLISECONDS)
return started
}
fun isStarted(): Boolean = started
fun startupFailureMessage(): String? = startupFailure?.message
override fun onOpen(
conn: WebSocket,
handshake: ClientHandshake,
) {
val authHeader = handshake.getFieldValue("Authorization")
val bearerToken = parseBearerToken(authHeader)
if (bearerToken == null || !authorizedTokens.contains(bearerToken)) {
conn.close(1008, "Unauthorized")
return
}
val path = handshake.resourceDescriptor ?: CONTROL_PATH
if (path == CONTROL_PATH) {
return
}
if (path.startsWith(SESSION_PATH_PREFIX)) {
val attachToken = path.removePrefix(SESSION_PATH_PREFIX)
val target = attachTokens[attachToken]
if (target == null) {
conn.close(1008, "Unknown attach token")
return
}
if (target.expiresAtElapsedRealtimeMs <= SystemClock.elapsedRealtime()) {
attachTokens.remove(attachToken, target)
DesktopAttachKeepAliveManager.release(context, target.keepAliveId)
conn.close(1008, "Expired attach token")
return
}
val connectionId = openSessionProxy(
sessionId = target.sessionId,
onMessage = { message ->
runCatching { conn.send(message) }
.onFailure { conn.close(1011, it.message ?: "Desktop send failed") }
},
onClosed = { reason ->
conn.close(1000, reason ?: "Session proxy closed")
},
)
if (connectionId == null) {
conn.close(1011, "Session is not attachable")
return
}
DesktopAttachKeepAliveManager.acquire(connectionId)
conn.setAttachment(
SessionProxyConnection(
sessionId = target.sessionId,
connectionId = connectionId,
keepAliveId = connectionId,
),
)
return
}
conn.close(1008, "Unsupported path")
}
override fun onClose(
conn: WebSocket,
code: Int,
reason: String,
remote: Boolean,
) {
val attachment = conn.getAttachment<SessionProxyConnection>()
if (attachment != null) {
DesktopAttachKeepAliveManager.release(context, attachment.keepAliveId)
closeSessionProxy(
sessionId = attachment.sessionId,
connectionId = attachment.connectionId,
reason = reason.ifBlank { null },
detachPlanner = shouldDetachPlannerOnWebSocketClose(code, remote),
)
}
}
override fun onMessage(
conn: WebSocket,
message: String,
) {
val attachment = conn.getAttachment<SessionProxyConnection>()
if (attachment != null) {
if (!sendSessionProxyInput(
sessionId = attachment.sessionId,
connectionId = attachment.connectionId,
message = message,
)
) {
conn.close(1008, "Session proxy is no longer active")
}
return
}
thread(name = "DesktopBridgeControl") {
handleControlMessage(conn, message)
}
}
override fun onError(
conn: WebSocket?,
ex: Exception,
) {
Log.w(TAG, "Desktop bridge websocket failed", ex)
if (conn == null && !started) {
startupFailure = ex
startupLatch.countDown()
synchronized(this@DesktopBridgeServer) {
if (server === this) {
server = null
}
}
}
}
override fun onStart() {
started = true
connectionLostTimeout = 30
startupLatch.countDown()
}
private fun handleControlMessage(
conn: WebSocket,
message: String,
) {
val request = runCatching { JSONObject(message) }
.getOrElse { err ->
sendError(conn, null, -32700, err.message ?: "Invalid JSON")
return
}
val requestId = request.opt("id")
val method = request.optString("method")
val params = request.optJSONObject("params")
pruneCreatedHomeSessionUiLeases()
runCatching {
when (method) {
"androidSession/list" -> listSessions()
"androidSession/read" -> readSession(params)
"androidSession/create" -> createSession(params)
"androidSession/start" -> startSession(params)
"androidSession/answer" -> answerQuestion(params)
"androidSession/cancel" -> cancelSession(params)
"androidSession/attachTarget" -> attachTarget(params)
"androidSession/attach" -> attachSession(params)
else -> {
sendError(
conn = conn,
requestId = requestId,
code = -32601,
message = "Unsupported desktop bridge method: $method",
)
return
}
}
}.onSuccess { result ->
sendResult(conn, requestId, result)
}.onFailure { err ->
val code = when (err) {
is IllegalArgumentException -> -32602
is IllegalStateException -> -32000
else -> -32603
}
sendError(
conn = conn,
requestId = requestId,
code = code,
message = err.message ?: err::class.java.simpleName,
)
}
}
private fun listSessions(): JSONObject {
val snapshot = sessionController.loadSnapshot(null)
val data = JSONArray()
snapshot.sessions.forEach { session ->
data.put(sessionJson(session))
}
return JSONObject().put("data", data)
}
private fun readSession(params: JSONObject?): JSONObject {
val sessionId = params.requireString("sessionId")
return sessionJson(requireSession(sessionId), includeTimeline = true)
}
private fun createSession(params: JSONObject?): JSONObject {
val targetPackage = params.optNullableString("targetPackage")
val model = params.optNullableString("model") ?: DEFAULT_MODEL
val reasoningEffort = params.optNullableString("reasoningEffort") ?: DEFAULT_REASONING_EFFORT
val result = AgentSessionLauncher.createSessionDraft(
request = CreateSessionRequest(
targetPackage = targetPackage,
model = model,
reasoningEffort = reasoningEffort,
),
sessionController = sessionController,
)
if (result.anchor == AgentSessionInfo.ANCHOR_HOME) {
registerCreatedHomeSessionUiLease(result.sessionId)
}
return sessionJson(requireSession(result.sessionId), includeTimeline = true)
}
private fun startSession(params: JSONObject?): JSONObject {
val sessionId = params.requireString("sessionId")
val prompt = params.requireString("prompt")
val result = AgentSessionLauncher.startSessionDraftAsync(
context = context,
request = StartSessionRequest(
sessionId = sessionId,
prompt = prompt,
),
sessionController = sessionController,
requestUserInputHandler = null,
)
unregisterCreatedHomeSessionUiLease(sessionId)
return sessionJson(requireSession(result.parentSessionId), includeTimeline = true)
.put("geniePackage", result.geniePackage)
.put("plannedTargets", JSONArray(result.plannedTargets))
.put("childSessionIds", JSONArray(result.childSessionIds))
}
private fun answerQuestion(params: JSONObject?): JSONObject {
val sessionId = params.requireString("sessionId")
val answer = params.requireString("answer")
val snapshot = sessionController.loadSnapshot(sessionId)
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId }
?: throw IllegalArgumentException("Unknown session: $sessionId")
sessionController.answerQuestion(sessionId, answer, session.parentSessionId)
return JSONObject().put("ok", true)
}
private fun cancelSession(params: JSONObject?): JSONObject {
val sessionId = params.requireString("sessionId")
sessionController.cancelSessionTree(sessionId)
unregisterCreatedHomeSessionUiLease(sessionId)
return JSONObject().put("ok", true)
}
private fun attachTarget(params: JSONObject?): JSONObject {
val sessionId = params.requireString("sessionId")
sessionController.attachTarget(sessionId)
return JSONObject().put("ok", true)
}
private fun attachSession(params: JSONObject?): JSONObject {
val sessionId = params.requireString("sessionId")
val session = requireSession(sessionId)
ensureSessionAttachable(session)
val threadId = activeThreadId(session)
?: throw IllegalStateException("Session $sessionId is not attachable")
pruneExpiredAttachTokens()
val attachToken = UUID.randomUUID().toString().replace("-", "")
val target = AttachedSessionTarget(
sessionId = sessionId,
expiresAtElapsedRealtimeMs = SystemClock.elapsedRealtime() + ATTACH_TOKEN_TTL_MS,
keepAliveId = attachToken,
)
DesktopAttachKeepAliveManager.acquire(attachToken)
attachTokens[attachToken] = target
thread(name = "DesktopAttachTokenExpiry") {
SystemClock.sleep(ATTACH_TOKEN_TTL_MS)
if (attachTokens.remove(attachToken, target)) {
DesktopAttachKeepAliveManager.release(context, target.keepAliveId)
}
}
return JSONObject()
.put("sessionId", sessionId)
.put("threadId", threadId)
.put("websocketPath", "$SESSION_PATH_PREFIX$attachToken")
}
private fun pruneExpiredAttachTokens() {
val now = SystemClock.elapsedRealtime()
attachTokens.entries.removeIf { (_, target) ->
if (target.expiresAtElapsedRealtimeMs > now) {
return@removeIf false
}
DesktopAttachKeepAliveManager.release(context, target.keepAliveId)
true
}
}
private fun sessionJson(
session: AgentSessionDetails,
includeTimeline: Boolean = false,
): JSONObject {
val threadId = activeThreadId(session)
val executionSettings = sessionController.executionSettingsForSession(session.sessionId)
return JSONObject()
.put("sessionId", session.sessionId)
.put("parentSessionId", session.parentSessionId)
.put("kind", sessionKind(session))
.put("anchor", session.anchor)
.put("state", session.state)
.put("stateLabel", session.stateLabel)
.put("targetPackage", session.targetPackage)
.put("targetPresentation", session.targetPresentationLabel)
.put("targetRuntime", session.targetRuntimeLabel)
.put("latestQuestion", session.latestQuestion)
.put("latestResult", session.latestResult)
.put("latestError", session.latestError)
.put("latestTrace", session.latestTrace)
.put("model", executionSettings.model)
.put("reasoningEffort", executionSettings.reasoningEffort)
.put("threadId", threadId)
.put("attachable", !threadId.isNullOrBlank())
.apply {
if (includeTimeline) {
put("timeline", session.timeline)
}
}
}
private fun activeThreadId(session: AgentSessionDetails): String? {
return AgentSessionBridgeServer.activeThreadId(session.sessionId)
?: AgentPlannerRuntimeManager.activeThreadId(session.sessionId)
}
private fun requireSession(sessionId: String): AgentSessionDetails {
val snapshot = sessionController.loadSnapshot(sessionId)
return snapshot.sessions.firstOrNull { it.sessionId == sessionId }
?: throw IllegalArgumentException("Unknown session: $sessionId")
}
private fun ensureSessionAttachable(session: AgentSessionDetails) {
if (!activeThreadId(session).isNullOrBlank()) {
return
}
if (
!session.targetPackage.isNullOrBlank() &&
session.parentSessionId != null &&
session.state != AgentSessionInfo.STATE_COMPLETED &&
session.state != AgentSessionInfo.STATE_CANCELLED &&
session.state != AgentSessionInfo.STATE_FAILED
) {
waitForAttachableThread(session)
return
}
if (session.state != AgentSessionInfo.STATE_CREATED) {
return
}
when {
session.anchor == AgentSessionInfo.ANCHOR_HOME &&
session.parentSessionId == null &&
!session.targetPackage.isNullOrBlank() -> {
sessionController.startExistingHomeSessionIdle(
sessionId = session.sessionId,
targetPackage = checkNotNull(session.targetPackage),
allowDetachedMode = true,
finalPresentationPolicy = session.requiredFinalPresentationPolicy
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
executionSettings = sessionController.executionSettingsForSession(session.sessionId),
)
unregisterCreatedHomeSessionUiLease(session.sessionId)
}
session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
session.parentSessionId == null &&
session.targetPackage == null -> {
AgentPlannerRuntimeManager.ensureIdleDesktopSession(
context = context,
sessionController = sessionController,
sessionId = session.sessionId,
)
}
else -> return
}
waitForAttachableThread(session)
}
private fun waitForAttachableThread(session: AgentSessionDetails) {
val deadline = SystemClock.elapsedRealtime() + ATTACH_THREAD_WAIT_MS
while (SystemClock.elapsedRealtime() < deadline) {
if (!activeThreadId(session).isNullOrBlank()) {
return
}
Thread.sleep(ATTACH_THREAD_POLL_MS)
}
throw IllegalStateException("Session ${session.sessionId} did not expose an attachable thread in time")
}
private fun registerCreatedHomeSessionUiLease(sessionId: String) {
createdHomeSessionUiLeases.computeIfAbsent(sessionId) {
Binder().also { token ->
sessionController.registerSessionUiLease(sessionId, token)
}
}
}
private fun unregisterCreatedHomeSessionUiLease(sessionId: String) {
val token = createdHomeSessionUiLeases.remove(sessionId) ?: return
runCatching {
sessionController.unregisterSessionUiLease(sessionId, token)
}
}
private fun pruneCreatedHomeSessionUiLeases() {
if (createdHomeSessionUiLeases.isEmpty()) {
return
}
val sessionsById = sessionController.loadSnapshot(null).sessions.associateBy(AgentSessionDetails::sessionId)
createdHomeSessionUiLeases.keys.forEach { sessionId ->
val session = sessionsById[sessionId]
if (
session == null ||
session.anchor != AgentSessionInfo.ANCHOR_HOME ||
session.parentSessionId != null ||
session.state != AgentSessionInfo.STATE_CREATED
) {
unregisterCreatedHomeSessionUiLease(sessionId)
}
}
}
private fun openSessionProxy(
sessionId: String,
onMessage: (String) -> Unit,
onClosed: (String?) -> Unit,
): String? {
return AgentSessionBridgeServer.openDesktopProxy(
sessionId = sessionId,
onMessage = onMessage,
onClosed = onClosed,
) ?: AgentPlannerRuntimeManager.openDesktopProxy(
sessionId = sessionId,
onMessage = onMessage,
onClosed = onClosed,
)
}
private fun sendSessionProxyInput(
sessionId: String,
connectionId: String,
message: String,
): Boolean {
return AgentSessionBridgeServer.sendDesktopProxyInput(
sessionId = sessionId,
connectionId = connectionId,
message = message,
) || AgentPlannerRuntimeManager.sendDesktopProxyInput(
sessionId = sessionId,
connectionId = connectionId,
message = message,
)
}
private fun closeSessionProxy(
sessionId: String,
connectionId: String,
reason: String? = null,
detachPlanner: Boolean = false,
) {
AgentSessionBridgeServer.closeDesktopProxy(sessionId, connectionId, reason)
AgentPlannerRuntimeManager.closeDesktopProxy(
sessionId = sessionId,
connectionId = connectionId,
reason = reason,
detachPlanner = detachPlanner,
)
}
private fun shouldDetachPlannerOnWebSocketClose(
code: Int,
remote: Boolean,
): Boolean {
return when (code) {
1000, 1001 -> true
else -> !remote && code == 1005
}
}
private fun sendResult(
conn: WebSocket,
requestId: Any?,
result: JSONObject,
) {
conn.send(
JSONObject()
.put("id", requestId)
.put("result", result)
.toString(),
)
}
private fun sendError(
conn: WebSocket,
requestId: Any?,
code: Int,
message: String,
) {
conn.send(
JSONObject()
.put("id", requestId)
.put(
"error",
JSONObject()
.put("code", code)
.put("message", message),
)
.toString(),
)
}
private fun sessionKind(session: AgentSessionDetails): String {
return when {
session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
session.parentSessionId == null &&
session.targetPackage == null -> "agent_parent"
session.parentSessionId != null -> "genie_child"
else -> "home_session"
}
}
private fun JSONObject?.requireString(key: String): String {
val value = this?.optString(key)?.trim().orEmpty()
require(value.isNotEmpty()) { "Missing required parameter: $key" }
return value
}
private fun JSONObject?.optNullableString(key: String): String? {
if (this == null || !has(key) || isNull(key)) {
return null
}
return optString(key).trim().ifEmpty { null }
}
}
private data class SessionProxyConnection(
val sessionId: String,
val connectionId: String,
val keepAliveId: String,
)
private fun parseBearerToken(header: String?): String? {
if (header.isNullOrBlank()) {
return null
}
val prefix = "Bearer "
if (!header.startsWith(prefix, ignoreCase = true)) {
return null
}
return header.substring(prefix.length).trim().ifEmpty { null }
}
}

View File

@@ -0,0 +1,68 @@
package com.openai.codex.agent
object DesktopInspectionRegistry {
private val lock = Any()
private val attachedPlannerSessions = linkedSetOf<String>()
private val heldChildrenByParent = mutableMapOf<String, MutableSet<String>>()
private val parentByHeldChild = mutableMapOf<String, String>()
fun markPlannerAttached(parentSessionId: String) {
synchronized(lock) {
attachedPlannerSessions += parentSessionId
}
}
fun markPlannerDetached(parentSessionId: String): Set<String> {
return synchronized(lock) {
attachedPlannerSessions.remove(parentSessionId)
val releasedChildren = heldChildrenByParent.remove(parentSessionId).orEmpty().toSet()
releasedChildren.forEach(parentByHeldChild::remove)
releasedChildren
}
}
fun holdChildrenForAttachedPlanner(
parentSessionId: String,
childSessionIds: Collection<String>,
): Boolean {
return synchronized(lock) {
if (parentSessionId !in attachedPlannerSessions) {
return false
}
val heldChildren = heldChildrenByParent.getOrPut(parentSessionId, ::linkedSetOf)
childSessionIds.forEach { childSessionId ->
if (childSessionId.isNotBlank()) {
heldChildren += childSessionId
parentByHeldChild[childSessionId] = parentSessionId
}
}
true
}
}
fun isPlannerAttached(parentSessionId: String): Boolean {
return synchronized(lock) {
parentSessionId in attachedPlannerSessions
}
}
fun isSessionHeldForInspection(sessionId: String): Boolean {
return synchronized(lock) {
sessionId in parentByHeldChild
}
}
fun removeSession(sessionId: String) {
synchronized(lock) {
if (attachedPlannerSessions.remove(sessionId)) {
heldChildrenByParent.remove(sessionId).orEmpty().forEach(parentByHeldChild::remove)
}
parentByHeldChild.remove(sessionId)?.let { parentSessionId ->
heldChildrenByParent[parentSessionId]?.remove(sessionId)
if (heldChildrenByParent[parentSessionId].isNullOrEmpty()) {
heldChildrenByParent.remove(parentSessionId)
}
}
}
}
}

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,761 @@
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 = "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 the parent and all 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 {
sessionController.cancelSessionTree(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 ${topLevelSession.sessionId} and its 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

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