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

@@ -118,6 +118,8 @@ data class SessionSummary(
val provider: String, val provider: String,
val title: String, val title: String,
val model: String?, val model: String?,
/** How much the session asks before acting; null when it was never set. */
val permissionMode: String?,
val status: String, val status: String,
val lastActivity: Double, val lastActivity: Double,
) )
@@ -129,6 +131,7 @@ private fun parseSession(session: JSONObject) =
provider = session.getString("provider"), provider = session.getString("provider"),
title = session.getString("title"), title = session.getString("title"),
model = session.optString("model").ifEmpty { null }, model = session.optString("model").ifEmpty { null },
permissionMode = session.optString("permissionMode").ifEmpty { null },
status = session.getString("status"), status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"), lastActivity = session.getDouble("lastActivity"),
) )
@@ -431,6 +434,26 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String)
requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {} 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) { fun deleteSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {} requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
} }
@@ -24,6 +24,8 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -147,6 +149,13 @@ fun SessionScreen(
var expandedTools by remember { mutableStateOf(setOf<String>()) } var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message. // Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) } var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// 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<List<String>>(emptyList()) }
val context = LocalContext.current val context = LocalContext.current
// The resume cursor, written from the stream's IO thread. // The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) } 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) { fun act(action: () -> Unit) {
scope.launch { scope.launch {
try { try {
@@ -371,6 +398,28 @@ fun SessionScreen(
) { ) {
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}") 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)) Spacer(Modifier.weight(1f))
if (status == "running" || status == "compacting") { if (status == "running" || status == "compacting") {
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) { 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<String>, 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)
},
)
}
}
}
}
+26 -1
View File
@@ -68,6 +68,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/answer", post(answer)) .route("/sessions/{id}/answer", post(answer))
.route("/sessions/{id}/interrupt", post(interrupt)) .route("/sessions/{id}/interrupt", post(interrupt))
.route("/sessions/{id}/model", post(set_model)) .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}/compact", post(compact))
.route("/sessions/{id}/attachments", post(upload_attachment)) .route("/sessions/{id}/attachments", post(upload_attachment))
.route("/sessions/{id}/files/{name}", get(serve_file)) .route("/sessions/{id}/files/{name}", get(serve_file))
@@ -497,7 +498,14 @@ async fn spawn_session(
manager.spawn_imported( manager.spawn_imported(
spec, spec,
crate::session::Seed { 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, events,
}, },
) )
@@ -604,6 +612,23 @@ async fn set_model(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PermissionModeRequest {
mode: String,
}
async fn set_permission_mode(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<PermissionModeRequest>,
) -> Result<StatusCode, ApiError> {
manager
.set_session_permission_mode(&id, &body.mode)
.map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
async fn compact( async fn compact(
State(manager): State<Arc<SessionManager>>, State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>, UrlPath(id): UrlPath<String>,
+8
View File
@@ -33,6 +33,10 @@
//! between two Bash calls) -- the behavior this app exists for. //! between two Bash calls) -- the behavior this app exists for.
//! - `control_request{subtype:set_model}` answers success; //! - `control_request{subtype:set_model}` answers success;
//! `{subtype:interrupt}` stops the turn. //! `{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::collections::VecDeque;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -303,6 +307,10 @@ impl Driver for ClaudeDriver {
self.send_control(json!({"subtype": "interrupt"})); 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) { fn set_model(&self, model: &str) {
self.send_control(json!({"subtype": "set_model", "model": model})); self.send_control(json!({"subtype": "set_model", "model": model}));
} }
+4
View File
@@ -106,6 +106,10 @@ pub trait Driver: Send + Sync {
/// Stop mid-run; the session survives. /// Stop mid-run; the session survives.
fn interrupt(&self); fn interrupt(&self);
fn set_model(&self, model: &str); 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`. /// pi: native compaction; claude: `/compact`.
fn compact(&self); fn compact(&self);
/// Graceful process exit. /// Graceful process exit.
+6
View File
@@ -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) { fn set_model(&self, model: &str) {
self.emit(Event::Error { self.emit(Event::Error {
message: format!("echo sessions have no model to change to {model}"), message: format!("echo sessions have no model to change to {model}"),
+72
View File
@@ -348,3 +348,75 @@ pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
.with_context(|| format!("deleting {}", chosen.path))?; .with_context(|| format!("deleting {}", chosen.path))?;
Ok(()) 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<Cursor> {
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<usize> {
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<Vec<Event>> {
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))
}
+8
View File
@@ -247,6 +247,14 @@ impl Driver for LlamaDriver {
self.cancel.store(true, Ordering::Relaxed); 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) { fn set_model(&self, _model: &str) {
let _ = self.sink.send(Event::Error { let _ = self.sink.send(Event::Error {
message: "a llama.cpp session's model is fixed when it starts, because the server \ message: "a llama.cpp session's model is fixed when it starts, because the server \
+155 -2
View File
@@ -30,7 +30,7 @@ use crate::config::{
Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry, Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry,
}; };
use claude::ClaudeDriver; use claude::ClaudeDriver;
use driver::{Driver, Event, ImageRef, SessionStatus}; use driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
use echo::EchoDriver; use echo::EchoDriver;
use llama::LlamaDriver; use llama::LlamaDriver;
use transcript::{SeqEvent, Transcript}; use transcript::{SeqEvent, Transcript};
@@ -76,6 +76,12 @@ pub struct SessionInfo {
pub title: String, pub title: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>, pub cwd: Option<PathBuf>,
pub status: SessionStatus, pub status: SessionStatus,
@@ -104,6 +110,20 @@ struct Shared {
status: Mutex<SessionStatus>, status: Mutex<SessionStatus>,
last_activity: Mutex<f64>, last_activity: Mutex<f64>,
model: Mutex<Option<String>>, 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 { impl LiveSession {
@@ -184,6 +204,7 @@ impl LiveSession {
setup_name: setup_name.to_string(), setup_name: setup_name.to_string(),
title: self.meta.title.clone(), title: self.meta.title.clone(),
model: self.shared.model.lock().unwrap().clone(), model: self.shared.model.lock().unwrap().clone(),
permission_mode: self.shared.permission_mode.lock().unwrap().clone(),
cwd: self.meta.cwd.clone(), cwd: self.meta.cwd.clone(),
status: *self.shared.status.lock().unwrap(), status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(),
@@ -462,6 +483,7 @@ impl SessionManager {
provider: meta.provider.clone(), provider: meta.provider.clone(),
title: meta.title.clone(), title: meta.title.clone(),
model: meta.model.clone(), model: meta.model.clone(),
permission_mode: meta.permission_mode.clone(),
cwd: meta.cwd.clone(), cwd: meta.cwd.clone(),
status: SessionStatus::Exited, status: SessionStatus::Exited,
last_activity: meta.created, last_activity: meta.created,
@@ -535,7 +557,16 @@ impl SessionManager {
setup: setup.id.clone(), setup: setup.id.clone(),
provider: provider.name.clone(), provider: provider.name.clone(),
title, 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, cwd: spec.cwd,
permission_mode: spec.permission_mode, permission_mode: spec.permission_mode,
params: spec.params, params: spec.params,
@@ -570,6 +601,30 @@ impl SessionManager {
/// list shows it) and handed to the driver, which switches in place /// list shows it) and handed to the driver, which switches in place
/// where its dialect can. Through the manager, not the session, so the /// where its dialect can. Through the manager, not the session, so the
/// config and the live view can't disagree. /// 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<()> { pub fn set_session_model(&self, id: &str, model: &str) -> Result<()> {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
if !inner.config.sessions.iter().any(|meta| meta.id == id) { 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 /// Creates the session directory, opens its transcript (continuing the
/// sequence numbering if one exists), starts the driver, and spawns the /// sequence numbering if one exists), starts the driver, and spawns the
/// event pump connecting them. /// 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 /// What an imported session starts life with: the token that makes the CLI
/// continue rather than begin, and the conversation so far. /// continue rather than begin, and the conversation so far.
pub struct Seed { pub struct Seed {
/// The CLI's own session id, written where `claude.rs` looks for it. /// The CLI's own session id, written where `claude.rs` looks for it.
pub resume: String, 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 /// 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 /// is joining. The CLI reads the real file itself, so this is what the
/// reader sees rather than what the model is given. /// 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. // the history is already in the transcript a phone will read.
if let Some(seed) = seed { if let Some(seed) = seed {
claude::write_resume_token(&dir, &seed.resume); claude::write_resume_token(&dir, &seed.resume);
import::write_cursor(&dir, &seed.cursor);
let at = now(); let at = now();
for event in seed.events { for event in seed.events {
transcript.append(event, at)?; transcript.append(event, at)?;
@@ -705,8 +841,24 @@ fn launch(
status: Mutex::new(SessionStatus::Idle), status: Mutex::new(SessionStatus::Idle),
last_activity: Mutex::new(now()), last_activity: Mutex::new(now()),
model: Mutex::new(meta.model.clone()), 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 { let driver: Box<dyn Driver> = match provider.kind {
DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())), DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
DriverKind::LlamaCpp => Box::new(LlamaDriver::spawn( DriverKind::LlamaCpp => Box::new(LlamaDriver::spawn(
@@ -765,6 +917,7 @@ async fn pump(
*shared.status.lock().unwrap() = *state; *shared.status.lock().unwrap() = *state;
} }
*shared.last_activity.lock().unwrap() = ts; *shared.last_activity.lock().unwrap() = ts;
*shared.written.lock().unwrap() += 1;
// No subscribers is fine; the transcript already has it. // No subscribers is fine; the transcript already has it.
let _ = events.send(entry); let _ = events.send(entry);
} }