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
26 changes: 23 additions & 3 deletions crates/plannotator-tui-hosts/src/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
//! custom entries) forming a tree. There is no pid registry, so a running pi is found by
//! its cwd. Verified against pi's `session-manager`/`migrations.ts` encoding and the
//! `harness/session/types.ts` entry set.
//!
//! Which session a cwd is *in* is a question pi answers for itself the same way: its
//! `findMostRecentSession` (`core/session-manager.ts`, behind `pi --continue`) ranks the
//! bucket's files by `mtimeMs`, not by name.

use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -79,7 +83,9 @@ pub fn encoded_dir(cwd: &Path) -> String {

/// The newest session for `cwd` that holds at least one message: its encoded directory
/// first, then legacy flat files whose header names the cwd, else the newest session
/// anywhere under `sessions_dir`. Newest is by the timestamp in the filename.
/// anywhere under `sessions_dir`. Newest is by last write, not by the timestamp in the
/// filename: pi appends to the session it is resuming, so a session started days ago and
/// still in use outranks one created since and left idle.
pub fn find_transcript(sessions_dir: &Path, cwd: &Path) -> Option<PathBuf> {
let mut for_cwd: Vec<PathBuf> = jsonl_files(&sessions_dir.join(encoded_dir(cwd)));
for_cwd.extend(
Expand All @@ -95,14 +101,28 @@ pub fn find_transcript(sessions_dir: &Path, cwd: &Path) -> Option<PathBuf> {
newest_with_messages(all)
}

/// The last-written candidate that holds at least one message. Ranked by modification
/// time, newest first; a file whose metadata cannot be read ranks after every file that
/// has one, and ties fall back to the timestamp in the filename, so a directory whose
/// files all share an mtime keeps the old ordering.
fn newest_with_messages(mut files: Vec<PathBuf>) -> Option<PathBuf> {
files.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
files.sort();
files.dedup();
files
let mut ranked: Vec<(Option<std::time::SystemTime>, PathBuf)> =
files.into_iter().map(|path| (modified(&path), path)).collect();
ranked.sort_by(|(a_time, a), (b_time, b)| {
b_time.cmp(a_time).then_with(|| b.file_name().cmp(&a.file_name()))
});
ranked
.into_iter()
.map(|(_, path)| path)
.find(|p| std::fs::read_to_string(p).is_ok_and(|text| !parse_messages(&text, 1).is_empty()))
}

fn modified(path: &Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).and_then(|data| data.modified()).ok()
}

fn jsonl_files(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
entries
Expand Down
39 changes: 39 additions & 0 deletions crates/plannotator-tui-hosts/tests/omp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,42 @@ fn omp_sessions_resolve_and_parse_with_pis_rules() {
assert!(messages.iter().any(|m| m.role == Role::Assistant));
assert_eq!(omp::DEFAULT_AGENT_DIR, ".omp/agent");
}

/// OMP discovery is pi's function, so the last-written session wins here too.
#[test]
fn omp_ranks_candidates_by_last_write_like_pi() {
let root = std::env::temp_dir().join(format!("plannotator-tui-omp-mtime-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let bucket = root.join(pi::encoded_dir(Path::new("/work/project")));
std::fs::create_dir_all(&bucket).expect("bucket");
let session = |id: &str| {
format!(
concat!(
r#"{{"type":"session","version":3,"id":"{0}","cwd":"/work/project"}}"#,
"\n",
r#"{{"type":"message","id":"m-{0}","parentId":null,"#,
r#""message":{{"role":"assistant","content":[{{"type":"text","text":"{0}"}}]}}}}"#,
"\n",
),
id
)
};
let resumed = bucket.join("2026-08-28T10-00-00-000Z_01a00000-0000-7000-8000-00000000000a.jsonl");
let idle = bucket.join("2026-08-29T10-00-00-000Z_01a00000-0000-7000-8000-00000000000b.jsonl");
std::fs::write(&resumed, session("a")).expect("write");
std::fs::write(&idle, session("b")).expect("write");
let at = |seconds: u64| std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds);
let set = |path: &PathBuf, seconds: u64| {
std::fs::File::options()
.write(true)
.open(path)
.expect("open")
.set_modified(at(seconds))
.expect("mtime");
};
set(&idle, 1_000_000);
set(&resumed, 1_000_060);

assert_eq!(omp::find_transcript(&root, Path::new("/work/project")).expect("found"), resumed);
std::fs::remove_dir_all(&root).expect("cleanup");
}
91 changes: 86 additions & 5 deletions crates/plannotator-tui-hosts/tests/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,50 @@ fn sessions() -> &'static Path {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/pi-sessions"))
}

fn temp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("plannotator-tui-pi-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}

/// A writable copy of the session fixtures whose modification times rank in filename
/// order, one minute apart. A checkout leaves every fixture the same age in whatever
/// order git wrote them, which says nothing about which session was last used.
fn staged_sessions(name: &str) -> std::path::PathBuf {
let root = temp(name);
let mut copied = Vec::new();
for entry in walk(sessions()) {
let relative = entry.strip_prefix(sessions()).expect("under the fixtures");
let target = root.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("bucket");
}
std::fs::copy(&entry, &target).expect("copy");
copied.push(target);
}
copied.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
for (index, path) in copied.iter().enumerate() {
touch(path, 1_000_000 + index as u64 * 60);
}
root
}

fn walk(dir: &Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).expect("fixtures").flatten() {
let path = entry.path();
if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) }
}
out
}

