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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ Droid, Hermes CLI, OpenCode (1 and 2). `--host`, `--pid`, `--session <transcript
no host is named) and `--session-id <id>` (Hermes, OpenCode) override detection; `--stdin`
reads a document;
`--print` writes the newest reply to stdout and always exits 0 (for hooks and scripts).
Reply reviews are never written to disk.
A reply review keeps its annotations in memory only; nothing about it survives the run, but
the feedback you send or copy is archived like any other (see Feedback archive below).

On Linux, an explicit Codex `--pid` selects the rollout opened by that process. If it
cannot be identified uniquely, `last` reports the failure instead of choosing an unrelated
Expand All @@ -114,6 +115,18 @@ its path: Plannotator's own layout, so both tools see one record per file. The J
Plannotator Workspaces wire shape; any agent can read it. Nothing is written next to your
files. `PLANNOTATOR_DATA_DIR` relocates the directory.

### Feedback archive

A successful Send or Copy also appends what was submitted (the feedback text, the quoted
selections and their annotations, and the file, folder or agent session it was about) to
`{data_dir}/feedback/<project>/index.jsonl`, with a Markdown copy under `records/`. The data
dir is `PLANNOTATOR_DATA_DIR`, else an existing `~/.plannotator`, else
`$XDG_DATA_HOME/plannotator`, else `~/.plannotator`. File, folder and reply reviews are all
archived; a send that fails or is refused is not. The format is the one the Plannotator
browser app writes, so both tools share one history. To turn it off, set
`PLANNOTATOR_FEEDBACK_HISTORY=0` (once the variable is set, only `1` or `true` enable) or put
`"feedbackHistory": false` in `{data_dir}/config.json`; the variable wins over the file.

## Headless

```sh
Expand Down
24 changes: 14 additions & 10 deletions crates/plannotator-tui-hosts/src/claude/ladder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@ use std::path::{Path, PathBuf};
use std::time::SystemTime;

use super::{parse_messages, parse_session_meta, project_slug};
use crate::SessionMeta;
use crate::{Match, SessionMeta};

const MAX_ANCESTOR_HOPS: usize = 8;

