Condense the documentation and thin the server's comments
The markdown had accumulated a lot that was stale rather than wrong. PLAN.md still described pi as the llama.cpp harness, a refcounted LlamaServerManager, and a providers-by-hosts cross-product, all of which were superseded or never built; it also carried a second copy of the HTTP table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held implementation checklists for work that has since landed. AGENTS.md restated most of PLAN.md's design instead of being the working-notes layer it says it is. 3225 lines of markdown to 2180, with the stale sections gone rather than reworded. On the server, comments explaining what the code already says are out and the ones recording a constraint, a measurement or an incident are kept but cut to a few lines each: 5504 comment lines to 4586. Four doc comments in session/mod.rs, and one each in process.rs and usage.rs, had drifted onto the item above the one they describe -- functions were reordered without them, so `stop_session`'s doc sat on `set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on `type Cached`. Each is back on its own item. routes.rs's module table also claimed later phases would add `/hosts`, which setups replaced. cargo test (127 passed), clippy --all-targets and fmt are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3e02d55f7
commit
79682f03a7
24 files changed
+4572
-6821
No files matched your search
+222
-321
@@ -28,20 +28,15 @@ 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.
|
||||
/// 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 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.
|
||||
/// 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 {
|
||||
@@ -49,8 +44,8 @@ pub enum InUse {
|
||||
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".
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -61,113 +56,87 @@ 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.
|
||||
/// 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.
|
||||
/// 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. A 77 MB file whose context is 10k tokens is
|
||||
/// cheap to continue; a smaller one that has never compacted may not
|
||||
/// be.
|
||||
/// 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 rather than a default.
|
||||
/// `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.
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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. 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.
|
||||
/// 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 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.
|
||||
/// 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 in
|
||||
/// either direction.
|
||||
/// 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.
|
||||
#[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.
|
||||
/// 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 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.
|
||||
// `~/.claude/sessions/<pid>.json`, and 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 by a crashed CLI 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.
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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?)
|
||||
@@ -175,13 +144,10 @@ pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||
|
||||
/// 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.
|
||||
/// 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);
|
||||
@@ -199,18 +165,14 @@ pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>>
|
||||
|
||||
/// 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.
|
||||
/// 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 -- `${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.
|
||||
// `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 --
|
||||
// exactly the kind of edit that looks right and changes what the shell runs.
|
||||
SCRIPT.replace("{glob}", glob)
|
||||
}
|
||||
|
||||
@@ -260,21 +222,18 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
||||
};
|
||||
}
|
||||
// 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.
|
||||
// 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 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.
|
||||
// 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 -- 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.
|
||||
// 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)
|
||||
@@ -283,12 +242,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, 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.
|
||||
// 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)
|
||||
}
|
||||
@@ -322,23 +279,21 @@ 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 one to survive the filter is the most recent
|
||||
// thing that was actually said.
|
||||
// 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.
|
||||
// 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.
|
||||
// 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)
|
||||
@@ -351,24 +306,21 @@ fn parse_row(line: &str) -> Option<Importable> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The input tokens named in one `usage` object, added up: prompt plus cache
|
||||
/// creation plus cache read, all three being 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.
|
||||
/// Missing fields count as zero, which is what an absent category means.
|
||||
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.
|
||||
// inside `"cache_read_input_tokens"`, and the same number is counted three
|
||||
// times.
|
||||
let field = |name: &str| -> u64 {
|
||||
usage
|
||||
.split_once(&format!("\"{name}\":"))
|
||||
@@ -388,12 +340,10 @@ 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 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.
|
||||
/// 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();
|
||||
@@ -404,19 +354,15 @@ 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 into this transcript would
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
@@ -432,36 +378,31 @@ fn text_of(content: &Value) -> String {
|
||||
|
||||
/// 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".
|
||||
/// 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;
|
||||
}
|
||||
// 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.
|
||||
// question this stands 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 literal on both transports, 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.
|
||||
/// `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`].
|
||||
/// Returns text rather than events because turning records into events has a
|
||||
/// side effect -- writing out the images they carry -- and that needs the
|
||||
/// session directory, which does not exist until after this runs.
|
||||
pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
||||
let launch = Launch::new(
|
||||
"tail",
|
||||
@@ -477,32 +418,28 @@ pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
||||
/// 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.
|
||||
/// 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 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.
|
||||
/// 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. Kept to the end rather than pushed as it is found, because
|
||||
// the answer is the last one and everything before it is history.
|
||||
// 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.
|
||||
// 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 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;
|
||||
@@ -531,20 +468,18 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||
|
||||
/// 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.
|
||||
/// 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 (`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.
|
||||
/// 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
|
||||
/// time the CLI renames a field, and the half that drifted would produce
|
||||
/// 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") {
|
||||
@@ -562,27 +497,22 @@ pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this record means the session is working, as far as it can be
|
||||
/// told from the file.
|
||||
/// 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, 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.
|
||||
/// 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. 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.
|
||||
/// `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 here would replace a stale reading with a
|
||||
/// confident wrong one.
|
||||
/// 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)? {
|
||||
@@ -600,20 +530,19 @@ fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
|
||||
|
||||
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.
|
||||
// models it -- but it is the other half of a tool call, 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.
|
||||
// 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.
|
||||
// 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));
|
||||
}
|
||||
@@ -626,12 +555,10 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
||||
}
|
||||
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.
|
||||
// Replayed from the CLI's own file: it was read long ago, so there is
|
||||
// no waiting bubble for it to resolve. Its images are saved and
|
||||
// referenced separately just above, because they came out of somebody
|
||||
// else's file rather than this app's composer.
|
||||
events.push(Event::UserMessage {
|
||||
id: None,
|
||||
text,
|
||||
@@ -640,11 +567,10 @@ 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 exactly as a live one does.
|
||||
/// 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],
|
||||
@@ -698,21 +624,17 @@ 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"
|
||||
/// rather than folded together by the shell -- only one of them is worth
|
||||
/// retrying, and the caller is what knows how to word either.
|
||||
/// 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 -- 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.
|
||||
/// 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, so nothing here
|
||||
/// is shell syntax; `is_session_id` is what keeps one from globbing its way
|
||||
/// out of the projects directory.
|
||||
/// 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
|
||||
@@ -730,37 +652,30 @@ 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.
|
||||
/// 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.
|
||||
/// Irreversible, and the caller is expected to have said so. Claude Code keeps
|
||||
/// no copy: the JSONL *is* the session.
|
||||
///
|
||||
/// 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.
|
||||
/// 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 past the remote
|
||||
/// sshd's `MaxStartups` had rows come back as `Connection closed by …` -- a row
|
||||
/// reporting a delete that never ran, for a reason nothing to do with the
|
||||
/// session.
|
||||
///
|
||||
/// 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.
|
||||
/// Still one outcome per id, because a batch is not a transaction. 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.
|
||||
// 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()),
|
||||
@@ -780,23 +695,16 @@ 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, 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.
|
||||
// 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 -- 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.
|
||||
// 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: with a batch, exiting would leave every id after it
|
||||
// unexplained. See [`DELETE_SCRIPT`] for what the words mean.
|
||||
// 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(),
|
||||
@@ -806,8 +714,7 @@ pub async fn delete(
|
||||
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.
|
||||
// which is true of every id in the batch rather than of any one of them.
|
||||
let reported = transport
|
||||
.capture(&launch)
|
||||
.await
|
||||
@@ -826,10 +733,10 @@ pub async fn delete(
|
||||
},
|
||||
);
|
||||
}
|
||||
// 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".
|
||||
// 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 read as either a
|
||||
// success or a clean "not there".
|
||||
for id in safe {
|
||||
outcomes.entry(id.clone()).or_insert_with(|| {
|
||||
Err(format!(
|
||||
@@ -843,40 +750,34 @@ pub async fn delete(
|
||||
|
||||
/// 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
|
||||
/// 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. [`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.
|
||||
/// 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: an id that is not one of
|
||||
/// these did not come from the list this app showed.
|
||||
/// 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: nothing here is waiting on it, and the events arrive on the same
|
||||
/// stream as everything else once they do.
|
||||
/// 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.
|
||||
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.
|
||||
/// 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.
|
||||
/// 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.
|
||||
|
||||
Reference in new issue
Block a user