From 448fbf677b72a071876dee167dc4197a1ba81a96 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:22:46 +0200 Subject: [PATCH] fix(search): harden the past-session scan and note it in the changelog - Honour CLAUDE_CONFIG_DIR for Claude Code's projects, as the hooks installer already does. - Read a Codex rollout's head as bytes: the 2 MiB cap can split a multi-byte character, and read_line then dropped the whole session. - Escape `%` and `#` (not only `?`) in the immutable SQLite URI fallback, so a database under such a directory still opens. - Refuse a session id starting with `-`: ids now come from file and directory names on disk, and one would be read as a flag by the resume or fork command. - Update the unreleased Sessions changelog entry for the new agents, remote workspaces, the Cmd-E actions and the v12 dialect bump. --- CHANGELOG.md | 20 +++++-- crates/tty7-core/src/core/agent_history.rs | 70 ++++++++++++++++++---- crates/tty7-core/src/core/cli_agent.rs | 12 +++- 3 files changed, 83 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a090ed1b..8d87c863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,12 +116,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ⏎ goes back to the tab you were just in. The keybinding action is still `TogglePalette`, so a custom binding keeps working. - **Resume a past agent session from Search Everywhere.** Its **Sessions** tab - lists the Claude Code and Codex sessions on this computer — those that ran in - the focused tab's directory first — by the title the agent gave them, with - directory, branch and age, and ⏎ resumes one in a new tab in the - directory it ran in, with the agent's configured launch flags. Only the ends - of each transcript are read, in the background, and remembered until the file - changes. + lists past sessions of Claude Code, Codex, Gemini CLI, Qwen Code, Qoder, + CodeBuddy, Pi, Oh My Pi, Kimi Code, Copilot CLI, Droid, Cursor CLI and + OpenCode — those that ran in the focused tab's directory first — by the title + the agent gave them, with directory, branch and age, and ⏎ + resumes one in a new tab in the directory it ran in, with the agent's + configured launch flags. In a remote workspace the list is that machine's + sessions, and they resume there. ⌘ E (Ctrl E + elsewhere) on a session opens what else can be done with it: fork it, for + the agents that can, copy its id, or *Remove from List*, which only hides it + from the tab (`hidden_agent_sessions` in `config.json`) and leaves the + agent's history alone. Only the ends of each transcript are read, in the + background, and remembered until the file changes; OpenCode's and Cursor's + SQLite stores are opened read-only. The control dialect moves to v12, so + each remote host needs one Update Server, which ends the sessions on it. - **The command-line ghost suggests what you ran last, not what you ran most.** It was the top prefix match by frecency, where run count and the diff --git a/crates/tty7-core/src/core/agent_history.rs b/crates/tty7-core/src/core/agent_history.rs index cda0daeb..b8f8d4e4 100644 --- a/crates/tty7-core/src/core/agent_history.rs +++ b/crates/tty7-core/src/core/agent_history.rs @@ -50,7 +50,7 @@ pub struct PastSession { /// Where each agent keeps its history on this computer. #[derive(Clone, Debug)] pub struct Roots { - /// `~/.claude`. + /// `$CLAUDE_CONFIG_DIR`, else `~/.claude`. pub claude: PathBuf, /// `$CODEX_HOME`, else `~/.codex`. pub codex: PathBuf, @@ -111,6 +111,12 @@ impl Roots { .map(PathBuf::from) }; let mut roots = Self::under(home); + // The same override the hooks installer honours + // (`agent_hooks::claude_settings_path`): Claude Code keeps its + // projects wherever it keeps its settings. + if let Some(dir) = var("CLAUDE_CONFIG_DIR") { + roots.claude = dir; + } if let Some(dir) = var("CODEX_HOME") { roots.codex = dir; } @@ -533,17 +539,26 @@ fn open_read_only(path: &Path) -> Option { { return Some(conn); } - let uri = format!( - "file:{}?immutable=1", - path.to_string_lossy() - .replace('\\', "/") - .replace('?', "%3f") - ); - Connection::open_with_flags(uri, flags) + Connection::open_with_flags(immutable_uri(path), flags) .ok() .and_then(usable) } +/// `path` as a SQLite URI that reads the file as it stands, taking no locks +/// and never looking for a log. What SQLite would read as URI syntax in the +/// path — `%`, `?`, `#` — is escaped. +fn immutable_uri(path: &Path) -> String { + format!( + "file:{}?immutable=1", + // `%` first: it is the escape the other two are spelled in. + path.to_string_lossy() + .replace('\\', "/") + .replace('%', "%25") + .replace('?', "%3f") + .replace('#', "%23") + ) +} + /// OpenCode's databases: `$OPENCODE_DB`, else every `opencode*.db` in its /// data directory (release and other channels). fn opencode_dbs(roots: &Roots) -> Vec { @@ -769,10 +784,14 @@ fn read_codex(path: &Path, updated: u64) -> Option { let file = File::open(path).ok()?; let mut reader = std::io::BufReader::new(file.take(CODEX_HEAD_BYTES)); let mut head = String::new(); - let mut line = String::new(); - while reader.read_line(&mut line).ok()? > 0 { - let prompt = line.contains("\"user_message\""); - head.push_str(&line); + let mut line = Vec::new(); + // Bytes, not `read_line`: the cap can cut a multi-byte character in + // half, and `read_line` fails the whole read on that — losing a session + // whose metadata was already in. + while reader.read_until(b'\n', &mut line).ok()? > 0 { + let line_text = String::from_utf8_lossy(&line); + let prompt = line_text.contains("\"user_message\""); + head.push_str(&line_text); line.clear(); // The prompt comes after the metadata, so once one is in, both are. if prompt { @@ -1795,6 +1814,33 @@ mod tests { assert_eq!(found[0].updated, 7); } + #[test] + fn the_immutable_fallback_opens_a_path_with_uri_characters_in_it() { + let dir = tempfile::tempdir().unwrap(); + let odd = dir.path().join("50% #1 ?x"); + std::fs::create_dir_all(&odd).unwrap(); + let db = odd.join("opencode.db"); + drop(opencode_db(&db)); + let flags = + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI; + let conn = rusqlite::Connection::open_with_flags(immutable_uri(&db), flags).unwrap(); + assert_eq!(read_opencode(&conn).len(), 2); + } + + #[test] + fn a_codex_rollout_cut_inside_a_character_still_lists() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + let meta = codex_meta(json!({"title": "named"})); + // A second record long enough that the head cap lands one byte into + // a two-byte character, with no prompt before it. + let filler = CODEX_HEAD_BYTES as usize - meta.len() - 1; + let long = format!("{}{}\n", "a".repeat(filler), "é".repeat(8)); + std::fs::write(&path, format!("{meta}{long}")).unwrap(); + let s = read_codex(&path, 0).expect("the metadata was read"); + assert_eq!((s.id.as_str(), s.title.as_str()), ("0199-abc", "named")); + } + #[test] fn a_cursor_chat_without_a_title_is_named_from_its_store() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 0620a50b..b3e7bc30 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -296,7 +296,11 @@ impl CLIAgent { session_id: &str, launch_argv: Option<&[String]>, ) -> Option { + // A leading `-` would be read as a flag, not the id: `--resume + // --dangerously-skip-permissions`. No agent writes such an id, but + // the listing takes ids from file and directory names on disk. if session_id.is_empty() + || session_id.starts_with('-') || !session_id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') @@ -2246,7 +2250,13 @@ mod tests { #[test] fn fork_commands_are_shell_safe() { - for id in ["abc; rm -rf /", "$(boom)", "", "a b"] { + for id in [ + "abc; rm -rf /", + "$(boom)", + "", + "a b", + "--dangerously-skip-permissions", + ] { assert_eq!( CLIAgent::Codex.fork_command(id, None), None,