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.
This commit is contained in:
l0ng-ai
2026-09-27 11:22:46 +02:00
parent a2ec8fd316
commit 448fbf677b
3 changed files with 83 additions and 19 deletions
+14 -6
View File
@@ -116,12 +116,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<kbd>⏎</kbd> 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 <kbd>⏎</kbd> 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 <kbd>⏎</kbd>
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. <kbd>⌘ E</kbd> (<kbd>Ctrl E</kbd>
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
+58 -12
View File
@@ -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<rusqlite::Connection> {
{
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<PathBuf> {
@@ -769,10 +784,14 @@ fn read_codex(path: &Path, updated: u64) -> Option<PastSession> {
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();
+11 -1
View File
@@ -296,7 +296,11 @@ impl CLIAgent {
session_id: &str,
launch_argv: Option<&[String]>,
) -> Option<String> {
// 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,