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:
iris committed 2026-08-28 21:45:14 -04:00
1 parent 2a1bc84c1e
commit 6bbc829a3e
9 files changed
+720 -13

No files matched your search

+11
View File
@@ -30,6 +30,17 @@ can't come apart). Read dev-updater's `README.md` and `AGENTS.md` for the
conventions before diverging from them; module-by-module intent for this conventions before diverging from them; module-by-module intent for this
repo is in PLAN.md's "Backend layout" section. repo is in PLAN.md's "Backend layout" section.
- `server/src/session/import.rs` — continuing a Claude Code session the
machine already has. Claude Code keeps each one as JSONL under
`~/.claude/projects/`, and the CLI resumes one with `--resume <id>`
which `claude.rs` already does for crash recovery, so an import is that
same path with the token written up front rather than a second way to
start a session. The phone picks an **id**, never a path: the server
resolves which file that is, so an enrolled token cannot become "read me
an arbitrary file", the same rule that keeps a command out of
`POST /setups`. Only the tail is replayed (`REPLAY_LINES`) because these
files reach tens of megabytes and the CLI reads the real one itself; what
crosses the tunnel is what a person reads, not what the model is given.
- `server/src/models.rs` — downloaded GGUF models and the HuggingFace - `server/src/models.rs` — downloaded GGUF models and the HuggingFace
browsing behind them. Downloads are keyed by the model rather than by browsing behind them. Downloads are keyed by the model rather than by
who asked, so any device can watch one; they resume through HTTP Range, who asked, so any device can watch one; they resume through HTTP Range,
@@ -176,6 +176,33 @@ private fun parseSetup(setup: JSONObject) =
fun fetchSetups(settings: ServerSettings): List<Setup> = fun fetchSetups(settings: ServerSettings): List<Setup> =
requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) } requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) }
/**
* A Claude Code session already on a machine, which can be continued here.
*
* Identified by [id] and never by a path. The server resolves which file that is, so this app has
* no way to ask it to read one -- the same rule that keeps a provider's command out of this client.
*/
data class Importable(
val id: String,
val cwd: String,
val title: String,
val modified: Double,
val lines: Int,
)
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable") {
it.jsonObjects { session ->
Importable(
id = session.getString("id"),
cwd = session.optString("cwd"),
title = session.optString("title"),
modified = session.optDouble("modified", 0.0),
lines = session.optInt("lines", 0),
)
}
}
/** /**
* How to reach a machine. Deliberately carries no command: the server discovers what a machine can * How to reach a machine. Deliberately carries no command: the server discovers what a machine can
* run by asking it, so this app has no way to introduce something to run. * run by asking it, so this app has no way to introduce something to run.
@@ -261,6 +288,8 @@ fun spawnSession(
cwd: String? = null, cwd: String? = null,
permissionMode: String? = null, permissionMode: String? = null,
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
/** Continue this Claude Code session instead of starting an empty one. */
import: String? = null,
): SessionSummary = ): SessionSummary =
requestFromServer( requestFromServer(
settings, settings,
@@ -275,6 +304,7 @@ fun spawnSession(
if (!model.isNullOrBlank()) put("model", model) if (!model.isNullOrBlank()) put("model", model)
if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!cwd.isNullOrBlank()) put("cwd", cwd)
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
if (!import.isNullOrBlank()) put("import", import)
if (params.isNotEmpty()) { if (params.isNotEmpty()) {
put("params", JSONObject(params.toMap<String, Any>())) put("params", JSONObject(params.toMap<String, Any>()))
} }
@@ -26,6 +26,9 @@ private sealed class Screen {
data object Spawn : Screen() data object Spawn : Screen()
/** Continuing a session the machine already had, rather than starting an empty one. */
data object Import : Screen()
/** /**
* Reached from a session rather than from the list, because usage belongs to the provider * Reached from a session rather than from the list, because usage belongs to the provider
* running that session and not to the app. It carries the session back with it so Back returns * running that session and not to the app. It carries the session back with it so Back returns
@@ -102,6 +105,7 @@ fun AppRoot(settingsVersion: Int) {
reloadToken = reloadToken, reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImport = { screen = Screen.Import },
onModels = { screen = Screen.Models }, onModels = { screen = Screen.Models },
onSetups = { screen = Screen.Setups }, onSetups = { screen = Screen.Setups },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
@@ -122,6 +126,15 @@ fun AppRoot(settingsVersion: Int) {
}, },
onBack = goToList, onBack = goToList,
) )
is Screen.Import ->
ImportScreen(
settings = current,
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onBack = goToList,
)
is Screen.Usage -> is Screen.Usage ->
UsageScreen( UsageScreen(
settings = current, settings = current,
@@ -0,0 +1,227 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Continuing a Claude Code session the machine already has.
*
* The list is the machine's answer, not this app's: it asks a setup what sessions it holds and
* shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this
* screen into a file reader.
*/
@Composable
fun ImportScreen(
settings: ServerSettings,
onImported: (SessionSummary) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// Which row is being imported. Spawning resumes a CLI, which is not instant, and a tap with
// no acknowledgement invites a second tap and a second session.
var importing by remember { mutableStateOf<String?>(null) }
var failure by remember { mutableStateOf<String?>(null) }
fun loadSessions(setup: Setup) {
sessions = LoadState.Loading
scope.launch {
sessions =
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
)
} catch (err: Exception) {
LoadState.Error(err.message ?: "Couldn't list sessions")
}
}
}
LaunchedEffect(Unit) {
setups =
try {
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
found.firstOrNull()?.let {
chosen = it
loadSessions(it)
}
LoadState.Loaded(found)
} catch (err: Exception) {
LoadState.Error(err.message ?: "Couldn't list machines")
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"Import a session",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
}
Text(
"Sessions Claude Code already has on the machine. Importing continues one where it " +
"left off; the transcript here shows its recent history.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
when (val loaded = setups) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
// Only worth choosing when there is a choice.
if (loaded.value.size > 1) {
Row(Modifier.fillMaxWidth()) {
loaded.value.forEach { setup ->
TextButton(
onClick = {
chosen = setup
loadSessions(setup)
}
) {
Text(
setup.name,
color =
if (setup.id == chosen?.id)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
if (chosen != null && provider == null) {
Text(
"${chosen?.name} has no Claude CLI, so there is nothing here to continue.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
failure?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
ImportableList(
state = sessions,
importing = importing,
onPick = { session ->
val setup = chosen ?: return@ImportableList
val useProvider = provider ?: return@ImportableList
importing = session.id
failure = null
scope.launch {
try {
val spawned =
withContext(Dispatchers.IO) {
spawnSession(
settings,
setup = setup.id,
provider = useProvider.name,
title = "",
import = session.id,
)
}
onImported(spawned)
} catch (err: Exception) {
failure = err.message ?: "Couldn't import that session"
} finally {
importing = null
}
}
},
)
}
}
}
}
}
@Composable
private fun ImportableList(
state: LoadState<List<Importable>>,
importing: String?,
onPick: (Importable) -> Unit,
) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(state.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
if (state.value.isEmpty()) {
Text(
"No Claude Code sessions on that machine.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(Modifier.fillMaxSize()) {
items(state.value) { session ->
Card(
Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable(
enabled = importing == null
) {
onPick(session)
}
) {
Column(Modifier.padding(12.dp)) {
Text(
session.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
Text(
listOfNotNull(
if (importing == session.id) "importing…" else null,
"${session.lines} lines",
// The tail, not the head: a path is identified by
// where it ends, and these all share a long prefix.
session.cwd
.takeIf { it.isNotEmpty() }
?.let { cwd ->
if (cwd.length > 34) "" + cwd.takeLast(34)
else cwd
},
)
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
@@ -50,6 +50,7 @@ fun SessionListScreen(
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImport: () -> Unit,
onModels: () -> Unit, onModels: () -> Unit,
onSetups: () -> Unit, onSetups: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
@@ -98,6 +99,7 @@ fun SessionListScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
TextButton(onClick = onImport) { Text("Import") }
TextButton(onClick = onModels) { Text("Models") } TextButton(onClick = onModels) { Text("Models") }
TextButton(onClick = onSetups) { Text("Setups") } TextButton(onClick = onSetups) { Text("Setups") }
TextButton(onClick = onSettings) { Text("Settings") } TextButton(onClick = onSettings) { Text("Settings") }
+112 -10
View File
@@ -52,6 +52,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
Router::new() Router::new()
.route("/setups", get(list_setups).post(add_setup)) .route("/setups", get(list_setups).post(add_setup))
.route("/setups/probe", post(probe_setup)) .route("/setups/probe", post(probe_setup))
.route("/setups/{id}/importable", get(list_importable))
.route( .route(
"/setups/{id}", "/setups/{id}",
get(read_setup).put(update_setup).delete(delete_setup), get(read_setup).put(update_setup).delete(delete_setup),
@@ -287,18 +288,28 @@ async fn add_setup(
Ok(axum::Json(info_for(setup))) Ok(axum::Json(info_for(setup)))
} }
async fn read_setup( /// One setup by id, or the 404 that says so.
State(manager): State<Arc<SessionManager>>, ///
UrlPath(id): UrlPath<String>, /// Three handlers ask this same question; the answer, and the wording of
) -> Result<axum::Json<SetupInfo>, ApiError> { /// the refusal, belong in one place.
fn setup_by_id(
manager: &Arc<SessionManager>,
id: &str,
) -> Result<crate::config::SetupConfig, ApiError> {
manager manager
.setups() .setups()
.into_iter() .into_iter()
.find(|setup| setup.id == id) .find(|setup| setup.id == id)
.map(|setup| axum::Json(info_for(setup)))
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}"))) .ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))
} }
async fn read_setup(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
setup_by_id(&manager, &id).map(|setup| axum::Json(info_for(setup)))
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct UpdateSetupRequest { struct UpdateSetupRequest {
@@ -363,22 +374,113 @@ struct SpawnRequest {
/// `SessionConfig::params`. /// `SessionConfig::params`.
#[serde(default)] #[serde(default)]
params: std::collections::BTreeMap<String, String>, params: std::collections::BTreeMap<String, String>,
/// Continue a Claude Code session the machine already has, named by
/// the id `GET /setups/{id}/importable` reported.
///
/// An id and not a path, deliberately. The server looks the path up
/// again among the sessions it enumerated, so an enrolled token cannot
/// turn this field into "read me an arbitrary file" -- the same rule
/// that keeps a provider's command out of `POST /setups`.
#[serde(default)]
import: Option<String>,
}
/// What a machine already has that could be continued.
async fn list_importable(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<crate::session::import::Importable>>, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let found = crate::session::import::list(&transport)
.await
.map_err(bad_request)?;
Ok(axum::Json(found))
} }
async fn spawn_session( async fn spawn_session(
State(manager): State<Arc<SessionManager>>, State(manager): State<Arc<SessionManager>>,
axum::Json(body): axum::Json<SpawnRequest>, axum::Json(body): axum::Json<SpawnRequest>,
) -> Result<axum::Json<SessionInfo>, ApiError> { ) -> Result<axum::Json<SessionInfo>, ApiError> {
let info = manager // Resolved before the spawn because both halves of it are the
.spawn_session(SpawnSpec { // machine's answer, not the phone's: which file that id names, and
// what is in it.
let seed = match &body.import {
Some(want) => {
let setup = setup_by_id(&manager, &body.setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let found = crate::session::import::list(&transport)
.await
.map_err(bad_request)?;
let chosen = found
.into_iter()
.find(|candidate| &candidate.id == want)
.ok_or_else(|| {
ApiError::NotFound(format!(
"setup \"{}\" has no Claude Code session {want} to import",
body.setup
))
})?;
let events = crate::session::import::replay(&transport, &chosen.path)
.await
.map_err(bad_request)?;
// The recorded directory can outlive itself; resuming into one
// that is gone fails at `cd` before the CLI starts. Starting
// somewhere real keeps the conversation, which is the point of
// importing, and the log says which one was dropped.
let mut chosen = chosen;
if !crate::session::import::directory_exists(&transport, &chosen.cwd).await {
tracing::warn!(
"imported session {} recorded {} as its directory, which is not there any \
more -- starting in the default one instead",
chosen.id,
chosen.cwd,
);
chosen.cwd = String::new();
}
Some((chosen, events))
}
None => None,
};
let spec = SpawnSpec {
setup: body.setup, setup: body.setup,
provider: body.provider, provider: body.provider,
title: body.title, // An imported session is recognised by what it was about, so its
// opening message is the title unless one was typed.
title: body.title.or_else(|| {
seed.as_ref()
.map(|(chosen, _)| chosen.title.clone())
.filter(|title| !title.is_empty())
}),
model: body.model, model: body.model,
cwd: body.cwd, // Resumed where it was working, so the CLI picks up the same tree.
cwd: body.cwd.or_else(|| {
seed.as_ref()
.map(|(chosen, _)| PathBuf::from(&chosen.cwd))
.filter(|cwd| cwd.as_os_str() != "")
}),
permission_mode: body.permission_mode, permission_mode: body.permission_mode,
params: body.params, params: body.params,
}) };
let info = match seed {
Some((chosen, events)) => {
tracing::info!(
"importing Claude Code session {} ({} events replayed)",
chosen.id,
events.len()
);
manager.spawn_imported(
spec,
crate::session::Seed {
resume: chosen.id,
events,
},
)
}
None => manager.spawn_session(spec),
}
.map_err(bad_request)?; .map_err(bad_request)?;
tracing::info!( tracing::info!(
"spawned {} session {} ({})", "spawned {} session {} ({})",
+1 -1
View File
@@ -372,7 +372,7 @@ fn read_resume_token(session_dir: &Path) -> Option<String> {
.map(String::from) .map(String::from)
} }
fn write_resume_token(session_dir: &Path, session_id: &str) { pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
let path = session_dir.join(RESUME_FILE); let path = session_dir.join(RESUME_FILE);
if let Err(err) = std::fs::write(&path, json!({"sessionId": session_id}).to_string()) { if let Err(err) = std::fs::write(&path, json!({"sessionId": session_id}).to_string()) {
tracing::error!("couldn't persist resume token to {}: {err}", path.display()); tracing::error!("couldn't persist resume token to {}: {err}", path.display());
+277
View File
@@ -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),
});
}
}
_ => {}
}
}
}
+47 -2
View File
@@ -12,6 +12,7 @@
pub mod claude; pub mod claude;
pub mod driver; pub mod driver;
pub mod echo; pub mod echo;
pub mod import;
pub mod llama; pub mod llama;
pub mod transcript; pub mod transcript;
pub mod transport; pub mod transport;
@@ -225,7 +226,14 @@ impl SessionManager {
// shows as exited rather than taking the whole server down // shows as exited rather than taking the whole server down
// with it, and can still be deleted from the phone. // with it, and can still be deleted from the phone.
match resolve(&config, meta).and_then(|(setup, provider)| { match resolve(&config, meta).and_then(|(setup, provider)| {
launch(meta.clone(), &setup, &provider, &data_dir, &models_dir) launch(
meta.clone(),
&setup,
&provider,
&data_dir,
&models_dir,
None,
)
}) { }) {
Ok(session) => { Ok(session) => {
live.insert(meta.id.clone(), session); live.insert(meta.id.clone(), session);
@@ -475,6 +483,21 @@ impl SessionManager {
} }
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> { pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
self.spawn_seeded(spec, None)
}
/// Spawns a session that continues one the machine already had.
///
/// The same path as any other spawn, with a [`Seed`] written into the
/// session directory before the driver starts -- which is all an
/// import is, because `claude.rs` already resumes when it finds a
/// resume token. A separate spawn path would be a second way to start
/// a session, and the driver would have to learn which one it was.
pub fn spawn_imported(&self, spec: SpawnSpec, seed: Seed) -> Result<SessionInfo> {
self.spawn_seeded(spec, Some(seed))
}
fn spawn_seeded(&self, spec: SpawnSpec, seed: Option<Seed>) -> Result<SessionInfo> {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
let setup = inner let setup = inner
.config .config
@@ -525,6 +548,7 @@ impl SessionManager {
&provider, &provider,
&self.data_dir, &self.data_dir,
&self.models_dir, &self.models_dir,
seed,
)?; )?;
let mut candidate = inner.config.clone(); let mut candidate = inner.config.clone();
candidate.sessions.push(meta); candidate.sessions.push(meta);
@@ -642,17 +666,38 @@ fn unique_id(config: &Config) -> String {
/// Creates the session directory, opens its transcript (continuing the /// Creates the session directory, opens its transcript (continuing the
/// sequence numbering if one exists), starts the driver, and spawns the /// sequence numbering if one exists), starts the driver, and spawns the
/// event pump connecting them. /// event pump connecting them.
/// What an imported session starts life with: the token that makes the CLI
/// continue rather than begin, and the conversation so far.
pub struct Seed {
/// The CLI's own session id, written where `claude.rs` looks for it.
pub resume: String,
/// Replayed into the transcript so the phone shows the conversation it
/// is joining. The CLI reads the real file itself, so this is what the
/// reader sees rather than what the model is given.
pub events: Vec<Event>,
}
fn launch( fn launch(
meta: SessionConfig, meta: SessionConfig,
setup: &SetupConfig, setup: &SetupConfig,
provider: &ProviderConfig, provider: &ProviderConfig,
data_dir: &Path, data_dir: &Path,
models_dir: &Path, models_dir: &Path,
seed: Option<Seed>,
) -> Result<Arc<LiveSession>> { ) -> Result<Arc<LiveSession>> {
let dir = data_dir.join(&meta.id); let dir = data_dir.join(&meta.id);
wg_app_link::private::create_dir(&dir)?; wg_app_link::private::create_dir(&dir)?;
let transcript_path = dir.join("transcript.jsonl"); let transcript_path = dir.join("transcript.jsonl");
let transcript = Transcript::open(&transcript_path)?; let mut transcript = Transcript::open(&transcript_path)?;
// Before the driver starts, so the token is there when it looks and
// the history is already in the transcript a phone will read.
if let Some(seed) = seed {
claude::write_resume_token(&dir, &seed.resume);
let at = now();
for event in seed.events {
transcript.append(event, at)?;
}
}
let (sink, source) = mpsc::unbounded_channel(); let (sink, source) = mpsc::unbounded_channel();
let (events, _) = broadcast::channel(EVENT_BUFFER); let (events, _) = broadcast::channel(EVENT_BUFFER);