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
+726 -19

No files matched your search

@@ -176,6 +176,33 @@ private fun parseSetup(setup: JSONObject) =
fun fetchSetups(settings: ServerSettings): List<Setup> =
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
* 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<String, String> = 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<String, Any>()))
}
@@ -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,
@@ -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,
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") }