//! Agent sessions kept with the changes they made (`on sessions`). When an //! agent lands, the part of its session since the last land (what it was //! asked, what it said, what it ran) is kept with the change, secrets //! removed. Only the change's owner sees it, and people the change is //! shared with: not the project's owners, not the host's admins, and it //! never reaches git. Nothing is recorded between lands, and nothing but //! the agent's own session. `on sessions off` stops it on this machine //! (`ONSET_SESSIONS=0` for one command); `on sessions delete` removes what //! was kept, here and on the host. //! //! Agents found: Claude Code (by its session ID), Codex (its session in //! this folder, written to in the last 10 minutes), and any other that //! names its session file in ONSET_SESSION_FILE. use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use anyhow::{Result, anyhow}; use serde::Deserialize; use serde_json::{Value, json}; use crate::app::{App, Session}; use crate::db::{Change, Project, SessionRow}; use crate::ids; use crate::out::{Fail, Out, Report}; use crate::paths; use crate::remote::Remote; /// The most of one session kept with a change. const MAX_BYTES: usize = 50 << 20; /// How recently a session found by its folder must have been written to. const RECENT: Duration = Duration::from_secs(600); fn off_file() -> PathBuf { paths::home().join("sessions-off") } /// Whether sessions are kept on this machine (and for this command). pub fn enabled() -> bool { crate::paths::var("SESSIONS").as_deref() != Some("0") && !off_file().exists() } /// An agent session that's working in a workspace. struct Found { agent: String, /// What it's known by across lands (its ID, or its file). key: String, path: PathBuf, } fn user_home() -> Option { std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from) } fn recent(path: &Path) -> bool { path.metadata().and_then(|m| m.modified()).ok().and_then(|t| SystemTime::now().duration_since(t).ok()).is_some_and(|age| age < RECENT) } /// Claude Code keeps a session per project folder, named as the folder with /// everything but letters, digits and dashes turned into dashes. fn claude_slug(dir: &Path) -> String { dir.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect() } fn find_session(root: &Path) -> Option { if let Some(file) = crate::paths::var_os("SESSION_FILE").map(PathBuf::from).filter(|p| p.is_file()) { let agent = crate::paths::var("SESSION_AGENT").filter(|a| !a.trim().is_empty()).unwrap_or_else(|| "agent".into()); return Some(Found { agent, key: format!("file:{}", file.display()), path: file }); } let claude = std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from).or_else(|| user_home().map(|h| h.join(".claude")))?.join("projects"); if let Ok(id) = std::env::var("CLAUDE_CODE_SESSION_ID") && !id.is_empty() && !id.contains(['/', '\\']) { for dir in std::fs::read_dir(&claude).ok()?.flatten() { let file = dir.path().join(format!("{id}.jsonl")); if file.is_file() { return Some(Found { agent: "Claude Code".into(), key: format!("claude:{id}"), path: file }); } } } if std::env::var("CLAUDECODE").ok().as_deref() == Some("1") { // An older Claude Code: its newest session in this folder or one holding it. for dir in root.ancestors() { let newest = std::fs::read_dir(claude.join(claude_slug(dir))) .ok() .into_iter() .flatten() .flatten() .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|x| x == "jsonl") && recent(p)) .max_by_key(|p| p.metadata().and_then(|m| m.modified()).ok()); if let Some(path) = newest { let id = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); return Some(Found { agent: "Claude Code".into(), key: format!("claude:{id}"), path }); } } } codex_session(root) } /// Codex writes each session to CODEX_HOME/sessions////, /// its first line naming the folder it works in. fn codex_session(root: &Path) -> Option { let base = std::env::var_os("CODEX_HOME").map(PathBuf::from).or_else(|| user_home().map(|h| h.join(".codex")))?.join("sessions"); let mut best: Option<(SystemTime, PathBuf, String)> = None; let mut stack = vec![(base, 0)]; while let Some((dir, depth)) = stack.pop() { for e in std::fs::read_dir(&dir).ok()?.flatten() { let path = e.path(); let Ok(meta) = e.metadata() else { continue }; let Ok(modified) = meta.modified() else { continue }; // Only folders and files touched lately. if SystemTime::now().duration_since(modified).is_ok_and(|age| age > if meta.is_dir() { Duration::from_secs(3 * 86_400) } else { RECENT }) { continue; } if meta.is_dir() && depth < 3 { stack.push((path, depth + 1)); } else if meta.is_file() && path.extension().is_some_and(|x| x == "jsonl") && best.as_ref().is_none_or(|(t, _, _)| modified > *t) { let first = std::io::BufRead::lines(std::io::BufReader::new(std::fs::File::open(&path).ok()?)).next()?.ok()?; let meta: Value = serde_json::from_str(&first).ok()?; let cwd = meta["payload"]["cwd"].as_str().map(PathBuf::from)?; let cwd = crate::sys::canonical(&cwd).unwrap_or(cwd); if root.starts_with(&cwd) || cwd.starts_with(root) { let id = meta["payload"]["id"].as_str().unwrap_or_default().to_string(); best = Some((modified, path, id)); } } } } best.map(|(_, path, id)| Found { agent: "Codex".into(), key: format!("codex:{id}"), path }) } /// The messages in a session's text: what someone asked and what the agent said. fn messages(agent: &str, text: &str) -> u64 { render(agent, text).iter().filter(|(who, _)| who == "you" || who == "agent").count() as u64 } /// Keeps the session of the agent working in this workspace with change `c` /// (when sessions are on and there is one). Answers a line for the land's /// report; a failure to keep it never stops a land. pub fn keep_for(app: &App, s: &Session, c: &Change) -> Option { if !enabled() { return None; } let found = find_session(&s.root)?; match keep(app, &s.project, c, &found) { Ok(line) => line, Err(e) => Some(format!("couldn't keep the agent session: {e:#}")), } } fn keep(app: &App, project: &Project, c: &Change, found: &Found) -> Result> { let bytes = std::fs::read(&found.path)?; let mark = app.db.session_mark(&found.key)? as usize; let from = if mark <= bytes.len() { mark } else { 0 }; // Whole lines only: the agent may be writing the last one. let end = bytes[from..].iter().rposition(|b| *b == b'\n').map(|i| from + i + 1).unwrap_or(from); if end <= from { return Ok(None); } if end - from > MAX_BYTES { app.db.set_session_mark(&found.key, end as u64)?; return Ok(Some(format!("{}'s session since the last land is over 50 MB, so it wasn't kept", found.agent))); } let (text, removed) = onset_core::secrets::redact(&String::from_utf8_lossy(&bytes[from..end])); let mut row = SessionRow { n: 0, agent: found.agent.clone(), file: app.store.put_file(text.as_bytes())?, bytes: text.len() as u64, messages: messages(&found.agent, &text), at: ids::now() }; if project.hosted() { let remote = Remote::for_project(project); remote.upload(&app.store, &app.store.file_parts(&row.file)?)?; #[derive(Deserialize)] struct Kept { n: u64, } let kept: Kept = remote.send_json("PUT", &format!("/changes/{}/sessions", c.id), &row)?; row.n = kept.n; } app.db.add_session(&c.id, &row)?; app.db.set_session_mark(&found.key, end as u64)?; let mut line = format!("{}, {} {}, kept with the change; only you see it", found.agent, row.messages, if row.messages == 1 { "message" } else { "messages" }); if removed > 0 { line.push_str(&format!(" ({removed} {} removed)", if removed == 1 { "secret" } else { "secrets" })); } // The first time on this machine, how to stop it. let told = paths::home().join("sessions-told"); if !told.exists() { let _ = std::fs::write(&told, ""); line.push_str(". on sessions off stops this"); } Ok(Some(line)) } /// A session's text as turns: ("you" | "agent" | "ran", text). pub fn render(agent: &str, text: &str) -> Vec<(String, String)> { let mut out = Vec::new(); let short = |s: &str| { let s = s.trim().replace('\n', " "); if s.chars().count() > 200 { format!("{}…", s.chars().take(200).collect::()) } else { s } }; for line in text.lines() { let Ok(v) = serde_json::from_str::(line) else { if agent != "Claude Code" && agent != "Codex" && !line.trim().is_empty() { out.push(("text".into(), line.to_string())); } continue; }; match agent { "Claude Code" => { let who = match v["type"].as_str() { Some("user") => "you", Some("assistant") => "agent", _ => continue, }; match &v["message"]["content"] { Value::String(s) if !s.trim().is_empty() => out.push((who.into(), s.trim().to_string())), Value::Array(items) => { for item in items { match item["type"].as_str() { Some("text") => { let t = item["text"].as_str().unwrap_or("").trim(); if !t.is_empty() { out.push((who.into(), t.to_string())); } } Some("tool_use") => { let input = &item["input"]; let what = ["command", "file_path", "path", "pattern", "url", "description", "prompt"].iter().find_map(|k| input[*k].as_str()).unwrap_or(""); out.push(("ran".into(), format!("{} {}", item["name"].as_str().unwrap_or("tool"), short(what)))); } _ => {} } } } _ => {} } } "Codex" => { let p = &v["payload"]; match (v["type"].as_str(), p["type"].as_str()) { (Some("response_item"), Some("message")) => { let who = match p["role"].as_str() { Some("user") => "you", Some("assistant") => "agent", _ => continue, }; for item in p["content"].as_array().into_iter().flatten() { let t = item["text"].as_str().unwrap_or("").trim(); if !t.is_empty() { out.push((who.into(), t.to_string())); } } } (Some("response_item"), Some("function_call" | "custom_tool_call")) => { let args = p["arguments"].as_str().or(p["input"].as_str()).unwrap_or(""); let what = serde_json::from_str::(args).ok().and_then(|a| a["command"].as_str().map(str::to_string).or_else(|| a["cmd"].as_str().map(str::to_string))).unwrap_or_else(|| args.to_string()); out.push(("ran".into(), format!("{} {}", p["name"].as_str().unwrap_or("tool"), short(&what)))); } _ => {} } } _ => out.push(("text".into(), line.to_string())), } } out } /// One kept session's text: here, or from the host. fn text_of(app: &App, project: &Project, change: &str, row: &SessionRow) -> Result { if let Ok(bytes) = app.store.read_file(&row.file) { return Ok(String::from_utf8_lossy(&bytes).into_owned()); } if !project.hosted() { return Err(anyhow!("session {} of change {change} isn't on this machine", row.n)); } app.store.ingest(&Remote::for_project(project).get_bytes(&format!("/changes/{change}/sessions/{}", row.n))?)?; Ok(String::from_utf8_lossy(&app.store.read_file(&row.file)?).into_owned()) } /// A change's kept sessions: from the host for a hosted project (so another machine's too). fn sessions_of(app: &App, project: &Project, change: &str) -> Result> { if !project.hosted() { return Ok(app.db.sessions(change)?); } #[derive(Deserialize)] struct List { sessions: Vec, } Ok(Remote::for_project(project).get_json::(&format!("/changes/{change}/sessions"))?.sessions) } #[derive(Debug, Clone, clap::Subcommand)] pub enum Action { /// Keep agent sessions with changes on this machine (the default). On, /// Stop keeping them on this machine. Off, /// Print what the agent was asked, said and ran for a change. Show { change: String, /// The session as it was kept, line by line. #[arg(long)] raw: bool, }, /// Delete the sessions kept with a change (or --all of yours in this project), here and on the host. Delete { change: Option, #[arg(long)] all: bool, }, } /// `on sessions [on | off | show | delete | delete --all]`. pub fn run(app: &App, action: Option) -> Out { let mut r = Report::new(); match action { None => { let on = enabled(); r.line("sessions", if on { "on: an agent's session is kept with each change it lands; only you see it" } else { "off on this machine" }); r.set("on", on); if let Ok(s) = app.session() { let mine = if s.project.hosted() { #[derive(Deserialize)] struct Mine { change: String, #[serde(flatten)] session: SessionRow, } #[derive(Deserialize)] struct List { sessions: Vec, } Remote::for_project(&s.project).get_json::("/sessions")?.sessions.into_iter().map(|m| (m.change, m.session)).collect() } else { app.db.project_sessions(&s.project.id)? }; for (change, row) in mine.iter().take(20) { r.line("kept", format!("{} {}, {} messages, {}", app.short(&s.project, change), row.agent, row.messages, ids::ago(row.at))); } r.set("sessions", mine.iter().map(|(c, row)| json!({ "change": c, "session": row })).collect::>()); } r.next(if on { "on sessions off to stop keeping them; on sessions delete --all removes the ones kept" } else { "on sessions on to keep them again" }); } Some(Action::On) => { let _ = std::fs::remove_file(off_file()); r.line("sessions", "on: an agent's session is kept with each change it lands; only you see it"); r.set("on", true); } Some(Action::Off) => { std::fs::create_dir_all(paths::home())?; std::fs::write(off_file(), "")?; r.line("sessions", "off: nothing more is kept on this machine"); r.set("on", false); r.next("on sessions delete --all removes the ones already kept"); } Some(Action::Show { change, raw }) => { let s = app.session()?; let c = app.find_change(&s.project, &change)?; let list = sessions_of(app, &s.project, &c.id)?; if list.is_empty() { r.line("sessions", format!("none kept with {} (or you can't see them)", app.short(&s.project, &c.id))); } let mut turns = Vec::new(); for row in &list { let text = text_of(app, &s.project, &c.id, row)?; r.raw(format!("── {} session {}, {} messages, {}\n", row.agent, row.n, row.messages, ids::ago(row.at))); if raw { r.raw(text.clone()); continue; } for (who, what) in render(&row.agent, &text) { r.raw(match who.as_str() { "ran" => format!(" ran {what}\n"), "text" => format!("{what}\n"), _ => format!("{who}: {what}\n\n"), }); turns.push(json!({ "session": row.n, "who": who, "text": what })); } } r.set("change", c.id.clone()).set("sessions", serde_json::to_value(&list).unwrap_or_default()).set("turns", turns); } Some(Action::Delete { change, all }) => { let s = app.session()?; let which = match (change, all) { (Some(text), false) => Some(app.find_change(&s.project, &text)?.id), (None, true) => None, _ => return Err(Fail::new("bad_usage", "name a change, or --all for every session of yours in this project").state("nothing changed")), }; if s.project.hosted() { let remote = Remote::for_project(&s.project); let _: Value = match &which { Some(id) => remote.send_json("POST", &format!("/changes/{id}/sessions/delete"), &json!({}))?, None => remote.send_json("POST", "/sessions/delete", &json!({}))?, }; } let files: Vec = match &which { Some(id) => app.db.sessions(id)?.into_iter().map(|r| r.file).collect(), None => app.db.project_sessions(&s.project.id)?.into_iter().map(|(_, r)| r.file).collect(), }; let gone = app.db.delete_sessions(&s.project.id, which.as_deref())?; // Their text goes from this machine too, unless something else here has the same bytes. let live = crate::gc::live_objects(app, 0)?; for file in files { for part in app.store.file_parts(&file).unwrap_or_else(|_| vec![file]) { if !live.contains(&part) { app.store.remove(&part); } } } r.line("deleted", match &which { Some(id) => format!("the sessions kept with {}", app.short(&s.project, id)), None => "every session of yours kept in this project".to_string(), }); r.set("deleted", gone); } } Ok(r) } #[cfg(test)] mod tests { use super::*; #[test] fn renders_claude_code_turns() { let text = [ r#"{"type":"user","message":{"role":"user","content":"add a login page"}}"#, r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Adding it."},{"type":"tool_use","name":"Bash","input":{"command":"on land -m 'Login'"}}]}}"#, r#"{"type":"user","message":{"content":[{"type":"tool_result","content":"landed"}]}}"#, ] .join("\n"); let turns = render("Claude Code", &text); assert_eq!(turns, vec![("you".into(), "add a login page".into()), ("agent".into(), "Adding it.".into()), ("ran".into(), "Bash on land -m 'Login'".into())]); assert_eq!(messages("Claude Code", &text), 2); } #[test] fn renders_codex_turns() { let text = [ r#"{"type":"session_meta","payload":{"id":"x","cwd":"/w"}}"#, r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"fix the test"}]}}"#, r#"{"type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"command\":\"cargo test\"}"}}"#, r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Fixed."}]}}"#, ] .join("\n"); assert_eq!(render("Codex", &text), vec![("you".into(), "fix the test".into()), ("ran".into(), "shell cargo test".into()), ("agent".into(), "Fixed.".into())]); } #[test] fn claude_folders() { assert_eq!(claude_slug(Path::new("/home/u/coline-app/.claude/worktrees/a_b")), "-home-u-coline-app--claude-worktrees-a-b"); } }