//! Adopting a Claude Code session that already exists on a machine. //! //! Claude Code keeps every session as JSONL under //! `~/.claude/projects//.jsonl`, and the CLI can //! be told to continue one with `--resume `. This module is the two //! halves of putting that behind the phone: asking a machine what it has, //! and turning one of those files into the transcript a phone reads. //! //! **Continuing is not this module's job.** `claude.rs` already resumes //! whenever a session directory holds a resume token, for crash recovery, //! so an import is that same path with the token written up front. There //! is deliberately no second way to start a session. //! //! **The phone never names a file.** It picks an id out of what this //! module enumerated, and the path is looked up again on the server -- the //! same rule the setups model follows for providers, and for the same //! reason: an enrolled token must not be able to turn into "read me this //! arbitrary path". use anyhow::{Context, Result, bail, ensure}; use serde::Serialize; use serde_json::Value; use super::driver::{self, Event}; use super::transport::{Launch, Transport}; /// How much of a transcript's tail is replayed into the phone's view. /// /// The imported conversation is for reading; *continuing* it is the CLI's /// job through `--resume`, and it reads the whole file itself regardless /// of what is shown here. So this is a display budget, not a fidelity one /// -- and it needs to be a budget, because these files reach tens of /// megabytes (the session this feature was written in was 39 MB) and every /// line of it would otherwise cross a WireGuard link to a phone. const REPLAY_LINES: usize = 2000; /// Whether a session is open in a CLI somewhere. /// /// Three answers, because "nobody could check" is not "nobody is using /// it". Collapsing them would put the dangerous case behind the safe /// word, which is how the expensive version of this happens: an import /// that looks permitted, of a session that is being written to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum InUse { /// Checked, and nothing is running it. No, /// Checked, and a live CLI has it open. Yes, /// The machine does not keep the record this is read from, so there is /// no answer to be had -- not an answer of "no". Unknown, } /// One Claude Code session found on a machine. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Importable { /// The CLI's own session id, which is both the file name and the /// `--resume` token. pub id: String, /// Where that session was working, offered as the imported session's /// cwd so it resumes pointing at the same tree. pub cwd: String, /// The first thing a person said in it, for recognising it in a list. pub title: String, /// Epoch seconds, for ordering by "what I was last doing". pub modified: f64, pub lines: usize, /// How many tokens the model was holding at the last turn. /// /// The input side of the most recent assistant message's usage -- /// prompt plus both cache figures -- which is the closest thing to /// "what continuing this costs", and unlike the size it is a number /// the CLI itself recorded rather than one inferred from the file. /// /// Size and this disagree in the direction that matters. Most of a big /// transcript is usually history from before a compaction, which the /// model is no longer given: of the 133 MB session behind the /// 2026-08-29 incident, 99% of the bytes sat before its last /// compaction summary. A 77 MB file whose context is 10k tokens is /// cheap to continue; a smaller one that has never compacted may not /// be. /// /// `None` when no assistant turn has recorded usage yet -- which is /// not zero, and is why this is an option rather than a default. pub context_tokens: Option, /// Size of the file, in bytes. /// /// Reported because it is the only thing on a row that predicts what /// continuing the session will cost, and lines do not: these /// transcripts embed screenshots as base64, so one line can be a /// megabyte. The session behind the 2026-08-29 incident was 65 MB /// across 13,000 lines, which is a line count that looks unremarkable. /// /// Shown rather than warned about. Importing a large session is a /// choice somebody is entitled to make, and marking it would be the /// interface nagging about a decision already taken -- but they should /// be able to see what they are taking on. pub bytes: u64, /// Whether [`title`](Self::title) is a name somebody chose rather than /// something read out of the conversation. Sorted on, and worth the /// reader knowing: a name is a claim about what a session *is*, and a /// last message is only the last thing that happened in it. pub named: bool, /// Whether a CLI is running this session right now. /// /// The load-bearing field on this struct. Importing a session that is /// already open puts a second `--resume` on one file: the whole /// conversation gets duplicated into it, both copies then read each /// other's writes as work done elsewhere, and the adopted one is /// billed for re-reading everything -- measured on 2026-08-29 at 65 MB /// and 154 screenshots, from importing the session the importing agent /// was itself running in. pub in_use: InUse, /// Where it lives. Not serialized: the phone chooses by id and the /// server resolves the path, so a path never crosses the wire in /// either direction. #[serde(skip)] pub path: String, } /// Asks `transport`'s machine which Claude Code sessions it has. /// /// One command rather than one per file, for the reason `setups::discover` /// gives: over ssh each would be its own connection and handshake. /// /// `stat -c` is GNU-specific, which is fine for the machines here and is /// the thing to change first if this ever meets a BSD. pub async fn list(transport: &Transport) -> Result> { // Which sessions are open right now, before the files themselves. // // Claude Code writes a descriptor per live session at // `~/.claude/sessions/.json`, and the pid is the file name. It // also records `procStart` -- the kernel's start time for that pid -- // for the same reason `session::process` does: a pid on its own is // reused, so a descriptor left behind by a CLI that crashed would // otherwise mark a session as open for as long as something else held // its number. Checking both is what makes this a measurement. // // The `LIVEKNOWN` line says the directory was there to be read at // all. Without it an old CLI that keeps no descriptors would look // exactly like a machine with nothing running, which is the one // mistake this check exists to prevent. // // Then two questions per file, both answered from the end of it. // // A rename, if there was one: `/rename` appends a `custom-title` // record, and a name somebody chose beats anything inferred from the // conversation. Grepped over the whole file rather than its tail, // because a session can be named early and talked in for hours after. // // Then the last several things a person said. The *last*, not the // first: the question a list like this answers is "which one was I // just in", and every session's opening line is the least distinctive // thing about it. Several, because the final ones are often the CLI's // own -- a slash command, the caveat wrapped around its output -- and // one of those identifies nothing. // // Tool results are excluded rather than typed messages included, and // the difference matters: a tool result is *also* a user record -- // it is how the API models one -- so grepping the type alone gave a // session that ended mid-tool a tail of empty records and a row // saying nothing was said, when plenty was. But matching only a // string `content` was worse: a message carrying an attachment stores // its text in a list, so that reading lost twenty rows rather than // two. Excluding `tool_use_id` keeps both shapes of a real message // and drops the one that is not. let script = r#" if [ -d "$HOME/.claude/sessions" ]; then printf 'LIVEKNOWN\n' for s in "$HOME"/.claude/sessions/*.json; do [ -f "$s" ] || continue pid=${s##*/}; pid=${pid%.json} [ -d "/proc/$pid" ] || continue start=$(awk '{ n=index($0,") "); $0=substr($0,n+2); print $20 }' "/proc/$pid/stat" 2>/dev/null) [ -n "$start" ] || continue grep -q "\"procStart\":\"$start\"" "$s" || continue sid=$(grep -o '"sessionId":"[^"]*"' "$s" | head -1 | cut -d'"' -f4) [ -n "$sid" ] && printf 'LIVE\t%s\n' "$sid" done fi for f in "$HOME"/.claude/projects/*/*.jsonl; do [ -f "$f" ] || continue printf '%s\t%s\t%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" \ "$(wc -l < "$f")" "$(stat -c %s "$f" 2>/dev/null || echo 0)" \ "$(grep -o '"usage":{[^}]*' "$f" 2>/dev/null | tail -1)" "$f" grep '"type":"custom-title"' "$f" 2>/dev/null | tail -1 | tr '\n' '\037' grep '"type":"user"' "$f" 2>/dev/null | grep -v '"tool_use_id"' | tail -12 | tr '\n' '\037' printf '\n' done "#; let launch = Launch::new("sh", vec!["-c".to_string(), script.to_string()], None); let found = transport.capture(&launch).await?; let mut live = std::collections::HashSet::new(); let mut checkable = false; for line in found.lines() { if line.trim() == "LIVEKNOWN" { checkable = true; } else if let Some(id) = line.strip_prefix("LIVE\t") { live.insert(id.trim().to_string()); } } let mut sessions: Vec = found.lines().filter_map(parse_row).collect(); for session in &mut sessions { session.in_use = match (checkable, live.contains(&session.id)) { (_, true) => InUse::Yes, (true, false) => InUse::No, (false, false) => InUse::Unknown, }; } // Most recent first, and only that. Naming was tried as the first key // and is a worse list: it buries what somebody was just doing under // everything they ever named, and the reason to open this screen is // almost always to pick up where they left off. A name still shows, // as the row's title and as a word beside it -- being easier to // recognise is what a name is for, and it does not need the order too. sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified)); Ok(sessions) } /// One line of [`list`]'s output, or nothing if it is not one. fn parse_row(line: &str) -> Option { let mut fields = line.splitn(6, '\t'); let modified: f64 = fields.next()?.trim().parse().ok()?; let lines: usize = fields.next()?.trim().parse().ok()?; let bytes: u64 = fields.next()?.trim().parse().ok()?; let context_tokens = context_tokens(fields.next()?); let path = fields.next()?.to_string(); let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string(); let mut named = None; let mut said = None; let mut cwd = None; for record in fields .next() .unwrap_or("") .split('\u{1f}') .filter_map(|record| serde_json::from_str::(record).ok()) { if cwd.is_none() { cwd = record.get("cwd").and_then(Value::as_str).map(String::from); } if let Some(custom) = record.get("customTitle").and_then(Value::as_str) { named = Some(custom.to_string()); continue; } if !is_hidden(&record) && let Some(text) = first_line_of(&record) { // Kept rather than broken out of: these arrive oldest first, // so the last one to survive the filter is the most recent // thing that was actually said. said = Some(text); } } Some(Importable { id, // Filled in by `list`, which is the only thing that knows: it // takes one command to ask a machine, and asking per row would be // one ssh connection each. in_use: InUse::Unknown, cwd: cwd.unwrap_or_default(), // A name somebody typed outranks anything read out of the // conversation, because they chose it to answer this exact // question. named: named.is_some(), title: named .or(said) .unwrap_or_else(|| "(no messages)".to_string()), modified, lines, bytes, context_tokens, path, }) } /// The input tokens named in one `usage` object, added up. /// /// Prompt plus cache creation plus cache read: all three are context the /// model was given -- the definition is [`driver::context_tokens`]; this /// is the same three figures dug out of a raw line rather than a parsed /// one, because these files reach tens of megabytes. /// /// `None` for an empty blob, meaning no assistant turn has recorded usage. /// Missing individual fields count as zero, which is what an absent /// category means; an unparseable one does the same rather than /// discarding the figures that did read. fn context_tokens(usage: &str) -> Option { if usage.trim().is_empty() { return None; } // The leading quote matters: without it `"input_tokens"` also matches // inside `"cache_read_input_tokens"`, and the same number gets counted // three times. let field = |name: &str| -> u64 { usage .split_once(&format!("\"{name}\":")) .map(|(_, rest)| rest.trim_start()) .and_then(|rest| { let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); digits.parse().ok() }) .unwrap_or(0) }; Some(driver::context_tokens( field("input_tokens"), field("cache_creation_input_tokens"), field("cache_read_input_tokens"), )) } /// The first line of what a person typed, short enough for a list row. /// /// None for the CLI's own plumbing. A slash command, the caveat wrapped /// around a local command's output, and an injected reminder are all /// stored as ordinary user records without the `isMeta` flag -- so titling /// by "first user record" gave a list where most rows read /// `/clear`, which identifies nothing. The /// caller offers several candidates for exactly this reason. fn first_line_of(record: &Value) -> Option { let text = text_of(record.get("message")?.get("content")?); let first = text.lines().find(|line| !line.trim().is_empty())?.trim(); if first.starts_with('<') { return None; } let trimmed: String = first.chars().take(90).collect(); (!trimmed.is_empty()).then_some(trimmed) } /// Records the transcript should not show: a subagent's private /// conversation, and the CLI's own injected notes. /// /// The same rule the live translator applies -- a sidechain is another /// agent talking to itself, and duplicating it into this transcript would /// show the reader two conversations interleaved as one. fn is_hidden(record: &Value) -> bool { record.get("isSidechain").and_then(Value::as_bool) == Some(true) || record.get("isMeta").and_then(Value::as_bool) == Some(true) } /// Concatenated text of a message's content, which is either a bare string /// or the API's list of blocks. fn text_of(content: &Value) -> String { match content { Value::String(text) => text.clone(), Value::Array(blocks) => blocks .iter() .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) .filter_map(|block| block.get("text").and_then(Value::as_str)) .collect::>() .join("\n"), _ => String::new(), } } /// Whether a directory the machine recorded is still there. /// /// Asked because a session's recorded cwd can outlive the directory: these /// files go back months, and a checkout that moved leaves every session /// from before the move pointing at a path that is gone. Resuming into one /// fails at `cd` before the CLI starts, which is a confusing way to meet a /// feature whose whole promise is "carry on where you left off". pub async fn directory_exists(transport: &Transport, path: &str) -> bool { if path.is_empty() { return false; } let launch = Launch::new("test", vec!["-d".to_string(), path.to_string()], None); transport.capture(&launch).await.is_ok() } /// Reads the tail of one session's file, as the raw JSONL. /// /// `tail` rather than the whole file, and as [`Launch`] arguments rather /// than a shell string, so the path is an argument and never syntax. /// /// Returns text rather than events because turning records into events has /// a side effect -- writing out the images they carry -- and it needs the /// session directory to write them into. That directory does not exist /// until the session is created, which is after this runs, so the /// conversion happens there instead. See [`events_from`]. pub async fn read_tail(transport: &Transport, path: &str) -> Result { let launch = Launch::new( "tail", vec!["-n".to_string(), REPLAY_LINES.to_string(), path.to_string()], None, ); transport .capture(&launch) .await .with_context(|| format!("reading {path}")) } /// Claude Code's stored JSONL as this project's events. /// /// A partial first line is expected and ignored: `tail -n` cuts at a line /// boundary, but the *file* may have been appended to since, and a line /// that does not parse is one this reader has no opinion about. /// /// `session_dir` is where images found along the way are written, the same /// place and by the same function the live translator uses -- so a /// screenshot looks identical whether it was watched as it happened or /// replayed afterwards. It is only the *reference* that reaches the phone; /// the bytes are fetched from `/sessions/{id}/files/{ref}` when something /// actually draws them, and none of this is ever sent back to the CLI, /// which reads its own session file. pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec { let mut events = Vec::new(); // What the newest record that had an opinion says the session is // doing. Kept to the end rather than pushed as it is found, because // the answer is the last one and everything before it is history. let mut state = None; for line in text.lines() { let Ok(record) = serde_json::from_str::(line) else { continue; }; if let Some(peer) = peer_message(&record) { // Before `is_hidden`, which these records are: the CLI marks // them meta because they are not the user's own words, and // that is the reason to draw them differently rather than the // reason to drop them. A session working on something a phone // never asked for is otherwise unexplainable from the phone. state = turn_state(&record).or(state); events.push(peer); continue; } if is_hidden(&record) { continue; } state = turn_state(&record).or(state); let Some(message) = record.get("message") else { continue; }; let Some(content) = message.get("content") else { continue; }; match record.get("type").and_then(Value::as_str) { Some("user") => push_user(&mut events, content, session_dir), Some("assistant") => push_assistant(&mut events, content), _ => {} } } if let Some(state) = state { events.push(Event::Status { state }); } events } /// A message from another agent, as the CLI records one. /// /// Measured from a real session file (2026-08-29): the record is a `user` /// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the /// sending session's `name`, and the message itself as `body`. The /// message content beside it is the same text wrapped in an explanatory /// preamble and a `` tag, which is written for the /// model that has to read it rather than for a person -- so the body is /// what a reader is shown, and the name is who they are told sent it. fn peer_message(record: &Value) -> Option { let origin = record.get("origin")?; if origin.get("kind").and_then(Value::as_str) != Some("peer") { return None; } Some(Event::PeerMessage { from: origin .get("name") .and_then(Value::as_str) .unwrap_or("another session") .to_string(), text: origin.get("body").and_then(Value::as_str)?.to_string(), }) } /// Whether this record means the session is working, as far as it can be /// told from the file. /// /// The one thing a session file does not contain is the CLI saying "this /// turn is over": there is no `result` record, only the messages. What /// there is instead is why the last assistant message stopped, and that /// answers it -- `tool_use` means a call is being made and more is coming, /// anything else means the model has finished talking. Anything on the /// user's side of the conversation -- a person, a tool's result, another /// agent -- means the session has something to answer and is answering it. /// /// `None` is the third answer and it matters: a record that says nothing /// about the turn leaves the status alone rather than voting for idle. The /// same goes for a record whose reason for stopping is missing, which is /// what a future CLI adding a shape we do not know looks like. /// /// What this cannot see is a session that stopped existing mid-turn -- its /// file's last record still says `tool_use`, so it reads as working /// forever. Nothing in the file distinguishes that from a model thinking, /// and inventing a timeout here would replace a stale reading with a /// confident wrong one. fn turn_state(record: &Value) -> Option { use super::driver::SessionStatus; match record.get("type").and_then(Value::as_str)? { "user" => Some(SessionStatus::Running), "assistant" => match record["message"] .get("stop_reason") .and_then(Value::as_str)? { "tool_use" => Some(SessionStatus::Running), _ => Some(SessionStatus::Idle), }, _ => None, } } fn push_user(events: &mut Vec, content: &Value, session_dir: &std::path::Path) { // A tool result arrives as a user record, because that is how the API // models it -- but it is the other half of a tool call, not something // a person said, and showing it as a message would put the reader's // own words and a command's output in the same voice. if let Value::Array(blocks) = content { for block in blocks { // A picture the person attached to their own message, rather // than one a tool produced. Same block shape, one level up. push_images(events, std::slice::from_ref(block), session_dir, None); if block.get("type").and_then(Value::as_str) == Some("tool_result") && let Some(id) = block.get("tool_use_id").and_then(Value::as_str) { // Before the tool's own row, matching the live translator: // a screenshot belongs to the call that took it, and after // the result it reads as belonging to whatever came next. if let Some(Value::Array(parts)) = block.get("content") { push_images(events, parts, session_dir, Some(id)); } events.push(Event::ToolEnd { id: id.to_string(), output: text_of(block.get("content").unwrap_or(&Value::Null)), }); } } } let text = text_of(content); if !text.trim().is_empty() { // Replayed from the CLI's own file: it was read long ago, so // there is no waiting bubble for it to resolve. // The images in this record are saved and referenced separately just // above, because a replayed message's pictures came out of somebody // else's file rather than out of this app's composer -- there is no // upload here whose refs could ride on the message. events.push(Event::UserMessage { id: None, text, images: Vec::new(), }); } } /// Saves every image block in `parts` and references each one. /// /// `about` is the call the images came out of, or `None` for one a person attached /// to their own message -- the same distinction the live translator makes, so replayed /// history draws a screenshot under the call that took it exactly as a live one does. fn push_images( events: &mut Vec, parts: &[Value], session_dir: &std::path::Path, about: Option<&str>, ) { for part in parts { if part.get("type").and_then(Value::as_str) == Some("image") && let Some(name) = super::claude::translate::save_image(session_dir, part) { events.push(Event::Image { image: name, about: about.map(String::from), }); } } } fn push_assistant(events: &mut Vec, content: &Value) { let Value::Array(blocks) = content else { return; }; for block in blocks { match block.get("type").and_then(Value::as_str) { Some("text") => { if let Some(text) = block.get("text").and_then(Value::as_str) && !text.is_empty() { events.push(Event::AssistantText { delta: text.to_string(), }); } } Some("tool_use") => { if let (Some(id), Some(name)) = ( block.get("id").and_then(Value::as_str), block.get("name").and_then(Value::as_str), ) { events.push(Event::ToolStart { id: id.to_string(), tool: name.to_string(), input: block.get("input").cloned().unwrap_or(Value::Null), }); } } _ => {} } } } /// Deletes one of the sessions [`list`] reported. /// /// By id, resolved here against what the machine actually has, so the /// caller never names a file -- the same rule importing follows, and it /// matters more here: this one removes something. /// /// Irreversible, and the caller is expected to have said so. Claude Code /// keeps no copy: the JSONL *is* the session, so deleting it ends any /// chance of resuming that conversation, including from an ai-app session /// that was already importing it. pub async fn delete(transport: &Transport, id: &str) -> Result<()> { // The file name *is* the id, so the machine can find it by name. This // used to call `list` and search its output, which is correct and costs // a full read of every transcript on the machine -- around four seconds // against a gigabyte of them, per delete, so a batch of ten took the // best part of a minute doing nothing but re-reading the same files. // `context_of` below already resolved an id the cheap way; this is the // same lookup, and the two now agree. ensure!(is_session_id(id), "not a Claude Code session id: {id}"); let script = r#" for f in "$HOME"/.claude/projects/*/"$1".jsonl; do [ -f "$f" ] || continue rm -f "$f" || exit 1 printf '%s\n' "$f" exit 0 done "#; let launch = Launch::new( "sh", vec![ "-c".to_string(), script.to_string(), "sh".to_string(), id.to_string(), ], None, ); // Nothing on stdout means the loop found no such file. Said here rather // than by exiting non-zero, because a non-zero exit is reported as the // machine being unreachable -- which is a different thing from the // session not being there, and only one of them is worth retrying. let removed = transport .capture(&launch) .await .with_context(|| format!("deleting Claude Code session {id}"))?; if removed.trim().is_empty() { bail!("no Claude Code session {id} on that machine"); } Ok(()) } /// Whether an id is one of ours to put in a shell glob. /// /// Both places that resolve an id to a file interpolate it into /// `$HOME/.claude/projects/*/"$1".jsonl`. That is an argument rather than /// script text, so a shell cannot be talked into running something -- but a /// `/` or a `..` inside it still walks the glob out of the directory the id /// is supposed to name. [`delete`] is where that would be fatal, because it /// removes whatever it lands on, and it is exactly the reason `delete` used /// to resolve ids by searching a listing instead. /// /// Claude Code names each transcript with a uuid, so hex and dashes is the /// whole alphabet. Refused rather than escaped: an id that is not one of /// these did not come from the list this app showed. fn is_session_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') } /// How often an imported session checks whether its source file grew. /// /// A poll rather than a watch, because the file may be on another machine /// and there is no portable way to be told. Ten seconds is chosen against /// the cost of an ssh round trip rather than against how fast a person /// types: nothing here is waiting on it, and the events arrive on the same /// stream as everything else once they do. pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); /// Where an imported session came from, and how much of it has been shown. /// /// Kept beside the session rather than in its config, because it is a /// position in someone else's file rather than anything the person chose, /// and it changes constantly. #[derive(Debug, Clone, Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct Cursor { /// Server-side only, and resolved once at import. Nothing accepts a /// path from the phone; this is the path *we* found. pub path: String, /// Lines of that file already accounted for -- whether replayed into /// the transcript or skipped because this session wrote them itself. pub lines: usize, } const CURSOR_FILE: &str = "import.json"; pub fn read_cursor(session_dir: &std::path::Path) -> Option { let text = std::fs::read_to_string(session_dir.join(CURSOR_FILE)).ok()?; serde_json::from_str(&text).ok() } pub fn write_cursor(session_dir: &std::path::Path, cursor: &Cursor) { let path = session_dir.join(CURSOR_FILE); match serde_json::to_string(cursor) { Ok(text) => { if let Err(err) = std::fs::write(&path, text) { tracing::error!( "couldn't persist the import cursor to {}: {err}", path.display() ); } } Err(err) => tracing::error!("couldn't serialize the import cursor: {err}"), } } /// How many lines the source file has now. pub async fn line_count(transport: &Transport, path: &str) -> Result { let launch = Launch::new("wc", vec!["-l".to_string(), path.to_string()], None); let out = transport.capture(&launch).await?; out.split_whitespace() .next() .and_then(|n| n.parse().ok()) .with_context(|| format!("couldn't read a line count out of {out:?}")) } /// What the CLI's own file says a session is holding, for a session this /// server has no measurement of. /// /// A restarting server has been told nothing, and a session that has not /// taken a turn since will not tell it -- so a conversation that is nearly /// full reads as one nobody has counted until somebody sends a message to /// it. The CLI records the figure on every assistant message, so it is /// there to be read rather than waited for, and reading it is a /// measurement rather than a guess: the same three fields, from the same /// file, that the import list reports. /// /// A clear needs no special case here even though it makes the last usage /// in a file stale. Clearing gives the CLI a *new* session id, which the /// reader persists as the resume token, so this looks in a file that has /// no usage in it yet and answers `None` -- which is the true answer. /// /// `None` for every way it cannot be read: no resume token, no file, a /// machine that cannot be reached, or a file with no assistant turn in it. /// Not knowing is a state the status row draws, so there is nothing to be /// gained by inventing a number here. pub async fn context_of(transport: &Transport, session_id: &str) -> Option { // The same guard `delete` explains, applied to the other member of the // set: this one only reads, but a glob that can leave the directory is // worth closing in both places rather than in the dangerous one only. if !is_session_id(session_id) { return None; } // The id crosses as an argument rather than as script text: it comes // from the CLI, but it reaches a shell on a machine that may not be // this one, and the rule there is that data never becomes syntax. let script = r#" for f in "$HOME"/.claude/projects/*/"$1".jsonl; do [ -f "$f" ] || continue grep -o '"usage":{[^}]*' "$f" | tail -1 exit 0 done "#; let launch = Launch::new( "sh", vec![ "-c".to_string(), script.to_string(), "sh".to_string(), session_id.to_string(), ], None, ); context_tokens(&transport.capture(&launch).await.ok()?) } /// Events from the lines after `after`, which is a 0-based count of lines /// already accounted for. pub async fn replay_after( transport: &Transport, path: &str, after: usize, session_dir: &std::path::Path, ) -> Result> { let launch = Launch::new( "tail", vec![format!("-n+{}", after + 1), path.to_string()], None, ); let text = transport .capture(&launch) .await .with_context(|| format!("reading {path} from line {}", after + 1))?; Ok(events_from(&text, session_dir)) } #[cfg(test)] mod tests { use super::*; /// The guard on the only thing this module ever puts in a glob. /// /// Worth a test of its own because what it protects is a `rm`: `delete` /// resolves an id straight to `$HOME/.claude/projects/*/"$1".jsonl`, so /// an id that can contain a slash or a `..` is an id that can name a /// file outside the directory and have it removed. #[test] fn a_session_id_cannot_walk_out_of_the_projects_directory() { assert!(is_session_id("5ecf21da-d53f-4a11-9c0d-000000000100")); assert!(is_session_id("deadbeef")); assert!(!is_session_id("../../../etc/passwd")); assert!(!is_session_id("a/b")); assert!(!is_session_id("..")); assert!(!is_session_id("a.b")); assert!(!is_session_id("a*")); assert!(!is_session_id("a b")); // Empty would glob to the directory itself, and a long one is not a // uuid whatever else it is. assert!(!is_session_id("")); assert!(!is_session_id(&"a".repeat(65))); } use super::*; /// A 1x1 PNG, base64 -- the smallest thing with a real header. const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk\ YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; #[test] fn replayed_screenshots_are_saved_and_referenced() { let dir = tempfile::tempdir().expect("tempdir"); // The shape a screenshot actually has in these files: an image // part inside a tool result, beside its text. let line = format!( r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_1","content":[{{"type":"text","text":"took a screenshot"}},{{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{PNG}"}}}}]}}]}}}}"# ); let events = events_from(&line, dir.path()); let Some(Event::Image { image, .. }) = events.first() else { panic!("a replayed screenshot must become an image event: {events:?}"); }; assert!(image.ends_with(".png")); // On disk, where the files route serves it from -- the phone // fetches it only when something draws it. assert!(dir.path().join("files").join(image).is_file()); // And it comes before the tool row it belongs to, so it does not // read as belonging to whatever happened next. assert!( matches!(events.get(1), Some(Event::ToolEnd { .. })), "{events:?}" ); } #[test] fn context_tokens_add_the_input_side_only() { // The shape the CLI records, as captured from a real transcript. let usage = r#""usage":{"input_tokens":2,"cache_creation_input_tokens":703,"cache_read_input_tokens":142228,"output_tokens":587,"output_tokens_details":{"thinking_tokens":0"#; // 2 + 703 + 142228. Output is not context to carry forward, so it // is not in the total; if it were, this would read 143520. assert_eq!(context_tokens(usage), Some(142_933)); // The leading quote is load-bearing: without it "input_tokens" // matches inside both cache field names and the prompt figure gets // counted three times. let only_cache = r#""usage":{"cache_read_input_tokens":100,"output_tokens":9"#; assert_eq!(context_tokens(only_cache), Some(100)); // No assistant turn yet is not a context of zero. assert_eq!(context_tokens(""), None); assert_eq!(context_tokens(" "), None); } #[test] fn a_record_with_no_image_writes_nothing() { let dir = tempfile::tempdir().expect("tempdir"); let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"plain output"}]}}"#; let events = events_from(line, dir.path()); // The result, and the turn state it implies: a tool has answered, // so the model is about to be asked again. assert_eq!(events.len(), 2, "{events:?}"); assert_eq!( events[1], Event::Status { state: super::super::driver::SessionStatus::Running } ); // No stray directory for a session that never produced one. assert!(!dir.path().join("files").exists()); } #[test] fn a_message_from_another_agent_is_kept_and_named() { // The real shape, from a session file: the CLI marks these meta, // and everything a reader needs is in `origin`. let dir = tempfile::tempdir().expect("tempdir"); let line = r#"{"type":"user","isMeta":true,"origin":{"kind":"peer","from":"uds:/run/user/1000/cc-socks/605.sock","verifiedPeerPid":605,"name":"dev-updater-f5","fromMode":"prompting","body":"Pull before you touch AGENTS.md."},"message":{"role":"user","content":"Another Claude session sent a message:\n\nPull before you touch AGENTS.md.\n"}}"#; let events = events_from(line, dir.path()); assert_eq!( events[0], Event::PeerMessage { from: "dev-updater-f5".to_string(), // The body, not the wrapper the model is given. text: "Pull before you touch AGENTS.md.".to_string(), }, "{events:?}" ); // And it counts as the session having been given something. assert_eq!( events[1], Event::Status { state: super::super::driver::SessionStatus::Running } ); } #[test] fn the_last_record_says_whether_the_session_is_working() { use super::super::driver::SessionStatus; let dir = tempfile::tempdir().expect("tempdir"); let asked = r#"{"type":"user","message":{"role":"user","content":"do the thing"}}"#; let calling = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}"#; let done = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"done"}]}}"#; let state = |text: &str| { events_from(text, dir.path()) .into_iter() .rev() .find_map(|event| match event { Event::Status { state } => Some(state), _ => None, }) }; assert_eq!(state(asked), Some(SessionStatus::Running)); assert_eq!( state(&[asked, calling].join("\n")), Some(SessionStatus::Running) ); assert_eq!( state(&[asked, calling, done].join("\n")), Some(SessionStatus::Idle), "a turn that has finished talking is over" ); // A subagent's own messages are not the session's turn, and a // record with no stop reason is not an answer -- neither may // overrule what the conversation itself last said. let sidechain = r#"{"type":"assistant","isSidechain":true,"message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"sub"}]}}"#; let unknown = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"?"}]}}"#; assert_eq!( state(&[asked, calling, sidechain, unknown].join("\n")), Some(SessionStatus::Running) ); // And nothing at all to go on says nothing, rather than idle. assert_eq!(state(r#"{"type":"summary","summary":"x"}"#), None); } }