From deb908034cb4e59e954244b0faa617dbc29d0768 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 23:16:34 -0400 Subject: [PATCH] Move a session to another working directory `POST /sessions/{id}/cwd`, behind a field in the session settings dialog. A working directory is settled when the process is spawned -- the CLI is launched with it as its cwd and there is no control request that changes one -- so this records the new one and ends the process that is in the old one. It does not start a replacement, and the field says so in a line beside it: a session with no process starts on the next message or on Start, which is this app's rule for that everywhere else, and "usually restarts" is a worse control than "always stops". The path is checked against the session's own machine and refused if it is not there. The spawn path corrects instead of refusing, because it is resuming a directory the *machine* recorded and that can be gone through nobody's fault; a path somebody has just typed is different, and a mistyped one accepted here would surface much later as a session that would not start, with nothing pointing at the typo. The refusal names the machine and the path, and is drawn under the field it is about. Nothing of Claude Code's own is moved, and that is measured rather than assumed: on CLI 2.1.237, `claude --resume ` finds a session from any working directory -- an id that does not exist answers "No conversation found with session ID", and a real one resumed from an unrelated directory did not. So the conversation continues in the new place with nothing relocated. Doing otherwise would mean reproducing a rule this app cannot see the whole of; PLAN.md records what that rule is, for whoever tries. Found while checking it: `SessionInfo.cwd` came from the snapshot a session launched with, so a moved session went on reporting its *old* directory for as long as its process lived -- a dialog showing a directory the next launch would not use, with nothing saying so. It is read from the config where the row is built now, the same way `setup_name` already was, and for the reason already written above `setup_name`: only the manager holds the config, and both of these change under a running session. Checked end to end on the emulator against a session whose process really does take a cwd: /proc said /tmp/cwd-a before and /tmp/cwd-b after, the dialog showed the new path immediately rather than after a restart, and a directory that is not there and a relative path were both refused with the session left exactly as it was. --- AGENTS.md | 8 ++ PLAN.md | 36 +++++++ .../src/main/kotlin/com/example/aiapp/Api.kt | 30 ++++++ .../example/aiapp/SessionSettingsDialog.kt | 95 +++++++++++++++++-- server/src/routes.rs | 65 +++++++++++++ server/src/session/mod.rs | 82 +++++++++++++++- 6 files changed, 305 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0cf8e4a..193cce7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,6 +273,14 @@ first if a remote spawn ever mangles an argument. reply into the tool output under it -- and a container per row leaves whatever was drawn without one silently unselectable, which nothing on screen reports. Rows keep their tap handlers; selection is a long press. +- **A session can be moved to another directory** from the settings dialog + (`POST /sessions/{id}/cwd`). It stops the process, because a working + directory is settled at spawn; the next message starts it in the new one. + **`claude --resume ` finds a session from any directory** -- measured + on 2.1.237 -- so nothing of Claude Code's is relocated, and should you ever + be tempted, its project directory is the path with every non-alphanumeric + character replaced by `-`, cut at 200 characters with a hash appended, and + overridable besides. - **A message from another agent reaches a live session on the turn's `result`, not before.** Measured on CLI 2.1.237 by sending a real cross-session message to a real stream-json session: no `user` record, and diff --git a/PLAN.md b/PLAN.md index eb5f2a2..ed0064a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -247,6 +247,42 @@ turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`. - Images in: base64 image content blocks in the stream-json user message. - Working directory, host, and model are spawn-screen fields. +### Moving a session to another directory (decided 2026-08-31) + +`POST /sessions/{id}/cwd {cwd}`, behind a field in the session settings +dialog. The directory is settled when the process is spawned -- the CLI is +launched with it as its cwd and there is no control request that changes one +-- so this records the new one and **ends** the process that is in the old +one. It does not start a replacement: a session with no process starts on +the next thing said to it or on Start, which is this app's rule for that +everywhere else, and "usually restarts" would be a worse control than +"always stops" (starting one here would have to wait for the recorded status +to catch up with a process already gone). + +The path is checked against the session's own machine and **refused** if it +is not there, rather than corrected. The spawn path corrects instead, +because it is resuming a directory the *machine* recorded and that can be +gone through nobody's fault; a path somebody has just typed is different, +and a mistyped one accepted here would surface much later as a session that +would not start, with nothing pointing at the typo. + +**Nothing of Claude Code's own is moved**, and that is a measurement rather +than an omission. Checked against CLI 2.1.237 on 2026-08-31: `claude +--resume ` finds a session from any working directory — an id that does +not exist answers "No conversation found with session ID", and a real one +resumed from an unrelated directory did not. So the conversation continues +in the new place with nothing relocated, and the session file stays under +the project directory the CLI made for it, which is where the CLI itself +looks. Relocating it would mean reproducing a rule this app cannot see the +whole of: the CLI's project directory is the path with every non-alphanumeric +character replaced by `-`, truncated at 200 characters with a hash of its own +appended, and an override can replace the name entirely. + +While fixing this: `SessionInfo.cwd` came from the snapshot a session +launched with, so a moved session reported its *old* directory for as long +as the process lived. It is read from the config where the row is built now, +the same way `setup_name` already was and for the same reason. + ### A message from another agent, on a live session (measured 2026-08-31) Peer messages were only ever produced by the *import* path, reading them out 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 eff62ff..0f8997f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -149,6 +149,14 @@ data class SessionSummary( * on when a backend is too old to say, which matches what that backend actually does. */ val notify: Boolean, + /** + * The directory the session works in, or null where it was never given one. + * + * Null is not "the home directory": it is the session never having been told, and what the + * process then starts in belongs to whatever launches it. Shown as unset rather than filled in + * with a guess, so a reader changing it is choosing rather than confirming. + */ + val cwd: String?, /** * How much context this session is holding, as the server last measured it -- see * `SessionEvent.UsageDelta`. @@ -184,6 +192,7 @@ private fun parseSession(session: JSONObject) = permissionMode = session.optString("permissionMode").ifEmpty { null }, imported = session.optBoolean("imported", false), notify = session.optBoolean("notify", true), + cwd = session.optString("cwd").ifEmpty { null }, contextTokens = if (session.has("contextTokens")) session.getLong("contextTokens") else null, maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 }, @@ -509,6 +518,27 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin ) {} } +/** + * Moves a session to a different working directory. + * + * The server checks the directory is there on that machine and refuses if it is not -- a mistyped + * path accepted here would surface much later, as a session that would not start, with nothing + * pointing at the typo. + * + * Its process is **stopped**, because a working directory is settled when the process is spawned. + * The next thing said to the session starts it again in the new one, which is this app's rule for a + * session with no process everywhere else. + */ +fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) { + requestFromServer( + settings, + "/sessions/$sessionId/cwd", + method = "POST", + jsonBody = JSONObject().put("cwd", cwd).toString(), + readTimeoutMs = 30000, + ) {} +} + /** Uploads one picked image; the returned id goes into [sendMessage]. */ fun uploadAttachment( settings: ServerSettings, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt index 7816ba0..d04f52b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -68,17 +68,53 @@ fun SessionSettingsDialog( // it, which is what not knowing looks like: distinguishable from off, and from a refusal. var notify by remember(sessionId) { mutableStateOf(null) } var notifyError by remember { mutableStateOf(null) } + // Where the session works. Null until the server has been asked, for the same reason the + // switch above is: the row this dialog opened over is a snapshot, and a path drawn from it + // could be one somebody changed from another device. An empty answer is a session that was + // never given a directory, which is not the same as one whose directory is unknown -- the + // field is only enabled once one of those two is settled. + var cwd by remember(sessionId) { mutableStateOf(null) } + var typedCwd by remember(sessionId) { mutableStateOf("") } + var cwdError by remember { mutableStateOf(null) } + var movingCwd by remember { mutableStateOf(false) } LaunchedEffect(sessionId) { - notify = + try { + val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) } + notify = fresh.notify + cwd = fresh.cwd.orEmpty() + typedCwd = fresh.cwd.orEmpty() + } catch (e: ApiException) { + // Left unknown rather than falling back to the stale row: the switch stays + // disabled, instead of offering a position nothing confirmed. + notifyError = e.message + notify = null + } + } + + /** + * Moves the session, which ends the process that is in the old directory. + * + * Said plainly beside the field rather than confirmed in a second dialog: what it costs is a + * process, and a stopped session is a state this app already has a word and a button for. + */ + fun moveCwd() { + val chosen = typedCwd.trim() + if (movingCwd || chosen.isEmpty() || chosen == cwd) return + movingCwd = true + cwdError = null + scope.launch { try { - withContext(Dispatchers.IO) { fetchSession(settings, sessionId).notify } + withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) } + cwd = chosen } catch (e: ApiException) { - // Left unknown rather than falling back to the stale row: the switch stays - // disabled, instead of offering a position nothing confirmed. - notifyError = e.message - null + // Where it happened: this field is the only thing on screen that knows a move was + // asked for, and the reason is usually the path itself. + cwdError = e.message + } finally { + movingCwd = false } + } } // Moved optimistically so the switch answers the finger that moved it, and put back if the @@ -167,6 +203,53 @@ fun SessionSettingsDialog( style = MaterialTheme.typography.bodySmall, ) } + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = typedCwd, + onValueChange = { typedCwd = it }, + label = { Text("Working directory") }, + // What the field cannot say by being empty: a session that was never + // given one starts wherever its launcher does, and this names that + // rather than showing a path nobody chose. + placeholder = { Text("wherever the session was started") }, + singleLine = true, + enabled = cwd != null && !movingCwd, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { moveCwd() }), + ) + TextButton( + onClick = { moveCwd() }, + enabled = + cwd != null && + !movingCwd && + typedCwd.trim().isNotEmpty() && + typedCwd.trim() != cwd, + ) { + Text(if (movingCwd) "Moving..." else "Move") + } + } + // The whole of what pressing Move does, where it is about to be pressed. A + // directory is settled when the process is spawned, so there is no changing one + // under a running session -- it is ended, and the next thing said to the session + // starts it in the new place. + Text( + "Moving stops the session's process. It starts again in the new directory " + + "with the next message, or with Start.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + cwdError?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } error?.let { Spacer(Modifier.height(8.dp)) Text( diff --git a/server/src/routes.rs b/server/src/routes.rs index c305e9b..f55dfec 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -24,6 +24,8 @@ //! POST /sessions/{id}/stop end the process; the session and transcript stay //! POST /sessions/{id}/start run the process again, continuing the conversation //! POST /sessions/{id}/title {title} +//! POST /sessions/{id}/cwd {cwd} -- move it; stops the process, +//! which starts again in the new one //! POST /sessions/{id}/model {model} //! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own //! (starts the process first if it has exited) @@ -101,6 +103,7 @@ pub fn router(manager: Arc) -> Router { .route("/sessions/{id}/stop", post(stop)) .route("/sessions/{id}/start", post(start)) .route("/sessions/{id}/title", post(rename)) + .route("/sessions/{id}/cwd", post(set_cwd)) .route("/sessions/{id}/model", post(set_model)) .route("/sessions/{id}/permission-mode", post(set_permission_mode)) .route("/sessions/{id}/notify", post(set_notify)) @@ -1054,6 +1057,68 @@ async fn rename( Ok(StatusCode::NO_CONTENT) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct CwdRequest { + cwd: PathBuf, +} + +/// Moves a session to a different working directory. +/// +/// The directory is checked here rather than in the manager because +/// checking it is an ssh round trip on a remote setup, and the manager is +/// not async -- the same division `POST /sessions` already makes for the +/// directory an import was recorded in. +/// +/// Checked rather than trusted, and refused rather than corrected: a +/// mistyped path that was accepted would leave a session recorded somewhere +/// its process cannot start, and the failure would arrive later, as a +/// session that would not come back, with nothing pointing at the typo. The +/// spawn path corrects instead because it is resuming a directory the +/// *machine* recorded, which can be gone through nobody's fault; a path +/// somebody has just typed is different. +/// +/// Note what this does not do: it does not start a replacement process. +/// See [`SessionManager::set_session_cwd`]. +async fn set_cwd( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + let session = manager + .sessions() + .into_iter() + .find(|session| session.id == id) + .ok_or_else(|| ApiError::NotFound(format!("no session {id}")))?; + let cwd = body.cwd.to_string_lossy().trim().to_string(); + if cwd.is_empty() { + return Err(ApiError::BadRequest( + "a working directory is a path, and this one is empty".to_string(), + )); + } + // Absolute, because the alternative is relative to whatever the CLI is + // launched from, which is not something the person typing it can see. + if !cwd.starts_with('/') && !cwd.starts_with('~') { + return Err(ApiError::BadRequest(format!( + "{cwd} is not an absolute path, so where it would be depends on where the \ + session happens to start" + ))); + } + let setup = setup_by_id(&manager, &session.setup)?; + let transport = crate::session::transport::Transport::for_setup(&setup); + if !crate::session::import::directory_exists(&transport, &cwd).await { + return Err(ApiError::BadRequest(format!( + "{} has no directory {cwd}", + setup.name + ))); + } + manager + .set_session_cwd(&id, PathBuf::from(&cwd)) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ModelRequest { diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index c34855c..c46ece6 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -500,15 +500,31 @@ impl LiveSession { Ok(name) } - /// `setup_name` is passed in rather than stored: only the manager - /// holds the config, and the label can change under a running session. + /// `setup_name` and `cwd` are passed in rather than read from the + /// snapshot this session launched with: only the manager holds the + /// config, and both of them can change under a running session. The + /// label changes when a setup is renamed; the directory changes when + /// somebody moves the session, and reading the snapshot reported the + /// old one for as long as the process lived -- a screen showing a + /// directory the next launch will not use, with nothing saying so. + /// + /// Passed rather than mirrored into `Shared`, which is where `title` + /// and `notify` live: a second copy is a second thing to keep level, + /// and this way there is one answer, read where the row is built. + /// /// `kind` rather than the facts derived from it: two of this row's /// fields are answers about the provider's *kind*, and passing them /// separately meant every caller deriving each one and a third arriving /// as a third parameter. `None` where the provider has been edited away, /// which is a session that cannot run -- so both answers are the /// cautious one rather than a guess. - fn info(&self, setup_name: &str, imported: bool, kind: Option) -> SessionInfo { + fn info( + &self, + setup_name: &str, + cwd: Option<&Path>, + imported: bool, + kind: Option, + ) -> SessionInfo { SessionInfo { id: self.meta.id.clone(), provider: self.meta.provider.clone(), @@ -522,7 +538,7 @@ impl LiveSession { max_image_edge: kind.and_then(DriverKind::max_image_edge), imported, keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript), - cwd: self.meta.cwd.clone(), + cwd: cwd.map(Path::to_path_buf), status: *self.shared.status.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(), created: self.meta.created, @@ -962,6 +978,7 @@ impl SessionManager { .map(|meta| match inner.live.get(&meta.id) { Some(session) => session.info( label_of(&inner.config, &meta.setup), + meta.cwd.as_deref(), import::read_cursor(&self.data_dir.join(&meta.id)).is_some(), kind_of(&inner.config, &meta.setup, &meta.provider), ), @@ -1123,6 +1140,7 @@ impl SessionManager { // listing asks of the directory a moment later. let info = session.info( &setup.name, + session.meta.cwd.as_deref(), import::read_cursor(&self.data_dir.join(&id)).is_some(), Some(provider.kind), ); @@ -1285,6 +1303,60 @@ impl SessionManager { /// for every provider that has a process at all -- so asking it here /// stops a session whose driver is in no state to be asked, and adds no /// method a new driver could implement wrongly. + /// Moves a session to a different working directory. + /// + /// The directory is settled at spawn -- the CLI is launched with it as + /// its cwd and there is no control request that changes one -- so this + /// records the new one and ends the process that is in the old one. It + /// does **not** start a replacement: a session with no process starts + /// on the next thing said to it, or on Start, which is this app's one + /// rule for that everywhere else. Starting one here would have to wait + /// for the recorded status to catch up with a process that is already + /// gone, and "usually restarts" is a worse control than "always stops". + /// + /// Nothing of Claude Code's own is moved, and that is a measurement + /// rather than an omission: `claude --resume ` finds a session from + /// any working directory (checked against 2.1.237 on 2026-08-31 -- an + /// id that does not exist says "No conversation found with session ID" + /// and a real one resumed from an unrelated directory did not), so the + /// conversation continues in the new place with nothing relocated. The + /// file stays under the project directory the CLI made for it, which is + /// where the CLI itself looks. Reimplementing that directory's name to + /// move it would mean reproducing a rule this app cannot see the whole + /// of -- the CLI truncates at 200 characters and appends a hash of its + /// own, and an override can replace the name entirely -- to relocate a + /// file the CLI is still writing. + /// + /// Whether the directory exists is the caller's question, because + /// asking it is an ssh round trip on a remote setup; see the route. + pub fn set_session_cwd(&self, id: &str, cwd: PathBuf) -> Result<()> { + { + let mut inner = self.inner.write().unwrap(); + if !inner.config.sessions.iter().any(|meta| meta.id == id) { + bail!("no session {id}"); + } + let mut candidate = inner.config.clone(); + for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) { + meta.cwd = Some(cwd.clone()); + } + candidate.save(&self.config_path)?; + inner.config = candidate; + } + // Saved first, so a process that cannot be stopped leaves a session + // that will start in the right place rather than one recorded in a + // directory nothing agrees with. + let dir = self.data_dir.join(id); + if let Some(record) = process::live(&dir) { + tracing::info!( + "moving session {id} to {} -- stopping pid {}", + cwd.display(), + record.pid + ); + process::stop(&record, process::STOP_GRACE); + } + Ok(()) + } + pub fn stop_session(&self, id: &str) -> Result<()> { if !self .inner @@ -2497,7 +2569,7 @@ mod tests { assert_eq!(first.session_id, info.id); // The title travels with it, because the phone may have no screen // open to look one up on. - assert_eq!(first.title, session.info("m", false, None).title); + assert_eq!(first.title, session.info("m", None, false, None).title); manager.set_session_notify(&info.id, false).expect("off"); // Subscribed before the message, or the turn can finish in the gap