/// The ladder, most precise first; the first candidate that yields a message wins:
/// The ladder, most precise first; the first candidate that yields a message wins, and
/// the returned [`Match`] says which rung it came from:
/// 1. `sessions/<pid>.json` for `start_pid` and up to eight of its ancestors — with a ghost
/// check: a newer transcript in the same project dir that no running session claims is a
/// `/clear` session and is preferred;
/// 2. every session whose `cwd` is ours, newest `startedAt` first;
/// 3. the project dir for our cwd (case-insensitive fallback), newest transcript first;
/// 4. the same for each parent directory of cwd.
/// `/clear` session and is preferred ([`Match::Session`]);
/// 2. every session whose `cwd` is ours, newest `startedAt` first ([`Match::Cwd`]);
/// 3. the project dir for our cwd (case-insensitive fallback), newest transcript first
/// ([`Match::Folder`]);
/// 4. the same for each parent directory of cwd ([`Match::Folder`]).
pub fn find_transcript(
sessions_dir: &Path,
projects_dir: &Path,
cwd: &Path,
process_table: &[(u32, u32)],
start_pid: u32,
) -> Option<PathBuf> {
) -> Option<(PathBuf, Match)> {
let sessions = registered_sessions(sessions_dir);
let registered: HashSet<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();

Expand All @@ -32,7 +34,7 @@ pub fn find_transcript(
if let Some(meta) = sessions.iter().find(|s| s.pid == current)
&& let Some(found) = transcript_for_session(projects_dir, meta, &registered)
{
return Some(found);
return Some((found, Match::Session));
}
pid = process_table.iter().find(|(p, _)| *p == current).map(|(_, ppid)| *ppid).filter(|&p| p > 1);
}
Expand All @@ -41,11 +43,13 @@ pub fn find_transcript(
same_cwd.sort_by_key(|s| std::cmp::Reverse(s.started_at));
for meta in same_cwd {
if let Some(found) = transcript_for_session(projects_dir, meta, &registered) {
return Some(found);
return Some((found, Match::Cwd));
}
}

cwd.ancestors().find_map(|dir| newest_with_messages(&project_dir(projects_dir, dir)?, None))
cwd.ancestors()
.find_map(|dir| newest_with_messages(&project_dir(projects_dir, dir)?, None))
.map(|found| (found, Match::Folder))
}

fn registered_sessions(sessions_dir: &Path) -> Vec<SessionMeta> {
Expand Down
2 changes: 1 addition & 1 deletion crates/plannotator-tui-hosts/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ fn all_rollouts(dir: &Path) -> Vec<PathBuf> {
}

/// The thread id is the trailing uuid of `rollout-<timestamp>-<uuid>.jsonl`.
fn thread_of(path: &Path) -> Option<String> {
pub(crate) fn thread_of(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
let parts: Vec<&str> = stem.rsplitn(6, '-').collect();
(parts.len() == 6).then(|| parts.iter().take(5).rev().copied().collect::<Vec<_>>().join("-"))
Expand Down
25 changes: 14 additions & 11 deletions crates/plannotator-tui-hosts/src/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::time::SystemTime;

use serde_json::Value;

use crate::{Message, Role};
use crate::{Match, Message, Role};

const MAX_ANCESTOR_HOPS: usize = 8;

Expand All @@ -25,42 +25,45 @@ pub fn find_session_by_id(
Ok(dir.join("events.jsonl").is_file().then_some(dir))
}

/// The session directory for the Copilot process we were launched from.
/// The session directory for the Copilot process we were launched from, and the
/// [`Match`] that says which rung chose it.
///
/// 1. Walk `start_pid` and up to eight ancestors; the first pid that owns an
/// `inuse.<pid>.lock` wins, provided `is_copilot(pid)` confirms the pid still names a
/// Copilot process (locks outlive sessions and pids get reused; a stale match is dropped
/// and the walk continues).
/// 2. Else by cwd, newest directory first: a locked session for `cwd`, any locked session,
/// a session for `cwd`, the newest session at all.
/// and the walk continues). [`Match::Session`].
/// 2. Else by cwd, newest directory first: a locked session for `cwd` ([`Match::Cwd`]),
/// any locked session ([`Match::Newest`]), a session for `cwd` ([`Match::Cwd`]), the
/// newest session at all ([`Match::Newest`]).
pub fn find_session(
copilot_home: &Path,
cwd: &Path,
process_table: &[(u32, u32)],
start_pid: u32,
is_copilot: impl Fn(u32) -> bool,
) -> Option<PathBuf> {
) -> Option<(PathBuf, Match)> {
let state_dir = copilot_home.join("session-state");
let sessions = list_sessions(&state_dir);

let mut chain = ancestor_chain(process_table, start_pid);
let locks = lock_owners(&sessions);
while let Some(&pid) = chain.iter().find(|pid| locks.contains_key(pid)) {
if is_copilot(pid) {
return locks.get(&pid).cloned();
return locks.get(&pid).cloned().map(|dir| (dir, Match::Session));
}
chain.retain(|&p| p != pid);
}

let wanted = normalize(cwd);
let matches = |s: &Session| s.cwd.as_deref().is_some_and(|c| normalize(Path::new(c)) == wanted);
let pick = |session: &Session, rung: Match| (session.dir.clone(), rung);
sessions
.iter()
.find(|s| s.locked && matches(s))
.or_else(|| sessions.iter().find(|s| s.locked))
.or_else(|| sessions.iter().find(|s| matches(s)))
.or_else(|| sessions.first())
.map(|s| s.dir.clone())
.map(|s| pick(s, Match::Cwd))
.or_else(|| sessions.iter().find(|s| s.locked).map(|s| pick(s, Match::Newest)))
.or_else(|| sessions.iter().find(|s| matches(s)).map(|s| pick(s, Match::Cwd)))
.or_else(|| sessions.first().map(|s| pick(s, Match::Newest)))
}

/// Human prompts and assistant replies from `events.jsonl`, newest first, at most `n`.
Expand Down
83 changes: 82 additions & 1 deletion crates/plannotator-tui-hosts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub mod pi;
pub(crate) mod sqlite;
pub(crate) mod time;

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

/// A supported agent host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -107,6 +107,20 @@ pub struct Message {
pub at: Option<String>,
}

/// Which rung of a discovery ladder chose a session, so a caller can say whether the
/// result identifies the agent's own session or is the best guess for its folder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Match {
/// The session the agent process (or one of its ancestors) registered: not a guess.
Session,
/// A session whose recorded working directory is the agent's, newest first.
Cwd,
/// The newest session filed under the agent's folder or one of its parents.
Folder,
/// The newest session the host has at all, not scoped to any directory.
Newest,
}

/// `~/.claude/sessions/<pid>.json`: one running Claude Code session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionMeta {
Expand Down Expand Up @@ -157,6 +171,31 @@ impl From<std::io::Error> for HostError {
}
}

/// The host-assigned session id a transcript's name carries, when the host's naming
/// scheme makes it unambiguous: Claude Code and Droid file a session as `<uuid>.jsonl`,
/// a Codex rollout ends in its thread uuid, and a Copilot session is a directory named by
/// its uuid. Anything that is not uuid-shaped yields `None`: an arbitrary path handed in
/// with `--session` must never be mistaken for an id, and a path is never one.
pub fn session_id_of(host: Host, transcript: &Path) -> Option<String> {
let candidate = match host {
Host::ClaudeCode | Host::Droid => transcript.file_stem()?.to_str()?.to_owned(),
Host::Codex => codex::thread_of(transcript)?,
Host::Copilot => transcript.file_name()?.to_str()?.to_owned(),
Host::Pi | Host::Omp | Host::Hermes | Host::OpenCode => return None,
};
is_uuid(&candidate).then_some(candidate)
}

/// `8-4-4-4-12` hex groups, any case.
fn is_uuid(text: &str) -> bool {
let bytes = text.as_bytes();
bytes.len() == 36
&& bytes.iter().enumerate().all(|(i, b)| match i {
8 | 13 | 18 | 23 => *b == b'-',
_ => b.is_ascii_hexdigit(),
})
}

/// Which host launched us, from the environment. `PLANNOTATOR_TUI_HOST` overrides when it
/// names a known host; then the hosts' own markers, in Plannotator's order; then Claude Code.
///
Expand Down Expand Up @@ -205,3 +244,45 @@ pub fn detect_host(env: impl Fn(&str) -> Option<String>) -> Result<Host, HostErr
}
Ok(Host::ClaudeCode)
}

