Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
5428cd75c9
commit
25370731d0
193 files changed
+693
-16219
No files matched your search
@@ -1,22 +1,3 @@
|
||||
//! 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};
|
||||
@@ -26,77 +7,34 @@ 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. So this is a
|
||||
/// display budget, and it needs to be one: these files reach tens of megabytes
|
||||
/// and every line 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 puts
|
||||
/// the dangerous case behind the safe word.
|
||||
#[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, which is the closest thing
|
||||
/// to "what continuing this costs" and is a number the CLI 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.
|
||||
///
|
||||
/// `None` when no assistant turn has recorded usage yet -- which is not
|
||||
/// zero, and is why this is an option.
|
||||
pub context_tokens: Option<u64>,
|
||||
/// Size of the file, in bytes. Reported because it 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.
|
||||
///
|
||||
/// Shown rather than warned about: importing a large session is a choice
|
||||
/// somebody is entitled to make.
|
||||
pub bytes: u64,
|
||||
/// Whether [`title`](Self::title) is a name somebody chose rather than
|
||||
/// something read out of the conversation. 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 already open
|
||||
/// puts a second `--resume` on one file: the conversation gets duplicated
|
||||
/// into it, both copies 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.
|
||||
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 either direction.
|
||||
@@ -104,14 +42,7 @@ pub struct Importable {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Asks `transport`'s machine which Claude Code sessions it has.
|
||||
///
|
||||
/// One command rather than one per file: over ssh each would be its own
|
||||
/// connection and handshake. `stat -c` is GNU-specific, which 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 records `procStart` -- the kernel's
|
||||
// start time for that pid -- for the same reason `session::process` does: a
|
||||
@@ -119,35 +50,17 @@ pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||
// 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.
|
||||
//
|
||||
// Then two questions per file, both answered from the end of it. A rename
|
||||
// if there was one, 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*, because the question
|
||||
// this answers is "which one was I just in", and several because the final
|
||||
// ones are often the CLI's own.
|
||||
//
|
||||
// Tool results are excluded rather than typed messages included, and the
|
||||
// difference matters: a tool result is *also* a user record, so grepping
|
||||
// the type alone gave a session that ended mid-tool a tail of empty records.
|
||||
// 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, and used to get it by listing
|
||||
/// *every* session and searching the result -- a full read of every transcript
|
||||
/// on the machine, seconds of it, to answer a question about one file, paid
|
||||
/// once per import in a batch. 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);
|
||||
@@ -163,12 +76,6 @@ pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>>
|
||||
.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: a row has
|
||||
/// to mean the same thing whether it came from a listing or a lookup. The glob
|
||||
/// is this module's own text; the only thing that crosses from outside is the
|
||||
/// id, which stays an argument 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,
|
||||
// and every one would have to be doubled to survive a format string --
|
||||
@@ -201,7 +108,6 @@ for f in {glob}; do
|
||||
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;
|
||||
@@ -225,15 +131,6 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
||||
// addresses: `--resume` takes it, deleting globs for it, the in-flight
|
||||
// registry is keyed on it, and the phone keys its list on it -- which
|
||||
// turned two rows sharing an id into a crash rather than a confusion.
|
||||
//
|
||||
// It is a real state of the machine, not corruption: resuming from a
|
||||
// different working directory makes the CLI write a second file under that
|
||||
// directory's project folder with the same id. One is then usually a stub
|
||||
// of a few hundred bytes.
|
||||
//
|
||||
// So the copy with the most in it wins, and the row's `cwd` comes from that
|
||||
// same copy. Ties go to the more recent, and the *stub* is often the more
|
||||
// recent, so size has to be the first key rather than the tie-break.
|
||||
sessions.sort_by(|a, b| {
|
||||
b.lines
|
||||
.cmp(&a.lines)
|
||||
@@ -242,15 +139,10 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
||||
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. A name still shows, as the row's title and as a word
|
||||
// beside it.
|
||||
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()?;
|
||||
@@ -279,17 +171,12 @@ fn parse_row(line: &str) -> Option<Importable> {
|
||||
if !is_hidden(&record)
|
||||
&& let Some(text) = first_line_of(&record)
|
||||
{
|
||||
// Kept rather than broken out of: these arrive oldest first, so the
|
||||
// last to survive the filter is the most recent thing 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,
|
||||
@@ -318,9 +205,6 @@ 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 is counted three
|
||||
// times.
|
||||
let field = |name: &str| -> u64 {
|
||||
usage
|
||||
.split_once(&format!("\"{name}\":"))
|
||||
@@ -338,12 +222,6 @@ fn context_tokens(usage: &str) -> Option<u64> {
|
||||
))
|
||||
}
|
||||
|
||||
/// 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 `isMeta` -- so titling by "first user record"
|
||||
/// gave a list where most rows read `<command-name>/clear</command-name>`.
|
||||
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();
|
||||
@@ -354,17 +232,11 @@ fn first_line_of(record: &Value) -> Option<String> {
|
||||
(!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 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)
|
||||
}
|
||||
|
||||
/// Whether a `tool_result` block says the call itself failed.
|
||||
///
|
||||
/// One reader for the field rather than one per caller: the live
|
||||
/// translator (`translate.rs`) and this replay of the CLI's own file look
|
||||
/// at the same block shape, and a call drawn as failed in one and as
|
||||
@@ -392,12 +264,6 @@ fn text_of(content: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a directory the machine recorded is still there.
|
||||
///
|
||||
/// A session's recorded cwd can outlive the directory: these files go back
|
||||
/// months, and a checkout that moved leaves every session from before it
|
||||
/// pointing at a path that is gone. Resuming into one fails at `cd` before the
|
||||
/// CLI starts.
|
||||
pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
||||
if path.is_empty() {
|
||||
return false;
|
||||
@@ -411,8 +277,6 @@ pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
||||
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.
|
||||
///
|
||||
@@ -431,15 +295,6 @@ pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
||||
.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.
|
||||
///
|
||||
/// `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 happening or replayed afterwards.
|
||||
/// Only the *reference* reaches the phone.
|
||||
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.
|
||||
@@ -482,14 +337,6 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||
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 as `body`. The message content beside it
|
||||
/// is the same text wrapped in a preamble written for the model rather than for
|
||||
/// a person, so the body is what a reader is shown.
|
||||
///
|
||||
/// Shared with the live driver, 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
|
||||
@@ -508,27 +355,10 @@ pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
|
||||
.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 the file can
|
||||
/// say.
|
||||
///
|
||||
/// The one thing a session file does not contain is the CLI saying "this turn
|
||||
/// is over": there is no `result` record. What there is instead is why the last
|
||||
/// assistant message stopped -- `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 means the session has something to answer.
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// 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 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)? {
|
||||
@@ -551,14 +381,10 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
||||
// 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.
|
||||
if let Some(Value::Array(parts)) = block.get("content") {
|
||||
push_images(events, parts, session_dir, Some(id));
|
||||
}
|
||||
@@ -584,10 +410,6 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn push_images(
|
||||
events: &mut Vec<Event>,
|
||||
parts: &[Value],
|
||||
@@ -638,20 +460,6 @@ fn push_assistant(events: &mut Vec<Event>, content: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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" --
|
||||
/// only one of them is worth retrying.
|
||||
///
|
||||
/// Every copy of each id, not the first. The same id can name a file under two
|
||||
/// project directories, and stopping at the first left the other behind, so the
|
||||
/// row came back on the next listing after a delete that reported success.
|
||||
/// `failed` therefore sticks once set.
|
||||
///
|
||||
/// Ids arrive as arguments rather than in the script text; `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
|
||||
@@ -667,8 +475,6 @@ for id do
|
||||
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.
|
||||
@@ -690,9 +496,6 @@ 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. 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()),
|
||||
@@ -712,16 +515,6 @@ pub async fn delete(
|
||||
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.
|
||||
//
|
||||
// Every copy of each id, not the first: the same id can name a file under
|
||||
// two project directories, and stopping at the first left the other behind.
|
||||
//
|
||||
// Each id prints its own verdict rather than the loop exiting on the first
|
||||
// failure, which would leave every id after it unexplained.
|
||||
let mut args = vec![
|
||||
"-c".to_string(),
|
||||
DELETE_SCRIPT.to_string(),
|
||||
@@ -730,8 +523,6 @@ pub async fn delete(
|
||||
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.
|
||||
let reported = transport
|
||||
.capture(&launch)
|
||||
.await
|
||||
@@ -765,22 +556,10 @@ pub async fn delete(
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// Whether an id is one of ours to put in a shell glob.
|
||||
///
|
||||
/// Both places that resolve an id 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, and [`delete`] removes whatever it lands on.
|
||||
///
|
||||
/// Claude Code names each transcript with a uuid, so hex and dashes is the
|
||||
/// whole alphabet. Refused rather than escaped.
|
||||
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.
|
||||
@@ -793,8 +572,6 @@ pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cursor {
|
||||
/// Server-side only, 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.
|
||||
@@ -823,7 +600,6 @@ pub fn write_cursor(session_dir: &std::path::Path, cursor: &Cursor) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
@@ -833,17 +609,6 @@ pub async fn line_count(transport: &Transport, path: &str) -> Result<usize> {
|
||||
.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
|
||||
@@ -854,9 +619,6 @@ pub async fn line_count(transport: &Transport, path: &str) -> Result<usize> {
|
||||
/// 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;
|
||||
}
|
||||
@@ -883,8 +645,6 @@ done
|
||||
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,
|
||||
@@ -907,17 +667,6 @@ pub async fn replay_after(
|
||||
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";
|
||||
@@ -933,17 +682,9 @@ mod tests {
|
||||
|
||||
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"));
|
||||
@@ -955,19 +696,10 @@ mod tests {
|
||||
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");
|
||||
@@ -992,21 +724,17 @@ mod tests {
|
||||
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}"}}}}]}}]}}}}"#
|
||||
);
|
||||
@@ -1016,12 +744,8 @@ mod tests {
|
||||
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:?}"
|
||||
@@ -1030,19 +754,12 @@ mod tests {
|
||||
|
||||
#[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);
|
||||
}
|
||||
@@ -1052,8 +769,6 @@ mod tests {
|
||||
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],
|
||||
@@ -1061,14 +776,11 @@ mod tests {
|
||||
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());
|
||||
@@ -1076,13 +788,11 @@ mod tests {
|
||||
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 {
|
||||
@@ -1119,9 +829,6 @@ mod tests {
|
||||
"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!(
|
||||
@@ -1129,7 +836,6 @@ mod tests {
|
||||
Some(SessionStatus::Running)
|
||||
);
|
||||
|
||||
// And nothing at all to go on says nothing, rather than idle.
|
||||
assert_eq!(state(r#"{"type":"summary","summary":"x"}"#), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user