From a25f4066a002eb56d34c68d9e067f0e17a5e6a1d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 31 Jul 2026 14:35:22 +0900 Subject: [PATCH 1/5] feat(yolop): add session support (#55) Discover, preview, resume, and delete Yolop sessions. Scans the platform-native yolop/sessions store, parses workspace.json + bounded head/tail of events.jsonl, validates session_<32hex> dir names, resumes with 'yolop --session '. Integrated onto main: rebased onto the shell-escaped resume_cmd signature (#58) and the (agent, session_id) wiring. Original work by @chaliy (#55). --- CHANGELOG.md | 4 + Cargo.toml | 2 +- README.md | 6 +- src/cache.rs | 13 +- src/config.rs | 6 + src/delete.rs | 34 ++++ src/model.rs | 18 ++ src/plugin.rs | 6 + src/scanner/mod.rs | 2 + src/scanner/yolop.rs | 462 +++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 549 insertions(+), 4 deletions(-) create mode 100644 src/scanner/yolop.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 760e2aa..8b742a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Yolop session support** — discover, preview, resume, and delete sessions from Yolop's platform-native session store. Current metadata supplies session titles, canonical repository names, timestamps, and concise worktree labels; older sessions fall back to first prompts and repository-name recovery. + ### Fixed - **Streaming startup keeps the initial cursor at the top** — when a fast scanner (commonly OpenCode) returned before a slower one (commonly Claude Code), the later merge preserved the fast scanner's initially selected session and pushed the cursor down the newly sorted list. The top row now follows incoming results until the user moves away from it, while explicit selections remain anchored. diff --git a/Cargo.toml b/Cargo.toml index ab9acdb..331f5a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.12.0" edition = "2024" # 1.88 = let-chains (edition 2024); also covers usize::is_multiple_of (1.87). rust-version = "1.88" -description = "Find and resume local AI coding-agent sessions across Claude Code, Codex, Gemini, Cursor CLI, OpenCode, Kiro, pi, and Hermes" +description = "Find and resume local AI coding-agent sessions across Claude Code, Codex, Gemini, Cursor CLI, OpenCode, Kiro, pi, Hermes, and Yolop" license = "MIT" repository = "https://github.com/subinium/agf" keywords = ["tui", "cli", "agent", "session", "claude"] diff --git a/README.md b/README.md index 2fbf63b..b75ed11 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ > Find the AI coding session you meant to resume. `agf` is a local-first fuzzy finder for AI coding-agent sessions. -Search local sessions across **Claude Code**, **Codex**, **Gemini CLI**, **Cursor CLI**, **OpenCode**, **Kiro**, **pi**, and **Hermes** — then resume the right one in a keystroke. +Search local sessions across **Claude Code**, **Codex**, **Gemini CLI**, **Cursor CLI**, **OpenCode**, **Kiro**, **pi**, **Hermes**, and **Yolop** — then resume the right one in a keystroke. ![agf demo](./assets/demo.gif) @@ -51,6 +51,7 @@ Then you either dig through history files or start over. | [Kiro](https://kiro.dev) | `kiro-cli chat --resume` *(no per-session resume — always opens the latest session for the cwd)* | `~/Library/Application Support/kiro-cli/data.sqlite3` | | [pi](https://github.com/badlogic/pi-mono) | `pi --session ` | `~/.pi/agent/sessions//*.jsonl` | | [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes --resume ` *(cwd-independent — resumes in your current shell directory)* | `~/.hermes/state.db` | +| [Yolop](https://github.com/everruns/yolop) | `yolop --session ` | Platform data directory under `yolop/sessions/` |
Full session storage paths @@ -65,6 +66,7 @@ Then you either dig through history files or start over. | Cursor CLI | SQLite + JSONL/TXT | `~/.cursor/chats///store.db` (metadata; required for `.jsonl` to be resumable)
`~/.cursor/projects/*/agent-transcripts//.jsonl` (Composer 2+ transcript)
`~/.cursor/projects/*/agent-transcripts/.txt` (legacy transcript) | | Gemini | JSON | `~/.gemini/tmp//chats/session--.json`
`` is a named dir or SHA-256 hash of the project path
Project paths resolved via `~/.gemini/projects.json` | | Hermes | SQLite | `~/.hermes/state.db` (sessions + messages)
JSON dumps in `~/.hermes/sessions/session_.json`
Hermes is cwd-independent — resume runs in your current shell directory | +| Yolop | JSONL + JSON | macOS: `~/Library/Application Support/yolop/sessions//`
Linux: `$XDG_DATA_HOME/yolop/sessions//`
Windows: `%APPDATA%\yolop\sessions\\` |
@@ -166,7 +168,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes. ## Requirements - macOS, Linux, or Windows (PowerShell 5.1+ / PowerShell 7+) -- One or more of: `claude`, `codex`, `opencode`, `pi`, `kiro-cli`, `cursor-agent`, `gemini` +- One or more of: `claude`, `codex`, `opencode`, `pi`, `kiro-cli`, `cursor-agent`, `gemini`, `hermes`, `yolop` ## Install from source diff --git a/src/cache.rs b/src/cache.rs index 5c9e801..040abe3 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -39,7 +39,17 @@ use crate::plugin; // entries written by 0.10.x would surface as stale "cli session (...)" // summaries until the source DB mtime happens to change. // Bumping the version forces a one-time rescan on first 0.11.0 launch. -const CACHE_VERSION: u32 = 6; +// Bumped to 8 after v0.12.0: +// - Yolop support adds a new per-agent cache key. +// - Yolop worktree sessions use repo_root for the project name instead of the +// generated session directory. Local builds share the released 0.12.0 +// package version, so agf_version alone cannot invalidate their old entries. +// - Older nested Yolop worktree sessions recover the original repository name +// through their `.git` indirection. +// - Deleted nested worktrees recover it from their parent session metadata. +// - Yolop's persisted titles, canonical roots, timestamps, and short worktree +// slugs now replace inferred summaries, generated paths, and log-only times. +const CACHE_VERSION: u32 = 8; /// The binary version stamped into every cache write; any mismatch on read /// invalidates the whole cache (see `parse_cache`). @@ -324,6 +334,7 @@ pub fn start_stale_scan(stale: &[Agent]) -> std::sync::mpsc::Receiver crate::scanner::cursor_agent::scan().unwrap_or_default(), Agent::Gemini => crate::scanner::gemini::scan().unwrap_or_default(), Agent::Hermes => crate::scanner::hermes::scan().unwrap_or_default(), + Agent::Yolop => crate::scanner::yolop::scan().unwrap_or_default(), }; if debug { eprintln!( diff --git a/src/config.rs b/src/config.rs index 5c3f7a3..ea376a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,6 +37,12 @@ pub fn hermes_dir() -> Result { Ok(home_dir()?.join(".hermes")) } +pub fn yolop_sessions_dir() -> Result { + dirs::data_dir() + .map(|d| d.join("yolop").join("sessions")) + .ok_or(AgfError::NoHomeDir) +} + pub fn kiro_data_dir() -> Result { // Kiro CLI stores data via dirs::data_local_dir() // macOS: ~/Library/Application Support/kiro-cli/ diff --git a/src/delete.rs b/src/delete.rs index 8bf9e59..55a1387 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -25,9 +25,27 @@ pub fn delete_session(session: &Session) -> Result<(), io::Error> { Agent::CursorAgent => delete_cursor_agent_session(session), Agent::Gemini => delete_gemini_session(session), Agent::Hermes => delete_hermes_session(session), + Agent::Yolop => delete_yolop_session(session), } } +fn delete_yolop_session(session: &Session) -> Result<(), io::Error> { + let sessions_dir = config::yolop_sessions_dir().map_err(io::Error::other)?; + delete_yolop_session_from(&sessions_dir, &session.session_id) +} + +fn delete_yolop_session_from(sessions_dir: &Path, session_id: &str) -> Result<(), io::Error> { + let session_dir = sessions_dir.join(session_id); + if session_dir.is_dir() { + fs::remove_dir_all(session_dir)?; + } + let legacy_log = sessions_dir.join(format!("{session_id}.jsonl")); + if legacy_log.is_file() { + fs::remove_file(legacy_log)?; + } + Ok(()) +} + // --------------------------------------------------------------------------- // Shared JSONL helpers // --------------------------------------------------------------------------- @@ -613,4 +631,20 @@ mod tests { "unrelated sibling legacy .txt must survive", ); } + + #[test] + fn delete_yolop_session_removes_only_target_folder_and_legacy_log() { + let base = make_codex_dir("agf-test-yolop-delete"); + let target = "session_019e3db018a17450aba5407af5777237"; + let sibling = "session_019f4fec10e370b2be16cca7debb6ab1"; + fs::create_dir(base.join(target)).unwrap(); + fs::create_dir(base.join(sibling)).unwrap(); + fs::write(base.join(format!("{target}.jsonl")), b"{}").unwrap(); + + delete_yolop_session_from(&base, target).unwrap(); + + assert!(!base.join(target).exists()); + assert!(!base.join(format!("{target}.jsonl")).exists()); + assert!(base.join(sibling).exists()); + } } diff --git a/src/model.rs b/src/model.rs index a3b0d8b..353e9c7 100644 --- a/src/model.rs +++ b/src/model.rs @@ -16,6 +16,7 @@ pub enum Agent { CursorAgent, Gemini, Hermes, + Yolop, } impl fmt::Display for Agent { @@ -29,6 +30,7 @@ impl fmt::Display for Agent { Agent::CursorAgent => write!(f, "Cursor CLI"), Agent::Gemini => write!(f, "Gemini"), Agent::Hermes => write!(f, "Hermes"), + Agent::Yolop => write!(f, "Yolop"), } } } @@ -44,6 +46,7 @@ impl Agent { Agent::CursorAgent => (245, 184, 65), // #F5B841 Cursor brand yellow Agent::Gemini => (66, 133, 244), // #4285F4 Google blue Agent::Hermes => (168, 85, 247), // #A855F7 purple (Nous Research) + Agent::Yolop => (34, 197, 94), // #22C55E green } } @@ -57,6 +60,7 @@ impl Agent { Agent::CursorAgent, Agent::Gemini, Agent::Hermes, + Agent::Yolop, ] } @@ -71,6 +75,7 @@ impl Agent { Agent::CursorAgent => "cursor-agent", Agent::Gemini => "gemini", Agent::Hermes => "hermes", + Agent::Yolop => "yolop", } } @@ -93,6 +98,7 @@ impl Agent { Agent::CursorAgent => format!("cursor-agent --resume {id}"), Agent::Gemini => format!("gemini --resume {id}"), Agent::Hermes => format!("hermes --resume {id}"), + Agent::Yolop => format!("yolop --session {id}"), } } @@ -136,6 +142,7 @@ impl Agent { Agent::CursorAgent => "cursor-agent", Agent::Gemini => "gemini", Agent::Hermes => "hermes", + Agent::Yolop => "yolop", } } } @@ -313,4 +320,15 @@ mod tests { "claude --resume 'a''b'" ); } + + #[test] + fn yolop_resume_command_uses_selected_session_id() { + assert_eq!( + Agent::Yolop.resume_cmd( + "session_019e3db018a17450aba5407af5777237", + &crate::shell::CommandShell::Posix + ), + "yolop --session 'session_019e3db018a17450aba5407af5777237'" + ); + } } diff --git a/src/plugin.rs b/src/plugin.rs index 39edcfd..682f01e 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -33,6 +33,7 @@ pub fn all_plugins() -> Vec> { Box::new(PluginAdapter(Agent::CursorAgent)), Box::new(PluginAdapter(Agent::Gemini)), Box::new(PluginAdapter(Agent::Hermes)), + Box::new(PluginAdapter(Agent::Yolop)), ] } @@ -55,6 +56,7 @@ impl AgentPlugin for PluginAdapter { Agent::CursorAgent => "Cursor CLI", Agent::Gemini => "Gemini", Agent::Hermes => "Hermes", + Agent::Yolop => "Yolop", } } @@ -77,6 +79,7 @@ impl AgentPlugin for PluginAdapter { Agent::CursorAgent => scanner::cursor_agent::scan().unwrap_or_default(), Agent::Gemini => scanner::gemini::scan().unwrap_or_default(), Agent::Hermes => scanner::hermes::scan().unwrap_or_default(), + Agent::Yolop => scanner::yolop::scan().unwrap_or_default(), } } @@ -122,6 +125,9 @@ impl AgentPlugin for PluginAdapter { Agent::Hermes => config::hermes_dir() .map(|d| vec![d.join("state.db")]) .unwrap_or_default(), + Agent::Yolop => config::yolop_sessions_dir() + .map(|d| vec![d]) + .unwrap_or_default(), } } } diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index afd65a5..8614466 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -10,6 +10,7 @@ pub mod hermes; pub mod kiro; pub mod opencode; pub mod pi; +pub mod yolop; /// Truncate a string to `max` chars, appending "..." if truncated. pub(crate) fn truncate(s: &str, max: usize) -> String { @@ -183,6 +184,7 @@ pub fn scan_all() -> Vec { thread::spawn(|| cursor_agent::scan().unwrap_or_default()), thread::spawn(|| gemini::scan().unwrap_or_default()), thread::spawn(|| hermes::scan().unwrap_or_default()), + thread::spawn(|| yolop::scan().unwrap_or_default()), ]; let mut sessions: Vec = handles .into_iter() diff --git a/src/scanner/yolop.rs b/src/scanner/yolop.rs new file mode 100644 index 0000000..d07bd73 --- /dev/null +++ b/src/scanner/yolop.rs @@ -0,0 +1,462 @@ +use serde_json::Value; +use std::path::Path; + +use crate::error::AgfError; +use crate::model::{Agent, Session}; +use crate::scanner::{ + collapse_whitespace, first_line_truncated, project_name_from_path, read_head_tail, +}; + +const SUMMARY_MAX_CHARS: usize = 120; +const EVENT_HEAD_BYTES: u64 = 512 * 1024; +const EVENT_TAIL_BYTES: u64 = 64 * 1024; + +pub fn scan() -> Result, AgfError> { + scan_from(&crate::config::yolop_sessions_dir()?) +} + +fn scan_from(sessions_dir: &Path) -> Result, AgfError> { + if !sessions_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut sessions = Vec::new(); + for entry in std::fs::read_dir(sessions_dir)?.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let Some(session_id) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if !valid_session_id(&session_id) { + continue; + } + if let Some(session) = parse_session(&dir, session_id) { + sessions.push(session); + } + } + sessions.sort_by_key(|s| std::cmp::Reverse(s.timestamp)); + Ok(sessions) +} + +fn valid_session_id(id: &str) -> bool { + id.strip_prefix("session_") + .is_some_and(|suffix| suffix.len() == 32 && suffix.bytes().all(|b| b.is_ascii_hexdigit())) +} + +fn parse_session(dir: &Path, session_id: String) -> Option { + let log_path = dir.join("events.jsonl"); + if !log_path.is_file() { + return None; + } + let workspace: Value = + serde_json::from_slice(&std::fs::read(dir.join("workspace.json")).ok()?).ok()?; + let project_path = workspace + .get("active_root") + .and_then(Value::as_str) + .or_else(|| workspace.get("workspace_root").and_then(Value::as_str))? + .to_string(); + let mut project_name = workspace + .get("canonical_repo_root") + .and_then(Value::as_str) + .filter(|root| !root.is_empty()) + .or_else(|| workspace.get("repo_root").and_then(Value::as_str)) + .map(project_name_from_path) + .unwrap_or_else(|| project_name_from_path(&project_path)); + if project_name.starts_with("session_") + && let Some(repo_name) = linked_session_repo_name(dir, Path::new(&project_path)) + .or_else(|| worktree_repo_name(Path::new(&project_path))) + { + project_name = repo_name; + } + let git_branch = workspace + .pointer("/worktree/branch") + .and_then(Value::as_str) + .map(str::to_owned); + let worktree = worktree_label(&workspace); + let metadata_title = workspace.get("title").and_then(Value::as_str); + let metadata_summary = workspace.get("summary").and_then(Value::as_str); + let metadata_timestamp = workspace + .get("updated_at") + .and_then(Value::as_str) + .and_then(parse_timestamp) + .or_else(|| { + workspace + .get("created_at") + .and_then(Value::as_str) + .and_then(parse_timestamp) + }) + .unwrap_or(0); + + let events = read_head_tail(&log_path, EVENT_HEAD_BYTES, EVENT_TAIL_BYTES)?; + let mut prompt_summaries = Vec::new(); + let mut event_title = None; + let mut latest_ts = None; + for line in events.head.lines().chain(events.tail.lines()) { + if let Ok(event) = serde_json::from_str::(line) { + if event.get("session_id").and_then(Value::as_str) != Some(&session_id) { + continue; + } + if let Some(ts) = event + .get("ts") + .and_then(Value::as_str) + .and_then(parse_timestamp) + { + latest_ts = Some(latest_ts.map_or(ts, |current: i64| current.max(ts))); + } + if event.get("type").and_then(Value::as_str) == Some("session.title.updated") + && let Some(title) = event.pointer("/data/title").and_then(Value::as_str) + { + event_title = Some(title.to_string()); + } + if event.get("type").and_then(Value::as_str) == Some("input.message") + && let Some(message) = event.pointer("/data/message") + && message.get("role").and_then(Value::as_str) == Some("user") + && let Some(summary) = message.get("content").and_then(extract_text) + { + push_unique_summary(&mut prompt_summaries, &summary); + } + } + } + let mut summaries = Vec::new(); + if let Some(title) = event_title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .or(metadata_title) + { + push_unique_summary(&mut summaries, title); + } + if let Some(summary) = metadata_summary { + push_unique_summary(&mut summaries, summary); + } + for summary in prompt_summaries { + push_unique_summary(&mut summaries, &summary); + } + + // Explicit metadata is useful for idle sessions, while the log mtime and + // event timestamps keep active sessions fresh between metadata writes. + let timestamp = metadata_timestamp + .max(modified_ms(&log_path)) + .max(latest_ts.unwrap_or(0)); + + Some(Session { + agent: Agent::Yolop, + session_id, + project_name, + project_path, + summaries, + timestamp, + git_branch, + worktree, + recap: None, + }) +} + +fn parse_timestamp(timestamp: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(timestamp) + .ok() + .map(|timestamp| timestamp.timestamp_millis()) +} + +fn push_unique_summary(summaries: &mut Vec, summary: &str) { + let summary = collapse_whitespace(summary); + if !summary.is_empty() && !summaries.contains(&summary) { + summaries.push(summary); + } +} + +fn extract_text(value: &Value) -> Option { + match value { + Value::String(text) => first_line_truncated(text, SUMMARY_MAX_CHARS), + Value::Array(items) => items.iter().find_map(extract_text), + Value::Object(map) => map + .get("text") + .and_then(Value::as_str) + .and_then(|text| first_line_truncated(text, SUMMARY_MAX_CHARS)), + _ => None, + } +} + +fn worktree_label(workspace: &Value) -> Option { + let worktree = workspace.get("worktree")?; + if let Some(slug) = worktree + .get("slug") + .and_then(Value::as_str) + .map(str::trim) + .filter(|slug| !slug.is_empty()) + { + return Some(slug.to_string()); + } + + let path_label = worktree + .get("path") + .and_then(Value::as_str) + .map(project_name_from_path) + .filter(|label| !label.starts_with("session_")); + path_label.or_else(|| { + worktree + .get("branch") + .and_then(Value::as_str) + .map(str::to_owned) + }) +} + +fn modified_ms(path: &Path) -> i64 { + path.metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Older Yolop sessions created from inside an existing worktree recorded the +/// worktree itself as `repo_root`. Its `.git` indirection still points at the +/// original repository, which provides a useful project label. +fn worktree_repo_name(project_path: &Path) -> Option { + let git_file = std::fs::read_to_string(project_path.join(".git")).ok()?; + let git_dir = Path::new(git_file.trim().strip_prefix("gitdir: ")?); + let common_git_dir = git_dir + .ancestors() + .find(|path| path.file_name().and_then(|name| name.to_str()) == Some(".git"))?; + Some(project_name_from_path(common_git_dir.parent()?)) +} + +/// Nested sessions can point at another Yolop session's worktree. That parent +/// session retains the original repo metadata even after its worktree is gone. +fn linked_session_repo_name(session_dir: &Path, project_path: &Path) -> Option { + let linked_id = project_path.file_name()?.to_str()?; + if !valid_session_id(linked_id) { + return None; + } + let workspace: Value = serde_json::from_slice( + &std::fs::read(session_dir.parent()?.join(linked_id).join("workspace.json")).ok()?, + ) + .ok()?; + let root = workspace + .get("canonical_repo_root") + .and_then(Value::as_str) + .filter(|root| !root.is_empty()) + .or_else(|| workspace.get("repo_root").and_then(Value::as_str))?; + let name = project_name_from_path(root); + (!name.starts_with("session_")).then_some(name) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + const SESSION_ID: &str = "session_019e3db018a17450aba5407af5777237"; + + fn temp_root(name: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "agf-yolop-{name}-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + fs::create_dir_all(&root).unwrap(); + root + } + + fn write_session(root: &Path) -> std::path::PathBuf { + let dir = root.join(SESSION_ID); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("workspace.json"), + r#"{"active_root":"/tmp/example-wt","repo_root":"/tmp/example","worktree":{"path":"/tmp/example-wt","branch":"feat/yolop","base_ref":"main"}}"#, + ) + .unwrap(); + fs::write( + dir.join("events.jsonl"), + concat!( + r#"{"type":"input.message","ts":"2026-07-10T12:00:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"message":{"role":"user","content":[{"type":"text","text":"add support for yolop\nwith tests"}]}}}"#, + "\n", + r#"{"type":"output.message.completed","ts":"2026-07-10T12:01:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"message":{"role":"assistant","content":"done"}}}"#, + "\n" + ), + ) + .unwrap(); + dir + } + + #[test] + fn parse_session_extracts_workspace_prompt_and_timestamp() { + let root = temp_root("parse"); + let dir = write_session(&root); + + let session = parse_session(&dir, SESSION_ID.to_string()).unwrap(); + + assert_eq!(session.agent, Agent::Yolop); + assert_eq!(session.project_name, "example"); + assert_eq!(session.project_path, "/tmp/example-wt"); + assert_eq!(session.summaries, ["add support for yolop"]); + assert_eq!(session.git_branch.as_deref(), Some("feat/yolop")); + assert_eq!(session.worktree.as_deref(), Some("example-wt")); + assert!(session.timestamp >= 1_783_684_860_000); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn current_metadata_supplies_title_project_time_and_worktree_slug() { + let root = temp_root("current-metadata"); + let dir = root.join(SESSION_ID); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("workspace.json"), + r#"{ + "active_root":"/tmp/yolop/worktrees/session_generated", + "repo_root":"/tmp/yolop/worktrees/session_generated", + "canonical_repo_root":"/Users/example/Projects/everruns/yolop", + "title":"Release Yolop\nand Tuika", + "summary":"Prepare and publish both releases", + "created_at":"2026-07-10T11:00:00Z", + "updated_at":"2030-07-10T12:00:00Z", + "worktree":{ + "path":"/tmp/yolop/worktrees/session_generated", + "branch":"feat/release", + "slug":"release-new-version" + } + }"#, + ) + .unwrap(); + fs::write( + dir.join("events.jsonl"), + concat!( + r#"{"type":"input.message","ts":"2026-07-10T12:00:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"message":{"role":"user","content":"Prepare and publish both releases"}}}"#, + "\n", + r#"{"type":"input.message","ts":"2026-07-10T12:01:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"message":{"role":"user","content":"Verify the release artifacts"}}}"#, + "\n" + ), + ) + .unwrap(); + + let session = parse_session(&dir, SESSION_ID.to_string()).unwrap(); + + assert_eq!(session.project_name, "yolop"); + assert_eq!( + session.summaries, + [ + "Release Yolop and Tuika", + "Prepare and publish both releases", + "Verify the release artifacts" + ] + ); + assert_eq!(session.worktree.as_deref(), Some("release-new-version")); + assert_eq!(session.timestamp, 1_909_915_200_000); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn latest_title_event_overrides_stale_metadata_title() { + let root = temp_root("title-event"); + let dir = write_session(&root); + let workspace_path = dir.join("workspace.json"); + let mut workspace: Value = + serde_json::from_slice(&fs::read(&workspace_path).unwrap()).unwrap(); + workspace["title"] = Value::String("Old generated title".to_string()); + fs::write(&workspace_path, serde_json::to_vec(&workspace).unwrap()).unwrap(); + fs::write( + dir.join("events.jsonl"), + concat!( + r#"{"type":"session.title.updated","ts":"2026-07-10T12:02:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"title":"First generated title"}}"#, + "\n", + r#"{"type":"session.title.updated","ts":"2026-07-10T12:03:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"title":"Useful current title"}}"#, + "\n", + r#"{"type":"input.message","ts":"2026-07-10T12:04:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"message":{"role":"user","content":"original request"}}}"#, + "\n" + ), + ) + .unwrap(); + + let session = parse_session(&dir, SESSION_ID.to_string()).unwrap(); + + assert_eq!( + session.summaries, + ["Useful current title", "original request"] + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn scan_ignores_non_session_directories() { + let root = temp_root("scan"); + write_session(&root); + fs::create_dir(root.join("outputs")).unwrap(); + + let sessions = scan_from(&root).unwrap(); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, SESSION_ID); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn valid_session_id_rejects_path_traversal() { + assert!(valid_session_id(SESSION_ID)); + assert!(!valid_session_id( + "../session_019e3db018a17450aba5407af5777237" + )); + assert!(!valid_session_id("session_not-hex")); + } + + #[test] + fn worktree_repo_name_resolves_original_repository() { + let root = temp_root("worktree-name"); + let worktree = root.join("session_019f4fec10e370b2be16cca7debb6ab1"); + fs::create_dir(&worktree).unwrap(); + fs::write( + worktree.join(".git"), + "gitdir: /Users/example/Projects/everruns/yolop/.git/worktrees/session_test\n", + ) + .unwrap(); + + assert_eq!(worktree_repo_name(&worktree).as_deref(), Some("yolop")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn malformed_input_event_does_not_drop_session() { + let root = temp_root("malformed-event"); + let dir = write_session(&root); + fs::write( + dir.join("events.jsonl"), + concat!( + r#"{"type":"input.message","ts":"2026-07-10T12:00:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"message":{"role":"user"}}}"#, + "\n", + r#"{"type":"input.message","ts":"2026-07-10T12:01:00Z","session_id":"session_019e3db018a17450aba5407af5777237","data":{"message":{"role":"user","content":"valid prompt"}}}"#, + "\n" + ), + ) + .unwrap(); + + let session = parse_session(&dir, SESSION_ID.to_string()).unwrap(); + + assert_eq!(session.summaries, ["valid prompt"]); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn linked_session_repo_name_uses_parent_metadata() { + let root = temp_root("linked-name"); + let child = root.join(SESSION_ID); + fs::create_dir(&child).unwrap(); + let linked_id = "session_019f4fec10e370b2be16cca7debb6ab1"; + let linked = root.join(linked_id); + fs::create_dir(&linked).unwrap(); + fs::write( + linked.join("workspace.json"), + r#"{"active_root":"/tmp/generated","repo_root":"/Users/example/Projects/everruns/yolop"}"#, + ) + .unwrap(); + + let name = linked_session_repo_name( + &child, + Path::new("/tmp/worktrees").join(linked_id).as_path(), + ); + + assert_eq!(name.as_deref(), Some("yolop")); + fs::remove_dir_all(root).unwrap(); + } +} From a8fdb951cbc6fc8d117de4579890bfd169aa3a65 Mon Sep 17 00:00:00 2001 From: Michelh91 Date: Fri, 31 Jul 2026 14:40:42 +0900 Subject: [PATCH 2/5] feat: add Oh My Pi (omp) session support (#56) Register Oh My Pi as a first-class agent. Scans ~/.omp/agent/sessions/*/*.jsonl reusing the pi JSONL parser (now shared via pi::scan_from), excludes nested subagent transcripts, and resumes with 'omp --resume ' (verified against the omp CLI: --resume, with --session as an alias). Integrated onto main: adapted to the shell-escaped resume_cmd signature (#58); omp inherits the pi last-activity timestamp fix. Original work by @Michelh91 (#56). --- Cargo.toml | 2 +- README.md | 6 ++-- src/cache.rs | 1 + src/config.rs | 4 +++ src/delete.rs | 72 +++++++++++++++++++++++++++++++++++------ src/model.rs | 18 +++++++++++ src/plugin.rs | 6 ++++ src/scanner/mod.rs | 2 ++ src/scanner/oh_my_pi.rs | 62 +++++++++++++++++++++++++++++++++++ src/scanner/pi.rs | 34 ++++++++++++------- 10 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 src/scanner/oh_my_pi.rs diff --git a/Cargo.toml b/Cargo.toml index 331f5a5..67c2dde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.12.0" edition = "2024" # 1.88 = let-chains (edition 2024); also covers usize::is_multiple_of (1.87). rust-version = "1.88" -description = "Find and resume local AI coding-agent sessions across Claude Code, Codex, Gemini, Cursor CLI, OpenCode, Kiro, pi, Hermes, and Yolop" +description = "Find and resume local AI coding-agent sessions across Claude Code, Codex, Gemini, Cursor CLI, OpenCode, Kiro, pi, Hermes, Oh My Pi, and Yolop" license = "MIT" repository = "https://github.com/subinium/agf" keywords = ["tui", "cli", "agent", "session", "claude"] diff --git a/README.md b/README.md index b75ed11..9bc2c4d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ > Find the AI coding session you meant to resume. `agf` is a local-first fuzzy finder for AI coding-agent sessions. -Search local sessions across **Claude Code**, **Codex**, **Gemini CLI**, **Cursor CLI**, **OpenCode**, **Kiro**, **pi**, **Hermes**, and **Yolop** — then resume the right one in a keystroke. +Search local sessions across **Claude Code**, **Codex**, **Gemini CLI**, **Cursor CLI**, **OpenCode**, **Kiro**, **pi**, and **Hermes** — then resume the right one in a keystroke. ![agf demo](./assets/demo.gif) @@ -51,6 +51,7 @@ Then you either dig through history files or start over. | [Kiro](https://kiro.dev) | `kiro-cli chat --resume` *(no per-session resume — always opens the latest session for the cwd)* | `~/Library/Application Support/kiro-cli/data.sqlite3` | | [pi](https://github.com/badlogic/pi-mono) | `pi --session ` | `~/.pi/agent/sessions//*.jsonl` | | [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes --resume ` *(cwd-independent — resumes in your current shell directory)* | `~/.hermes/state.db` | +| [Oh My Pi](https://github.com/can1357/oh-my-pi) | `omp --resume ` | `~/.omp/agent/sessions//*.jsonl` | | [Yolop](https://github.com/everruns/yolop) | `yolop --session ` | Platform data directory under `yolop/sessions/` |
@@ -62,6 +63,7 @@ Then you either dig through history files or start over. | Codex | JSONL | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | | OpenCode | SQLite | `~/.local/share/opencode/opencode.db` | | pi | JSONL | `~/.pi/agent/sessions/----/_.jsonl` | +| Oh My Pi | JSONL | `~/.omp/agent/sessions//_.jsonl` | | Kiro | SQLite | macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`
Linux: `~/.local/share/kiro-cli/data.sqlite3` | | Cursor CLI | SQLite + JSONL/TXT | `~/.cursor/chats///store.db` (metadata; required for `.jsonl` to be resumable)
`~/.cursor/projects/*/agent-transcripts//.jsonl` (Composer 2+ transcript)
`~/.cursor/projects/*/agent-transcripts/.txt` (legacy transcript) | | Gemini | JSON | `~/.gemini/tmp//chats/session--.json`
`` is a named dir or SHA-256 hash of the project path
Project paths resolved via `~/.gemini/projects.json` | @@ -168,7 +170,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes. ## Requirements - macOS, Linux, or Windows (PowerShell 5.1+ / PowerShell 7+) -- One or more of: `claude`, `codex`, `opencode`, `pi`, `kiro-cli`, `cursor-agent`, `gemini`, `hermes`, `yolop` +- One or more of: `claude`, `codex`, `opencode`, `pi`, `kiro-cli`, `cursor-agent`, `gemini`, `hermes`, `omp`, `yolop` ## Install from source diff --git a/src/cache.rs b/src/cache.rs index 040abe3..2ac4535 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -330,6 +330,7 @@ pub fn start_stale_scan(stale: &[Agent]) -> std::sync::mpsc::Receiver crate::scanner::codex::scan().unwrap_or_default(), Agent::OpenCode => crate::scanner::opencode::scan().unwrap_or_default(), Agent::Pi => crate::scanner::pi::scan().unwrap_or_default(), + Agent::OhMyPi => crate::scanner::oh_my_pi::scan().unwrap_or_default(), Agent::Kiro => crate::scanner::kiro::scan().unwrap_or_default(), Agent::CursorAgent => crate::scanner::cursor_agent::scan().unwrap_or_default(), Agent::Gemini => crate::scanner::gemini::scan().unwrap_or_default(), diff --git a/src/config.rs b/src/config.rs index ea376a6..d733b47 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,10 @@ pub fn pi_sessions_dir() -> Result { Ok(home_dir()?.join(".pi/agent/sessions")) } +pub fn oh_my_pi_sessions_dir() -> Result { + Ok(home_dir()?.join(".omp/agent/sessions")) +} + pub fn gemini_dir() -> Result { Ok(home_dir()?.join(".gemini")) } diff --git a/src/delete.rs b/src/delete.rs index 55a1387..0dced6d 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -21,6 +21,7 @@ pub fn delete_session(session: &Session) -> Result<(), io::Error> { Agent::Codex => delete_codex_session(session), Agent::OpenCode => delete_opencode_session(session), Agent::Pi => delete_pi_session(session), + Agent::OhMyPi => delete_oh_my_pi_session(session), Agent::Kiro => delete_kiro_session(session), Agent::CursorAgent => delete_cursor_agent_session(session), Agent::Gemini => delete_gemini_session(session), @@ -277,11 +278,22 @@ fn delete_opencode_session(session: &Session) -> Result<(), io::Error> { /// `~/.pi/agent/sessions//_.jsonl`. fn delete_pi_session(session: &Session) -> Result<(), io::Error> { let sessions_dir = config::pi_sessions_dir().map_err(io::Error::other)?; + delete_pi_style_session(session, &sessions_dir) +} + +/// Oh My Pi uses the same JSONL session format as pi under +/// `~/.omp/agent/sessions//_.jsonl`. +fn delete_oh_my_pi_session(session: &Session) -> Result<(), io::Error> { + let sessions_dir = config::oh_my_pi_sessions_dir().map_err(io::Error::other)?; + delete_pi_style_session(session, &sessions_dir) +} + +fn delete_pi_style_session(session: &Session, sessions_dir: &Path) -> Result<(), io::Error> { if !sessions_dir.exists() { return Ok(()); } - for entry in WalkDir::new(&sessions_dir).into_iter().flatten() { + for entry in WalkDir::new(sessions_dir).into_iter().flatten() { let path = entry.path(); if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; @@ -291,15 +303,18 @@ fn delete_pi_session(session: &Session) -> Result<(), io::Error> { continue; }; - let first_line = match content.lines().next() { - Some(line) if !line.trim().is_empty() => line.trim(), - _ => continue, - }; - - if let Ok(value) = serde_json::from_str::(first_line) - && value.get("type").and_then(|v| v.as_str()) == Some("session") - && value.get("id").and_then(|v| v.as_str()) == Some(&session.session_id) - { + let matches = content + .lines() + .filter(|line| !line.trim().is_empty()) + .any(|line| { + serde_json::from_str::(line) + .ok() + .is_some_and(|value| { + value.get("type").and_then(|v| v.as_str()) == Some("session") + && value.get("id").and_then(|v| v.as_str()) == Some(&session.session_id) + }) + }); + if matches { fs::remove_file(path)?; return Ok(()); } @@ -647,4 +662,41 @@ mod tests { assert!(!base.join(format!("{target}.jsonl")).exists()); assert!(base.join(sibling).exists()); } + + #[test] + fn delete_pi_style_session_accepts_oh_my_pi_title_slot() { + let root = make_codex_dir("agf-test-omp-delete"); + let target = root.join("target.jsonl"); + let sibling = root.join("sibling.jsonl"); + fs::write( + &target, + concat!( + "{\"type\":\"title\",\"title\":\"Target\"}\n", + "{\"type\":\"session\",\"id\":\"target-id\",\"cwd\":\"/tmp/x\"}\n" + ), + ) + .unwrap(); + fs::write( + &sibling, + "{\"type\":\"session\",\"id\":\"sibling-id\",\"cwd\":\"/tmp/x\"}\n", + ) + .unwrap(); + let session = Session { + agent: Agent::OhMyPi, + session_id: "target-id".to_string(), + project_name: "x".to_string(), + project_path: "/tmp/x".to_string(), + summaries: Vec::new(), + timestamp: 0, + git_branch: None, + worktree: None, + recap: None, + }; + + delete_pi_style_session(&session, &root).unwrap(); + + assert!(!target.exists()); + assert!(sibling.exists()); + let _ = fs::remove_dir_all(root); + } } diff --git a/src/model.rs b/src/model.rs index 353e9c7..470c913 100644 --- a/src/model.rs +++ b/src/model.rs @@ -12,6 +12,7 @@ pub enum Agent { Codex, OpenCode, Pi, + OhMyPi, Kiro, CursorAgent, Gemini, @@ -26,6 +27,7 @@ impl fmt::Display for Agent { Agent::Codex => write!(f, "Codex"), Agent::OpenCode => write!(f, "OpenCode"), Agent::Pi => write!(f, "pi"), + Agent::OhMyPi => write!(f, "Oh My Pi"), Agent::Kiro => write!(f, "Kiro"), Agent::CursorAgent => write!(f, "Cursor CLI"), Agent::Gemini => write!(f, "Gemini"), @@ -42,6 +44,7 @@ impl Agent { Agent::Codex => (0, 166, 126), // #00A67E teal green (OpenAI) Agent::OpenCode => (59, 130, 246), // #3B82F6 blue Agent::Pi => (236, 72, 153), // #EC4899 pink + Agent::OhMyPi => (249, 115, 22), // #F97316 orange Agent::Kiro => (136, 69, 244), // #8845F4 deep purple (AWS Kiro) Agent::CursorAgent => (245, 184, 65), // #F5B841 Cursor brand yellow Agent::Gemini => (66, 133, 244), // #4285F4 Google blue @@ -56,6 +59,7 @@ impl Agent { Agent::Codex, Agent::OpenCode, Agent::Pi, + Agent::OhMyPi, Agent::Kiro, Agent::CursorAgent, Agent::Gemini, @@ -71,6 +75,7 @@ impl Agent { Agent::Codex => "codex", Agent::OpenCode => "opencode", Agent::Pi => "pi", + Agent::OhMyPi => "omp", Agent::Kiro => "kiro-cli", Agent::CursorAgent => "cursor-agent", Agent::Gemini => "gemini", @@ -92,6 +97,7 @@ impl Agent { Agent::Codex => format!("codex resume {id}"), Agent::OpenCode => format!("opencode -s {id}"), Agent::Pi => format!("pi --session {id}"), + Agent::OhMyPi => format!("omp --resume {id}"), // Kiro CLI has no per-session resume flag — `--resume` always // reopens the latest session for the cwd, so session_id is unused. Agent::Kiro => "kiro-cli chat --resume".to_string(), @@ -138,6 +144,7 @@ impl Agent { Agent::Codex => "codex", Agent::OpenCode => "opencode", Agent::Pi => "pi", + Agent::OhMyPi => "omp", Agent::Kiro => "kiro-cli chat", Agent::CursorAgent => "cursor-agent", Agent::Gemini => "gemini", @@ -331,4 +338,15 @@ mod tests { "yolop --session 'session_019e3db018a17450aba5407af5777237'" ); } + + #[test] + fn oh_my_pi_resume_command_uses_selected_session_id() { + assert_eq!( + Agent::OhMyPi.resume_cmd( + "019e14f4-c9a5-76dc-b7b6-0613e602a620", + &crate::shell::CommandShell::Posix + ), + "omp --resume '019e14f4-c9a5-76dc-b7b6-0613e602a620'" + ); + } } diff --git a/src/plugin.rs b/src/plugin.rs index 682f01e..d7f00da 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -29,6 +29,7 @@ pub fn all_plugins() -> Vec> { Box::new(PluginAdapter(Agent::Codex)), Box::new(PluginAdapter(Agent::OpenCode)), Box::new(PluginAdapter(Agent::Pi)), + Box::new(PluginAdapter(Agent::OhMyPi)), Box::new(PluginAdapter(Agent::Kiro)), Box::new(PluginAdapter(Agent::CursorAgent)), Box::new(PluginAdapter(Agent::Gemini)), @@ -52,6 +53,7 @@ impl AgentPlugin for PluginAdapter { Agent::Codex => "Codex", Agent::OpenCode => "OpenCode", Agent::Pi => "pi", + Agent::OhMyPi => "Oh My Pi", Agent::Kiro => "Kiro", Agent::CursorAgent => "Cursor CLI", Agent::Gemini => "Gemini", @@ -75,6 +77,7 @@ impl AgentPlugin for PluginAdapter { Agent::Codex => scanner::codex::scan().unwrap_or_default(), Agent::OpenCode => scanner::opencode::scan().unwrap_or_default(), Agent::Pi => scanner::pi::scan().unwrap_or_default(), + Agent::OhMyPi => scanner::oh_my_pi::scan().unwrap_or_default(), Agent::Kiro => scanner::kiro::scan().unwrap_or_default(), Agent::CursorAgent => scanner::cursor_agent::scan().unwrap_or_default(), Agent::Gemini => scanner::gemini::scan().unwrap_or_default(), @@ -113,6 +116,9 @@ impl AgentPlugin for PluginAdapter { Agent::Pi => config::pi_sessions_dir() .map(|d| vec![d]) .unwrap_or_default(), + Agent::OhMyPi => config::oh_my_pi_sessions_dir() + .map(|d| vec![d]) + .unwrap_or_default(), Agent::Kiro => config::kiro_data_dir() .map(|d| vec![d.join("data.sqlite3")]) .unwrap_or_default(), diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 8614466..ac861a3 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -8,6 +8,7 @@ pub mod cursor_agent; pub mod gemini; pub mod hermes; pub mod kiro; +pub mod oh_my_pi; pub mod opencode; pub mod pi; pub mod yolop; @@ -180,6 +181,7 @@ pub fn scan_all() -> Vec { thread::spawn(|| codex::scan().unwrap_or_default()), thread::spawn(|| opencode::scan().unwrap_or_default()), thread::spawn(|| pi::scan().unwrap_or_default()), + thread::spawn(|| oh_my_pi::scan().unwrap_or_default()), thread::spawn(|| kiro::scan().unwrap_or_default()), thread::spawn(|| cursor_agent::scan().unwrap_or_default()), thread::spawn(|| gemini::scan().unwrap_or_default()), diff --git a/src/scanner/oh_my_pi.rs b/src/scanner/oh_my_pi.rs new file mode 100644 index 0000000..327a76f --- /dev/null +++ b/src/scanner/oh_my_pi.rs @@ -0,0 +1,62 @@ +use crate::error::AgfError; +use crate::model::{Agent, Session}; + +pub fn scan() -> Result, AgfError> { + let sessions_dir = crate::config::oh_my_pi_sessions_dir()?; + // OMP stores task/subagent transcripts below each top-level session. + // Its own global resume lookup only scans `*/*.jsonl`, so match that + // boundary and don't surface nested files that `omp --resume` can't find. + Ok(super::pi::scan_from(&sessions_dir, Agent::OhMyPi, Some(2))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + + #[test] + fn scans_oh_my_pi_session_with_title_slot() { + let root = std::env::temp_dir().join(format!( + "agf-omp-scan-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + let session_dir = root.join("-tmp-project"); + fs::create_dir_all(&session_dir).unwrap(); + + let mut file = fs::File::create(session_dir.join("session.jsonl")).unwrap(); + writeln!( + file, + r#"{{"type":"title","v":1,"title":"Fix scanner","updatedAt":"2026-07-27T10:00:00Z","pad":""}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"type":"session","version":3,"id":"omp-session","timestamp":"2026-07-27T10:00:00Z","cwd":"/tmp/project"}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"type":"message","message":{{"role":"user","content":[{{"type":"text","text":"Add OMP support"}}]}}}}"# + ) + .unwrap(); + + let nested_dir = session_dir.join("session"); + fs::create_dir_all(&nested_dir).unwrap(); + fs::write( + nested_dir.join("subagent.jsonl"), + r#"{"type":"session","id":"subagent","timestamp":"2026-07-27T10:01:00Z","cwd":"/tmp/project"}"#, + ) + .unwrap(); + + let sessions = super::super::pi::scan_from(&root, Agent::OhMyPi, Some(2)); + let _ = fs::remove_dir_all(&root); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].agent, Agent::OhMyPi); + assert_eq!(sessions[0].session_id, "omp-session"); + assert_eq!(sessions[0].project_path, "/tmp/project"); + assert_eq!(sessions[0].summaries, vec!["Add OMP support"]); + } +} diff --git a/src/scanner/pi.rs b/src/scanner/pi.rs index a5df434..c1f8854 100644 --- a/src/scanner/pi.rs +++ b/src/scanner/pi.rs @@ -2,6 +2,7 @@ use serde::Deserialize; use serde_json::Value; use std::fs::File; use std::io::{BufRead, BufReader}; +use std::path::Path; use walkdir::WalkDir; use crate::error::AgfError; @@ -30,22 +31,31 @@ struct PiSessionHeader { pub fn scan() -> Result, AgfError> { let sessions_dir = crate::config::pi_sessions_dir()?; + Ok(scan_from(&sessions_dir, Agent::Pi, None)) +} + +pub(crate) fn scan_from( + sessions_dir: &Path, + agent: Agent, + max_depth: Option, +) -> Vec { if !sessions_dir.exists() { - return Ok(Vec::new()); + return Vec::new(); } let mut sessions = Vec::new(); + let mut walker = WalkDir::new(sessions_dir); + if let Some(depth) = max_depth { + walker = walker.max_depth(depth); + } - for entry in WalkDir::new(&sessions_dir) - .into_iter() - .filter_map(|e| e.ok()) - { + for entry in walker.into_iter().filter_map(|e| e.ok()) { let path = entry.path(); if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } - if let Some(session) = parse_session(path) { + if let Some(session) = parse_session(path, agent) { sessions.push(session); } } @@ -54,10 +64,10 @@ pub fn scan() -> Result, AgfError> { // so keep older sessions from the same project selectable. sessions.sort_by_key(|s| std::cmp::Reverse(s.timestamp)); - Ok(sessions) + sessions } -fn parse_session(path: &std::path::Path) -> Option { +fn parse_session(path: &Path, agent: Agent) -> Option { let file = File::open(path).ok()?; let reader = BufReader::new(file); let mut header = None; @@ -122,7 +132,7 @@ fn parse_session(path: &std::path::Path) -> Option { let project_name = project_name_from_path(&cwd); Some(Session { - agent: Agent::Pi, + agent, session_id, project_name, project_path: cwd, @@ -196,7 +206,7 @@ mod tests { ], ); - let session = parse_session(&path); + let session = parse_session(&path, Agent::Pi); let _ = fs::remove_file(&path); let session = session.expect("session header should parse"); @@ -222,7 +232,7 @@ mod tests { let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect(); let path = temp_session_file("byte-budget", &line_refs); - let session = parse_session(&path); + let session = parse_session(&path, Agent::Pi); let _ = fs::remove_file(&path); let session = session.expect("header on line 1 should parse within the budget"); @@ -264,7 +274,7 @@ mod tests { ); fs::write(&path, &bytes).unwrap(); - let session = parse_session(&path); + let session = parse_session(&path, Agent::Pi); let _ = fs::remove_file(&path); let session = session.expect("session should surface despite invalid UTF-8 on line 1"); From b9488e323e46c080c18c7e378a7a3f478dadc913 Mon Sep 17 00:00:00 2001 From: Subin An Date: Fri, 31 Jul 2026 14:43:39 +0900 Subject: [PATCH 3/5] docs: add adding-an-agent guide; keep community agents low-key in README New agents (Oh My Pi, Yolop) stay out of the intro/feature copy and appear only in the Supported-agents reference table + Requirements. Add a contributor guide (docs/adding-an-agent.md) covering the full wiring checklist, including the three Vec registrations the compiler can't enforce. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- docs/adding-an-agent.md | 75 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 docs/adding-an-agent.md diff --git a/README.md b/README.md index 9bc2c4d..8ef2873 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ agf setup ## Contributing -Issues and PRs are welcome. +Issues and PRs are welcome. Adding support for another agent/harness is a self-contained change — see [docs/adding-an-agent.md](docs/adding-an-agent.md) for the wiring checklist. [![Contributors](https://contrib.rocks/image?repo=subinium/agf)](https://github.com/subinium/agf/graphs/contributors) diff --git a/docs/adding-an-agent.md b/docs/adding-an-agent.md new file mode 100644 index 0000000..58b98b8 --- /dev/null +++ b/docs/adding-an-agent.md @@ -0,0 +1,75 @@ +# Adding an agent + +`agf` discovers sessions by reading the files an agent already writes locally — no +plugin binaries, no config. Adding a new agent (harness) is a self-contained +change: implement one scanner and register it in a handful of match arms. The +compiler enforces most of the wiring — every `match self { Agent::… }` becomes +non-exhaustive until you add the new arm — but three registrations live in +plain `Vec`s the compiler can't check, so they're called out below. + +Community integrations are welcome even if the agent isn't a "main" harness; +keep them scoped like the existing ones (read-only scan, deletion limited to the +validated session). + +## Checklist + +Say the new agent is `Foo`, CLI `foo`, sessions under `~/.foo/sessions/`. + +1. **`src/model.rs` — the `Agent` enum.** Add `Foo` and fill in every arm the + compiler now flags: `Display`, `color()`, `all()` *(plain array — add it)*, + `cli_name()`, `resume_cmd()`, `new_session_cmd()`. Add `resume_mode_options()` + only if `foo` has permission/approval flags. + - `resume_cmd()` **must** quote the id via the passed `shell` (`shell.quote(session_id)`), + never raw `'{session_id}'` — session ids come from parsed files and may contain + shell metacharacters. + +2. **`src/config.rs`** — add a `foo_sessions_dir()` (or `_dir()`) helper returning + the on-disk location. Use `dirs::` for platform-correct paths. + +3. **`src/scanner/foo.rs`** — implement `pub fn scan() -> Result, AgfError>`. + - Return a `Session` per resumable session. Set `timestamp` (Unix **ms**) to the + **last-activity** time (file mtime or the newest in-file event) — not creation + time — so time sort is consistent with the other agents. + - Bound reads on large transcripts with the shared `read_head_tail` / bounded-read + helpers in `scanner/mod.rs`; never slurp multi-MB logs whole. + - Skip malformed lines, don't panic on bad input. + - Register the module in **`src/scanner/mod.rs`**: add `pub mod foo;` **and** a + `thread::spawn(|| foo::scan().unwrap_or_default())` line in `scan_all()` + *(plain `Vec` — the compiler won't remind you)*. + +4. **`src/plugin.rs`** — add `Box::new(PluginAdapter(Agent::Foo))` to `all_plugins()` + *(plain `Vec` — not compiler-checked)*, then fill the `name()`, `scan()`, and + `data_sources()` arms. `data_sources()` returns the paths whose mtime decides + cache freshness — keep it as narrow as possible (a single file/db beats a whole + tree; see the perf note in `scanner/claude.rs`). + +5. **`src/cache.rs`** — add the `Agent::Foo => scanner::foo::scan().unwrap_or_default()` + arm in `start_stale_scan()`. Bump `CACHE_VERSION` only if the cached payload + *shape* can change within a single released package version (a new agent key + alone doesn't require it — the `agf_version` stamp forces a rescan on upgrade). + +6. **`src/delete.rs`** — add `Agent::Foo => delete_foo_session(session)` and + implement it. **Scope deletion to the one validated session** (match by id in + file content / a validated dir name); never delete by unvalidated path. Add a + test proving a sibling session survives. + +7. **Tests + docs** — unit-test the scanner against a fixture session, add a + `resume_cmd` test, add a row to the *Supported agents* and storage tables in + `README.md`, and add the CLI name to the Requirements list. + +## Verify + +```bash +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings +cargo fmt --all -- --check +# Real-data smoke test (scans all agents regardless of install): +AGF_DEBUG=1 cargo run -- list --agent foo --format json +``` + +`agf list` runs every scanner unconditionally, so you can verify `foo` against a +fixture `$HOME` without installing the CLI: + +```bash +HOME=/tmp/agf-fixture cargo run -- list --agent foo --format json +``` From 619a3c20f4f7a5e54a5274da7c0e2d2b0b5526f9 Mon Sep 17 00:00:00 2001 From: Subin An Date: Fri, 31 Jul 2026 14:43:39 +0900 Subject: [PATCH 4/5] chore(cache): consolidate CACHE_VERSION to 7 for v0.13.0 #55 bumped 6 -> 8 (skipping 7); on the 0.12->0.13 upgrade the agf_version stamp forces a full rescan regardless, so use a single sequential 6 -> 7 bump covering both new-agent keys (Oh My Pi, Yolop). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cache.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 2ac4535..f1b93f5 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -39,17 +39,15 @@ use crate::plugin; // entries written by 0.10.x would surface as stale "cli session (...)" // summaries until the source DB mtime happens to change. // Bumping the version forces a one-time rescan on first 0.11.0 launch. -// Bumped to 8 after v0.12.0: -// - Yolop support adds a new per-agent cache key. +// Bumped to 7 in v0.13.0: +// - Oh My Pi (#56) and Yolop (#55) each add a new per-agent cache key. // - Yolop worktree sessions use repo_root for the project name instead of the -// generated session directory. Local builds share the released 0.12.0 +// generated session directory, and recover the original repository name via +// `.git` indirection / parent-session metadata; its persisted titles, +// canonical roots, timestamps, and short worktree slugs replace inferred +// summaries, generated paths, and log-only times. Local dev builds can share a // package version, so agf_version alone cannot invalidate their old entries. -// - Older nested Yolop worktree sessions recover the original repository name -// through their `.git` indirection. -// - Deleted nested worktrees recover it from their parent session metadata. -// - Yolop's persisted titles, canonical roots, timestamps, and short worktree -// slugs now replace inferred summaries, generated paths, and log-only times. -const CACHE_VERSION: u32 = 8; +const CACHE_VERSION: u32 = 7; /// The binary version stamped into every cache write; any mismatch on read /// invalidates the whole cache (see `parse_cache`). From e40d05248787d253b040d7b2d5d131a5379e5419 Mon Sep 17 00:00:00 2001 From: Subin An Date: Fri, 31 Jul 2026 14:45:46 +0900 Subject: [PATCH 5/5] chore: release v0.13.0 Functional fixes + Oh My Pi (#56) and Yolop (#55) agent support. See CHANGELOG. --- CHANGELOG.md | 21 +++++++++++++++++++-- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b742a2..bb2d1e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,30 @@ ## [Unreleased] +## [0.13.0] - 2026-07-31 + ### Added -- **Yolop session support** — discover, preview, resume, and delete sessions from Yolop's platform-native session store. Current metadata supplies session titles, canonical repository names, timestamps, and concise worktree labels; older sessions fall back to first prompts and repository-name recovery. +- **Oh My Pi (`omp`) session support** ([#56](https://github.com/subinium/agf/pull/56), by @Michelh91) — discover, preview, resume (`omp --resume `), and delete Oh My Pi sessions under `~/.omp/agent/sessions/`, reusing the pi JSONL parser and excluding nested subagent transcripts. +- **Yolop session support** ([#55](https://github.com/subinium/agf/pull/55), by @chaliy) — discover, preview, resume, and delete sessions from Yolop's platform-native session store. Current metadata supplies session titles, canonical repository names, timestamps, and concise worktree labels; older sessions fall back to first prompts and repository-name recovery. +- **Contributor guide for adding an agent** — [`docs/adding-an-agent.md`](docs/adding-an-agent.md) documents the full wiring checklist, including the three `Vec` registrations the compiler can't enforce. ### Fixed -- **Streaming startup keeps the initial cursor at the top** — when a fast scanner (commonly OpenCode) returned before a slower one (commonly Claude Code), the later merge preserved the fast scanner's initially selected session and pushed the cursor down the newly sorted list. The top row now follows incoming results until the user moves away from it, while explicit selections remain anchored. +- **Bulk delete could delete the wrong sessions** ([#58](https://github.com/subinium/agf/pull/58)) — multi-select stored `sessions` Vec indices captured at toggle time, but a background scan landing mid-selection reorders that Vec, so confirming a bulk delete removed different sessions than the ones checked. Selections are now keyed by `(agent, session_id)` and resolved at delete time. +- **`session_id` is shell-escaped in resume commands** ([#58](https://github.com/subinium/agf/pull/58)) — the id was wrapped in raw single quotes, so an id containing `'` broke the eval'd command and a crafted transcript filename could inject shell. It now goes through the shell-aware quoter (POSIX and PowerShell). +- **Cursor and pi sorted by creation time, not last activity** ([#58](https://github.com/subinium/agf/pull/58)) — a session created long ago but used today sorted as old and sank below stale ones. Both now use the transcript's last-modified time (pi: the max of its header timestamp and mtime), matching the other agents. Oh My Pi inherits the pi fix. +- **Grouped view ordered projects by the first session, not the newest** ([#58](https://github.com/subinium/agf/pull/58)) — correct only under Time sort; grouped view now keys on each group's max session timestamp. +- **Codex timestamp overflow on corrupt data** ([#58](https://github.com/subinium/agf/pull/58)) — `updated_at * 1000` now saturates instead of wrapping to a garbage sort key. +- **Streaming startup keeps the initial cursor at the top** ([#57](https://github.com/subinium/agf/pull/57), by @MilkClouds) — when a fast scanner (commonly OpenCode) returned before a slower one (commonly Claude Code), the later merge pushed the cursor down the newly sorted list. The top row now follows incoming results until the user moves away from it, while explicit selections remain anchored. + +### Performance + +- **Faster cold scan on large `~/.claude/projects` trees** ([#58](https://github.com/subinium/agf/pull/58)) — the Claude metadata scan read whole transcript bodies (up to 272 KB each) just to reach the tail for the off-by-default recap. The tail window is now 32 KB; measured read I/O dropped 62 MB → 29 MB (−53%) on a 714-file tree, with the recap unchanged. + +### Changed + +- **Dropped `panic = "abort"`** ([#58](https://github.com/subinium/agf/pull/58)) — restores `scan_all`'s per-thread panic isolation so one malformed session file can't abort the whole listing. ## [0.12.0] - 2026-06-11 diff --git a/Cargo.lock b/Cargo.lock index 5389468..6bcde82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agf" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 67c2dde..bc28a01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agf" -version = "0.12.0" +version = "0.13.0" edition = "2024" # 1.88 = let-chains (edition 2024); also covers usize::is_multiple_of (1.87). rust-version = "1.88"