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
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,30 @@

## [Unreleased]

## [0.13.0] - 2026-07-31

### Added

- **Oh My Pi (`omp`) session support** ([#56](https://github.com/subinium/agf/pull/56), by @Michelh91) — discover, preview, resume (`omp --resume <id>`), 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

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[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"
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, Oh My Pi, and Yolop"
license = "MIT"
repository = "https://github.com/subinium/agf"
keywords = ["tui", "cli", "agent", "session", "claude"]
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ 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 <id>` | `~/.pi/agent/sessions/<cwd>/*.jsonl` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes --resume <id>` *(cwd-independent — resumes in your current shell directory)* | `~/.hermes/state.db` |
| [Oh My Pi](https://github.com/can1357/oh-my-pi) | `omp --resume <id>` | `~/.omp/agent/sessions/<cwd>/*.jsonl` |
| [Yolop](https://github.com/everruns/yolop) | `yolop --session <id>` | Platform data directory under `yolop/sessions/` |

<details>
<summary>Full session storage paths</summary>
Expand All @@ -61,10 +63,12 @@ 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/--<encoded-cwd>--/<ts>_<id>.jsonl` |
| Oh My Pi | JSONL | `~/.omp/agent/sessions/<encoded-cwd>/<ts>_<id>.jsonl` |
| Kiro | SQLite | macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`<br>Linux: `~/.local/share/kiro-cli/data.sqlite3` |
| Cursor CLI | SQLite + JSONL/TXT | `~/.cursor/chats/<workspace>/<id>/store.db` (metadata; required for `.jsonl` to be resumable)<br>`~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl` (Composer 2+ transcript)<br>`~/.cursor/projects/*/agent-transcripts/<id>.txt` (legacy transcript) |
| Gemini | JSON | `~/.gemini/tmp/<project>/chats/session-<date>-<id>.json`<br>`<project>` is a named dir or SHA-256 hash of the project path<br>Project paths resolved via `~/.gemini/projects.json` |
| Hermes | SQLite | `~/.hermes/state.db` (sessions + messages)<br>JSON dumps in `~/.hermes/sessions/session_<id>.json`<br>Hermes is cwd-independent — resume runs in your current shell directory |
| Yolop | JSONL + JSON | macOS: `~/Library/Application Support/yolop/sessions/<id>/`<br>Linux: `$XDG_DATA_HOME/yolop/sessions/<id>/`<br>Windows: `%APPDATA%\yolop\sessions\<id>\` |

</details>

Expand Down Expand Up @@ -166,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`
- One or more of: `claude`, `codex`, `opencode`, `pi`, `kiro-cli`, `cursor-agent`, `gemini`, `hermes`, `omp`, `yolop`

## Install from source

Expand All @@ -189,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)

Expand Down
75 changes: 75 additions & 0 deletions docs/adding-an-agent.md
Original file line number Diff line number Diff line change
@@ -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<Vec<Session>, 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
```
12 changes: 11 additions & 1 deletion src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +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.
const CACHE_VERSION: u32 = 6;
// 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, 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.
const CACHE_VERSION: u32 = 7;

/// The binary version stamped into every cache write; any mismatch on read
/// invalidates the whole cache (see `parse_cache`).
Expand Down Expand Up @@ -320,10 +328,12 @@ pub fn start_stale_scan(stale: &[Agent]) -> std::sync::mpsc::Receiver<ScanResult
Agent::Codex => 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(),
Agent::Hermes => crate::scanner::hermes::scan().unwrap_or_default(),
Agent::Yolop => crate::scanner::yolop::scan().unwrap_or_default(),
};
if debug {
eprintln!(
Expand Down
10 changes: 10 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub fn pi_sessions_dir() -> Result<PathBuf, AgfError> {
Ok(home_dir()?.join(".pi/agent/sessions"))
}

pub fn oh_my_pi_sessions_dir() -> Result<PathBuf, AgfError> {
Ok(home_dir()?.join(".omp/agent/sessions"))
}

pub fn gemini_dir() -> Result<PathBuf, AgfError> {
Ok(home_dir()?.join(".gemini"))
}
Expand All @@ -37,6 +41,12 @@ pub fn hermes_dir() -> Result<PathBuf, AgfError> {
Ok(home_dir()?.join(".hermes"))
}

pub fn yolop_sessions_dir() -> Result<PathBuf, AgfError> {
dirs::data_dir()
.map(|d| d.join("yolop").join("sessions"))
.ok_or(AgfError::NoHomeDir)
}

pub fn kiro_data_dir() -> Result<PathBuf, AgfError> {
// Kiro CLI stores data via dirs::data_local_dir()
// macOS: ~/Library/Application Support/kiro-cli/
Expand Down
106 changes: 96 additions & 10 deletions src/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,32 @@ 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),
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -259,11 +278,22 @@ fn delete_opencode_session(session: &Session) -> Result<(), io::Error> {
/// `~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<sessionId>.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/<encoded-cwd>/<timestamp>_<sessionId>.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;
Expand All @@ -273,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::<serde_json::Value>(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::<serde_json::Value>(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(());
}
Expand Down Expand Up @@ -613,4 +646,57 @@ 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());
}

#[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);
}
}
Loading
Loading