Compare commits

..

62 Commits

Author SHA1 Message Date
Michael Fan
2c08f00f65 codex: gate create-api-key guidance to legacy TUI
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 19:38:28 -04:00
Michael Fan
076310369b codex: update create-api-key success copy
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 19:10:58 -04:00
Michael Fan
c27ee7ae9d codex: align unified exec test harness with dependency env
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 17:15:32 -04:00
Michael Fan
14a169b4bb codex: propagate dependency env to unified exec
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:59:03 -04:00
Michael Fan
5e054045aa codex: revert stale status_line_cwd visibility
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:50 -04:00
Michael Fan
44d1bc54a5 codex: remove create-api-key process env mutation
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:50 -04:00
Michael Fan
b54bfc88d5 codex: redact dependency env updates in session logs
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:50 -04:00
Michael Fan
3d5965dad5 codex: target create-api-key env updates to source thread
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
3d5d514b09 codex: update create-api-key snapshots
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
39821795d4 codex: set created API keys in session env
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
a19cf09ac5 codex: show created API keys in TUI
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
64e57306e5 codex: restore fixed create-api-key callback port
Use the Hydra-registered localhost:5000 redirect URI for project API key
creation, but fail fast on port conflicts instead of sending /cancel to a
potentially unrelated local service.

Validation:
- cargo test -p codex-login
- just fmt
- just fix -p codex-login
- just argument-comment-lint

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
bfd4a6e7fe codex: clean up create-api-key naming
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
ec0ca67f78 codex: address create-api-key review feedback
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
285c4926b5 codex: rename api-provision slash command
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
cb37b3e641 codex: clean up shared OAuth callback handling
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
86f87f3431 codex: harden API provisioning secret writes
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
98481441df codex: restrict dotenv API key file permissions
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
dfa9c641b6 codex: narrow API provisioning options
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
dd93a89cd2 codex: clean up callback server on auth URL errors
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
0275e40f6b codex: revert unrelated rust CI workflow change
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:49 -04:00
Michael Fan
27e0ea5e48 codex: restore moved callback server comment
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
7b601b4c3d codex: sort oauth callback server imports
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
7a885b6a56 Extract shared OAuth callback server machinery
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
7dc4b016a3 codex: tighten api provisioning implementation
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
de5c66e9a8 codex: move dotenv api key helper to tui
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
a4d68acd12 codex: remove api provision helper binary
Drop the standalone helper binary and the CLI-only auth.json sync path from codex-login.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
b802f49ca2 codex: remove stale api provision alias
Drop the old run_onboard_oauth_helper_from_env re-export after the module rename.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
0dbda0e71b codex: reorganize api provision modules
Move the TUI slash command into the chatwidget module and rename the login helper module to match API provisioning behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
3ed105cbdb Fix clippy warning in auth code server
Remove the redundant Arc clone in the shared authorization-code server loop.

Validation:
- cargo check -p codex-login --lib

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
2a021f889f Format api-provision rebase merge
Apply rustfmt after resolving the api_provision rebase conflict.

Validation:
- cargo fmt --all --manifest-path /home/dev-user/code/codex/codex-rs/Cargo.toml

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
97c8d8fa00 Refactor api-provision browser auth reuse
Move the reusable authorization-code callback server into codex-login::server, switch api-provision over to the shared PKCE/state/callback flow, and keep the TUI browser path alive even when auto-open fails.

Validation:
- cargo check -p codex-login --lib
- cargo fmt --all --manifest-path /home/dev-user/code/codex/codex-rs/Cargo.toml
- git diff --check

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
e33a5b3570 changes 2026-03-26 16:57:48 -04:00
Michael Fan
a70ec9b26e codex: fix remaining CI failures on PR #15561
Skip redundant cargo-home cache saves in Windows test jobs to avoid post-test timeouts, and add the required argument comments in the login OAuth helper.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:48 -04:00
Michael Fan
b54ee4952e codex: fix CI failure on PR #15561 2026-03-26 16:57:48 -04:00
Michael Fan
9b034e7b46 .env -> .env.local 2026-03-26 16:57:47 -04:00
Michael Fan
62d24d13e3 Use OPENAI_API_KEY for api provisioning
Skip /api-provision when the current Codex process already inherited
OPENAI_API_KEY, and otherwise persist the provisioned key to .env under
OPENAI_API_KEY instead of CODEX_API_KEY.

Validation:
- cargo test -p codex-login
- cargo test -p codex-tui
- just fix -p codex-login
- just fix -p codex-tui
- just fmt

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:47 -04:00
Michael Fan
fa7beabaff Add CLI api-provision slash command
Extract the browser-based provisioning flow from codex-login so the plain TUI can
reuse it. Add /api-provision to the CLI, persist CODEX_API_KEY to .env, and
hot-apply the key via ephemeral auth without touching auth.json.