#[cfg(test)]
mod tests {
use super::*;

const ID: &str = "01a04583-a848-7b21-a890-f3ed0c9fef05";

#[test]
fn a_uuid_named_transcript_yields_its_session_id() {
let claude = Path::new("/h/.claude/projects/-w-repo").join(format!("{ID}.jsonl"));
assert_eq!(session_id_of(Host::ClaudeCode, &claude).as_deref(), Some(ID));
let droid = Path::new("/h/.factory/sessions/-w-repo").join(format!("{ID}.jsonl"));
assert_eq!(session_id_of(Host::Droid, &droid).as_deref(), Some(ID));
let codex = Path::new("/h/.codex/sessions/2026/08/27")
.join(format!("rollout-2026-08-27T16-17-33-{ID}.jsonl"));
assert_eq!(session_id_of(Host::Codex, &codex).as_deref(), Some(ID));
let copilot = Path::new("/h/.copilot/session-state").join(ID);
assert_eq!(session_id_of(Host::Copilot, &copilot).as_deref(), Some(ID));
}

#[test]
fn anything_that_is_not_uuid_shaped_is_not_an_id() {
assert_eq!(session_id_of(Host::ClaudeCode, Path::new("/tmp/session.jsonl")), None);
assert_eq!(
session_id_of(Host::Codex, Path::new("/tmp/rollout-2026-08-27T16-17-33-not-a-uuid.jsonl")),
None
);
assert_eq!(session_id_of(Host::Copilot, Path::new("/h/.copilot/session-state/near")), None);
let pi = Path::new("/h/.pi/agent/sessions/x").join(format!("{ID}.jsonl"));
assert_eq!(session_id_of(Host::Pi, &pi), None, "pi names sessions by pattern, not by id alone");
assert_eq!(session_id_of(Host::OpenCode, Path::new("/h/opencode.db")), None);
}

#[test]
fn uuid_shape_is_checked_strictly() {
assert!(is_uuid(ID));
assert!(is_uuid("AAAA1111-0000-4000-8000-000000000001"));
assert!(!is_uuid("01a04583a8487b21a890f3ed0c9fef05"));
assert!(!is_uuid("01a04583-a848-7b21-a890-f3ed0c9fef0g"));
assert!(!is_uuid(""));
}
}
36 changes: 24 additions & 12 deletions crates/plannotator-tui-hosts/tests/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use plannotator_tui_hosts::Role;
use plannotator_tui_hosts::copilot::{find_session, parse_messages};
use plannotator_tui_hosts::{Match, Role};

