diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index c7a3504fb..4eec3e9d4 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2565,6 +2565,13 @@ "null" ] }, + "cwd": { + "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", + "type": [ + "string", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index eba43fc5e..581869a60 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15334,6 +15334,13 @@ "null" ] }, + "cwd": { + "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", + "type": [ + "string", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index dd4c7a4f1..fa88ea3f7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -39,6 +39,13 @@ "null" ] }, + "cwd": { + "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", + "type": [ + "string", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index c54f323f5..7fdadbad6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -31,4 +31,9 @@ sourceKinds?: Array | null, * Optional archived filter; when set to true, only archived threads are returned. * If false or null, only non-archived threads are returned. */ -archived?: boolean | null, }; +archived?: boolean | null, +/** + * Optional cwd filter; when set, only threads whose session cwd exactly + * matches this path are returned. + */ +cwd?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f885a33be..9bbd8fe6f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1708,6 +1708,10 @@ pub struct ThreadListParams { /// If false or null, only non-archived threads are returned. #[ts(optional = nullable)] pub archived: Option, + /// Optional cwd filter; when set, only threads whose session cwd exactly + /// matches this path are returned. + #[ts(optional = nullable)] + pub cwd: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ae450fbed..39ddc73c6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -117,7 +117,7 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` and auto-subscribes you to turn/item events for the new thread. -- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering. +- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, and `cwd` filters. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. - `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success. @@ -221,6 +221,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). +- `cwd` — restrict results to threads whose session cwd exactly matches this path. Example: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2b5f52a15..bbd8bfa81 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -266,6 +266,13 @@ use crate::thread_state::ThreadStateManager; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; +struct ThreadListFilters { + model_providers: Option>, + source_kinds: Option>, + archived: bool, + cwd: Option, +} + // Duration before a ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); @@ -2430,6 +2437,7 @@ impl CodexMessageProcessor { model_providers, source_kinds, archived, + cwd, } = params; let requested_page_size = limit @@ -2444,10 +2452,13 @@ impl CodexMessageProcessor { .list_threads_common( requested_page_size, cursor, - model_providers, - source_kinds, core_sort_key, - archived.unwrap_or(false), + ThreadListFilters { + model_providers, + source_kinds, + archived: archived.unwrap_or(false), + cwd: cwd.map(PathBuf::from), + }, ) .await { @@ -3221,10 +3232,13 @@ impl CodexMessageProcessor { .list_threads_common( requested_page_size, cursor, - model_providers, - None, CoreThreadSortKey::UpdatedAt, - false, + ThreadListFilters { + model_providers, + source_kinds: None, + archived: false, + cwd: None, + }, ) .await { @@ -3242,11 +3256,15 @@ impl CodexMessageProcessor { &self, requested_page_size: usize, cursor: Option, - model_providers: Option>, - source_kinds: Option>, sort_key: CoreThreadSortKey, - archived: bool, + filters: ThreadListFilters, ) -> Result<(Vec, Option), JSONRPCErrorError> { + let ThreadListFilters { + model_providers, + source_kinds, + archived, + cwd, + } = filters; let mut cursor_obj: Option = match cursor.as_ref() { Some(cursor_str) => { Some(parse_cursor(cursor_str).ok_or_else(|| JSONRPCErrorError { @@ -3327,6 +3345,9 @@ impl CodexMessageProcessor { if source_kind_filter .as_ref() .is_none_or(|filter| source_kind_matches(&summary.source, filter)) + && cwd + .as_ref() + .is_none_or(|expected_cwd| &summary.cwd == expected_cwd) { filtered.push(summary); if filtered.len() >= remaining { diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 1e415600a..cee88a18d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -17,6 +17,8 @@ use codex_app_server_protocol::ThreadSourceKind; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; @@ -66,6 +68,7 @@ async fn list_threads_with_sort( model_providers: providers, source_kinds, archived, + cwd: None, }) .await?; let resp: JSONRPCResponse = timeout( @@ -127,6 +130,26 @@ fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> { Ok(()) } +fn set_rollout_cwd(path: &Path, cwd: &Path) -> Result<()> { + let content = fs::read_to_string(path)?; + let mut lines: Vec = content.lines().map(str::to_string).collect(); + let first_line = lines + .first_mut() + .ok_or_else(|| anyhow::anyhow!("rollout at {} is empty", path.display()))?; + let mut rollout_line: RolloutLine = serde_json::from_str(first_line)?; + let RolloutItem::SessionMeta(mut session_meta_line) = rollout_line.item else { + return Err(anyhow::anyhow!( + "rollout at {} does not start with session metadata", + path.display() + )); + }; + session_meta_line.meta.cwd = cwd.to_path_buf(); + rollout_line.item = RolloutItem::SessionMeta(session_meta_line); + *first_line = serde_json::to_string(&rollout_line)?; + fs::write(path, lines.join("\n") + "\n")?; + Ok(()) +} + #[tokio::test] async fn thread_list_basic_empty() -> Result<()> { let codex_home = TempDir::new()?; @@ -300,6 +323,63 @@ async fn thread_list_respects_provider_filter() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_respects_cwd_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let filtered_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "filtered", + Some("mock_provider"), + None, + )?; + let unfiltered_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T11-00-00", + "2025-01-02T11:00:00Z", + "unfiltered", + Some("mock_provider"), + None, + )?; + + let target_cwd = codex_home.path().join("target-cwd"); + fs::create_dir_all(&target_cwd)?; + set_rollout_cwd( + rollout_path(codex_home.path(), "2025-01-02T10-00-00", &filtered_id).as_path(), + &target_cwd, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: Some(target_cwd.to_string_lossy().into_owned()), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadListResponse { data, next_cursor } = to_response::(resp)?; + + assert_eq!(next_cursor, None); + assert_eq!(data.len(), 1); + assert_eq!(data[0].id, filtered_id); + assert_ne!(data[0].id, unfiltered_id); + assert_eq!(data[0].cwd, target_cwd); + + Ok(()) +} + #[tokio::test] async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> { let codex_home = TempDir::new()?; @@ -1107,6 +1187,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + cwd: None, }) .await?; let error: JSONRPCError = timeout( diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index cacb5a773..7cf80e3d8 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -180,6 +180,7 @@ impl AppServerClient { model_providers: None, source_kinds: None, archived: None, + cwd: None, }, }; self.send(&request)?; diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 293ecb7ec..73512c03c 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -81,9 +81,9 @@ Interrupt a running turn: `interruptConversation`. List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`. -For v2 threads, use `thread/list` with `archived: true` to list archived rollouts and -`thread/unarchive` to restore them to the active sessions directory (it returns the restored -thread summary). +For v2 threads, use `thread/list` with filters such as `archived: true` or `cwd: "/path"` to +narrow results, and `thread/unarchive` to restore archived rollouts to the active sessions +directory (it returns the restored thread summary). ## Models