Compare commits

...

127 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Net effect: existing targets keep the same HTTP surface, but the TLS backend is unified and Android-friendly.
2026-03-26 07:13:02 -07:00
203 changed files with 15636 additions and 512 deletions

9
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

4
android/.gitignore vendored Normal file
View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 791 KiB

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

@@ -0,0 +1 @@
# No custom rules yet.

View File

@@ -0,0 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:label="@string/app_name">
<service
android:name=".CodexGenieService"
android:exported="true"
android:permission="android.permission.BIND_GENIE_SERVICE">
<intent-filter>
<action android:name="android.app.agent.GenieService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -0,0 +1,164 @@
package com.openai.codex.genie
import android.app.agent.GenieService
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.util.Log
import com.openai.codex.bridge.FrameworkSessionTransportCompat
import com.openai.codex.bridge.SessionExecutionSettings
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.Closeable
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.UUID
import org.json.JSONObject
class AgentBridgeClient(
callback: GenieService.Callback,
private val sessionId: String,
) : Closeable {
companion object {
private const val TAG = "AgentBridgeClient"
private const val OP_GET_RUNTIME_STATUS = "getRuntimeStatus"
private const val OP_READ_INSTALLED_AGENTS_FILE = "readInstalledAgentsFile"
private const val OP_READ_SESSION_EXECUTION_SETTINGS = "readSessionExecutionSettings"
private const val WRITE_CHUNK_BYTES = 4096
private const val RESPONSES_METHOD = "POST"
private const val DEFAULT_RESPONSES_PATH = "/responses"
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_VALUE_APPLICATION_JSON = "application/json"
private const val HEADER_VALUE_TEXT_EVENT_STREAM = "text/event-stream"
private const val HEADER_VALUE_IDENTITY = "identity"
}
private val frameworkCallback = callback
private val bridgeFd: ParcelFileDescriptor = callback.openSessionBridge(sessionId)
private val input = DataInputStream(BufferedInputStream(FileInputStream(bridgeFd.fileDescriptor)))
private val output = DataOutputStream(BufferedOutputStream(FileOutputStream(bridgeFd.fileDescriptor)))
private val ioLock = Any()
private var frameworkResponsesPath: String = DEFAULT_RESPONSES_PATH
init {
Log.i(TAG, "Using framework session bridge transport for $sessionId")
Log.i(TAG, "Using framework-owned HTTP bridge for $sessionId")
}
fun getRuntimeStatus(): CodexAgentBridge.RuntimeStatus {
val status = request(
JSONObject().put("method", OP_GET_RUNTIME_STATUS),
).getJSONObject("runtimeStatus")
frameworkResponsesPath = status.optString("frameworkResponsesPath").ifBlank { DEFAULT_RESPONSES_PATH }
return CodexAgentBridge.RuntimeStatus(
authenticated = status.getBoolean("authenticated"),
accountEmail = status.optNullableString("accountEmail"),
clientCount = status.optInt("clientCount"),
modelProviderId = status.optString("modelProviderId"),
configuredModel = status.optNullableString("configuredModel"),
effectiveModel = status.optNullableString("effectiveModel"),
upstreamBaseUrl = status.optString("upstreamBaseUrl"),
frameworkResponsesPath = frameworkResponsesPath,
)
}
fun readInstalledAgentsMarkdown(): String {
return request(
JSONObject().put("method", OP_READ_INSTALLED_AGENTS_FILE),
).getString("agentsMarkdown")
}
fun readSessionExecutionSettings(): SessionExecutionSettings {
val settings = request(
JSONObject().put("method", OP_READ_SESSION_EXECUTION_SETTINGS),
).getJSONObject("executionSettings")
return SessionExecutionSettings(
model = settings.optNullableString("model"),
reasoningEffort = settings.optNullableString("reasoningEffort"),
)
}
fun sendResponsesRequest(body: String): AgentResponsesHttpResponse {
val response = FrameworkSessionTransportCompat.executeStreamingRequest(
callback = frameworkCallback,
sessionId = sessionId,
request = FrameworkSessionTransportCompat.HttpRequest(
method = RESPONSES_METHOD,
path = frameworkResponsesPath,
headers = 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)
},
body = body.toByteArray(StandardCharsets.UTF_8),
),
)
return AgentResponsesHttpResponse(
statusCode = response.statusCode,
body = response.bodyString,
)
}
override fun close() {
synchronized(ioLock) {
runCatching { input.close() }
runCatching { output.close() }
runCatching { bridgeFd.close() }
}
}
private fun request(request: JSONObject): JSONObject {
val requestId = UUID.randomUUID().toString()
synchronized(ioLock) {
writeMessage(request.put("requestId", requestId))
val response = readMessage()
if (response.optString("requestId") != requestId) {
throw IOException("Mismatched Agent bridge response id")
}
if (!response.optBoolean("ok")) {
throw IOException(response.optString("error").ifBlank { "Agent bridge request failed" })
}
return response
}
}
private fun writeMessage(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 readMessage(): JSONObject {
val size = input.readInt()
if (size <= 0) {
throw IOException("Invalid Agent bridge message length: $size")
}
val payload = ByteArray(size)
input.readFully(payload)
return JSONObject(payload.toString(StandardCharsets.UTF_8))
}
private fun JSONObject.optNullableString(key: String): String? {
if (!has(key) || isNull(key)) {
return null
}
return optString(key).ifBlank { null }
}
data class AgentResponsesHttpResponse(
val statusCode: Int,
val body: String,
)
}

View File

@@ -0,0 +1,197 @@
package com.openai.codex.genie
import android.app.agent.GenieService
import android.graphics.Bitmap
import android.util.Base64
import com.openai.codex.bridge.DetachedTargetCompat
import java.io.ByteArrayOutputStream
import java.io.IOException
import kotlin.math.max
import kotlin.math.roundToInt
import org.json.JSONObject
class AndroidGenieToolExecutor(
private val callback: GenieService.Callback,
private val sessionId: String,
) {
companion object {
private const val MAX_CAPTURE_LONG_EDGE = 480
private const val MAX_CAPTURE_JPEG_BYTES = 48 * 1024
private const val INITIAL_JPEG_QUALITY = 65
private const val MIN_CAPTURE_JPEG_QUALITY = 38
const val ENSURE_HIDDEN_TARGET_TOOL = "android_target_ensure_hidden"
const val SHOW_TARGET_TOOL = "android_target_show"
const val HIDE_TARGET_TOOL = "android_target_hide"
const val ATTACH_TARGET_TOOL = "android_target_attach"
const val CLOSE_TARGET_TOOL = "android_target_close"
const val CAPTURE_TARGET_FRAME_TOOL = "android_target_capture_frame"
}
fun execute(
toolName: String,
@Suppress("UNUSED_PARAMETER") arguments: JSONObject,
): GenieToolObservation {
return when (toolName) {
ENSURE_HIDDEN_TARGET_TOOL -> requestTargetVisibility(
action = "ensure hidden",
request = {
DetachedTargetCompat.ensureDetachedTargetHidden(callback, sessionId)
},
attemptRecovery = false,
)
SHOW_TARGET_TOOL -> requestTargetVisibility(
action = "show",
request = {
DetachedTargetCompat.showDetachedTarget(callback, sessionId)
},
)
HIDE_TARGET_TOOL -> requestTargetVisibility(
action = "hide",
request = {
DetachedTargetCompat.hideDetachedTarget(callback, sessionId)
},
)
ATTACH_TARGET_TOOL -> requestTargetVisibility(
action = "attach",
request = {
DetachedTargetCompat.attachDetachedTarget(callback, sessionId)
},
)
CLOSE_TARGET_TOOL -> requestTargetVisibility(
action = "close",
request = {
DetachedTargetCompat.closeDetachedTarget(callback, sessionId)
},
attemptRecovery = false,
)
CAPTURE_TARGET_FRAME_TOOL -> captureDetachedTargetFrame()
else -> throw IOException("Unknown tool: $toolName")
}
}
private fun requestTargetVisibility(
action: String,
request: () -> DetachedTargetCompat.DetachedTargetControlResult,
attemptRecovery: Boolean = true,
): GenieToolObservation {
val recoveryDetails = mutableListOf<String>()
var result = request()
if (attemptRecovery && result.needsRecovery()) {
val recovery = DetachedTargetCompat.ensureDetachedTargetHidden(callback, sessionId)
recoveryDetails += recovery.summary("ensure hidden")
if (recovery.isOk()) {
result = request()
} else {
throw IOException(
"${result.summary(action)} Recovery failed: ${recovery.summary("ensure hidden")}",
)
}
}
if (!result.isOk()) {
throw IOException(result.summary(action))
}
val promptDetails = buildString {
append(result.summary(action))
recoveryDetails.forEach { detail ->
append("\n")
append(detail)
}
}
return GenieToolObservation(
name = "android_target_" + action.replace(' ', '_'),
summary = promptDetails.lineSequence().first(),
promptDetails = promptDetails,
)
}
private fun captureDetachedTargetFrame(): GenieToolObservation {
val recoveryDetails = mutableListOf<String>()
var capture = DetachedTargetCompat.captureDetachedTargetFrameResult(callback, sessionId)
if (capture.needsRecovery()) {
val recovery = DetachedTargetCompat.ensureDetachedTargetHidden(callback, sessionId)
recoveryDetails += recovery.summary("ensure hidden")
if (recovery.isOk()) {
capture = DetachedTargetCompat.captureDetachedTargetFrameResult(callback, sessionId)
} else {
throw IOException("${capture.summary()} Recovery failed: ${recovery.summary("ensure hidden")}")
}
}
if (!capture.isOk()) {
throw IOException(capture.summary())
}
val result = checkNotNull(capture.captureResult)
val hardwareBuffer = result.hardwareBuffer ?: throw IOException("Detached frame missing hardware buffer")
val bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer, result.colorSpace)
?: throw IOException("Failed to wrap detached frame")
val copy = bitmap.copy(Bitmap.Config.ARGB_8888, false)
?: throw IOException("Failed to copy detached frame")
val (encodedBitmap, jpeg) = encodeDetachedFrame(copy)
return GenieToolObservation(
name = CAPTURE_TARGET_FRAME_TOOL,
summary = "Captured detached target frame ${encodedBitmap.width}x${encodedBitmap.height} (${capture.targetRuntime.label}).",
promptDetails = buildString {
append(
"Captured detached target frame ${encodedBitmap.width}x${encodedBitmap.height}. Runtime=${capture.targetRuntime.label}. JPEG=${jpeg.size} bytes.",
)
recoveryDetails.forEach { detail ->
append("\n")
append(detail)
}
append("\nUse the attached image to inspect the current UI.")
},
imageDataUrls = listOf(
"data:image/jpeg;base64," + Base64.encodeToString(jpeg, Base64.NO_WRAP),
),
)
}
private fun encodeDetachedFrame(bitmap: Bitmap): Pair<Bitmap, ByteArray> {
var encodedBitmap = bitmap.downscaleIfNeeded(MAX_CAPTURE_LONG_EDGE)
var quality = INITIAL_JPEG_QUALITY
var jpeg = encodedBitmap.encodeJpeg(quality)
while (jpeg.size > MAX_CAPTURE_JPEG_BYTES && quality > MIN_CAPTURE_JPEG_QUALITY) {
quality -= 7
jpeg = encodedBitmap.encodeJpeg(quality)
}
while (jpeg.size > MAX_CAPTURE_JPEG_BYTES) {
val nextWidth = max((encodedBitmap.width * 0.8f).roundToInt(), 1)
val nextHeight = max((encodedBitmap.height * 0.8f).roundToInt(), 1)
if (nextWidth == encodedBitmap.width && nextHeight == encodedBitmap.height) {
break
}
val scaled = Bitmap.createScaledBitmap(encodedBitmap, nextWidth, nextHeight, true)
if (encodedBitmap !== bitmap) {
encodedBitmap.recycle()
}
encodedBitmap = scaled
quality = INITIAL_JPEG_QUALITY
jpeg = encodedBitmap.encodeJpeg(quality)
while (jpeg.size > MAX_CAPTURE_JPEG_BYTES && quality > MIN_CAPTURE_JPEG_QUALITY) {
quality -= 7
jpeg = encodedBitmap.encodeJpeg(quality)
}
}
return encodedBitmap to jpeg
}
private fun Bitmap.downscaleIfNeeded(maxLongEdge: Int): Bitmap {
val longEdge = max(width, height)
if (longEdge <= maxLongEdge) {
return this
}
val scale = maxLongEdge.toFloat() / longEdge.toFloat()
val scaledWidth = max((width * scale).roundToInt(), 1)
val scaledHeight = max((height * scale).roundToInt(), 1)
return Bitmap.createScaledBitmap(this, scaledWidth, scaledHeight, true)
}
private fun Bitmap.encodeJpeg(quality: Int): ByteArray {
return ByteArrayOutputStream().use { output ->
if (!compress(Bitmap.CompressFormat.JPEG, quality, output)) {
throw IOException("Failed to encode detached frame")
}
output.toByteArray()
}
}
}

View File