const EVENTS: &str =
include_str!("fixtures/copilot/session-state/aaaa1111-0000-4000-8000-000000000001/events.jsonl");
Expand Down Expand Up @@ -77,7 +77,10 @@ fn a_lock_held_by_an_ancestor_beats_every_cwd_heuristic() {
let locked = home.session("locked-elsewhere", "/elsewhere", &[300], 100);
// 4242 → 4000 → 300: the third hop owns the lock.
let table = [(4242, 4000), (4000, 300), (300, 1)];
assert_eq!(find_session(&home.root, Path::new("/w"), &table, 4242, always_copilot), Some(locked));
assert_eq!(
find_session(&home.root, Path::new("/w"), &table, 4242, always_copilot),
Some((locked, Match::Session))
);
}

#[test]
Expand All @@ -88,7 +91,7 @@ fn a_stale_lock_is_skipped_and_the_walk_continues() {
let table = [(4242, 4000), (4000, 300), (300, 1)];
// 4000 no longer names a copilot process; 300 does.
let found = find_session(&home.root, Path::new("/w"), &table, 4242, |pid| pid == 300);
assert_eq!(found, Some(live));
assert_eq!(found, Some((live, Match::Session)));
}

#[test]
Expand All @@ -99,10 +102,16 @@ fn the_ninth_ancestor_is_out_of_reach() {
let table: Vec<(u32, u32)> = (1..=17).map(|p| (p + 1, p)).collect(); // 18 → 17 → … → 2
let only_nine_is_live = |pid: u32| pid == 9;
// From 18 the walk reaches 11; the stale lock at 12 is dropped and 9 is never seen, so
// the cwd ladder decides: the newest active session.
assert_eq!(find_session(&home.root, Path::new("/none"), &table, 18, only_nine_is_live), Some(near));
// the cwd ladder decides: the newest active session, which is not scoped to a cwd.
assert_eq!(
find_session(&home.root, Path::new("/none"), &table, 18, only_nine_is_live),
Some((near, Match::Newest))
);
// From 16 the eighth hop is 9 and its live lock wins.
assert_eq!(find_session(&home.root, Path::new("/none"), &table, 16, only_nine_is_live), Some(far));
assert_eq!(
find_session(&home.root, Path::new("/none"), &table, 16, only_nine_is_live),
Some((far, Match::Session))
);
}

#[test]
Expand All @@ -115,15 +124,15 @@ fn without_a_lock_match_the_cwd_ladder_applies_in_order() {
let no_pid_match = [(1, 1)];
let find = |cwd: &str| find_session(&home.root, Path::new(cwd), &no_pid_match, 4242, always_copilot);

assert_eq!(find("/w"), Some(cwd_locked.clone()), "an active session for the cwd wins");
assert_eq!(find("/w"), Some((cwd_locked.clone(), Match::Cwd)), "an active session for the cwd wins");
fs::remove_file(cwd_locked.join("inuse.777.lock")).expect("unlock");
assert_eq!(find("/w"), Some(any_locked.clone()), "then any active session");
assert_eq!(find("/w"), Some((any_locked.clone(), Match::Newest)), "then any active session");
fs::remove_file(any_locked.join("inuse.888.lock")).expect("unlock");
// Removing locks touched those directories; restore their ages so mtime order holds.
age(&cwd_locked, 40);
age(&any_locked, 60);
assert_eq!(find("/w"), Some(cwd_plain), "then the newest session for the cwd");
assert_eq!(find("/nowhere"), Some(newest_any), "then the newest session at all");
assert_eq!(find("/w"), Some((cwd_plain, Match::Cwd)), "then the newest session for the cwd");
assert_eq!(find("/nowhere"), Some((newest_any, Match::Newest)), "then the newest session at all");
}

#[test]
Expand All @@ -132,7 +141,10 @@ fn a_session_directory_without_events_is_not_a_candidate() {
let with_events = home.session("with-events", "/w", &[], 50);
let bare = home.session("bare", "/w", &[], 5);
fs::remove_file(bare.join("events.jsonl")).expect("strip events");
assert_eq!(find_session(&home.root, Path::new("/w"), &[], 1, always_copilot), Some(with_events));
assert_eq!(
find_session(&home.root, Path::new("/w"), &[], 1, always_copilot),
Some((with_events, Match::Cwd))
);
}

#[test]
Expand All @@ -141,7 +153,7 @@ fn cwd_comparison_ignores_case_and_slash_direction() {
let win = home.session("win", "C:\\Users\\Me\\Repo", &[], 10);
home.session("other", "/other", &[], 5);
let found = find_session(&home.root, Path::new("c:/users/me/repo"), &[], 1, always_copilot);
assert_eq!(found, Some(win));
assert_eq!(found, Some((win, Match::Cwd)));
}

#[test]
Expand Down
Loading
Loading