From 6bbc829a3ebfeb36f682b82268882a31df114a41 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 21:45:14 -0400 Subject: [PATCH] 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 `. `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 `/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. --- AGENTS.md | 11 + .../src/main/kotlin/com/example/aiapp/Api.kt | 30 ++ .../main/kotlin/com/example/aiapp/AppRoot.kt | 13 + .../kotlin/com/example/aiapp/ImportScreen.kt | 227 ++++++++++++++ .../com/example/aiapp/SessionListScreen.kt | 2 + server/src/routes.rs | 134 ++++++++- server/src/session/claude.rs | 2 +- server/src/session/import.rs | 277 ++++++++++++++++++ server/src/session/mod.rs | 49 +++- 9 files changed, 726 insertions(+), 19 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt create mode 100644 server/src/session/import.rs diff --git a/AGENTS.md b/AGENTS.md index 971bdf3..4227d96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 ` — + 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 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, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 0cf069a..d4316c9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -176,6 +176,33 @@ private fun parseSetup(setup: JSONObject) = fun fetchSetups(settings: ServerSettings): List = 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 = + 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 * run by asking it, so this app has no way to introduce something to run. @@ -261,6 +288,8 @@ fun spawnSession( cwd: String? = null, permissionMode: String? = null, params: Map = emptyMap(), + /** Continue this Claude Code session instead of starting an empty one. */ + import: String? = null, ): SessionSummary = requestFromServer( settings, @@ -275,6 +304,7 @@ fun spawnSession( if (!model.isNullOrBlank()) put("model", model) if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) + if (!import.isNullOrBlank()) put("import", import) if (params.isNotEmpty()) { put("params", JSONObject(params.toMap())) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index b47b26a..512a097 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -26,6 +26,9 @@ private sealed class 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 * 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, onOpen = { screen = Screen.Session(it) }, onSpawn = { screen = Screen.Spawn }, + onImport = { screen = Screen.Import }, onModels = { screen = Screen.Models }, onSetups = { screen = Screen.Setups }, onSettings = { screen = Screen.Settings }, @@ -122,6 +126,15 @@ fun AppRoot(settingsVersion: Int) { }, onBack = goToList, ) + is Screen.Import -> + ImportScreen( + settings = current, + onImported = { imported -> + reloadToken++ + screen = Screen.Session(imported) + }, + onBack = goToList, + ) is Screen.Usage -> UsageScreen( settings = current, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt new file mode 100644 index 0000000..0e44781 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -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.Loading) } + var chosen by remember { mutableStateOf(null) } + var sessions by remember { mutableStateOf>>(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(null) } + var failure by remember { mutableStateOf(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>, + 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, + ) + } + } + } + } + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 5d63643..f219781 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -50,6 +50,7 @@ fun SessionListScreen( reloadToken: Int, onOpen: (SessionSummary) -> Unit, onSpawn: () -> Unit, + onImport: () -> Unit, onModels: () -> Unit, onSetups: () -> Unit, onSettings: () -> Unit, @@ -98,6 +99,7 @@ fun SessionListScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { + TextButton(onClick = onImport) { Text("Import") } TextButton(onClick = onModels) { Text("Models") } TextButton(onClick = onSetups) { Text("Setups") } TextButton(onClick = onSettings) { Text("Settings") } diff --git a/server/src/routes.rs b/server/src/routes.rs index 0ef51c1..35a7c71 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -52,6 +52,7 @@ pub fn router(manager: Arc) -> Router { Router::new() .route("/setups", get(list_setups).post(add_setup)) .route("/setups/probe", post(probe_setup)) + .route("/setups/{id}/importable", get(list_importable)) .route( "/setups/{id}", get(read_setup).put(update_setup).delete(delete_setup), @@ -287,18 +288,28 @@ async fn add_setup( Ok(axum::Json(info_for(setup))) } -async fn read_setup( - State(manager): State>, - UrlPath(id): UrlPath, -) -> Result, ApiError> { +/// One setup by id, or the 404 that says so. +/// +/// Three handlers ask this same question; the answer, and the wording of +/// the refusal, belong in one place. +fn setup_by_id( + manager: &Arc, + id: &str, +) -> Result { manager .setups() .into_iter() .find(|setup| setup.id == id) - .map(|setup| axum::Json(info_for(setup))) .ok_or_else(|| ApiError::NotFound(format!("no setup {id}"))) } +async fn read_setup( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result, ApiError> { + setup_by_id(&manager, &id).map(|setup| axum::Json(info_for(setup))) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateSetupRequest { @@ -363,23 +374,114 @@ struct SpawnRequest { /// `SessionConfig::params`. #[serde(default)] params: std::collections::BTreeMap, + /// 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, +} + +/// What a machine already has that could be continued. +async fn list_importable( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result>, 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( State(manager): State>, axum::Json(body): axum::Json, ) -> Result, ApiError> { - let info = manager - .spawn_session(SpawnSpec { - setup: body.setup, - provider: body.provider, - title: body.title, - model: body.model, - cwd: body.cwd, - permission_mode: body.permission_mode, - params: body.params, - }) - .map_err(bad_request)?; + // Resolved before the spawn because both halves of it are the + // 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, + provider: body.provider, + // 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, + // 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, + 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)?; tracing::info!( "spawned {} session {} ({})", info.provider, diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 1b57a79..08ad0e8 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -372,7 +372,7 @@ fn read_resume_token(session_dir: &Path) -> Option { .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); 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()); diff --git a/server/src/session/import.rs b/server/src/session/import.rs new file mode 100644 index 0000000..0e7d58c --- /dev/null +++ b/server/src/session/import.rs @@ -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//.jsonl`, and the CLI can +//! be told to continue one with `--resume `. 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> { + // 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 = 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 { + 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::(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 +/// `/clear`, which identifies nothing. The +/// caller offers several candidates for exactly this reason. +fn first_line_of(record: &Value) -> Option { + 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::>() + .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> { + 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 { + let mut events = Vec::new(); + for line in text.lines() { + let Ok(record) = serde_json::from_str::(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, 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, 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), + }); + } + } + _ => {} + } + } +} diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index df79a53..11ba5d9 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -12,6 +12,7 @@ pub mod claude; pub mod driver; pub mod echo; +pub mod import; pub mod llama; pub mod transcript; pub mod transport; @@ -225,7 +226,14 @@ impl SessionManager { // shows as exited rather than taking the whole server down // with it, and can still be deleted from the phone. 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) => { live.insert(meta.id.clone(), session); @@ -475,6 +483,21 @@ impl SessionManager { } pub fn spawn_session(&self, spec: SpawnSpec) -> Result { + 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 { + self.spawn_seeded(spec, Some(seed)) + } + + fn spawn_seeded(&self, spec: SpawnSpec, seed: Option) -> Result { let mut inner = self.inner.write().unwrap(); let setup = inner .config @@ -525,6 +548,7 @@ impl SessionManager { &provider, &self.data_dir, &self.models_dir, + seed, )?; let mut candidate = inner.config.clone(); candidate.sessions.push(meta); @@ -642,17 +666,38 @@ fn unique_id(config: &Config) -> String { /// Creates the session directory, opens its transcript (continuing the /// sequence numbering if one exists), starts the driver, and spawns the /// 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, +} + fn launch( meta: SessionConfig, setup: &SetupConfig, provider: &ProviderConfig, data_dir: &Path, models_dir: &Path, + seed: Option, ) -> Result> { let dir = data_dir.join(&meta.id); wg_app_link::private::create_dir(&dir)?; 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 (events, _) = broadcast::channel(EVENT_BUFFER);