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: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## [0.11.3] - 2026-05-27

### Fixed

- **Cursor scanner: walk both legacy `.txt` and current Composer 2+ `.jsonl` layouts** ([#45](https://github.com/subinium/agf/pull/45), by @rooty0 / Stan) — current Cursor stores transcripts at `~/.cursor/projects/*/agent-transcripts/<uuid>/<uuid>.jsonl` (depth 4) rather than the legacy `~/.cursor/projects/*/agent-transcripts/<uuid>.txt` (depth 3). The scanner walked depth 3 with a `.txt`-only filter, so on current Cursor it returned **zero sessions**. Verified against live data: `~/.cursor/projects` had 2 JSONL transcripts at depth 4 and 0 TXT, and `agf list --agent cursor-agent` returned `No sessions found.` before this release. Closes [#35](https://github.com/subinium/agf/issues/35).
- **Cursor scanner: read chat metadata from the right table** ([#45](https://github.com/subinium/agf/pull/45), by @rooty0) — the previous code queried `SELECT value FROM cursorDiskKV WHERE key = 'composerData'`, which is the **IDE's** `state.vscdb` schema, not the CLI's `store.db`. Cursor CLI's `store.db` actually exposes a `meta(key TEXT PRIMARY KEY, value TEXT)` table with a single `key = '0'` row whose value is a hex-encoded JSON containing `agentId`, `name`, `createdAt`, `mode`, and `lastUsedModel`. Verified via `sqlite3` against a real store.db on disk.
- **Cursor scanner: skip JSONL transcripts whose `store.db` is missing** ([#45](https://github.com/subinium/agf/pull/45), by @rooty0) — `cursor-agent --resume` only surfaces sessions that have BOTH a transcript and a `~/.cursor/chats/<workspace>/<session_id>/store.db` entry; reporting orphaned transcripts that the CLI itself refuses to resume just confuses the listing. Legacy `.txt` sessions are unaffected (they predate the `chats/` directory).
- **Cursor scanner: fall back to the first user prompt when `store.db` has no usable metadata** ([#45](https://github.com/subinium/agf/pull/45), by @rooty0) — the JSONL is parsed for the first `role: user` text part, with `<user_info>` system injections skipped and `<user_query>` wrappers stripped.
- **`extract_first_prompt` no longer panics on inverted `<user_query>` tags** — `str::find` returns the FIRST occurrence of each substring independently, so a text part where `</user_query>` byte-precedes `<user_query>` (e.g. a pasted log or AI-generated code sample) gave `start > end` and `text[s+12..e]` panicked with `begin > end`. Confirmed via a standalone rustc reproducer. The closing tag is now searched **after** the opening one. Regression test: `extract_first_prompt_does_not_panic_on_inverted_tags`.
- **`extract_first_prompt` no longer aborts on the first malformed or non-UTF-8 line** — both the per-line IO read and `serde_json::from_str` used `.ok()?`, which propagates `None` out of the whole function on the first error instead of skipping the bad line. A single corrupted/truncated/non-UTF-8 line at the top of the JSONL silently disabled the blank-summary fallback for the rest of the file. Replaced with `let Ok(...) else { continue; };` (matching `scanner/pi.rs`). Regression tests: `extract_first_prompt_skips_malformed_json_lines` and `extract_first_prompt_skips_invalid_utf8_lines`.
- **`extract_first_prompt` now bounded by a 512 KiB byte budget** — pi.rs added this safeguard in v0.11.2 after large Claude logs stalled the TUI; Cursor transcripts can carry multi-MB tool-result blobs, and the `CACHE_VERSION` bump in this release forces a cold rescan for every upgrader, so the same precaution applies.
- **Cursor delete: legacy `.txt` transcripts now actually get removed** — `delete_cursor_agent_session` called `remove_dirs_matching_name(&projects_dir, &session.session_id)`, but that helper filters on `path.is_dir()` AND `file_name == name`. Legacy sessions live at `agent-transcripts/<uuid>.txt` (a file named `<uuid>.txt`), so it never matched. Delete returned `Ok(())`, the orphan file persisted on disk, and the next scan resurrected it. A new sibling helper `remove_files_matching_name` removes the file form alongside the directory form. Regression test: `delete_cursor_agent_removes_legacy_txt_transcript`.
- **Cursor scanner: enforce stem == parent UUID invariant on the `.jsonl` arm** — the previous check only required the grandparent to be named `agent-transcripts`. A stray `agent-transcripts/<uuidA>/<uuidB>.jsonl` would produce `session_id = uuidB`, which mismatches both the store.db lookup and what `cursor-agent --resume` expects. Real Cursor always writes them equal, but the invariant is now explicit. Regression test: `scan_from_rejects_jsonl_with_stem_mismatched_to_parent`.
- **`decode_dash_path` test coverage for hyphenated project segments** — added `decode_dash_path_resolves_hyphenated_segments` which places `agent`, `agent-tui`, and `agent-tui-finder` as sibling directories and asserts the backtracking decoder resolves to the longest existing match. This is the load-bearing case for this very repo's path.

### Changed

- **`CACHE_VERSION` bumped to 6** — the new orphan-skip rule fires only on fresh scans; cached `0.11.x` cursor entries persist with their old summaries until each transcript's mtime changes. Bumping the version forces a one-time rescan on upgrade so the "35 orphans → 0" effect actually lands for upgraders.

### Docs

- **README: Cursor CLI doc link + transcript paths updated** ([#45](https://github.com/subinium/agf/pull/45), by @rooty0) — `docs.cursor.com/agent` no longer resolves; switched to `cursor.com/docs/cli/overview`. Storage column now lists both the current JSONL layout and the legacy `.txt` form.

## [0.11.2] - 2026-05-23

### Fixed
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.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "agf"
version = "0.11.2"
version = "0.11.3"
edition = "2021"
description = "Find and resume local AI coding-agent sessions across Claude Code, Codex, Gemini, Cursor CLI, OpenCode, Kiro, pi, and Hermes"
license = "MIT"
Expand Down
11 changes: 10 additions & 1 deletion src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ use serde::{Deserialize, Serialize};
use crate::model::{Agent, Session};
use crate::plugin;

// Bumped to 6 in v0.11.3:
// - Cursor scanner now (a) walks both depth-3 .txt and depth-4 .jsonl layouts,
// (b) reads chat metadata from the `meta` table of store.db, and (c) drops
// .jsonl transcripts that have no matching store.db (orphans `cursor-agent`
// itself refuses to resume). Cache entries written by 0.11.x would surface
// the old orphan-laden list until each transcript's mtime happened to
// change. Bumping the version forces a one-time rescan on upgrade so the
// PR description's "35 orphans -> 0" claim actually holds for upgraders.
//
// Bumped to 5 after v0.11.1:
// - Pi scanner now keeps all user-message summaries instead of only the first
// one, matching the History preview behavior of other agents.
Expand All @@ -24,7 +33,7 @@ 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 = 5;
const CACHE_VERSION: u32 = 6;

#[derive(Serialize, Deserialize)]
struct CacheFile {
Expand Down
68 changes: 63 additions & 5 deletions src/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ fn remove_dirs_matching_name(base: &Path, name: &str) -> Result<(), io::Error> {
Ok(())
}

/// Walk a directory tree and remove any regular file whose name (including
/// extension) matches the target.
fn remove_files_matching_name(base: &Path, name: &str) -> Result<(), io::Error> {
if !base.is_dir() {
return Ok(());
}
for entry in WalkDir::new(base).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some(name) {
fs::remove_file(path)?;
}
}
Ok(())
}

// ---------------------------------------------------------------------------
// Codex
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -320,22 +335,33 @@ fn delete_kiro_session(session: &Session) -> Result<(), io::Error> {
// Cursor Agent
// ---------------------------------------------------------------------------

/// Cursor Agent sessions are stored in two locations:
/// 1. `~/.cursor/chats/<workspace-hash>/<session_id>/store.db` (SQLite)
/// 2. `~/.cursor/projects/*/agent-transcripts/<session_id>/` (transcript directory)
/// Cursor Agent sessions are stored across two layouts:
/// - Current (Composer 2+) JSONL: directory at
/// `~/.cursor/projects/*/agent-transcripts/<session_id>/` containing
/// `<session_id>.jsonl`, plus chat metadata under
/// `~/.cursor/chats/<workspace-hash>/<session_id>/store.db`.
/// - Legacy: file at `~/.cursor/projects/*/agent-transcripts/<session_id>.txt`
/// with no `chats/` counterpart.
///
/// Both shapes are still surfaced by the scanner (see `scan_from`), so delete
/// must remove the directory AND the file form — otherwise legacy sessions
/// silently no-op (`remove_dirs_matching_name` filters on `is_dir()`) and the
/// next scan resurrects the orphan.
fn delete_cursor_agent_session(session: &Session) -> Result<(), io::Error> {
let cursor_dir = config::cursor_dir().map_err(io::Error::other)?;

// 1. Remove chat directory: ~/.cursor/chats/*/<session_id>/
// 1. Chat metadata: ~/.cursor/chats/*/<session_id>/
let chats_dir = cursor_dir.join("chats");
if chats_dir.exists() {
remove_dirs_matching_name(&chats_dir, &session.session_id)?;
}

// 2. Remove transcript directory: ~/.cursor/projects/*/agent-transcripts/<session_id>/
// 2. Transcript: directory form (JSONL) and file form (legacy .txt).
let projects_dir = cursor_dir.join("projects");
if projects_dir.exists() {
remove_dirs_matching_name(&projects_dir, &session.session_id)?;
let legacy_txt = format!("{}.txt", session.session_id);
remove_files_matching_name(&projects_dir, &legacy_txt)?;
}

Ok(())
Expand Down Expand Up @@ -571,4 +597,36 @@ mod tests {
assert!(!target_dir.exists(), "target session dir should be deleted");
assert!(sibling_dir.exists(), "sibling session dir must survive");
}

/// Regression: legacy Cursor sessions live as plain files at
/// `projects/<slug>/agent-transcripts/<uuid>.txt`, not as directories.
/// `remove_dirs_matching_name` filters on `is_dir()`, so the dir-only
/// pass added in #45 left these files behind and the next scan
/// resurrected the orphan. Delete must remove both shapes.
#[test]
fn delete_cursor_agent_removes_legacy_txt_transcript() {
let base = make_codex_dir("agf-test-cursor-legacy-txt");
let transcripts = base.join("projects/encproj/agent-transcripts");
fs::create_dir_all(&transcripts).unwrap();

let target_uuid = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeee1";
let sibling_uuid = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeee2";

let target_file = transcripts.join(format!("{target_uuid}.txt"));
let sibling_file = transcripts.join(format!("{sibling_uuid}.txt"));
fs::write(&target_file, b"legacy transcript").unwrap();
fs::write(&sibling_file, b"legacy transcript").unwrap();

let projects_dir = base.join("projects");
remove_files_matching_name(&projects_dir, &format!("{target_uuid}.txt")).unwrap();

assert!(
!target_file.exists(),
"legacy .txt transcript should be deleted",
);
assert!(
sibling_file.exists(),
"unrelated sibling legacy .txt must survive",
);
}
}
Loading
Loading