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:
1 parent
2a1bc84c1e
commit
6bbc829a3e
9 files changed
+726
-19
No files matched your search
+118
-16
@@ -52,6 +52,7 @@ pub fn router(manager: Arc<SessionManager>) -> 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<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<SetupInfo>, 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<SessionManager>,
|
||||
id: &str,
|
||||
) -> Result<crate::config::SetupConfig, ApiError> {
|
||||
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<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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateSetupRequest {
|
||||
@@ -363,23 +374,114 @@ struct SpawnRequest {
|
||||
/// `SessionConfig::params`.
|
||||
#[serde(default)]
|
||||
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(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
axum::Json(body): axum::Json<SpawnRequest>,
|
||||
) -> Result<axum::Json<SessionInfo>, 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,
|
||||
|
||||
Reference in new issue
Block a user