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>()))
}