Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 1 addition & 2 deletions docs/agent-resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ Live validation on 2026-09-28 was performed with the official macOS arm64 Codex

The [recorded terminal fixtures](../tests/corpus/codex_0158_terminal/README.md) preserve resumed-history and clipboard output from those runs. Offline replay checks the recorded display and OSC 52 forwarding; it does not run the notifier or execute save/load. The separate `tests/daemon_resume.rs` integration tests exercise session resume with stub agents.

Live validation on 2026-10-04 was performed with codex-cli 0.160.0 over nine TUI sessions. Three thread IDs were reported from one process: the unsaved title thread, a sub-agent, and the root. The title thread's notification arrived before the root's in three sessions and after it in six, and no rollout appeared for it even minutes later. For a root or sub-agent turn, the thread's rollout header was on disk at least 2.5 s before its notification, and `task_complete` was recorded 21–256 ms before it. When resuming the title thread, "No saved session found" was reported; for the sub-agent, "cannot resume an unloaded multi-agent v2 sub-agent through its parent" was reported. With the notify override alone, `⚠ 1 warning` was displayed in the footer; with both overrides, none was displayed. With an unknown `features.*` key, startup completed with an unrecognized-setting warning. No Codex version predating `daemon_auto_start` was tested. On the same date, 493 local rollout headers from Codex 0.135.0 through 0.160.0 were examined: `id` and `session_id` were present in each. In Codex 0.140.0 and 0.141.0, a sub-agent's own ID was stored as its `session_id`; that format is rejected.
Live validation on 2026-10-04 was performed with codex-cli 0.160.0 over nine TUI sessions. Three thread IDs were reported from one process: the unsaved title thread, a sub-agent, and the root. The title thread's notification arrived before the root's in three sessions and after it in six, and no rollout appeared for it even minutes later. For a root or sub-agent turn, the thread's rollout header was on disk at least 2.5 s before its notification, and `task_complete` was recorded 21–256 ms before it. When resuming the title thread, "No saved session found" was reported; for the sub-agent, "cannot resume an unloaded multi-agent v2 sub-agent through its parent" was reported. With the notify override alone, `⚠ 1 warning` was displayed in the footer; with both overrides, none was displayed. With an unknown `features.*` key, startup completed with an unrecognized-setting warning. No Codex version predating `daemon_auto_start` was tested.

### `grok`

Expand All @@ -129,7 +129,6 @@ At `agent_end`, after every turn, report two changes without dedicated events: s
As a result:

- For a fresh `omp` task, no ID is captured until the end of the first turn. If you save before then, no ID is stored, so you start fresh on reload.
- Before omp 18.3.2, `ctx.agent` is unavailable, so no session ID is captured: an `omp` task is always saved without an ID.
- After switching or branching to a session without a file on disk, continue using the previous ID until the end of the next turn.

Write each report to a temporary file beside the capture file, then rename it over the destination to avoid reads of partial payloads. Capture is best-effort: return immediately for an empty capture path and ignore write errors.
Expand Down
16 changes: 1 addition & 15 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ A session file is a JSON object with three fields: `version` for the format vers
- Values are ordered lists, loaded in order. Each task is started in its own PTY under the specified directory.
- Directories are serialized alphabetically, with entry order preserved within each directory.

The `version` field must be an integer from 1 through the newest format supported by the running `fleetcom`. A missing field means version 1. Invalid or unsupported versions are refused on load, with the file's value and supported version in the error. A version 2 file read by an older `fleetcom` is refused by that same gate.
The `version` field must be an integer from 1 through the newest format supported by the running `fleetcom`. A missing field means version 1. A version 1 file loads with every entry literal, including `claude` and `claude --resume '<uuid>'`, and they are written back as literals on save; to track such a task's conversation, launch it from the Agent page and save again. Invalid or unsupported versions are refused on load, with the file's value and supported version in the error. A version 2 file read by an older `fleetcom` is refused by that same gate.

The schema is identified by shape, independently of the version. With an object-valued `dirs`, the wrapped form above is used. A flat map is also accepted, with directories as top-level keys and entry arrays as values. In that form, an array-valued key named `dirs` remains a directory entry, but a top-level `version` member is always the format version, never a directory. Flat-map files are listed by filename stem because no name is stored. On save, the wrapped form is written, without a stored-name collision check.

