Add app-server background terminal process APIs (#26041)

## Summary

Codex Apps needs app-server as the source of truth for chat-started
background terminals instead of guessing from local process trees.

This PR adds experimental v2 APIs to list and terminate background
terminals for a loaded thread using app-server process ids, so clients
can manage background terminals without local PID discovery.

## Changes

- `thread/backgroundTerminals/list` returns paginated background
terminal records with `itemId`, app-server `processId`, `command`,
`cwd`, nullable `osPid`, nullable `cpuPercent`, and nullable `rssKb`.
- `thread/backgroundTerminals/terminate` terminates one running
background terminal by app-server `processId` and returns whether a
process was terminated.
- Background terminal list and terminate operations use unified-exec
process manager state as their source of truth.
This commit is contained in:
Eric Traut
2026-06-10 11:18:09 -07:00
committed by GitHub
parent 2704ecea9a
commit a1a8807e9d
16 changed files with 773 additions and 36 deletions
@@ -1126,6 +1126,16 @@ impl MessageProcessor {
.thread_background_terminals_clean(&request_id, params)
.await
}
ClientRequest::ThreadBackgroundTerminalsList { params, .. } => {
self.thread_processor
.thread_background_terminals_list(params)
.await
}
ClientRequest::ThreadBackgroundTerminalsTerminate { params, .. } => {
self.thread_processor
.thread_background_terminals_terminate(params)
.await
}
ClientRequest::ThreadRollback { params, .. } => {
self.thread_processor
.thread_rollback(&request_id, params)
@@ -171,8 +171,13 @@ use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadArchivedNotification;
use codex_app_server_protocol::ThreadBackgroundTerminal;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsListParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsTerminateParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsTerminateResponse;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
@@ -569,6 +569,24 @@ impl ThreadRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_background_terminals_list(
&self,
params: ThreadBackgroundTerminalsListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_background_terminals_list_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_background_terminals_terminate(
&self,
params: ThreadBackgroundTerminalsTerminateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_background_terminals_terminate_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_rollback(
&self,
request_id: &ConnectionRequestId,
@@ -1725,6 +1743,54 @@ impl ThreadRequestProcessor {
Ok(ThreadBackgroundTerminalsCleanResponse {})
}
async fn thread_background_terminals_list_inner(
&self,
params: ThreadBackgroundTerminalsListParams,
) -> Result<ThreadBackgroundTerminalsListResponse, JSONRPCErrorError> {
let ThreadBackgroundTerminalsListParams {
thread_id,
cursor,
limit,
} = params;
let (_, thread) = self.load_thread(&thread_id).await?;
let terminals = thread
.list_background_terminals()
.await
.into_iter()
.map(|terminal| ThreadBackgroundTerminal {
item_id: terminal.item_id,
process_id: terminal.process_id,
command: terminal.command,
cwd: terminal.cwd,
os_pid: None,
cpu_percent: None,
rss_kb: None,
})
.collect::<Vec<_>>();
let (data, next_cursor) = paginate_background_terminals(&terminals, cursor, limit)?;
Ok(ThreadBackgroundTerminalsListResponse { data, next_cursor })
}
async fn thread_background_terminals_terminate_inner(
&self,
params: ThreadBackgroundTerminalsTerminateParams,
) -> Result<ThreadBackgroundTerminalsTerminateResponse, JSONRPCErrorError> {
let ThreadBackgroundTerminalsTerminateParams {
thread_id,
process_id,
} = params;
let process_id = process_id.parse::<i32>().map_err(|err| {
invalid_request(format!("invalid background terminal process id: {err}"))
})?;
let (_, thread) = self.load_thread(&thread_id).await?;
let terminated = thread.terminate_background_terminal(process_id).await;
Ok(ThreadBackgroundTerminalsTerminateResponse { terminated })
}
async fn thread_shell_command_inner(
&self,
request_id: &ConnectionRequestId,
@@ -4250,6 +4316,34 @@ fn build_thread_from_snapshot(
}
}
fn paginate_background_terminals(
terminals: &[ThreadBackgroundTerminal],
cursor: Option<String>,
limit: Option<u32>,
) -> Result<(Vec<ThreadBackgroundTerminal>, Option<String>), JSONRPCErrorError> {
let start = match cursor {
Some(cursor) => {
let cursor = cursor
.parse::<i32>()
.map_err(|err| invalid_request(format!("invalid cursor: {err}")))?;
terminals
.iter()
.position(|terminal| {
terminal
.process_id
.parse::<i32>()
.is_ok_and(|process_id| process_id > cursor)
})
.unwrap_or(terminals.len())
}
None => 0,
};
let effective_limit = limit.unwrap_or(terminals.len() as u32).max(1) as usize;
let end = start.saturating_add(effective_limit).min(terminals.len());
let next_cursor = (end < terminals.len()).then(|| terminals[end - 1].process_id.clone());
Ok((terminals[start..end].to_vec(), next_cursor))
}
fn build_thread_from_loaded_snapshot(
thread_id: ThreadId,
config_snapshot: &ThreadConfigSnapshot,
@@ -36,6 +36,65 @@ mod thread_list_cwd_filter_tests {
}
}
mod background_terminal_pagination_tests {
use super::super::paginate_background_terminals;
use codex_app_server_protocol::ThreadBackgroundTerminal;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
fn terminal(process_id: &str) -> ThreadBackgroundTerminal {
let cwd = if cfg!(windows) { r"C:\tmp" } else { "/tmp" };
ThreadBackgroundTerminal {
item_id: format!("item-{process_id}"),
process_id: process_id.to_string(),
command: format!("command-{process_id}"),
cwd: AbsolutePathBuf::from_absolute_path(cwd).expect("absolute cwd"),
os_pid: None,
cpu_percent: None,
rss_kb: None,
}
}
#[test]
fn paginates_with_process_id_cursor() {
let terminals = vec![
terminal("1"),
terminal("2"),
terminal("3"),
terminal("4"),
terminal("5"),
];
let (data, next_cursor) =
paginate_background_terminals(&terminals, /*cursor*/ None, Some(2))
.expect("valid page");
assert_eq!(data, vec![terminal("1"), terminal("2")]);
assert_eq!(next_cursor, Some("2".to_string()));
let first_cursor = next_cursor;
let terminals_without_anchor = vec![terminal("1"), terminal("3"), terminal("4")];
let (data, next_cursor) =
paginate_background_terminals(&terminals_without_anchor, first_cursor.clone(), Some(2))
.expect("valid page");
assert_eq!(data, vec![terminal("3"), terminal("4")]);
assert_eq!(next_cursor, None);
let (data, next_cursor) =
paginate_background_terminals(&terminals, first_cursor, Some(2)).expect("valid page");
assert_eq!(data, vec![terminal("3"), terminal("4")]);
assert_eq!(next_cursor, Some("4".to_string()));
assert!(
paginate_background_terminals(&terminals, Some("missing".to_string()), Some(1))
.is_err()
);
}
}
mod thread_processor_behavior_tests {
async fn forked_from_id_from_rollout(path: &Path) -> Option<String> {
codex_core::read_session_meta_line(path)