/// Set `path`'s modification time to `seconds` after the Unix epoch.
fn touch(path: &Path, seconds: u64) {
let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds);
std::fs::File::options().write(true).open(path).expect("open").set_modified(time).expect("mtime");
}

#[test]
fn the_newest_assistant_message_comes_first_and_the_later_rewind_branch_wins() {
let messages = parse_messages(&fixture("pi.jsonl"), 25);
Expand Down Expand Up @@ -70,20 +114,55 @@ fn the_encoded_dir_matches_pis_session_manager() {

#[test]
fn the_newest_session_for_the_cwd_wins_over_a_newer_one_elsewhere_and_over_empty_ones() {
let found = find_transcript(sessions(), Path::new("/work/project")).expect("found");
let root = staged_sessions("cwd");
let found = find_transcript(&root, Path::new("/work/project")).expect("found");
assert!(
found.ends_with(
"--work-project--/2026-08-28T10-00-00-000Z_01a00000-0000-7000-8000-000000000001.jsonl"
),
"{found:?}"
);
std::fs::remove_dir_all(&root).expect("cleanup");
}

/// A resumed session is written to; a session created after it and left alone is not. Pi
/// names a file once, at creation, so only the modification time says which one the pane
/// is actually in.
#[test]
fn a_session_written_to_more_recently_wins_over_one_with_a_newer_name() {
let root = temp("mtime");
let bucket = root.join(encoded_dir(Path::new("/work/project")));
std::fs::create_dir_all(&bucket).expect("bucket");
let session = |id: &str, text: &str| {
format!(
concat!(
r#"{{"type":"session","version":3,"id":"{0}","cwd":"/work/project"}}"#,
"\n",
r#"{{"type":"message","id":"m-{0}","parentId":null,"timestamp":"2026-08-28T10:00:00.000Z","#,
r#""message":{{"role":"assistant","content":[{{"type":"text","text":"{1}"}}]}}}}"#,
"\n",
),
id, text
)
};
let resumed = bucket.join("2026-08-28T10-00-00-000Z_01a00000-0000-7000-8000-00000000000a.jsonl");
let idle = bucket.join("2026-08-29T10-00-00-000Z_01a00000-0000-7000-8000-00000000000b.jsonl");
std::fs::write(&resumed, session("a", "resumed")).expect("write");
std::fs::write(&idle, session("b", "idle")).expect("write");
touch(&idle, 1_000_000);
touch(&resumed, 1_000_060);

let found = find_transcript(&root, Path::new("/work/project")).expect("found");

assert_eq!(found, resumed, "the last-written session is the one the pane is in");
let text = std::fs::read_to_string(&found).expect("session");
assert_eq!(parse_messages(&text, 1).first().map(|m| m.text.as_str()), Some("resumed"));
std::fs::remove_dir_all(&root).expect("cleanup");
}

#[test]
fn a_legacy_flat_file_counts_for_its_cwd_when_the_encoded_dir_has_nothing() {
let root = std::env::temp_dir().join(format!("plannotator-tui-pi-flat-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("dir");
let root = temp("flat");
let flat = sessions().join("2026-08-27T09-00-00-000Z_01a00000-0000-7000-8000-000000000003.jsonl");
std::fs::copy(&flat, root.join(flat.file_name().expect("name"))).expect("copy");
let found = find_transcript(&root, Path::new("/work/project")).expect("found");
Expand All @@ -93,11 +172,13 @@ fn a_legacy_flat_file_counts_for_its_cwd_when_the_encoded_dir_has_nothing() {

#[test]
fn an_unknown_cwd_falls_back_to_the_newest_session_anywhere() {
let found = find_transcript(sessions(), Path::new("/nowhere")).expect("found");
let root = staged_sessions("anywhere");
let found = find_transcript(&root, Path::new("/nowhere")).expect("found");
assert!(
found.ends_with("--work-other--/2026-08-28T11-00-00-000Z_01a00000-0000-7000-8000-000000000004.jsonl"),
"{found:?}"
);
std::fs::remove_dir_all(&root).expect("cleanup");
}

#[test]
Expand Down
Loading