Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e4cb80f
feat(app-server): add background terminal process APIs
etraut-openai Jun 3, 2026
3a5856f
Simplify background terminal list API
etraut-openai Jun 3, 2026
353ed1b
Reduce background terminal test churn
etraut-openai Jun 3, 2026
0d37f06
Restore background terminal metadata fields
etraut-openai Jun 3, 2026
b90d036
Restore background terminal start time
etraut-openai Jun 4, 2026
4d1537d
codex: address PR review feedback (#26041)
etraut-openai Jun 4, 2026
e0f9c32
codex: address PR review feedback (#26041)
etraut-openai Jun 4, 2026
64dc0d2
codex: address PR review feedback (#26041)
etraut-openai Jun 4, 2026
cce9ca7
codex: address PR review feedback (#26041)
etraut-openai Jun 4, 2026
33a8e24
Merge branch 'main' into etraut/background-terminal-apis
etraut-openai Jun 4, 2026
61c5d9c
codex: stabilize background terminal termination tests
etraut-openai Jun 4, 2026
e78df1e
codex: make background terminal tests deterministic
etraut-openai Jun 4, 2026
ff2201a
Merge branch 'main' into etraut/background-terminal-apis
etraut-openai Jun 9, 2026
b592328
Simplify background terminal APIs
etraut-openai Jun 9, 2026
b949dc3
codex: address background terminal review feedback
etraut-openai Jun 10, 2026
7daf140
codex: simplify background terminal tests
etraut-openai Jun 10, 2026
dcd1b73
Address background terminal review comments
etraut-openai Jun 10, 2026
28b3f1e
Merge branch 'main' into etraut/background-terminal-apis
etraut-openai Jun 10, 2026
cac8884
codex: address PR review feedback (#26041)
etraut-openai Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions codex-rs/app-server-protocol/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ use ts_rs::TS;
pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] =
&["RemoteControlClient", "RemoteControlClientsListOrder"];
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
"RemoteControlClient",
"RemoteControlClientsListOrder",
"ThreadBackgroundTerminal",
];
const SPECIAL_DEFINITIONS: &[&str] = &[
"ClientNotification",
"ClientRequest",
Expand Down
60 changes: 60 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,18 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadBackgroundTerminalsCleanResponse,
},
#[experimental("thread/backgroundTerminals/list")]
ThreadBackgroundTerminalsList => "thread/backgroundTerminals/list" {
Comment thread
etraut-openai marked this conversation as resolved.
params: v2::ThreadBackgroundTerminalsListParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadBackgroundTerminalsListResponse,
},
#[experimental("thread/backgroundTerminals/terminate")]
ThreadBackgroundTerminalsTerminate => "thread/backgroundTerminals/terminate" {
params: v2::ThreadBackgroundTerminalsTerminateParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadBackgroundTerminalsTerminateResponse,
},
ThreadRollback => "thread/rollback" {
params: v2::ThreadRollbackParams,
serialization: thread_id(params.thread_id),
Expand Down Expand Up @@ -2936,6 +2948,54 @@ mod tests {
Ok(())
}

#[test]
fn serialize_thread_background_terminals_list() -> Result<()> {
let request = ClientRequest::ThreadBackgroundTerminalsList {
request_id: RequestId::Integer(8),
params: v2::ThreadBackgroundTerminalsListParams {
thread_id: "thr_123".to_string(),
cursor: None,
limit: None,
},
};
assert_eq!(
json!({
"method": "thread/backgroundTerminals/list",
"id": 8,
"params": {
"threadId": "thr_123",
"cursor": null,
"limit": null
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}

#[test]
fn serialize_thread_background_terminals_terminate() -> Result<()> {
let request = ClientRequest::ThreadBackgroundTerminalsTerminate {
request_id: RequestId::Integer(8),
params: v2::ThreadBackgroundTerminalsTerminateParams {
thread_id: "thr_123".to_string(),
process_id: "42".to_string(),
},
};
assert_eq!(
json!({
"method": "thread/backgroundTerminals/terminate",
"id": 8,
"params": {
"threadId": "thr_123",
"processId": "42"
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}

#[test]
fn serialize_thread_realtime_start() -> Result<()> {
let request = ClientRequest::ThreadRealtimeStart {
Expand Down
51 changes: 51 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,57 @@ pub struct ThreadBackgroundTerminalsCleanParams {
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsCleanResponse {}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsListParams {
Comment thread
etraut-openai marked this conversation as resolved.
Comment thread
etraut-openai marked this conversation as resolved.
pub thread_id: String,
/// Opaque pagination cursor returned by a previous call.
#[ts(optional = nullable)]
pub cursor: Option<String>,
/// Optional page size.
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
Comment thread
etraut-openai marked this conversation as resolved.

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminal {
pub item_id: String,
pub process_id: String,
pub command: String,
pub cwd: AbsolutePathBuf,
pub os_pid: Option<u32>,
pub cpu_percent: Option<f64>,
pub rss_kb: Option<u64>,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsListResponse {
pub data: Vec<ThreadBackgroundTerminal>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// If None, there are no more items to return.
pub next_cursor: Option<String>,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsTerminateParams {
pub thread_id: String,
pub process_id: String,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsTerminateResponse {
pub terminated: bool,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
Expand Down
28 changes: 28 additions & 0 deletions codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ Example with notification opt-out:
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids.
- `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
Expand Down Expand Up @@ -872,6 +874,32 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi
{ "id": 35, "result": {} }
```

### Example: List and terminate background terminals

Use `thread/backgroundTerminals/list` to inspect running background terminals associated with a loaded thread. The `backgroundTerminals` segment intentionally follows the existing `thread/backgroundTerminals/clean` method. The returned `processId` is the app-server process id; host OS metadata is nullable. The request accepts the standard `cursor` and `limit` pagination fields. When `nextCursor` is non-null, pass it as `cursor` to fetch the next page.

```json
{ "method": "thread/backgroundTerminals/list", "id": 36, "params": { "threadId": "thr_123" } }
{ "id": 36, "result": { "data": [
{
"itemId": "item_456",
"processId": "42",
"command": "python3 -m http.server",
"cwd": "/workspace",
"osPid": null,
"cpuPercent": null,
"rssKb": null
}
], "nextCursor": null } }
```

Use `thread/backgroundTerminals/terminate` to terminate one running background terminal by that `processId`.

```json
{ "method": "thread/backgroundTerminals/terminate", "id": 37, "params": { "threadId": "thr_123", "processId": "42" } }
{ "id": 37, "result": { "terminated": true } }
```

### Example: Steer an active turn

Use `turn/steer` to append additional user input to the currently active regular turn. This does
Expand Down
10 changes: 10 additions & 0 deletions codex-rs/app-server/src/message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/app-server/src/request_processors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
94 changes: 94 additions & 0 deletions codex-rs/app-server/src/request_processors/thread_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading