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,