Stop choosing a model, and keep an imported session up to date

**Why the model became fable.** `spawn_session` fell back to the
provider's first listed model when none was given. That list is a shortcut
for the spawn screen, written in whatever order somebody typed it, and its
first entry is `fable` -- so every session spawned without a model, which
is every import, silently became a fable session. It looked like a default
and was an artefact of list order. Absent now means absent: no `--model`
flag, and the CLI uses whatever the person configured for themselves.

**Model and permission mode are now visible and changeable** from the
session, as buttons that read as their current value rather than labels
beside one. The mode was spawn-only; the CLI turns out to accept
`control_request{subtype:set_permission_mode}` and echo the mode back,
probed against 2.1.237 the same way the rest of the protocol record was.
Both default to `auto` -- on a phone every ask is a round trip to a
question card, which is how "allow Bash?" became the most-answered
question in the app.

The mode is reported by the API so the picker shows what the session is
actually set to, and it is kept in the live session beside the model for
the reason the model already was: `meta` is the shape a session was
*launched* with, so reporting from it shows the value a change replaced.

**And an imported session keeps itself level with its source file**, so
work done at a terminal arrives without a button. `--resume` appends to
the same transcript rather than forking -- measured, not assumed -- so the
only hard question is which new lines came from here.

Answered by counting the events this session has recorded. Status is the
obvious signal and is wrong, which cost a round trip to find: a turn that
starts and finishes between two polls reads as idle at both, so its output
is replayed on top of itself. It showed up on screen as `donedone`, and
only because the reply was one word -- with a longer answer it would have
looked like the model repeating itself.

Verified against both halves: text appended to the source file the way a
terminal writes it appears within one interval, and a message sent through
the app appears exactly once, before and after a turn.
This commit is contained in:
iris committed 2026-08-28 22:44:41 -04:00
1 parent c3e7f07a5d
commit a9ea84c96c
9 files changed
+381 -3

No files matched your search

+155 -2
View File
@@ -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<String>,
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
pub status: SessionStatus,
@@ -104,6 +110,20 @@ struct Shared {
status: Mutex<SessionStatus>,
last_activity: Mutex<f64>,
model: Mutex<Option<String>>,
/// 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<Option<String>>,
/// 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<u64>,
}
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<Shared>,
) {
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<dyn Driver> = 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);
}