Validation:
- cargo test -p codex-login
- cargo test -p codex-tui
- just fix -p codex-login
- just fix -p codex-tui
- just fmt

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 16:57:47 -04:00
Michael Bolin
dfb36573cd sandboxing: use OsString for SandboxCommand.program (#15897)
## Why

`SandboxCommand.program` represents an executable path, but keeping it
as `String` forced path-backed callers to run `to_string_lossy()` before
the sandbox layer ever touched the command. That loses fidelity earlier
than necessary and adds avoidable conversions in runtimes that already
have a `PathBuf`.

## What changed

- Changed `SandboxCommand.program` to `OsString`.
- Updated `SandboxManager::transform` to keep the program and argv in
`OsString` form until the `SandboxExecRequest` conversion boundary.
- Switched the path-backed `apply_patch` and `js_repl` runtimes to pass
`into_os_string()` instead of `to_string_lossy()`.
- Updated the remaining string-backed builders and tests to match the
new type while preserving the existing Linux helper `arg0` behavior.

## Verification

- `cargo test -p codex-sandboxing`
- `just argument-comment-lint -p codex-core -p codex-sandboxing`
- `cargo test -p codex-core` currently fails in unrelated existing
config tests: `config::tests::approvals_reviewer_*` and
`config::tests::smart_approvals_alias_*`
2026-03-26 20:38:33 +00:00
Michael Bolin
b23789b770 [codex] import token_data from codex-login directly (#15903)
## Why
`token_data` is owned by `codex-login`, but `codex-core` was still
re-exporting it. That let callers pull auth token types through
`codex-core`, which keeps otherwise unrelated crates coupled to
`codex-core` and makes `codex-core` more of a build-graph bottleneck.

## What changed
- remove the `codex-core` re-export of `codex_login::token_data`
- update the remaining `codex-core` internals that used
`crate::token_data` to import `codex_login::token_data` directly
- update downstream callers in `codex-rs/chatgpt`,
`codex-rs/tui_app_server`, `codex-rs/app-server/tests/common`, and
`codex-rs/core/tests` to import `codex_login::token_data` directly
- add explicit `codex-login` workspace dependencies and refresh lock
metadata for crates that now depend on it directly

## Validation
- `cargo test -p codex-chatgpt --locked`
- `just argument-comment-lint`
- `just bazel-lock-update`
- `just bazel-lock-check`

## Notes
- attempted `cargo test -p codex-core --locked` and `cargo test -p
codex-core auth_refresh --locked`, but both ran out of disk while
linking `codex-core` test binaries in the local environment
2026-03-26 13:34:02 -07:00
rreichel3-oai
86764af684 Protect first-time project .codex creation across Linux and macOS sandboxes (#15067)
## Problem

Codex already treated an existing top-level project `./.codex` directory
as protected, but there was a gap on first creation.

If `./.codex` did not exist yet, a turn could create files under it,
such as `./.codex/config.toml`, without going through the same approval
path as later modifications. That meant the initial write could bypass
the intended protection for project-local Codex state.

## What this changes

This PR closes that first-creation gap in the Unix enforcement layers:

- `codex-protocol`
- treat the top-level project `./.codex` path as a protected carveout
even when it does not exist yet
- avoid injecting the default carveout when the user already has an
explicit rule for that exact path
- macOS Seatbelt
- deny writes to both the exact protected path and anything beneath it,
so creating `./.codex` itself is blocked in addition to writes inside it
- Linux bubblewrap
- preserve the same protected-path behavior for first-time creation
under `./.codex`
- tests
- add protocol regressions for missing `./.codex` and explicit-rule
collisions
- add Unix sandbox coverage for blocking first-time `./.codex` creation
  - tighten Seatbelt policy assertions around excluded subpaths

## Scope

This change is intentionally scoped to protecting the top-level project
`.codex` subtree from agent writes.

It does not make `.codex` unreadable, and it does not change the product
behavior around loading project skills from `.codex` when project config
is untrusted.

## Why this shape

The fix is pointed rather than broad:
- it preserves the current model of “project `.codex` is protected from
writes”
- it closes the security-relevant first-write hole
- it avoids folding a larger permissions-model redesign into this PR

## Validation

- `cargo test -p codex-protocol`
- `cargo test -p codex-sandboxing seatbelt`
- `cargo test -p codex-exec --test all
sandbox_blocks_first_time_dot_codex_creation -- --nocapture`

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
2026-03-26 16:06:53 -04:00
Ruslan Nigmatullin
9736fa5e3d app-server: Split transport module (#15811)
`transport.rs` is getting pretty big, split individual transport
implementations into separate files.
2026-03-26 13:01:35 -07:00
Michael Bolin
b3e069e8cb skills: remove unused skill permission metadata (#15900)
## Why

Skill metadata accepted a `permissions` block and stored the result on
`SkillMetadata`, but that data was never consumed by runtime behavior.
Leaving the dead parsing path in place makes it look like skills can
widen or otherwise influence execution permissions when, in practice,
declared skill permissions are ignored.

This change removes that misleading surface area so the skill metadata
model matches what the system actually uses.

## What changed

- removed `permission_profile` and `managed_network_override` from
`core-skills::SkillMetadata`
- stopped parsing `permissions` from skill metadata in
`core-skills/src/loader.rs`
- deleted the loader tests that only exercised the removed permissions
parsing path
- cleaned up dependent `SkillMetadata` constructors in tests and TUI
code that were only carrying `None` for those fields

## Testing

- `cargo test -p codex-core-skills`
- `cargo test -p codex-tui
submission_prefers_selected_duplicate_skill_path`
- `just argument-comment-lint`
2026-03-26 19:33:23 +00:00
viyatb-oai
b6050b42ae fix: resolve bwrap from trusted PATH entry (#15791)
## Summary
- resolve system bwrap from PATH instead of hardcoding /usr/bin/bwrap
- skip PATH entries that resolve inside the current workspace before
launching the sandbox helper
- keep the vendored bubblewrap fallback when no trusted system bwrap is
found

## Validation
- cargo test -p codex-core bwrap --lib
- cargo test -p codex-linux-sandbox
- just fix -p codex-core
- just fix -p codex-linux-sandbox
- just fmt
- just argument-comment-lint
- cargo clean
2026-03-26 12:13:51 -07:00
Matthew Zeng
3360f128f4 [plugins] Polish tool suggest prompts. (#15891)
- [x] Polish tool suggest prompts to distinguish between missing
connectors and discoverable plugins, and be very precise about the
triggering conditions.
2026-03-26 18:52:59 +00:00
Matthew Zeng
25134b592c [mcp] Fix legacy_tools (#15885)
- [x] Fix legacy_tools
2026-03-26 11:08:49 -07:00
Felipe Coury
2c54d4b160 feat(tui): add terminal title support to tui app server (#15860)
## TR;DR

Replicates the `/title` command from `tui` to `tui_app_server`.

## Problem

The classic `tui` crate supports customizing the terminal window/tab
title via `/title`, but the `tui_app_server` crate does not. Users on
the app-server path have no way to configure what their terminal title
shows (project name, status, spinner, thread, etc.), making it harder to
identify Codex sessions across tabs or windows.

## Mental model

The terminal title is a *status surface* -- conceptually parallel to the
footer status line. Both surfaces are configurable lists of items, both
share expensive inputs (git branch lookup, project root discovery), and
both must be refreshed at the same lifecycle points. This change ports
the classic `tui`'s design verbatim:

1. **`terminal_title.rs`** owns the low-level OSC write path and input
sanitization. It strips control characters and bidi/invisible codepoints
before placing untrusted text (model output, thread names, project
paths) inside an escape sequence.

2. **`title_setup.rs`** defines `TerminalTitleItem` (the 8 configurable
items) and `TerminalTitleSetupView` (the interactive picker that wraps
`MultiSelectPicker`).

3. **`status_surfaces.rs`** is the shared refresh pipeline. It parses
both surface configs once per refresh, warns about invalid items once
per session, synchronizes the git-branch cache, then renders each
surface from the same `StatusSurfaceSelections` snapshot.

4. **`chatwidget.rs`** sets `TerminalTitleStatusKind` at each state
transition (Working, Thinking, Undoing, WaitingForBackgroundTerminal)
and calls `refresh_terminal_title()` whenever relevant state changes.

5. **`app.rs`** handles the three setup events (confirm/preview/cancel),
persists config via `ConfigEditsBuilder`, and clears the managed title
on `Drop`.

## Non-goals

- **Restoring the previous terminal title on exit.** There is no
portable way to read the terminal's current title, so `Drop` clears the
managed title rather than restoring it.
- **Sharing code between `tui` and `tui_app_server`.** The
implementation is a parallel copy, matching the existing pattern for the
status-line feature. Extracting a shared crate is future work.

## Tradeoffs

- **Duplicate code across crates.** The three core files
(`terminal_title.rs`, `title_setup.rs`, `status_surfaces.rs`) are
byte-for-byte copies from the classic `tui`. This was chosen for
consistency with the existing status-line port and to avoid coupling the
two crates at the dependency level. Future changes must be applied in
both places.

- **`status_surfaces.rs` is large (~660 lines).** It absorbs logic that
previously lived inline in `chatwidget.rs` (status-line refresh, git
branch management, project root discovery) plus all new terminal-title
logic. This consolidation trades file size for a single place where both
surfaces are coordinated.

- **Spinner scheduling on every refresh.** The terminal title spinner
(when active) schedules a frame every 100ms. This is the same pattern
the status-indicator spinner already uses; the overhead is a timer
registration, not a redraw.

## Architecture

```
/title command
  -> SlashCommand::Title
  -> open_terminal_title_setup()
  -> TerminalTitleSetupView (MultiSelectPicker)
  -> on_change:  AppEvent::TerminalTitleSetupPreview  -> preview_terminal_title()
  -> on_confirm: AppEvent::TerminalTitleSetup         -> ConfigEditsBuilder + setup_terminal_title()
  -> on_cancel:  AppEvent::TerminalTitleSetupCancelled -> cancel_terminal_title_setup()

Runtime title refresh:
  state change (turn start, reasoning, undo, plan update, thread rename, ...)
  -> set terminal_title_status_kind
  -> refresh_terminal_title()
  -> status_surface_selections()  (parse configs, collect invalids)
  -> refresh_terminal_title_from_selections()
     -> terminal_title_value_for_item() for each configured item
     -> assemble title string with separators
     -> skip if identical to last_terminal_title (dedup OSC writes)
     -> set_terminal_title() (sanitize + OSC 0 write)
     -> schedule spinner frame if animating

Widget replacement:
  replace_chat_widget_with_app_server_thread()
  -> transfer last_terminal_title from old widget to new
  -> avoids redundant OSC clear+rewrite on session switch
```

## Observability

- Invalid terminal-title item IDs in config emit a one-per-session
warning via `on_warning()` (gated by
`terminal_title_invalid_items_warned` `AtomicBool`).
- OSC write failures are logged at `tracing::debug` level.
- Config persistence failures are logged at `tracing::error` and
surfaced to the user via `add_error_message()`.

## Tests

- `terminal_title.rs`: 4 unit tests covering sanitization (control
chars, bidi codepoints, truncation) and OSC output format.
- `title_setup.rs`: 3 tests covering setup view snapshot rendering,
parse order preservation, and invalid-ID rejection.
- `chatwidget/tests.rs`: Updated test helpers with new fields; existing
tests continue to pass.

---------

Co-authored-by: Eric Traut <etraut@openai.com>
2026-03-26 11:59:12 -06:00
jif-oai
970386e8b2 fix: root as std agent (#15881) 2026-03-26 18:57:34 +01:00
evawong-oai
0bd34c28c7 Add wildcard in the middle test coverage (#15813)
## Summary
Add a focused codex network proxy unit test for the denylist pattern
with wildcard in the middle `region*.some.malicious.tunnel.com`. This
does not change how existing code works, just ensure that behavior stays
the same and we got CI guards to guard existin behavior.

## Why
The managed Codex denylist update relies on this mid label glob form,
and the existing tests only covered exact hosts, `*.` subdomains, and
`**.` apex plus subdomains.

## Validation
`cargo test -p codex-network-proxy
compile_globset_supports_mid_label_wildcards`
`cargo test -p codex-network-proxy`
`./tools/argument-comment-lint/run-prebuilt-linter.sh -p
codex-network-proxy`
2026-03-26 17:53:31 +00:00
Adrian
af04273778 [codex] Block unsafe git global options from safe allowlist (#15796)
## Summary
- block git global options that can redirect config, repository, or
helper lookup from being auto-approved as safe
- share the unsafe global-option predicate across the Unix and Windows
git safety checks
- add regression coverage for inline and split forms, including `bash
-lc` and PowerShell wrappers

## Root cause
The Unix safe-command gate only rejected `-c` and `--config-env`, even
though the shared git parser already knew how to skip additional
pre-subcommand globals such as `--git-dir`, `--work-tree`,
`--exec-path`, `--namespace`, and `--super-prefix`. That let those
arguments slip through safe-command classification on otherwise
read-only git invocations and bypass approval. The Windows-specific
safe-command path had the same trust-boundary gap for git global
options.
2026-03-26 10:46:04 -07:00
Michael Bolin
e36ebaa3da fix: box apply_patch test harness futures (#15835)
## Why

`#[large_stack_test]` made the `apply_patch_cli` tests pass by giving
them more stack, but it did not address why those tests needed the extra
stack in the first place.

The real problem is the async state built by the `apply_patch_cli`
harness path. Those tests await three helper boundaries directly:
harness construction, turn submission, and apply-patch output
collection. If those helpers inline their full child futures, the test
future grows to include the whole harness startup and request/response
path.

This change replaces the workaround from #12768 with the same basic
approach used in #13429, but keeps the fix narrower: only the helper
boundaries awaited directly by `apply_patch_cli` stay boxed.

## What Changed

- removed `#[large_stack_test]` from
`core/tests/suite/apply_patch_cli.rs`
- restored ordinary `#[tokio::test(flavor = "multi_thread",
worker_threads = 2)]` annotations in that suite
- deleted the now-unused `codex-test-macros` crate and removed its
workspace wiring
- boxed only the three helper boundaries that the suite awaits directly:
  - `apply_patch_harness_with(...)`
  - `TestCodexHarness::submit(...)`
  - `TestCodexHarness::apply_patch_output(...)`
- added comments at those boxed boundaries explaining why they remain
boxed

## Testing

- `cargo test -p codex-core --test all suite::apply_patch_cli --
--nocapture`

## References

- #12768
- #13429
2026-03-26 17:32:04 +00:00
Eric Traut
e7139e14a2 Enable tui_app_server feature by default (#15661) 2026-03-26 11:28:25 -06:00
nicholasclark-openai
8d479f741c Add MCP connector metrics (#15805)
## Summary
- enrich `codex.mcp.call` with `tool`, `connector_id`, and sanitized
`connector_name` for actual MCP executions
- record `codex.mcp.call.duration_ms` for actual MCP executions so
connector-level latency is visible in metrics
- keep skipped, blocked, declined, and cancelled paths on the plain
status-only `codex.mcp.call` counter

## Included Changes
- `codex-rs/core/src/mcp_tool_call.rs`: add connector-sliced MCP count
and duration metrics only for executed tool calls, while leaving
non-executed outcomes as status-only counts
- `codex-rs/core/src/mcp_tool_call_tests.rs`: cover metric tag shaping,
connector-name sanitization, and the new duration metric tags

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

## Notes
- `cargo test -p codex-core` still hits existing unrelated failures in
approvals-reviewer config tests and the sandboxed JS REPL `mktemp` test
- full workspace `cargo test` was not run

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 17:08:02 +00:00
Eric Traut
0d44bd708e Fix duplicate /review messages in app-server TUI (#15839)
## Symptoms
When `/review` ran through `tui_app_server`, the TUI could show
duplicate review content:
- the `>> Code review started: ... <<` banner appeared twice
- the final review body could also appear twice

## Problem
`tui_app_server` was treating review lifecycle items as renderable
content on more than one delivery path.

Specifically:
- `EnteredReviewMode` was rendered both when the item started and again
when it completed
- `ExitedReviewMode` rendered the review text itself, even though the
same review text was also delivered later as the assistant message item

That meant the same logical review event was committed into history
multiple times.

## Solution
Make review lifecycle items control state transitions only once, and
keep the final review body sourced from the assistant message item:
- render the review-start banner from the live `ItemStarted` path, while
still allowing replay to restore it once
- treat `ExitedReviewMode` as a mode-exit/finish-banner event instead of
rendering the review body from it
- preserve the existing assistant-message rendering path as the single
source of final review text
2026-03-26 10:55:18 -06:00
jif-oai
352f37db03 fix: max depth agent still has v2 tools (#15880) 2026-03-26 17:36:12 +01:00
Matthew Zeng
c9214192c5 [plugins] Update the suggestable plugins list. (#15829)
- [x] Update the suggestable plugins list to be featured plugins.
2026-03-26 15:53:22 +00:00
jif-oai
6d2f4aaafc feat: use ProcessId in exec-server (#15866)
Use a full struct for the ProcessId to increase readability and make it
easier in the future to make it evolve if needed
2026-03-26 16:45:36 +01:00
jif-oai
a5824e37db chore: ask agents md not to play with PIDs (#15877)
Ask Codex to be patient with Rust
2026-03-26 15:43:19 +00:00
jif-oai
26c66f3ee1 fix: flaky (#15869) 2026-03-26 16:07:32 +01:00
Michael Bolin
01fa4f0212 core: remove special execve handling for skill scripts (#15812) 2026-03-26 07:46:04 -07:00
jif-oai
6dcac41d53 chore: drop artifacts lib (#15864) 2026-03-26 15:28:59 +01:00
jif-oai
7dac332c93 feat: exec-server prep for unified exec (#15691)
This PR partially rebase `unified_exec` on the `exec-server` and adapt
the `exec-server` accordingly.

## What changed in `exec-server`

1. Replaced the old "broadcast-driven; process-global" event model with
process-scoped session events. The goal is to be able to have dedicated
handler for each process.
2. Add to protocol contract to support explicit lifecycle status and
stream ordering:
- `WriteResponse` now returns `WriteStatus` (Accepted, UnknownProcess,
StdinClosed, Starting) instead of a bool.
  - Added seq fields to output/exited notifications.
  - Added terminal process/closed notification.
3. Demultiplexed remote notifications into per-process channels. Same as
for the event sys
4. Local and remote backends now both implement ExecBackend.
5. Local backend wraps internal process ID/operations into per-process
ExecProcess objects.
6. Remote backend registers a session channel before launch and
unregisters on failed launch.

## What changed in `unified_exec`

1. Added unified process-state model and backend-neutral process
wrapper. This will probably disappear in the future, but it makes it
easier to keep the work flowing on both side.
- `UnifiedExecProcess` now handles both local PTY sessions and remote
exec-server processes through a shared `ProcessHandle`.
- Added `ProcessState` to track has_exited, exit_code, and terminal
failure message consistently across backends.
2. Routed write and lifecycle handling through process-level methods.

## Some rationals

1. The change centralizes execution transport in exec-server while
preserving policy and orchestration ownership in core, avoiding
duplicated launch approval logic. This comes from internal discussion.
2. Session-scoped events remove coupling/cross-talk between processes
and make stream ordering and terminal state explicit (seq, closed,
failed).
3. The failure-path surfacing (remote launch failures, write failures,
transport disconnects) makes command tool output and cleanup behavior
deterministic

## Follow-ups:
* Unify the concept of thread ID behind an obfuscated struct
* FD handling
* Full zsh-fork compatibility
* Full network sandboxing compatibility
* Handle ws disconnection
2026-03-26 15:22:34 +01:00
374 changed files with 7631 additions and 28972 deletions

View File

@@ -40,6 +40,7 @@ In the codex-rs folder where the rust code lives:
`codex-rs/tui/src/bottom_pane/mod.rs`, and similarly central orchestration modules.
- When extracting code from a large module, move the related tests and module/type docs toward
the new implementation so the invariants stay close to the code that owns them.
- When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected.
Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests:

11
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

4
android/.gitignore vendored
View File

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

View File

@@ -1,218 +0,0 @@
# Android Agent/Genie
This file applies to the Android subtree under `android/`. It is developer-facing
context for working on the Android Agent/Genie implementation in this repo.
Do not confuse this with the runtime guidance asset at
`android/bridge/src/main/assets/AGENTS.md`, which is copied into Codex homes on
device for live Agent/Genie sessions.
## Module layout
- `android/app`: Agent app
- `android/genie`: Genie app
- `android/bridge`: shared Android bridge/runtime compatibility layer
- `android/build-agent-genie-apks.sh`: helper for building both APKs
- `android/install-and-provision-agent-genie.sh`: helper for adb install, role
assignment, and auth seeding
## Default SDK input
When building the Android APKs, use the branch-local Android Agent Platform stub
SDK at:
`$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip`
The Android build already accepts this through either:
- environment: `ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP`
- script flag: `android/build-agent-genie-apks.sh --agent-sdk-zip ...`
- Gradle property: `-PagentPlatformStubSdkZip=...`
Treat this path as the default SDK source unless the user explicitly says
otherwise.
## Authoritative design references
Read these first when recovering Android Agent/Genie context:
- local architecture/status doc:
`docs/android-agent-genie-refactor.md`
- SDK docs inside the stub SDK zip:
- `README.md`
- `AGENT_GENIE_DESIGN.md`
- `CONSUMER_GUIDE.md`
Useful inspection commands:
```bash
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" README.md | sed -n '1,220p'
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" AGENT_GENIE_DESIGN.md | sed -n '1,260p'
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" CONSUMER_GUIDE.md | sed -n '1,260p'
```
## Key platform contract to preserve
The current Android work in this repo assumes the same contract described by the
stub SDK docs and the local refactor doc:
- Agent and Genie are separate APKs.
- The framework-managed per-session bridge is the app-private Agent<->Genie
control plane.
- The framework-owned streaming HTTP exchange is the transport for active
`/responses` traffic in both top-level Agent planner sessions and live Genie
child sessions.
- Genie is headless and should not depend on direct internet access.
- Detached target handling must use framework-authoritative presentation/runtime
state and typed detached-target recovery APIs rather than guessed relaunches.
- Genies should keep the paired app hidden by default. Prefer
`DETACHED_HIDDEN` unless the user explicitly asks to bring the app to the
front, asks to leave it visibly shown, or the task clearly implies a visible
app handoff.
- App-scoped HOME drafts are real framework `STATE_CREATED` sessions created
before `startGenieSession(...)`; if you expose that flow outside the on-device
UI, remember that provisional HOME sessions are expected to hold a
session-UI lease until they are started or cancelled.
- Desktop draft attach is now implemented by bootstrapping an idle app-server
runtime before the first turn:
- HOME drafts start Genie with an internal idle-bootstrap sentinel and become
attachable immediately after bridge bootstrap.
- direct AGENT drafts spin up an idle planner app-server host inside the
Agent process.
- the first prompt can then be typed in the attached desktop TUI instead of
being supplied to `sessions start`.
- Attached runtime completion semantics are intentionally non-terminal:
- attached Genie turns remain live after `turn/completed` so the same desktop
TUI can send follow-up prompts.
- attached direct AGENT planner sessions stay live after planning completes.
- child Genie sessions spawned by an attached planner are launched in idle
desktop-attach mode instead of immediately consuming their delegated prompt.
- those idle child sessions still receive Agent-provisioned bridge state
first, stage the delegated objective as runtime context, and remain
attachable while the planner stays attached.
- if the planner detaches before the user manually starts the child, the
staged delegated objective is released automatically as a fallback.
- after a child turn completes, planner-held child sessions remain attachable
until the planner attach detaches.
- once the planner detaches, those held child sessions are allowed to settle
to their terminal framework state and the parent roll-up can complete.
- Recoverable hosted-runtime failures are also intentionally non-terminal when a
fresh app-server thread can still be bootstrapped:
- recoverable app-server / bridge I/O failures during an attached Genie turn
close only the current desktop attach, then restart the Genie into a fresh
attachable idle thread with staged recovery context
- recoverable I/O failures during an unattached Genie run first retry
automatically with staged recovery context, then pause into an attachable
idle recovery thread if automatic retries are exhausted
- only failures that prevent bootstrapping any new hosted runtime at all
should still terminate the Genie session
- Parent-session cancellation is tree-scoped for direct AGENT sessions:
cancelling the parent from the desktop bridge, framework tool bridge, or the
detail UI must cancel the parent and all child Genie sessions through the
framework `cancelSession(...)` path, even when some of those sessions are
already terminal.
- Framework-owned session notifications now support delegated AGENT rendering:
- user-facing question/result/error notifications should be rendered by the
Agent app when the framework calls `onShowOrUpdateSessionNotification(...)`
and cancelled when it calls `onCancelSessionNotification(...)`
- child Genie notification callbacks under a direct AGENT parent should be
ACKed but not rendered; the parent session icon/notification is the only
user-facing notification surface for Agent-anchored work
- RUNNING/CREATED/QUEUED callbacks should be ACKed but suppressed so the
Agent only notifies for user action or terminal outcomes
- the Agent must ACK a posted notification with `ackSessionNotification(...)`
and route inline replies through `answerQuestionFromNotification(...)`
- if delegated rendering is unavailable, the framework may post a generic
fallback notification, so app-side notification code must remain
token-aware and idempotent
- Direct AGENT planning can ask user-facing questions before any child Genie is
created:
- hosted planner `request_user_input` calls are stored by the Agent, the
parent session is moved to `WAITING_FOR_USER`, and the parent Agent icon /
notification becomes the user-facing question surface
- answering the parent question resolves the pending planner tool request so
the same planner turn can continue and produce the child-Genie plan
- child Genie questions remain separate child-session questions that roll up
through the parent session when user escalation is needed
- Retention is intentionally bounded:
- keep only the most recent 10 terminal top-level session trees in framework
history; never prune active/queued/waiting sessions
- prune stale Agent planner `CODEX_HOME` cache directories under the Agent app
cache to the most recent 10
- prune stale Genie `CODEX_HOME` cache directories per paired target app to
the most recent 10 from inside the Genie runtime, because the Agent app does
not have direct filesystem ownership of other app sandboxes
- HOME icon / notification taps for question or final-result states should route
to `SessionPopupActivity`, which uses one dialog-style popup shape for both
question answering and result follow-up.
For top-level HOME `RUNNING` states with a detached target, the same handler
should immediately call `showDetachedTarget(sessionId)` so a red-badged live
icon brings the paired app on screen without attaching and ending the Genie
session.
Launcher may dispatch HOME-anchored icon taps through either
`ACTION_HANDLE_AGENT_SESSION` or `ACTION_HANDLE_HOME_AGENT_SESSION`; both
should first resolve through the transparent `SessionRouterActivity` so running
live-preview taps can open the target without flashing Agent UI.
The full `SessionDetailActivity` remains the fallback inspector for non-popup
states and Agent-app initiated navigation.
- HOME completion and cancellation are AGENT-owned policy decisions:
- pressing Done in a completed HOME result popup should call
`consumeCompletedHomeSession(sessionId)`, then close a still-detached
target instead of relying on Launcher to consume/open the target directly
- pressing Done in a non-completed terminal HOME result popup should call
`consumeHomeSessionPresentation(sessionId)` and close a still-detached
target
- pressing Send in the final result popup for a HOME session should launch a
fresh HOME continuation with previous-result context, then consume the old
result presentation
- pressing Send in the final result popup for a completed AGENT session should
resume the same direct parent session with previous-result context, keeping
the same parent HOME icon identity; the prompt must not be routed directly
to one of the child Genies the planner previously created
- Codex Agent is an AGENT-role app, not a HOME-role surface, so its
user-driven cancellation flows should still call `cancelSession(sessionId)`;
`cancelHomeSession(sessionId)` is for Launcher/HOME callers
- Direct AGENT parent sessions may also surface on HOME:
- `Codex` should be a launcher entrypoint that opens only the planner-session
prompt flow
- `Codex Manager` should remain the explicit admin/session-list entrypoint
- parent HOME icon taps should route through `HANDLE_SESSION` to the
per-session popup/detail flow rather than the management list
- parent result `Done` should call
`consumeHomeSessionPresentation(parentSessionId)` and leave the session
inspectable in Manager
## External reference implementations
There are standalone stub apps outside this repo that are useful for
understanding the intended Android API usage:
- Agent stub root:
`$HOME/code/omix/AgentStub`
- Genie stub root:
`$HOME/code/omix/GenieStub`
Especially useful files:
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/ValidationAgentService.java`
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/AgentOrchestrationService.java`
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/SessionActivity.java`
- `$HOME/code/omix/AgentStub/README-standalone.md`
- `$HOME/code/omix/GenieStub/src/com/example/geniestub/ValidationGenieService.java`
- `$HOME/code/omix/GenieStub/README-standalone.md`
Use these as contract/reference implementations for session lifecycle, detached
target control, question flow, and framework HTTP exchange usage.
## Recovery checklist
When returning to Android Agent/Genie work after interruption:
1. Read `docs/android-agent-genie-refactor.md` for the current architecture and
recent implementation status.
2. Re-read the three markdown files inside the stub SDK zip if the framework
contract matters for the change.
3. Check `git log --oneline -- android docs/android-agent-genie-refactor.md` to
see the latest Android-specific changes.
4. If behavior is ambiguous, compare against the AgentStub/GenieStub reference
implementations before changing repo code.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 791 KiB

View File

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

View File

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

View File

@@ -1,124 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.DUMP" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.MANAGE_AGENTS" />
<uses-permission android:name="android.permission.START_AGENT_REQUESTS" />
<uses-permission android:name="android.permission.START_GENIE_EXECUTION" />
<uses-permission android:name="android.permission.OBSERVE_AGENT_SESSIONS" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:label="@string/app_name"
android:allowBackup="false"
android:extractNativeLibs="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
<service
android:name=".CodexAgentService"
android:exported="true"
android:permission="android.permission.BIND_AGENT_SERVICE">
<intent-filter>
<action android:name="android.app.agent.AgentService" />
</intent-filter>
</service>
<service
android:name=".DesktopAttachKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:name=".MainActivity"
android:exported="true"
android:icon="@mipmap/ic_session_manager"
android:label="@string/app_name_manager"
android:launchMode="singleTop"
android:roundIcon="@mipmap/ic_session_manager_round">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity-alias
android:name=".CodexSessionLauncherActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".CreateSessionActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".CreateSessionActivity"
android:documentLaunchMode="always"
android:exported="true"
android:excludeFromRecents="true"
android:launchMode="standard"
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>
</activity>
<activity
android:name=".SessionRouterActivity"
android:exported="true"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:noHistory="true"
android:taskAffinity="com.openai.codex.agent.router"
android:theme="@style/CodexSessionRouterTheme">
<intent-filter>
<action android:name="android.app.agent.action.HANDLE_SESSION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.app.agent.action.HANDLE_HOME_SESSION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".SessionDetailActivity"
android:exported="false"
android:launchMode="singleTop" />
<activity
android:name=".SessionPopupActivity"
android:documentLaunchMode="always"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="standard"
android:taskAffinity="com.openai.codex.agent.popup"
android:theme="@style/CodexSessionPopupTheme" />
<receiver
android:name=".DesktopBridgeBootstrapReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.openai.codex.agent.action.BOOTSTRAP_DESKTOP_BRIDGE" />
</intent-filter>
</receiver>
<receiver
android:name=".AgentNotificationReplyReceiver"
android:exported="false" />
</application>
</manifest>

View File

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

View File

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

View File

@@ -1,247 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,102 +0,0 @@
package com.openai.codex.agent
data class AgentNotificationPresentation(
val notificationSessionId: String,
val contentSessionId: String,
val answerSessionId: String,
val answerParentSessionId: String?,
val state: Int,
val targetPackage: String?,
val notificationText: String,
)
data class AgentNotificationChildQuestion(
val sessionId: String,
val targetPackage: String?,
val question: String,
)
object AgentNotificationPresentationSelector {
private const val BRIDGE_REQUEST_PREFIX = "__codex_bridge__ "
private const val GENERIC_INPUT_REQUIRED_TEXT = "Codex needs input."
fun select(
sessionId: String,
state: Int,
targetPackage: String?,
notificationText: String,
plannerQuestion: String?,
childQuestions: List<AgentNotificationChildQuestion>,
): AgentNotificationPresentation {
val trimmedPlannerQuestion = plannerQuestion?.trim()?.takeIf(String::isNotEmpty)
if (trimmedPlannerQuestion != null) {
return parentPresentation(
sessionId = sessionId,
state = state,
targetPackage = targetPackage,
notificationText = trimmedPlannerQuestion,
)
}
if (state == AgentSessionStateValues.WAITING_FOR_USER) {
firstUserVisibleChildQuestion(childQuestions)?.let { childQuestion ->
return AgentNotificationPresentation(
notificationSessionId = sessionId,
contentSessionId = sessionId,
answerSessionId = childQuestion.sessionId,
answerParentSessionId = sessionId,
state = state,
targetPackage = childQuestion.targetPackage,
notificationText = childQuestion.question.trim(),
)
}
}
return parentPresentation(
sessionId = sessionId,
state = state,
targetPackage = targetPackage,
notificationText = notificationText,
)
}
private fun parentPresentation(
sessionId: String,
state: Int,
targetPackage: String?,
notificationText: String,
): AgentNotificationPresentation {
return AgentNotificationPresentation(
notificationSessionId = sessionId,
contentSessionId = sessionId,
answerSessionId = sessionId,
answerParentSessionId = null,
state = state,
targetPackage = targetPackage,
notificationText = notificationText.toUserVisibleFallbackText(),
)
}
private fun firstUserVisibleChildQuestion(
childQuestions: List<AgentNotificationChildQuestion>,
): AgentNotificationChildQuestion? {
return childQuestions.firstOrNull { childQuestion ->
childQuestion.question.isUserVisibleQuestion()
}
}
private fun String.isUserVisibleQuestion(): Boolean {
return trim().let { question ->
question.isNotEmpty() && !question.startsWith(BRIDGE_REQUEST_PREFIX)
}
}
private fun String.toUserVisibleFallbackText(): String {
val trimmedText = trim()
return if (trimmedText.startsWith(BRIDGE_REQUEST_PREFIX)) {
GENERIC_INPUT_REQUIRED_TEXT
} else {
trimmedText
}
}
}

View File

@@ -1,71 +0,0 @@
package com.openai.codex.agent
import android.app.RemoteInput
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlin.concurrent.thread
class AgentNotificationReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != AgentQuestionNotifier.ACTION_REPLY_FROM_NOTIFICATION) {
return
}
val sessionId = intent.getStringExtra(AgentQuestionNotifier.EXTRA_SESSION_ID)?.trim().orEmpty()
val answerSessionId = intent.getStringExtra(AgentQuestionNotifier.EXTRA_ANSWER_SESSION_ID)
?.trim()
?.ifEmpty { null }
?: sessionId
val answerParentSessionId = intent.getStringExtra(AgentQuestionNotifier.EXTRA_ANSWER_PARENT_SESSION_ID)
?.trim()
?.ifEmpty { null }
val notificationToken = intent.getStringExtra(
AgentQuestionNotifier.EXTRA_NOTIFICATION_TOKEN,
)?.trim().orEmpty()
val answer = RemoteInput.getResultsFromIntent(intent)
?.getCharSequence(AgentQuestionNotifier.REMOTE_INPUT_KEY)
?.toString()
?.trim()
.orEmpty()
if (sessionId.isEmpty() || answer.isEmpty()) {
return
}
val pendingResult = goAsync()
thread(name = "CodexAgentNotificationReply-$sessionId") {
try {
AgentQuestionNotifier.suppress(
context = context,
sessionId = sessionId,
notificationToken = notificationToken,
)
val sessionController = AgentSessionController(context)
runCatching {
if (answerSessionId == sessionId) {
sessionController.answerQuestionFromNotification(
sessionId = sessionId,
notificationToken = notificationToken,
answer = answer,
parentSessionId = null,
)
} else {
sessionController.answerQuestion(
sessionId = answerSessionId,
answer = answer,
parentSessionId = answerParentSessionId,
)
sessionController.ackSessionNotification(sessionId, notificationToken)
}
}.onFailure { err ->
Log.w(TAG, "Failed to answer notification question for $answerSessionId", err)
}
} finally {
pendingResult.finish()
}
}
}
private companion object {
private const val TAG = "CodexAgentReply"
}
}

View File

@@ -1,198 +0,0 @@
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

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

View File

@@ -1,93 +0,0 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.app.agent.AgentSessionInfo
import android.content.Context
import android.util.Log
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import org.json.JSONArray
import org.json.JSONObject
object AgentPlannerQuestionRegistry {
private const val TAG = "AgentPlannerQuestionRegistry"
private data class PendingPlannerQuestion(
val questions: JSONArray,
val renderedQuestion: String,
val responses: LinkedBlockingQueue<PendingPlannerQuestionResponse> = LinkedBlockingQueue(1),
)
private data class PendingPlannerQuestionResponse(
val answer: JSONObject? = null,
val error: IOException? = null,
)
private val pendingQuestions = ConcurrentHashMap<String, PendingPlannerQuestion>()
fun requestUserInput(
context: Context,
sessionController: AgentSessionController,
sessionId: String,
questions: JSONArray,
): JSONObject {
val appContext = context.applicationContext
val manager = appContext.getSystemService(AgentManager::class.java)
?: throw IOException("AgentManager unavailable for planner question")
val pendingQuestion = PendingPlannerQuestion(
questions = JSONArray(questions.toString()),
renderedQuestion = AgentUserInputPrompter.renderQuestions(questions),
)
pendingQuestions.put(sessionId, pendingQuestion)?.responses?.offer(
PendingPlannerQuestionResponse(error = IOException("Planner question superseded")),
)
runCatching {
manager.publishTrace(sessionId, "Planner requested user input before delegating to Genies.")
}.onFailure { err ->
Log.w(TAG, "Failed to publish planner question trace for $sessionId", err)
}
manager.updateSessionState(sessionId, AgentSessionInfo.STATE_WAITING_FOR_USER)
return try {
val response = pendingQuestion.responses.take()
response.error?.let { throw it }
response.answer ?: throw IOException("Planner question completed without an answer")
} catch (err: InterruptedException) {
Thread.currentThread().interrupt()
throw IOException("Interrupted while waiting for planner question answer", err)
} finally {
pendingQuestions.remove(sessionId, pendingQuestion)
if (!sessionController.isTerminalSession(sessionId)) {
runCatching {
manager.updateSessionState(sessionId, AgentSessionInfo.STATE_RUNNING)
}.onFailure { err ->
Log.w(TAG, "Failed to restore planner session state for $sessionId", err)
}
}
}
}
fun answerQuestion(
sessionId: String,
answer: String,
): Boolean {
val pendingQuestion = pendingQuestions[sessionId] ?: return false
val answerJson = JSONObject().put(
"answers",
AgentUserInputPrompter.buildQuestionAnswers(pendingQuestion.questions, answer),
)
pendingQuestion.responses.offer(PendingPlannerQuestionResponse(answer = answerJson))
return true
}
fun cancelQuestion(
sessionId: String,
reason: String,
) {
pendingQuestions.remove(sessionId)?.responses?.offer(
PendingPlannerQuestionResponse(error = IOException(reason)),
)
}
fun latestQuestion(sessionId: String): String? = pendingQuestions[sessionId]?.renderedQuestion
}

View File

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

View File

@@ -1,379 +0,0 @@
package com.openai.codex.agent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.RemoteInput
import android.app.agent.AgentSessionInfo
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.os.Build
object AgentQuestionNotifier {
const val ACTION_REPLY_FROM_NOTIFICATION =
"com.openai.codex.agent.action.REPLY_FROM_NOTIFICATION"
const val EXTRA_SESSION_ID = "sessionId"
const val EXTRA_ANSWER_SESSION_ID = "answerSessionId"
const val EXTRA_ANSWER_PARENT_SESSION_ID = "answerParentSessionId"
const val EXTRA_NOTIFICATION_TOKEN = "notificationToken"
const val REMOTE_INPUT_KEY = "codexAgentNotificationReply"
private const val CHANNEL_ID = "codex_agent_questions"
private const val CHANNEL_NAME = "Codex Agent Questions"
private const val MAX_CONTENT_PREVIEW_CHARS = 400
private val notificationStateLock = Any()
private val activeNotificationTokens = mutableMapOf<String, String>()
private val retiredNotificationTokens = mutableMapOf<String, MutableSet<String>>()
private val suppressedNotificationTokens = mutableMapOf<String, MutableSet<String>>()
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 showOrUpdateDelegatedNotification(
context: Context,
presentation: AgentNotificationPresentation,
notificationToken: String,
): Boolean {
if (!activateNotificationToken(presentation.notificationSessionId, notificationToken)) {
return false
}
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
if (
!shouldShowDelegatedNotification(presentation.state) ||
isSuppressedNotificationToken(presentation.notificationSessionId, notificationToken)
) {
manager.cancel(notificationId(presentation.notificationSessionId))
return true
}
if (presentation.notificationText.isBlank()) {
return false
}
ensureChannel(manager)
manager.notify(
notificationId(presentation.notificationSessionId),
buildDelegatedNotification(
context = context,
presentation = presentation,
notificationToken = notificationToken,
),
)
return true
}
private fun shouldShowDelegatedNotification(state: Int): Boolean {
return when (state) {
AgentSessionInfo.STATE_WAITING_FOR_USER,
AgentSessionInfo.STATE_COMPLETED,
AgentSessionInfo.STATE_FAILED,
AgentSessionInfo.STATE_CANCELLED,
-> true
AgentSessionInfo.STATE_CREATED,
AgentSessionInfo.STATE_QUEUED,
AgentSessionInfo.STATE_RUNNING,
-> false
else -> false
}
}
fun suppress(
context: Context,
sessionId: String,
notificationToken: String,
) {
if (!suppressNotificationToken(sessionId, notificationToken)) {
return
}
val manager = context.getSystemService(NotificationManager::class.java) ?: return
manager.cancel(notificationId(sessionId))
}
fun cancel(context: Context, sessionId: String) {
retireActiveNotificationToken(sessionId)
val manager = context.getSystemService(NotificationManager::class.java) ?: return
manager.cancel(notificationId(sessionId))
}
fun clearSessionState(sessionId: String) {
clearNotificationToken(sessionId)
}
fun cancel(
context: Context,
sessionId: String,
notificationToken: String,
) {
if (!retireNotificationToken(sessionId, notificationToken)) {
return
}
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),
SessionPopupActivity.intent(context, sessionId).apply {
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 buildDelegatedNotification(
context: Context,
presentation: AgentNotificationPresentation,
notificationToken: String,
): Notification {
val targetIdentity = resolveTargetIdentity(context, presentation.targetPackage)
val contentIntent = PendingIntent.getActivity(
context,
notificationId(presentation.notificationSessionId),
SessionPopupActivity.intent(context, presentation.contentSessionId).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentText = presentation.notificationText.take(MAX_CONTENT_PREVIEW_CHARS)
val builder = Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(targetIdentity.icon)
.setLargeIcon(targetIdentity.icon)
.setContentTitle(buildNotificationTitle(presentation.state, targetIdentity.displayName))
.setContentText(contentText)
.setStyle(Notification.BigTextStyle().bigText(contentText))
.setContentIntent(contentIntent)
.setAutoCancel(false)
.setOngoing(true)
buildInlineReplyAction(
context = context,
presentation = presentation,
notificationToken = notificationToken,
)?.let { replyAction ->
builder.addAction(replyAction)
}
return builder.build()
}
private fun buildNotificationTitle(
state: Int,
targetDisplayName: String,
): String {
return when (state) {
AgentSessionInfo.STATE_WAITING_FOR_USER ->
"Codex needs input for $targetDisplayName"
AgentSessionInfo.STATE_COMPLETED ->
"Codex finished $targetDisplayName"
AgentSessionInfo.STATE_FAILED ->
"Codex hit an issue in $targetDisplayName"
AgentSessionInfo.STATE_CANCELLED ->
"Codex cancelled $targetDisplayName"
AgentSessionInfo.STATE_CREATED,
AgentSessionInfo.STATE_QUEUED,
AgentSessionInfo.STATE_RUNNING,
-> "Codex session for $targetDisplayName"
else -> "Codex session for $targetDisplayName"
}
}
private fun buildInlineReplyAction(
context: Context,
presentation: AgentNotificationPresentation,
notificationToken: String,
): Notification.Action? {
if (presentation.state != AgentSessionInfo.STATE_WAITING_FOR_USER || notificationToken.isBlank()) {
return null
}
val replyIntent = PendingIntent.getBroadcast(
context,
notificationId(presentation.notificationSessionId),
Intent(context, AgentNotificationReplyReceiver::class.java).apply {
action = ACTION_REPLY_FROM_NOTIFICATION
putExtra(EXTRA_SESSION_ID, presentation.notificationSessionId)
putExtra(EXTRA_ANSWER_SESSION_ID, presentation.answerSessionId)
putExtra(EXTRA_ANSWER_PARENT_SESSION_ID, presentation.answerParentSessionId)
putExtra(EXTRA_NOTIFICATION_TOKEN, notificationToken)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY)
.setLabel("Reply")
.build()
return Notification.Action.Builder(
Icon.createWithResource(context, android.R.drawable.ic_menu_send),
"Reply",
replyIntent,
)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(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 activateNotificationToken(
sessionId: String,
notificationToken: String,
): Boolean {
if (notificationToken.isBlank()) {
return false
}
synchronized(notificationStateLock) {
if (retiredNotificationTokens[sessionId]?.contains(notificationToken) == true) {
return false
}
activeNotificationTokens.put(sessionId, notificationToken)?.let { previousToken ->
if (previousToken != notificationToken) {
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
.add(previousToken)
}
}
return true
}
}
private fun clearNotificationToken(sessionId: String) {
synchronized(notificationStateLock) {
activeNotificationTokens.remove(sessionId)
retiredNotificationTokens.remove(sessionId)
suppressedNotificationTokens.remove(sessionId)
}
}
private fun retireNotificationToken(
sessionId: String,
notificationToken: String,
): Boolean {
if (notificationToken.isBlank()) {
retireActiveNotificationToken(sessionId)
return true
}
synchronized(notificationStateLock) {
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
.add(notificationToken)
suppressedNotificationTokens[sessionId]?.remove(notificationToken)
if (activeNotificationTokens[sessionId] != notificationToken) {
return false
}
activeNotificationTokens.remove(sessionId)
return true
}
}
private fun retireActiveNotificationToken(sessionId: String) {
synchronized(notificationStateLock) {
activeNotificationTokens.remove(sessionId)?.let { notificationToken ->
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
.add(notificationToken)
suppressedNotificationTokens[sessionId]?.remove(notificationToken)
}
}
}
private fun suppressNotificationToken(
sessionId: String,
notificationToken: String,
): Boolean {
if (!activateNotificationToken(sessionId, notificationToken)) {
return false
}
synchronized(notificationStateLock) {
suppressedNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
.add(notificationToken)
}
return true
}
private fun isSuppressedNotificationToken(
sessionId: String,
notificationToken: String,
): Boolean {
synchronized(notificationStateLock) {
return suppressedNotificationTokens[sessionId]?.contains(notificationToken) == true
}
}
private fun resolveTargetIdentity(
context: Context,
targetPackage: String?,
): TargetIdentity {
if (targetPackage.isNullOrBlank()) {
return TargetIdentity(
displayName = "Codex Agent",
icon = Icon.createWithResource(context, android.R.drawable.ic_dialog_info),
)
}
val packageManager = context.packageManager
return runCatching {
val appInfo = packageManager.getApplicationInfo(
targetPackage,
PackageManager.ApplicationInfoFlags.of(0),
)
val iconResId = appInfo.icon.takeIf { it != 0 }
TargetIdentity(
displayName = packageManager.getApplicationLabel(appInfo).toString()
.ifBlank { targetPackage },
icon = if (iconResId == null) {
Icon.createWithResource(context, android.R.drawable.ic_dialog_info)
} else {
Icon.createWithResource(targetPackage, iconResId)
},
)
}.getOrDefault(
TargetIdentity(
displayName = targetPackage,
icon = Icon.createWithResource(context, android.R.drawable.ic_dialog_info),
),
)
}
private fun notificationId(sessionId: String): Int {
return sessionId.hashCode()
}
private data class TargetIdentity(
val displayName: String,
val icon: Icon,
)
}

View File

@@ -1,328 +0,0 @@
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

@@ -1,399 +0,0 @@
package com.openai.codex.agent
import android.app.agent.AgentManager
import android.content.Context
import android.os.ParcelFileDescriptor
import android.util.Log
import com.openai.codex.bridge.HostedCodexConfig
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.Closeable
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import org.json.JSONObject
object AgentSessionBridgeServer {
private val runningBridges = ConcurrentHashMap<String, RunningBridge>()
private const val TAG = "AgentSessionBridge"
fun ensureStarted(
context: Context,
agentManager: AgentManager,
sessionId: String,
) {
runningBridges.computeIfAbsent(sessionId) {
RunningBridge(
context = context.applicationContext,
agentManager = agentManager,
sessionId = sessionId,
).also(RunningBridge::start)
}
}
fun closeSession(sessionId: String) {
runningBridges.remove(sessionId)?.close()
}
fun activeThreadId(sessionId: String): String? = runningBridges[sessionId]?.activeThreadId()
fun openDesktopProxy(
sessionId: String,
onMessage: (String) -> Unit,
onClosed: (String?) -> Unit,
): String? = runningBridges[sessionId]?.openDesktopProxy(onMessage, onClosed)
fun sendDesktopProxyInput(
sessionId: String,
connectionId: String,
message: String,
): Boolean = runningBridges[sessionId]?.sendDesktopProxyInput(connectionId, message) ?: false
fun closeDesktopProxy(
sessionId: String,
connectionId: String,
reason: String? = null,
) {
runningBridges[sessionId]?.closeDesktopProxy(connectionId, reason)
}
private class RunningBridge(
private val context: Context,
private val agentManager: AgentManager,
private val sessionId: String,
) : Closeable {
companion object {
private const val METHOD_GET_RUNTIME_STATUS = "getRuntimeStatus"
private const val METHOD_READ_INSTALLED_AGENTS_FILE = "readInstalledAgentsFile"
private const val METHOD_READ_SESSION_EXECUTION_SETTINGS = "readSessionExecutionSettings"
private const val METHOD_READ_DESKTOP_INSPECTION_HOLD = "readDesktopInspectionHold"
private const val METHOD_REGISTER_APP_SERVER_THREAD = "registerAppServerThread"
private const val WRITE_CHUNK_BYTES = 4096
private const val KIND_REQUEST = "request"
private const val KIND_RESPONSE = "response"
private const val KIND_REMOTE_CLIENT_MESSAGE = "remoteAppServerClientMessage"
private const val KIND_REMOTE_SERVER_MESSAGE = "remoteAppServerServerMessage"
private const val KIND_REMOTE_CLOSED = "remoteAppServerClosed"
}
private data class DesktopProxy(
val connectionId: String,
val onMessage: (String) -> Unit,
val onClosed: (String?) -> Unit,
)
private data class PendingDesktopMessage(
val connectionId: String,
val message: String,
)
private val closed = AtomicBoolean(false)
private var bridgeFd: ParcelFileDescriptor? = null
private var input: DataInputStream? = null
private var output: DataOutputStream? = null
private val executionSettingsStore = SessionExecutionSettingsStore(context)
private val writerLock = Any()
private val proxyLock = Any()
private val pendingDesktopMessages = LinkedBlockingQueue<PendingDesktopMessage>()
@Volatile
private var currentDesktopProxy: DesktopProxy? = null
@Volatile
private var currentThreadId: String? = null
private val serveThread = thread(
start = false,
name = "AgentSessionBridge-$sessionId",
) {
serveLoop()
}
private val desktopDispatchThread = thread(
start = false,
name = "AgentSessionBridgeDesktop-$sessionId",
) {
dispatchDesktopMessages()
}
fun start() {
serveThread.start()
desktopDispatchThread.start()
}
override fun close() {
if (!closed.compareAndSet(false, true)) {
return
}
val proxy = synchronized(proxyLock) {
currentDesktopProxy.also {
currentDesktopProxy = null
}
}
runCatching {
proxy?.onClosed("Agent session bridge closed")
}
runCatching { input?.close() }
runCatching { output?.close() }
runCatching { bridgeFd?.close() }
serveThread.interrupt()
desktopDispatchThread.interrupt()
}
fun activeThreadId(): String? = currentThreadId
fun openDesktopProxy(
onMessage: (String) -> Unit,
onClosed: (String?) -> Unit,
): String? {
val threadId = currentThreadId ?: return null
val connectionId = UUID.randomUUID().toString()
val replacement = synchronized(proxyLock) {
currentDesktopProxy.also {
currentDesktopProxy = DesktopProxy(connectionId, onMessage, onClosed)
}
}
runCatching {
replacement?.onClosed("Replaced by a newer desktop attach")
}
return connectionId
}
fun sendDesktopProxyInput(
connectionId: String,
message: String,
): Boolean {
val proxy = currentDesktopProxy
if (proxy?.connectionId != connectionId) {
return false
}
sendBridgeMessage(
JSONObject()
.put("kind", KIND_REMOTE_CLIENT_MESSAGE)
.put("connectionId", connectionId)
.put("message", message),
)
return true
}
fun closeDesktopProxy(
connectionId: String,
reason: String? = null,
) {
val proxy = synchronized(proxyLock) {
currentDesktopProxy?.takeIf { it.connectionId == connectionId }?.also {
currentDesktopProxy = null
}
} ?: return
sendBridgeMessage(
JSONObject()
.put("kind", KIND_REMOTE_CLOSED)
.put("connectionId", connectionId)
.put("reason", reason),
)
runCatching {
proxy.onClosed(reason)
}
}
private fun serveLoop() {
try {
val fd = agentManager.openSessionBridge(sessionId)
bridgeFd = fd
input = DataInputStream(BufferedInputStream(FileInputStream(fd.fileDescriptor)))
output = DataOutputStream(BufferedOutputStream(FileOutputStream(fd.fileDescriptor)))
Log.i(TAG, "Opened framework session bridge for $sessionId")
while (!closed.get()) {
val message = try {
readMessage(input ?: break)
} catch (_: EOFException) {
return
}
when (message.optString("kind", KIND_REQUEST)) {
KIND_REQUEST -> {
val response = handleRequest(message)
sendBridgeMessage(response)
}
KIND_REMOTE_SERVER_MESSAGE -> {
handleRemoteServerMessage(message)
}
KIND_REMOTE_CLOSED -> {
handleRemoteClosed(message)
}
else -> {
Log.w(TAG, "Ignoring unsupported Agent bridge message for $sessionId: $message")
}
}
}
} catch (err: Exception) {
if (!closed.get() && !isExpectedSessionShutdown(err)) {
Log.w(TAG, "Session bridge failed for $sessionId", err)
}
} finally {
runningBridges.remove(sessionId, this)
close()
}
}
private fun isExpectedSessionShutdown(err: Exception): Boolean {
return err is IllegalStateException
&& err.message?.contains("No active Genie runtime for session") == true
}
private fun handleRequest(request: JSONObject): JSONObject {
val requestId = request.optString("requestId")
return runCatching {
when (request.optString("method")) {
METHOD_GET_RUNTIME_STATUS -> {
val status = AgentCodexAppServerClient.readRuntimeStatus(context)
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
.put(
"runtimeStatus",
JSONObject()
.put("authenticated", status.authenticated)
.put("accountEmail", status.accountEmail)
.put("clientCount", status.clientCount)
.put("modelProviderId", status.modelProviderId)
.put("configuredModel", status.configuredModel)
.put("effectiveModel", status.effectiveModel)
.put("upstreamBaseUrl", status.upstreamBaseUrl)
.put("frameworkResponsesPath", status.frameworkResponsesPath),
)
}
METHOD_READ_INSTALLED_AGENTS_FILE -> {
val codexHome = File(context.filesDir, "codex-home")
HostedCodexConfig.installBundledAgentsFile(context, codexHome)
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
.put("agentsMarkdown", HostedCodexConfig.readInstalledAgentsMarkdown(codexHome))
}
METHOD_READ_SESSION_EXECUTION_SETTINGS -> {
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
.put("executionSettings", executionSettingsStore.toJson(sessionId))
}
METHOD_READ_DESKTOP_INSPECTION_HOLD -> {
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
.put("inspectionHold", DesktopInspectionRegistry.isSessionHeldForInspection(sessionId))
}
METHOD_REGISTER_APP_SERVER_THREAD -> {
currentThreadId = request.optString("threadId").trim().ifEmpty { null }
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", true)
}
else -> {
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", false)
.put("error", "Unsupported bridge method: ${request.optString("method")}")
}
}
}.getOrElse { err ->
JSONObject()
.put("kind", KIND_RESPONSE)
.put("requestId", requestId)
.put("ok", false)
.put("error", err.message ?: err::class.java.simpleName)
}
}
private fun handleRemoteServerMessage(message: JSONObject) {
val connectionId = message.optString("connectionId")
val proxy = currentDesktopProxy
if (proxy == null || proxy.connectionId != connectionId) {
return
}
pendingDesktopMessages.offer(
PendingDesktopMessage(
connectionId = connectionId,
message = message.optString("message"),
),
)
}
private fun handleRemoteClosed(message: JSONObject) {
val connectionId = message.optString("connectionId")
val reason = message.optString("reason").ifBlank { null }
val proxy = synchronized(proxyLock) {
currentDesktopProxy?.takeIf { it.connectionId == connectionId }?.also {
currentDesktopProxy = null
}
} ?: return
runCatching {
proxy.onClosed(reason)
}
}
private fun readMessage(input: DataInputStream): JSONObject {
val size = input.readInt()
if (size <= 0) {
throw IOException("Invalid session bridge message length: $size")
}
val payload = ByteArray(size)
input.readFully(payload)
return JSONObject(payload.toString(StandardCharsets.UTF_8))
}
private fun writeMessage(
output: DataOutputStream,
message: JSONObject,
) {
val payload = message.toString().toByteArray(StandardCharsets.UTF_8)
output.writeInt(payload.size)
output.flush()
var offset = 0
while (offset < payload.size) {
val chunkSize = minOf(WRITE_CHUNK_BYTES, payload.size - offset)
output.write(payload, offset, chunkSize)
output.flush()
offset += chunkSize
}
}
private fun sendBridgeMessage(message: JSONObject) {
synchronized(writerLock) {
writeMessage(output ?: throw IOException("Session bridge output unavailable"), message)
}
}
private fun dispatchDesktopMessages() {
while (!closed.get()) {
val pending = try {
pendingDesktopMessages.take()
} catch (_: InterruptedException) {
return
}
val proxy = currentDesktopProxy
if (proxy == null || proxy.connectionId != pending.connectionId) {
continue
}
runCatching {
proxy.onMessage(pending.message)
}.onFailure { err ->
Log.w(TAG, "Desktop proxy delivery failed for $sessionId", err)
closeDesktopProxy(pending.connectionId, err.message ?: err::class.java.simpleName)
}
}
}
}
}

View File

@@ -1,274 +0,0 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
import android.content.Context
import com.openai.codex.bridge.SessionExecutionSettings
import kotlin.concurrent.thread
import org.json.JSONArray
import org.json.JSONObject
data class CreateSessionRequest(
val targetPackage: String?,
val model: String?,
val reasoningEffort: String?,
)
data class LaunchSessionRequest(
val prompt: String,
val targetPackage: String?,
val model: String?,
val reasoningEffort: String?,
val existingSessionId: String? = null,
)
data class StartSessionRequest(
val sessionId: String,
val prompt: String,
)
data class SessionDraftResult(
val sessionId: String,
val anchor: Int,
)
object AgentSessionLauncher {
fun createSessionDraft(
request: CreateSessionRequest,
sessionController: AgentSessionController,
): SessionDraftResult {
val executionSettings = SessionExecutionSettings(
model = request.model?.trim()?.ifEmpty { null },
reasoningEffort = request.reasoningEffort?.trim()?.ifEmpty { null },
)
val targetPackage = request.targetPackage?.trim()?.ifEmpty { null }
return if (targetPackage == null) {
SessionDraftResult(
sessionId = sessionController.createDirectSessionDraft(executionSettings),
anchor = AgentSessionInfo.ANCHOR_AGENT,
)
} else {
SessionDraftResult(
sessionId = sessionController.createHomeSessionDraft(
targetPackage = targetPackage,
finalPresentationPolicy = SessionFinalPresentationPolicy.AGENT_CHOICE,
executionSettings = executionSettings,
),
anchor = AgentSessionInfo.ANCHOR_HOME,
)
}
}
fun startSessionAsync(
context: Context,
request: LaunchSessionRequest,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
val executionSettings = SessionExecutionSettings(
model = request.model?.trim()?.ifEmpty { null },
reasoningEffort = request.reasoningEffort?.trim()?.ifEmpty { null },
)
val targetPackage = request.targetPackage?.trim()?.ifEmpty { null }
val existingSessionId = request.existingSessionId?.trim()?.ifEmpty { null }
if (targetPackage != null || existingSessionId != null) {
return startSession(
context = context,
request = request,
sessionController = sessionController,
requestUserInputHandler = requestUserInputHandler,
)
}
val draftSession = createSessionDraft(
request = CreateSessionRequest(
targetPackage = null,
model = executionSettings.model,
reasoningEffort = executionSettings.reasoningEffort,
),
sessionController = sessionController,
)
return startSessionDraftAsync(
context = context,
request = StartSessionRequest(
sessionId = draftSession.sessionId,
prompt = request.prompt,
),
sessionController = sessionController,
requestUserInputHandler = requestUserInputHandler,
)
}
fun startSessionDraftAsync(
context: Context,
request: StartSessionRequest,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
val sessionId = request.sessionId.trim()
require(sessionId.isNotEmpty()) { "Missing session id" }
val prompt = request.prompt.trim()
require(prompt.isNotEmpty()) { "Missing prompt" }
val snapshot = sessionController.loadSnapshot(sessionId)
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId }
?: throw IllegalArgumentException("Unknown session: $sessionId")
if (
session.anchor == AgentSessionInfo.ANCHOR_HOME &&
session.parentSessionId == null &&
!session.targetPackage.isNullOrBlank()
) {
return sessionController.startExistingHomeSession(
sessionId = sessionId,
targetPackage = checkNotNull(session.targetPackage),
prompt = prompt,
allowDetachedMode = true,
finalPresentationPolicy = session.requiredFinalPresentationPolicy
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
executionSettings = sessionController.executionSettingsForSession(sessionId),
)
}
check(
session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
session.parentSessionId == null &&
session.targetPackage == null,
) {
"Session $sessionId is not a startable draft"
}
check(AgentPlannerRuntimeManager.activeThreadId(sessionId) == null) {
"Session $sessionId is already attached to an idle planner runtime; send the first prompt through the attached client"
}
val executionSettings = sessionController.executionSettingsForSession(sessionId)
val pendingSession = sessionController.prepareDirectSessionDraftForStart(
sessionId = sessionId,
objective = prompt,
executionSettings = executionSettings,
)
val applicationContext = context.applicationContext
thread(name = "CodexAgentPlanner-${pendingSession.parentSessionId}") {
runCatching {
val effectiveRequestUserInputHandler = requestUserInputHandler ?: { questions: JSONArray ->
AgentPlannerQuestionRegistry.requestUserInput(
context = applicationContext,
sessionController = sessionController,
sessionId = pendingSession.parentSessionId,
questions = questions,
)
}
AgentTaskPlanner.planSession(
context = applicationContext,
userObjective = prompt,
executionSettings = executionSettings,
sessionController = sessionController,
requestUserInputHandler = effectiveRequestUserInputHandler,
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 -> {
sessionController.continueDirectSessionWithPlanner(
parentSessionId = sourceTopLevelSession.sessionId,
objective = SessionContinuationPromptBuilder.build(
sourceTopLevelSession = sourceTopLevelSession,
selectedSession = selectedSession,
prompt = prompt,
),
executionSettings = executionSettings,
)
}
}
}
}

View File

@@ -1,309 +0,0 @@
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`.
- Prefer `DETACHED_HIDDEN` by default so the target app stays in the background while the Agent reports the outcome.
- Use `ATTACHED` only when the user explicitly asks to leave the app open, bring it to the front, show them the resulting screen, or the request clearly implies that the final UI must be visible.
- Use `DETACHED_SHOWN` only when the user asks for the app to remain visibly shown in detached mode rather than attached.
- Use `AGENT_CHOICE` only when there is a strong reason to let the Genie choose the final presentation dynamically; otherwise pick `DETACHED_HIDDEN`.
- 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.
- If the user objective is too ambiguous to choose target packages or delegated objectives safely, call request_user_input before returning JSON. Do not guess a task for vague prompts like "do something".
- `pm list packages PACKAGE_NAME` alone is not sufficient verification.
- Prefer focused verification commands such as `pm list packages clock`, `cmd package query-activities --brief -p PACKAGE -a android.intent.action.MAIN`, and `cmd package resolve-activity --brief -a RELEVANT_ACTION PACKAGE`.
- Do not enumerate every launcher activity on the device. Query specific candidate packages instead.
""".trimIndent()
private val PLANNER_OUTPUT_SCHEMA =
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject()
.put(
"targets",
JSONObject()
.put("type", "array")
.put("minItems", 1)
.put(
"items",
JSONObject()
.put("type", "object")
.put(
"properties",
JSONObject()
.put("packageName", JSONObject().put("type", "string"))
.put("objective", JSONObject().put("type", "string"))
.put(
"finalPresentationPolicy",
JSONObject()
.put("type", "string")
.put(
"enum",
JSONArray()
.put(SessionFinalPresentationPolicy.ATTACHED.wireValue)
.put(SessionFinalPresentationPolicy.DETACHED_HIDDEN.wireValue)
.put(SessionFinalPresentationPolicy.DETACHED_SHOWN.wireValue)
.put(SessionFinalPresentationPolicy.AGENT_CHOICE.wireValue),
),
),
)
.put(
"required",
JSONArray()
.put("packageName")
.put("objective")
.put("finalPresentationPolicy"),
)
.put("additionalProperties", false),
),
)
.put("reason", JSONObject().put("type", "string"))
.put("allowDetachedMode", JSONObject().put("type", "boolean")),
)
.put("required", JSONArray().put("targets").put("reason").put("allowDetachedMode"))
.put("additionalProperties", false)
internal fun plannerInstructions(): String = PLANNER_INSTRUCTIONS
fun startSession(
context: Context,
userObjective: String,
targetPackageOverride: String?,
allowDetachedMode: Boolean,
finalPresentationPolicyOverride: SessionFinalPresentationPolicy? = null,
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
if (!targetPackageOverride.isNullOrBlank()) {
Log.i(TAG, "Using explicit target override $targetPackageOverride")
return sessionController.startDirectSession(
plan = AgentDelegationPlan(
originalObjective = userObjective,
targets = listOf(
AgentDelegationTarget(
packageName = targetPackageOverride,
objective = userObjective,
finalPresentationPolicy =
finalPresentationPolicyOverride ?: SessionFinalPresentationPolicy.AGENT_CHOICE,
),
),
rationale = "Using explicit target package override.",
usedOverride = true,
),
allowDetachedMode = allowDetachedMode,
)
}
val pendingSession = sessionController.createPendingDirectSession(
objective = userObjective,
executionSettings = executionSettings,
)
val sessionStartResult = try {
val effectiveRequestUserInputHandler = requestUserInputHandler ?: { questions: JSONArray ->
AgentPlannerQuestionRegistry.requestUserInput(
context = context,
sessionController = sessionController,
sessionId = pendingSession.parentSessionId,
questions = questions,
)
}
val request = planSession(
context = context,
userObjective = userObjective,
executionSettings = executionSettings,
sessionController = sessionController,
requestUserInputHandler = effectiveRequestUserInputHandler,
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

@@ -1,116 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,552 +0,0 @@
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.util.concurrent.atomic.AtomicBoolean
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 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 val retentionPruneScheduled = AtomicBoolean(false)
}
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()
scheduleRetentionPrune()
}
override fun onSessionChanged(session: AgentSessionInfo) {
Log.i(TAG, "onSessionChanged $session")
maybeRollUpParentSession(session)
agentManager?.let { manager ->
if (shouldServeSessionBridge(session)) {
AgentSessionBridgeServer.ensureStarted(this, manager, session.sessionId)
} else if (isTerminalSessionState(session.state)) {
AgentSessionBridgeServer.closeSession(session.sessionId)
}
}
if (isTerminalSessionState(session.state) && !DesktopInspectionRegistry.isPlannerAttached(session.sessionId)) {
AgentPlannerQuestionRegistry.cancelQuestion(
sessionId = session.sessionId,
reason = "Planner session ended before the question was answered",
)
AgentPlannerRuntimeManager.closeSession(session.sessionId)
scheduleRetentionPrune()
}
if (isTerminalSessionState(session.state)) {
scheduleRetentionPrune()
}
if (session.state != AgentSessionInfo.STATE_WAITING_FOR_USER) {
return
}
if (!pendingQuestionLoads.add(session.sessionId)) {
return
}
thread(name = "CodexAgentQuestionLoad-${session.sessionId}") {
try {
handleWaitingSession(session)
} finally {
pendingQuestionLoads.remove(session.sessionId)
}
}
}
override fun onSessionRemoved(sessionId: String) {
Log.i(TAG, "onSessionRemoved sessionId=$sessionId")
AgentSessionBridgeServer.closeSession(sessionId)
AgentPlannerRuntimeManager.closeSession(sessionId)
DesktopInspectionRegistry.removeSession(sessionId)
AgentPlannerQuestionRegistry.cancelQuestion(
sessionId = sessionId,
reason = "Planner session was removed before the question was answered",
)
AgentQuestionNotifier.cancel(this, sessionId)
AgentQuestionNotifier.clearSessionState(sessionId)
presentationPolicyStore.removePolicy(sessionId)
handledGenieQuestions.removeIf { it.startsWith("$sessionId:") }
handledBridgeRequests.removeIf { it.startsWith("$sessionId:") }
pendingGenieQuestions.removeIf { it.startsWith("$sessionId:") }
scheduleRetentionPrune()
}
private fun scheduleRetentionPrune() {
if (!retentionPruneScheduled.compareAndSet(false, true)) {
return
}
thread(name = "CodexAgentRetentionPrune") {
try {
sessionController.enforceRetentionPolicy()
} finally {
retentionPruneScheduled.set(false)
}
}
}
override fun onShowOrUpdateSessionNotification(
session: AgentSessionInfo,
notificationToken: String,
notificationText: String,
) {
showOrUpdateSessionNotification(
session = session,
notificationToken = notificationToken,
notificationText = notificationText,
)
}
override fun onCancelSessionNotification(
sessionId: String,
notificationToken: String,
reason: Int,
) {
cancelSessionNotification(
sessionId = sessionId,
notificationToken = notificationToken,
reason = reason,
)
}
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 { childSession ->
childSession.parentSessionId == parentSessionId &&
(
parentSession.continuationGeneration == AgentSessionInfo.CONTINUATION_GENERATION_NONE ||
childSession.continuationGeneration == parentSession.continuationGeneration
)
}
if (childSessions.isEmpty()) {
return
}
val rollup = AgentParentSessionAggregator.rollup(
childSessions.map { childSession ->
val events = manager.getSessionEvents(childSession.sessionId)
ParentSessionChildSummary(
sessionId = childSession.sessionId,
targetPackage = childSession.targetPackage,
state = childSession.state,
targetPresentation = childSession.targetPresentation,
requiredFinalPresentationPolicy = presentationPolicyStore.getPolicy(childSession.sessionId),
latestResult = findLastEventMessage(events, AgentSessionEvent.TYPE_RESULT),
latestError = findLastEventMessage(events, AgentSessionEvent.TYPE_ERROR),
)
},
)
val deferTerminalRollup =
DesktopInspectionRegistry.isPlannerAttached(parentSessionId) &&
isTerminalSessionState(rollup.state)
rollup.sessionsToAttach.forEach { childSessionId ->
runCatching {
manager.attachTarget(childSessionId)
manager.publishTrace(
parentSessionId,
"Requested attach for $childSessionId to satisfy the required final presentation policy.",
)
}.onFailure { err ->
Log.w(TAG, "Failed to attach target for $childSessionId", err)
}
}
if (!deferTerminalRollup && shouldUpdateParentSessionState(parentSession.state, rollup.state)) {
runCatching {
manager.updateSessionState(parentSessionId, rollup.state)
}.onFailure { err ->
Log.w(TAG, "Failed to update parent session state for $parentSessionId", err)
}
}
val parentEvents = if (!deferTerminalRollup && (rollup.resultMessage != null || rollup.errorMessage != null)) {
manager.getSessionEvents(parentSessionId)
} else {
emptyList()
}
if (
!deferTerminalRollup &&
rollup.resultMessage != null &&
findLastEventMessage(parentEvents, AgentSessionEvent.TYPE_RESULT) != rollup.resultMessage
) {
runCatching {
manager.publishResult(parentSessionId, rollup.resultMessage)
}.onFailure { err ->
Log.w(TAG, "Failed to publish parent result for $parentSessionId", err)
}
}
if (
!deferTerminalRollup &&
rollup.errorMessage != null &&
findLastEventMessage(parentEvents, AgentSessionEvent.TYPE_ERROR) != rollup.errorMessage
) {
runCatching {
manager.publishError(parentSessionId, rollup.errorMessage)
}.onFailure { err ->
Log.w(TAG, "Failed to publish parent error for $parentSessionId", err)
}
}
}
private fun shouldServeSessionBridge(session: AgentSessionInfo): Boolean {
if (session.targetPackage.isNullOrBlank()) {
return false
}
return !isTerminalSessionState(session.state)
}
private fun shouldUpdateParentSessionState(
currentState: Int,
proposedState: Int,
): Boolean {
if (currentState == proposedState || isTerminalSessionState(currentState)) {
return false
}
if (
(currentState == AgentSessionInfo.STATE_RUNNING || currentState == AgentSessionInfo.STATE_WAITING_FOR_USER) &&
(proposedState == AgentSessionInfo.STATE_CREATED || proposedState == AgentSessionInfo.STATE_QUEUED)
) {
return false
}
return true
}
private fun isTerminalSessionState(state: Int): Boolean {
return when (state) {
AgentSessionInfo.STATE_COMPLETED,
AgentSessionInfo.STATE_CANCELLED,
AgentSessionInfo.STATE_FAILED,
-> true
else -> false
}
}
private fun handleWaitingSession(session: AgentSessionInfo) {
val manager = agentManager ?: return
val events = manager.getSessionEvents(session.sessionId)
val question = findLatestQuestion(events) ?: return
if (!isBridgeQuestion(question)) {
return
}
maybeAutoAnswerGenieQuestion(session, question)
}
private fun maybeAutoAnswerGenieQuestion(
session: AgentSessionInfo,
question: String,
) {
if (!isBridgeQuestion(question)) {
return
}
val questionKey = genieQuestionKey(session.sessionId, question)
if (handledGenieQuestions.contains(questionKey) || !pendingGenieQuestions.add(questionKey)) {
return
}
thread(name = "CodexAgentAutoAnswer-${session.sessionId}") {
Log.i(TAG, "Attempting Agent bridge-answer for ${session.sessionId}")
runCatching {
answerBridgeQuestion(session, question)
handledGenieQuestions.add(questionKey)
AgentQuestionNotifier.cancel(this, session.sessionId)
Log.i(TAG, "Answered bridge question for ${session.sessionId}")
}.onFailure { err ->
Log.i(TAG, "Agent bridge-answer unavailable for ${session.sessionId}: ${err.message}")
}
pendingGenieQuestions.remove(questionKey)
}
}
private fun showOrUpdateSessionNotification(
session: AgentSessionInfo,
notificationToken: String,
notificationText: String,
) {
thread(name = "CodexAgentNotificationShow-${session.sessionId}") {
if (shouldSuppressDelegatedChildNotification(session)) {
AgentQuestionNotifier.suppress(
context = this,
sessionId = session.sessionId,
notificationToken = notificationToken,
)
runCatching {
sessionController.ackSessionNotification(session.sessionId, notificationToken)
}.onFailure { err ->
Log.w(
TAG,
"Failed to ack suppressed child notification sessionId=${session.sessionId} token=$notificationToken",
err,
)
}
return@thread
}
val presentation = buildNotificationPresentation(
session = session,
notificationText = notificationText,
)
val posted = runCatching {
AgentQuestionNotifier.showOrUpdateDelegatedNotification(
context = this,
presentation = presentation,
notificationToken = notificationToken,
)
}.onFailure { err ->
Log.w(
TAG,
"Failed to post delegated notification sessionId=${session.sessionId} token=$notificationToken",
err,
)
}.getOrDefault(false)
if (!posted) {
return@thread
}
runCatching {
sessionController.ackSessionNotification(session.sessionId, notificationToken)
}.onFailure { err ->
Log.w(
TAG,
"Failed to ack delegated notification sessionId=${session.sessionId} token=$notificationToken",
err,
)
}
}
}
private fun buildNotificationPresentation(
session: AgentSessionInfo,
notificationText: String,
): AgentNotificationPresentation {
val plannerQuestion = AgentPlannerQuestionRegistry.latestQuestion(session.sessionId)
val childQuestions = if (plannerQuestion == null && isDirectParentSession(session)) {
loadWaitingChildNotificationQuestions(session)
} else {
emptyList()
}
return AgentNotificationPresentationSelector.select(
sessionId = session.sessionId,
state = session.state,
targetPackage = session.targetPackage,
notificationText = notificationText,
plannerQuestion = plannerQuestion,
childQuestions = childQuestions,
)
}
private fun loadWaitingChildNotificationQuestions(
parentSession: AgentSessionInfo,
): List<AgentNotificationChildQuestion> {
val manager = agentManager ?: return emptyList()
val sessions = runCatching {
manager.getSessions(currentUserId())
}.onFailure { err ->
Log.w(TAG, "Failed to load child sessions for parent notification ${parentSession.sessionId}", err)
}.getOrDefault(emptyList())
return sessions.mapNotNull { childSession ->
if (!isCurrentWaitingChild(parentSession, childSession)) {
return@mapNotNull null
}
val latestQuestion = runCatching {
findLatestQuestion(manager.getSessionEvents(childSession.sessionId))
}.onFailure { err ->
Log.w(TAG, "Failed to load latest question for ${childSession.sessionId}", err)
}.getOrNull() ?: return@mapNotNull null
AgentNotificationChildQuestion(
sessionId = childSession.sessionId,
targetPackage = childSession.targetPackage,
question = latestQuestion,
)
}
}
private fun isCurrentWaitingChild(
parentSession: AgentSessionInfo,
childSession: AgentSessionInfo,
): Boolean {
if (
childSession.parentSessionId != parentSession.sessionId ||
childSession.state != AgentSessionInfo.STATE_WAITING_FOR_USER
) {
return false
}
return parentSession.continuationGeneration == AgentSessionInfo.CONTINUATION_GENERATION_NONE ||
childSession.continuationGeneration == parentSession.continuationGeneration
}
private fun shouldSuppressDelegatedChildNotification(session: AgentSessionInfo): Boolean {
val parentSessionId = session.parentSessionId?.trim()?.ifEmpty { null } ?: return false
val manager = agentManager ?: return false
val parentSession = runCatching {
manager.getSessions(currentUserId()).firstOrNull { candidate ->
candidate.sessionId == parentSessionId
}
}.onFailure { err ->
Log.w(
TAG,
"Failed to resolve parent notification policy for child sessionId=${session.sessionId} parentSessionId=$parentSessionId",
err,
)
}.getOrNull() ?: return false
return isDirectParentSession(parentSession)
}
private fun cancelSessionNotification(
sessionId: String,
notificationToken: String,
reason: Int,
) {
thread(name = "CodexAgentNotificationCancel-$sessionId") {
Log.i(
TAG,
"Cancelling delegated notification sessionId=$sessionId token=$notificationToken reason=${notificationCancelReasonToString(reason)}",
)
AgentQuestionNotifier.cancel(
context = this,
sessionId = sessionId,
notificationToken = notificationToken,
)
}
}
private fun notificationCancelReasonToString(reason: Int): String {
return when (reason) {
NOTIFICATION_CANCEL_REASON_SUPPRESSED -> "SUPPRESSED"
NOTIFICATION_CANCEL_REASON_REMOVED -> "REMOVED"
else -> reason.toString()
}
}
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 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

@@ -1,15 +0,0 @@
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

@@ -1,613 +0,0 @@
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 var agentAnchoredOnly = 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
agentAnchoredOnly = isAgentSessionLauncherIntent(intent)
titleView.text = "New Session"
statusView.visibility = View.GONE
statusView.text = "Loading session…"
startButton.isEnabled = true
unlockTargetSelection()
updatePackageSummary()
if (agentAnchoredOnly) {
titleView.text = "New Codex Session"
lockTargetSelection()
}
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(
SessionPopupActivity.intent(this, incomingSessionId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP),
)
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,
)
}.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 = if (agentAnchoredOnly) {
"Codex will plan across apps and create child Genie sessions as needed."
} else {
"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 isAgentSessionLauncherIntent(intent: Intent?): Boolean {
return intent?.action == Intent.ACTION_MAIN &&
intent.hasCategory(Intent.CATEGORY_LAUNCHER) &&
intent.getStringExtra(AgentManager.EXTRA_SESSION_ID).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

@@ -1,36 +0,0 @@
package com.openai.codex.agent
import android.content.Context
import android.content.Intent
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
object DesktopAttachKeepAliveManager {
private const val TAG = "DesktopAttachKeepAlive"
private val activeConnections = ConcurrentHashMap.newKeySet<String>()
fun acquire(
connectionId: String,
) {
if (!activeConnections.add(connectionId)) {
return
}
Log.i(TAG, "Acquired desktop attach keepalive id=$connectionId count=${activeConnections.size}")
}
fun release(
context: Context,
connectionId: String,
) {
if (!activeConnections.remove(connectionId)) {
return
}
Log.i(TAG, "Released desktop attach keepalive id=$connectionId count=${activeConnections.size}")
if (activeConnections.isEmpty()) {
context.startService(
Intent(context, DesktopAttachKeepAliveService::class.java)
.setAction(DesktopAttachKeepAliveService.ACTION_STOP),
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,33 +0,0 @@
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

@@ -1,68 +0,0 @@
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

@@ -1,436 +0,0 @@
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
}
maybeHandleDebugIntent(intent)
}
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,
)
}
}
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

@@ -1,66 +0,0 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
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 {
val selectedIsTopLevel = selectedSession.sessionId == sourceTopLevelSession.sessionId
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.")
if (sourceTopLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT) {
appendLine("Route this follow-up through the top-level Agent planner; choose delegated Genie targets afresh instead of assuming the previous child target still applies.")
}
appendLine()
appendLine("Previous session context:")
appendLine("- Top-level session: ${sourceTopLevelSession.sessionId}")
if (selectedIsTopLevel) {
appendLine("- Previous focused session: top-level Agent session ${selectedSession.sessionId}")
} else {
appendLine("- Previous focused 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()
if (selectedIsTopLevel) {
appendLine("Recent timeline from the top-level Agent session:")
} else {
appendLine("Recent timeline from the previous focused 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

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

View File

@@ -1,67 +0,0 @@
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

@@ -1,15 +0,0 @@
package com.openai.codex.agent
import android.content.Context
object SessionNotificationCoordinator {
@Suppress("UNUSED_PARAMETER")
fun acknowledgeSessionTree(
context: Context,
sessionController: AgentSessionController,
topLevelSessionId: String,
sessionIds: Collection<String>,
) {
sessionController.acknowledgeSessionUi(topLevelSessionId)
}
}

View File

@@ -1,680 +0,0 @@
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.view.WindowManager
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import kotlin.concurrent.thread
class SessionPopupActivity : Activity() {
companion object {
const val EXTRA_SESSION_ID = "sessionId"
private const val HOME_FOLLOW_UP_SETTLE_TIMEOUT_MS = 2_000L
private const val HOME_FOLLOW_UP_SETTLE_POLL_MS = 50L
fun intent(
context: Context,
sessionId: String,
): Intent {
return Intent(context, SessionPopupActivity::class.java)
.putExtra(EXTRA_SESSION_ID, sessionId)
}
}
private val sessionController by lazy { AgentSessionController(this) }
private var requestedSessionId: String? = null
private var fallbackLaunched = false
private var popupRendered = false
private var refreshInFlight = false
private var sessionListenerRegistered = false
@Volatile
private var answerSubmissionInFlight = false
@Volatile
private var followUpSubmissionInFlight = false
private val sessionListener = object : AgentManager.SessionListener {
override fun onSessionChanged(session: AgentSessionInfo) {
if (answerSubmissionInFlight && session.sessionId == requestedSessionId) {
return
}
if (followUpSubmissionInFlight && session.sessionId == requestedSessionId) {
return
}
if (session.sessionId == requestedSessionId || session.parentSessionId == requestedSessionId) {
refreshPopup(force = true)
}
}
override fun onSessionRemoved(sessionId: String, userId: Int) {
if (answerSubmissionInFlight && sessionId == requestedSessionId) {
return
}
if (followUpSubmissionInFlight && sessionId == requestedSessionId) {
return
}
if (sessionId == requestedSessionId) {
finish()
} else {
refreshPopup(force = true)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedSessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.trim()?.ifEmpty { null }
if (requestedSessionId == null) {
finish()
return
}
setFinishOnTouchOutside(false)
}
override fun onResume() {
super.onResume()
registerSessionListenerIfNeeded()
refreshPopup(force = true)
}
override fun onPause() {
unregisterSessionListenerIfNeeded()
super.onPause()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
requestedSessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.trim()?.ifEmpty { null }
fallbackLaunched = false
popupRendered = false
refreshPopup(force = true)
}
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 refreshPopup(force: Boolean = false) {
if (!force && refreshInFlight) {
return
}
val sessionId = requestedSessionId ?: return
refreshInFlight = true
thread(name = "CodexSessionPopupLoad-$sessionId") {
try {
val session = runCatching {
resolvePopupSession(sessionController.loadSnapshot(sessionId), sessionId)
}.getOrNull()
runOnUiThread {
renderSession(session)
}
} finally {
refreshInFlight = false
}
}
}
private fun resolvePopupSession(
snapshot: AgentSnapshot,
sessionId: String,
): AgentSessionDetails? {
val requestedSession = snapshot.sessions.firstOrNull { session -> session.sessionId == sessionId }
?: snapshot.selectedSession?.takeIf { session -> session.sessionId == sessionId }
?: snapshot.parentSession?.takeIf { session -> session.sessionId == sessionId }
if (requestedSession?.let(::isTopLevelAgentSession) == true) {
if (isQuestionSession(requestedSession) || isResultSession(requestedSession)) {
return requestedSession
}
return snapshot.sessions.firstOrNull { session ->
session.parentSessionId == requestedSession.sessionId &&
isQuestionSession(session)
} ?: requestedSession
}
return requestedSession
}
private fun renderSession(session: AgentSessionDetails?) {
if (session == null) {
finish()
return
}
when {
isQuestionSession(session) -> showQuestionPopup(session)
isResultSession(session) -> showResultPopup(session)
isRunningTargetSession(session) -> openRunningTarget(session)
popupRendered || fallbackLaunched -> finish()
else -> launchFallbackDetail(session.sessionId)
}
}
private fun isQuestionSession(session: AgentSessionDetails): Boolean {
return session.state == AgentSessionInfo.STATE_WAITING_FOR_USER &&
!session.latestQuestion.isNullOrBlank()
}
private fun isResultSession(session: AgentSessionDetails): Boolean {
return when (session.state) {
AgentSessionInfo.STATE_COMPLETED,
AgentSessionInfo.STATE_CANCELLED,
AgentSessionInfo.STATE_FAILED,
-> true
else -> false
}
}
private fun isRunningTargetSession(session: AgentSessionDetails): Boolean {
return SessionTapRouting.shouldOpenRunningTarget(session)
}
private fun showQuestionPopup(session: AgentSessionDetails) {
popupRendered = true
setContentView(R.layout.activity_session_popup)
bindPopupHeader(
session = session,
title = "Codex needs input for ${targetDisplayName(session)}",
body = session.latestQuestion.orEmpty(),
)
val answerInput = findViewById<EditText>(R.id.session_popup_prompt_input)
answerInput.hint = "Answer"
val cancelButton = findViewById<Button>(R.id.session_popup_secondary_button)
val answerButton = findViewById<Button>(R.id.session_popup_primary_button)
cancelButton.text = "Cancel"
answerButton.text = "Answer"
cancelButton.setOnClickListener {
finish()
}
answerButton.setOnClickListener {
submitAnswer(
session = session,
answerInput = answerInput,
submitButton = answerButton,
cancelButton = cancelButton,
)
}
answerInput.requestFocus()
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
}
private fun showResultPopup(session: AgentSessionDetails) {
popupRendered = true
setContentView(R.layout.activity_session_popup)
bindPopupHeader(
session = session,
title = resultTitle(session),
body = resultBody(session),
)
val followUpInput = findViewById<EditText>(R.id.session_popup_prompt_input)
followUpInput.hint = "Follow-up prompt"
val okButton = findViewById<Button>(R.id.session_popup_secondary_button)
val sendButton = findViewById<Button>(R.id.session_popup_primary_button)
okButton.text = "Done"
sendButton.text = "Send"
okButton.setOnClickListener {
dismissResultPopup(session, okButton, sendButton)
}
sendButton.setOnClickListener {
submitFollowUpPrompt(
session = session,
promptInput = followUpInput,
sendButton = sendButton,
okButton = okButton,
)
}
}
private fun resultTitle(session: AgentSessionDetails): String {
val targetDisplayName = targetDisplayName(session)
return when (session.state) {
AgentSessionInfo.STATE_COMPLETED -> "Codex finished $targetDisplayName"
AgentSessionInfo.STATE_CANCELLED -> "Codex cancelled $targetDisplayName"
AgentSessionInfo.STATE_FAILED -> "Codex hit an issue in $targetDisplayName"
else -> "Codex session for $targetDisplayName"
}
}
private fun resultBody(session: AgentSessionDetails): String {
return when {
!session.latestResult.isNullOrBlank() -> session.latestResult
!session.latestError.isNullOrBlank() -> session.latestError
session.state == AgentSessionInfo.STATE_CANCELLED -> "This session was cancelled."
else -> "No final message was recorded for this session."
}
}
private fun submitAnswer(
session: AgentSessionDetails,
answerInput: EditText,
submitButton: Button,
cancelButton: Button,
) {
val answer = answerInput.text.toString().trim()
if (answer.isEmpty()) {
answerInput.error = "Enter an answer"
return
}
answerSubmissionInFlight = true
submitButton.isEnabled = false
cancelButton.isEnabled = false
thread(name = "CodexSessionPopupAnswer-${session.sessionId}") {
runCatching {
sessionController.answerQuestion(
session.sessionId,
answer,
session.parentSessionId,
)
SessionNotificationCoordinator.acknowledgeSessionTree(
context = this,
sessionController = sessionController,
topLevelSessionId = session.parentSessionId ?: session.sessionId,
sessionIds = listOf(session.sessionId),
)
}.onFailure { err ->
runOnUiThread {
answerSubmissionInFlight = false
submitButton.isEnabled = true
cancelButton.isEnabled = true
Toast.makeText(
this,
"Failed to answer question: ${err.message}",
Toast.LENGTH_SHORT,
).show()
}
}.onSuccess {
runOnUiThread {
finish()
}
}
}
}
private fun dismissResultPopup(
session: AgentSessionDetails,
okButton: Button,
sendButton: Button,
) {
AgentQuestionNotifier.cancel(this, session.sessionId)
if (isTopLevelHomeSession(session)) {
consumeHomeResultPresentation(
session = session,
okButton = okButton,
sendButton = sendButton,
)
return
}
if (isTopLevelAgentSession(session)) {
consumeAgentParentResultPresentation(
sessionId = session.sessionId,
okButton = okButton,
sendButton = sendButton,
)
return
}
if (session.parentSessionId != null) {
cancelAgentSession(
sessionId = session.sessionId,
okButton = okButton,
sendButton = sendButton,
)
return
}
finish()
}
private fun submitFollowUpPrompt(
session: AgentSessionDetails,
promptInput: EditText,
sendButton: Button,
okButton: Button,
) {
val prompt = promptInput.text.toString().trim()
if (prompt.isEmpty()) {
promptInput.error = "Enter a follow-up prompt"
return
}
followUpSubmissionInFlight = true
sendButton.isEnabled = false
okButton.isEnabled = false
thread(name = "CodexSessionPopupFollowUp-${session.sessionId}") {
runCatching {
startFollowUpPrompt(session, prompt)
AgentQuestionNotifier.cancel(this, session.sessionId)
}.onFailure { err ->
runOnUiThread {
followUpSubmissionInFlight = false
sendButton.isEnabled = true
okButton.isEnabled = true
Toast.makeText(
this,
"Failed to send follow-up: ${err.message}",
Toast.LENGTH_SHORT,
).show()
}
}.onSuccess {
runOnUiThread {
followUpSubmissionInFlight = false
finish()
}
}
}
}
private fun startFollowUpPrompt(
session: AgentSessionDetails,
prompt: String,
) {
val snapshot = sessionController.loadSnapshot(session.sessionId)
val selectedSession = resolvePopupSession(snapshot, session.sessionId) ?: session
val topLevelSession = selectedSession.parentSessionId
?.let { parentSessionId ->
snapshot.sessions.firstOrNull { candidate ->
candidate.sessionId == parentSessionId
}
}
?: selectedSession
if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_HOME) {
startHomeFollowUp(
topLevelSession = topLevelSession,
prompt = SessionContinuationPromptBuilder.build(
sourceTopLevelSession = topLevelSession,
selectedSession = selectedSession,
prompt = prompt,
),
)
return
}
val continuationSession = when {
session.sessionId == topLevelSession.sessionId -> topLevelSession
selectedSession.parentSessionId == topLevelSession.sessionId -> selectedSession
else -> topLevelSession
}
AgentSessionLauncher.continueSessionInPlace(
sourceTopLevelSession = topLevelSession,
selectedSession = continuationSession,
prompt = prompt,
sessionController = sessionController,
)
}
private fun startHomeFollowUp(
topLevelSession: AgentSessionDetails,
prompt: String,
) {
val targetPackage = checkNotNull(topLevelSession.targetPackage) {
"No target package available for follow-up"
}
val executionSettings = sessionController.executionSettingsForSession(topLevelSession.sessionId)
consumePreviousHomeSessionPresentation(topLevelSession)
val newSessionId = AgentSessionLauncher.startSession(
context = this,
request = LaunchSessionRequest(
prompt = prompt,
targetPackage = targetPackage,
model = executionSettings.model,
reasoningEffort = executionSettings.reasoningEffort,
),
sessionController = sessionController,
).parentSessionId
val deadline = System.currentTimeMillis() + HOME_FOLLOW_UP_SETTLE_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
val followUpSession = runCatching {
resolvePopupSession(sessionController.loadSnapshot(newSessionId), newSessionId)
}.getOrNull()
if (followUpSession != null) {
if (
followUpSession.targetDetached ||
followUpSession.targetPresentation != AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
) {
return
}
}
Thread.sleep(HOME_FOLLOW_UP_SETTLE_POLL_MS)
}
}
private fun consumePreviousHomeSessionPresentation(
topLevelSession: AgentSessionDetails,
) {
runCatching {
consumeTerminalHomeSessionPresentation(topLevelSession)
}.onFailure { err ->
if (!isUnknownSessionError(err)) {
throw err
}
}
}
private fun consumeHomeResultPresentation(
session: AgentSessionDetails,
okButton: Button,
sendButton: Button,
) {
okButton.isEnabled = false
sendButton.isEnabled = false
thread(name = "CodexSessionPopupConsume-${session.sessionId}") {
runCatching {
consumeTerminalHomeSessionPresentation(session)
}.onFailure { err ->
runOnUiThread {
okButton.isEnabled = true
sendButton.isEnabled = true
Toast.makeText(
this,
"Failed to clear result badge: ${err.message}",
Toast.LENGTH_SHORT,
).show()
}
}.onSuccess {
val nextQuestionSession = runCatching {
val topLevelSessionId = session.parentSessionId ?: session.sessionId
resolvePopupSession(sessionController.loadSnapshot(topLevelSessionId), topLevelSessionId)
?.takeIf(::isQuestionSession)
}.getOrNull()
runOnUiThread {
answerSubmissionInFlight = false
if (nextQuestionSession != null) {
renderSession(nextQuestionSession)
} else {
finish()
}
}
}
}
}
private fun consumeTerminalHomeSessionPresentation(session: AgentSessionDetails) {
if (session.state == AgentSessionInfo.STATE_COMPLETED) {
sessionController.consumeCompletedHomeSession(session.sessionId)
} else {
sessionController.consumeHomeSessionPresentation(session.sessionId)
}
if (session.targetDetached) {
sessionController.closeDetachedTarget(session.sessionId)
}
}
private fun consumeAgentParentResultPresentation(
sessionId: String,
okButton: Button,
sendButton: Button,
) {
okButton.isEnabled = false
sendButton.isEnabled = false
thread(name = "CodexSessionPopupConsumeAgentParent-$sessionId") {
runCatching {
sessionController.consumeHomeSessionPresentation(sessionId)
}.onSuccess {
runOnUiThread {
finish()
}
}.onFailure { err ->
if (isUnknownSessionError(err)) {
runOnUiThread {
finish()
}
return@thread
}
runOnUiThread {
okButton.isEnabled = true
sendButton.isEnabled = true
Toast.makeText(
this,
"Failed to clear session icon: ${err.message}",
Toast.LENGTH_SHORT,
).show()
}
}
}
}
private fun cancelAgentSessionTree(
sessionId: String,
okButton: Button,
sendButton: Button,
) {
okButton.isEnabled = false
sendButton.isEnabled = false
thread(name = "CodexSessionPopupCancelTree-$sessionId") {
runCatching {
sessionController.cancelSessionTree(sessionId)
}.onFailure { err ->
runOnUiThread {
okButton.isEnabled = true
sendButton.isEnabled = true
Toast.makeText(
this,
"Failed to clear session state: ${err.message}",
Toast.LENGTH_SHORT,
).show()
}
}.onSuccess {
runOnUiThread {
finish()
}
}
}
}
private fun cancelAgentSession(
sessionId: String,
okButton: Button,
sendButton: Button,
) {
okButton.isEnabled = false
sendButton.isEnabled = false
thread(name = "CodexSessionPopupCancel-$sessionId") {
runCatching {
sessionController.cancelSession(sessionId)
}.onFailure { err ->
runOnUiThread {
okButton.isEnabled = true
sendButton.isEnabled = true
Toast.makeText(
this,
"Failed to clear session state: ${err.message}",
Toast.LENGTH_SHORT,
).show()
}
}.onSuccess {
runOnUiThread {
finish()
}
}
}
}
private fun isTopLevelAgentSession(session: AgentSessionDetails): Boolean {
return session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
session.parentSessionId == null
}
private fun isTopLevelHomeSession(session: AgentSessionDetails): Boolean {
return session.anchor == AgentSessionInfo.ANCHOR_HOME &&
session.parentSessionId == null
}
private fun launchFallbackDetail(sessionId: String) {
fallbackLaunched = true
startActivity(
Intent(this, SessionDetailActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(SessionDetailActivity.EXTRA_SESSION_ID, sessionId),
)
finish()
}
private fun openRunningTarget(session: AgentSessionDetails) {
fallbackLaunched = true
thread(name = "CodexSessionPopupAttachTarget-${session.sessionId}") {
runCatching {
if (session.targetDetached) {
sessionController.showDetachedTarget(session.sessionId)
} else {
sessionController.attachTarget(session.sessionId)
}
}.onFailure {
runOnUiThread {
launchFallbackDetail(session.sessionId)
}
}.onSuccess {
runOnUiThread {
finish()
}
}
}
}
private fun bindPopupHeader(
session: AgentSessionDetails,
title: String,
body: String,
) {
findViewById<ImageView>(R.id.session_popup_icon)
.setImageDrawable(targetIcon(session))
findViewById<TextView>(R.id.session_popup_title).text = title
findViewById<TextView>(R.id.session_popup_body_text).text = body
}
private fun targetIcon(session: AgentSessionDetails): Drawable? {
val targetPackage = session.targetPackage?.trim()?.ifEmpty { null }
?: return getDrawable(android.R.drawable.ic_dialog_info)
return runCatching {
InstalledAppCatalog.resolveInstalledApp(this, sessionController, targetPackage).icon
}.getOrNull() ?: getDrawable(android.R.drawable.ic_dialog_info)
}
private fun targetDisplayName(session: AgentSessionDetails): String {
val targetPackage = session.targetPackage?.trim()?.ifEmpty { null }
?: return "Codex Agent"
return runCatching {
InstalledAppCatalog.resolveInstalledApp(this, sessionController, targetPackage).label
}.getOrDefault(targetPackage)
}
private fun isUnknownSessionError(err: Throwable): Boolean {
return err is IllegalArgumentException &&
err.message?.contains("Unknown session", ignoreCase = true) == true
}
}

View File

@@ -1,97 +0,0 @@
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, but hidden background completion is preferred unless the user asked for a visible app.",
),
;
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 -> {
"Prefer finishing DETACHED_HIDDEN so the app does not come to the front by default. Attach or show the target only when the delegated objective explicitly asks for a user-visible app state or clearly implies that the final UI should be visible. 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

@@ -1,40 +0,0 @@
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

@@ -1,147 +0,0 @@
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.os.Bundle
import android.util.Log
import kotlin.concurrent.thread
class SessionRouterActivity : Activity() {
private val sessionController by lazy { AgentSessionController(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
routeIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
routeIntent(intent)
}
private fun routeIntent(intent: Intent?) {
val sessionId = intent
?.getStringExtra(AgentManager.EXTRA_SESSION_ID)
?.trim()
?.ifEmpty { null }
if (sessionId == null) {
finish()
return
}
thread(name = "CodexSessionRouter-$sessionId") {
val destination = runCatching { resolveDestination(sessionId) }
.getOrElse { err ->
Log.w(TAG, "Failed to route framework session $sessionId", err)
Destination.Popup(sessionId)
}
runOnUiThread {
openDestination(destination)
}
}
}
private fun resolveDestination(sessionId: String): Destination {
val snapshot = sessionController.loadSnapshot(sessionId)
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId }
?: snapshot.selectedSession?.takeIf { it.sessionId == sessionId }
?: snapshot.parentSession?.takeIf { it.sessionId == sessionId }
?: return Destination.Popup(sessionId)
val hasChildren = snapshot.sessions.any { it.parentSessionId == sessionId }
if (isStandaloneHomeDraftSession(session, hasChildren)) {
val targetPackage = checkNotNull(session.targetPackage)
return Destination.CreateHomeDraft(
sessionId = session.sessionId,
targetPackage = targetPackage,
)
}
if (isRunningTargetSession(session)) {
return Destination.OpenRunningTarget(session)
}
return Destination.Popup(sessionId)
}
private fun openDestination(destination: Destination) {
when (destination) {
is Destination.CreateHomeDraft -> {
startActivity(
CreateSessionActivity.existingHomeSessionIntent(
context = this,
sessionId = destination.sessionId,
targetPackage = destination.targetPackage,
initialSettings = sessionController.executionSettingsForSession(destination.sessionId),
).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK),
)
finish()
}
is Destination.OpenRunningTarget -> {
openRunningTarget(destination.session)
}
is Destination.Popup -> {
startActivity(
SessionPopupActivity.intent(this, destination.sessionId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP),
)
finish()
}
}
}
private fun openRunningTarget(session: AgentSessionDetails) {
thread(name = "CodexSessionRouterOpenTarget-${session.sessionId}") {
val opened = runCatching {
if (session.targetDetached) {
sessionController.showDetachedTarget(session.sessionId)
} else {
sessionController.attachTarget(session.sessionId)
}
}.isSuccess
runOnUiThread {
if (!opened) {
startActivity(
SessionPopupActivity.intent(this, session.sessionId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP),
)
}
finish()
}
}
}
private fun isStandaloneHomeDraftSession(
session: AgentSessionDetails,
hasChildren: Boolean,
): Boolean {
return session.anchor == AgentSessionInfo.ANCHOR_HOME &&
session.state == AgentSessionInfo.STATE_CREATED &&
!hasChildren &&
!session.targetPackage.isNullOrBlank()
}
private fun isRunningTargetSession(session: AgentSessionDetails): Boolean {
return SessionTapRouting.shouldOpenRunningTarget(session)
}
private sealed interface Destination {
data class CreateHomeDraft(
val sessionId: String,
val targetPackage: String,
) : Destination
data class OpenRunningTarget(
val session: AgentSessionDetails,
) : Destination
data class Popup(
val sessionId: String,
) : Destination
}
companion object {
private const val TAG = "CodexSessionRouter"
}
}

View File

@@ -1,15 +0,0 @@
package com.openai.codex.agent
import android.app.agent.AgentSessionInfo
object AgentSessionAnchorValues {
const val AGENT = AgentSessionInfo.ANCHOR_AGENT
const val HOME = AgentSessionInfo.ANCHOR_HOME
}
internal object SessionTapRouting {
fun shouldOpenRunningTarget(session: AgentSessionDetails): Boolean {
return !session.targetPackage.isNullOrBlank() &&
session.state == AgentSessionStateValues.RUNNING
}
}

View File

@@ -1,116 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -1,9 +0,0 @@
<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

@@ -1,9 +0,0 @@
<?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

@@ -1,9 +0,0 @@
<?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

@@ -1,9 +0,0 @@
<?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

@@ -1,5 +0,0 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="20dp" />
<solid android:color="@color/codex_session_popup_primary_button_background" />
</shape>

View File

@@ -1,8 +0,0 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="20dp" />
<solid android:color="@android:color/transparent" />
<stroke
android:width="1dp"
android:color="@color/codex_session_popup_secondary_button_border" />
</shape>

View File

@@ -1,118 +0,0 @@
<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

@@ -1,78 +0,0 @@
<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

@@ -1,228 +0,0 @@
<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

@@ -1,71 +0,0 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/session_popup_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null" />
<TextView
android:id="@+id/session_popup_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="Codex"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Large"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_marginTop="12dp"
android:fadeScrollbars="false"
android:overScrollMode="ifContentScrolls"
android:scrollbars="vertical">
<TextView
android:id="@+id/session_popup_body_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true" />
</ScrollView>
<EditText
android:id="@+id/session_popup_prompt_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Reply"
android:inputType="textCapSentences|textMultiLine"
android:minLines="2" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal">
<Button
android:id="@+id/session_popup_secondary_button"
style="@style/CodexSessionPopupSecondaryButton"
android:text="Cancel" />
<Button
android:id="@+id/session_popup_primary_button"
style="@style/CodexSessionPopupPrimaryButton"
android:layout_marginStart="8dp"
android:text="Send" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,60 +0,0 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Codex needs input"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Large"
android:textStyle="bold" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:maxHeight="220dp"
android:fadeScrollbars="false"
android:overScrollMode="ifContentScrolls"
android:scrollbars="vertical">
<TextView
android:id="@+id/session_popup_question_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true" />
</ScrollView>
<EditText
android:id="@+id/session_popup_answer_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Answer"
android:inputType="textCapSentences|textMultiLine"
android:minLines="2" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal">
<Button
android:id="@+id/session_popup_cancel_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Cancel" />
<Button
android:id="@+id/session_popup_submit_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="Answer" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,36 +0,0 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/session_popup_result_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Codex Result"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Large"
android:textStyle="bold" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="320dp"
android:layout_marginTop="12dp"
android:fadeScrollbars="false"
android:overScrollMode="ifContentScrolls"
android:scrollbars="vertical">
<TextView
android:id="@+id/session_popup_result_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true" />
</ScrollView>
<Button
android:id="@+id/session_popup_ok_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="OK" />
</LinearLayout>

View File

@@ -1,80 +0,0 @@
<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

@@ -1,37 +0,0 @@
<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

@@ -1,5 +0,0 @@
<?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

@@ -1,5 +0,0 @@
<?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

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -1,8 +0,0 @@
<resources>
<color name="ic_launcher_background">#FFFFFFFF</color>
<color name="ic_session_manager_background">#00000000</color>
<color name="codex_session_popup_primary_button_background">#5F6867</color>
<color name="codex_session_popup_primary_button_text">#FFFFFFFF</color>
<color name="codex_session_popup_secondary_button_border">#C4C7C5</color>
<color name="codex_session_popup_secondary_button_text">#5F6867</color>
</resources>

View File

@@ -1,4 +0,0 @@
<resources>
<string name="app_name">Codex</string>
<string name="app_name_manager">Session Manager</string>
</resources>

View File

@@ -1,46 +0,0 @@
<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>
<style name="CodexSessionPopupTheme" parent="@android:style/Theme.DeviceDefault.Light.Dialog.NoActionBar">
<item name="android:windowCloseOnTouchOutside">false</item>
<item name="android:windowMinWidthMajor">90%</item>
<item name="android:windowMinWidthMinor">90%</item>
</style>
<style name="CodexSessionRouterTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
<item name="android:windowDisablePreview">true</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
<style name="CodexSessionPopupButton">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">40dp</item>
<item name="android:layout_weight">1</item>
<item name="android:minHeight">40dp</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
<item name="android:paddingStart">20dp</item>
<item name="android:paddingEnd">20dp</item>
<item name="android:stateListAnimator">@null</item>
<item name="android:textAllCaps">false</item>
<item name="android:textSize">14sp</item>
</style>
<style name="CodexSessionPopupPrimaryButton" parent="@style/CodexSessionPopupButton">
<item name="android:background">@drawable/session_popup_primary_button_background</item>
<item name="android:textColor">@color/codex_session_popup_primary_button_text</item>
</style>
<style name="CodexSessionPopupSecondaryButton" parent="@style/CodexSessionPopupButton">
<item name="android:background">@drawable/session_popup_secondary_button_background</item>
<item name="android:textColor">@color/codex_session_popup_secondary_button_text</item>
</style>
</resources>

View File

@@ -1,127 +0,0 @@
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

@@ -1,97 +0,0 @@
package com.openai.codex.agent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AgentNotificationPresentationSelectorTest {
@Test
fun plannerQuestionWinsOverChildQuestion() {
val presentation = select(
plannerQuestion = "Which task should I do?",
childQuestions = listOf(clockQuestion()),
)
assertEquals(parentSessionId, presentation.notificationSessionId)
assertEquals(parentSessionId, presentation.contentSessionId)
assertEquals(parentSessionId, presentation.answerSessionId)
assertNull(presentation.answerParentSessionId)
assertNull(presentation.targetPackage)
assertEquals("Which task should I do?", presentation.notificationText)
}
@Test
fun waitingChildQuestionUsesChildIdentityAndAnswerDestination() {
val presentation = select(
notificationText = "Codex needs input for Codex Agent",
childQuestions = listOf(clockQuestion()),
)
assertEquals(parentSessionId, presentation.notificationSessionId)
assertEquals(parentSessionId, presentation.contentSessionId)
assertEquals(clockSessionId, presentation.answerSessionId)
assertEquals(parentSessionId, presentation.answerParentSessionId)
assertEquals(clockPackage, presentation.targetPackage)
assertEquals("What time should I set the alarm for?", presentation.notificationText)
}
@Test
fun bridgeQuestionsAreNotSurfacedAsChildQuestions() {
val presentation = select(
notificationText = "__codex_bridge__ {\"method\":\"getRuntimeStatus\"}",
childQuestions = listOf(
clockQuestion(
question = "__codex_bridge__ {\"method\":\"getRuntimeStatus\"}",
),
),
)
assertEquals(parentSessionId, presentation.answerSessionId)
assertNull(presentation.answerParentSessionId)
assertNull(presentation.targetPackage)
assertEquals("Codex needs input.", presentation.notificationText)
}
@Test
fun fallbackUsesParentNotificationText() {
val presentation = select(notificationText = "Planner result is ready")
assertEquals(parentSessionId, presentation.notificationSessionId)
assertEquals(parentSessionId, presentation.contentSessionId)
assertEquals(parentSessionId, presentation.answerSessionId)
assertNull(presentation.answerParentSessionId)
assertNull(presentation.targetPackage)
assertEquals("Planner result is ready", presentation.notificationText)
}
private fun select(
notificationText: String = "Parent needs input",
plannerQuestion: String? = null,
childQuestions: List<AgentNotificationChildQuestion> = emptyList(),
): AgentNotificationPresentation {
return AgentNotificationPresentationSelector.select(
sessionId = parentSessionId,
state = AgentSessionStateValues.WAITING_FOR_USER,
targetPackage = null,
notificationText = notificationText,
plannerQuestion = plannerQuestion,
childQuestions = childQuestions,
)
}
private fun clockQuestion(
question: String = "What time should I set the alarm for?",
): AgentNotificationChildQuestion {
return AgentNotificationChildQuestion(
sessionId = clockSessionId,
targetPackage = clockPackage,
question = question,
)
}
private companion object {
const val parentSessionId = "planner-session"
const val clockSessionId = "clock-session"
const val clockPackage = "com.android.deskclock"
}
}

View File

@@ -1,76 +0,0 @@
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

@@ -1,140 +0,0 @@
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

@@ -1,80 +0,0 @@
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

@@ -1,53 +0,0 @@
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

@@ -1,99 +0,0 @@
package com.openai.codex.agent
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionTapRoutingTest {
@Test
fun opensTopLevelRunningTargets() {
val session = sessionDetails(
parentSessionId = null,
anchor = AgentSessionAnchorValues.HOME,
state = AgentSessionStateValues.RUNNING,
)
assertTrue(SessionTapRouting.shouldOpenRunningTarget(session))
}
@Test
fun opensParentedRunningHomeTargets() {
val session = sessionDetails(
parentSessionId = "planner-session",
anchor = AgentSessionAnchorValues.HOME,
state = AgentSessionStateValues.RUNNING,
)
assertTrue(SessionTapRouting.shouldOpenRunningTarget(session))
}
@Test
fun opensParentedRunningAgentTargets() {
val session = sessionDetails(
parentSessionId = "planner-session",
anchor = AgentSessionAnchorValues.AGENT,
state = AgentSessionStateValues.RUNNING,
)
assertTrue(SessionTapRouting.shouldOpenRunningTarget(session))
}
@Test
fun keepsHomeQuestionsAndResultsInCodexUi() {
val waiting = sessionDetails(
parentSessionId = "planner-session",
anchor = AgentSessionAnchorValues.HOME,
state = AgentSessionStateValues.WAITING_FOR_USER,
)
val completed = sessionDetails(
parentSessionId = "planner-session",
anchor = AgentSessionAnchorValues.HOME,
state = AgentSessionStateValues.COMPLETED,
)
assertFalse(SessionTapRouting.shouldOpenRunningTarget(waiting))
assertFalse(SessionTapRouting.shouldOpenRunningTarget(completed))
}
@Test
fun keepsAgentSessionTapsInCodexUi() {
val session = sessionDetails(
parentSessionId = null,
anchor = AgentSessionAnchorValues.AGENT,
state = AgentSessionStateValues.RUNNING,
)
assertFalse(SessionTapRouting.shouldOpenRunningTarget(session))
}
private fun sessionDetails(
parentSessionId: String?,
anchor: Int,
state: Int,
): AgentSessionDetails {
return AgentSessionDetails(
sessionId = "session-id",
parentSessionId = parentSessionId,
targetPackage = if (anchor == AgentSessionAnchorValues.AGENT && parentSessionId == null) {
null
} else {
"com.example.target"
},
anchor = anchor,
state = state,
stateLabel = state.toString(),
targetPresentation = AgentTargetPresentationValues.ATTACHED,
targetPresentationLabel = "ATTACHED",
targetRuntime = null,
targetRuntimeLabel = "NONE",
targetDetached = true,
continuationGeneration = 0,
requiredFinalPresentationPolicy = null,
latestQuestion = null,
latestResult = null,
latestError = null,
latestTrace = null,
timeline = "",
)
}
}

View File

@@ -1,61 +0,0 @@
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

@@ -1,52 +0,0 @@
# 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.
- Keep the paired app hidden by default. Prefer completing in `DETACHED_HIDDEN` and report the outcome back to the Agent instead of surfacing the app UI.
- 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.
- Do not call framework show/attach controls just to inspect state when hidden frame capture or ordinary shell inspection is enough. Show or attach the app only when the user asked for a visible app handoff, when the request clearly implies one, or when asking a user-facing question would benefit from visible UI context.
- 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

@@ -1,81 +0,0 @@
package com.openai.codex.bridge
import java.io.File
object CodexHomeRetention {
const val DEFAULT_RETAINED_SESSION_HOMES: Int = 10
private const val ACTIVE_MARKER = ".codex-active-session"
private const val STALE_ACTIVE_MARKER_MS = 6 * 60 * 60 * 1000L
data class PruneResult(
val deletedHomeNames: List<String>,
val failedHomeNames: Map<String, String>,
)
fun markActive(codexHome: File) {
codexHome.mkdirs()
File(codexHome, ACTIVE_MARKER).writeText(System.currentTimeMillis().toString())
}
fun clearActive(codexHome: File) {
File(codexHome, ACTIVE_MARKER).delete()
}
fun pruneSessionHomes(
root: File,
keepHomeNames: Set<String>,
retainedSessionHomes: Int = DEFAULT_RETAINED_SESSION_HOMES,
nowMillis: Long = System.currentTimeMillis(),
): PruneResult {
val children = root.listFiles()
?.filter(File::isDirectory)
?.filter { it.name.isNotBlank() }
.orEmpty()
if (children.isEmpty()) {
return PruneResult(deletedHomeNames = emptyList(), failedHomeNames = emptyMap())
}
val candidates = children.filterNot { home ->
home.name in keepHomeNames || hasFreshActiveMarker(home, nowMillis)
}
val retainedCandidateNames = candidates
.sortedWith(compareByDescending<File> { it.lastModified() }.thenBy(File::getName))
.take(retainedSessionHomes.coerceAtLeast(0))
.mapTo(mutableSetOf(), File::getName)
val deletedHomeNames = mutableListOf<String>()
val failedHomeNames = linkedMapOf<String, String>()
candidates
.filterNot { it.name in retainedCandidateNames }
.forEach { home ->
runCatching {
home.deleteRecursively()
}.onSuccess { deleted ->
if (deleted) {
deletedHomeNames += home.name
} else {
failedHomeNames[home.name] = "deleteRecursively returned false"
}
}.onFailure { err ->
failedHomeNames[home.name] = err.message ?: err::class.java.simpleName
}
}
return PruneResult(
deletedHomeNames = deletedHomeNames,
failedHomeNames = failedHomeNames,
)
}
private fun hasFreshActiveMarker(home: File, nowMillis: Long): Boolean {
val marker = File(home, ACTIVE_MARKER)
if (!marker.isFile) {
return false
}
val markerTime = marker.readText()
.trim()
.toLongOrNull()
?: marker.lastModified()
return nowMillis - markerTime < STALE_ACTIVE_MARKER_MS
}
}

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