The composer's settings row is outlined bubbles opening round menus, and the message box is a TextFieldValue so anything put into it without being typed -- a draft, a share, a slash command -- leaves the cursor at the end. A tap that puts a text selection away no longer also collapses the card the text was drawn in: every open and close on the session screen goes through one guard that spends such a press on the selection. The usage bar and the usage dialog were two polls of one measurement and disagreed for up to a minute at a time; they are one feed now, and the countdown rounds up to the minute in the one place both read. A working directory typed as ~/repos/ai-app was four literal characters on the local transport and as an argument on both, so the existence check refused every home-relative path. It is checked by entering the directory now, expanded for a local spawn the way the remote shell expands it, and stored short so the phone draws what somebody would write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1218 lines
54 KiB
Rust
1218 lines
54 KiB
Rust
//! Adopting a Claude Code session that already exists on a machine.
|
|
//!
|
|
//! Claude Code keeps every session as JSONL under
|
|
//! `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, and the CLI can
|
|
//! be told to continue one with `--resume <id>`. 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 std::collections::HashMap;
|
|
|
|
use anyhow::{Context, Result};
|
|
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<u64>,
|
|
/// 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<Vec<Importable>> {
|
|
// Which sessions are open right now, before the files themselves.
|
|
//
|
|
// Claude Code writes a descriptor per live session at
|
|
// `~/.claude/sessions/<pid>.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 = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#);
|
|
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
|
parse_listing(&transport.capture(&launch).await?)
|
|
}
|
|
|
|
/// The same listing, for one session named by id.
|
|
///
|
|
/// Importing needs everything a row holds -- the path to follow, how many
|
|
/// lines have already been written, what it is called, where it was working
|
|
/// and whether something else has it open -- and used to get them by
|
|
/// listing *every* session and searching the result. That is a full read of
|
|
/// every transcript on the machine, seconds of it, to answer a question
|
|
/// about one file; a batch of imports paid it once each. Same script, same
|
|
/// parsing, one glob narrower.
|
|
pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> {
|
|
if !is_session_id(id) {
|
|
return Ok(None);
|
|
}
|
|
let script = listing_script(r#""$HOME"/.claude/projects/*/"$1".jsonl"#);
|
|
let launch = Launch::new(
|
|
"sh",
|
|
vec!["-c".to_string(), script, "sh".to_string(), id.to_string()],
|
|
None,
|
|
);
|
|
Ok(parse_listing(&transport.capture(&launch).await?)?
|
|
.into_iter()
|
|
.find(|candidate| candidate.id == id))
|
|
}
|
|
|
|
/// What the machine is asked, over whichever set of files `glob` names.
|
|
///
|
|
/// One script with the glob substituted rather than two that drift: the
|
|
/// per-file half decides what a row *is*, and a row has to mean the same
|
|
/// thing whether it arrived from a listing or from a lookup. The glob is
|
|
/// this module's own text; the only thing that ever crosses from outside is
|
|
/// the id, which stays an argument (`$1`) and is checked by
|
|
/// [`is_session_id`] first.
|
|
fn listing_script(glob: &str) -> String {
|
|
// `replace` rather than `format!`: this is shell, so it is full of
|
|
// braces -- `${s##*/}`, an awk program, the `{[^}]*` that finds a usage
|
|
// record -- and every one of them would have to be doubled to survive a
|
|
// format string. Doubling braces inside a script is exactly the kind of
|
|
// edit that looks right and changes what the shell runs.
|
|
SCRIPT.replace("{glob}", glob)
|
|
}
|
|
|
|
const SCRIPT: &str = 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 {glob}; 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
|
|
"#;
|
|
|
|
/// Rows out of what [`listing_script`] printed, with `in_use` filled in.
|
|
fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
|
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<Importable> = 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,
|
|
};
|
|
}
|
|
// One row per session id, because the id is what everything downstream
|
|
// addresses: `--resume` takes it, deleting globs for it, and the
|
|
// in-flight registry is keyed on it. So two rows sharing an id are two
|
|
// rows that no operation can tell apart -- and the phone keys its list
|
|
// on it too, which turned this into a crash rather than a confusion.
|
|
//
|
|
// It is a real state of the machine, not corruption: resuming a session
|
|
// from a different working directory makes the CLI write a second file
|
|
// under that directory's project folder with the same id. One of the two
|
|
// is then usually a stub of a few hundred bytes and the other is the
|
|
// conversation somebody means.
|
|
//
|
|
// So the copy with the most in it wins, and the row's `cwd` comes from
|
|
// that same copy -- which is the directory `--resume` will find it under.
|
|
// Ties go to the more recent, and the *stub* is often the more recent, so
|
|
// the size has to be the first key rather than the tie-break.
|
|
sessions.sort_by(|a, b| {
|
|
b.lines
|
|
.cmp(&a.lines)
|
|
.then_with(|| b.modified.total_cmp(&a.modified))
|
|
});
|
|
let mut seen = std::collections::HashSet::new();
|
|
sessions.retain(|session| seen.insert(session.id.clone()));
|
|
|
|
// 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<Importable> {
|
|
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::<Value>(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<u64> {
|
|
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
|
|
/// `<command-name>/clear</command-name>`, which identifies nothing. The
|
|
/// caller offers several candidates for exactly this reason.
|
|
fn first_line_of(record: &Value) -> Option<String> {
|
|
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::<Vec<_>>()
|
|
.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;
|
|
}
|
|
// Asked by *entering* it rather than by `test -d <path>`, because the
|
|
// question this is standing in for is "can a session start here" and
|
|
// because a path is only expanded where it is a working directory --
|
|
// `~/repos/ai-app` as an argument stays four literal characters on
|
|
// both transports (`ssh::quote_path`, `ssh::expand_home`), so the old
|
|
// form answered "no such directory" about every home-relative path
|
|
// somebody typed.
|
|
let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path)));
|
|
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<String> {
|
|
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<Event> {
|
|
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::<Value>(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 reports 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 `<cross-session-message>` 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.
|
|
///
|
|
/// Shared with the live driver (`claude::translate`), which finds the same
|
|
/// `origin` object on a different record -- so this reads the object and
|
|
/// not the record around it. One function because it is one wire format:
|
|
/// two copies would drift the first time the CLI renames a field, and the
|
|
/// half that drifted would go on producing nothing at all, which is
|
|
/// indistinguishable from nobody having sent anything.
|
|
pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
|
|
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(),
|
|
// The session file has it in the right place already.
|
|
turn_start: None,
|
|
})
|
|
}
|
|
|
|
/// 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<super::driver::SessionStatus> {
|
|
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<Event>, 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,
|
|
attachments: 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<Event>,
|
|
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<Event>, 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),
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Removes each id it is given and prints one `<id>\t<state>` line per id.
|
|
///
|
|
/// The three states are every way removing one id can end: `deleted` if at
|
|
/// least one file went, `missing` if the glob matched nothing, `failed` if
|
|
/// an `rm` refused. "Not there" is deliberately kept apart from "it broke"
|
|
/// rather than folded together by the shell -- only one of them is worth
|
|
/// retrying, and the caller is what knows how to word either.
|
|
///
|
|
/// Every copy of each id, not the first. The same id can name a file under
|
|
/// two project directories -- see the de-duplication in `parse_listing` --
|
|
/// and stopping at the first left the other behind, so the row came back on
|
|
/// the next listing after a delete that had reported success. `failed`
|
|
/// therefore sticks once set: one copy removed and another refused is not a
|
|
/// success.
|
|
///
|
|
/// Ids arrive as arguments rather than in the script text, so nothing here
|
|
/// is shell syntax; `is_session_id` is what keeps one from globbing its way
|
|
/// out of the projects directory.
|
|
const DELETE_SCRIPT: &str = r#"
|
|
for id do
|
|
state=missing
|
|
for f in "$HOME"/.claude/projects/*/"$id".jsonl; do
|
|
[ -f "$f" ] || continue
|
|
if rm -f "$f"; then
|
|
[ "$state" = failed ] || state=deleted
|
|
else
|
|
state=failed
|
|
fi
|
|
done
|
|
printf '%s\t%s\n' "$id" "$state"
|
|
done
|
|
"#;
|
|
|
|
/// Deletes sessions [`list`] reported, and says what happened to each.
|
|
///
|
|
/// By id, resolved on the machine against what it 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.
|
|
///
|
|
/// The whole batch in one invocation, which over ssh is the difference
|
|
/// between one connection and one per session. Six deletes started in the
|
|
/// same tick were six `ssh` processes racing to authenticate, and a batch
|
|
/// big enough to pass the remote sshd's `MaxStartups` (10 unauthenticated
|
|
/// connections, by default, before it begins refusing) had rows come back
|
|
/// as `Connection closed by … port 2222` -- a row reporting a delete that
|
|
/// never ran, for a reason that has nothing to do with the session. One
|
|
/// connection cannot exceed that however many ids are selected.
|
|
///
|
|
/// Still one outcome per id, because a batch is not a transaction: six
|
|
/// removals that must all succeed or all roll back is not something a
|
|
/// filesystem offers, and the caller settles each row from its own line.
|
|
/// Every requested id gets an entry, so an id the machine said nothing
|
|
/// about is reported as such rather than defaulting to either answer.
|
|
pub async fn delete(
|
|
transport: &Transport,
|
|
ids: &[String],
|
|
) -> Result<HashMap<String, Result<(), String>>> {
|
|
// Refused here rather than on the machine: `is_session_id` is what
|
|
// keeps an id from walking out of the projects directory, and a bad
|
|
// one must never reach the glob. It fails only itself -- one malformed
|
|
// id is not a reason to leave the other five in place.
|
|
let (safe, mut outcomes): (Vec<&String>, HashMap<String, Result<(), String>>) =
|
|
ids.iter().fold(
|
|
(Vec::new(), HashMap::new()),
|
|
|(mut safe, mut outcomes), id| {
|
|
if is_session_id(id) {
|
|
safe.push(id);
|
|
} else {
|
|
outcomes.insert(
|
|
id.clone(),
|
|
Err(format!("not a Claude Code session id: {id}")),
|
|
);
|
|
}
|
|
(safe, outcomes)
|
|
},
|
|
);
|
|
if safe.is_empty() {
|
|
return Ok(outcomes);
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Every copy of each id, not the first. The same id can name a file
|
|
// under two project directories -- see the de-duplication in
|
|
// `parse_listing` -- and stopping at the first left the other behind,
|
|
// so the row came back on the next listing after a delete that had
|
|
// reported success.
|
|
//
|
|
// Each id prints its own verdict rather than the loop exiting on the
|
|
// first failure: with a batch, exiting would leave every id after it
|
|
// unexplained. See [`DELETE_SCRIPT`] for what the words mean.
|
|
let mut args = vec![
|
|
"-c".to_string(),
|
|
DELETE_SCRIPT.to_string(),
|
|
"sh".to_string(),
|
|
];
|
|
args.extend(safe.iter().map(|id| (*id).clone()));
|
|
let launch = Launch::new("sh", args, None);
|
|
|
|
// A failure to run the script at all is the machine being unreachable,
|
|
// which is true of every id in the batch rather than of any one of
|
|
// them -- so it is returned as the error, not written into each row.
|
|
let reported = transport
|
|
.capture(&launch)
|
|
.await
|
|
.with_context(|| format!("deleting {} Claude Code sessions", safe.len()))?;
|
|
|
|
for line in reported.lines() {
|
|
let Some((id, state)) = line.trim().split_once('\t') else {
|
|
continue;
|
|
};
|
|
outcomes.insert(
|
|
id.to_string(),
|
|
match state {
|
|
"deleted" => Ok(()),
|
|
"missing" => Err(format!("no Claude Code session {id} on that machine")),
|
|
_ => Err(format!("couldn't remove Claude Code session {id}")),
|
|
},
|
|
);
|
|
}
|
|
// Anything the machine did not mention. The connection can drop
|
|
// part-way through the loop, and an id whose line never arrived is one
|
|
// nobody knows the fate of -- which is its own answer, and must not be
|
|
// read as either a success or a clean "not there".
|
|
for id in safe {
|
|
outcomes.entry(id.clone()).or_insert_with(|| {
|
|
Err(format!(
|
|
"couldn't tell whether Claude Code session {id} was deleted -- the machine \
|
|
stopped answering part-way through the batch"
|
|
))
|
|
});
|
|
}
|
|
Ok(outcomes)
|
|
}
|
|
|
|
/// 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<Cursor> {
|
|
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<usize> {
|
|
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<u64> {
|
|
// 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<Vec<Event>> {
|
|
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::*;
|
|
|
|
/// One session id, two files, one row.
|
|
///
|
|
/// Resuming a session from a different working directory makes the CLI
|
|
/// write a second file with the same id under that directory's project
|
|
/// folder, so this is an ordinary state of a machine rather than a
|
|
/// corrupt one. Everything downstream addresses a session by id, and
|
|
/// the phone keys its list on it, so two rows sharing one was a crash.
|
|
///
|
|
/// The stub is deliberately the *newer* of the two here, because that
|
|
/// is how the real case looked: ordering by recency alone picks the
|
|
/// near-empty copy and describes the session by the wrong cwd.
|
|
#[test]
|
|
fn a_session_recorded_under_two_projects_is_offered_once() {
|
|
let id = "3114dee1-2f95-4de0-9c04-3d6fcc594afe";
|
|
let said = r#"{"cwd":"/home/bob/repos/survey","message":{"role":"user","content":"the real conversation"}}"#;
|
|
let stub = r#"{"cwd":"/home/bob/repos/elsewhere","message":{"role":"user","content":"resumed here once"}}"#;
|
|
let listing = format!(
|
|
"LIVEKNOWN\n\
|
|
1000.0\t412\t160638\t\t/home/bob/.claude/projects/-home-bob-repos-survey/{id}.jsonl\t{said}\n\
|
|
2000.0\t4\t614\t\t/home/bob/.claude/projects/-home-bob-repos-elsewhere/{id}.jsonl\t{stub}\n"
|
|
);
|
|
|
|
let rows = parse_listing(&listing).expect("parse");
|
|
|
|
assert_eq!(rows.len(), 1, "one id is one row: {rows:#?}");
|
|
assert_eq!(rows[0].lines, 412, "the conversation, not the stub");
|
|
// The cwd has to come from the copy that was kept, because that is
|
|
// the directory `--resume` will find those 412 lines under.
|
|
assert_eq!(rows[0].cwd, "/home/bob/repos/survey");
|
|
}
|
|
|
|
/// 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)));
|
|
}
|
|
|
|
/// One batch, one invocation, one verdict per id -- including for the
|
|
/// two cases a single-id delete never had to keep apart from the rest:
|
|
/// an id recorded under two project directories (both copies must go,
|
|
/// and it still reports once) and an id that is not there at all.
|
|
///
|
|
/// Runs the real script against a temporary `$HOME`, because what is
|
|
/// being checked is the shell, not the Rust around it.
|
|
#[test]
|
|
fn a_batch_deletes_every_copy_and_reports_each_id_once() {
|
|
let home = tempfile::tempdir().expect("tempdir");
|
|
let projects = home.path().join(".claude/projects");
|
|
let one = "5ecf21da-d53f-4a11-9c0d-000000000100";
|
|
let twice = "5ecf21da-d53f-4a11-9c0d-000000000200";
|
|
let absent = "5ecf21da-d53f-4a11-9c0d-000000000300";
|
|
for (project, id) in [("a", one), ("a", twice), ("b", twice)] {
|
|
let dir = projects.join(project);
|
|
std::fs::create_dir_all(&dir).expect("project dir");
|
|
std::fs::write(dir.join(format!("{id}.jsonl")), "{}\n").expect("transcript");
|
|
}
|
|
|
|
let output = std::process::Command::new("sh")
|
|
.args(["-c", DELETE_SCRIPT, "sh", one, twice, absent])
|
|
.env("HOME", home.path())
|
|
.output()
|
|
.expect("run the delete script");
|
|
assert!(output.status.success());
|
|
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&output.stdout),
|
|
format!("{one}\tdeleted\n{twice}\tdeleted\n{absent}\tmissing\n"),
|
|
);
|
|
// The second copy is the one a per-id delete used to leave behind.
|
|
assert!(!projects.join("b").join(format!("{twice}.jsonl")).exists());
|
|
assert!(!projects.join("a").join(format!("{twice}.jsonl")).exists());
|
|
assert!(!projects.join("a").join(format!("{one}.jsonl")).exists());
|
|
}
|
|
|
|
/// 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<cross-session-message from-name=\"dev-updater-f5\">\nPull before you touch AGENTS.md.\n</cross-session-message>"}}"#;
|
|
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(),
|
|
turn_start: None,
|
|
},
|
|
"{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);
|
|
}
|
|
}
|