diff --git a/PLAN.md b/PLAN.md index d8185bf..a0c24a3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -147,12 +147,26 @@ turn. Spawn: `claude -p --verbose --input-format stream-json --output-format stream-json --permission-mode ` in the chosen working directory, plus -`--model`. Wire-format notes are pinned against CLI 2.1.237 in -`session/claude.rs`'s module doc: permissions need the hidden -`--permission-prompt-tool stdio` flag, AskUserQuestion answers ride -`updatedInput.answers` keyed by question text, and `set_model`/`interrupt` +`--model` and, where one has been chosen, `--effort`. Wire-format notes are +pinned against CLI 2.1.237 in `session/claude.rs`'s module doc: permissions +need the hidden `--permission-prompt-tool stdio` flag, AskUserQuestion answers +ride `updatedInput.answers` keyed by question text, and `set_model`/`interrupt` are control requests. +**The thinking level is settled at launch** (added 2026-09-04, because it is +the largest saving available on a long session: output is about an eighth of +what a session costs and thinking is the bulk of output, against the ~1.5% that +is prose). The CLI's only two setting control requests are `set_model` and +`set_permission_mode` -- checked against the 2.1.258 binary -- so there is no +way to ask a running process to think differently. `set_session_effort` is +therefore shaped like `set_session_cwd` rather than like `set_session_model`: +it records the level and **stops the process**, and the next message or Start +launches one that has it. It lives in the session settings dialog beside the +working directory for that reason, not on the session bar beside the model and +the mode, which do take effect mid-turn. `None` is a level in its own right -- +the CLI's own default -- so the picker can return to it; a level this app named +as the default instead would be this app choosing one. + **`--resume` only ever runs when nothing else has that session open.** That is the rule behind the import refusal, the single `ClaudeDriver::launch` entry point, and the `Exited` correction below; two CLIs on one session file 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 166a337..8dafacd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -142,6 +142,20 @@ data class SessionSummary( val keepsOwnTranscript: Boolean, /** How much the session asks before acting; null when it was never set. */ val permissionMode: String?, + /** + * How hard the model thinks, or null for the CLI's own default. + * + * Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is + * reported rather than assumed for the same reason [permissionMode] is. + */ + val effort: String?, + /** + * Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one. + * + * Asked of the server rather than worked out from the provider's name, because this is a + * property of the driver's *kind* and the phone only has the name. + */ + val takesEffort: Boolean, /** * Whether this continues a session the machine already had, which changes what deleting means. */ @@ -203,6 +217,8 @@ private fun parseSession(session: JSONObject) = title = session.getString("title"), model = session.optString("model").ifEmpty { null }, permissionMode = session.optString("permissionMode").ifEmpty { null }, + effort = session.optString("effort").ifEmpty { null }, + takesEffort = session.optBoolean("takesEffort", false), imported = session.optBoolean("imported", false), notify = session.optBoolean("notify", true), cwd = session.optString("cwd").ifEmpty { null }, @@ -992,6 +1008,35 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) */ val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") +/** + * How hard the model thinks, as `claude --effort` takes them, cheapest first. + * + * Not offered alongside the model and the permission mode on the session's own bar, because it does + * not behave like them: the CLI has a control request for those two and none for this (checked + * against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops + * the process, which is what the working directory beside it in this dialog does, and why it is + * here rather than on a bar whose other controls take effect mid-turn. + */ +val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max") + +/** What the picker shows, and sends as null, for a session that has chosen no level. */ +const val DEFAULT_EFFORT = "default" + +/** + * Records how hard a session thinks and **stops its process**, since the level is read when the + * process is launched. The next message, or Start, runs one that has it. + * + * [level] is null for the CLI's own default. + */ +fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) { + requestFromServer( + settings, + "/sessions/$sessionId/effort", + method = "POST", + jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(), + ) {} +} + /** Switches how much a running session asks before acting, also in place. */ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) { requestFromServer( 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 6255810..f31cd0e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1846,6 +1846,8 @@ fun SessionScreen( settings = settings, sessionId = summary.id, title = title, + effort = summary.effort.takeIf { summary.takesEffort }, + takesEffort = summary.takesEffort, cachedBytes = cachedBytes, // The purge finishes before the epoch moves, because the relaunched opening effect // reads the same directory and would otherwise draw what is about to be deleted. The @@ -2249,7 +2251,7 @@ private const val ONE_TAP_MS = 250L * session is set to without spending a second line on saying it. */ @Composable -private fun PickerButton(current: String, options: List, onPick: (String) -> Unit) { +fun PickerButton(current: String, options: List, onPick: (String) -> Unit) { var open by remember { mutableStateOf(false) } // When an outside touch last closed the menu. // 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 4e116b2..c756642 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -54,6 +54,16 @@ fun SessionSettingsDialog( */ title: String, onRenamed: (String) -> Unit, + /** + * How hard the model thinks, as the session reports it, or null for the CLI's own default. + * + * Taken from the row this dialog was opened over rather than fetched, because unlike the + * notification switch there is nothing else that changes it: the level is this app's to set and + * the server does not resolve it into something else. + */ + effort: String?, + /** Whether a level does anything here; the row is left out entirely where it does not. */ + takesEffort: Boolean, /** * What this phone is holding of the conversation, or null while that is being measured -- see * the Reload row below, which is what would discard it. @@ -69,6 +79,8 @@ fun SessionSettingsDialog( ) { val scope = rememberCoroutineScope() var name by remember(sessionId) { mutableStateOf(title) } + var level by remember(sessionId) { mutableStateOf(effort) } + var effortError by remember { mutableStateOf(null) } var saving by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } // Null until the server has been asked. The row this dialog was opened over is a snapshot of @@ -125,6 +137,26 @@ fun SessionSettingsDialog( } } + /** + * Chooses a thinking level, which ends the process the old level was launched with. + * + * Put back if the request is refused, for the reason the notification switch below gives: a + * control that stays where it was put after a refusal is stating something untrue. + */ + fun setEffort(chosen: String?) { + val was = level + level = chosen + effortError = null + scope.launch { + try { + withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) } + } catch (e: ApiException) { + level = was + effortError = e.message + } + } + } + // Moved optimistically so the switch answers the finger that moved it, and put back if the // request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel, // and one that stays moved after a refusal lies. @@ -257,6 +289,44 @@ fun SessionSettingsDialog( style = MaterialTheme.typography.bodySmall, ) } + // Left out rather than disabled, the one place this dialog does that: a disabled + // control teaches what the thing can do, and a llama session cannot do this at all + // -- the row would be teaching something false about it. + if (takesEffort) { + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Thinking", modifier = Modifier.weight(1f)) + PickerButton( + current = level ?: DEFAULT_EFFORT, + // The level the CLI picks for itself is in the list as well as in the + // button, so leaving a level is not a one-way trip -- the same + // correction the model picker carries. + options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS, + onPick = { chosen -> + setEffort(chosen.takeIf { it != DEFAULT_EFFORT }) + }, + ) + } + // What it costs, said where it is about to be pressed, like Move above: the + // CLI reads the level when it launches and has no control request for + // changing one. + Text( + "Changing this stops the session's process. It starts again with the " + + "next message, or with Start.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + effortError?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, diff --git a/server/src/config.rs b/server/src/config.rs index 2eae1d8..02d7bc4 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -199,6 +199,24 @@ impl DriverKind { Self::Echo | Self::LlamaCpp => false, } } + + /// Whether a thinking level means anything to this kind, so the phone can + /// offer the control only where it does something. + /// + /// Reported from here rather than decided on the phone, and asked of the + /// *kind* rather than branched on: the alternative is the session-type + /// `if` this app does not have anywhere else. `--effort` is the Claude + /// CLI's; a llama session's sampling is `params`, and echo does not think. + /// + /// It matters more than a control that would simply do nothing, because + /// choosing a level stops the process -- so on a session that cannot use + /// one it is a button whose only effect is the cost. + pub fn takes_effort(self) -> bool { + match self { + Self::ClaudeCli => true, + Self::Echo | Self::LlamaCpp => false, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -233,6 +251,17 @@ pub struct SessionConfig { /// the CLI stays the one authority on which modes exist. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, + /// How hard the model thinks, passed straight to `--effort`. A string for + /// the same reason `permission_mode` is: the CLI owns which levels exist. + /// + /// Unlike the model and the mode, there is no control request that changes + /// one -- checked against 2.1.258, whose only two are `set_model` and + /// `set_permission_mode` -- so this is settled at launch and `None` means + /// whatever the CLI's own default is. That is a state the phone has to be + /// able to *choose*, not just start in, which is why it is an option + /// rather than a level with a default written here. + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, /// Settings the driver interprets, chosen at spawn. /// /// Deliberately untyped: what a temperature or a context size means is the @@ -441,6 +470,7 @@ mod tests { model: None, cwd: None, permission_mode: None, + effort: None, params: BTreeMap::new(), notify: true, throwaway: false, diff --git a/server/src/routes.rs b/server/src/routes.rs index 541f2f6..7ce44bd 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -42,6 +42,8 @@ //! which starts again in the new one //! POST /sessions/{id}/model {model} //! POST /sessions/{id}/permission-mode {permissionMode} +//! POST /sessions/{id}/effort {effort} -- null for the CLI's default; +//! settled at launch, so this stops the process //! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own //! (starts the process first if it has exited) //! POST /sessions/{id}/compact @@ -134,6 +136,7 @@ pub fn router(manager: Arc) -> Router { .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}/effort", post(set_effort)) .route("/sessions/{id}/notify", post(set_notify)) .route("/notifications", get(notifications)) .route("/sessions/{id}/compact", post(compact)) @@ -644,6 +647,8 @@ struct SpawnRequest { cwd: Option, #[serde(default)] permission_mode: Option, + #[serde(default)] + effort: Option, /// Whatever the chosen driver understands -- llama.cpp's context size and /// sampling. Opaque here on purpose: see `SessionConfig::params`. #[serde(default)] @@ -831,6 +836,7 @@ async fn start_import( model: body.model.clone(), cwd: None, permission_mode: body.permission_mode.clone(), + effort: body.effort.clone(), params: std::collections::BTreeMap::new(), import: Some(session.clone()), }; @@ -863,6 +869,8 @@ struct ImportRequest { model: Option, #[serde(default)] permission_mode: Option, + #[serde(default)] + effort: Option, } /// Runs `work` on the server, marked as in flight for as long as it takes. @@ -1013,6 +1021,7 @@ async fn spawn(manager: &Arc, body: SpawnRequest) -> Result, +} + +/// Records how hard this session thinks, and stops the process so the next one +/// is launched with it -- `--effort` has no control request behind it. See +/// [`SessionManager::set_session_effort`]. +async fn set_effort( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + manager + .set_session_effort(&id, body.effort.as_deref()) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + async fn set_permission_mode( State(manager): State>, UrlPath(id): UrlPath, diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 5adfc93..5bffaaa 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -351,6 +351,12 @@ impl ClaudeDriver { if let Some(mode) = &meta.permission_mode { push("--permission-mode", mode); } + // Launch-only: see `SessionConfig::effort`. Omitted entirely when + // unset, so the CLI's own default is what an unchosen session gets + // rather than a level this app decided to call the default. + if let Some(effort) = &meta.effort { + push("--effort", effort); + } // Named at birth, so this session is the same session in the CLI's own // picker and in what other agents see. // diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index c7b017a..4c6cf81 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -63,6 +63,8 @@ pub struct SpawnSpec { pub model: Option, pub cwd: Option, pub permission_mode: Option, + /// See `SessionConfig::effort`. + pub effort: Option, /// Driver-interpreted settings; see `SessionConfig::params`. pub params: std::collections::BTreeMap, } @@ -118,6 +120,16 @@ pub struct SessionInfo { /// were confirming. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, + /// How hard it thinks; see `SessionConfig::effort`. Reported for the same + /// reason the mode is, and absent where nothing has been chosen -- which + /// the phone draws as the CLI's default rather than as a level. + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + /// Whether a level means anything here; see `DriverKind::takes_effort`. + /// Reported beside the level because absent-and-irrelevant and + /// absent-and-unchosen are different answers, and only one of them is a + /// control worth drawing. + pub takes_effort: bool, /// Whether this session continues one the machine already had. /// Reported because it changes what deleting *means*: an imported /// session's real transcript belongs to the CLI and survives, so @@ -435,6 +447,7 @@ impl LiveSession { &self, setup_name: &str, cwd: Option<&Path>, + effort: Option<&str>, imported: bool, kind: Option, ) -> SessionInfo { @@ -446,6 +459,11 @@ impl LiveSession { title: self.shared.title.lock().unwrap().clone(), model: self.shared.model.lock().unwrap().clone(), permission_mode: self.shared.permission_mode.lock().unwrap().clone(), + // From the config rather than from `shared`, like the cwd beside + // it: neither can change under a running process, so there is no + // live value for one to disagree with. + effort: effort.map(str::to_string), + takes_effort: kind.is_some_and(DriverKind::takes_effort), context_tokens: *self.shared.context_tokens.lock().unwrap(), notify: *self.shared.notify.lock().unwrap(), max_image_edge: kind.and_then(DriverKind::max_image_edge), @@ -902,6 +920,7 @@ impl SessionManager { Some(session) => session.info( label_of(&inner.config, &meta.setup), meta.cwd.as_deref(), + meta.effort.as_deref(), import::read_cursor(&self.data_dir.join(&meta.id)).is_some(), kind_of(&inner.config, &meta.setup, &meta.provider), ), @@ -913,6 +932,9 @@ impl SessionManager { title: meta.title.clone(), model: meta.model.clone(), permission_mode: meta.permission_mode.clone(), + effort: meta.effort.clone(), + takes_effort: kind_of(&inner.config, &meta.setup, &meta.provider) + .is_some_and(DriverKind::takes_effort), context_tokens: None, max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider) .and_then(DriverKind::max_image_edge), @@ -1016,6 +1038,7 @@ impl SessionManager { model: spec.model, cwd: spec.cwd, permission_mode: spec.permission_mode, + effort: spec.effort, params: spec.params, // On by default. Not offered at spawn: a session's first turn // is exactly the one somebody is waiting for. @@ -1050,6 +1073,7 @@ impl SessionManager { let info = session.info( &setup.name, session.meta.cwd.as_deref(), + session.meta.effort.as_deref(), import::read_cursor(&self.data_dir.join(&id)).is_some(), Some(provider.kind), ); @@ -1247,6 +1271,47 @@ impl SessionManager { Ok(()) } + /// Sets how hard this session's model thinks. + /// + /// Shaped like [`SessionManager::set_session_cwd`] rather than like + /// [`SessionManager::set_session_model`], because `--effort` is a launch + /// flag with no control request behind it: the running process cannot be + /// asked, so the choice is recorded and the process ended, and the next + /// thing said to the session starts one that has it. Announcing it as a + /// settled change instead would put a level on the phone that the process + /// still running underneath was not using. + /// + /// `None` clears it, which is a level in its own right -- the CLI's own + /// default -- and the reason this takes an option rather than a string. + pub fn set_session_effort(&self, id: &str, effort: Option<&str>) -> Result<()> { + let effort = effort.map(str::trim).filter(|level| !level.is_empty()); + { + 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.effort = effort.map(str::to_string); + } + candidate.save(&self.config_path)?; + inner.config = candidate; + } + // Saved before the process is touched, for the reason `set_session_cwd` + // gives: a process that will not stop must not leave the session + // recorded as something nothing agrees with. + let dir = self.data_dir.join(id); + if let Some(record) = process::live(&dir) { + tracing::info!( + "session {id} effort now {} -- stopping pid {}", + effort.unwrap_or("default"), + record.pid + ); + process::stop(&record, process::STOP_GRACE); + } + Ok(()) + } + /// Ends this session's process, leaving the session -- its transcript, /// its place in the list, everything a phone is watching -- exactly /// where it is. [`SessionManager::start_session`] is the way back. @@ -2256,6 +2321,7 @@ mod tests { model: None, cwd: None, permission_mode: None, + effort: None, } } @@ -2558,7 +2624,10 @@ 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", None, false, None).title); + assert_eq!( + first.title, + session.info("m", None, 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 @@ -3411,6 +3480,60 @@ mod tests { std::fs::write(path, rewritten).expect("write transcript"); } + /// A thinking level is stored and the process **ended**, because `--effort` + /// is read when the CLI launches and has no control request behind it. A + /// session left running would go on thinking at the old level underneath a + /// phone showing the new one -- the failure this app has already had once + /// with the model, and the one a stop makes impossible rather than + /// unlikely. + /// + /// Clearing it back to the CLI's own default is exercised too: that is a + /// level somebody can choose, not only one to start in, so a picker that + /// could not return to it would make leaving a level a one-way trip. + #[tokio::test] + async fn choosing_a_thinking_level_stores_it_and_ends_the_process() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.ron"); + let data_dir = dir.path().join("sessions"); + // The stand-in CLI rather than the echo driver: what is under test is + // that a *process* is ended, and an echo session has none to end. + let provider = seed_stand_in_cli(&config_path, dir.path()); + let manager = SessionManager::new( + config_path.clone(), + data_dir.clone(), + data_dir.join("models"), + ) + .expect("manager"); + let info = manager + .spawn_session(stand_in_spec(&provider)) + .expect("spawn"); + assert_eq!(info.effort, None, "nothing is chosen at spawn"); + let record = process::live(&data_dir.join(&info.id)).expect("the session has a process"); + + manager + .set_session_effort(&info.id, Some("low")) + .expect("store the level"); + assert_eq!( + manager.sessions()[0].effort.as_deref(), + Some("low"), + "stored, so the next start is launched with it" + ); + process::wait_gone(&[record], process::STOP_GRACE); + + // Blank is the same answer as unchosen; normalized here so a caller + // clearing the field cannot store a level the CLI would reject. + manager + .set_session_effort(&info.id, Some(" ")) + .expect("clear the level"); + assert_eq!( + manager.sessions()[0].effort, + None, + "the CLI's own default has to be reachable again" + ); + + manager.delete_session(&info.id).expect("delete"); + } + /// A setting changed on a session with nothing running is recorded as the /// session's own, rather than refused because there is no driver. The /// config already took it, so the refusal was about the driver while