Add thread archive CLI commands (#25021)

## Problem

Saved threads can already be archived through app-server RPCs, but the
command line did not expose direct archive or unarchive commands.

## Solution

Add `codex archive <thread>` and `codex unarchive <thread>`, resolving
UUIDs or exact thread names before calling the existing `thread/archive`
and `thread/unarchive` RPCs. The commands support scoped remote flags so
callers can target remote app-server endpoints when archiving or
unarchiving threads.

This also fixes a long-standing bug in `codex resume <thread id>` and
`codex fork <thread id>` that I found when testing the new commands.
These operations shouldn't be allowed on archived sessions. They now
fail with an error that tells the user to run `codex unarchive <thread
id>` first.

## Verification

Added app-server coverage for rejecting archived thread resume by id and
checking that the error includes the matching `codex unarchive <thread
id>` command.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-05-29 23:37:26 -07:00
1 parent e0435afb72
commit 3e7baa00e4
8 files changed
+697 -47

No files matched your search

+27 -8
View File
@@ -652,6 +652,31 @@ fn active_turn_steer_race(error: &TypedRequestError) -> Option<ActiveTurnSteerRa
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })
}
fn session_start_error(
action: &str,
target_session: &SessionTarget,
err: color_eyre::eyre::Report,
) -> color_eyre::eyre::Report {
if let Some(message) = archived_session_guidance(&err) {
return color_eyre::eyre::eyre!("{message}");
}
let target_label = target_session.display_label();
color_eyre::eyre::eyre!("Failed to {action} session from {target_label}: {err}")
}
fn archived_session_guidance(err: &color_eyre::eyre::Report) -> Option<String> {
let err = err.to_string();
let message = &err[err.find("session ")?..];
if !message.contains(" is archived. Run `codex unarchive ") {
return None;
}
let message = message
.split_once(" (code ")
.map_or(message, |(message, _)| message);
Some(message.to_string())
}
impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
@@ -872,10 +897,7 @@ impl App {
let resumed = app_server
.resume_thread(config.clone(), target_session.thread_id)
.await
.wrap_err_with(|| {
let target_label = target_session.display_label();
format!("Failed to resume session from {target_label}")
})?;
.map_err(|err| session_start_error("resume", &target_session, err))?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
@@ -913,10 +935,7 @@ impl App {
let forked = app_server
.fork_thread(config.clone(), target_session.thread_id)
.await
.wrap_err_with(|| {
let target_label = target_session.display_label();
format!("Failed to fork session from {target_label}")
})?;
.map_err(|err| session_start_error("fork", &target_session, err))?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),