Import a Claude Code session the machine already has
Claude Code keeps every session as JSONL under `~/.claude/projects/`, and the CLI continues one with `--resume <id>`. `claude.rs` already resumes whenever it finds a resume token in the session directory, for crash recovery -- so importing is that same path with the token written before the driver starts, and there is deliberately no second way to begin a session. The seed goes through `launch` with the ordinary spawn, so the driver never learns which kind it got. Two things the machine answers and the phone does not. **Which sessions exist.** One command per setup rather than one per file, for the reason discovery already gives: over ssh each would be its own connection. Titles come from the first few user records rather than the first, because a session opens with records the CLI injected -- slash commands, caveats around local command output -- which are stored as ordinary user records without the meta flag, so titling by "first user record" produced a list where most rows read `<command-name>/clear`. **Which file an id names.** The phone sends an id and never a path; the server looks it up again among the sessions it enumerated. An enrolled token must not be able to turn a spawn into "read me this file", which is the same rule that keeps a provider's command out of `POST /setups`. Only the tail is replayed. The imported conversation is for reading -- continuing it is the CLI's job, and it reads the whole file itself -- so this is a display budget, and it has to be one: the session this was written in is 39 MB, and all of it would otherwise cross a tunnel to a phone. A recorded working directory can outlive itself, which this found immediately: every session from before the checkouts moved to `~/repos` still records `~/host/repos/...`. Resuming into one fails at `cd` before the CLI starts -- a confusing way to meet a feature whose promise is "carry on where you left off" -- so the directory is checked, and a missing one is dropped with a log line naming it rather than being passed on to fail. Verified against this very session: 905 events replayed from the tail (351 tool calls, 350 results, 185 assistant messages, 19 mine), the resume token pointing at its id, and the stale directory reported and dropped. The list was read on the emulator, where the top row is that session under its opening sentence.
This commit is contained in:
1 parent
2a1bc84c1e
commit
6bbc829a3e
9 files changed
+726
-19
No files matched your search
@@ -0,0 +1,277 @@
|
||||
//! 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 anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::driver::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;
|
||||
|
||||
/// 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,
|
||||
/// 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>> {
|
||||
// The first few user records rather than only the first: a session
|
||||
// usually opens with meta records the CLI injected -- caveats about
|
||||
// local commands, and so on -- and titling a session with those would
|
||||
// give a list where every row reads the same.
|
||||
let script = r#"
|
||||
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" "$(wc -l < "$f")" "$f"
|
||||
grep -m8 '"type":"user"' "$f" 2>/dev/null | tr '\n' '\037'
|
||||
printf '\n'
|
||||
done
|
||||
"#;
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script.to_string()], None);
|
||||
let found = transport.capture(&launch).await?;
|
||||
|
||||
let mut sessions: Vec<Importable> = found.lines().filter_map(parse_row).collect();
|
||||
// Most recent first: the reason to open this list is almost always to
|
||||
// pick up what you were just doing.
|
||||
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(4, '\t');
|
||||
let modified: f64 = fields.next()?.trim().parse().ok()?;
|
||||
let lines: usize = fields.next()?.trim().parse().ok()?;
|
||||
let path = fields.next()?.to_string();
|
||||
let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string();
|
||||
|
||||
let heads = fields.next().unwrap_or("");
|
||||
let (title, cwd) = heads
|
||||
.split('\u{1f}')
|
||||
.filter_map(|record| serde_json::from_str::<Value>(record).ok())
|
||||
.fold((None, None), |(title, cwd), record| {
|
||||
let cwd = cwd.or_else(|| record.get("cwd")?.as_str().map(String::from));
|
||||
if title.is_some() || is_hidden(&record) {
|
||||
return (title, cwd);
|
||||
}
|
||||
(first_line_of(&record), cwd)
|
||||
});
|
||||
|
||||
Some(Importable {
|
||||
id,
|
||||
cwd: cwd.unwrap_or_default(),
|
||||
title: title.unwrap_or_else(|| "(no opening message)".to_string()),
|
||||
modified,
|
||||
lines,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
let launch = Launch::new("test", vec!["-d".to_string(), path.to_string()], None);
|
||||
transport.capture(&launch).await.is_ok()
|
||||
}
|
||||
|
||||
/// Reads the tail of one session's file and turns it into events.
|
||||
///
|
||||
/// `tail` rather than the whole file, and as [`Launch`] arguments rather
|
||||
/// than a shell string, so the path is an argument and never syntax.
|
||||
pub async fn replay(transport: &Transport, path: &str) -> Result<Vec<Event>> {
|
||||
let launch = Launch::new(
|
||||
"tail",
|
||||
vec!["-n".to_string(), REPLAY_LINES.to_string(), path.to_string()],
|
||||
None,
|
||||
);
|
||||
let text = transport
|
||||
.capture(&launch)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
Ok(events_from(&text))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn events_from(text: &str) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
for line in text.lines() {
|
||||
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
if is_hidden(&record) {
|
||||
continue;
|
||||
}
|
||||
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),
|
||||
Some("assistant") => push_assistant(&mut events, content),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
fn push_user(events: &mut Vec<Event>, content: &Value) {
|
||||
// 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 {
|
||||
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)
|
||||
{
|
||||
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() {
|
||||
events.push(Event::UserMessage { text });
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user