Let a session choose how hard it thinks

Output is about an eighth of what a session costs and thinking is nearly
all of it -- prose is ~1.5% of output tokens, measured over 27,015 requests
of this account's own transcripts -- so the level is the largest saving
available short of shortening the conversation itself.

Shaped like the working directory rather than like the model: the CLI's
only two setting control requests are `set_model` and `set_permission_mode`
(checked against the 2.1.258 binary), so `--effort` is read when the process
launches and cannot be asked of a running one. `set_session_effort` records
the level and stops the process; the next message or Start launches one that
has it. That is also why the picker is in the session settings dialog beside
Move, and not on the bar beside the model and the mode, which take effect
mid-turn.

`None` is a level in its own right -- the CLI's own default -- so the picker
can return to it, and a blank is normalized to it at the boundary rather
than stored as a level the CLI would reject.

Offered only where it means something: `DriverKind::takes_effort` reports
the capability and the phone leaves the row out entirely, rather than the
session-type branch this app does not have anywhere else. A llama session
would otherwise get a control whose only effect is stopping its process.

Verified on the emulator against the sandbox's fake CLI: the picker sets it,
the server reports it, and an echo session's dialog is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 21:23:58 -04:00
1 parent e4f0935f98
commit 1ff662c7c3
8 files changed
+329 -6

No files matched your search

+6
View File
@@ -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.
//
+124 -1
View File
@@ -63,6 +63,8 @@ pub struct SpawnSpec {
pub model: Option<String>,
pub cwd: Option<PathBuf>,
pub permission_mode: Option<String>,
/// See `SessionConfig::effort`.
pub effort: Option<String>,
/// Driver-interpreted settings; see `SessionConfig::params`.
pub params: std::collections::BTreeMap<String, String>,
}
@@ -118,6 +120,16 @@ pub struct SessionInfo {
/// were confirming.
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
/// 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<String>,
/// 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<DriverKind>,
) -> 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