@@ -0,0 +1,146 @@
package com.openai.codex.genie
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
object CodexAgentBridge {
fun buildResponsesRequest(
model: String,
instructions: String,
prompt: String,
imageDataUrls: List<String> = emptyList(),
): JSONObject {
val content = JSONArray().put(
JSONObject()
.put("type", "input_text")
.put("text", prompt),
)
imageDataUrls.forEach { imageDataUrl ->
content.put(
JSONObject()
.put("type", "input_image")
.put("image_url", imageDataUrl),
)
}
return JSONObject()
.put("model", model)
.put("store", false)
.put("stream", true)
.put("instructions", instructions)
.put(
"input",
JSONArray().put(
JSONObject()
.put("role", "user")
.put("content", content),
),
)
}
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 HttpResponse(
val statusCode: Int,
val body: String,
)
fun parseResponsesOutputText(httpResponse: HttpResponse): String {
if (httpResponse.statusCode != 200) {
throw IOException("HTTP ${httpResponse.statusCode}: ${httpResponse.body}")
}
val body = httpResponse.body.trim()
if (body.startsWith("event:") || body.startsWith("data:")) {
return parseResponsesStreamOutputText(body)
}
val data = JSONObject(body)
return parseResponsesJsonOutputText(data)
}
private fun parseResponsesJsonOutputText(data: JSONObject): String {
val directOutput = data.optString("output_text")
if (directOutput.isNotBlank()) {
return directOutput
}
val output = data.optJSONArray("output")
?: throw IOException("Responses payload missing output")
val combined = buildString {
for (outputIndex in 0 until output.length()) {
val item = output.optJSONObject(outputIndex) ?: continue
val content = item.optJSONArray("content") ?: continue
for (contentIndex in 0 until content.length()) {
val part = content.optJSONObject(contentIndex) ?: continue
if (part.optString("type") == "output_text") {
append(part.optString("text"))
}
}
}
}
if (combined.isBlank()) {
throw IOException("Responses payload missing output_text content")
}
return combined
}
private fun parseResponsesStreamOutputText(body: String): String {
val deltaText = StringBuilder()
val completedItems = mutableListOf<String>()
body.split("\n\n").forEach { rawEvent ->
val lines = rawEvent.lineSequence().map(String::trimEnd).toList()
if (lines.isEmpty()) {
return@forEach
}
val dataPayload = lines
.filter { it.startsWith("data:") }
.joinToString("\n") { it.removePrefix("data:").trimStart() }
.trim()
if (dataPayload.isEmpty() || dataPayload == "[DONE]") {
return@forEach
}
val event = JSONObject(dataPayload)
when (event.optString("type")) {
"response.output_text.delta" -> deltaText.append(event.optString("delta"))
"response.output_item.done" -> {
val item = event.optJSONObject("item") ?: return@forEach
val content = item.optJSONArray("content") ?: return@forEach
val text = buildString {
for (index in 0 until content.length()) {
val part = content.optJSONObject(index) ?: continue
if (part.optString("type") == "output_text") {
append(part.optString("text"))
}
}
}
if (text.isNotBlank()) {
completedItems += text
}
}
"response.failed" -> {
throw IOException(event.toString())
}
}
}
if (deltaText.isNotBlank()) {
return deltaText.toString()
}
val completedText = completedItems.joinToString("")
if (completedText.isNotBlank()) {
return completedText
}
throw IOException("Responses stream missing output_text content")
}
}
internal fun JSONObject.optNullableString(name: String): String? = when {
isNull(name) -> null
else -> optString(name).ifBlank { null }
}

View File

