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