Add Android session create-attach and clear commands

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-03-30 15:16:32 -07:00
parent c122ff283f
commit 40b635e24f
4 changed files with 246 additions and 53 deletions

View File

@@ -250,6 +250,7 @@ object DesktopBridgeServer {
"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 -> {
@@ -347,6 +348,58 @@ object DesktopBridgeServer {
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)

View File

@@ -66,12 +66,16 @@ enum AndroidSessionsSubcommand {
Read(AndroidSessionIdArgs),
/// Create a new Android session draft without starting it.
Create(AndroidCreateSessionArgs),
/// Create a new Android session draft and immediately attach to it.
CreateAttach(AndroidCreateAttachSessionArgs),
/// Start a previously created Android session draft.
Start(AndroidStartSessionArgs),
/// Answer a waiting Android session question.
Answer(AndroidAnswerSessionArgs),
/// Cancel an Android session.
Cancel(AndroidSessionIdArgs),
/// Delete every Android session on the target device.
Clear(AndroidClearSessionsArgs),
/// Attach the target surface for an Android session.
AttachTarget(AndroidSessionIdArgs),
/// Attach the Codex TUI to a live Android session runtime.
@@ -119,6 +123,28 @@ struct AndroidCreateSessionArgs {
reasoning_effort: Option<String>,
}
#[derive(Debug, Args)]
struct AndroidCreateAttachSessionArgs {
#[clap(flatten)]
device: AndroidDeviceArgs,
/// Optional target package to create as an app-scoped HOME draft.
#[arg(long = "target-package", value_name = "PACKAGE")]
target_package: Option<String>,
/// Optional model override stored on the draft session.
#[arg(long = "model", value_name = "MODEL")]
model: Option<String>,
/// Optional reasoning effort override stored on the draft session.
#[arg(long = "reasoning-effort", value_name = "EFFORT")]
reasoning_effort: Option<String>,
/// Disable alternate screen mode for the attached TUI.
#[arg(long = "no-alt-screen", default_value_t = false)]
no_alt_screen: bool,
}
#[derive(Debug, Args)]
struct AndroidStartSessionArgs {
#[clap(flatten)]
@@ -147,6 +173,16 @@ struct AndroidAnswerSessionArgs {
answer: String,
}
#[derive(Debug, Args)]
struct AndroidClearSessionsArgs {
#[clap(flatten)]
device: AndroidDeviceArgs,
/// Required safeguard for deleting every Android session on the device.
#[arg(long = "all", required = true, default_value_t = false)]
all: bool,
}
#[derive(Debug, Args)]
struct AndroidAttachSessionArgs {
#[clap(flatten)]
@@ -186,21 +222,42 @@ pub async fn run(
Ok(None)
}
AndroidSessionsSubcommand::Create(args) => {
let bridge = AndroidBridgeClient::connect(args.device.serial).await?;
let bridge = AndroidBridgeClient::connect(args.device.serial.clone()).await?;
print_json(
bridge
.rpc(
"androidSession/create",
json!({
"targetPackage": args.target_package,
"model": args.model,
"reasoningEffort": args.reasoning_effort,
}),
)
.await?,
create_session_draft(
&bridge,
args.target_package.as_ref(),
args.model.as_ref(),
args.reasoning_effort.as_ref(),
)
.await?,
)?;
Ok(None)
}
AndroidSessionsSubcommand::CreateAttach(args) => {
let device_serial = args.device.serial.clone();
let bridge = AndroidBridgeClient::connect(device_serial.clone()).await?;
let create_result = create_session_draft(
&bridge,
args.target_package.as_ref(),
args.model.as_ref(),
args.reasoning_effort.as_ref(),
)
.await?;
let session_id = required_string(&create_result, "sessionId")?;
let exit_info = attach_session_to_runtime(
bridge,
device_serial,
session_id,
root_config_overrides,
root_interactive,
/*interactive_args*/ None,
args.no_alt_screen,
arg0_paths,
)
.await?;
Ok(Some(exit_info))
}
AndroidSessionsSubcommand::Start(args) => {
let bridge = AndroidBridgeClient::connect(args.device.serial).await?;
print_json(
@@ -243,6 +300,15 @@ pub async fn run(
)?;
Ok(None)
}
AndroidSessionsSubcommand::Clear(args) => {
let bridge = AndroidBridgeClient::connect(args.device.serial).await?;
print_json(
bridge
.rpc("androidSession/clear", json!({ "all": args.all }))
.await?,
)?;
Ok(None)
}
AndroidSessionsSubcommand::AttachTarget(args) => {
let bridge = AndroidBridgeClient::connect(args.device.serial).await?;
print_json(
@@ -258,57 +324,97 @@ pub async fn run(
AndroidSessionsSubcommand::Attach(args) => {
let device_serial = args.device.serial.clone();
let bridge = AndroidBridgeClient::connect(device_serial.clone()).await?;
let attach = bridge
.rpc(
"androidSession/attach",
json!({ "sessionId": args.session_id }),
)
.await?;
let thread_id = required_string(&attach, "threadId")?;
let websocket_path = required_string(&attach, "websocketPath")?;
let remote = format!("ws://127.0.0.1:{}{websocket_path}", bridge.local_port());
let remote_auth_token = bridge.auth_token().to_string();
start_desktop_attach_keepalive(device_serial.as_deref()).await?;
let mut interactive = root_interactive;
interactive.resume_picker = false;
interactive.resume_last = false;
interactive.resume_session_id = Some(thread_id);
interactive.resume_show_all = false;
interactive.resume_include_non_interactive = false;
interactive.fork_picker = false;
interactive.fork_last = false;
interactive.fork_session_id = None;
interactive.fork_show_all = false;
super::merge_interactive_cli_flags(&mut interactive, args.interactive);
super::prepend_config_flags(
&mut interactive.config_overrides,
let no_alt_screen = args.interactive.no_alt_screen;
let exit_info = attach_session_to_runtime(
bridge,
device_serial,
args.session_id,
root_config_overrides,
);
interactive
.config_overrides
.raw_overrides
.push("features.tui_app_server=true".to_string());
interactive
.config_overrides
.raw_overrides
.push("disable_paste_burst=true".to_string());
let exit_info = super::run_interactive_tui_with_remote_auth_token(
interactive,
Some(remote),
Some(remote_auth_token),
root_interactive,
Some(args.interactive),
no_alt_screen,
arg0_paths,
)
.await
.map_err(anyhow::Error::from)?;
drop(bridge);
.await?;
Ok(Some(exit_info))
}
},
}
}
async fn create_session_draft(
bridge: &AndroidBridgeClient,
target_package: Option<&String>,
model: Option<&String>,
reasoning_effort: Option<&String>,
) -> anyhow::Result<Value> {
bridge
.rpc(
"androidSession/create",
json!({
"targetPackage": target_package,
"model": model,
"reasoningEffort": reasoning_effort,
}),
)
.await
}
async fn attach_session_to_runtime(
bridge: AndroidBridgeClient,
device_serial: Option<String>,
session_id: String,
root_config_overrides: CliConfigOverrides,
root_interactive: TuiCli,
interactive_args: Option<TuiCli>,
no_alt_screen: bool,
arg0_paths: Arg0DispatchPaths,
) -> anyhow::Result<AppExitInfo> {
let attach = bridge
.rpc("androidSession/attach", json!({ "sessionId": session_id }))
.await?;
let thread_id = required_string(&attach, "threadId")?;
let websocket_path = required_string(&attach, "websocketPath")?;
let remote = format!("ws://127.0.0.1:{}{websocket_path}", bridge.local_port());
let remote_auth_token = bridge.auth_token().to_string();
start_desktop_attach_keepalive(device_serial.as_deref()).await?;
let mut interactive = root_interactive;
interactive.resume_picker = false;
interactive.resume_last = false;
interactive.resume_session_id = Some(thread_id);
interactive.resume_show_all = false;
interactive.resume_include_non_interactive = false;
interactive.fork_picker = false;
interactive.fork_last = false;
interactive.fork_session_id = None;
interactive.fork_show_all = false;
if let Some(interactive_args) = interactive_args {
super::merge_interactive_cli_flags(&mut interactive, interactive_args);
}
interactive.no_alt_screen = interactive.no_alt_screen || no_alt_screen;
super::prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
interactive
.config_overrides
.raw_overrides
.push("features.tui_app_server=true".to_string());
interactive
.config_overrides
.raw_overrides
.push("disable_paste_burst=true".to_string());
let exit_info = super::run_interactive_tui_with_remote_auth_token(
interactive,
Some(remote),
Some(remote_auth_token),
arg0_paths,
)
.await
.map_err(anyhow::Error::from)?;
drop(bridge);
Ok(exit_info)
}
struct AndroidBridgeClient {
auth_token: String,
forward_guard: AdbForwardGuard,

View File

@@ -1971,6 +1971,28 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Android(_))));
}
#[test]
fn android_sessions_create_attach_subcommand_parses() {
let cli = MultitoolCli::try_parse_from([
"codex",
"android",
"sessions",
"create-attach",
"--target-package",
"com.android.settings",
"--no-alt-screen",
])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Android(_))));
}
#[test]
fn android_sessions_clear_all_subcommand_parses() {
let cli = MultitoolCli::try_parse_from(["codex", "android", "sessions", "clear", "--all"])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Android(_))));
}
#[test]
fn reject_remote_mode_for_non_interactive_subcommands() {
let err = reject_remote_mode_for_subcommand(Some("127.0.0.1:4500"), None, "exec")

View File

@@ -35,10 +35,12 @@ available in the device UI:
- `codex android sessions list`
- `codex android sessions create`
- `codex android sessions create-attach`
- `codex android sessions start`
- `codex android sessions read`
- `codex android sessions answer`
- `codex android sessions cancel`
- `codex android sessions clear --all`
- `codex android sessions attach-target`
- `codex android sessions attach`
@@ -81,9 +83,11 @@ The desktop entrypoint is:
- `codex android sessions list [--serial SERIAL]`
- `codex android sessions read [--serial SERIAL] SESSION_ID`
- `codex android sessions create [--serial SERIAL] [--target-package PACKAGE] [--model MODEL] [--reasoning-effort EFFORT]`
- `codex android sessions create-attach [--serial SERIAL] [--target-package PACKAGE] [--model MODEL] [--reasoning-effort EFFORT]`
- `codex android sessions start [--serial SERIAL] SESSION_ID --prompt "..."`
- `codex android sessions answer [--serial SERIAL] SESSION_ID --answer "..."`
- `codex android sessions cancel [--serial SERIAL] SESSION_ID`
- `codex android sessions clear [--serial SERIAL] --all`
- `codex android sessions attach-target [--serial SERIAL] SESSION_ID`
- `codex android sessions attach [--serial SERIAL] SESSION_ID`
The attach flow forces `disable_paste_burst=true` for that TUI session so scripted
@@ -115,10 +119,18 @@ Draft-session behavior:
- `sessions start` remains the non-interactive path for draft launch when the
caller wants to provide the first prompt on the command line instead of
through an attached TUI.
- `sessions create-attach` is the convenience path for
`sessions create` followed immediately by `sessions attach` on the new draft.
- HOME-scoped drafts require a session-UI lease while they remain
`STATE_CREATED`; the desktop bridge holds that lease on behalf of the CLI
until idle attach, `sessions start`, or `sessions cancel`.
Session cleanup behavior:
- `sessions clear --all` is an explicit destructive command that cancels and
removes every visible Agent and Genie session on the target device until the
framework session list is empty or no further progress is possible.
## Android-side design
- Add an exported bootstrap broadcast receiver that starts the bridge server and