mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
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:
+14
-6
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user