Expand All @@ -60,20 +60,6 @@ After validation, group and display names are stripped of control characters and

Use the string form for commands with neither a group nor a name. String and object entries can appear in the same directory array.

### Loading version 1 files

In format 1, instrumented tasks were stored as command strings: a bare program word, or the word followed by its resume selector and a single-quoted UUID. When loading a file with `version` 1 or no version field, convert exactly those two forms to managed entries:

| Version 1 entry | Loads as |
| -- | -- |
| `claude`, `codex`, `grok`, or `omp` alone | managed, fresh conversation |
| `claude --resume '<uuid>'`, `codex resume '<uuid>'`, `grok --resume '<uuid>'`, or `omp --resume '<uuid>'`, with or without the quotes | managed, resuming that ID |
| anything else | literal, unchanged |

Tabs and repeated spaces between tokens are still accepted. Commands with paths (`/usr/local/bin/claude`, `~/bin/claude`) are loaded as literals to preserve your chosen binary instead of substituting one found on `PATH`; managed resume tracking is then unavailable. Commands with extra arguments, prompts, other resume spellings, or shell syntax remain literal, as before. No conversion is applied to version 2 files: a literal `claude` stays literal.

Recovery snapshots are rewritten continually in the new format; named sessions are converted on the next save. This conversion will be removed one release after format 2 ships. After that, all entries in unconverted version 1 files will be loaded as literals.

## Recovery

The current task set is automatically snapshotted under `recovery/` inside the session directory. A separate file is written for each daemon (or `--foreground` core), named for its start time and process ID. After each write, the new file and files whose process IDs are still live are protected; among the remaining files, the nine newest names are retained. A shared recovery directory can therefore contain more than ten snapshots while multiple writers are live.
Expand Down
34 changes: 13 additions & 21 deletions src/harness/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@
//! replace `$FLEETCOM_CAPTURE_FILE` with `{reason, sessionId, sessionFile,
//! cwd}` JSON. Write `<capture file>.<pid>.tmp` beside the capture file and
//! rename it over the destination to avoid reads of partial payloads.
//! Report only the top-level session (`ctx.agent.kind` is `"main"`) and
//! only when `sessionFile` exists on disk. On omp older than 18.3.2,
//! `ctx.agent` is absent, so write nothing. Skip unset or empty capture
//! paths and ignore all errors.
//! Report only the top-level session (`ctx.agent.kind` is `"main"`), since
//! sub-agents share the handlers and their IDs are not resumable, and only
//! when `sessionFile` exists on disk. Skip unset or empty capture paths and
//! ignore all errors.

