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);