mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Add Android session create-attach and clear commands
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user