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 00e78fd..bbd04be 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -118,6 +118,8 @@ data class SessionSummary( val provider: String, val title: String, val model: String?, + /** How much the session asks before acting; null when it was never set. */ + val permissionMode: String?, val status: String, val lastActivity: Double, ) @@ -129,6 +131,7 @@ private fun parseSession(session: JSONObject) = provider = session.getString("provider"), title = session.getString("title"), model = session.optString("model").ifEmpty { null }, + permissionMode = session.optString("permissionMode").ifEmpty { null }, status = session.getString("status"), lastActivity = session.getDouble("lastActivity"), ) @@ -431,6 +434,26 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {} } +/** Switches a running session's model; the CLI changes it in place. */ +fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) { + requestFromServer( + settings, + "/sessions/$sessionId/model", + method = "POST", + jsonBody = JSONObject().put("model", model).toString(), + ) {} +} + +/** Switches how much a running session asks before acting, also in place. */ +fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) { + requestFromServer( + settings, + "/sessions/$sessionId/permission-mode", + method = "POST", + jsonBody = JSONObject().put("mode", mode).toString(), + ) {} +} + fun deleteSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {} } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 7e8c3de..a1460cf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -147,6 +149,13 @@ fun SessionScreen( var expandedTools by remember { mutableStateOf(setOf()) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } + // What this session is set to now, seeded from the row that opened it and + // then owned here, because changing either is something this screen does. + var model by remember { mutableStateOf(summary.model) } + var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") } + // The models this provider actually offers, asked of the server rather + // than listed here: a hardcoded list is a claim about a machine. + var offeredModels by remember { mutableStateOf>(emptyList()) } val context = LocalContext.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } @@ -211,6 +220,24 @@ fun SessionScreen( } } + LaunchedEffect(summary.setupName, summary.provider) { + offeredModels = + try { + withContext(Dispatchers.IO) { + fetchSetups(settings) + .firstOrNull { it.name == summary.setupName } + ?.providers + ?.firstOrNull { it.name == summary.provider } + ?.models + .orEmpty() + } + } catch (_: Exception) { + // Not worth reporting: the picker simply has nothing to + // offer, which is visible, and the session is unaffected. + emptyList() + } + } + fun act(action: () -> Unit) { scope.launch { try { @@ -371,6 +398,28 @@ fun SessionScreen( ) { Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}") } + // Beside the field they govern, and showing their current + // value rather than a label: what this session is set to is + // the thing worth reading at a glance, and the control for + // changing it is the same object. + if (offeredModels.isNotEmpty()) { + PickerButton( + current = model ?: "default", + options = offeredModels, + onPick = { chosen -> + model = chosen + act { setSessionModel(settings, summary.id, chosen) } + }, + ) + } + PickerButton( + current = permissionMode, + options = PERMISSION_MODES, + onPick = { chosen -> + permissionMode = chosen + act { setSessionPermissionMode(settings, summary.id, chosen) } + }, + ) Spacer(Modifier.weight(1f)) if (status == "running" || status == "compacting") { OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) { @@ -494,3 +543,33 @@ private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String } } } + +/** The modes the CLI accepts, in the order they give up asking. */ +private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") + +/** + * A control that reads as its own value. + * + * The button *is* the current setting rather than a label beside one, so the row says what the + * session is set to without spending a second line on saying it. + */ +@Composable +private fun PickerButton(current: String, options: List, onPick: (String) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { open = true }) { + Text(current, style = MaterialTheme.typography.bodySmall) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + open = false + if (option != current) onPick(option) + }, + ) + } + } + } +} diff --git a/server/src/routes.rs b/server/src/routes.rs index c1cc136..905d234 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -68,6 +68,7 @@ pub fn router(manager: Arc) -> Router { .route("/sessions/{id}/answer", post(answer)) .route("/sessions/{id}/interrupt", post(interrupt)) .route("/sessions/{id}/model", post(set_model)) + .route("/sessions/{id}/permission-mode", post(set_permission_mode)) .route("/sessions/{id}/compact", post(compact)) .route("/sessions/{id}/attachments", post(upload_attachment)) .route("/sessions/{id}/files/{name}", get(serve_file)) @@ -497,7 +498,14 @@ async fn spawn_session( manager.spawn_imported( spec, crate::session::Seed { - resume: chosen.id, + resume: chosen.id.clone(), + // Where it came from and how far it has been shown, so + // the session keeps itself level with the file a + // terminal is also writing to. + cursor: crate::session::import::Cursor { + path: chosen.path, + lines: chosen.lines, + }, events, }, ) @@ -604,6 +612,23 @@ async fn set_model( Ok(StatusCode::NO_CONTENT) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PermissionModeRequest { + mode: String, +} + +async fn set_permission_mode( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + manager + .set_session_permission_mode(&id, &body.mode) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + async fn compact( State(manager): State>, UrlPath(id): UrlPath, diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 08ad0e8..ff5b7db 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -33,6 +33,10 @@ //! between two Bash calls) -- the behavior this app exists for. //! - `control_request{subtype:set_model}` answers success; //! `{subtype:interrupt}` stops the turn. +//! - `control_request{subtype:set_permission_mode}` answers success and +//! echoes the mode back (`{"response":{"mode":"acceptEdits"}}`), so the +//! mode is changeable mid-session rather than only at spawn. Probed the +//! same way as the rest, against 2.1.237 on 2026-08-29. use std::collections::VecDeque; use std::path::{Path, PathBuf}; @@ -303,6 +307,10 @@ impl Driver for ClaudeDriver { self.send_control(json!({"subtype": "interrupt"})); } + fn set_permission_mode(&self, mode: &str) { + self.send_control(json!({"subtype": "set_permission_mode", "mode": mode})); + } + fn set_model(&self, model: &str) { self.send_control(json!({"subtype": "set_model", "model": model})); } diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index b5c387c..2e22a9a 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -106,6 +106,10 @@ pub trait Driver: Send + Sync { /// Stop mid-run; the session survives. fn interrupt(&self); fn set_model(&self, model: &str); + /// How much the session asks about before acting. Live rather than + /// spawn-only: the answer changes with what is being done, and a phone + /// is the worst place to answer "may I run this?" forty times. + fn set_permission_mode(&self, mode: &str); /// pi: native compaction; claude: `/compact`. fn compact(&self); /// Graceful process exit. diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index a94e792..c9d3aa9 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -145,6 +145,12 @@ impl Driver for EchoDriver { }); } + fn set_permission_mode(&self, mode: &str) { + self.emit(Event::Error { + message: format!("an echo session asks for nothing, so {mode} changes nothing"), + }); + } + fn set_model(&self, model: &str) { self.emit(Event::Error { message: format!("echo sessions have no model to change to {model}"), diff --git a/server/src/session/import.rs b/server/src/session/import.rs index d834559..8941d30 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -348,3 +348,75 @@ pub async fn delete(transport: &Transport, id: &str) -> Result<()> { .with_context(|| format!("deleting {}", chosen.path))?; Ok(()) } + +/// How often an imported session checks whether its source file grew. +/// +/// A poll rather than a watch, because the file may be on another machine +/// and there is no portable way to be told. Ten seconds is chosen against +/// the cost of an ssh round trip rather than against how fast a person +/// types: nothing here is waiting on it, and the events arrive on the same +/// stream as everything else once they do. +pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); + +/// Where an imported session came from, and how much of it has been shown. +/// +/// Kept beside the session rather than in its config, because it is a +/// position in someone else's file rather than anything the person chose, +/// and it changes constantly. +#[derive(Debug, Clone, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Cursor { + /// Server-side only, and resolved once at import. Nothing accepts a + /// path from the phone; this is the path *we* found. + pub path: String, + /// Lines of that file already accounted for -- whether replayed into + /// the transcript or skipped because this session wrote them itself. + pub lines: usize, +} + +const CURSOR_FILE: &str = "import.json"; + +pub fn read_cursor(session_dir: &std::path::Path) -> Option { + let text = std::fs::read_to_string(session_dir.join(CURSOR_FILE)).ok()?; + serde_json::from_str(&text).ok() +} + +pub fn write_cursor(session_dir: &std::path::Path, cursor: &Cursor) { + let path = session_dir.join(CURSOR_FILE); + match serde_json::to_string(cursor) { + Ok(text) => { + if let Err(err) = std::fs::write(&path, text) { + tracing::error!( + "couldn't persist the import cursor to {}: {err}", + path.display() + ); + } + } + Err(err) => tracing::error!("couldn't serialize the import cursor: {err}"), + } +} + +/// How many lines the source file has now. +pub async fn line_count(transport: &Transport, path: &str) -> Result { + let launch = Launch::new("wc", vec!["-l".to_string(), path.to_string()], None); + let out = transport.capture(&launch).await?; + out.split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .with_context(|| format!("couldn't read a line count out of {out:?}")) +} + +/// Events from the lines after `after`, which is a 0-based count of lines +/// already accounted for. +pub async fn replay_after(transport: &Transport, path: &str, after: usize) -> Result> { + let launch = Launch::new( + "tail", + vec![format!("-n+{}", after + 1), path.to_string()], + None, + ); + let text = transport + .capture(&launch) + .await + .with_context(|| format!("reading {path} from line {}", after + 1))?; + Ok(events_from(&text)) +} diff --git a/server/src/session/llama.rs b/server/src/session/llama.rs index f0d8694..37d3932 100644 --- a/server/src/session/llama.rs +++ b/server/src/session/llama.rs @@ -247,6 +247,14 @@ impl Driver for LlamaDriver { self.cancel.store(true, Ordering::Relaxed); } + fn set_permission_mode(&self, _mode: &str) { + let _ = self.sink.send(Event::Error { + message: "a llama.cpp session runs no tools, so there is nothing for a permission \ + mode to govern." + .to_string(), + }); + } + fn set_model(&self, _model: &str) { let _ = self.sink.send(Event::Error { message: "a llama.cpp session's model is fixed when it starts, because the server \ diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 11ba5d9..09b8e18 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -30,7 +30,7 @@ use crate::config::{ Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry, }; use claude::ClaudeDriver; -use driver::{Driver, Event, ImageRef, SessionStatus}; +use driver::{Driver, Event, EventSink, ImageRef, SessionStatus}; use echo::EchoDriver; use llama::LlamaDriver; use transcript::{SeqEvent, Transcript}; @@ -76,6 +76,12 @@ pub struct SessionInfo { pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + /// How much this session asks before acting. Reported so the phone can + /// *show* the current mode rather than assume one -- a picker that + /// guesses its own value is how you end up changing something you + /// thought you were confirming. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, pub status: SessionStatus, @@ -104,6 +110,20 @@ struct Shared { status: Mutex, last_activity: Mutex, model: Mutex>, + /// Beside the model and for the same reason: `meta` is the shape the + /// session was *launched* with, so reporting from it would show the + /// mode a change had already replaced. + permission_mode: Mutex>, + /// How many events this session has ever recorded. + /// + /// Only the import sync reads it, and only to answer one question: + /// "did *we* write anything since I last looked?" A session and a + /// terminal append to the same file, so that is the whole of what + /// separates lines worth replaying from lines already shown. Status + /// cannot answer it -- a turn that starts and finishes between two + /// polls is idle at both, and its output then gets replayed on top of + /// itself. + written: Mutex, } impl LiveSession { @@ -184,6 +204,7 @@ impl LiveSession { setup_name: setup_name.to_string(), title: self.meta.title.clone(), model: self.shared.model.lock().unwrap().clone(), + permission_mode: self.shared.permission_mode.lock().unwrap().clone(), cwd: self.meta.cwd.clone(), status: *self.shared.status.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(), @@ -462,6 +483,7 @@ impl SessionManager { provider: meta.provider.clone(), title: meta.title.clone(), model: meta.model.clone(), + permission_mode: meta.permission_mode.clone(), cwd: meta.cwd.clone(), status: SessionStatus::Exited, last_activity: meta.created, @@ -535,7 +557,16 @@ impl SessionManager { setup: setup.id.clone(), provider: provider.name.clone(), title, - model: spec.model.or_else(|| provider.models.first().cloned()), + // No model unless one was chosen. This used to fall back to + // the provider's first listed model, which sounds like a + // default and is not one: that list is a shortcut for the + // spawn screen, written in whatever order somebody typed it, + // and its first entry happened to be `fable`. Every session + // spawned without a model -- every import, since importing + // asks for none -- silently became a fable session. Absent + // means absent, and the CLI then uses whatever the person + // configured for themselves. + model: spec.model, cwd: spec.cwd, permission_mode: spec.permission_mode, params: spec.params, @@ -570,6 +601,30 @@ impl SessionManager { /// list shows it) and handed to the driver, which switches in place /// where its dialect can. Through the manager, not the session, so the /// config and the live view can't disagree. + /// Changes how much a session asks before acting, live and persisted. + /// + /// Alongside the model rather than folded into it: they are set at the + /// same moment and by the same screen, but they answer different + /// questions, and a caller changing one must not have to restate the + /// other. + pub fn set_session_permission_mode(&self, id: &str, mode: &str) -> 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.permission_mode = Some(mode.to_string()); + } + candidate.save(&self.config_path)?; + inner.config = candidate; + if let Some(session) = inner.live.get(id) { + *session.shared.permission_mode.lock().unwrap() = Some(mode.to_string()); + session.driver.set_permission_mode(mode); + } + Ok(()) + } + pub fn set_session_model(&self, id: &str, model: &str) -> Result<()> { let mut inner = self.inner.write().unwrap(); if !inner.config.sessions.iter().any(|meta| meta.id == id) { @@ -666,11 +721,91 @@ fn unique_id(config: &Config) -> String { /// Creates the session directory, opens its transcript (continuing the /// sequence numbering if one exists), starts the driver, and spawns the /// event pump connecting them. +/// Keeps an imported session's transcript level with the file the CLI +/// writes. +/// +/// Both this app and a terminal append to one file -- `--resume` continues +/// the same transcript rather than forking, measured rather than assumed +/// -- so the only hard question is which new lines are *ours*. They are +/// already in the transcript, having arrived through the driver, and +/// replaying them shows every message twice. +/// +/// Answered by counting what this session has recorded rather than by +/// looking at its status. Status is the obvious signal and it is wrong: a +/// turn that begins and ends between two polls reads as idle at both, and +/// its output is then replayed on top of itself. The count cannot miss +/// that, because the events went through the same pump either way. +/// +/// Its path out: the sink belongs to the session, so once that is dropped +/// every send fails and this returns. Nothing else has to remember it. +fn spawn_import_sync( + transport: Transport, + dir: PathBuf, + mut cursor: import::Cursor, + sink: EventSink, + shared: Arc, +) { + tokio::spawn(async move { + // What the session had recorded when the cursor was last correct. + let mut written_at_cursor = *shared.written.lock().unwrap(); + loop { + tokio::time::sleep(import::SYNC_INTERVAL).await; + if sink.is_closed() { + return; + } + let Ok(lines) = import::line_count(&transport, &cursor.path).await else { + // A file that cannot be counted is not worth reporting: it + // is usually a machine briefly away, and the next poll asks + // again. + continue; + }; + let written_now = *shared.written.lock().unwrap(); + if written_now != written_at_cursor { + // This session produced something since the cursor was set, + // so the new lines are its own. Skip them and resynchronise. + cursor.lines = lines; + written_at_cursor = written_now; + import::write_cursor(&dir, &cursor); + continue; + } + if lines <= cursor.lines { + continue; + } + match import::replay_after(&transport, &cursor.path, cursor.lines).await { + Ok(events) => { + tracing::info!( + "{} grew by {} lines with nothing from here; replaying {} events", + cursor.path, + lines - cursor.lines, + events.len(), + ); + let count = events.len() as u64; + for event in events { + if sink.send(event).is_err() { + return; + } + } + cursor.lines = lines; + // The pump is about to record exactly these, so account + // for them rather than reading a count that may not have + // caught up yet. + written_at_cursor = written_now + count; + import::write_cursor(&dir, &cursor); + } + Err(err) => tracing::warn!("couldn't read new lines of {}: {err:#}", cursor.path), + } + } + }); +} + /// What an imported session starts life with: the token that makes the CLI /// continue rather than begin, and the conversation so far. pub struct Seed { /// The CLI's own session id, written where `claude.rs` looks for it. pub resume: String, + /// Where that session's file is and how much of it has been shown, so + /// the session can keep itself up to date afterwards. + pub cursor: import::Cursor, /// Replayed into the transcript so the phone shows the conversation it /// is joining. The CLI reads the real file itself, so this is what the /// reader sees rather than what the model is given. @@ -693,6 +828,7 @@ fn launch( // the history is already in the transcript a phone will read. if let Some(seed) = seed { claude::write_resume_token(&dir, &seed.resume); + import::write_cursor(&dir, &seed.cursor); let at = now(); for event in seed.events { transcript.append(event, at)?; @@ -705,8 +841,24 @@ fn launch( status: Mutex::new(SessionStatus::Idle), last_activity: Mutex::new(now()), model: Mutex::new(meta.model.clone()), + permission_mode: Mutex::new(meta.permission_mode.clone()), + written: Mutex::new(0), }); + // An imported session shares its transcript file with the CLI -- + // `--resume` appends to the same one rather than forking, measured + // rather than assumed -- so work done at a terminal belongs in this + // session too, and arrives without anybody pressing anything. + if let Some(cursor) = import::read_cursor(&dir) { + spawn_import_sync( + Transport::for_setup(setup), + dir.clone(), + cursor, + sink.clone(), + Arc::clone(&shared), + ); + } + let driver: Box = match provider.kind { DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())), DriverKind::LlamaCpp => Box::new(LlamaDriver::spawn( @@ -765,6 +917,7 @@ async fn pump( *shared.status.lock().unwrap() = *state; } *shared.last_activity.lock().unwrap() = ts; + *shared.written.lock().unwrap() += 1; // No subscribers is fine; the transcript already has it. let _ = events.send(entry); }