use std::{
fs, io,
Expand Down Expand Up @@ -89,9 +89,8 @@ exec "$@"
/// Report only an ID usable with `omp --resume`:
///
/// - Only the top-level session's ID. The same handlers are registered for
/// sub-agent sessions, with the child's ID in their session manager.
/// - None on an omp older than 18.3.2. `ctx.agent` is absent there, so the
/// top-level session cannot be told from a sub-agent.
/// sub-agent sessions, with the child's ID in their session manager, and a
/// sub-agent's ID is not resumable.
/// - Only after the session file exists on disk. For a fresh session, first
/// report at `agent_end`.
///
Expand All @@ -106,7 +105,7 @@ function write(ctx, reason) {
const path = process.env.FLEETCOM_CAPTURE_FILE;
if (!path) return;
// These handlers are also registered for sub-agent sessions, which cannot
// be resumed by ID. Without `ctx.agent` (before 18.3.2), report nothing.
// be resumed by ID: report only the top-level session.
if (ctx.agent?.kind !== "main") return;
// The session file is created after the first assistant message. Until
// then, the ID cannot be found through `omp --resume`.
Expand Down Expand Up @@ -350,22 +349,21 @@ mod tests {
/// Stand-in for omp's extension host. Load the module, collect its
/// registrations, and fire one event with a context built from the
/// arguments `<event> <agent kind> <session id> <session file>`. An empty
/// kind means no `ctx.agent`, as before omp 18.3.2; an empty file means
/// `undefined` from `getSessionFile()`. Print the registered event names
/// in registration order.
/// file means `undefined` from `getSessionFile()`. Print the registered
/// event names in registration order.
const OMP_HOST_DRIVER: &str = r#"import register from "./omp-capture.mjs";

const handlers = {};
register({ on: (event, handler) => (handlers[event] = handler) });
const [event, kind, sessionId, sessionFile] = process.argv.slice(2);
const ctx = {
cwd: "/work/proj",
agent: { kind },
sessionManager: {
getSessionId: () => sessionId,
getSessionFile: () => sessionFile || undefined,
},
};
if (kind) ctx.agent = { kind };
handlers[event]({ type: event }, ctx);
console.log(Object.keys(handlers).join(" "));
"#;
Expand Down Expand Up @@ -928,15 +926,9 @@ console.log(Object.keys(handlers).join(" "));
}
assert!(!orphan.parent().unwrap().exists());

// Use an existing file for the sub-agent and absent-`ctx.agent` cases
// to test rejection by agent kind alone. For the top-level cases,
// omit the file on disk.
for (kind, file) in [
("sub", session.as_str()),
("", session.as_str()),
("main", unwritten),
("main", ""),
] {
// Use an existing file for the sub-agent case to test rejection by
// agent kind alone. For the top-level cases, omit the file on disk.
for (kind, file) in [("sub", session.as_str()), ("main", unwritten), ("main", "")] {
for event in ["session_start", "agent_end"] {
fire(event, kind, ID, file, Some(&cap));
assert!(!cap.exists(), "{event} {kind:?} {file:?} must not report");
Expand Down
30 changes: 5 additions & 25 deletions src/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,9 @@ fn replace_slot(capture: &Path, root: &str) -> io::Result<()> {
/// `source` object.
///
/// Return `None` unless exactly one rollout is named for `thread` and its
/// header is in one of those two formats. No rollout is saved for the title
/// thread. Examine only directory listings and the first line.
/// header's `session_id` is `thread` itself or, under a `subagent` source, the
/// root. No rollout is saved
/// for the title thread. Examine only directory listings and the first line.
fn root_thread(home: &Path, thread: &str) -> Option<String> {
let mut rollouts = Vec::new();
collect_rollouts(
Expand All @@ -197,9 +198,8 @@ fn root_thread(home: &Path, thread: &str) -> Option<String> {
return None;
}
let root = capture_id(meta, "session_id")?;
// Require exactly one condition. In codex 0.140.0 and 0.141.0, a
// sub-agent's own ID was stored as `session_id`, so its root is unknown.
((root == thread) != meta["source"].has_key("subagent")).then_some(root)
// A foreign root is trusted only from a sub-agent's header.
(root == thread || meta["source"].has_key("subagent")).then_some(root)
}

/// Push every entry with a name ending in `suffix`, exactly `depth` directory
Expand Down Expand Up @@ -1027,26 +1027,6 @@ mod tests {
assert_eq!(resolve(&home, CHILD).as_deref(), Some(ID));
}

/// In codex 0.140.0 and 0.141.0, a sub-agent's own ID was stored as
/// `session_id`. Reject `id == session_id` with a `subagent` source:
/// the root is unknown, and the sub-agent's ID is not resumable.
#[test]
fn arrival_root_refuses_a_sub_agent_that_names_itself_as_root() {
let home = temp("codex_capture_self_rooted");
for source in [
thread_spawn(ID, 1),
r#"{"subagent":{"other":"guardian"}}"#.to_string(),
r#"{"subagent":"review"}"#.to_string(),
] {
install_codex_rollout(
&home,
CHILD,
codex_session_meta(&child_meta(CHILD, ID, CHILD, &source)),
);
assert_eq!(resolve(&home, CHILD), None, "{source}");
}
}

/// No rollout is saved for the title thread. Reject its notification
/// with an absent store, an empty store, or rollouts for other threads.
#[test]
Expand Down
2 changes: 0 additions & 2 deletions src/harness/omp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
//! the same handlers are registered for them, but their IDs are not resumable.
//! Wait until the session file is created after the first assistant message.
//! For a fresh launch, no ID is captured until the end of the first turn.
//! Capture is unavailable before omp 18.3.2: without the agent kind in the
//! extension context, top-level sessions cannot be distinguished from sub-agents.
//!
//! omp's IDs are `UUIDv7`. [`is_uuid`](super::is_uuid) validates the 8-4-4-4-12
//! lowercase-hex shape and not the version field, so they pass unchanged.
Expand Down
Loading
Loading