diff --git a/PLAN.md b/PLAN.md index 3e20b2b..c95ef0a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -193,6 +193,12 @@ it touches the transcript or the phone: and the phone folds with the same one (2026-08-30: this replaced a running spend total, which could only climb and so kept reporting a context a compaction or a clear had already taken away). + A session the server has no measurement of asks the CLI's own file + instead of waiting for a turn — `import::context_of`, the same three + fields the import list reads, in the background at load so a start never + waits on an ssh. A clear needs no special case: it gives the CLI a new + session id, so the lookup lands on a file with no usage in it and + answers "unknown", which is true. - `Error { message }`. Every event is appended to the session's transcript file with a sequence diff --git a/server/src/session/import.rs b/server/src/session/import.rs index 758779a..fb69684 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -688,6 +688,50 @@ pub async fn line_count(transport: &Transport, path: &str) -> Result { .with_context(|| format!("couldn't read a line count out of {out:?}")) } +/// What the CLI's own file says a session is holding, for a session this +/// server has no measurement of. +/// +/// A restarting server has been told nothing, and a session that has not +/// taken a turn since will not tell it -- so a conversation that is nearly +/// full reads as one nobody has counted until somebody sends a message to +/// it. The CLI records the figure on every assistant message, so it is +/// there to be read rather than waited for, and reading it is a +/// measurement rather than a guess: the same three fields, from the same +/// file, that the import list reports. +/// +/// A clear needs no special case here even though it makes the last usage +/// in a file stale. Clearing gives the CLI a *new* session id, which the +/// reader persists as the resume token, so this looks in a file that has +/// no usage in it yet and answers `None` -- which is the true answer. +/// +/// `None` for every way it cannot be read: no resume token, no file, a +/// machine that cannot be reached, or a file with no assistant turn in it. +/// Not knowing is a state the status row draws, so there is nothing to be +/// gained by inventing a number here. +pub async fn context_of(transport: &Transport, session_id: &str) -> Option { + // The id crosses as an argument rather than as script text: it comes + // from the CLI, but it reaches a shell on a machine that may not be + // this one, and the rule there is that data never becomes syntax. + let script = r#" +for f in "$HOME"/.claude/projects/*/"$1".jsonl; do + [ -f "$f" ] || continue + grep -o '"usage":{[^}]*' "$f" | tail -1 + exit 0 +done +"#; + let launch = Launch::new( + "sh", + vec![ + "-c".to_string(), + script.to_string(), + "sh".to_string(), + session_id.to_string(), + ], + None, + ); + context_tokens(&transport.capture(&launch).await.ok()?) +} + /// Events from the lines after `after`, which is a 0-based count of lines /// already accounted for. pub async fn replay_after( diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 20e17f5..b53fc0d 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -1299,6 +1299,32 @@ fn launch( written: Mutex::new(0), }); + // Nothing here has measured this session's context: the transcript + // predates the figure being recorded, or the last turn happened before + // this server was watching. The CLI wrote it down at the time, so ask + // its file rather than leaving the row saying "unknown" until somebody + // sends a message. In the background, because it is a file read on a + // machine that may be at the other end of an ssh connection, and a + // server start must not wait on one. + if provider.kind == DriverKind::ClaudeCli + && shared.context_tokens.lock().unwrap().is_none() + && let Some(session_id) = claude::read_resume_token(&dir) + { + let transport = Transport::for_setup(setup); + let shared = Arc::clone(&shared); + tokio::spawn(async move { + if let Some(context) = import::context_of(&transport, &session_id).await { + // Only if nothing else has answered in the meantime: a turn + // that finished while this was in flight measured the + // context after the one this read. + let mut held = shared.context_tokens.lock().unwrap(); + if held.is_none() { + *held = Some(context); + } + } + }); + } + // 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