@@ -0,0 +1,746 @@
package com.openai.codex.genie
import android.app.agent.AgentSessionInfo
import android.app.agent.GenieRequest
import android.app.agent.GenieService
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 org.json.JSONArray
import org.json.JSONObject
class CodexAppServerHost(
private val context: Context,
private val request: GenieRequest,
private val callback: GenieService.Callback,
private val control: GenieSessionControl,
private val bridgeClient: AgentBridgeClient,
private val runtimeStatus: CodexAgentBridge.RuntimeStatus,
) : Closeable {
companion object {
private const val TAG = "CodexAppServerHost"
private const val APP_SERVER_BRIDGE_ENV_VAR = "CODEX_OPENAI_APP_SERVER_BRIDGE"
private const val REQUEST_TIMEOUT_MS = 30_000L
private const val POLL_TIMEOUT_MS = 250L
private const val DEFAULT_HOSTED_MODEL = "gpt-5.3-codex"
}
private val requestIdSequence = AtomicInteger(1)
private val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
private val inboundMessages = LinkedBlockingQueue<JSONObject>()
private val writerLock = Any()
private val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
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 finalAgentMessage: String? = null
private var resultPublished = false
fun run() {
startProcess()
initialize()
executionSettings = bridgeClient.readSessionExecutionSettings()
val model = resolveModel()
val threadId = startThread(model)
startTurn(threadId, model)
callback.publishTrace(request.sessionId, "Hosted codex app-server thread $threadId for ${request.targetPackage}.")
eventLoop()
}
override fun close() {
stdoutThread?.interrupt()
stderrThread?.interrupt()
synchronized(writerLock) {
runCatching { writer.close() }
}
if (::codexHome.isInitialized) {
runCatching { codexHome.deleteRecursively() }
}
if (::process.isInitialized) {
process.destroy()
}
control.process = null
}
private fun startProcess() {
codexHome = File(context.cacheDir, "codex-home/${request.sessionId}").apply {
deleteRecursively()
mkdirs()
}
HostedCodexConfig.installAgentsFile(codexHome, bridgeClient.readInstalledAgentsMarkdown())
val processBuilder = ProcessBuilder(
listOf(
CodexBinaryLocator.resolve(context).absolutePath,
"-c",
"enable_request_compression=false",
"app-server",
"--listen",
"stdio://",
),
)
val env = processBuilder.environment()
env["CODEX_HOME"] = codexHome.absolutePath
env[APP_SERVER_BRIDGE_ENV_VAR] = "1"
env["RUST_LOG"] = "warn"
process = processBuilder.start()
control.process = process
writer = process.outputStream.bufferedWriter()
startStdoutPump()
startStderrPump()
}
private fun startStdoutPump() {
stdoutThread = Thread {
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 codex app-server stdout line", err)
return@forEach
}
routeInbound(message)
}
}
} catch (_: InterruptedIOException) {
// Expected when the hosted app-server exits and the stream closes underneath the reader.
} catch (err: IOException) {
if (!control.cancelled && process.isAlive) {
Log.w(TAG, "Stdout pump failed for ${request.sessionId}", err)
}
}
}.also {
it.name = "CodexAppServerStdout-${request.sessionId}"
it.start()
}
}
private fun startStderrPump() {
stderrThread = Thread {
try {
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
if (line.isBlank()) {
return@forEach
}
when {
line.contains(" ERROR ") -> Log.e(TAG, line)
line.contains(" WARN ") || line.startsWith("WARNING:") -> Log.w(TAG, line)
}
}
}
} catch (_: InterruptedIOException) {
// Expected when the hosted app-server exits and the stream closes underneath the reader.
} catch (err: IOException) {
if (!control.cancelled && process.isAlive) {
Log.w(TAG, "Stderr pump failed for ${request.sessionId}", err)
}
}
}.also {
it.name = "CodexAppServerStderr-${request.sessionId}"
it.start()
}
}
private fun routeInbound(message: JSONObject) {
if (message.has("id") && !message.has("method")) {
pendingResponses[message.get("id").toString()]?.offer(message)
return
}
inboundMessages.offer(message)
}
private fun initialize() {
request(
method = "initialize",
params = JSONObject()
.put(
"clientInfo",
JSONObject()
.put("name", "android_genie")
.put("title", "Android Genie")
.put("version", "0.1.0"),
)
.put(
"capabilities",
JSONObject().put("experimentalApi", true),
),
)
notify("initialized", JSONObject())
}
private fun startThread(model: String): String {
val params = JSONObject()
.put("approvalPolicy", "never")
.put("sandbox", "read-only")
.put("ephemeral", true)
.put("cwd", context.filesDir.absolutePath)
.put("serviceName", "android_genie")
.put("baseInstructions", buildBaseInstructions())
.put("dynamicTools", buildDynamicToolSpecs())
params.put("model", model)
val result = request(
method = "thread/start",
params = params,
)
return result.getJSONObject("thread").getString("id")
}
private fun startTurn(
threadId: String,
model: String,
) {
Log.i(TAG, "Starting hosted turn for ${request.sessionId} with model=$model")
request(
method = "turn/start",
params = JSONObject()
.put("threadId", threadId)
.put("model", model)
.apply {
executionSettings.reasoningEffort
?.takeIf(String::isNotBlank)
?.let { put("effort", it) }
}
.put(
"input",
JSONArray().put(
JSONObject()
.put("type", "text")
.put("text", buildDelegatedPrompt()),
),
),
)
}
private fun resolveModel(): String = executionSettings.model
?.takeIf(String::isNotBlank)
?: runtimeStatus.configuredModel
?.takeIf(String::isNotBlank)
?: runtimeStatus.effectiveModel
?.takeIf(String::isNotBlank)
?: DEFAULT_HOSTED_MODEL
private fun eventLoop() {
while (!control.cancelled) {
val message = inboundMessages.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS)
if (message == null) {
if (!process.isAlive) {
throw IOException("codex 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
}
}
throw IOException("Cancelled")
}
private fun handleServerRequest(message: JSONObject) {
val method = message.getString("method")
val requestId = message.get("id")
val params = message.optJSONObject("params") ?: JSONObject()
Log.i(TAG, "Handling app-server request method=$method session=${request.sessionId}")
when (method) {
"item/tool/call" -> handleDynamicToolCall(requestId, params)
"item/tool/requestUserInput" -> handleRequestUserInput(requestId, params)
"response/send" -> handleResponsesBridgeRequest(requestId, params)
else -> {
callback.publishTrace(request.sessionId, "Unsupported codex app-server request: $method")
sendError(
requestId = requestId,
code = -32601,
message = "Unsupported app-server request: $method",
)
}
}
}
private fun handleDynamicToolCall(
requestId: Any,
params: JSONObject,
) {
val toolName = params.optString("tool").trim()
val arguments = params.optJSONObject("arguments") ?: JSONObject()
Log.i(TAG, "Executing dynamic tool $toolName arguments=$arguments")
val toolExecutor = AndroidGenieToolExecutor(
callback = callback,
sessionId = request.sessionId,
)
val observation = runCatching {
toolExecutor.execute(toolName, arguments)
}.getOrElse { err ->
GenieToolObservation(
name = toolName.ifBlank { "unknown" },
summary = "Tool $toolName failed: ${err.message}",
promptDetails = "Tool $toolName failed.\nError: ${err.message ?: err::class.java.simpleName}",
)
}
callback.publishTrace(request.sessionId, observation.summary)
sendResult(
requestId = requestId,
result = JSONObject()
.put("success", !observation.summary.contains(" failed:"))
.put("contentItems", buildDynamicToolContentItems(observation)),
)
}
private fun handleRequestUserInput(
requestId: Any,
params: JSONObject,
) {
val questions = params.optJSONArray("questions") ?: JSONArray()
val renderedQuestion = renderAgentQuestion(questions)
Log.i(TAG, "Requesting Agent input for ${request.sessionId}: $renderedQuestion")
callback.publishQuestion(request.sessionId, renderedQuestion)
callback.updateState(request.sessionId, AgentSessionInfo.STATE_WAITING_FOR_USER)
val answer = control.waitForUserResponse()
callback.updateState(request.sessionId, AgentSessionInfo.STATE_RUNNING)
callback.publishTrace(request.sessionId, "Received Agent answer for ${request.targetPackage}.")
Log.i(TAG, "Received Agent input for ${request.sessionId}: ${answer.take(160)}")
sendResult(
requestId = requestId,
result = JSONObject().put("answers", buildQuestionAnswers(questions, answer)),
)
}
private fun handleResponsesBridgeRequest(
requestId: Any,
params: JSONObject,
) {
val requestBody = params.optString("requestBody")
val httpResponse = bridgeClient.sendResponsesRequest(requestBody)
sendResult(
requestId = requestId,
result = JSONObject()
.put("statusCode", httpResponse.statusCode)
.put("body", httpResponse.body),
)
}
private fun handleNotification(message: JSONObject): Boolean {
val method = message.getString("method")
val params = message.optJSONObject("params") ?: JSONObject()
return when (method) {
"turn/started" -> {
callback.publishTrace(request.sessionId, "codex turn started for ${request.targetPackage}.")
false
}
"item/agentMessage/delta" -> {
val itemId = params.optString("itemId")
if (itemId.isNotBlank()) {
streamedAgentMessages.getOrPut(itemId, ::StringBuilder)
.append(params.optString("delta"))
}
false
}
"item/started" -> {
publishItemStartedTrace(params.optJSONObject("item"))
false
}
"item/completed" -> {
captureCompletedItem(params.optJSONObject("item"))
false
}
"turn/completed" -> {
finishTurn(params)
true
}
else -> false
}
}
private fun publishItemStartedTrace(item: JSONObject?) {
if (item == null) {
return
}
val command = commandForItem(item)
Log.i(
TAG,
"item/started type=${item.optString("type")} tool=${item.optString("tool")} command=${command ?: ""}",
)
when (item.optString("type")) {
"dynamicToolCall" -> {
val tool = item.optString("tool")
callback.publishTrace(request.sessionId, "Codex requested dynamic tool $tool.")
}
"commandExecution" -> {
if (request.isDetachedModeAllowed && command != null) {
check(!DetachedSessionGuard.isForbiddenTargetLaunchCommand(command, request.targetPackage)) {
DetachedSessionGuard.violationMessage(request.targetPackage, command)
}
}
callback.publishTrace(
request.sessionId,
"Codex started command execution: ${command ?: "command"}",
)
}
}
}
private fun captureCompletedItem(item: JSONObject?) {
if (item == null) {
return
}
val command = commandForItem(item)
val errorDetail = item.optString("aggregatedOutput").ifBlank {
item.optString("stderr").ifBlank {
item.optString("output").ifBlank {
item.optString("error")
}
}
}.trim()
Log.i(
TAG,
"item/completed type=${item.optString("type")} status=${item.optString("status")} tool=${item.optString("tool")} command=${command ?: ""} error=${errorDetail.take(200)}",
)
when (item.optString("type")) {
"agentMessage" -> {
val itemId = item.optString("id")
val text = item.optString("text").ifBlank {
streamedAgentMessages[itemId]?.toString().orEmpty()
}
if (text.isNotBlank()) {
finalAgentMessage = text
}
}
"commandExecution" -> {
val status = item.optString("status")
val exitCode = if (item.has("exitCode")) item.opt("exitCode") else null
val resolvedCommand = command ?: "command"
if (status == "failed") {
Log.i(TAG, "Failed command item=${item}")
val detailSuffix = errorDetail
.takeIf(String::isNotBlank)
?.let { " Details: ${it.take(240)}" }
.orEmpty()
callback.publishTrace(
request.sessionId,
"Command failed: $resolvedCommand (status=$status, exitCode=${exitCode ?: "unknown"}).$detailSuffix",
)
if (errorDetail.contains("package=com.android.shell does not belong to uid=")) {
callback.publishTrace(
request.sessionId,
"This shell command requires com.android.shell privileges. The target is already running hidden; use detached-target dynamic tools to show or inspect it instead of retrying the same shell launch surface.",
)
}
} else {
callback.publishTrace(
request.sessionId,
"Command completed: $resolvedCommand (status=$status, exitCode=${exitCode ?: "unknown"}).",
)
}
}
"dynamicToolCall" -> {
val tool = item.optString("tool")
val status = item.optString("status")
callback.publishTrace(request.sessionId, "Dynamic tool $tool completed with status=$status.")
}
}
}
private fun commandForItem(item: JSONObject): String? {
return item.optString("command")
.takeIf(String::isNotBlank)
?: item.optJSONArray("command")?.join(" ")
}
private fun finishTurn(params: JSONObject) {
val turn = params.optJSONObject("turn") ?: JSONObject()
Log.i(TAG, "turn/completed status=${turn.optString("status")} error=${turn.opt("error")}")
when (turn.optString("status")) {
"completed" -> {
val resultText = finalAgentMessage?.takeIf(String::isNotBlank)
?: "Genie completed without a final assistant message."
publishResultOnce(resultText)
callback.updateState(request.sessionId, AgentSessionInfo.STATE_COMPLETED)
}
"interrupted" -> {
callback.publishError(request.sessionId, "Genie turn interrupted")
callback.updateState(request.sessionId, AgentSessionInfo.STATE_CANCELLED)
}
else -> {
val errorDetail = turn.opt("error")?.toString()
?: "Genie turn failed with status ${turn.optString("status", "unknown")}"
callback.publishError(request.sessionId, errorDetail)
callback.updateState(request.sessionId, AgentSessionInfo.STATE_FAILED)
}
}
}
private fun publishResultOnce(text: String) {
if (resultPublished) {
return
}
resultPublished = true
callback.publishResult(request.sessionId, text)
}
private fun request(
method: String,
params: JSONObject,
): JSONObject {
val requestId = requestIdSequence.getAndIncrement().toString()
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 buildBaseInstructions(): String {
val detachedSessionInstructions = if (request.isDetachedModeAllowed) {
DetachedSessionGuard.instructions(request.targetPackage)
} else {
""
}
return """
You are Codex acting as a child Android Genie bound to ${request.targetPackage}.
The user interacts only with the supervising Agent.
Decide your own local plan and choose tools yourself.
Prefer direct self-targeted Android shell commands and intents first when they can satisfy the objective without UI-driving.
In this platform build, an active Genie session may use self-targeted shell surfaces such as `am start --user 0`, `cmd activity start-activity --user 0`, `cmd package resolve-activity`, `cmd package query-activities --user 0`, `input`, `uiautomator dump`, `screencap`, and `screenrecord`.
When using `am start`, `cmd activity start-activity`, or `cmd package query-activities`, pass `--user 0`; omitting it can fail with cross-user permission errors.
Android shell `date` is not GNU coreutils `date`; do not rely on `date -d "+5 minutes"` or similar relative-date parsing because it fails on this platform.
When you must convert a relative request like “in 5 minutes” into wall-clock alarm fields, compute it with shell arithmetic from `date +%H` and `date +%M`, for example: `h=$(date +%H); m=$(date +%M); total=$((10#${'$'}h * 60 + 10#${'$'}m + 5)); hour=$(((total / 60) % 24)); minute=$((total % 60))`, then pass `hour` and `minute` as integers to `am start`.
When the objective is a timer duration rather than a wall-clock alarm, prefer direct duration-based intents like `android.intent.action.SET_TIMER` with a length in seconds instead of computing a future clock time.
Avoid `dumpsys` and `cmd package dump` for package/activity inspection because they require `android.permission.DUMP` in the paired app UID and will not help you complete the task.
If a direct command or intent clearly accomplishes the objective, stop and report success instead of continuing exploratory UI actions.
The Genie may request detached target launch through the framework callback, and after that it should treat the target as already launched by the framework.
Use detached-target tools to show or inspect the target, then continue with supported shell input and inspection surfaces rather than relaunching the target package.
If detached recovery is needed because the target disappeared, use android_target_ensure_hidden before retrying UI inspection.
Use Android dynamic tools only for framework-only detached target operations that do not have a working shell equivalent in the paired app sandbox.
$detachedSessionInstructions
The delegated objective may include a required final target presentation such as ATTACHED, DETACHED_HIDDEN, or DETACHED_SHOWN. Treat that as a hard completion requirement and do not report success until the framework session actually matches it.
If you need clarification or a decision from the supervising Agent, call request_user_input with concise free-form question text.
Do not use hidden control protocols.
Finish with a normal assistant message describing what you accomplished or what blocked you.
Detached target mode allowed: ${request.isDetachedModeAllowed}.
Agent-owned runtime provider: ${runtimeStatus.modelProviderId}.
""".trimIndent()
}
private fun buildDelegatedPrompt(): String {
val detachedSessionPrompt = if (request.isDetachedModeAllowed) {
"""
Detached-session requirement:
- The framework already launched ${request.targetPackage} hidden for this session.
- Do not relaunch ${request.targetPackage} with shell launch commands. Use framework target controls plus UI inspection and input instead.
- If the detached target disappears or looks empty, use android_target_ensure_hidden to request framework-owned recovery.
""".trimIndent()
} else {
""
}
return """
Target package:
${request.targetPackage}
Delegated objective:
${request.prompt}
$detachedSessionPrompt
""".trimIndent()
}
private fun buildDynamicToolSpecs(): JSONArray {
return JSONArray()
.put(dynamicToolSpec(AndroidGenieToolExecutor.ENSURE_HIDDEN_TARGET_TOOL, "Ensure the detached target exists and remains hidden. Use this to restore a missing detached target.", emptyObjectSchema()))
.put(dynamicToolSpec(AndroidGenieToolExecutor.SHOW_TARGET_TOOL, "Show the detached target window.", emptyObjectSchema()))
.put(dynamicToolSpec(AndroidGenieToolExecutor.HIDE_TARGET_TOOL, "Hide the detached target window.", emptyObjectSchema()))
.put(dynamicToolSpec(AndroidGenieToolExecutor.ATTACH_TARGET_TOOL, "Reattach the detached target back to the main display.", emptyObjectSchema()))
.put(dynamicToolSpec(AndroidGenieToolExecutor.CLOSE_TARGET_TOOL, "Close the detached target window.", emptyObjectSchema()))
.put(dynamicToolSpec(AndroidGenieToolExecutor.CAPTURE_TARGET_FRAME_TOOL, "Capture the detached target window as an image.", emptyObjectSchema()))
}
private fun dynamicToolSpec(
name: String,
description: String,
inputSchema: JSONObject,
): JSONObject {
return JSONObject()
.put("name", name)
.put("description", description)
.put("inputSchema", inputSchema)
}
private fun emptyObjectSchema(): JSONObject {
return objectSchema(emptyMap())
}
private fun objectSchema(
properties: Map<String, JSONObject>,
required: List<String> = emptyList(),
): JSONObject {
val propertiesJson = JSONObject()
properties.forEach { (name, schema) -> propertiesJson.put(name, schema) }
return JSONObject()
.put("type", "object")
.put("properties", propertiesJson)
.put("required", JSONArray(required))
.put("additionalProperties", false)
}
private fun buildDynamicToolContentItems(observation: GenieToolObservation): JSONArray {
val items = JSONArray().put(
JSONObject()
.put("type", "inputText")
.put("text", observation.promptDetails),
)
observation.imageDataUrls.forEach { imageUrl ->
items.put(
JSONObject()
.put("type", "inputImage")
.put("imageUrl", imageUrl),
)
}
return items
}
private fun renderAgentQuestion(questions: JSONArray): String {
if (questions.length() == 0) {
return "Genie 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."
}
}
private 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,15 @@
package com.openai.codex.genie
import android.content.Context
import java.io.File
import java.io.IOException
object CodexBinaryLocator {
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,146 @@
package com.openai.codex.genie
import android.app.agent.AgentSessionInfo
import android.app.agent.GenieRequest
import android.app.agent.GenieService
import android.util.Log
import com.openai.codex.bridge.DetachedTargetCompat
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
class CodexGenieService : GenieService() {
companion object {
private const val TAG = "CodexGenieService"
}
private val sessionControls = ConcurrentHashMap<String, GenieSessionControl>()
override fun onStartGenieSession(request: GenieRequest, callback: Callback) {
val control = GenieSessionControl()
sessionControls[request.sessionId] = control
Thread {
runSession(request, callback, control)
}.apply {
name = "CodexGenie-${request.sessionId}"
start()
}
}
override fun onCancelGenieSession(sessionId: String) {
sessionControls.remove(sessionId)?.cancel()
Log.i(TAG, "Cancelled session $sessionId")
}
override fun onUserResponse(sessionId: String, response: String) {
sessionControls[sessionId]?.recordResponse(response)
Log.i(TAG, "Received Agent response for $sessionId")
}
private fun runSession(
request: GenieRequest,
callback: Callback,
control: GenieSessionControl,
) {
val sessionId = request.sessionId
try {
callback.updateState(sessionId, AgentSessionInfo.STATE_RUNNING)
callback.publishTrace(
sessionId,
"Codex Genie started for target=${request.targetPackage} prompt=${request.prompt}",
)
callback.publishTrace(
sessionId,
"Genie is headless. It hosts codex app-server locally, routes model traffic through the Agent bridge, uses normal Android shell commands for package/app driving, and reserves dynamic tools for framework-only target controls.",
)
if (request.isDetachedModeAllowed) {
val detachedLaunch = DetachedTargetCompat.ensureDetachedTargetHidden(callback, sessionId)
callback.publishTrace(sessionId, detachedLaunch.summary("ensure hidden"))
check(detachedLaunch.isOk()) {
"Failed to prepare detached target for ${request.targetPackage}: ${detachedLaunch.summary("ensure hidden")}"
}
callback.publishTrace(
sessionId,
"Detached-session contract active for ${request.targetPackage}: the framework owns detached launch and recovery. Codex must use framework target controls plus UI inspection/input, not plain shell relaunches of the target package.",
)
}
AgentBridgeClient(
callback = callback,
sessionId = sessionId,
).use { bridgeClient ->
val runtimeStatus = bridgeClient.getRuntimeStatus()
val accountSuffix = runtimeStatus.accountEmail?.let { " ($it)" } ?: ""
callback.publishTrace(
sessionId,
"Reached Agent bridge; authenticated=${runtimeStatus.authenticated}${accountSuffix}, provider=${runtimeStatus.modelProviderId}, model=${runtimeStatus.effectiveModel ?: "unknown"}, clients=${runtimeStatus.clientCount}.",
)
if (!runtimeStatus.authenticated) {
callback.publishResult(
sessionId,
"Reached the Agent bridge, but the Agent runtime was not authenticated for ${request.targetPackage}.",
)
callback.updateState(sessionId, AgentSessionInfo.STATE_COMPLETED)
return
}
CodexAppServerHost(
context = this,
request = request,
callback = callback,
control = control,
bridgeClient = bridgeClient,
runtimeStatus = runtimeStatus,
).use { host ->
host.run()
}
}
} catch (err: InterruptedException) {
Thread.currentThread().interrupt()
Log.w(TAG, "Interrupted Genie session $sessionId", err)
safeCallback("publish interrupted error") {
callback.publishError(sessionId, "Interrupted: ${err.message}")
}
safeCallback("publish interrupted state") {
callback.updateState(sessionId, AgentSessionInfo.STATE_FAILED)
}
} catch (err: IOException) {
Log.w(TAG, "I/O failure in Genie session $sessionId", err)
if (control.cancelled) {
safeCallback("publish cancelled error") {
callback.publishError(sessionId, "Cancelled")
}
safeCallback("publish cancelled state") {
callback.updateState(sessionId, AgentSessionInfo.STATE_CANCELLED)
}
} else {
safeCallback("publish I/O error") {
callback.publishError(sessionId, err.message ?: err::class.java.simpleName)
}
safeCallback("publish failed state") {
callback.updateState(sessionId, AgentSessionInfo.STATE_FAILED)
}
}
} catch (err: RuntimeException) {
Log.w(TAG, "Runtime failure in Genie session $sessionId", err)
safeCallback("publish runtime error") {
callback.publishError(sessionId, "${err::class.java.simpleName}: ${err.message}")
}
safeCallback("publish runtime failed state") {
callback.updateState(sessionId, AgentSessionInfo.STATE_FAILED)
}
} finally {
sessionControls.remove(sessionId)
control.cancel()
}
}
private fun safeCallback(
operation: String,
block: () -> Unit,
) {
runCatching(block).onFailure { err ->
Log.w(TAG, "Ignoring Genie callback failure during $operation", err)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.openai.codex.genie
internal object DetachedSessionGuard {
fun instructions(
targetPackage: String,
): String {
return """
Detached-session contract for $targetPackage:
- The framework already launched $targetPackage hidden before your turn started.
- Do not relaunch $targetPackage with `am start`, `cmd activity start-activity`, `monkey -p`, or similar shell launch surfaces. That bypasses detached hosting and can be blocked by Android background-activity-launch policy.
- To surface the running target, use `android_target_show`.
- If the detached target disappears or the framework reports it missing, use `android_target_ensure_hidden` to request framework-owned recovery.
- To inspect the running detached target, use `android_target_capture_frame` and UI-inspection commands such as `uiautomator dump`.
- Do not infer missing-target state from a blank launcher badge or a null frame alone. Use framework target controls first; if they still do not expose a usable target, report the framework-state problem instead of guessing.
""".trimIndent()
}
fun isForbiddenTargetLaunchCommand(
command: String,
targetPackage: String,
): Boolean {
val normalized = command.trim()
val launchPatterns = listOf(
"/bin/sh -lc 'am start",
"/bin/sh -lc 'am start-activity",
"/bin/sh -lc 'cmd activity start-activity",
"/bin/sh -lc 'monkey ",
)
if (launchPatterns.none(normalized::startsWith)) {
return false
}
return normalized.contains("-n $targetPackage/")
|| normalized.contains("-p $targetPackage")
|| normalized.contains("--package $targetPackage")
}
fun violationMessage(
targetPackage: String,
command: String,
): String {
return "Detached session contract violated: attempted to relaunch $targetPackage with shell command `$command`. The framework already launched the target hidden; use android_target_ensure_hidden/android_target_show/android_target_capture_frame plus UI inspection/input instead."
}
}

View File

@@ -0,0 +1,35 @@
package com.openai.codex.genie
import java.io.IOException
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
class GenieSessionControl {
@Volatile
var cancelled = false
@Volatile
var process: Process? = null
val userResponses = LinkedBlockingQueue<String>()
fun cancel() {
cancelled = true
process?.destroy()
process = null
}
fun waitForUserResponse(): String {
while (!cancelled) {
val response = userResponses.poll(100, TimeUnit.MILLISECONDS)
if (response != null) {
return response
}
}
throw IOException("Cancelled while waiting for Agent response")
}
fun recordResponse(response: String) {
userResponses.offer(response)
}
}

View File

@@ -0,0 +1,16 @@
package com.openai.codex.genie
data class GenieToolObservation(
val name: String,
val summary: String,
val promptDetails: String,
val imageDataUrls: List<String> = emptyList(),
) {
fun renderForPrompt(): String {
return """
Tool: $name
Observation:
$promptDetails
""".trimIndent()
}
}

View File

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

View File

@@ -0,0 +1,104 @@
package com.openai.codex.genie
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
class CodexAgentBridgeTest {
@Test
fun buildResponsesRequestUsesListInputPayload() {
val request = CodexAgentBridge.buildResponsesRequest(
model = "gpt-5.1-codex",
instructions = "reply",
prompt = "inspect the target app",
imageDataUrls = listOf("data:image/jpeg;base64,AAA"),
)
assertEquals("gpt-5.1-codex", request.getString("model"))
assertFalse(request.getBoolean("store"))
assertEquals(true, request.getBoolean("stream"))
val input = request.getJSONArray("input")
assertEquals(1, input.length())
val message = input.getJSONObject(0)
assertEquals("user", message.getString("role"))
val content = message.getJSONArray("content")
assertEquals(2, content.length())
assertEquals("input_text", content.getJSONObject(0).getString("type"))
assertEquals("inspect the target app", content.getJSONObject(0).getString("text"))
assertEquals("input_image", content.getJSONObject(1).getString("type"))
assertEquals("data:image/jpeg;base64,AAA", content.getJSONObject(1).getString("image_url"))
}
@Test
fun parseResponsesOutputTextCombinesOutputItems() {
val response = CodexAgentBridge.HttpResponse(
statusCode = 200,
body = JSONObject()
.put(
"output",
org.json.JSONArray().put(
JSONObject()
.put("type", "message")
.put("role", "assistant")
.put(
"content",
org.json.JSONArray()
.put(
JSONObject()
.put("type", "output_text")
.put("text", "Open the clock app. "),
)
.put(
JSONObject()
.put("type", "output_text")
.put("text", "Set the requested timer."),
),
),
),
)
.toString(),
)
val outputText = CodexAgentBridge.parseResponsesOutputText(response)
assertEquals("Open the clock app. Set the requested timer.", outputText)
}
@Test
fun parseResponsesOutputTextReadsSseDeltaPayloads() {
val response = CodexAgentBridge.HttpResponse(
statusCode = 200,
body = """
event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"Open Clock. "}
event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"Start the timer."}
event: response.completed
data: {"type":"response.completed","response":{"id":"resp-1"}}
data: [DONE]
""".trimIndent(),
)
val outputText = CodexAgentBridge.parseResponsesOutputText(response)
assertEquals("Open Clock. Start the timer.", outputText)
}
@Test
fun optNullableStringTreatsJsonNullAsNull() {
val json = JSONObject()
.put("present", "gpt-5.3-codex")
.put("blank", "")
.put("missingViaNull", JSONObject.NULL)
assertEquals("gpt-5.3-codex", json.optNullableString("present"))
assertNull(json.optNullableString("blank"))
assertNull(json.optNullableString("missingViaNull"))
assertNull(json.optNullableString("missing"))
}
}

View File

@@ -0,0 +1,47 @@
package com.openai.codex.genie
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DetachedSessionGuardTest {
@Test
fun instructionsBanTargetRelaunches() {
val instructions = DetachedSessionGuard.instructions("com.aurora.store")
assertTrue(instructions.contains("com.aurora.store"))
assertTrue(instructions.contains("Do not relaunch"))
assertTrue(instructions.contains("android_target_ensure_hidden"))
assertTrue(instructions.contains("android_target_show"))
}
@Test
fun detectsForbiddenAmTargetLaunch() {
val forbidden = DetachedSessionGuard.isForbiddenTargetLaunchCommand(
command = "/bin/sh -lc 'am start --user 0 -n com.aurora.store/.MainActivity'",
targetPackage = "com.aurora.store",
)
assertTrue(forbidden)
}
@Test
fun detectsForbiddenCmdActivityTargetLaunch() {
val forbidden = DetachedSessionGuard.isForbiddenTargetLaunchCommand(
command = "/bin/sh -lc 'cmd activity start-activity --user 0 -p com.aurora.store'",
targetPackage = "com.aurora.store",
)
assertTrue(forbidden)
}
@Test
fun allowsNonLaunchPackageInspectionCommands() {
val forbidden = DetachedSessionGuard.isForbiddenTargetLaunchCommand(
command = "/bin/sh -lc 'cmd package query-activities --user 0 -a android.intent.action.MAIN -c android.intent.category.LAUNCHER com.aurora.store'",
targetPackage = "com.aurora.store",
)
assertFalse(forbidden)
}
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
android/gradlew vendored Executable file
View File

@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,253 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Install the Codex Agent and Genie APKs and provision device roles.
Usage:
install-and-provision-agent-genie.sh [options]
Options:
--serial SERIAL adb device serial. Defaults to the adb default device.
--user USER_ID Android user id for role assignment. Defaults to 0.
--variant VALUE APK variant to install: debug or release. Defaults to debug.
--agent-apk PATH Agent APK path. Overrides the default path for the selected variant.
--genie-apk PATH Genie APK path. Overrides the default path for the selected variant.
--auth-file PATH Auth file to seed into the Agent sandbox.
Defaults to $HOME/.codex/auth.json when present.
--skip-auth Do not copy auth.json into the Agent sandbox.
--launch-agent Launch the Agent main activity after provisioning.
-h, --help Show this help text.
EOF
}
fail() {
echo "error: $*" >&2
exit 1
}
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
agent_package="com.openai.codex.agent"
genie_package="com.openai.codex.genie"
user_id=0
adb_serial=""
launch_agent=0
skip_auth=0
auth_file="$HOME/.codex/auth.json"
variant="debug"
agent_apk=""
genie_apk=""
agent_apk_overridden=0
genie_apk_overridden=0
while [[ $# -gt 0 ]]; do
case "$1" in
--serial)
shift
[[ $# -gt 0 ]] || fail "--serial requires a value"
adb_serial="$1"
;;
--user)
shift
[[ $# -gt 0 ]] || fail "--user requires a value"
user_id="$1"
;;
--variant)
shift
[[ $# -gt 0 ]] || fail "--variant requires a value"
variant="$1"
;;
--agent-apk)
shift
[[ $# -gt 0 ]] || fail "--agent-apk requires a path"
agent_apk="$1"
agent_apk_overridden=1
;;
--genie-apk)
shift
[[ $# -gt 0 ]] || fail "--genie-apk requires a path"
genie_apk="$1"
genie_apk_overridden=1
;;
--auth-file)
shift
[[ $# -gt 0 ]] || fail "--auth-file requires a path"
auth_file="$1"
;;
--skip-auth)
skip_auth=1
;;
--launch-agent)
launch_agent=1
;;
-h|--help)
usage
exit 0
;;
*)
fail "unknown argument: $1"
;;
esac
shift
done
[[ "$variant" == "debug" || "$variant" == "release" ]] || fail "--variant must be debug or release"
if [[ $agent_apk_overridden -eq 0 ]]; then
agent_apk="$script_dir/app/build/outputs/apk/$variant/app-$variant.apk"
fi
if [[ $genie_apk_overridden -eq 0 ]]; then
genie_apk="$script_dir/genie/build/outputs/apk/$variant/genie-$variant.apk"
fi
if [[ "$variant" == "release" ]]; then
if [[ $agent_apk_overridden -eq 0 ]]; then
agent_apk="$script_dir/app/build/outputs/apk/$variant/app-$variant-unsigned.apk"
fi
if [[ $genie_apk_overridden -eq 0 ]]; then
genie_apk="$script_dir/genie/build/outputs/apk/$variant/genie-$variant-unsigned.apk"
fi
fi
[[ -f "$agent_apk" ]] || fail "Agent APK not found: $agent_apk"
[[ -f "$genie_apk" ]] || fail "Genie APK not found: $genie_apk"
if [[ "$variant" == "release" && $agent_apk_overridden -eq 0 && "$agent_apk" == *-unsigned.apk ]]; then
fail "default release Agent APK is unsigned: $agent_apk; sign it first or pass --agent-apk"
fi
if [[ "$variant" == "release" && $genie_apk_overridden -eq 0 && "$genie_apk" == *-unsigned.apk ]]; then
fail "default release Genie APK is unsigned: $genie_apk; sign it first or pass --genie-apk"
fi
adb_cmd=(adb)
if [[ -n "$adb_serial" ]]; then
adb_cmd+=(-s "$adb_serial")
fi
"${adb_cmd[@]}" get-state >/dev/null 2>&1 || fail "adb device is not available"
purge_existing_agent_sessions() {
local sessions_output line session_id parent_session_id initiator_package state top_level_session_id
local -a all_session_ids=()
local -a top_level_session_ids=()
local -a home_session_specs=()
sessions_output="$(${adb_cmd[@]} shell cmd agent list-sessions "$user_id" 2>/dev/null | tr -d '\r')"
[[ -n "$sessions_output" ]] || return 0
while IFS= read -r line; do
[[ "$line" == AgentSessionInfo\{* ]] || continue
session_id="$(printf '%s\n' "$line" | sed -n 's/.*sessionId=\([^,}]*\).*/\1/p')"
parent_session_id="$(printf '%s\n' "$line" | sed -n 's/.*parentSessionId=\([^,}]*\).*/\1/p')"
initiator_package="$(printf '%s\n' "$line" | sed -n 's/.*initiatorPackage=\([^,}]*\).*/\1/p')"
state="$(printf '%s\n' "$line" | sed -n 's/.*state=\([0-9][0-9]*\).*/\1/p')"
[[ -n "$session_id" ]] || continue
all_session_ids+=("$session_id")
if [[ "$parent_session_id" == "null" ]]; then
top_level_session_ids+=("$session_id")
if [[ "$initiator_package" != "null" ]] && printf '%s\n' "$line" | grep -q 'targetPackage=' && ! printf '%s\n' "$line" | grep -q 'targetPackage=null'; then
home_session_specs+=("$initiator_package:$session_id:$state")
fi
fi
done <<<"$sessions_output"
if [[ ${#all_session_ids[@]} -eq 0 ]]; then
return 0
fi
echo "Purging existing framework sessions"
for session_id in "${all_session_ids[@]}"; do
"${adb_cmd[@]}" shell cmd agent cancel-session "$session_id" >/dev/null 2>&1 || true
done
if (( ${#top_level_session_ids[@]} > 0 )); then
for top_level_session_id in "${top_level_session_ids[@]}"; do
local lease_id="codex-install-${top_level_session_id}"
"${adb_cmd[@]}" shell cmd agent register-ui-lease "$top_level_session_id" "$lease_id" >/dev/null 2>&1 || true
"${adb_cmd[@]}" shell cmd agent unregister-ui-lease "$top_level_session_id" "$lease_id" >/dev/null 2>&1 || true
done
fi
if (( ${#home_session_specs[@]} > 0 )); then
for home_spec in "${home_session_specs[@]}"; do
IFS=':' read -r initiator_package session_id state <<<"$home_spec"
if [[ "$state" == "4" ]]; then
"${adb_cmd[@]}" shell cmd agent consume-completed-home-session "$initiator_package" "$session_id" >/dev/null 2>&1 || true
else
"${adb_cmd[@]}" shell cmd agent consume-home-session "$initiator_package" "$session_id" >/dev/null 2>&1 || true
fi
done
fi
}
purge_existing_agent_sessions
echo "Stopping existing Agent/Genie processes"
for package_name in \
"$agent_package" \
"$genie_package" \
com.example.agentstub \
com.example.geniestub \
com.example.agentstub.standalone \
com.example.geniestub.standalone; do
"${adb_cmd[@]}" shell am force-stop "$package_name" >/dev/null 2>&1 || true
done
echo "Installing Agent APK: $agent_apk"
"${adb_cmd[@]}" install -r "$agent_apk"
echo "Installing Genie APK: $genie_apk"
"${adb_cmd[@]}" install -r "$genie_apk"
echo "Assigning AGENT role to $agent_package"
"${adb_cmd[@]}" shell cmd role clear-role-holders --user "$user_id" android.app.role.AGENT 0
"${adb_cmd[@]}" shell cmd role add-role-holder --user "$user_id" android.app.role.AGENT "$agent_package" 0
echo "Assigning GENIE role to $genie_package"
"${adb_cmd[@]}" shell cmd role clear-role-holders --user "$user_id" android.app.role.GENIE 0
"${adb_cmd[@]}" shell cmd role add-role-holder --user "$user_id" android.app.role.GENIE "$genie_package" 0
echo "Granting Agent notification permission"
"${adb_cmd[@]}" shell pm grant "$agent_package" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true
echo "Granting Agent framework session permission"
"${adb_cmd[@]}" shell pm grant "$agent_package" android.permission.START_AGENT_REQUESTS >/dev/null 2>&1 || true
if [[ $skip_auth -eq 0 && -f "$auth_file" ]]; then
echo "Seeding Agent auth from $auth_file"
remote_auth_tmp="/data/local/tmp/codex-agent-auth-${user_id}-$$.json"
cleanup_remote_auth_tmp() {
"${adb_cmd[@]}" shell rm -f "$remote_auth_tmp" >/dev/null 2>&1 || true
}
trap cleanup_remote_auth_tmp EXIT
"${adb_cmd[@]}" push "$auth_file" "$remote_auth_tmp" >/dev/null
"${adb_cmd[@]}" shell run-as "$agent_package" mkdir -p files/codex-home
"${adb_cmd[@]}" shell \
"run-as $agent_package sh -c 'cat $remote_auth_tmp > files/codex-home/auth.json'"
cleanup_remote_auth_tmp
trap - EXIT
elif [[ $skip_auth -eq 0 ]]; then
echo "Auth file not found; skipping auth seed: $auth_file"
fi
if [[ $launch_agent -eq 1 ]]; then
echo "Launching Agent main activity"
"${adb_cmd[@]}" shell am start -W -n "$agent_package/.MainActivity" >/dev/null
fi
cat <<EOF
Provisioning complete.
Agent package:
$agent_package
Genie package:
$genie_package
Role holders:
AGENT: $("${adb_cmd[@]}" shell cmd role get-role-holders --user "$user_id" android.app.role.AGENT | tr -d '\r')
GENIE: $("${adb_cmd[@]}" shell cmd role get-role-holders --user "$user_id" android.app.role.GENIE | tr -d '\r')
APK paths:
Agent: $agent_apk
Genie: $genie_apk
EOF

View File

@@ -0,0 +1,20 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "codex-agent-service"
include(":bridge")
include(":app")
include(":genie")

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