Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 5428cd75c9
commit 25370731d0
193 files changed
+693 -16219

No files matched your search

-40
View File
@@ -1,16 +1,3 @@
//! Bearer-token auth for the entire HTTP surface.
//!
//! This server's API *is* remote code execution, so the token gates every route
//! with zero unauthenticated endpoints -- the middleware is applied once around
//! the whole router (including the fallback) in `main.rs`, never per-route, so a
//! new route can't forget it. See PLAN.md's security section for the threat
//! model.
//!
//! Nothing in this module -- and nothing anywhere else -- may log the
//! Authorization header or the token; the test below is a tripwire against a
//! logging change silently starting to. It is one test covering both gating and
//! logging on purpose -- see the note in it.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
@@ -24,9 +11,6 @@ use wg_app_link::enroll::{take_pending, token_hash_hex, token_matches};
use crate::config::TokenEntry;
use crate::session::SessionManager;
/// Applied to every rejection. Not against brute force -- infeasible at 256
/// bits -- but so a scanner probing the port shows up as a slow, loggable
/// drip rather than a fast one.
const REJECT_DELAY: Duration = Duration::from_millis(300);
pub async fn require_token(
@@ -48,9 +32,6 @@ pub async fn require_token(
if token_matches(token, &hashes) {
return next.run(request).await;
}
// A link minted by `--enroll-link` while this server was running:
// the entry moves from the spool into the config here, on first
// use, and is an ordinary token from then on.
match take_pending(&manager.pending_enrollments_dir(), token) {
Ok(Some(name)) => {
let entry = TokenEntry {
@@ -127,12 +108,6 @@ mod tests {
builder.body(Body::empty()).expect("request")
}
/// One test rather than separate gating and logging tests,
/// deliberately: tracing caches callsite interest process-wide, so a
/// test that hits the rejection path with no subscriber installed can
/// poison the interest cache for the one that captures logs. Keeping
/// every exercise of the middleware under the capturing subscriber
/// makes the log assertions deterministic.
#[tokio::test]
async fn gates_every_route_and_never_logs_the_token() {
#[derive(Clone, Default)]
@@ -164,8 +139,6 @@ mod tests {
let token = generate_token();
let router = guarded_router(manager_with_token(dir.path(), &token));
// No header, wrong token, wrong scheme: 401 everywhere, including
// paths that don't exist -- a scanner learns nothing.
for (path, auth) in [
("/probe", None),
("/probe", Some("Bearer wrong".to_string())),
@@ -191,29 +164,16 @@ mod tests {
.expect("response");
assert_eq!(ok.status(), StatusCode::OK);
// The tripwire that keeps a future logging change (e.g. logging
// request headers) from silently leaking credentials.
let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned();
assert!(
!logged.contains(&token),
"the bearer token leaked into the logs: {logged}"
);
// The rejections themselves do get logged (that's the point).
assert!(logged.contains("missing or invalid bearer token"));
}
/// A token spooled by `--enroll-link` is refused by nothing: the first
/// request carrying it is served, and from then on it is in the config
/// like any other.
#[tokio::test]
async fn a_spooled_enrollment_is_adopted_on_first_use() {
// Under a subscriber, like every other exercise of this middleware.
// `tracing` caches a callsite's interest process-wide the first time it
// is reached, so the refusal at the end of this test -- reached with no
// subscriber on this thread -- could cache the rejection warning as
// never-enabled and make the tripwire above see an empty log. That
// failed about one full-suite run in ten, in the test that exists to
// notice a credential leak, which is the worst place for a flake.
let _guard = tracing::subscriber::set_default(
tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
-207
View File
@@ -1,18 +1,3 @@
//! The server's persistent state: the enrolled token hashes and the sessions
//! that exist.
//!
//! Written whole and atomically (temp file + rename) rather than appended to:
//! it is small, and a half-written config would take the server down on next
//! start with no obvious way to recover from a phone. Every mutation funnels
//! through `SessionManager`, so in-memory and on-disk state can't come apart.
//!
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the same
//! two house rules as dev-updater's config, because both are read and written
//! by hand.
//!
//! Transcripts do NOT live here: each session's events are an append-only JSONL
//! file in its own directory.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -24,14 +9,9 @@ use wg_app_link::format;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Config {
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak the
/// credential. A list (of one, today) so per-device tokens with individual
/// revocation are a config entry later, not a migration.
pub tokens: Vec<TokenEntry>,
pub setups: Vec<SetupConfig>,
pub sessions: Vec<SessionConfig>,
/// What a new session's thinking level is when nothing chose one.
///
/// Here rather than on a provider because providers are *discovered*: a
/// default written onto one would be erased by the next rediscovery, which
/// is the kind of setting that looks like it stuck until the day it did
@@ -44,26 +24,13 @@ pub struct Config {
pub default_effort: Option<String>,
}
/// A machine, and the things it can run.
///
/// This is the unit a session is spawned against. Grouping providers under the
/// machine they exist on is what stops the spawn screen offering combinations
/// that cannot work; the previous model let any provider be paired with any
/// host and offered the whole cross-product.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupConfig {
/// Stable identifier, minted when the setup is added and never
/// changed. Sessions reference this rather than the label, so
/// renaming a machine on the phone does not orphan its sessions --
/// which is the whole reason the two are separate fields.
pub id: String,
pub name: String,
/// How to reach it, absent for this machine.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<SshConfig>,
/// What can be spawned here. Names are unique within a setup, and only
/// within it: two machines may each have a `claude-cli`, which is the point.
#[serde(default)]
pub providers: Vec<ProviderConfig>,
}
@@ -74,25 +41,18 @@ impl SetupConfig {
}
}
/// One thing a setup can run: which driver, and how to invoke it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
/// Shown on the spawn screen and stored by sessions that use it.
pub name: String,
pub kind: DriverKind,
/// Override for the executable, for an install that isn't on PATH.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
/// Models offered on the spawn screen. Free text is always allowed
/// too; this is a shortcut list, not a restriction.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
impl ProviderConfig {
/// The executable to run for this provider: its override, or its kind's
/// default.
pub fn program(&self) -> &str {
self.command
.as_deref()
@@ -100,28 +60,16 @@ impl ProviderConfig {
}
}
/// How to reach a setup that isn't this machine, with the system `ssh` client
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
/// one place to configure connections. A remote session is the identical
/// command with `ssh host …` in front, and nothing downstream knows.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SshConfig {
/// `user@host`, or a `Host` alias from `~/.ssh/config`.
pub address: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_file: Option<PathBuf>,
/// Extra `-o` settings, each written as `Key=value`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>,
/// Where this machine keeps the GGUF models it can serve, absent for
/// the same default this backend uses (`~/.local/share/ai-app/models`
/// -- `$XDG_DATA_HOME` is not read on the far side, since it is this
/// machine's environment that would answer). A `~` prefix is the
/// remote home.
///
/// Here rather than on the provider because it is a fact about the
/// machine, and because a machine reached over ssh is where the model
/// has to be: a llama.cpp session serves the file from the machine
@@ -139,22 +87,11 @@ pub struct SshConfig {
/// Which translator runs a session. A new one is a new driver behind the same
/// trait -- never a branch in shared code.
///
/// Snake case, which is both Rust's and RON's: this is written into a config a
/// person edits by hand, and a hyphen is not a RON identifier, so kebab case
/// cost the file a `kind: r#claude-cli` escape. The same string is what the
/// phone compares against, so the two move together.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriverKind {
/// The fake driver: echoes messages back as streamed events, proving the
/// pipe with no AI involved. Always available as a built-in provider.
Echo,
/// A GGUF model served by llama.cpp's `llama-server`. The model is one this
/// machine has downloaded; the provider's command is the server binary.
LlamaCpp,
/// The Claude Code CLI over stream-json. Named for the CLI specifically:
/// bare "claude" would suggest the credit-billed API, which this is not.
ClaudeCli,
}
@@ -162,13 +99,6 @@ impl DriverKind {
/// The longest edge, in pixels, an image should have when it reaches this
/// kind of session -- `None` where nothing here has a limit worth enforcing.
///
/// Reported to the phone rather than applied here, so the bytes are made
/// small before they cross the tunnel instead of after: a modern phone photo
/// is several megabytes, and every one of them was being uploaded over
/// WireGuard only to be rejected at the other end. What decides the number
/// is the provider, which is why it lives beside the kind rather than in the
/// app.
///
/// 1568 for the Claude CLI because that is the longest edge the API itself
/// resizes to; anything larger is charged the same and spends the upload for
/// nothing, and far larger is refused outright -- which is what "sending an
@@ -183,19 +113,10 @@ impl DriverKind {
/// Which paid service meters a session of this kind, and `None` for one
/// that costs nothing.
///
/// What decides which account -- if any -- a rate-limit bar is about is the
/// provider a session runs, not the machine it runs on: an echo session on
/// a machine that also has the Claude CLI was drawn with that CLI's
/// five-hour window, a quota it cannot spend.
///
/// Echo names a meter of its own that exists only when a test has asked for
/// one (`/usage` in `session::echo`), which is how the bar's states are
/// reached without an account. With none set there is no snapshot, and the
/// phone draws nothing.
///
/// The string is a [`crate::usage::UsageProvider::name`], and it is what
/// pairs a session with one of `GET /usage`'s snapshots -- so
/// `usage::providers_for` reads this rather than matching on kinds again.
pub fn usage_provider(self) -> Option<&'static str> {
match self {
Self::ClaudeCli => Some(crate::usage::CLAUDE),
@@ -214,22 +135,10 @@ impl DriverKind {
match self {
Self::ClaudeCli => "claude",
Self::LlamaCpp => "llama-server",
// Echo is translated in-process; nothing is spawned for it.
Self::Echo => "echo",
}
}
/// Whether the conversation exists outside this app, so that deleting the
/// session here does not end it.
///
/// The Claude Code CLI owns its own transcript and is resumable from it
/// whatever started it, so a session this app spawned is every bit as
/// recoverable as one it imported. Echo has nothing to keep, and a llama
/// session's conversation is folded out of *this* app's transcript.
///
/// Asked before warning somebody that a deletion cannot be undone, which is
/// the one sentence that has to be true: said of a session that can in fact
/// be brought back, it spends the credibility the warning needs.
pub fn keeps_own_transcript(self) -> bool {
match self {
Self::ClaudeCli => true,
@@ -237,17 +146,6 @@ impl DriverKind {
}
}
/// 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,
@@ -260,23 +158,14 @@ impl DriverKind {
#[serde(rename_all = "camelCase")]
pub struct TokenEntry {
pub name: String,
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256 bits
/// from the OS CSPRNG, so there is nothing to dictionary-attack.
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionConfig {
/// Stable identifier; names the session's directory and its routes.
pub id: String,
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
/// so the machine can be renamed without losing its sessions.
pub setup: String,
/// Name of the provider within that setup. Both stored by name rather than
/// resolved, so an edited setup takes effect on the next relaunch; a session
/// whose setup or provider is gone reports as exited and can still be
/// deleted.
pub provider: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -288,9 +177,6 @@ pub struct SessionConfig {
/// the CLI stays the one authority on which modes exist.
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
/// 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
@@ -299,16 +185,8 @@ pub struct SessionConfig {
/// rather than a level with a default written here.
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
/// Settings the driver interprets, chosen at spawn.
///
/// Deliberately untyped: what a temperature or a context size means is the
/// driver's business, and a field per driver is how a shared model starts
/// carrying one dialect's vocabulary. `permission_mode` above predates this
/// and should fold into it. BTreeMap so the file's order is stable.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, String>,
/// Whether a phone should be told when this session wants attention.
///
/// Stored here rather than on the phone because it is a fact about the
/// session: one that runs unattended overnight should be quiet on every
/// device.
@@ -318,9 +196,6 @@ pub struct SessionConfig {
/// turned off in one tap where one that never arrived is not diagnosable.
#[serde(default = "notify_default")]
pub notify: bool,
/// Whether a session stopped by the account's usage limit sends itself a
/// message once the limit lifts, instead of waiting for a person.
///
/// Off unless somebody asked for it. It spends quota the moment it becomes
/// available and it does so while nobody is looking, which is exactly the
/// kind of thing that must not happen because a default said so.
@@ -331,40 +206,17 @@ pub struct SessionConfig {
/// the field goes back to it rather than sending an empty message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_resume_message: Option<String>,
/// The message this session owes itself once the limit lifts, and when to
/// try. Written when a limit is hit, moved when the wait turns out to be
/// wrong, and cleared when the message goes out or auto-resume is turned
/// off -- see [`ScheduledResume`].
///
/// Persisted rather than held in memory because the wait outlives the
/// process doing it: a five-hour window and a weekly one both routinely
/// outlast a backend restart, and a resume forgotten across one is a
/// session that silently never comes back.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume: Option<ScheduledResume>,
/// Whether this session's process is stopped when the server exits, instead
/// of being left running for the next start to adopt.
///
/// A fact about the session rather than about the run that spawned it, which
/// is why it is persisted: whichever server is running when the time comes
/// is the one that has to act on it.
///
/// Written by a server started with `--throwaway-sessions`, the default in a
/// debug build. Under the ordinary rule a test session's `claude` outlives
/// every server that ever knew about it -- twelve accumulated on this
/// machine in a day. Absent means false.
#[serde(default, skip_serializing_if = "not_set")]
pub throwaway: bool,
pub created: f64,
}
/// A message owed to a session whose account ran out, and when to try sending
/// it.
///
/// `since` is the whole reason this is a struct: the wait is rescheduled every
/// time the meter is asked and still says no, so `at` alone cannot say how long
/// this has been going on -- and something has to, or a machine that can never
/// be asked is retried until somebody notices. See `crate::resume`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledResume {
@@ -375,35 +227,20 @@ pub struct ScheduledResume {
pub since: f64,
}
/// What an auto-resume says when nothing else was chosen. One word, because
/// the session already knows what it was doing and this is only the nudge that
/// lets it carry on.
pub const DEFAULT_RESUME_MESSAGE: &str = "continue";
fn notify_default() -> bool {
true
}
/// Keeps the ordinary case out of the file entirely -- see
/// [`SessionConfig::throwaway`].
fn not_set(flag: &bool) -> bool {
!*flag
}
/// The name of the echo provider, and of the setup this machine gets on first
/// run.
///
/// Echo is seeded into the config rather than conjured at read time. An
/// implicit provider is one a person cannot see in the file or edit from the
/// phone; if somebody deletes it, that was a choice.
pub const ECHO_PROVIDER: &str = "echo";
pub const LOCAL_SETUP: &str = "this machine";
/// The id of the setup a fresh install seeds. Fixed rather than random so a
/// hand-written config can name it without looking one up.
pub const LOCAL_SETUP_ID: &str = "local";
/// Where `ai-server --enroll-link` leaves a token for the running server to
/// adopt: beside the config, since it is config in transit.
pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
config_path.with_file_name("pending-enrollments")
}
@@ -413,15 +250,10 @@ impl Config {
self.setups.iter().find(|setup| setup.id == id)
}
/// A setup by the label a person sees, for messages and for the one place a
/// name still arrives from outside. Nothing else should look one up this
/// way, since labels are editable and ids are not.
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.name == name)
}
/// This machine, offering whatever was found on it.
///
/// The providers are passed in rather than written here because they have to
/// be *discovered*: a hardcoded list is a claim about what is installed, and
/// this one was wrong -- every fresh install asserted a `claude-cli`
@@ -435,9 +267,6 @@ impl Config {
}
}
/// The one provider that needs no discovery, and the floor to fall back to
/// when discovery itself fails. Echo runs in-process, so it exists exactly
/// where this server does and nowhere else.
pub fn echo_provider() -> ProviderConfig {
ProviderConfig {
name: ECHO_PROVIDER.to_string(),
@@ -451,8 +280,6 @@ impl Config {
match std::fs::read_to_string(path) {
Ok(text) => format::parse(&text)
.with_context(|| format!("{} is not valid config RON", path.display())),
// A first run has no config -- the normal starting state; a token is
// generated and saved on that first start.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
warn_about_a_config_left_behind(path);
Ok(Self::default())
@@ -461,8 +288,6 @@ impl Config {
}
}
/// Writes the config, owner-readable only.
///
/// The token hashes here are verifiers rather than secrets, but the file
/// also names every host this backend can reach and every session it is
/// running. The mode is set on the temporary file *before* the rename, so
@@ -472,15 +297,6 @@ impl Config {
}
}
/// Says so when the only config here is one this server no longer reads.
///
/// The format moved from JSON to RON and the switch is outright -- there is
/// no reader for the old file. Everywhere else that is invisible, but this
/// file holds the enrolled token hashes: starting empty leaves the phone
/// unable to talk to this server, and looks from the phone like the config
/// having been lost rather than renamed. The old file is named and left
/// alone rather than read or deleted, since it is the only record of what
/// was configured.
fn warn_about_a_config_left_behind(path: &Path) {
let old = path.with_extension("json");
if old.is_file() {
@@ -503,9 +319,6 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.ron");
// A missing file is the ordinary first-run state, not an error.
// Nothing is conjured to fill it: the seed setup is written by the
// manager, so the file always says what there is.
let first_run = Config::load(&path).expect("load");
assert!(first_run.tokens.is_empty());
assert!(first_run.setups.is_empty());
@@ -569,7 +382,6 @@ mod tests {
let loaded = Config::load(&path).expect("reload");
assert_eq!(loaded.tokens[0].name, "phone");
assert_eq!(loaded.sessions[0].setup, "vm");
// The label and the id are separate, and the session holds the id.
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm");
assert_eq!(loaded.sessions[0].provider, "claude-cli");
assert_eq!(
@@ -582,8 +394,6 @@ mod tests {
.port,
Some(2222),
);
// The same provider name on two machines is the point, not a
// collision: names are unique within a setup and only within one.
assert!(
loaded
.setup(LOCAL_SETUP_ID)
@@ -593,14 +403,6 @@ mod tests {
);
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
// The house rule both halves of `format` depend on: what is written
// is the *body* of the struct, with no outer parentheses and
// nothing indented for them. Asserted rather than trusted because
// `render` strips what `parse` adds back -- if only one of the two
// ever changed, every file on disk would still load and only look
// wrong. The absent `Some(...)` is the other half of the same
// bargain: implicit_some is what lets a person write `port: 2222`,
// and only `skip_serializing_if` keeps this from writing it back.
let text = std::fs::read_to_string(&path).expect("read back");
assert!(!text.trim_start().starts_with('('), "outer parens: {text}");
assert!(
@@ -614,14 +416,6 @@ mod tests {
}
#[test]
/// The seed is this machine and nothing more: a name, no ssh, and
/// exactly the providers it was handed.
///
/// It used to assert a `claude-cli` provider here, which is what made
/// the bug look correct -- the test agreed with the code that every
/// machine has `claude`, because both were written from the same
/// assumption. What a machine has is discovered, so the only thing
/// this can check is that the seed does not invent anything.
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
let seed = Config::seed(vec![Config::echo_provider()]);
assert_eq!(seed.name, LOCAL_SETUP);
@@ -635,7 +429,6 @@ mod tests {
"the seed must not assert a provider nobody looked for",
);
// And it carries through whatever discovery did find.
let discovered = Config::seed(vec![
Config::echo_provider(),
ProviderConfig {
+9 -120
View File
@@ -1,22 +1,3 @@
//! Reading and changing files on the machine a setup names.
//!
//! Every operation here is one small POSIX shell script handed to `Transport`,
//! the way `setups::discover` and `import::list` already ask a machine a
//! question. That is what makes the local and the ssh case one implementation:
//! a second one written against `std::fs` would be the one that gets tested,
//! and the remote half -- the ordering of entries, what a symlink reports, how
//! a permission error reads -- would drift until it shipped broken. The cost is
//! an `sh` process per operation here, which is under a millisecond.
//!
//! The scripts assume GNU coreutils and findutils, which is what
//! `session::import` already assumes. A machine without them fails with that
//! tool's own message, which names what is missing.
//!
//! **The phone names a path, and that is deliberate** -- see PLAN.md's Security
//! section. What is *not* given up: no route here accepts a command. Listing,
//! reading and writing are the fixed scripts below, and the phone chooses only
//! the path and the bytes.
use anyhow::{Context, Result};
use serde::Serialize;
@@ -28,31 +9,15 @@ use crate::session::transport::{Input, Launch, Transport};
/// because "we did not read this" and "this is empty" must not look the same.
pub const FILE_LIMIT: u64 = 1024 * 1024;
/// The prelude every script here starts with: the path arrives as `$1`, and
/// this is where a leading `~` becomes that machine's own home.
///
/// The path is a **positional argument** and never text spliced into the script
/// -- the rule `import::find` follows, for the reason `ssh::quote` exists: a
/// path is attacker-adjacent input in a server whose job is running commands,
/// and interpolated it would be syntax rather than data.
///
/// `~` is the one character that costs something for it. A shell expands a
/// tilde in *text*, so a path handed over as an argument arrives with a literal
/// one; expanding it here gives it the same meaning `ssh::quote_path` gives it
/// everywhere else, and it is the *far* machine's `$HOME`. `~user` stays
/// literal and fails with the shell's own message.
///
/// Everything below uses `$p` for the path and `$2` for whatever else.
const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#;
/// What a directory turned out to be, and what is in it.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Listing {
/// `pwd -P` of the directory that was listed. Answered by the machine
/// rather than worked out here, so the phone navigates on a resolved
/// absolute path: the parent of one of these is a string operation, and a
/// `~` a session was spawned with is shown as what it turned out to be.
pub path: String,
pub entries: Vec<Entry>,
}
@@ -61,13 +26,9 @@ pub struct Listing {
#[serde(rename_all = "camelCase")]
pub struct Entry {
pub name: String,
/// What tapping it does, which for a symlink is decided by its *target* --
/// a link to a directory navigates.
pub kind: EntryKind,
pub size: u64,
pub modified: i64,
/// Whether the entry itself is a symlink, whatever [`Entry::kind`] says
/// its target is.
pub link: bool,
}
@@ -82,8 +43,6 @@ pub enum EntryKind {
Other,
}
/// What reading a file produced -- four answers, not content-or-error.
///
/// A binary file drawn as text and a big file cut off silently are both wrong
/// in ways the reader cannot see, and "couldn't read it" must not look like
/// "it is empty". A genuinely empty file is [`FileRead::Text`] with nothing in
@@ -94,19 +53,19 @@ pub enum FileRead {
Text {
size: u64,
modified: i64,
/// What [`write`] is given back to prove the file has not moved on.
sha256: String,
content: String,
},
/// Not UTF-8. Its size is reported; nothing is shown.
Binary { size: u64, modified: i64 },
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what they
/// are looking at rather than only that they cannot have it.
TooBig { size: u64, modified: i64 },
Binary {
size: u64,
modified: i64,
},
TooBig {
size: u64,
modified: i64,
},
}
/// What a file is after being written, so the editor's precondition is
/// fresh without a second read.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Written {
@@ -115,16 +74,8 @@ pub struct Written {
pub sha256: String,
}
/// The exit code the write script uses for "this is not the file you read",
/// which the route turns into a 409. Distinct from every other failure, which
/// is a message from the machine.
pub const STALE: i32 = 3;
/// A path the phone may name: absolute, or home-relative on that machine.
///
/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for the
/// same reason -- a relative path is relative to something nobody looking at
/// the screen can see, so it is refused rather than resolved against a guess.
pub fn check_path(path: &str) -> Result<String> {
let path = path.trim();
if path.is_empty() {
@@ -139,24 +90,12 @@ pub fn check_path(path: &str) -> Result<String> {
Ok(path.to_string())
}
/// Runs one of the scripts below with `path` as `$1` and `extra` as `$2`.
fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
let mut args = vec![
"-c".to_string(),
script,
// `$0`, which is what `sh` names itself in a message about the script;
// the path is `$1`.
"sh".to_string(),
path.to_string(),
];
let mut args = vec!["-c".to_string(), script, "sh".to_string(), path.to_string()];
args.extend(extra.map(str::to_string));
Launch::new("sh", args, None)
}
/// Everything in `path`, and what `path` resolved to. Entries are separated by
/// `\0` and their fields by `\t`, so a filename with a newline or a tab in it
/// survives -- both are legal, and a listing that lost one would quietly show
/// the wrong thing.
pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
let script = format!(
"{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \
@@ -176,20 +115,14 @@ pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
})
}
/// The `find` output above, as rows. A record without all five fields is
/// dropped rather than guessed at: it can only come from a `find` that printed
/// something else, and half a row is worse than no row.
fn parse_entries(text: &str) -> Vec<Entry> {
text.split('\0')
.filter(|record| !record.is_empty())
.filter_map(|record| {
// Five, so that a name containing a tab keeps it: `%f` is last
// exactly so the split can stop.
let mut fields = record.splitn(5, '\t');
let own = fields.next()?;
let target = fields.next()?;
let size = fields.next()?.parse().ok()?;
// `%T@` is seconds with a fractional part; the phone shows a date.
let modified = fields.next()?.split('.').next()?.parse().ok()?;
let name = fields.next()?;
Some(Entry {
@@ -208,10 +141,6 @@ fn parse_entries(text: &str) -> Vec<Entry> {
}
/// One file's content, or the reason there is none to show.
///
/// The size is checked on the far machine *before* anything reads the file, so
/// a file over [`FILE_LIMIT`] costs a `stat` rather than a transfer. `stat -L`
/// and `sha256sum` both follow symlinks, as `cat` does.
pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
// Two header lines, then the bytes: `<size> <mtime>`, then either `tooBig`
// or the digest. A header rather than a JSON envelope because the content
@@ -244,7 +173,6 @@ pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
})
}
/// The read script's two header lines and the bytes after them.
fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> {
let missing = || anyhow::anyhow!("the machine did not describe the file it read");
let first = out.iter().position(|b| *b == b'\n').ok_or_else(missing)?;
@@ -260,21 +188,6 @@ fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> {
))
}
/// Replaces `path`'s contents, but only while it still hashes to `expected`.
///
/// Agents edit files while people read them, so a stale copy landing on top of
/// somebody else's edit is the common case rather than the exotic one. The
/// digest the reader was shown is compared on the machine, and a file that has
/// moved on comes back as [`STALE`] rather than being overwritten.
///
/// A temp file and a rename, so a connection dropped mid-write leaves the old
/// file whole, and `chmod --reference` so the mode survives -- an executable
/// script written as a fresh file would stop being one. What that trades away:
/// the inode changes, so a hard link elsewhere stops being the same file.
///
/// The check and the write are **not** atomic against a writer landing between
/// them -- a window of microseconds on that machine. Accepted: the alternative
/// is a lock this has no way to make every other writer take.
pub async fn write(
transport: &Transport,
path: &str,
@@ -329,9 +242,6 @@ pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
Ok(())
}
/// Creates a directory. Plain `mkdir`, not `-p`, for the reason
/// [`create_file`] sets noclobber: a name that exists is something the person
/// typing it should be told about.
pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> {
let script = format!("{PATH_PRELUDE}mkdir -- \"$p\"");
transport
@@ -351,8 +261,6 @@ fn text(captured: crate::session::transport::Captured) -> Result<String> {
mod tests {
use super::*;
/// The names a listing has to survive. All four are legal, and each one
/// broke a listing somewhere before it was separated with `\0`.
#[test]
fn a_listing_survives_the_names_a_filesystem_allows() {
let record = |own: &str, target: &str, size: &str, time: &str, name: &str| {
@@ -381,8 +289,6 @@ mod tests {
assert_eq!(entries[0].modified, 1756900000);
assert_eq!(entries[2].kind, EntryKind::Directory);
assert_eq!(entries[2].size, 4096);
// The kind is the target's, so a link to a directory navigates -- and
// one whose target is gone is neither a file nor a directory.
assert!(entries[3].link);
assert_eq!(entries[3].kind, EntryKind::Directory);
assert_eq!(entries[4].kind, EntryKind::Other);
@@ -406,9 +312,6 @@ mod tests {
assert!(refused.contains("start it with / or ~"), "{refused}");
}
/// The scripts, against a real tree, through the transport that runs them
/// here -- cheap, because `sh` is wherever `cargo test` is. The remote
/// transport runs the identical text.
fn tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap();
@@ -428,8 +331,6 @@ mod tests {
let listing = list(&Transport::Here, &dir.path().to_string_lossy())
.await
.unwrap();
// `pwd -P`, so a temp directory reached through a symlinked /tmp
// answers with what it really is.
assert!(listing.path.starts_with('/'), "{}", listing.path);
let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
names.sort_unstable();
@@ -472,8 +373,6 @@ mod tests {
std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap();
assert!(matches!(read_at("big").await, FileRead::TooBig { .. }));
// Empty is text with nothing in it -- not a fourth state, and not the
// same as any of the three above.
std::fs::write(dir.path().join("empty"), "").unwrap();
assert!(matches!(
read_at("empty").await,
@@ -495,9 +394,6 @@ mod tests {
assert_eq!(written.size, 6);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "three\n");
// The same digest again, against a file that has moved on: the
// agent-edits-while-you-read case, which must refuse rather than
// overwrite.
std::fs::write(&path, "somebody else\n").unwrap();
assert!(
write(&Transport::Here, &path, &sha256, b"mine\n".to_vec())
@@ -543,7 +439,6 @@ mod tests {
.await
.is_err()
);
// And the file that was there is untouched.
std::fs::write(at(&dir, "new.txt"), "mine\n").unwrap();
assert!(
create_file(&Transport::Here, &at(&dir, "new.txt"))
@@ -566,9 +461,6 @@ mod tests {
);
}
/// A path that tries to close the quote and start a command of its own. It
/// is an argument rather than syntax, so it stays one absurd filename -- the
/// same property `ssh.rs` tests for the remote side.
#[tokio::test]
async fn a_path_full_of_shell_crosses_as_data() {
let dir = tree();
@@ -588,8 +480,6 @@ mod tests {
);
}
/// The tilde is the one character the prelude gives a meaning, and it is the
/// *machine's* home -- here, this one.
#[tokio::test]
async fn a_leading_tilde_means_the_machine_s_own_home() {
let Some(home) = std::env::home_dir() else {
@@ -611,7 +501,6 @@ mod tests {
.unwrap();
assert_eq!(text(captured).unwrap(), home.to_string_lossy());
// Leading, and its own segment only -- `ssh::quote_path`'s rule.
for literal in ["/tmp/~/x", "~user/x"] {
let captured = Transport::Here
.capture_with_input(&launch(script.clone(), literal, None), Input::None)
-90
View File
@@ -1,16 +1,3 @@
//! A phone interface to AI coding sessions -- the backend. See PLAN.md for the
//! whole picture; this is the entry point: config + session registry, token
//! bootstrap, and the one TLS listener.
//!
//! The listener binds the WireGuard interface's address only, and fails closed
//! -- if `wg0` is down the server refuses to start rather than falling back to
//! `0.0.0.0`, because this API *is* remote code execution and the tunnel is
//! what keeps its pre-auth surface off the open internet. `--bind` overrides
//! explicitly for development; a deliberate, logged choice, never a fallback.
//!
//! There is no plaintext listener at all, so the bearer token can't travel
//! unencrypted by misconfiguration -- even inside the tunnel.
mod auth;
mod config;
mod files;
@@ -42,73 +29,35 @@ use session::SessionManager;
const DEFAULT_PORT: u16 = 8443;
/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app.
#[derive(Parser)]
struct Args {
/// TLS port for the whole API surface.
#[arg(long, default_value_t = DEFAULT_PORT)]
port: u16,
/// Address to bind instead of the wg0 interface's -- a development override
/// (127.0.0.1 for curl, or a LAN address for a phone before the tunnel
/// exists). Production runs without it and fails closed when wg0 is absent.
#[arg(long)]
bind: Option<IpAddr>,
/// Where the token hashes, providers, hosts, and session list live.
/// Defaults to `$XDG_CONFIG_HOME/ai-app/config.ron`.
#[arg(long)]
config: Option<PathBuf>,
/// Directory for per-session data (transcripts, attachments, images).
/// Defaults to `$XDG_DATA_HOME/ai-app/sessions`.
#[arg(long)]
data_dir: Option<PathBuf>,
/// Directory for downloaded GGUF models. Defaults to
/// `$XDG_DATA_HOME/ai-app/models`.
#[arg(long)]
models_dir: Option<PathBuf>,
/// Directory holding the TLS certificates, generated here on first
/// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
#[arg(long)]
certs: Option<PathBuf>,
/// Invalidate every enrolled token, generate a fresh one, and print
/// its enrollment QR -- the whole lost-phone story.
#[arg(long)]
rotate_token: bool,
/// Enroll one more device without touching the running server: mint a
/// token, print its enrollment link (one line, stdout, nothing else) and
/// exit. The server adopts the token the first time that device uses it.
/// For a tool that opens the link on the phone, where a QR printed here
/// cannot be scanned.
#[arg(long)]
enroll_link: bool,
/// Hold every response back by this many milliseconds.
///
/// A development aid, and a specific one: over the tunnel a phone's requests
/// take tens to hundreds of milliseconds, and several faults live entirely
/// in what the app does *while* one is outstanding. On a loopback server
/// those windows close before anything can be observed and the bug looks
/// like it is not there.
#[arg(long, default_value_t = 0, value_name = "MS")]
delay: u64,
/// Mark every session spawned here as throwaway: its process is stopped
/// when this server exits, instead of being left running for the next start
/// to adopt. On by default in a debug build.
///
/// Sessions outlive the backend on purpose, which is right for the ones
/// somebody is using and wrong for the ones a test made -- twelve of those
/// accumulated on this machine in a day, each holding a conversation open.
///
/// The flag decides only what *new* sessions are marked as. What happens on
/// the way out is decided by the mark, which outlives the server that made
/// it.
#[arg(
long,
default_value_t = cfg!(debug_assertions),
@@ -131,13 +80,6 @@ fn certs_dir(certs: &Option<std::path::PathBuf>) -> std::path::PathBuf {
.unwrap_or_else(|| config_home("ai-app").join("certs"))
}
/// The CA every enrollment link carries (`wg_app_link::enroll::ca_param`),
/// so an app that was not built on this machine can still pin it -- the
/// iris client is cross-compiled in a VM and run against this server.
///
/// `--enroll-link` reads it before the server has been anywhere near
/// `certs::ensure`, so the file may genuinely not exist yet; the message
/// says what makes it exist rather than reporting a bare ENOENT.
fn read_ca(certs_dir: &std::path::Path) -> Result<String> {
let path = certs_dir.join("ca.pem");
std::fs::read_to_string(&path).with_context(|| {
@@ -151,8 +93,6 @@ fn read_ca(certs_dir: &std::path::Path) -> Result<String> {
#[tokio::main]
async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq brings
// ring, axum-server brings aws-lc-rs), so rustls refuses to pick one itself.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("no other TLS crypto provider is installed before main");
@@ -172,10 +112,6 @@ async fn main() -> Result<()> {
let config_path = args
.config
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
// Before the manager exists, on purpose: constructing it and seeding setups
// touches sessions and subprocesses this invocation has no business with
// while another instance is serving. Only the hash reaches disk, in the
// spool `auth.rs` reads; the link goes to stdout alone.
if args.enroll_link {
let bind_ip = match args.bind {
Some(ip) => ip,
@@ -230,9 +166,6 @@ async fn main() -> Result<()> {
for setup in manager.setups() {
match &setup.ssh {
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
// No parenthetical naming the local machine: the default setup is
// *called* "this machine", and the line read "setup this machine
// (this machine)".
None => tracing::info!(" setup \"{}\" runs here", setup.name),
}
for provider in &setup.providers {
@@ -248,9 +181,6 @@ async fn main() -> Result<()> {
);
}
// Before the interface check below, deliberately: the certificates are also
// what the phone app embeds at build time, so they need to be obtainable on
// a machine whose tunnel isn't up yet. The leaf is reissued on every start.
let certs_dir = certs_dir(&args.certs);
let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses())
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
@@ -273,8 +203,6 @@ async fn main() -> Result<()> {
None => netif::wg_address("ai-server")?,
};
// Token bootstrap: first run generates one; --rotate-token replaces whatever
// exists. Either way the plaintext appears exactly once, in the QR.
if args.rotate_token || manager.tokens().is_empty() {
let rotating = args.rotate_token && !manager.tokens().is_empty();
let token = enroll::generate_token();
@@ -308,15 +236,8 @@ async fn main() -> Result<()> {
// that sets it is typed; the monitor is what serves it.
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
// The one thing in here that acts without a request behind it: a session
// switched to auto-resume waits out its account's usage limit and picks
// itself back up. Started whether or not any session has it on, because
// the setting is per session and changes from the phone -- see
// `resume::run`.
tokio::spawn(resume::run(Arc::clone(&manager), Arc::clone(&monitor)));
// The bearer-token middleware wraps the entire router -- routes and fallback
// alike -- here and only here, so a new route can't forget auth.
let app = routes::router(Arc::clone(&manager))
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
.merge(routes::models_router(Arc::clone(&models)))
@@ -325,9 +246,6 @@ async fn main() -> Result<()> {
auth::require_token,
));
// Outside the auth layer, so an unauthenticated request is refused at the
// speed it always was: this is here to slow the app down, not to widen the
// window on anything guessing at tokens.
let app = match args.delay {
0 => app,
ms => {
@@ -344,11 +262,6 @@ async fn main() -> Result<()> {
let addr = SocketAddr::new(bind_ip, args.port);
tracing::info!("serving https://{addr}");
// Let go of the sessions on the way out rather than stopping them: their
// processes are meant to outlive this one. Each is recorded in its session
// directory and adopted again on the way back up. The exception is the
// sessions marked throwaway, which are stopped first. Both signals, because
// systemd and OpenRC send TERM while a terminal sends INT.
let serving = axum_server::bind_rustls(addr, tls_config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>());
let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?;
@@ -357,9 +270,6 @@ async fn main() -> Result<()> {
_ = terminate.recv() => tracing::info!("SIGTERM -- letting go of sessions"),
_ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"),
}
// Stopped before the rest are let go of, and on every way out of the select
// above: a throwaway session is one nobody meant to keep, and the whole point
// is that nothing has to remember to clean it up.
manager.stop_throwaway_sessions();
manager.detach_all();
-13
View File
@@ -1,15 +1,3 @@
//! The image types that travel between the phone, the session
//! directories, and a driver's dialect.
//!
//! Media type and file extension have to agree in four places -- storing
//! an upload, serving it back, handing it to a CLI as a content block, and
//! saving one a tool produced -- so the table lives here once. The
//! *default* for an unrecognized type is deliberately not here: it differs
//! by direction (a phone upload is a photo, a produced image is a
//! screenshot), so each caller states its own.
/// Media type to extension. Only the types Claude's API accepts as image
/// content blocks -- anything else has nowhere to go.
const IMAGE_TYPES: [(&str, &str); 4] = [
("image/png", "png"),
("image/jpeg", "jpg"),
@@ -56,7 +44,6 @@ mod tests {
fn unknown_types_are_the_callers_problem() {
assert_eq!(extension_for("application/pdf"), None);
assert_eq!(media_type_for("abc123.pdf"), None);
// No extension at all -- not "the whole name is the extension".
assert_eq!(media_type_for("abc123"), None);
}
}
-114
View File
@@ -1,23 +1,3 @@
//! GGUF models on this machine, and the downloads that produce them.
//!
//! The registry pattern again: one owner, one lock, so what is on disk and what
//! this server believes cannot come apart. Three things shape the design, all
//! consequences of a model file being gigabytes rather than kilobytes:
//!
//! **A download belongs to the model, not to whoever asked for it.** It is
//! keyed by the model it produces and lives here, so any device can watch it --
//! including one that did not start it. State in a per-connection channel would
//! not survive the phone locking its screen, which for an hour-long download is
//! the normal case.
//!
//! **Every run has an id, and its outcome outlives it.** Without those, "not
//! downloading" is three answers at once -- it finished, it never started, or a
//! different run finished while you were away.
//!
//! **Progress is measured, never estimated.** `total` is whatever
//! `Content-Length` said and nothing else; when the server does not send one it
//! stays `None` and the phone shows that it does not know.
use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
@@ -31,21 +11,13 @@ use wg_app_link::private;
use crate::session::transport::{Launch, Transport};
/// Identifies this client to HuggingFace. They ask for one, and a request
/// without it is more likely to be rate-limited.
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
/// Read size per loop iteration. Big enough that the syscall overhead is
/// nothing against a multi-gigabyte file, small enough that a cancel is noticed
/// promptly -- the flag is only checked between chunks.
const CHUNK: usize = 256 * 1024;
/// A model file sitting on this machine, ready to run.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalModel {
/// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are already
/// unique, so nothing has to invent an id.
pub key: String,
pub repo: String,
pub file: String,
@@ -69,13 +41,10 @@ pub enum DownloadState {
Cancelled,
}
/// One download run, as the phone sees it.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadStatus {
pub key: String,
/// Distinguishes this run from any earlier one for the same model, so a
/// device that was watching run 3 can tell it is now looking at run 4.
pub run: u64,
pub repo: String,
pub file: String,
@@ -92,7 +61,6 @@ pub struct DownloadStatus {
pub finished: Option<f64>,
}
/// The mutable half of a run, behind one lock.
#[derive(Debug)]
struct Progress {
state: DownloadState,
@@ -103,15 +71,12 @@ struct Progress {
finished: Option<f64>,
}
/// A run, shared between the thread doing the work and everyone watching.
struct Run {
id: u64,
key: String,
repo: String,
file: String,
progress: Mutex<Progress>,
/// Set by [`ModelStore::cancel`]; the download loop checks it between
/// chunks and stops, leaving the partial file for a later resume.
cancel: AtomicBool,
}
@@ -140,11 +105,8 @@ impl Run {
}
}
/// Every model this machine has, and every download in flight or finished.
pub struct ModelStore {
dir: PathBuf,
/// Keyed by model key: one run per model at a time, and the last run for a
/// model stays here after it ends so its outcome can still be read.
runs: Mutex<HashMap<String, Arc<Run>>>,
next_run: AtomicU64,
}
@@ -158,9 +120,6 @@ impl ModelStore {
}
}
/// Where a model's file lives, refusing anything that would escape the
/// models directory.
///
/// The repo and file come from a phone, so they are treated as hostile:
/// every component must be an ordinary name. Rejecting rather than
/// sanitising, since a silently rewritten path would download the right
@@ -180,9 +139,6 @@ impl ModelStore {
format!("{repo}/{file}")
}
/// Every `.gguf` found under the models directory, newest first. Read from
/// disk on each call rather than cached: a file deleted by hand should stop
/// being offered.
pub fn list(&self) -> Vec<LocalModel> {
let mut found = Vec::new();
collect(&self.dir, &self.dir, &mut found);
@@ -190,7 +146,6 @@ impl ModelStore {
found
}
/// The status of every run this server remembers.
pub fn downloads(&self) -> Vec<DownloadStatus> {
let runs = self.runs.lock().unwrap();
let mut all: Vec<_> = runs.values().map(|run| run.status()).collect();
@@ -235,9 +190,6 @@ impl ModelStore {
let status = run.status();
drop(runs);
// A dedicated thread rather than the blocking pool: this holds its
// thread for as long as the download takes, which is minutes to hours,
// and the pool exists for short work.
let store = Arc::clone(self);
std::thread::spawn(move || {
let outcome = store.fetch(&run, &target);
@@ -260,8 +212,6 @@ impl ModelStore {
Ok(status)
}
/// Asks a running download to stop. The partial file stays, so starting
/// again resumes rather than refetching.
pub fn cancel(&self, key: &str) -> Result<DownloadStatus> {
let runs = self.runs.lock().unwrap();
let Some(run) = runs.get(key) else {
@@ -271,7 +221,6 @@ impl ModelStore {
Ok(run.status())
}
/// Removes a downloaded model, and any partial file for it.
pub fn delete(&self, key: &str) -> Result<()> {
let (repo, file) = key.rsplit_once('/').context("a key is repo/file")?;
let target = self.path_for(repo, file)?;
@@ -295,8 +244,6 @@ impl ModelStore {
private::create_dir(parent)?;
}
// What we have, and what it was part of. A partial with no recorded
// identity is not resumable -- it could be a fragment of any revision.
let known = std::fs::read_to_string(&identity)
.ok()
.map(|s| s.trim().to_string());
@@ -313,12 +260,6 @@ impl ModelStore {
let (mut response, mut resumed) = request(&url, have)?;
let mut etag = etag_of(&response);
// HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a
// deliberately stale validator still answers 206 with the ranged bytes.
// So the header cannot be relied on to restart us, and the check is done
// here instead: if what arrived is not the revision our partial belongs
// to, resuming would splice two files into something of exactly the
// right length and the wrong contents.
if resumed && etag.is_some() && etag != known {
tracing::info!(
"{} changed upstream since the partial was written -- starting again",
@@ -330,10 +271,6 @@ impl ModelStore {
etag = etag_of(&response);
}
// On a 206, Content-Length is the length of the *range*, not of the file
// -- taken at face value it would fill the bar at 72 MB of a 234 MB
// model. The whole size is the last field of Content-Range, which has
// the further merit of not depending on where the range began.
let total: Option<u64> = if resumed {
response
.headers()
@@ -357,10 +294,6 @@ impl ModelStore {
p.total = total;
}
// `truncate(false)` is the whole resume story: the file is opened to be
// seeked into and appended to, and truncating would throw away exactly
// the bytes the Range request just asked the server not to send again.
// Stated rather than left to the default.
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
@@ -374,8 +307,6 @@ impl ModelStore {
file.set_len(0)
.context("truncate a partial we cannot resume onto")?;
}
// Written before the body, so an interrupted download leaves a partial
// that can still say which revision it belongs to.
if let Some(etag) = &etag {
std::fs::write(&identity, etag).ok();
}
@@ -420,8 +351,6 @@ impl ModelStore {
}
}
// Renamed only once complete, so a file at its real name is always a
// whole model -- `list` needs no other way to tell.
std::fs::rename(&partial, target)
.with_context(|| format!("finish {}", target.display()))?;
std::fs::remove_file(&identity).ok();
@@ -429,8 +358,6 @@ impl ModelStore {
}
}
/// The sha256 of a file, read in chunks -- these are gigabytes, and reading one
/// into memory to hash it would be the largest allocation this server makes.
fn sha256_of(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let mut file =
@@ -444,8 +371,6 @@ fn sha256_of(path: &Path) -> Result<String> {
}
hasher.update(&buffer[..read]);
}
// Hex by hand, as `wg_app_link::enroll::token_hash_hex` also has to, since
// this sha2 version's output type does not implement LowerHex.
Ok(hasher
.finalize()
.iter()
@@ -453,15 +378,12 @@ fn sha256_of(path: &Path) -> Result<String> {
.collect())
}
/// One GET, ranged when there is something to resume onto.
fn request(url: &str, from: u64) -> Result<(ureq::http::Response<ureq::Body>, bool)> {
let mut get = ureq::get(url).header("User-Agent", USER_AGENT);
if from > 0 {
get = get.header("Range", &format!("bytes={from}-"));
}
let response = get.call().with_context(|| format!("GET {url}"))?;
// Trust the status, not the request: a server that ignores Range answers
// 200 with the whole file, and appending to that would corrupt it.
let resumed = response.status() == 206;
Ok((response, resumed))
}
@@ -478,22 +400,18 @@ fn etag_of(response: &ureq::http::Response<ureq::Body>) -> Option<String> {
)
}
/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial beside it
/// is a piece of.
fn identity_of(target: &Path) -> PathBuf {
let mut name = target.as_os_str().to_os_string();
name.push(".part.etag");
PathBuf::from(name)
}
/// `x.gguf` -> `x.gguf.part`, the in-progress name.
fn partial_of(target: &Path) -> PathBuf {
let mut name = target.as_os_str().to_os_string();
name.push(".part");
PathBuf::from(name)
}
/// Walks `dir` collecting `.gguf` files, keyed by their path under `root`.
fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
@@ -523,17 +441,8 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
}
}
/// Where a machine reached over ssh keeps its models, when its setup does
/// not say.
///
/// The same place this backend puts its own downloads, written out rather
/// than derived: `$XDG_DATA_HOME` here describes *this* machine's
/// environment, and the far machine's is the far machine's business. A
/// setup whose models are elsewhere says so (`SshConfig::models_dir`).
const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models";
/// Which directory holds the models on the machine `transport` reaches.
///
/// One answer, because two things ask: the list a spawn screen offers,
/// and the path a session hands `llama-server`. A machine that listed one
/// directory and served from another would offer models that then failed
@@ -550,9 +459,6 @@ pub fn dir_on(transport: &Transport, local: &Path) -> String {
}
}
/// Every GGUF on the machine a setup names, which is the machine that
/// would have to serve it.
///
/// The local half of this is [`ModelStore::list`], reading the same shape
/// off this machine's disk; a caller picks by transport, since a setup
/// with no ssh *is* this machine and asking a shell about it would be a
@@ -584,7 +490,6 @@ pub async fn on_machine(transport: &Transport, dir: &str) -> Result<Vec<LocalMod
let mut found: Vec<LocalModel> = out
.split('\0')
.filter(|record| !record.is_empty())
// Two fields, and the name last, so a `\t` in a filename survives.
.filter_map(|record| record.split_once('\t'))
.filter_map(|(bytes, key)| {
let (repo, file) = key.rsplit_once('/')?;
@@ -600,33 +505,22 @@ pub async fn on_machine(transport: &Transport, dir: &str) -> Result<Vec<LocalMod
Ok(found)
}
/// A model repository on HuggingFace, as the browse screen shows it.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteRepo {
/// `owner/name`, which is what everything else here is keyed by.
pub id: String,
pub downloads: u64,
pub likes: u64,
}
/// One downloadable file inside a repository.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteFile {
pub path: String,
pub bytes: u64,
/// Already on this machine, so the phone can say so rather than offering to
/// fetch it again.
pub have: bool,
}
/// Searches HuggingFace for GGUF repositories matching `query`.
///
/// Proxied through this server rather than called from the phone, for two
/// reasons that both matter: the app trusts exactly one certificate -- this
/// server's -- and has no general internet trust to spend on huggingface.co,
/// and the machine that has to do the downloading is this one.
pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
let url = format!(
"https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1",
@@ -654,9 +548,6 @@ pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
.collect())
}
/// The sha256 HuggingFace publishes for one file, if it publishes one. It is
/// the LFS object id, which for these repositories is the sha256 of the content
/// -- so it is a free integrity check rather than a second source of truth.
fn published_sha256(repo: &str, file: &str) -> Option<String> {
let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true");
let body = get_json(&url).ok()?;
@@ -666,8 +557,6 @@ fn published_sha256(repo: &str, file: &str) -> Option<String> {
})
}
/// The GGUF files in one repository, largest last, with the ones already
/// downloaded marked.
pub fn files(repo: &str, store: &ModelStore) -> Result<Vec<RemoteFile>> {
let url = format!("https://huggingface.co/api/models/{repo}/tree/main");
let body = get_json(&url)?;
@@ -705,9 +594,6 @@ fn get_json(url: &str) -> Result<serde_json::Value> {
serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON"))
}
/// Percent-encodes a query string. Deliberately minimal -- this escapes what a
/// model search actually contains rather than implementing the whole rule set,
/// and anything unexpected becomes `%XX` rather than being passed through.
fn urlencode(value: &str) -> String {
value
.bytes()
-77
View File
@@ -1,43 +1,13 @@
//! Auto-resume: picking a session back up when its account's usage limit
//! lifts.
//!
//! Off unless a session was switched to it, because this spends quota the
//! moment quota exists and does it while nobody is watching. What it does is
//! narrow on purpose: it sends one message -- "continue" unless something else
//! was typed -- to a session that stopped because the account ran out, and
//! then it is done. There is no retry loop around the conversation itself.
//!
//! **The schedule is a plan to ask, never a plan to send.** A reset time is
//! the one thing here that cannot be trusted: the dialect's is a hint written
//! when the turn failed, the endpoint's moves when the window moves, and both
//! are wrong across the case this exists for -- a limit that lifts later than
//! it said. So the wait ends in a *question* to [`crate::usage`], and only an
//! answer that says the limits no longer apply sends anything. Every other
//! answer, including one that cannot be got at all, becomes a new wait.
//!
//! This is the top layer: it holds the session manager and the usage monitor
//! and neither holds it. That is what lets the decision below be a pure
//! function of a snapshot and a clock, which is the whole of what is worth
//! testing here.
use std::sync::Arc;
use std::time::Duration;
use crate::session::{LimitHit, OwedResume, SessionManager, now};
use crate::usage::{UsageMonitor, UsageSnapshot, UsageState};
/// How often to look at the schedule. Coarse deliberately: a wait measured in
/// hours does not deserve a fine-grained clock, and the meter behind it is
/// cached for three minutes anyway.
const TICK: Duration = Duration::from_secs(60);
/// How close to a scheduled check is close enough to ask the meter. Anything
/// further out is left alone, so a session waiting five hours costs nothing
/// until the last few minutes of it.
const NEARLY: f64 = 300.0;
/// How long to wait after an answer that decided nothing -- the machine could
/// not be asked, or it says the limit is still on with no reset time.
const BACKOFF: f64 = 300.0;
/// The least time to wait before asking again, whatever a reset time says. A
@@ -45,8 +15,6 @@ const BACKOFF: f64 = 300.0;
/// every tick.
const AT_LEAST: f64 = 60.0;
/// How long after the limit was hit to stop waiting.
///
/// Something has to bound it, or a machine that can never be asked -- an
/// unplugged laptop, a setup somebody edited away -- is retried for ever with
/// nothing on screen saying so. A day is past the longest window Claude
@@ -58,19 +26,14 @@ const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
/// figure arriving slightly over is a full window, not a corrupt one.
const SPENT: f64 = 100.0;
/// What to do about one owed resume, having asked the meter.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Step {
/// The limits no longer apply: send the message.
Send,
/// Ask again at this epoch second.
WaitUntil(f64),
/// This has been waiting longer than anything real would take.
GiveUp,
}
/// Runs the schedule until the server stops.
///
/// Two things wake it: the tick, and a session reporting that it has just run
/// out. The second is not an optimisation -- a limit hit is what *creates* a
/// schedule, and a tick that happened a moment before it would otherwise leave
@@ -98,7 +61,6 @@ pub async fn run(manager: Arc<SessionManager>, monitor: Arc<UsageMonitor>) {
}
}
/// Records a limit against the session that hit it, if it is one that resumes.
pub(crate) fn note(manager: &SessionManager, session_id: &str, resets_at: Option<f64>) {
match manager.note_limit(session_id, resets_at) {
Ok(true) => tracing::info!("session {session_id} hit its usage limit; auto-resume is on"),
@@ -107,17 +69,12 @@ pub(crate) fn note(manager: &SessionManager, session_id: &str, resets_at: Option
}
}
/// One pass over everything owed a message.
async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
let at = now();
for owed in manager.owed_resumes() {
if owed.scheduled.at - at > NEARLY {
continue;
}
// Asked per session rather than once for the whole sweep: the answer
// is cached per machine and per meter, so several sessions on one
// account share one fetch, and a machine nobody is waiting on is not
// dialled at all.
let snapshot = snapshot_for(Arc::clone(monitor), manager, &owed).await;
match decide(snapshot.as_ref(), &owed, now()) {
Step::Send => match manager.resume_now(&owed.session_id) {
@@ -139,9 +96,6 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
}
}
Step::GiveUp => {
// About the machine rather than in the state's own words: the
// detail is in the log, and what lands in the transcript has
// to read on a phone.
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
_ => format!("{} could not be asked for a day", owed.setup),
@@ -154,11 +108,6 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
}
}
/// The numbers for the machine and the meter this session is billed against,
/// and `None` when nothing reports on it.
///
/// Blocking work, so it goes to a blocking thread: the fetch behind it reads a
/// credential file over ssh and then makes an HTTP call.
async fn snapshot_for(
monitor: Arc<UsageMonitor>,
manager: &SessionManager,
@@ -183,21 +132,8 @@ async fn snapshot_for(
.unwrap_or_default()
}
/// What one owed resume should do, given what the meter said and the time.
///
/// A pure function of the two, which is what makes the rule inspectable: every
/// answer that is not "the limits no longer apply" is a longer wait, and the
/// only thing that ends the waiting other than success is the clock.
///
/// The reset time comes from the *snapshot* rather than from the schedule, so
/// a window that turns out to reset later than the dialect said pushes the
/// check back, and one that resets sooner pulls it forward. That is the case
/// the whole design is about: the first answer was a guess, this one is a
/// measurement.
pub fn decide(snapshot: Option<&UsageSnapshot>, owed: &OwedResume, at: f64) -> Step {
let step = match snapshot {
// The meter answered with numbers, which is the only answer that can
// send anything.
Some(snapshot) if snapshot.state == UsageState::Ok => {
let spent: Vec<&crate::usage::UsageWindow> = snapshot
.windows
@@ -207,17 +143,12 @@ pub fn decide(snapshot: Option<&UsageSnapshot>, owed: &OwedResume, at: f64) -> S
if spent.is_empty() {
Step::Send
} else {
// The earliest of the spent windows: it is the first moment
// the situation can have changed, and if the others are still
// full this comes straight back here.
match spent
.iter()
.filter_map(|window| epoch_of(window.resets_at.as_deref()))
.min_by(f64::total_cmp)
{
Some(resets) => Step::WaitUntil(resets),
// Spent with no reset time anybody could read. Not a
// reason to send: what is known is that the limit is on.
None => Step::WaitUntil(at + BACKOFF),
}
}
@@ -230,8 +161,6 @@ pub fn decide(snapshot: Option<&UsageSnapshot>, owed: &OwedResume, at: f64) -> S
_ => Step::WaitUntil(at + BACKOFF),
};
match step {
// Waiting past the point where a real window would have reset means
// whatever is wrong is not going to fix itself.
Step::WaitUntil(_) if at - owed.scheduled.since > GIVE_UP => Step::GiveUp,
Step::WaitUntil(next) => Step::WaitUntil(next.max(at + AT_LEAST)),
other => other,
@@ -293,8 +222,6 @@ mod tests {
#[test]
fn a_window_still_spent_moves_the_check_to_its_own_reset_time() {
// The case the feature exists for: the wait was scheduled for one
// time, the limit is still on, and the endpoint now names another.
let at = 1_788_546_972.0;
let later = "2026-09-05T12:00:00+00:00";
let spent = snapshot(UsageState::Ok, vec![window(100.0, Some(later))]);
@@ -350,8 +277,6 @@ mod tests {
"{state:?}"
);
}
// And no snapshot at all -- a machine or provider edited away under a
// session that was waiting on it.
assert_eq!(
decide(None, &owed(at - 60.0), at),
Step::WaitUntil(at + BACKOFF)
@@ -371,8 +296,6 @@ mod tests {
decide(Some(&broken), &owed(at - GIVE_UP - 1.0), at),
Step::GiveUp
);
// A meter that answers is still allowed to send on the same tick: the
// ceiling bounds waiting, not resuming.
let clear = snapshot(UsageState::Ok, vec![window(3.0, None)]);
assert_eq!(
decide(Some(&clear), &owed(at - GIVE_UP - 1.0), at),
-405
View File
@@ -1,91 +1,3 @@
//! The HTTP surface -- REST for actions, one SSE stream per open session
//! screen for events, all behind the bearer-token middleware `main.rs`
//! wraps the whole router in.
//!
//! ```text
//! GET /setups machines, each with what it can run
//! POST /setups add {name, ssh?} -- providers are discovered
//! POST /setups/probe dry run {ssh?}: what would be found there
//! GET /setups/{id} one machine, for refetching after a change
//! GET /setups/{id}/models GGUFs on that machine, for a llama session
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
//! GET /setups/{id}/file?path=P content of file P, or why not
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
//! (409 when the file no longer matches ifSha256)
//! POST /setups/{id}/file {path} create empty; refused if it exists
//! POST /setups/{id}/dir {path} create; refused if it exists
//! GET /setups/{id}/importable Claude Code sessions on it that could be continued
//! POST /setups/{id}/importable/import {sessions} -> 202; runs on the server
//! POST /setups/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts
//! GET /setups/{id}/importable/events SSE: what is in flight against them
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
//! DELETE /setups/{id} remove, refused while sessions use it
//! GET /sessions list (id, provider, title, model, status, last activity)
//! GET /sessions/{id} one session, for refetching after a change
//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?}
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
//! (a backlog past CATCH_UP_LIMIT arrives as a
//! `reset` frame plus the newest window)
//! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent),
//! ?limit=N, ?coalesce=true to count rows not deltas,
//! ?after=N to floor it at what the caller already holds
//! GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest
//! first -- see docs/SUBAGENTS.md
//! GET /sessions/{id}/subagents/{sub}/transcript exactly the transcript route above,
//! against that subagent's own transcript
//! GET /sessions/{id}/subagents/{sub}/events?after=N exactly the events route above,
//! against that subagent's own stream
//! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited)
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
//! (409 when the session already has it)
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
//! POST /sessions/{id}/interrupt stop the running turn; the process stays
//! POST /sessions/{id}/stop end the process; the session and transcript stay
//! POST /sessions/{id}/start run the process again, continuing the conversation
//! POST /sessions/{id}/title {title}
//! POST /sessions/{id}/cwd {cwd} -- move it; stops the process,
//! 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
//! POST /sessions/{id}/attachments multipart upload, image or any file -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced, and what it was sent
//! DELETE /sessions/{id} kill process, delete transcript + files
//! (?deleteForeign=true removes the machine's own copy too)
//! POST /sessions/{id}/notify {notify} -- announce this one or not
//! POST /sessions/{id}/auto-resume {autoResume, message?} -- carry on by itself
//! once the account's usage limit lifts
//! GET /notifications SSE: every session's attention-wanting
//! moments, live only (see `notifications`)
//! GET /defaults {effort} -- what a new session starts at
//! POST /defaults {effort} -- null for the CLI's own default
//! GET /usage cached usage windows per provider
//! GET /models downloaded GGUFs, and what is being fetched
//! GET /models/search?q=Q HuggingFace repositories matching Q
//! GET /models/files?repo=R the GGUFs in one repository
//! POST /models/download {repo, file}; rejoins the run already going
//! POST /models/cancel {key}; the partial stays, so starting again resumes
//! POST /models/delete {key}
//! ```
//!
//! Everything here works purely in the common event model; nothing may
//! branch on the session kind (that's what drivers are for).
//!
//! **Every request body in this module refuses fields it does not know**
//! (`serde(deny_unknown_fields)`), and a new one is expected to do the
//! same. Silently ignoring a field is the worst available answer: a caller
//! that misspells `permissionMode` got a 200 and a session running in the
//! default permission mode, which is indistinguishable from success at the
//! place they are looking. It cost an hour here, chasing a "startup race"
//! that was a snake_case key serde had dropped on the floor. Query strings
//! are deliberately left permissive -- a stale link carrying an extra
//! parameter is not a mistake worth failing a request over.
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -123,7 +35,6 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
"/setups/{id}",
get(read_setup).put(update_setup).delete(delete_setup),
)
// The models on the machine a setup names, for a llama session there.
.route("/setups/{id}/models", get(setup_models))
// The filesystem of the machine a setup names. Under the setup
// rather than under a session because a filesystem is a property of
@@ -187,8 +98,6 @@ enum ApiError {
UnknownRoute,
#[error("{0}")]
BadRequest(String),
/// The request was understood and the state it names has moved on --
/// distinct from `BadRequest`, which is a caller that got it wrong.
#[error("{0}")]
Conflict(String),
#[error(transparent)]
@@ -202,8 +111,6 @@ impl IntoResponse for ApiError {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::Internal(err) => {
// The only variant whose real cause isn't safe to hand back
// verbatim, and the only one worth a log line.
tracing::error!("{err:#}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
@@ -212,9 +119,6 @@ impl IntoResponse for ApiError {
}
}
/// An `anyhow` error from a session mutation is a message written *for* the
/// phone ("no session abc123"), so it comes back as a 400 with that message
/// rather than a 500 and a log line.
fn bad_request(err: anyhow::Error) -> ApiError {
ApiError::BadRequest(format!("{err:#}"))
}
@@ -241,13 +145,6 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
axum::Json(manager.sessions())
}
/// One session's row, for a screen that has to show what is true now.
///
/// The list is a snapshot taken when somebody last looked at it, and a screen
/// opened from a row carries that snapshot with it. Fine for what a row
/// *says* and wrong for what a control is *set to*: a switch drawn from a
/// stale row shows the position it had when the list was fetched, and the
/// person reading it cannot tell. Same reason `GET /setups/{id}` exists.
async fn read_session(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -260,10 +157,6 @@ async fn read_session(
.ok_or_else(|| ApiError::NotFound(format!("no session {id}")))
}
/// What the spawn screen needs to render itself, so the phone holds no
/// hardcoded list: a setup added to `config.ron` shows up with no app
/// rebuild.
///
/// One list rather than two, because the halves are not independent. A
/// provider only exists on a machine that has it installed, so listing them
/// separately offered the whole cross-product -- including "the Claude CLI on
@@ -271,11 +164,8 @@ async fn read_session(
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SetupInfo {
/// Stable; what a session stores and what these routes address.
id: String,
/// The editable label.
name: String,
/// Where it runs, for telling two setups apart. Absent for this machine.
#[serde(skip_serializing_if = "Option::is_none")]
address: Option<String>,
providers: Vec<ProviderInfo>,
@@ -310,8 +200,6 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
}
}
/// How to reach a machine, as the phone describes it.
///
/// Note what is absent: nothing here names a program. Providers are found by
/// asking the machine (`crate::setups`), never sent, so the enrolled token
/// cannot introduce something to run.
@@ -328,17 +216,13 @@ struct SshRequest {
identity_file: Option<String>,
#[serde(default)]
options: Vec<String>,
/// Where attached files land on that machine; see `SshConfig`.
#[serde(default)]
attachments_dir: Option<String>,
/// Where that machine keeps its GGUF models; see `SshConfig`.
#[serde(default)]
models_dir: Option<String>,
}
impl SshRequest {
/// Tidied at the boundary rather than stored as typed -- this came from a
/// phone keyboard, so it may have a stray space or a `~`.
fn into_config(self) -> Result<crate::config::SshConfig, ApiError> {
let address = crate::setups::tidy(&self.address)
.ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?;
@@ -355,16 +239,12 @@ impl SshRequest {
.iter()
.filter_map(|o| crate::setups::tidy(o))
.collect(),
// Not `tidy`: that expands `~` to *this* machine's home, and this
// path is on the other one. The remote shell expands it there.
attachments_dir: self
.attachments_dir
.as_deref()
.map(str::trim)
.filter(|dir| !dir.is_empty())
.map(std::path::PathBuf::from),
// The same rule, and for the same reason: this directory is
// on the other machine, so a `~` in it is that machine's home.
models_dir: self
.models_dir
.as_deref()
@@ -380,15 +260,10 @@ impl SshRequest {
#[serde(deny_unknown_fields)]
struct AddSetupRequest {
name: String,
/// Absent means this machine.
#[serde(default)]
ssh: Option<SshRequest>,
}
/// What a machine turned out to have, without saving anything. The point of
/// trying before committing: a wrong address or an unauthorised key is caught
/// while the person is still looking at the form that caused it, rather than
/// at the first spawn.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -414,9 +289,6 @@ async fn probe_setup(
))
}
/// Asks the machine an `ssh` block describes -- or this one -- what it has.
/// `label` only ever appears in a failure message, so a probe of an unsaved
/// form can still say which machine would not answer.
async fn probe(
ssh: Option<crate::config::SshConfig>,
label: &str,
@@ -438,9 +310,6 @@ async fn add_setup(
axum::Json(body): axum::Json<AddSetupRequest>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
// Ask the machine being added what it has, before writing anything, so a
// bad address fails here rather than leaving a setup that can never
// spawn.
let providers = probe(ssh.clone(), &body.name).await?;
let setup = manager
.add_setup(&body.name, ssh, providers)
@@ -448,8 +317,6 @@ async fn add_setup(
Ok(axum::Json(info_for(setup)))
}
/// One setup by id, or the 404 that says so. Three handlers ask this same
/// question; the answer, and the wording of the refusal, belong in one place.
fn setup_by_id(
manager: &Arc<SessionManager>,
id: &str,
@@ -474,8 +341,6 @@ async fn read_setup(
struct UpdateSetupRequest {
#[serde(default)]
name: Option<String>,
/// Ask the machine again what it has -- after installing something
/// there, or when a binary moved.
#[serde(default)]
rediscover: bool,
}
@@ -514,10 +379,6 @@ async fn delete_setup(
Ok(StatusCode::NO_CONTENT)
}
/// The five explorer routes below all begin the same way: find the machine,
/// and check that what the phone named is a path this will act on. The check
/// is `files::check_path`, shared with [`set_cwd`] -- one rule about what an
/// acceptable path is, and one wording for refusing it.
fn files_on(
manager: &Arc<SessionManager>,
id: &str,
@@ -531,29 +392,15 @@ fn files_on(
))
}
/// A failure from one of the scripts is the *machine's* message, written to
/// be read where it happened, which is the phone. So it comes back as a 400
/// with those words rather than a 500 and a log line only the backend sees.
fn from_machine(err: anyhow::Error) -> ApiError {
ApiError::BadRequest(format!("{err:#}"))
}
/// Where a path is named for these routes. Query rather than a path segment:
/// a path contains slashes, and a segment that had to be escaped and
/// unescaped would be a second encoding to keep in step with the phone's.
#[derive(Deserialize)]
struct PathQuery {
path: String,
}
/// The models **that machine** has, which is the list a llama.cpp session
/// on it can choose from.
///
/// Not `GET /models`, which is this backend's own downloads: those are on
/// the machine a session runs on only when they are the same machine. A
/// spawn screen offering this backend's list for a remote setup would be
/// naming files that are not there, and the session would fail at the
/// point of loading rather than at the point of choosing.
async fn setup_models(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -567,7 +414,6 @@ async fn setup_models(
.map_err(from_machine)
}
/// What is in a directory, and what that directory resolved to.
async fn list_dir(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -580,9 +426,6 @@ async fn list_dir(
.map_err(from_machine)
}
/// One file's content, or which of the three reasons there is none. The path
/// it was asked for rides along, so a phone that has moved on since can tell
/// which answer this is.
#[derive(Serialize)]
struct FileResponse {
path: String,
@@ -608,10 +451,6 @@ async fn read_file(
struct WriteFileRequest {
path: String,
content: String,
/// The digest the read reported. Not optional: an editor that could omit
/// it would be one overwrite away from losing an agent's edit, and "I did
/// not check" is not something a caller should be able to say by leaving a
/// field out.
if_sha256: String,
}
@@ -670,7 +509,6 @@ async fn create_dir(
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct SpawnRequest {
/// Which machine, and which of the things it offers.
setup: String,
provider: String,
#[serde(default)]
@@ -683,21 +521,12 @@ struct SpawnRequest {
permission_mode: Option<String>,
#[serde(default)]
effort: Option<String>,
/// Whatever the chosen driver understands -- llama.cpp's context size and
/// sampling. Opaque here on purpose: see `SessionConfig::params`.
#[serde(default)]
params: std::collections::BTreeMap<String, String>,
/// Continue a Claude Code session the machine already has, named by the id
/// `GET /setups/{id}/importable` reported.
///
/// An id and not a path, deliberately: the server looks the path up again
/// among the sessions it enumerated, so an enrolled token cannot turn this
/// field into "read me an arbitrary file".
#[serde(default)]
import: Option<String>,
}
/// What a machine already has that could be continued.
async fn list_importable(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -710,12 +539,6 @@ async fn list_importable(
// Anything this app is already continuing is not offered again. Left out
// rather than shown-and-disabled, because it has not disappeared: it is in
// the session list, which is where it now belongs.
//
// Except while this server is in the middle of importing it. A spawn
// creates the session partway through, so the row would vanish the instant
// the work started and reappear as a session only once it finished -- and
// in between, the screen that asked for it would show nothing at all where
// the thing it is waiting for used to be.
found.retain(|candidate| {
manager.pending().running(&id, &candidate.id).is_some()
|| manager.session_driving(&candidate.id).is_none()
@@ -741,16 +564,11 @@ async fn list_importable(
Ok(axum::Json(rows))
}
/// A row of the import list: what the machine has, plus what this server is
/// doing to it. Flattened, so the two halves arrive as one object -- the phone
/// is drawing one row and has no use for the seam.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportableRow {
#[serde(flatten)]
importable: crate::session::import::Importable,
/// The word the row shows while something is running: "importing" or
/// "deleting". Absent when nothing is.
#[serde(skip_serializing_if = "Option::is_none")]
pending: Option<&'static str>,
/// How the last attempt on this row failed, if it did. Kept until
@@ -760,12 +578,6 @@ struct ImportableRow {
error: Option<String>,
}
/// Removes Claude Code sessions from a machine.
///
/// The transcript *is* the session, so this ends any chance of resuming those
/// conversations. The phone confirms before calling this; the server does not
/// second-guess a decision somebody was shown the cost of.
///
/// A batch and never a single session, which is why this is a POST with a body
/// rather than a `DELETE` on each id. One request per row made a handover only
/// as atomic as the network: leave the screen, lose signal, or have the fourth
@@ -783,9 +595,6 @@ async fn delete_importable(
) -> Result<StatusCode, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
// Registered before anything is spawned, so the 202 is only sent once
// every row is already showing "deleting" -- a phone that refetches the
// instant it gets the reply cannot catch a row that has not started.
let running: Vec<(String, crate::session::pending::InFlight)> = body
.sessions
.iter()
@@ -798,8 +607,6 @@ async fn delete_importable(
.collect();
let sessions = body.sessions;
tokio::spawn(async move {
// One failure here is the machine being unreachable, which is true of
// every row rather than of any one of them.
let outcomes = match crate::session::import::delete(&transport, &sessions).await {
Ok(outcomes) => outcomes,
Err(err) => {
@@ -824,9 +631,6 @@ async fn delete_importable(
tracing::warn!("deleting {session} on {id} failed: {message}");
flight.failed(message.clone());
}
// `delete` promises an entry per id, so this is a bug rather
// than a state -- but a row stuck on "deleting" for ever is a
// worse answer than one that says so.
None => flight.failed(format!("nothing was reported about {session}")),
}
}
@@ -834,7 +638,6 @@ async fn delete_importable(
Ok(StatusCode::ACCEPTED)
}
/// Which sessions to delete. See [`delete_importable`] for why it is a list.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -842,16 +645,12 @@ struct DeleteBatch {
sessions: Vec<String>,
}
/// Continues Claude Code sessions, in the background.
///
/// Separate from `POST /sessions` because the two are asked different
/// questions. That one means "start this and take me to it", so it waits and
/// answers with the session. This one is the import screen's batch: several at
/// once, nobody waiting on any particular one, and the answer arrives as a row
/// changing rather than as a reply -- the screen it was started from may well
/// be gone by then.
///
/// A list for the same reason [`delete_importable`] takes one.
async fn start_import(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -864,8 +663,6 @@ async fn start_import(
let request = SpawnRequest {
setup: id.clone(),
provider: body.provider.clone(),
// Nothing to say: `spawn` titles an import from the session it
// continues, and the cwd comes from the same place.
title: None,
model: body.model.clone(),
cwd: None,
@@ -895,8 +692,6 @@ async fn start_import(
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ImportRequest {
/// The sessions to continue, all with the settings below -- they were
/// picked together on one screen, so there is nothing to say per row.
sessions: Vec<String>,
provider: String,
#[serde(default)]
@@ -929,26 +724,18 @@ fn in_background<F>(
}
Err(err) => {
tracing::warn!("{} {session} on {setup} failed: {err:#}", operation.label());
// The server's own words, the way every other failure in this
// app reaches a person.
running.failed(format!("{err:#}"));
}
}
});
}
/// Every change to what is in flight against one machine. Scoped to the setup
/// the screen is showing, the same way a session's events are scoped to that
/// session.
async fn importable_events(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
let live = manager.pending().subscribe();
let stream = BroadcastStream::new(live).filter_map(move |item| {
// A lagged subscriber has missed changes it cannot get back here, and
// that is what the listing is for: the screen refetches on arrival and
// carries the truth whatever this stream missed.
let change = item.ok()?;
if change.setup() != id {
return None;
@@ -965,9 +752,6 @@ async fn spawn_session(
spawn(&manager, body).await.map(axum::Json)
}
/// Starts a session, continuing a Claude Code one where `body.import` names
/// it.
///
/// A function rather than only a handler because the import screen's batch runs
/// this from a background task. Spawning has to mean exactly the same thing
/// either way: the same refusal when something else already has the
@@ -995,13 +779,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
only this app's view of it."
)));
}
// Refused rather than warned about, because there is nothing
// useful on the other side of it. Importing an open session puts a
// second `--resume` on a file the first is still writing: the
// conversation gets duplicated into it, each copy replays the
// other's writes as work done elsewhere, and the adopted one is
// billed for re-reading the whole thing. On 2026-08-29 that was
// 65 MB and 154 screenshots.
if chosen.in_use == crate::session::import::InUse::Yes {
return Err(ApiError::BadRequest(format!(
"{want} is open in a terminal right now. Importing it would put a second \
@@ -1012,10 +789,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
let records = crate::session::import::read_tail(&transport, &chosen.path)
.await
.map_err(bad_request)?;
// The recorded directory can outlive itself, and resuming into one
// that is gone fails at `cd` before the CLI starts. Starting
// somewhere real keeps the conversation, and the log says which one
// was dropped.
let mut chosen = chosen;
if !crate::session::import::directory_exists(&transport, &chosen.cwd).await {
tracing::warn!(
@@ -1034,11 +807,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
let spec = SpawnSpec {
setup: body.setup,
provider: body.provider,
// An imported session is recognised by what it was about, so its
// opening message is the title unless one was typed. Blank normalised
// to absent rather than trusted as a choice: a client with nothing to
// say sends `""`, which is `Some` and so satisfied `or_else`, and every
// import arrived called "claude-cli session".
title: body
.title
.filter(|title| !title.trim().is_empty())
@@ -1048,7 +816,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
.filter(|title| !title.trim().is_empty())
}),
model: body.model,
// Resumed where it was working, so the CLI picks up the same tree.
cwd: body.cwd.or_else(|| {
seed.as_ref()
.map(|(chosen, _)| PathBuf::from(&chosen.cwd))
@@ -1070,9 +837,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
spec,
crate::session::Seed {
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,
@@ -1118,14 +882,9 @@ async fn delete_session(
.delete_foreign
.then(|| manager.foreign_transcript(&id))
.flatten();
// And *deleted* before it too, so a machine that cannot be reached leaves
// everything as it was rather than a deleted session and a transcript the
// phone has already promised is gone.
if let Some((setup, session)) = &foreign {
let setup = setup_by_id(&manager, setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
// A batch of one: the same call, so there is one description of what
// deleting a foreign transcript means.
crate::session::import::delete(&transport, std::slice::from_ref(session))
.await
.map_err(bad_request)?
@@ -1144,8 +903,6 @@ async fn delete_session(
#[serde(deny_unknown_fields)]
struct MessageRequest {
text: String,
/// Ids from `POST /attachments`, uploaded before the message that
/// references them.
#[serde(default)]
attachment_ids: Vec<String>,
}
@@ -1155,8 +912,6 @@ async fn message(
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<MessageRequest>,
) -> Result<StatusCode, ApiError> {
// For the 404 a session that is not here has always answered with; the send
// goes through the manager, which may have to start a process first.
lookup(&manager, &id)?;
if body.text.trim().is_empty() && body.attachment_ids.is_empty() {
return Err(ApiError::BadRequest("message is empty".to_string()));
@@ -1171,18 +926,9 @@ async fn message(
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct UnqueueRequest {
/// The id the `messageQueued` event carried, which is what the bubble on
/// screen is drawn from.
message_id: String,
}
/// Takes back a message the session has not read yet.
///
/// The two failures are separate answers rather than one refusal, because
/// they are different things to whoever tapped: `409` means the session has
/// already been told, and `404` means nothing is waiting under that id -- a
/// bubble something else has already resolved. The Claude driver can only
/// ever give the first, since it writes a steer into the CLI on arrival.
async fn unqueue(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1204,9 +950,6 @@ async fn unqueue(
#[serde(deny_unknown_fields)]
struct AnswerRequest {
question_id: String,
/// Everything chosen, in the order it was offered. A question that takes
/// one answer sends a list of one, so there is one shape here rather than
/// a single-answer route and a multi-answer route beside it.
answers: Vec<String>,
}
@@ -1232,11 +975,6 @@ async fn interrupt(
Ok(StatusCode::NO_CONTENT)
}
/// Ends the session's process. The session stays, and `start` brings it back.
///
/// Not `lookup`ed: a session that failed to relaunch has no live entry and may
/// still have a process running, which is exactly one worth being able to
/// stop.
async fn stop(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1245,9 +983,6 @@ async fn stop(
Ok(StatusCode::NO_CONTENT)
}
/// Starts a process for a session that has none, continuing the same
/// conversation. [`SessionManager::start_session`] refuses unless the session
/// is known to have exited.
async fn start(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1256,10 +991,6 @@ async fn start(
Ok(StatusCode::NO_CONTENT)
}
/// The usage screen needs two things that live in different places: the
/// cache, and the current list of machines to ask. Carried together rather
/// than the monitor holding the manager, which would point the dependency
/// upward -- `usage` sits below the session layer.
#[derive(Clone)]
pub struct UsageState {
monitor: Arc<crate::usage::UsageMonitor>,
@@ -1280,11 +1011,7 @@ pub fn usage_router(
async fn usage(
State(state): State<UsageState>,
) -> Result<axum::Json<Vec<crate::usage::UsageSnapshot>>, ApiError> {
// Read here rather than inside the fetch, so the list of machines is the
// one that existed when the request arrived and cannot change under a
// fetch that takes an ssh round trip per machine.
let setups = state.manager.setups();
// The fetch is blocking by design (see `usage`); off the workers.
let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&setups))
.await
.context("usage fetch panicked")?;
@@ -1315,8 +1042,6 @@ struct CwdRequest {
cwd: PathBuf,
}
/// Moves a session to a different working directory.
///
/// The directory is checked here rather than in the manager because checking
/// it is an ssh round trip on a remote setup, and the manager is not async.
///
@@ -1325,9 +1050,6 @@ struct CwdRequest {
/// cannot start, and the failure would arrive later with nothing pointing at
/// the typo. The spawn path corrects instead because it is resuming a
/// directory the *machine* recorded, which can be gone through nobody's fault.
///
/// It does not start a replacement process; see
/// [`SessionManager::set_session_cwd`].
async fn set_cwd(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1350,10 +1072,6 @@ async fn set_cwd(
setup.name
)));
}
// Stored in the short form, so the one path kept is the one the phone will
// draw -- rather than storing `/home/bob/…` and abbreviating it again at
// each place it is shown, which is two representations of one directory.
// Only where the setup runs here; see `setups::shorten_home`.
let stored = if setup.ssh.is_none() {
crate::setups::shorten_home(&cwd)
} else {
@@ -1407,8 +1125,6 @@ async fn defaults(State(manager): State<Arc<SessionManager>>) -> axum::Json<Defa
})
}
/// Sets what a new session's thinking level is. Applied when a session is
/// spawned, so nothing already running changes underneath anybody.
async fn set_defaults(
State(manager): State<Arc<SessionManager>>,
axum::Json(body): axum::Json<Defaults>,
@@ -1423,15 +1139,10 @@ async fn set_defaults(
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct EffortRequest {
/// Absent or null is the CLI's own default, which is a choice somebody can
/// make rather than only a state to start in.
#[serde(default)]
effort: Option<String>,
}
/// 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<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1475,15 +1186,10 @@ async fn set_notify(
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AutoResumeRequest {
auto_resume: bool,
/// What to send when the limit lifts. Absent -- and empty, which is what a
/// cleared field sends -- means this app's own default word, which is a
/// choice a caller has to be able to make rather than only start in.
#[serde(default)]
message: Option<String>,
}
/// Turns auto-resume on or off, and sets what it would say.
///
/// One request for both, because they are one decision: switching it on
/// without saying what to send is the ordinary case, and changing the words
/// while it is off is how somebody sets it up before it is needed.
@@ -1504,8 +1210,6 @@ struct CommandRequest {
text: String,
}
/// Runs one of the session's own commands, now or at the next boundary.
///
/// The two this server understands are turned into the operations it has, and
/// everything else is passed to the session verbatim, because a dialect's
/// vocabulary is its own and grows without this file.
@@ -1519,10 +1223,6 @@ async fn command(
Some((name, rest)) => (name, rest.trim()),
None => (text, ""),
};
// All of these start the session's process first if it has exited: a
// command is something somebody asked the session to do, and answering that
// its process is gone hands back the work of starting one.
//
// A rename still goes through `rename_session` rather than being a command
// like the rest, because the name is persisted and listed as well as
// forwarded, and that is one operation.
@@ -1552,15 +1252,8 @@ async fn compact(
Ok(StatusCode::NO_CONTENT)
}
/// The most one attachment may be. Streamed to disk, so this bounds the
/// session directory rather than memory; a day of `perfetto` is under a
/// gigabyte, and this leaves room for a few of them.
const ATTACHMENT_LIMIT: usize = 4 * 1024 * 1024 * 1024;
/// Accepts one file (any multipart field) and stores it under the session; the
/// returned id goes into a later `/message`'s attachmentIds. An image is later
/// shown to the model, anything else is named to it by path.
///
/// Written to disk as it arrives rather than collected first: a trace is bigger
/// than this process should hold. Under a `.part` name until it is whole, so a
/// tunnel that drops mid-upload leaves nothing a message could reference.
@@ -1618,7 +1311,6 @@ async fn upload_attachment(
.with_context(|| format!("name {}", path.display()))
.map_err(ApiError::Internal)?;
// Images are not copied: they ride the message itself as base64.
if crate::media::media_type_for(&name).is_none()
&& let Some((ssh, cwd)) = manager.remote_of(&id)
{
@@ -1641,14 +1333,6 @@ async fn upload_attachment(
Ok(axum::Json(serde_json::json!({ "id": name })))
}
/// Copies `local` to the machine `ssh` names, into the configured attachments
/// directory, else `cwd`, else the login home, and returns the absolute path it
/// has there.
///
/// One `ssh` invocation does the copy and answers the path: the file goes over
/// stdin to `cat`, and `pwd -P` afterwards resolves whatever the directory was
/// written as into the path the session will be told. `scp` would need a second
/// round trip for that answer.
async fn ship_attachment(
ssh: &crate::config::SshConfig,
cwd: Option<&Path>,
@@ -1659,15 +1343,10 @@ async fn ship_attachment(
let mut script = String::new();
if let Some(dir) = dir {
let dir = crate::ssh::quote_path(&dir.to_string_lossy());
// Created if missing: a configured directory may not exist yet, and a
// session's own cwd already does, so this costs it nothing.
script.push_str(&format!("mkdir -p {dir} && cd {dir} && "));
}
script.push_str(&format!("cat > {} && pwd -P", crate::ssh::quote(name)));
let source = std::fs::File::open(local).with_context(|| format!("open {}", local.display()))?;
// Through the transport's own "with this on stdin", which the explorer's
// write also uses -- one description of what that means rather than an ssh
// invocation assembled here as well.
let transport = crate::session::transport::Transport::Ssh {
name: ssh.address.clone(),
ssh: ssh.clone(),
@@ -1684,23 +1363,15 @@ async fn ship_attachment(
Ok(format!("{dir}/{name}"))
}
/// Where the remote path of a shipped attachment is recorded, beside it.
/// Read by `ClaudeDriver`'s `attachment_path`; removed with the session.
fn remote_marker(local: &Path) -> std::path::PathBuf {
let name = local.file_name().unwrap_or_default().to_string_lossy();
local.with_file_name(format!("{name}.remote"))
}
/// Serves a session's stored files -- both `files/` (images produced by tools)
/// and `attachments/` (uploaded from the phone), by the id events and uploads
/// reference.
async fn serve_file(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, name)): UrlPath<(String, String)>,
) -> Result<Response, ApiError> {
// Ids are server-generated -- hex and an extension, or hex and a cleaned
// file name; anything else (any path separator in particular) is refused,
// not resolved.
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
@@ -1718,13 +1389,9 @@ async fn serve_file(
"no file {name} in session {id}"
)));
};
// A file that is there but unreadable is this server's fault, not the
// request's.
let bytes = std::fs::read(path)
.with_context(|| format!("read {}", path.display()))
.map_err(ApiError::Internal)?;
// Every image this server writes has an extension it knows; the rest are
// files attached by name, served as the bytes they are.
let content_type = crate::media::media_type_for(&name).unwrap_or("application/octet-stream");
Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response())
}
@@ -1735,14 +1402,9 @@ struct EventsQuery {
after: u64,
}
/// The session screen's one data source: replay everything after the cursor
/// from the transcript, then live events. An SSE auto-reconnect sends the last
/// event id it saw as `Last-Event-ID`, which takes precedence over `after` --
/// same cursor, native mechanism.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TranscriptQuery {
/// Page backwards from this sequence number; absent means the newest.
#[serde(default)]
before: Option<u64>,
#[serde(default = "default_window")]
@@ -1764,11 +1426,6 @@ fn default_window() -> usize {
80
}
/// A page of a session's transcript, newest first to open with.
///
/// One request rather than one stream frame per event. The SSE stream remains
/// the right shape for *live* events, which arrive one at a time by nature; it
/// is only the backlog that has to stop pretending to be live.
async fn transcript(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1778,8 +1435,6 @@ async fn transcript(
transcript_page(session.transcript_path(), &id, query)
}
/// Exactly [`transcript`]'s route and answer, against one subagent's own
/// transcript instead of its session's -- see `docs/SUBAGENTS.md`'s wire shape.
async fn subagent_transcript(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, sub)): UrlPath<(String, String)>,
@@ -1794,10 +1449,6 @@ async fn subagent_transcript(
)
}
/// A page of history at `path`, newest first to open with -- the one
/// implementation [`transcript`] and [`subagent_transcript`] share, since a
/// subagent's transcript is read exactly the way a session's is. `label` is
/// only for the debug line below.
fn transcript_page(
path: &Path,
label: &str,
@@ -1811,10 +1462,6 @@ fn transcript_page(
query.coalesce,
)
.map_err(bad_request)?;
// How far back a phone has paged, and what each page cost it, which is the
// one question this route raises and nothing else can answer: the app asks
// for events and draws rows, and the ratio between them is a property of
// the conversation. `RUST_LOG=ai_server=debug`.
tracing::debug!(
session = %label,
before = ?query.before,
@@ -1835,8 +1482,6 @@ async fn events(
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
let session = lookup(&manager, &id)?;
let cursor = cursor_of(&headers, query.after);
// Subscribe before reading the file so nothing can land in the gap
// between replay and live; overlap is deduplicated by seq.
let live = session.subscribe();
Ok(sse_stream(
session.transcript_path().to_path_buf(),
@@ -1845,8 +1490,6 @@ async fn events(
))
}
/// Exactly [`events`]'s route and answer, against one subagent's own stream
/// instead of its session's -- see `docs/SUBAGENTS.md`'s wire shape.
async fn subagent_events(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, sub)): UrlPath<(String, String)>,
@@ -1860,8 +1503,6 @@ async fn subagent_events(
Ok(sse_stream(subagent.transcript_path(), cursor, live))
}
/// The cursor an SSE reconnect resumes from: the native `Last-Event-ID`
/// takes precedence over the query parameter, same cursor either way.
fn cursor_of(headers: &HeaderMap, query_after: u64) -> u64 {
headers
.get("last-event-id")
@@ -1870,8 +1511,6 @@ fn cursor_of(headers: &HeaderMap, query_after: u64) -> u64 {
.unwrap_or(query_after)
}
/// Spawns the backlog-then-live task and wraps it as the response, the one
/// piece [`events`] and [`subagent_events`] share.
fn sse_stream(
transcript: PathBuf,
cursor: u64,
@@ -1882,20 +1521,11 @@ fn sse_stream(
Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default())
}
/// `GET /sessions/{id}/subagents`: every subagent this session has started,
/// oldest first, with a status read from its own transcript -- see
/// `docs/SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is
/// reported `Unknown` instead when the session itself is not running: its
/// process was the session's, and a session with none has nothing left to
/// ask.
async fn list_subagents(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<SubagentInfo>>, ApiError> {
let session = lookup(&manager, &id)?;
// Anything but `Exited` or `Unknown` has a process behind it, which is
// what decides whether a subagent still reading `Running` from its own
// transcript can be believed -- see `docs/SUBAGENTS.md`'s wire shape.
let running = !matches!(
session.status(),
crate::session::driver::SessionStatus::Exited
@@ -1904,32 +1534,17 @@ async fn list_subagents(
Ok(axum::Json(session.subagents().list(running)))
}
/// Every session's attention-wanting moments, on one stream.
///
/// **Live only, with no cursor**, which is the one place this server does not
/// offer to catch a client up. A notification is a claim about now: replaying
/// "your turn" from an hour ago sends somebody to a session that may have been
/// answered from another device since, and a notification that is wrong costs
/// the reader the trip *and* teaches them to distrust the next one. What was
/// missed is still on the session list, which answers "what is waiting"
/// without claiming to be news.
async fn notifications(
State(manager): State<Arc<SessionManager>>,
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
let live = manager.subscribe_notifications();
let stream = BroadcastStream::new(live).filter_map(|item| {
// A lagged subscriber has lost the oldest notifications, and the ones
// it still gets are the recent ones -- the ones worth acting on.
let notification = item.ok()?;
Some(Ok(SseEvent::default().json_data(&notification).ok()?))
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
/// Feeds one SSE subscriber: transcript replay after the cursor, then live
/// events, catching back up from the file whenever the broadcast channel
/// laps us. Ends when the client disconnects (send fails) or the session
/// is deleted (channel closed).
async fn stream_session(
transcript: PathBuf,
mut last: u64,
@@ -1960,15 +1575,6 @@ async fn stream_session(
}
}
/// Sends everything after `last`, advancing it, and answers whether the
/// subscriber is still there.
///
/// A [`CatchUp::Restart`] is preceded by the `reset` frame that tells the
/// client to drop what it holds. Without it the window would be spliced onto
/// rows that are no longer adjacent to it, which reads as ordinary output
/// rather than as a gap -- which is why a bounded backlog cannot simply be
/// "the newest events".
///
/// Both ways into a backlog come through here -- the first replay and the
/// recovery from a lapped broadcast -- because either can be arbitrarily far
/// behind and owes the client the same answer.
@@ -2014,11 +1620,6 @@ async fn send_event(
}
/// Separate router because its state is the model store, like `usage`'s.
///
/// Keys are `owner/repo/file.gguf` and so contain slashes, which is why
/// nothing here puts one in the path: a key travels in the body or a query
/// string, and the routes stay addressable without escaping rules nobody would
/// get right from a phone.
pub fn models_router(store: Arc<crate::models::ModelStore>) -> Router {
Router::new()
.route("/models", get(list_models))
@@ -2030,10 +1631,6 @@ pub fn models_router(store: Arc<crate::models::ModelStore>) -> Router {
.with_state(store)
}
/// What this machine has and what it is fetching, in one answer. Both
/// together deliberately: a phone showing the model list needs both to draw
/// one screen, and two routes would let it render a model as absent while its
/// download sits at 99%.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ModelsResponse {
@@ -2061,7 +1658,6 @@ struct SearchQuery {
async fn search_models(
Query(query): Query<SearchQuery>,
) -> Result<axum::Json<Vec<crate::models::RemoteRepo>>, ApiError> {
// Blocking HTTP, like the usage fetch: off the request workers.
let found = tokio::task::spawn_blocking(move || crate::models::search(&query.q))
.await
.context("model search panicked")?
@@ -2091,7 +1687,6 @@ struct DownloadRequest {
file: String,
}
/// Starts a download, or rejoins the one already running for that model.
async fn start_download(
State(store): State<Arc<crate::models::ModelStore>>,
axum::Json(body): axum::Json<DownloadRequest>,
-361
View File
@@ -1,51 +1,3 @@
//! The Claude Code driver: `claude -p` speaking stream-json on stdio,
//! translated into the common event model.
//!
//! This half owns the process -- starting it, *adopting one this server
//! left running*, writing lines to it, and ending it. Where it runs is
//! `session::transport`'s business, not this file's: this one emits a
//! `Launch` and never learns whether it became a local child or an ssh
//! invocation.
//!
//! The process is meant to outlive the server, so that restarting the
//! backend does not end a turn: its stdio lives in the session directory
//! (a fifo it holds open itself, plus logs read from a byte offset) and
//! `session::process` records what it is. Everything comes through
//! [`ClaudeDriver::launch`], which adopts if it can and starts if it
//! cannot -- `--resume` is reachable only on the second path, because two
//! CLIs on one session file duplicate the conversation into it.
//! Turning a line into [`Event`]s is [`translate`], which changes when the
//! CLI's wire format does rather than when any of the above does.
//!
//! The probing record below stays here, since it is the provenance for
//! both halves: the flags are this file's, the message catalogue is what
//! `translate` implements.
//!
//! Wire format pinned against CLI 2.1.237 by probing (2026-08-24; scripts
//! summarized here since they live outside the repo):
//!
//! - Outbound: `system/init` (carries `session_id`, the `--resume` token),
//! `stream_event` (raw API deltas; `text_delta` is the streaming text),
//! consolidated `assistant` messages (their `tool_use` blocks have the
//! complete input), `user` messages with `tool_result` blocks, a `result`
//! per turn (usage + cost), `control_request` for anything needing a
//! human, `control_response` answering ours.
//! - Permission prompts require the hidden `--permission-prompt-tool stdio`
//! flag; they arrive as `control_request{subtype:can_use_tool}` and are
//! answered with `{behavior:"allow",updatedInput}` or
//! `{behavior:"deny",message}`. `AskUserQuestion` uses the same shape,
//! with the chosen labels added to `updatedInput` as
//! `answers:{<question text>:<label>}`.
//! - Inbound `user` messages sent mid-turn are queued and injected at the
//! next tool boundary (verified live: the model acknowledged a steer
//! between two Bash calls) -- the behavior this app exists for.
//! - `control_request{subtype:set_model}` answers success;
//! `{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::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
@@ -69,9 +21,6 @@ use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
/// because this is held per session for the life of the process.
const STDERR_LINES_KEPT: usize = 50;
/// The kept stderr as one block, with blank lines trimmed off both ends. The
/// trailing trim is the point: a shell's error ends with a blank line, so
/// anything reporting "the last line" reports nothing at all.
fn tail_of(kept: &VecDeque<String>) -> String {
let lines: Vec<&str> = kept.iter().map(String::as_str).collect();
let start = lines
@@ -86,41 +35,14 @@ fn tail_of(kept: &VecDeque<String>) -> String {
lines[start..end].join("\n")
}
/// Where the driver remembers its CLI session id between backend runs -- the
/// whole crash-recovery story, since respawning with `--resume <id>` picks the
/// conversation back up. Kept in the session directory rather than config.ron
/// so the shared schema stays free of per-driver state.
pub(super) mod translate;
const RESUME_FILE: &str = "claude-session.json";
/// Messages handed to the CLI that it has not visibly acted on yet.
///
/// The CLI *does* take a message written mid-turn: it goes into the next model
/// call, which is the next tool boundary, and steering a running turn is the
/// point of this app. An earlier version held every mid-turn message until the
/// turn ended, so a steer sent after the second tool call sat unread until all
/// the work it was meant to redirect had finished.
///
/// What the CLI does not do is say on stdout that it has read one. So the line
/// goes out immediately and the *announcement* waits here, until the CLI opens
/// the next model call -- see [`translate::starts_a_model_call`].
///
/// The proof has to be the model call and not the output. Assistant text and a
/// tool call both keep arriving from a message that was *already in flight*
/// when the steer was written, and that message saw none of it: a steer sent
/// while an answer was streaming was recorded in the middle of it, above tool
/// calls the model had already committed to.
#[derive(Default)]
struct Queue {
/// A turn is in flight, so a message sent now is a steer into it.
running: bool,
/// Written, not yet announced, oldest first, each with the id of the
/// `MessageQueued` that told the phone it was waiting -- so the
/// announcement can name which bubble it resolves.
awaiting: VecDeque<(String, String, Vec<AttachmentRef>)>,
/// The process is gone, so nothing can be taken up any more.
///
/// Needed because every other way out of a turn is an `Idle` this driver
/// sees, and an exit is the one that is not. Without it a process that
/// died mid-turn left `running` true for good, and since a message is only
@@ -177,37 +99,18 @@ const STDIN_FIFO: &str = "stdin.fifo";
const STDOUT_LOG: &str = "stdout.log";
const STDERR_LOG: &str = "stderr.log";
/// How often a reader with nothing to read looks again. A poll rather than a
/// watch: the alternative is an inotify dependency for one file per session,
/// and at this interval the streaming text already arrives faster than a phone
/// renders it.
const POLL: std::time::Duration = std::time::Duration::from_millis(50);
pub struct ClaudeDriver {
sink: EventSink,
queue: Arc<Mutex<Queue>>,
/// Lines for the process's stdin. Not closeable, unlike the pipe this used
/// to be: stdin is a fifo the process holds open itself, so closing this
/// end says nothing to it. Ending the process is [`Driver::stop`]'s job.
to_child: mpsc::UnboundedSender<String>,
state: Arc<Mutex<Translator>>,
session_dir: PathBuf,
/// Cleared to stop the reader without touching the process -- which is
/// exactly what detaching is.
reading: Arc<AtomicBool>,
}
impl ClaudeDriver {
/// Takes charge of this session's process: the one already running if
/// there is one, otherwise a new one.
///
/// One entry point rather than two, because the choice is not the caller's
/// and getting it wrong is the expensive bug. A second `--resume` against
/// a session file that is already open duplicates the whole conversation
/// into it and bills the reattached copy for re-reading it -- measured at
/// 65 MB and 154 screenshots on 2026-08-29. So `--resume` is reachable
/// only through the spawn half below, under a check that nothing is
/// running.
pub fn launch(
meta: &SessionConfig,
provider: &ProviderConfig,
@@ -223,13 +126,6 @@ impl ClaudeDriver {
let queue = Arc::new(Mutex::new(Queue::default()));
let reading = Arc::new(AtomicBool::new(true));
// Adopting is only possible for a process this server left behind on
// this machine: an ssh session's child died with the connection.
// Nothing was ever recorded for one, so this answers "no" without
// needing to know that.
//
// `started_here` is whether this launch *started* a process or picked
// one up; the two owe the session different things.
let started_here;
let record = match process::recorded(session_dir) {
// Still running, and ours. Pick it up where it was left -- the one
@@ -244,9 +140,6 @@ impl ClaudeDriver {
started_here = false;
record
}
// Recorded, and the machine will not say whether it is still
// there. Starting one anyway is the mistake this module is for, so
// nothing is started; `follow` keeps asking.
Some((record, process::Liveness::Unknown)) => {
tracing::warn!(
"session {} recorded pid {} but this machine won't say whether it is running; \
@@ -280,19 +173,11 @@ impl ClaudeDriver {
state: SessionStatus::Idle,
});
}
// Where reading of its output had reached. A process just started has
// said nothing, so its record says zero.
let resuming_from = match record.detail {
process::Detail::Stdio { stdout_read } => stdout_read,
// A record of the wrong shape belongs to a different driver; read
// its output from the start rather than trusting an offset into a
// file that means something else.
_ => 0,
};
// The writer end of the fifo. Opened write-only here: the process holds
// its own read-write handle, so this side coming and going across a
// restart is invisible to it.
let stdin = std::fs::OpenOptions::new()
.write(true)
.open(session_dir.join(STDIN_FIFO))
@@ -331,9 +216,6 @@ impl ClaudeDriver {
})
}
/// Starts a new CLI for this session, with its streams in the session
/// directory so the next run of this server can find them. The only path
/// that passes `--resume`, and it is reached only when nothing is running.
fn start(
meta: &SessionConfig,
provider: &ProviderConfig,
@@ -347,8 +229,6 @@ impl ClaudeDriver {
};
push("--input-format", "stream-json");
push("--output-format", "stream-json");
// Hidden but load-bearing: without it the CLI resolves permissions
// itself and nothing ever reaches the phone.
push("--permission-prompt-tool", "stdio");
if let Some(model) = &meta.model {
push("--model", model);
@@ -356,39 +236,14 @@ 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.
//
// Only when we are creating it. A resume is a session that already
// existed -- an import, or this server starting again -- and it already
// has whatever name it was given, quite possibly by the person typing
// in it. `Driver::set_title` is how it changes after this point, and
// that one is asked for.
match read_resume_token(session_dir) {
Some(resume) => push("--resume", &resume),
None => push("--name", &meta.title),
}
args.push("--include-partial-messages".to_string());
// Makes `bypassPermissions` *reachable* without selecting it: the
// session still starts in whatever mode was asked for above.
//
// The CLI is asymmetric about that mode. It will *launch* in
// `bypassPermissions` on `--permission-mode` alone, but refuses to
// *switch* into it later ("the session was not launched with
// --dangerously-skip-permissions"), so the phone's mode picker offered
// a mode that could not be picked on every session not given it at
// birth. Since the mode is already reachable at spawn, this grants
// nothing that was being withheld.
//
// Measured against 2.1.237 both ways round. Note it is the `--allow-`
// form; `--dangerously-skip-permissions` turns it on for everything,
// which would take the choice away from whoever holds the phone.
args.push("--allow-dangerously-skip-permissions".to_string());
// Fresh logs, because the offsets that index them start at zero and
@@ -435,21 +290,12 @@ impl ClaudeDriver {
let _ = self.to_child.send(line);
}
/// Writes one of the CLI's own commands into the session.
///
/// Slash commands ride the normal user-message channel -- there is no
/// control request for them, measured by asking. The turn they start is
/// marked here because they produce a `result` like any other, so a message
/// sent meanwhile belongs in the queue's "written, announce when read" path.
///
/// Nothing is emitted about the command itself: the manager has already
/// said it was sent, and the CLI announces what it does.
fn local_command(&self, text: String) {
let mut queue = self.queue.lock().unwrap();
// The same check `send_user_message` makes: a line written into a fifo
// nothing is reading goes nowhere and looks exactly like one that
// arrived. What this catches is the process going away between
// `Commands::submit`'s check and this write.
if queue.closed {
drop(queue);
let _ = self.sink.send(Event::Error {
@@ -474,19 +320,9 @@ impl ClaudeDriver {
);
}
/// Sends a control request, remembering what it asked for.
///
/// `confirms` is the setting this request will have made if the CLI answers
/// success -- see [`Translator::expect_setting`]. `None` for the ones that
/// change no setting, like an interrupt.
///
/// The id is random rather than the clock it used to be: two requests in
/// the same second shared an id.
fn send_control(&self, request: Value, confirms: Option<Setting>) {
let id = format!("req-{}", super::random_hex());
if let Some(setting) = confirms {
// Before the line goes out: the reader thread is already running,
// and a fast answer to a slow lock arrives first.
self.state
.lock()
.unwrap()
@@ -531,9 +367,6 @@ impl Driver for ClaudeDriver {
let line =
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
let mut queue = self.queue.lock().unwrap();
// Saying so beats writing into a fifo that nothing is reading, which is
// what this used to do -- the message went nowhere and looked exactly
// like one that had been delivered.
if queue.closed {
drop(queue);
let _ = self.sink.send(Event::Error {
@@ -543,13 +376,6 @@ impl Driver for ClaudeDriver {
return;
}
if queue.running {
// Into the running turn, now. Announced when the CLI shows it has
// been round the model again -- see `Queue`.
//
// The *waiting* is recorded here, which is the one thing that must
// not be left to the phone to remember: it drew the bubble from its
// own state, so leaving the session showed nothing pending while a
// message was still queued.
let id = super::random_hex();
queue
.awaiting
@@ -604,7 +430,6 @@ impl Driver for ClaudeDriver {
});
self.send_line(control_response.to_string());
}
// A multi-question AskUserQuestion still waiting on the rest.
AnswerOutcome::Pending => {}
AnswerOutcome::Unknown => {
let _ = self.sink.send(Event::Error {
@@ -618,8 +443,6 @@ impl Driver for ClaudeDriver {
/// deliberately, and dropping it would lose a message that never reached
/// the transcript.
fn interrupt(&self) {
// Recorded before the request goes out, so the result it produces reads
// as the stop somebody asked for rather than as a failure.
self.state.lock().unwrap().expect_interrupt();
self.send_control(json!({"subtype": "interrupt"}), None);
}
@@ -639,18 +462,10 @@ impl Driver for ClaudeDriver {
}
fn run_command(&self, text: &str) {
// Whatever the CLI's own vocabulary holds. It rides the same channel as
// `/compact` and starts a turn the same way, so the same bookkeeping
// applies; what it means is the CLI's business.
self.local_command(text.to_string());
}
fn set_title(&self, title: &str) {
// The CLI's own mechanism, and a local command rather than a control
// request -- `set_session_name` is not a subtype it knows, measured by
// asking. It answers this the way it answers `/compact`. A name with a
// newline would be two lines and the second would be a message, so it
// is refused rather than sent.
if title.contains('\n') {
let _ = self.sink.send(Event::Error {
message: "a session name cannot contain a line break".to_string(),
@@ -665,11 +480,6 @@ impl Driver for ClaudeDriver {
}
fn clear(&self) {
// Nothing is emitted here on purpose: the transcript should record a
// clear that happened, not one that was asked for. The CLI announces it
// with `conversation_reset`, which `translate.rs` turns into
// `Event::Cleared`, and follows it with a fresh `init` whose new
// `session_id` the reader persists as the resume token.
self.local_command("/clear".to_string());
}
@@ -679,9 +489,6 @@ impl Driver for ClaudeDriver {
}
fn detach(&self) {
// Stop reading and leave everything else exactly as it is. The process
// keeps its fifo, keeps writing its log, and keeps its record -- which
// is how the next run of this server finds it.
self.reading.store(false, Ordering::SeqCst);
}
@@ -694,14 +501,6 @@ impl Driver for ClaudeDriver {
}
}
/// Follows the process's stdout log, turning it into events, and is also what
/// decides whether the session is still running.
///
/// One loop rather than a reader plus a monitor. After a restart there is no
/// `Child` to wait on -- the process was reparented away -- so liveness has to
/// be a question asked of the record either way, and asking it in two places is
/// how the two answers come to disagree.
///
/// Reading is resumable because the position is written down with the process:
/// everything before it is already in the transcript, so a server coming back
/// picks up exactly where the last one stopped.
@@ -718,9 +517,6 @@ async fn follow(
) {
let stdout_path = session_dir.join(STDOUT_LOG);
let stderr_path = session_dir.join(STDERR_LOG);
// Whatever is already in the stderr log was logged by whichever run of this
// server was watching, so a reattach starts at the end of it. The tail is
// still read from the file if the process dies, which is when it matters.
let mut stderr_at = process::size_of(&stderr_path);
let mut said_unknown = false;
@@ -751,16 +547,6 @@ async fn follow(
return;
}
};
// Only whole lines, and the offset stops at the last newline -- so a
// line the process is halfway through writing is read again next pass.
// Deliberately *not* held in memory between passes: the offset would
// then have to point behind the bytes being held. It is also what makes
// the position crash-safe.
//
// Counted in bytes rather than on a decoded string: a read can cut a
// multi-byte character in half, and the replacement character is a
// different length from what it replaced -- which would slide the offset
// out of step with the file for the rest of the session.
let complete = complete_lines(&bytes);
for line in String::from_utf8_lossy(&bytes[..complete]).lines() {
@@ -779,9 +565,6 @@ async fn follow(
process::write(&session_dir, &record);
}
// Diagnostics only, and the tail of it is what an exit report carries --
// so it is read from the file rather than kept in memory, which means a
// reattached session can still explain a failure it did not witness.
if let Ok((bytes, at)) = process::read_from(&stderr_path, stderr_at)
&& at != stderr_at
{
@@ -795,11 +578,6 @@ async fn follow(
match record.liveness() {
process::Liveness::Alive => said_unknown = false,
// Drain whatever it wrote on the way out before saying so.
//
// Progress, not "there were bytes": a process that died mid-line
// leaves a partial one that is re-read every pass and never
// completes, so waiting on a non-empty read would wait for ever.
process::Liveness::Dead if complete > 0 => {}
process::Liveness::Dead => {
queue.lock().unwrap().close(&sink, "the session ended");
@@ -815,10 +593,6 @@ async fn follow(
process::clear(&session_dir);
return;
}
// The record is there and the machine will not say whether the
// process behind it is. Reported rather than guessed: calling it
// exited would invite starting a second one against the same
// conversation. Kept polling, so it resolves itself.
process::Liveness::Unknown => {
if !said_unknown {
said_unknown = true;
@@ -832,10 +606,6 @@ async fn follow(
}
}
/// How many leading bytes of `bytes` form complete lines. The offset only ever
/// advances by this, which is what lets a read land anywhere -- mid-line,
/// mid-character -- without the reader losing its place.
///
/// A line ends at `\n` and at nothing else, deliberately: this stream is JSONL,
/// so something terminated by a bare `\r` is not a record and treating one as a
/// line would hand `serde_json` a fragment. The accepted consequence is that
@@ -850,8 +620,6 @@ fn complete_lines(bytes: &[u8]) -> usize {
.unwrap_or(0)
}
/// One line of the CLI's output as events on the sink. `false` when the
/// session has been torn down and there is nothing left to send to.
fn translate_line(
line: &str,
session_dir: &Path,
@@ -878,20 +646,6 @@ fn translate_line(
if let Some(session_id) = new_session_id {
write_resume_token(session_dir, &session_id);
}
// A turn the CLI began by itself, said one line earlier than anything else
// could say it.
//
// The CLI picks the conversation back up with nothing written to it --
// measured: a backgrounded `sleep` finished nine seconds after the turn's
// result and it started again unprompted. It announces that with an `init`,
// and the first assistant text follows about a second and a half later;
// until this, that read as idle, which is long enough to send a command into
// and have it read as text.
//
// `before.is_some()` separates this from the `init` at startup. Our own
// `/clear` also produces one, and is excluded by `running` already being
// true.
// `local_command` set it before the line went out.
if opens_a_turn_by_itself(&message, before.is_some()) {
let started = {
let mut queue = queue.lock().unwrap();
@@ -911,16 +665,10 @@ fn translate_line(
return false;
}
}
// The steer is announced where the CLI opens the model call that read it,
// and *before* that call's output, so the message sits above what it
// produced and below what it did not. This line carries no events of its
// own, which is what makes it the right place.
if opens_a_model_call && !announce_steers(queue, sink) {
return false;
}
for event in events {
// A turn nobody here started -- see `proves_a_turn`. Said before the
// event that proves it, for the same reason a steer is.
let started = {
let mut queue = queue.lock().unwrap();
let started = proves_a_turn(&event) && !queue.running && !queue.closed;
@@ -960,30 +708,12 @@ fn translate_line(
true
}
/// Whether this line is the CLI announcing work it started on its own.
///
/// `system/init` is how it says a conversation is beginning, and it sends one
/// at startup, after a `/clear`, and when it picks the conversation back up by
/// itself. Only the third is a turn nobody here asked for: `already_started`
/// rules out the first, and the caller's `running` check rules out the second.
fn opens_a_turn_by_itself(message: &Value, already_started: bool) -> bool {
already_started
&& message.get("type").and_then(Value::as_str) == Some("system")
&& message.get("subtype").and_then(Value::as_str) == Some("init")
}
/// Whether this event could only have come from a turn in flight.
///
/// The turn this side starts is announced where it is started, and that covers
/// the common case and nothing else. Everything below happens without a phone
/// asking: a compaction the CLI decided on itself, a session adopted mid-turn,
/// a message that reached the conversation by another route. In all of them the
/// CLI is plainly working and the only thing that would have said so is a
/// `Running` nobody sent, so the session reads as idle until the turn ends.
///
/// Deliberately a wider set than what announces a steer: any sign of work
/// proves a turn is running, while only a `message_start` proves a line written
/// a moment ago has been read.
fn proves_a_turn(event: &Event) -> bool {
matches!(
event,
@@ -999,12 +729,6 @@ fn proves_a_turn(event: &Event) -> bool {
)
}
/// Records every message written since the last announcement, in the order it
/// was written. False means the session has been torn down.
///
/// Called from the two places that prove the CLI has consumed them: the start
/// of a new model call, and the end of the turn. The pair is the whole of the
/// rule -- a steer announced anywhere else lands above output that predates it.
fn announce_steers(queue: &Arc<Mutex<Queue>>, sink: &EventSink) -> bool {
let taken: Vec<(String, String, Vec<AttachmentRef>)> = {
let mut queue = queue.lock().unwrap();
@@ -1045,13 +769,6 @@ fn stderr_tail(path: &Path) -> String {
tail_of(&kept)
}
/// Creates the stdin fifo if it is not already there, and opens it read-write
/// for the process to inherit.
///
/// Read-write is the whole trick: a fifo opened read-only delivers EOF as soon
/// as the last writer closes, so the process would exit the moment this server
/// did -- exactly what leaving it running has to prevent. Holding it open for
/// writing means the process is its own last writer.
fn make_fifo(path: &Path) -> Result<std::fs::File> {
if !path.exists() {
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
@@ -1072,7 +789,6 @@ fn make_fifo(path: &Path) -> Result<std::fs::File> {
.with_context(|| format!("opening the fifo {}", path.display()))
}
/// A fresh, empty, owner-only log for one of the process's output streams.
fn create_log(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
@@ -1099,8 +815,6 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
}
}
/// Where an uploaded attachment is, as a path the CLI can be told.
///
/// Absolute, because the CLI's working directory is the session's and the
/// attachments are not in it. Refused rather than resolved when the id is not
/// one this server would have written, so a crafted id cannot name a file
@@ -1124,7 +838,6 @@ fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
std::fs::canonicalize(&path).with_context(|| format!("find {}", path.display()))
}
/// Reads an uploaded image into an API image content block.
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
let path = attachment_path(session_dir, id)?;
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
@@ -1133,8 +846,6 @@ fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
"type": "image",
"source": {
"type": "base64",
// Ids carry the extension the upload was stored under, so an
// unrecognized one means a name this server didn't write.
"media_type": crate::media::media_type_for(id).unwrap_or("image/jpeg"),
"data": base64::engine::general_purpose::STANDARD.encode(bytes),
}
@@ -1155,7 +866,6 @@ mod tests {
attachment_path(dir.path(), "ab12-x.bin").unwrap(),
attachments.join("ab12-x.bin").canonicalize().unwrap()
);
// Shipped to the session's machine: the path there, not here.
std::fs::write(
attachments.join("ab12-x.bin.remote"),
"/home/t/in/ab12-x.bin\n",
@@ -1169,9 +879,6 @@ mod tests {
assert!(attachment_path(dir.path(), "missing.bin").is_err());
}
/// Drives real CLI output lines through the reader and collects what came
/// out, which is the only way to check the wiring between "the CLI said
/// this" and "the transcript records that".
fn events_from_lines(lines: &[&str]) -> Vec<Event> {
let dir = tempfile::tempdir().expect("temp dir");
let state = Arc::new(Mutex::new(Translator::new(
@@ -1191,10 +898,6 @@ mod tests {
events
}
/// Feeds lines through the reader, running `interject` between two of them,
/// and returns what came out. The hook is what makes a steer testable at
/// all: what matters is not which events a line produces but *where* a
/// message written part-way through the stream ends up among them.
fn events_with_interjection(
lines: &[&str],
after: usize,
@@ -1222,10 +925,6 @@ mod tests {
events
}
/// One assistant message, streamed: two text deltas, then the `tool_use` it
/// ends with, then that call's result. Written out rather than shortened
/// because the point of both tests below is the *order*, and the shape of a
/// real turn is what makes the order mean anything. Recorded from 2.1.237.
const STREAMED_CALL: &[&str] = &[
r#"{"type":"stream_event","event":{"type":"message_start"},"session_id":"s","parent_tool_use_id":null}"#,
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me "}},"session_id":"s","parent_tool_use_id":null}"#,
@@ -1234,13 +933,6 @@ mod tests {
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"one","is_error":false}]},"parent_tool_use_id":null}"#,
];
/// A steer typed while an answer is streaming is recorded below that
/// answer's tool call and its result, not among them.
///
/// The message reaches the CLI immediately; what waits is saying so.
/// Everything emitted after it was typed still belongs to a model call that
/// had not read it. `message_start` is the first line that proves the next
/// call has it, so that is where the announcement goes.
#[test]
fn a_steer_is_recorded_below_the_call_that_had_not_read_it() {
let mut lines = STREAMED_CALL.to_vec();
@@ -1250,7 +942,6 @@ mod tests {
lines.push(
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Doing that instead."}},"session_id":"s","parent_tool_use_id":null}"#,
);
// Typed after the first delta, with the answer still arriving.
let events = events_with_interjection(&lines, 1, |queue| {
queue.lock().unwrap().awaiting.push_back((
"q1".into(),
@@ -1266,9 +957,6 @@ mod tests {
.unwrap_or_else(|| panic!("nothing matched in {events:?}"))
};
let taken = at(|e| matches!(e, Event::MessageTaken { .. }));
// Named, not just announced: the phone has a waiting bubble for this
// message and clears the one with this id. Matching on the text would
// clear the wrong bubble whenever the same thing was sent twice.
assert!(
matches!(
&events[taken],
@@ -1294,17 +982,12 @@ mod tests {
);
}
/// A steer written after the turn's last model call is still recorded.
/// Nothing further is coming, so no `message_start` will ever prove it was
/// read -- and a message only recorded when announced would vanish, leaving
/// a phone drawing it as waiting forever.
#[test]
fn a_steer_with_no_model_call_left_is_recorded_at_the_end_of_the_turn() {
let mut lines = STREAMED_CALL.to_vec();
lines.push(
r#"{"type":"result","subtype":"success","usage":{"input_tokens":1,"output_tokens":1}}"#,
);
// Typed after the tool result, with only the turn's end to come.
let events = events_with_interjection(&lines, 4, |queue| {
queue
.lock()
@@ -1334,12 +1017,6 @@ mod tests {
);
}
/// The divider comes from the CLI announcing the reset, not from an `init`
/// arriving. Measured against 2.1.237: `/clear` emits `conversation_reset`,
/// then a fresh `init` carrying a new session id. Watching the id be
/// replaced would work, but it reads the event through a side effect; the
/// announcement says so directly and arrives first, so the divider lands
/// above the new conversation.
#[test]
fn a_conversation_reset_is_what_records_a_clear() {
let events = events_from_lines(&[
@@ -1354,11 +1031,6 @@ mod tests {
);
}
/// An `init` on its own never records a clear, whatever id it carries.
/// Three ways one arrives and none is a cleared conversation: the first
/// init of a session, the one a compaction re-announces carrying the *same*
/// id, and the one that follows a resume. Reading any as a clear would tell
/// the reader a conversation had been dropped when it had been summarised.
#[test]
fn an_init_alone_is_never_a_clear() {
for ids in [["first", "first"], ["first", "second"]] {
@@ -1376,9 +1048,6 @@ mod tests {
}
}
/// The failure this exists for: a shell's complaint ends with a blank line,
/// so reporting "the last line of stderr" reported nothing, and the phone
/// showed a bare exit status while the reason sat in the server's log.
#[test]
fn the_report_keeps_the_message_and_not_the_blank_line_after_it() {
let fish_cd_failure = [
@@ -1401,12 +1070,9 @@ mod tests {
report.ends_with("'~/repos/ai-app'"),
"the trailing blank is trimmed: {report:?}",
);
// The blank *between* lines is part of the message and stays.
assert!(report.contains("does not exist\n\nembedded:"), "{report:?}");
}
/// Nothing to say is said as nothing, so the caller can tell the two
/// apart and print just the exit status.
#[test]
fn stderr_that_is_only_blank_lines_reports_as_empty() {
let kept: VecDeque<String> = ["", " ", ""].iter().map(|l| l.to_string()).collect();
@@ -1416,10 +1082,6 @@ mod tests {
#[test]
fn a_stream_read_in_arbitrary_chunks_yields_each_line_once() {
// The property the reading position has to have: however a write is
// split -- mid-line, or mid-character -- every line comes out exactly
// once and in order. Chunked at every prime-ish size so the cuts land in
// different places, including inside the multi-byte character.
let stream = "{\"a\":1}\n{\"b\":\"caf\u{e9}\"}\n{\"c\":3}\n";
for chunk in [1usize, 2, 3, 5, 7, 11, 1000] {
let mut offset = 0usize;
@@ -1428,8 +1090,6 @@ mod tests {
let mut available = 0usize;
while available < bytes.len() {
available = (available + chunk).min(bytes.len());
// What a read from the recorded offset returns: the file as far
// as it has been written, from where we left off.
let unread = &bytes[offset..available];
let complete = complete_lines(unread);
for line in String::from_utf8_lossy(&unread[..complete]).lines() {
@@ -1448,12 +1108,8 @@ mod tests {
#[test]
fn an_incomplete_line_advances_nothing() {
// Nothing to do yet, and crucially the position does not move -- so a
// crash here re-reads the line rather than skipping it.
assert_eq!(complete_lines(b"{\"partial\": tru"), 0);
assert_eq!(complete_lines(b""), 0);
// And a complete line followed by a partial one advances only past
// the complete one.
assert_eq!(complete_lines(b"done\nhalf"), 5);
}
@@ -1472,8 +1128,6 @@ mod tests {
.push_back(("q2".into(), "second".into(), Vec::new()));
queue.close(&sink, "the session ended");
// Named rather than counted, because these never reached the transcript:
// this message is the only record they existed.
let Some(Event::Error { message }) = received.try_recv().ok() else {
panic!("closing a queue holding messages must report them");
};
@@ -1484,18 +1138,12 @@ mod tests {
"{message}"
);
// And the flag is cleared, so a later message is refused with a reason
// rather than queued behind a turn that will never end.
assert!(!queue.running);
assert!(queue.closed);
}
#[test]
fn a_turn_this_side_did_not_start_still_reports_as_running() {
// The case: a session picked up while it was already working, or one
// another agent wrote to. Nothing called `send_user_message`, so the
// only thing that can say the session is busy is what it is observed
// doing.
let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(
@@ -1518,16 +1166,12 @@ mod tests {
Some(Event::AssistantText { .. })
));
// Once only: the turn is known to be running now, and a status per delta
// would be a status per word.
assert!(translate_line(text, dir.path(), &state, &sink, &queue));
assert!(matches!(
received.try_recv().ok(),
Some(Event::AssistantText { .. })
));
// And the end of the turn puts it back, so the next one is
// reported the same way.
let done = r#"{"type":"result","subtype":"success","is_error":false,"usage":{}}"#;
assert!(translate_line(done, dir.path(), &state, &sink, &queue));
assert_eq!(
@@ -1541,9 +1185,6 @@ mod tests {
#[test]
fn output_from_a_process_that_has_gone_does_not_revive_the_turn() {
// `close` is what says the process is gone and reports the messages that
// died with it. Anything still in the pipe after that must not put the
// session back to work.
let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(
@@ -1567,8 +1208,6 @@ mod tests {
let (sink, mut received) = mpsc::unbounded_channel();
let mut queue = Queue::default();
queue.close(&sink, "the session ended");
// A session that exits with nothing held has lost nothing, and an error
// saying so would be noise on every ordinary exit.
assert!(received.try_recv().is_err());
assert!(queue.closed);
}
-322
View File
@@ -1,14 +1,3 @@
//! The stream-json dialect: CLI lines in, common [`Event`]s out.
//!
//! Split from the driver beside it because the two change for unrelated
//! reasons. This half moves when the CLI's wire format does, which is what the
//! tests at the bottom pin by replaying recorded lines; the driver half moves
//! when spawning, resuming or shutting down changes.
//!
//! The one side effect here is saving images a tool result carries into the
//! session directory; everything else is pure, which is what makes the mapping
//! testable without a process.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
@@ -18,8 +7,6 @@ use serde_json::{Value, json};
use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens};
use super::super::subagent::Subagents;
/// Whether this line is the CLI opening a fresh model call.
///
/// `message_start` begins one assistant message, and the CLI sends the previous
/// call's tool results back before opening the next -- so this is the first
/// moment at which anything written since the last one can have been read.
@@ -33,71 +20,31 @@ pub(super) fn starts_a_model_call(message: &Value) -> bool {
&& message["event"].get("type").and_then(Value::as_str) == Some("message_start")
}
/// What answering a question produced.
pub(super) enum AnswerOutcome {
/// Send this control_response line to the CLI.
Respond(Value),
/// Part of a multi-question request; more answers still needed.
Pending,
Unknown,
}
/// A setting a control request asked for, held until the CLI says whether it
/// took. The CLI answers `set_model` with a bare success -- no value -- so the
/// only way to report what was accepted is to remember what was asked.
/// `set_permission_mode` does echo its mode back.
pub(super) enum Setting {
Model(String),
PermissionMode(String),
}
/// A `can_use_tool` request we've surfaced to the phone and not yet answered.
/// For plain permissions there is one implicit question (Allow/Deny); for
/// AskUserQuestion, one per entry in `questions`.
struct PendingRequest {
request_id: String,
input: Value,
/// Question text per sub-question, in order -- the keys the answers map
/// uses. Empty for a plain permission request.
questions: Vec<String>,
answers: HashMap<String, String>,
}
/// Translation state: stream-json lines in, common events out.
pub(super) struct Translator {
pub(super) session_id: Option<String>,
pending: HashMap<String, PendingRequest>,
/// Settings asked for and not yet answered, by request id. Its path out is
/// the response: every entry is removed when one arrives, whether it
/// succeeded or failed.
asked: HashMap<String, Setting>,
/// Whether this side asked the turn to stop.
///
/// The CLI reports an interrupted turn the same way it reports one that
/// broke -- a `result` with `is_error` set -- so the line cannot tell them
/// apart, and somebody who pressed Stop was shown "the turn ended with an
/// error". What separates them is that *we* asked.
///
/// Its path out is that result, so a genuine failure in a later turn is
/// still reported.
interrupting: bool,
/// The input side of the newest assistant message, waiting for the `result`
/// that ends the turn to carry it out.
///
/// Read from the assistant message rather than the result's own usage,
/// which is the whole turn added up: measured on 2026-08-30 against 2.1.237,
/// a two-message turn reported `cache_read_input_tokens` of 40,211, being
/// 14,259 and 25,952 -- the same conversation counted twice. The model held
/// 26,131. A turn with ten tool calls would overstate it tenfold.
///
/// Its path out is that result, so a turn whose messages carried no usage
/// reports none rather than repeating the previous turn's.
context: Option<u64>,
session_dir: PathBuf,
/// This session's subagents, shared with every child translator below --
/// see `SUBAGENTS.md`. One registry per session, so a subagent started
/// through this translator or any of its children lands in the same
/// place a route reads it back from.
subagents: Arc<Subagents>,
/// One translator per subagent id, holding *its* streaming and
/// tool-tracking state -- separate from the parent's because tool ids
@@ -120,51 +67,27 @@ impl Translator {
}
}
/// Remembers what a control request was for, so its answer can say so.
/// Called before the request goes out: the reader thread is already running
/// and a fast CLI can answer before this side gets back to it.
pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) {
self.asked.insert(request_id, setting);
}
/// Says that the turn about to end was stopped on purpose. Called before
/// the request goes out, for the reason [`Translator::expect_setting`]
/// gives.
pub(super) fn expect_interrupt(&mut self) {
self.interrupting = true;
}
pub(super) fn translate(&mut self, message: &Value) -> Vec<Event> {
// Events from subagents (Task tool internals) carry a
// parent_tool_use_id; the transcript shows the Task tool's own
// start/end instead of every nested step. Routed into that
// subagent's own transcript rather than dropped -- see
// `SUBAGENTS.md`.
if let Some(parent_id) = message.get("parent_tool_use_id").and_then(Value::as_str) {
return self.translate_child(parent_id, message);
}
self.dispatch(message)
}
/// A line belonging to a subagent rather than to this translator's own
/// session. Always returns nothing to the *caller*: everything it
/// produces goes into the subagent's own transcript instead.
fn translate_child(&mut self, id: &str, message: &Value) -> Vec<Event> {
match self.subagents.get(id) {
Some(subagent) if !subagent.is_open() => {
// Not stale: the Task tool runs in the background by
// default, so a finished subagent can still be sent another
// message later (SendMessage) and start working again. A
// line arriving after `finish` means exactly that, not a
// conversation that is over -- see `SUBAGENTS.md`.
self.subagents.reopen(id);
}
Some(_) => {}
None => {
// Nobody has heard of this id yet: the Task call itself
// either has not been seen or never will be. Started here
// with the best title available -- the tool name of this
// first line -- since SUBAGENTS.md's real title only
// arrives with the Task call.
self.subagents.start(id, &fallback_title(message), None);
}
}
@@ -196,10 +119,6 @@ impl Translator {
self.subagents.record(id, event);
}
}
// What actually ends a subagent's turn: not the parent's
// `tool_result`, which for a background Task arrives at launch
// ("Async agent launched...") long before the work is done -- see
// `SUBAGENTS.md`.
if ends_a_turn(message) {
self.subagents.finish(id);
}
@@ -209,11 +128,6 @@ impl Translator {
fn dispatch(&mut self, message: &Value) -> Vec<Event> {
match message.get("type").and_then(Value::as_str) {
Some("system") => self.translate_system(message),
// The CLI's own announcement that `/clear` took effect, sent just
// before the fresh `init` carrying the new session_id. Measured
// against 2.1.237: this used to watch for the id being *replaced*,
// which is the same event seen through a side effect. The
// announcement lands before the new init rather than after it.
Some("conversation_reset") => vec![Event::Cleared],
Some("stream_event") => self.translate_stream_event(&message["event"]),
Some("assistant") => self.translate_assistant(&message["message"]),
@@ -221,9 +135,6 @@ impl Translator {
Some("control_request") => self.translate_control_request(message),
Some("control_response") => {
let response = &message["response"];
// Answered either way, so the request stops being pending
// either way -- a rejected setting that stayed here would be
// applied by the next request that reused its id.
let asked = response
.get("request_id")
.and_then(Value::as_str)
@@ -237,9 +148,6 @@ impl Translator {
message: format!("claude rejected a request: {error}"),
}];
}
// Success, so the setting this request asked for is now the
// session's, and this is the only place that says so: the
// response carries no value of its own for a model.
match asked {
Some(Setting::Model(model)) => vec![Event::Settings {
model: Some(model),
@@ -247,10 +155,6 @@ impl Translator {
}],
Some(Setting::PermissionMode(mode)) => vec![Event::Settings {
model: None,
// The CLI echoes this one, and its answer wins: `auto`
// and `manual` are names it accepts on the way in and
// reports back under another name, so repeating the
// request would show a mode the session is not in.
permission_mode: Some(
response["response"]["mode"]
.as_str()
@@ -272,29 +176,14 @@ impl Translator {
.and_then(Value::as_u64)
.unwrap_or(0);
let mut events = Vec::new();
// A turn another agent started, which is only knowable here.
//
// Measured against 2.1.237 (2026-08-31) by sending a real
// cross-session message to a real stream-json session: the CLI
// emits no `user` record for it and nothing in the
// partial-message stream mentions it. The whole of it arrives as
// an `origin` object on the turn's `result`, in the same shape
// the session file records -- so this is `import::peer_message`
// reading a different record.
//
// The cost is the position: the note lands after the reply it
// caused, because at no earlier point does the CLI say why the
// turn started. Taken deliberately over a second reader tailing
// the CLI's own session file, which is two sources of truth for
// one conversation and a poll per live session.
//
// Only peer-caused turns carry it: four ordinary results over a
// real session's stdout had no `origin` between them.
if let Some(peer) = crate::session::import::peer_message(message) {
events.push(peer);
}
// Whichever way this result went, the interrupt it may have
// been answering is now spent.
let asked_to_stop = std::mem::take(&mut self.interrupting);
if !asked_to_stop
&& message
@@ -323,35 +212,12 @@ impl Translator {
}
}
/// The CLI's own notices: which session this is, and what it is doing that
/// is not a turn.
///
/// Compaction is the whole of that second kind, and it is announced rather
/// than inferred. Measured against 2.1.237 (2026-08-29) by driving a session
/// through `/compact`, one produces in order:
///
/// - `{"subtype":"status","status":"compacting"}` -- the start;
/// - `{"subtype":"status","status":null,"compact_result":"success"}`, or
/// `"failed"` with a `compact_error` -- the end;
/// - a fresh `init` carrying the same `session_id`;
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the token
/// counts, and only when it succeeded;
/// - the turn's ordinary `result`, which returns it to idle.
///
/// The keys are snake_case here and camelCase in the CLI's own transcript
/// file, which records the same events -- so reading the shape off that
/// file, the obvious place to look, gets every field name wrong and
/// silently yields a compaction with no numbers in it.
fn translate_system(&mut self, message: &Value) -> Vec<Event> {
match message.get("subtype").and_then(Value::as_str) {
Some("init") => {
if let Some(id) = message.get("session_id").and_then(Value::as_str) {
self.session_id = Some(id.to_string());
}
// The CLI's own account of what it is set to, and the only one
// that resolves an alias: a session launched with
// `--model haiku` reports `claude-haiku-4-5-20251001` here. It
// arrives again after a compaction, which is free.
vec![Event::Settings {
model: message
.get("model")
@@ -379,19 +245,7 @@ impl Translator {
}
}
/// A `system/status` line: the CLI entering or leaving a state that is not
/// a turn.
///
/// A null `status` is the leaving edge, and it carries how the thing went.
/// The turn it happened inside is still going when it ends -- the `result`
/// has not arrived -- so leaving says `Running`. A state this build does
/// not recognise is left alone rather than mapped onto the nearest one.
fn translate_status(&self, message: &Value) -> Vec<Event> {
// A mode change the CLI has made, announced a moment after it answers
// the request. Measured on 2.1.237:
// `{"subtype":"status","status":null,"permissionMode":"plan"}`, which
// is a leaving edge carrying no compaction result -- so it is checked
// before the compaction reading below.
if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) {
return vec![Event::Settings {
model: None,
@@ -426,9 +280,6 @@ impl Translator {
events
}
/// Raw API streaming: only text deltas become events. Consolidated blocks
/// arriving later re-carry the same text, so those are skipped in
/// `translate_assistant` -- one source per fact.
fn translate_stream_event(&mut self, event: &Value) -> Vec<Event> {
if event.get("type").and_then(Value::as_str) == Some("content_block_delta")
&& let Some(delta) = event["delta"].get("text")
@@ -469,9 +320,6 @@ impl Translator {
.unwrap_or_default()
.to_string();
let input = block.get("input").cloned().unwrap_or(Value::Null);
// A subagent this call is about to start -- see
// `SUBAGENTS.md`'s lifecycle #1. The parent's own transcript
// still shows only the Task call itself, below.
if tool == "Task" || tool == "Agent" {
self.start_subagent_from_task(&id, &input);
}
@@ -480,10 +328,6 @@ impl Translator {
.collect()
}
/// Starts the subagent a Task call names, with the title and prompt
/// SUBAGENTS.md describes: the call's `description`, then
/// `(<subagent_type>)` when one is given, falling back to the tool's own
/// name when there is no description to build one from.
fn start_subagent_from_task(&self, id: &str, input: &Value) {
let description = text_field(input, "description");
let subagent_type = text_field(input, "subagent_type");
@@ -535,10 +379,6 @@ impl Translator {
.and_then(Value::as_str)
.unwrap_or("(question)")
.to_string();
// Everything the reader decides on, carried in the event. The
// alternative -- and what this was -- is the phone reaching into
// the tool call's input for the parts the event dropped, which
// puts this dialect's schema where no other dialect can reach it.
let options = question
.get("options")
.and_then(Value::as_array)
@@ -576,8 +416,6 @@ impl Translator {
events.push(Event::Question {
id: request_id.clone(),
prompt: format!("Allow {tool_name}?\n{summary}"),
// No header: the question is about the call it names, and the
// phone draws it on that call's own row.
header: None,
options: vec![
QuestionOption::plain("Allow"),
@@ -602,13 +440,7 @@ impl Translator {
events
}
/// Applies one answer from the phone. Question ids are the control request
/// id, suffixed `#i` for AskUserQuestion sub-questions.
pub(super) fn answer(&mut self, question_id: &str, answers: &[String]) -> AnswerOutcome {
// Where this dialect's shape is put on: the CLI's `answers` map is
// string-valued whatever the question, so several choices become one
// line here rather than everything upstream pretending a question can
// only ever have one answer.
let answer = answers.join(", ");
let answer = answer.as_str();
let (request_id, sub) = match question_id.split_once('#') {
@@ -644,10 +476,6 @@ impl Translator {
}))
}
/// `user` messages: tool results become ToolEnd, with any image parts saved
/// into the session dir and referenced by an Image event. Replayed and
/// synthetic user text is skipped -- the manager already recorded the
/// user's side.
fn translate_user(&self, message: &Value) -> Vec<Event> {
// Only tool results are here. The CLI never echoes a person's own
// message back on stdout -- measured, because the obvious way to learn
@@ -662,8 +490,6 @@ impl Translator {
continue;
}
let mut texts = Vec::new();
// Held until the call's id is in hand a few lines below: an image is
// drawn under the call that produced it, so it has to carry that id.
let mut images = Vec::new();
match block.get("content") {
Some(Value::String(text)) => texts.push(text.clone()),
@@ -702,21 +528,11 @@ impl Translator {
output: texts.join("\n"),
is_error: crate::session::import::tool_result_is_error(block),
});
// Deliberately does *not* finish a subagent `about` might name:
// the Task tool runs in the background by default, so this
// `tool_result` -- "Async agent launched..." -- arrives at
// launch, long before the subagent's own work is done. What
// ends it is its own turn ending, handled in `translate_child`.
}
events
}
}
/// The title to start a subagent under when its own first line arrives
/// before (or without) its Task call ever being seen: the tool name of that
/// first line, which is the only thing known about it yet. `"subagent"` for
/// a line this cannot even find a tool name in, such as one that opens with
/// something other than a tool call.
fn fallback_title(message: &Value) -> String {
message["message"]["content"]
.as_array()
@@ -729,20 +545,10 @@ fn fallback_title(message: &Value) -> String {
.to_string()
}
/// Whether this line is a subagent's *own* turn ending -- the only thing
/// that does, per `SUBAGENTS.md`: not the parent's `tool_result`, which for
/// a background Task arrives at launch rather than at completion.
///
/// Checked on the raw line rather than on what `dispatch` returns, so this
/// never has to touch the shared `translate_stream_event`/`dispatch` code a
/// top-level session's own turn-ending also goes through -- a subagent's
/// idea of "ended" must not change when a real session's does.
///
/// `message_delta` is the raw API's own signal, carrying the stop reason:
/// `end_turn` is genuinely done, `tool_use` means the model is about to call
/// one and there is more coming. A `result` line is the CLI's own shape for
/// a top-level turn; a subagent has not been observed to send one, but
/// SUBAGENTS.md counts it too in case a future CLI version does.
fn ends_a_turn(message: &Value) -> bool {
match message.get("type").and_then(Value::as_str) {
Some("stream_event") => {
@@ -769,10 +575,6 @@ fn ends_a_turn(message: &Value) -> bool {
/// not say until when" -- which is not a reason to invent a time: `crate::resume`
/// asks the usage endpoint before sending anything, and that answer is the one
/// that decides.
///
/// Milliseconds are accepted as well as seconds and told apart by magnitude,
/// since a wrong guess would schedule a resume tens of thousands of years out
/// and look exactly like auto-resume being broken.
fn usage_limit(result: &str) -> Option<Option<f64>> {
if !result.to_ascii_lowercase().contains("usage limit reached") {
return None;
@@ -786,9 +588,6 @@ fn usage_limit(result: &str) -> Option<Option<f64>> {
Some(stamp)
}
/// A string field that is there and not empty, or `None`. The CLI omits these
/// rather than sending them empty, but a caller that sends `""` means the same
/// thing and should not produce a description that draws as a blank line.
fn text_field(value: &Value, name: &str) -> Option<String> {
value
.get(name)
@@ -797,8 +596,6 @@ fn text_field(value: &Value, name: &str) -> Option<String> {
.map(str::to_string)
}
/// Decodes one base64 image block into `files/` and returns its ref.
///
/// A free function rather than a method because the import replay needs exactly
/// this too: a session's history carries the same image blocks as its live
/// output. Two copies would be two naming schemes for one directory.
@@ -809,8 +606,6 @@ pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.ok()?;
// Screenshots are the overwhelming case and they are PNG; an unrecognized
// type is more likely a dialect change than a JPEG.
let extension = source
.get("media_type")
.and_then(Value::as_str)
@@ -832,7 +627,6 @@ pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option
mod tests {
use super::*;
/// What a phone sends back: everything chosen, even when that is one.
fn chose(answer: &str) -> Vec<String> {
vec![answer.to_string()]
}
@@ -848,10 +642,6 @@ mod tests {
.collect()
}
/// A fresh, empty subagent registry over the same temp dir a test's
/// translator writes into -- every test here is about the parent's own
/// events, so what a registry does with a subagent is `subagent.rs`'s
/// tests to make, not these.
fn test_subagents(dir: &tempfile::TempDir) -> Arc<Subagents> {
Arc::new(Subagents::new(dir.path().to_path_buf()))
}
@@ -867,8 +657,6 @@ mod tests {
],
);
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
// The resolved model, which is the point: a session launched with
// `--model haiku` is reported by its full name here.
assert_eq!(
events,
vec![Event::Settings {
@@ -883,15 +671,12 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
// What `set_model` does: remember, send, and say nothing yet.
translator.expect_setting("req-a".to_string(), Setting::Model("sonnet".to_string()));
translator.expect_setting(
"req-b".to_string(),
Setting::PermissionMode("plan".to_string()),
);
// Success carries no model of its own -- measured on 2.1.237 -- so what
// was asked for is the only answer available.
let events = translate_lines(
&mut translator,
&[
@@ -906,8 +691,6 @@ mod tests {
}]
);
// A mode the CLI answers with a value of its own is taken from that
// value: `auto` on the way in is `default` coming back.
translator.expect_setting(
"req-c".to_string(),
Setting::PermissionMode("auto".to_string()),
@@ -926,8 +709,6 @@ mod tests {
}]
);
// A refusal changes nothing, and says why rather than claiming a
// setting that was rejected.
let events = translate_lines(
&mut translator,
&[
@@ -941,8 +722,6 @@ mod tests {
}]
);
// And neither request is still waiting: a second answer to either id
// reports nothing at all.
let events = translate_lines(
&mut translator,
&[
@@ -955,8 +734,6 @@ mod tests {
#[test]
fn a_mode_the_cli_announces_is_taken_from_the_announcement() {
// The line it sends just after answering `set_permission_mode`, which is
// also how a mode changed from the terminal arrives.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
@@ -976,7 +753,6 @@ mod tests {
#[test]
fn streams_text_deltas_and_skips_the_consolidated_copy() {
// Real lines (trimmed) from the 2.1.237 probe.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
@@ -1023,11 +799,6 @@ mod tests {
);
}
/// The other half of the test above, and the one it cannot stand in
/// for: a call the tool itself reported as failed. Both lines are
/// `tool_result`s and both carry output, so nothing but `is_error`
/// tells them apart -- which is why dropping the field made a broken
/// call draw exactly like one that worked.
#[test]
fn a_failed_tool_result_says_so() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1036,9 +807,6 @@ mod tests {
&mut translator,
&[
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_02","type":"tool_result","content":"No such file or directory","is_error":true}]},"parent_tool_use_id":null}"#,
// No `is_error` at all: every transcript written before
// the field was read looks like this, and it means the
// call was not reported to have failed.
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_03","type":"tool_result","content":"fine"}]},"parent_tool_use_id":null}"#,
],
);
@@ -1072,9 +840,6 @@ mod tests {
assert!(events.is_empty());
}
/// A child line does not just vanish from the parent -- it lands in its
/// own subagent's transcript, with that transcript's own sequence
/// numbers, starting at 1 like any other.
#[test]
fn a_child_line_lands_in_its_own_subagents_transcript() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1103,8 +868,6 @@ mod tests {
);
}
/// The title and prompt shown for a subagent come from the Task call
/// that started it, not from anything guessed at its first line.
#[test]
fn the_subagent_takes_its_title_and_prompt_from_the_task_call() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1127,16 +890,8 @@ mod tests {
));
}
/// The parent's `tool_result` for the Task id is what ends the
/// subagent -- SUBAGENTS.md's lifecycle #3 -- and nothing else does.
#[test]
fn the_parents_tool_result_does_not_finish_the_subagent() {
// The Task tool runs in the background by default: this
// `tool_result` is "Async agent launched...", arriving the moment
// the subagent *starts*, while it goes on working for however long
// its own turn takes. Finishing it here was the bug -- a running
// background agent read as "finished" with its transcript truncated
// at launch.
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
@@ -1157,10 +912,6 @@ mod tests {
assert!(subagent.is_open());
}
/// What actually ends a subagent: the raw API's own `message_delta`
/// saying its turn stopped with `end_turn`. Never written into the
/// subagent's own transcript as `Idle` -- its vocabulary has no such
/// state.
#[test]
fn the_subagents_own_end_turn_finishes_it() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1191,8 +942,6 @@ mod tests {
);
}
/// `stop_reason: "tool_use"` is the model about to call a tool, with
/// more of the turn still coming -- not an end.
#[test]
fn a_stop_reason_of_tool_use_does_not_finish_the_subagent() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1213,9 +962,6 @@ mod tests {
);
}
/// A background Task can be sent another message long after its first
/// turn ended -- a further child line for it reopens rather than being
/// dropped, and the same transcript and child translator carry on.
#[test]
fn a_line_after_finish_reopens_the_subagent_rather_than_being_dropped() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1240,8 +986,6 @@ mod tests {
assert!(subagent.is_open());
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
// Running, [prompt], Exited, Running (reopened), then the new line's
// own ToolStart -- the same transcript throughout, not a new one.
assert!(
lines.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
@@ -1262,9 +1006,6 @@ mod tests {
);
}
/// Two subagents running at once keep two separate transcripts: tool ids
/// are unique but a `stream_event`'s content-block index is not, so
/// sharing translation state between them would cross their streams.
#[test]
fn two_parallel_subagents_keep_separate_transcripts() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1326,8 +1067,6 @@ mod tests {
panic!("expected a question, got {events:?}");
};
assert_eq!(id, "req-1");
// The call being asked about, so the phone draws the ask on that tool's
// row instead of as a second card repeating its input.
assert_eq!(about.as_deref(), Some("toolu_03"));
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
assert_eq!(labels(options), ["Allow", "Deny"]);
@@ -1338,7 +1077,6 @@ mod tests {
}
);
// Allowing echoes the input back; the request is then gone.
let AnswerOutcome::Respond(response) = translator.answer("req-1", &chose("Allow")) else {
panic!("expected a control response");
};
@@ -1372,8 +1110,6 @@ mod tests {
#[test]
fn ask_user_question_rides_the_same_flow_with_answers_keyed_by_question() {
// The real 2.1.237 shape, verified live: answers go back inside
// updatedInput, keyed by the question text.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
@@ -1397,7 +1133,6 @@ mod tests {
.collect();
assert_eq!(questions.len(), 2);
assert_eq!(questions[0].0, "req-3#0");
// Both belong to the call that asked, so a phone draws them on it.
assert!(events.iter().all(|event| match event {
Event::Question { about, .. } => about.as_deref() == Some("toolu_04"),
_ => true,
@@ -1405,8 +1140,6 @@ mod tests {
assert_eq!(questions[0].1, "Which color?");
assert_eq!(labels(&questions[0].2), ["Red", "Blue"]);
// First answer alone isn't enough; the response goes out when the
// last sub-question is answered, with all answers aboard.
assert!(matches!(
translator.answer("req-3#0", &chose("Blue")),
AnswerOutcome::Pending
@@ -1422,10 +1155,6 @@ mod tests {
#[test]
fn a_question_carries_what_it_takes_to_answer_it() {
// Descriptions and previews are what the reader decides on, and a
// multi-select is how many answers the question takes. All of it travels
// in the event: a phone that had to read this dialect's tool input to
// find them would be the only place that knew how.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
@@ -1458,8 +1187,6 @@ mod tests {
.contains("dev-updater")
);
// Two choices, one answer: the joining is this dialect's shape, done
// where it is spoken. The CLI's answers map holds strings.
let AnswerOutcome::Respond(response) = translator.answer(
"req-9#0",
&["Tool calls".to_string(), "Peer messages".to_string()],
@@ -1476,7 +1203,6 @@ mod tests {
fn images_in_tool_results_are_saved_and_referenced() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
// A 1x1 PNG, the smallest real payload worth round-tripping.
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
let line = format!(
r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_05","content":[{{"type":"text","text":"took a screenshot"}},{{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{png}"}}}}]}}]}},"parent_tool_use_id":null}}"#
@@ -1487,8 +1213,6 @@ mod tests {
panic!("expected an image event, got {events:?}");
};
assert!(image.ends_with(".png"));
// Named as belonging to the call that produced it, so a phone draws it
// under that row rather than beside it.
assert_eq!(about.as_deref(), Some("toolu_05"));
let saved = dir.path().join("files").join(image);
assert!(saved.is_file(), "image not saved at {}", saved.display());
@@ -1526,17 +1250,6 @@ mod tests {
);
}
/// A turn another agent started says so, on the record that carries it.
///
/// The line is the real shape, taken from a real cross-session message sent
/// to a real stream-json session on 2.1.237 (2026-08-31) -- including the
/// `from` socket path, which is deliberately *not* what a reader is shown:
/// the sending session's `name` is what they recognise it by. The `body` is
/// the message as written; the content the model is given wraps the same
/// text in a preamble written for the model rather than for a person.
///
/// The note comes before the usage and the idle, so it sits as close to the
/// turn it explains as the wire allows.
#[test]
fn a_turn_started_by_another_agent_records_who_and_what() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1553,8 +1266,6 @@ mod tests {
Event::PeerMessage {
from: "ai-app-2-fb".to_string(),
text: "Reply with just the word ACK.".to_string(),
// Stamped by the pump, which is the only place that knows
// what seq the turn started at.
turn_start: None,
},
Event::UsageDelta {
@@ -1568,9 +1279,6 @@ mod tests {
);
}
/// And an ordinary turn does not, which is the half that decides whether
/// the check above is a check or a rubber stamp. Measured over a real
/// session's stdout: four results, no `origin` between them.
#[test]
fn an_ordinary_turn_carries_no_peer_note() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1589,13 +1297,6 @@ mod tests {
);
}
/// The context is the last assistant message's, not the result's.
///
/// Real figures from a two-message haiku turn on 2.1.237, captured
/// 2026-08-30. The result adds the turn up -- its `cache_read_input_tokens`
/// of 40,211 is 14,259 and 25,952, the same conversation counted twice -- so
/// reading the context off it would report a size the model never held, by
/// more the more tool calls a turn makes.
#[test]
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1616,8 +1317,6 @@ mod tests {
})
);
// Taken by that result, so a following turn whose messages carry no
// usage reports none rather than repeating this one's.
let events = translate_lines(
&mut translator,
&[
@@ -1635,9 +1334,6 @@ mod tests {
#[test]
fn a_compaction_reports_its_start_and_what_it_recovered() {
// Real lines (trimmed) from a 2.1.237 session driven through `/compact`.
// Note the snake_case keys -- the CLI's transcript file writes the same
// records in camelCase.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
@@ -1737,12 +1433,6 @@ mod tests {
);
}
/// Running out of quota is a state, not a failure of the work.
///
/// The naive reading -- an error result like any other -- is what shipped
/// before this: the transcript said "Claude AI usage limit reached|…" in
/// red, which is neither readable nor actionable, and nothing above the
/// driver could tell it apart from a broken tool call.
#[test]
fn a_turn_stopped_by_the_usage_limit_says_so_and_carries_the_reset() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1771,8 +1461,6 @@ mod tests {
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Claude AI usage limit reached","usage":{}}"#,
],
);
// Not a time this side invented: the meter is asked before anything is
// sent, and a made-up reset would only decide when to ask.
assert_eq!(events[0], Event::LimitReached { resets_at: None });
}
@@ -1782,18 +1470,9 @@ mod tests {
usage_limit("Claude AI usage limit reached|1788546972000"),
Some(Some(1_788_546_972.0))
);
// And anything that is not the limit stays an ordinary failure.
assert_eq!(usage_limit("something broke"), None);
}
/// Pressing Stop is not a failure, and the CLI cannot tell you which it was.
///
/// An interrupted turn arrives as exactly the same shape a broken one does,
/// so somebody who pressed the button was shown "the turn ended with an
/// error". What separates the two is that this side asked. The second half
/// of this test is the one that matters, because the naive fix -- never
/// reporting an error result -- passes the first half and silences every
/// genuine failure afterwards.
#[test]
fn a_turn_stopped_on_purpose_is_not_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1816,7 +1495,6 @@ mod tests {
"an interrupted turn still has to end the turn"
);
// The interrupt is spent, so the next failure is a failure again.
let later = translate_lines(&mut translator, &[stopped_result]);
assert!(
later
-96
View File
@@ -1,26 +1,9 @@
//! The common event model and the `Driver` trait -- the one abstraction
//! everything hangs off (see PLAN.md).
//!
//! A driver translates its child process's JSONL dialect into [`Event`]s
//! and accepts the small inbound vocabulary below. The transcript, the SSE
//! stream, and the phone UI work purely in this model; nothing downstream
//! of a driver may branch on the session kind.
//!
//! The event model itself -- [`Event`], [`QuestionOption`], [`SessionStatus`],
//! [`AttachmentRef`], `ImageRef`, [`context_tokens`] and [`context_after`] --
//! moved to the `event-model` crate on 2026-09-04, so `client-core` can share
//! one definition with this server instead of a hand-kept Kotlin mirror.
//! Re-exported here so nothing downstream of this module had to change; what
//! stayed behind is the *driver* abstraction, which is how this server runs
//! a session rather than part of what a client reads off the wire.
pub use event_model::{
AttachmentRef, Event, QuestionOption, SessionStatus, context_after, context_tokens,
};
use tokio::sync::mpsc;
/// Something a session can be asked to do to itself.
///
/// A closed set rather than a string, because the two that are not
/// dialect-specific have to reach every provider: compaction is a capability
/// an llama session may one day have, and a name is this server's own. `Raw`
@@ -35,8 +18,6 @@ pub enum SessionCommand {
}
impl SessionCommand {
/// What a person would have typed to ask for this, which is what a phone
/// shows while it waits.
pub fn label(&self) -> String {
match self {
Self::Compact => "/compact".to_string(),
@@ -46,7 +27,6 @@ impl SessionCommand {
}
}
/// Runs it. Called only at a boundary -- see [`Event::CommandQueued`].
pub fn apply(&self, driver: &dyn Driver) {
match self {
Self::Compact => driver.compact(),
@@ -57,8 +37,6 @@ impl SessionCommand {
}
}
/// What became of a request to take a queued message back.
///
/// Three states rather than a bool because the two failures are not the same
/// fact. A driver that writes into its session the moment a message arrives
/// -- which is what `ClaudeDriver` does, so a steer reaches the model at the
@@ -68,9 +46,7 @@ impl SessionCommand {
pub enum Unqueued {
/// Out of the queue; the session will never read it.
Dropped,
/// Already handed to the session, so there is nothing left to take back.
AlreadySent,
/// Nothing is waiting under that id.
Unknown,
}
@@ -79,23 +55,12 @@ pub enum Unqueued {
/// is the backpressure-free buffer of record.
pub type EventSink = mpsc::UnboundedSender<Event>;
/// The inbound half of a session. Deliberately small; see PLAN.md for the
/// per-driver mapping of each method onto its dialect.
///
/// `send_user_message` during a run is the point of the whole app: both
/// real dialects queue it for injection at the next tool boundary rather
/// than the end of the turn.
pub trait Driver: Send + Sync {
/// Takes a message, now or once the session is free for it.
///
/// Every driver owes exactly one `MessageTaken` per message, at the moment
/// it actually starts reading it: that event is what puts the message in
/// the transcript, so a driver that never sends it drops the message from
/// the conversation entirely.
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>);
/// Takes back a message that is still waiting, named by the id its
/// [`Event::MessageQueued`] carried.
///
/// Answering is the whole of the contract: a driver that drops the message
/// owes an [`Event::MessageDropped`], and one that cannot must say which
/// of the two reasons it is -- "the session has already been told" is
@@ -105,55 +70,15 @@ pub trait Driver: Send + Sync {
fn unqueue(&self, _id: &str) -> Unqueued {
Unqueued::Unknown
}
/// Answers one question with everything that was chosen, in the order it
/// was offered. A driver whose dialect takes a single value joins them
/// where it writes it.
fn answer_question(&self, id: &str, answers: &[String]);
/// Stop mid-run; the session survives.
fn interrupt(&self);
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);
// Both of the above are requests, and neither reports the outcome by
// returning. A driver that changes the setting owes an [`Event::Settings`]
// once it has -- that event, not the request, is what the manager and the
// phone read. One that cannot owes an [`Event::Error`] saying why.
/// Tells the process what this conversation is called, when it has
/// somewhere to put it.
///
/// Unlike the two above, this is not a request that can fail: the rename
/// has already happened in this server's config, which is what a phone
/// lists. So a driver whose process has no notion of a name does nothing
/// and says nothing. Claude Code has one: `--name` at creation and
/// `/rename` afterwards, which is what puts the same name in its own
/// session picker and in what other agents see.
fn set_title(&self, title: &str);
/// Runs a command this session's own dialect understands, verbatim --
/// `/context`, `/usage`, anything a CLI adds next month. A driver with no
/// such vocabulary says so with an [`Event::Error`] rather than sending it
/// as a message, which would put a line meant for the session in front of
/// the model.
///
/// Called only when the session is between turns; the waiting is done
/// above, once, for every driver.
fn run_command(&self, text: &str);
/// llama: not built, and refused; claude: `/compact`.
fn compact(&self);
/// Drops the conversation so far without ending the session.
///
/// The cheap half of managing a long session, and why it is a driver
/// operation rather than a manager one: compaction *reads* the whole
/// conversation in order to summarise it, so on a large context it is
/// itself one of the most expensive requests the session will make --
/// measured at 1.7 million tokens for one automatic compaction on
/// 2026-08-29. Clearing costs nothing, because nothing is sent.
///
/// Every implementation emits [`Event::Cleared`] so the transcript carries
/// the divider whatever the dialect did behind it.
fn clear(&self);
/// Stop attending to the process but leave it running, because this
/// server is going away and means to adopt it again.
@@ -162,12 +87,6 @@ pub trait Driver: Send + Sync {
/// is in flight, so a session's process outlives the server that started
/// it and is found again through `session::process`.
///
/// Its counterpart is [`Driver::stop`]. Every driver owes exactly one of
/// the two on the way out, and which one is the difference between "back
/// shortly" and "this conversation is over".
/// Whether a line written *now* would start a turn of its own, rather than
/// landing inside one already in flight.
///
/// Asked of the driver because the driver is the only thing that knows: it
/// updates this the instant it writes rather than when output returns. The
/// manager's `SessionStatus` is built from what has been *recorded*, so
@@ -175,8 +94,6 @@ pub trait Driver: Send + Sync {
/// and a second line sent in that gap lands inside the turn the first one
/// started. For a command that is the difference between being executed
/// and being read to the model as text, which is silent both ways.
///
/// Defaults to true for a driver with no turn of its own to be inside.
fn between_turns(&self) -> bool {
true
}
@@ -195,10 +112,6 @@ pub trait Driver: Send + Sync {
mod tests {
use super::*;
/// A tripwire for the wire format, not for serde. The app reads these
/// names, and getting one wrong does not fail loudly: a field the app
/// cannot find reads as a field the server chose not to send, which
/// several of them are allowed to be.
#[test]
fn multi_word_fields_go_out_in_camel_case() {
let json = serde_json::to_value(Event::Compacted {
@@ -218,10 +131,6 @@ mod tests {
);
}
/// The two events that take the context *down* are the point of the fold:
/// a figure measured before a compaction or a clear stopped being true at
/// that moment, and carrying it forward is how a session that had just
/// been cleared went on reporting the context it no longer had.
#[test]
fn a_compaction_and_a_clear_move_the_context_a_turn_cannot() {
let after = |current, event| context_after(current, &event);
@@ -249,8 +158,6 @@ mod tests {
);
assert_eq!(after(Some(9_617), Event::Cleared), None);
// A compaction that did not say how much it recovered leaves the
// context unknown rather than stale: it definitely moved.
assert_eq!(
after(
Some(128_402),
@@ -263,8 +170,6 @@ mod tests {
None
);
// A turn the dialect reported no context for is stale by a turn,
// which every context figure is, rather than unknown.
assert_eq!(
after(
Some(30_100),
@@ -276,7 +181,6 @@ mod tests {
Some(30_100)
);
// Everything else leaves it alone.
assert_eq!(
after(
Some(30_100),
-213
View File
@@ -1,63 +1,3 @@
//! The fake driver: no child process, just events. It proves the whole pipe --
//! spawn, transcript, SSE cursors, questions, interrupts, compaction -- and
//! stays useful afterwards as a connectivity check that costs no tokens. It
//! produces exactly the event vocabulary the real drivers do, so a UI that
//! renders echo sessions correctly renders the real thing.
//!
//! Every message is echoed back as a few streamed text deltas. A leading word
//! asks for something more specific:
//!
//! - `/tool [input]` -- a full tool run, start through end.
//! - `/bash [command]` -- a Bash call carrying that command, for what the
//! phone's shell highlighting does to a particular line.
//! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one
//! call and the next, which is what makes a run *grow* while somebody is
//! looking at it -- the only way to reach the state where a call opened on
//! its own gains a neighbour. The first call carries a screenshot, so that
//! state is also reachable with an image open full screen.
//! - `/question [text]` -- a question, exercising the answer path.
//! - `/ask` -- an AskUserQuestion call: two questions on one tool call, with
//! descriptions, a preview and a multi-select, which is the shape that is
//! awkward to get a real model to produce on demand. Wrapped in a run of
//! ordinary calls on each side, because being asked something happens in the
//! middle of work.
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states
//! that only exist *while* something is happening can be looked at.
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
//! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the
//! in-place and the live shapes.
//! - `/usage [what]` -- an invented rate-limit answer, or `/usage off` to take
//! it away. An echo session meters nothing, so it draws no usage bar until
//! this is set; what it exists for is the states that bar can be in, which
//! otherwise cost real quota to reach. `/usage 42`, `/usage 95 20`,
//! `/usage 42 never`, `/usage notloggedin`, `/usage unreachable`,
//! `/usage failed`. The vocabulary is `usage::Fixture`'s, where the states
//! live.
//! - `/limit [minutes]` -- a turn that stops because the account is out of
//! quota, saying the limit lifts in `minutes` (default 5, and `never` for a
//! limit with no stated reset). What it exists for is auto-resume, which is
//! otherwise reachable only by actually exhausting somebody's account: pair
//! it with `/usage 100 5` for a meter that agrees, and then `/usage 20` for
//! the moment the limit lifts. The wait itself is decided by the meter, so
//! those two commands are the whole rig.
//! - `/compact` -- a compaction, start to finish.
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a
//! real model's reply arrives in, and the one where the row a reader is
//! anchored to is the row that keeps changing height.
//! - `/mixed N` -- N beats of an interleaved transcript: rows of every shape
//! and height the app draws, in one session, which is what a scrolling
//! problem needs in order to be reproduced twice the same way.
//! - `/table [columns]` -- a markdown table with cells too long for one line.
//! - `/subagent [n]` -- n subagents at once (default 1), each named
//! "helper k", its prompt recorded as its own first user message: a
//! streamed reply, one Bash call, then it finishes about three seconds
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`.
//!
//! `/slow` earns its place: a queued message, a Stop button and a spinner are
//! states that only exist mid-turn, and the obvious way to get one -- ask a
//! real model to sleep -- does not work. It declines and answers instantly, so
//! the state never arrives and the attempt still costs a turn.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
@@ -68,19 +8,10 @@ use super::driver::{
};
use super::subagent::Subagents;
/// Delay between streamed deltas -- long enough that streaming is visibly
/// streaming, short enough that tests waiting on a full turn stay fast.
const DELTA_DELAY: Duration = Duration::from_millis(50);
/// How long a fake compaction takes. A measured one, near enough: driving a
/// real session through `/compact` on 2026-08-29 took 13 seconds for a small
/// conversation. Three seconds -- what this was -- is too short to look at the
/// row that only exists while a compaction is running.
const COMPACT_TIME: Duration = Duration::from_secs(13);
/// A question echo is waiting on, and the tool call it belongs to. `call` is
/// `None` for `/question`, which asks on its own the way a permission does;
/// `Some` for `/ask`, where several questions share one call.
struct PendingQuestion {
id: String,
call: Option<String>,
@@ -88,34 +19,15 @@ struct PendingQuestion {
pub struct EchoDriver {
sink: EventSink,
/// Whether a turn is in flight, and what arrived during it.
///
/// A real CLI holds a message sent mid-turn and injects it at the next tool
/// boundary; echo used to answer it on the spot, which made it the wrong
/// shape for testing anything about queueing.
busy: Arc<AtomicBool>,
/// Held messages with the id of the `MessageQueued` each one announced, so
/// the announcement can say which waiting bubble it resolves.
queued: Arc<Mutex<Vec<Held>>>,
/// Where `/mixed` writes the attachments it references, which is the same
/// directory the files route serves them from.
session_dir: PathBuf,
/// Ids of the questions awaiting an answer, in the order asked. A list
/// because `/ask` puts up to four on one tool call, and the turn resumes
/// when the last is answered rather than the first.
pending_questions: Mutex<Vec<PendingQuestion>>,
/// The invented rate-limit answer `/usage` sets, shared with the usage
/// monitor that serves it. An echo session meters nothing, so this is unset
/// until a test asks for something -- see [`crate::usage::Fixture`].
usage: crate::usage::Fixture,
/// A pretend context, so the status row has something that behaves the way
/// a real one does: it grows with each turn, drops to what the compaction
/// says it recovered, and a clear leaves it unmeasured. What is real is
/// which way the numbers move.
context: Arc<AtomicU64>,
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
/// test rig for the same registry the claude driver routes real Task
/// calls into.
subagents: Arc<Subagents>,
}
@@ -139,17 +51,7 @@ impl EchoDriver {
}
}
/// An AskUserQuestion call, in the shape the CLI sends one.
///
/// Two questions on one call, because that is where the display is hardest
/// and where it was wrong. Written out in full rather than generated so it
/// carries the parts that are easy to leave out of a fixture -- a header, an
/// option with a description, an option with a preview block, and a
/// multi-select.
fn ask_user_question(&self) {
// Written once, in the shape the events carry, and turned into the tool
// call's own input below -- the CLI sends both, and two hand-written
// copies of one question would drift.
let asked = [
(
"Theme",
@@ -239,7 +141,6 @@ impl EchoDriver {
header: Some(header.to_string()),
options,
multi_select: multi,
// The call that asked, so all of it draws as one thing.
about: Some(call.clone()),
});
}
@@ -248,23 +149,10 @@ impl EchoDriver {
});
}
/// One typed line, whether it arrived as a message or as a command.
///
/// `announce` is the whole difference: a message is announced with
/// `MessageTaken`, which is what puts it in the transcript, and a command is
/// not -- the manager has already recorded that one was sent, and saying so
/// twice drew the same line in both colours.
fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) {
let sink = self.sink.clone();
// Mid-turn messages are held rather than answered, the way a real CLI
// holds them until the next tool boundary. Without this the session went
// idle the instant one arrived, and every state that only exists while
// something is queued was untestable.
if self.busy.load(Ordering::SeqCst) {
// The waiting is recorded, exactly as the real driver records it:
// the phone draws its pending bubbles from the server, so an echo
// session has to produce the same events.
let id = super::random_hex();
self.queued
.lock()
@@ -307,7 +195,6 @@ impl EchoDriver {
} else {
rest.trim().to_string()
},
// Stamped by the manager, exactly as a real one is.
turn_start: None,
});
self.emit(Event::Status {
@@ -361,12 +248,6 @@ impl EchoDriver {
return;
}
// A turn that ends the way a real one does when the account runs out:
// the same event a real driver reports, so what acts on it -- the
// transcript row and `crate::resume` -- is exercised rather than
// imitated. The meter it should agree with is `/usage`'s fixture,
// deliberately separate: the two disagreeing is a state worth being
// able to produce, since it is what a stale reset time looks like.
if let Some(rest) = text.strip_prefix("/limit") {
if announce {
self.emit(Event::MessageTaken {
@@ -394,11 +275,6 @@ impl EchoDriver {
return;
}
// `n` subagents at once, each with its own transcript in the
// registry a real Task call routes into -- see `SUBAGENTS.md`. The
// parent's own Task calls end when their subagent does, three
// seconds later, which is long enough to see the running state on
// the phone before it finishes.
if let Some(rest) = text.strip_prefix("/subagent") {
let n = rest.trim().parse::<usize>().unwrap_or(1).clamp(1, 8);
if announce {
@@ -443,9 +319,6 @@ impl EchoDriver {
return;
}
// The same word the real CLI takes, so a phone drives both the same way.
// `Driver::compact` is what the manager's route calls; this is the typed
// path onto it.
if text.trim() == "/compact" {
if announce {
self.emit(Event::MessageTaken {
@@ -501,8 +374,6 @@ impl EchoDriver {
return;
}
// Checked before `/tool`, which is a prefix of it: matching the shorter
// one first would read "/tools 4" as a single tool whose input is "s 4".
let many_tools = text.strip_prefix("/tools").map(|rest| {
let mut words = rest.split_whitespace();
// At least two, because one call is not a run of them.
@@ -565,9 +436,6 @@ impl EchoDriver {
let _ = sink.send(event);
};
let finish = || finish_turn(&sink, &queued, &busy);
// Echo takes a message the instant it gets one, but says so anyway:
// a driver that skips this leaves the phone holding a message it
// thinks is still queued.
if announce {
send(Event::MessageTaken {
id: None,
@@ -580,7 +448,6 @@ impl EchoDriver {
});
if let Some(linger) = linger {
// A delta a second: visibly alive rather than merely slow.
let seconds = linger.as_secs();
for remaining in (1..=seconds).rev() {
send(Event::AssistantText {
@@ -623,14 +490,6 @@ impl EchoDriver {
"timeout": 5000,
}),
});
// The first call carries a screenshot, and only the first.
// That is what makes this rig cover the case a growing run
// is about: an image opened full screen from a call that is
// alone, and then a second call turning that row into a
// group. The dialog used to be inside the row, so the reader
// was thrown back to the transcript by the session making
// another tool call. The first call is the one that is on
// its own for a whole `gap`.
if i == 1 {
let part = serde_json::json!({
"source": {"media_type": "image/png", "data": SAMPLE_PNG}
@@ -653,11 +512,6 @@ impl EchoDriver {
return;
}
// One long answer arriving in small pieces, which is what a real
// model does and what `/slow` does not: `/slow` emits a line a
// second, so its message grows in steps a reader can watch one at a
// time. A jump caused by the *anchor row itself* changing height
// needs growth that is continuous.
if let Some(pieces) = stream {
for i in 0..pieces {
let len = 3 + (i * 7) % 14;
@@ -730,8 +584,6 @@ impl EchoDriver {
});
tokio::time::sleep(DELTA_DELAY).await;
}
// A conversation gets bigger, so the pretend context does too:
// roughly a hundred tokens a turn plus the words themselves.
let spent = text.split_whitespace().count() as u64;
send(Event::UsageDelta {
tokens: spent,
@@ -763,8 +615,6 @@ impl EchoDriver {
driver
}
/// Sends are infallible from the driver's point of view: a closed sink
/// means the session is being torn down.
fn emit(&self, event: Event) {
let _ = self.sink.send(event);
}
@@ -776,13 +626,6 @@ impl EchoDriver {
/// that it occupies an image's worth of space.
const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg==";
/// One beat of `/mixed`: a row shape chosen by position, so the same N always
/// produces the same transcript.
///
/// Repeatable on purpose. A scrolling fault is judged by watching the same
/// content behave differently, and a rig that produced a different transcript
/// each run would make every comparison an argument about whether the content
/// changed.
async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
let send = |event: Event| {
let _ = sink.send(event);
@@ -796,10 +639,6 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
1 => 60,
_ => 220,
};
// Deliberately ragged: each word's length is a function of its
// position, so no two lines wrap the same way. A paragraph of
// uniform tokens looks identical at every offset, which makes it
// impossible to tell a scroll of one line from a scroll of ten.
let body: String = (0..words)
.map(|w| {
let len = 3 + (w * 7 + beat * 3) % 14;
@@ -810,7 +649,6 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
delta: format!("\n\nParagraph at beat {beat}:\n{body}"),
});
}
// One call on its own -- drawn as a card rather than a group.
2 => {
let id = format!("t-{}", super::random_hex());
send(Event::ToolStart {
@@ -824,8 +662,6 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
is_error: false,
});
}
// A run of three, which the app folds into one collapsed group -- the
// row whose identity depends on what is next to it.
3 => {
for i in 1..=3 {
let id = format!("t-{}", super::random_hex());
@@ -851,8 +687,6 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
});
}
}
// An image, under the call that produced it, which is where a real
// screenshot lands.
4 => {
let id = format!("t-{}", super::random_hex());
send(Event::ToolStart {
@@ -875,7 +709,6 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
is_error: false,
});
}
// Somebody else's voice, which is its own row shape.
_ => {
send(Event::PeerMessage {
from: format!("beat-{beat}-peer"),
@@ -884,17 +717,9 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
});
}
}
// Slow enough that the phone renders each beat as it arrives rather than
// composing the whole run in one frame -- which is the condition a scrolling
// fault actually happens under.
tokio::time::sleep(Duration::from_millis(120)).await;
}
/// One `/subagent` helper: a few streamed words, one Bash call, then
/// `Status::Exited` about three seconds after it started -- long enough that
/// its `Running` state can be seen on the phone before it finishes. The
/// parent's own Task call for it ends at the same moment, the same way a
/// real Task's `tool_result` ends it.
async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
let start = tokio::time::Instant::now();
for word in "Working on it now.".split_inclusive(' ') {
@@ -942,13 +767,6 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
/// three, because all three are what the `MessageTaken` at the other end owes.
type Held = (String, String, Vec<AttachmentRef>);
/// A markdown table [columns] wide, with cells too long for one line.
///
/// Both halves matter. Long cells are what the renderer used to cut off with an
/// ellipsis, and a cut cell looks exactly like a short one, so a fixture of
/// tidy one-word values would have rendered perfectly while the defect was
/// still there. The column count decides whether the table fits the screen.
///
/// Written out as markdown rather than assembled from a grid type because what
/// is being tested is the renderer's parse of the syntax a model actually
/// writes, pipes and alignment row included.
@@ -1033,10 +851,6 @@ impl Driver for EchoDriver {
!self.busy.load(Ordering::SeqCst)
}
/// Really droppable, which is what makes this the rig for the phone's side
/// of it: the held message is this driver's own and nothing has been written
/// anywhere, so a tap here exercises the whole path through to the bubble
/// disappearing on every device. The Claude driver can only ever refuse.
fn unqueue(&self, id: &str) -> Unqueued {
let mut queued = self.queued.lock().unwrap();
let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else {
@@ -1055,9 +869,6 @@ impl Driver for EchoDriver {
self.handle(text, attachments, true);
}
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- so
/// this is the same path with the same parsing, and the fixture behaves like
/// a real session driven the same way.
fn run_command(&self, text: &str) {
self.handle(text.to_string(), Vec::new(), false);
}
@@ -1073,8 +884,6 @@ impl Driver for EchoDriver {
return;
};
let answered = pending.remove(at);
// Whether anything on the same call is still unanswered: a tool that
// asked four questions ends once, not four times.
let waiting = answered
.call
.as_ref()
@@ -1090,9 +899,6 @@ impl Driver for EchoDriver {
output: format!("answered: {answer}"),
is_error: false,
});
// The work carries on where it left off, which is what makes the
// asked-here row a boundary with a group on each side rather than
// the last thing in the turn.
self.some_calls("after");
} else {
self.emit(Event::AssistantText {
@@ -1105,16 +911,12 @@ impl Driver for EchoDriver {
}
fn interrupt(&self) {
// Nothing real to stop; a pending question is abandoned so the session
// isn't stuck awaiting input forever.
self.pending_questions.lock().unwrap().clear();
self.emit(Event::Status {
state: SessionStatus::Idle,
});
}
// Nothing to forward: this process has no notion of what the conversation
// is called, and the rename has already happened where the name lives.
fn set_title(&self, _title: &str) {}
fn set_permission_mode(&self, mode: &str) {
@@ -1129,11 +931,6 @@ impl Driver for EchoDriver {
});
}
/// A compaction with nothing to compact. The counts are invented, like
/// everything else this driver says -- what is real is the shape and the
/// order: busy, a pause long enough to see, then the result. The only other
/// way to reach those states is to fill a real session's context and spend
/// two minutes of somebody's account getting it back.
fn compact(&self) {
let sink = self.sink.clone();
let queued = Arc::clone(&self.queued);
@@ -1145,10 +942,6 @@ impl Driver for EchoDriver {
state: SessionStatus::Compacting,
});
tokio::time::sleep(COMPACT_TIME).await;
// What it says it recovered is what the pretend context becomes, so
// the figure on the status row and the one on the divider agree --
// two numbers about the same moment disagreeing is the thing this
// rig exists to catch.
context.store(9_617, Ordering::SeqCst);
let _ = sink.send(Event::Compacted {
pre_tokens: Some(128_402),
@@ -1159,17 +952,11 @@ impl Driver for EchoDriver {
});
}
/// The same marker a real driver leaves, and nothing else -- there is
/// no context here to drop. It exists so the phone's divider, its
/// scroll behaviour and the transcript's shape can be exercised
/// without spending a real session's context to produce one.
fn clear(&self) {
self.context.store(0, Ordering::SeqCst);
let _ = self.sink.send(Event::Cleared);
}
/// Nothing to detach from and nothing to stop: the echo driver has no
/// process, so both halves of the way out are already done.
fn detach(&self) {}
fn stop(&self) {}
-294
View File
@@ -1,22 +1,3 @@
//! Adopting a Claude Code session that already exists on a machine.
//!
//! Claude Code keeps every session as JSONL under
//! `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, and the CLI can
//! be told to continue one with `--resume <id>`. This module is the two
//! halves of putting that behind the phone: asking a machine what it has,
//! and turning one of those files into the transcript a phone reads.
//!
//! **Continuing is not this module's job.** `claude.rs` already resumes
//! whenever a session directory holds a resume token, for crash recovery,
//! so an import is that same path with the token written up front. There
//! is deliberately no second way to start a session.
//!
//! **The phone never names a file.** It picks an id out of what this
//! module enumerated, and the path is looked up again on the server -- the
//! same rule the setups model follows for providers, and for the same
//! reason: an enrolled token must not be able to turn into "read me this
//! arbitrary path".
use std::collections::HashMap;
use anyhow::{Context, Result};
@@ -26,77 +7,34 @@ use serde_json::Value;
use super::driver::{self, Event};
use super::transport::{Launch, Transport};
/// How much of a transcript's tail is replayed into the phone's view.
///
/// The imported conversation is for reading; *continuing* it is the CLI's job
/// through `--resume`, and it reads the whole file itself. So this is a
/// display budget, and it needs to be one: these files reach tens of megabytes
/// and every line would otherwise cross a WireGuard link to a phone.
const REPLAY_LINES: usize = 2000;
/// Whether a session is open in a CLI somewhere. Three answers, because
/// "nobody could check" is not "nobody is using it" -- collapsing them puts
/// the dangerous case behind the safe word.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum InUse {
/// Checked, and nothing is running it.
No,
/// Checked, and a live CLI has it open.
Yes,
/// The machine does not keep the record this is read from, so there is no
/// answer to be had -- not an answer of "no".
Unknown,
}
/// One Claude Code session found on a machine.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Importable {
/// The CLI's own session id, which is both the file name and the
/// `--resume` token.
pub id: String,
/// Where that session was working, offered as the imported session's cwd
/// so it resumes pointing at the same tree.
pub cwd: String,
/// The first thing a person said in it, for recognising it in a list.
pub title: String,
/// Epoch seconds, for ordering by "what I was last doing".
pub modified: f64,
pub lines: usize,
/// How many tokens the model was holding at the last turn: the input side
/// of the most recent assistant message's usage, which is the closest thing
/// to "what continuing this costs" and is a number the CLI recorded rather
/// than one inferred from the file.
///
/// Size and this disagree in the direction that matters. Most of a big
/// transcript is usually history from before a compaction, which the model
/// is no longer given: of the 133 MB session behind the 2026-08-29
/// incident, 99% of the bytes sat before its last compaction summary.
///
/// `None` when no assistant turn has recorded usage yet -- which is not
/// zero, and is why this is an option.
pub context_tokens: Option<u64>,
/// Size of the file, in bytes. Reported because it predicts what
/// continuing the session will cost and lines do not: these transcripts
/// embed screenshots as base64, so one line can be a megabyte. The session
/// behind the 2026-08-29 incident was 65 MB across 13,000 lines.
///
/// Shown rather than warned about: importing a large session is a choice
/// somebody is entitled to make.
pub bytes: u64,
/// Whether [`title`](Self::title) is a name somebody chose rather than
/// something read out of the conversation. Worth the reader knowing: a name
/// is a claim about what a session *is*, and a last message is only the
/// last thing that happened in it.
pub named: bool,
/// Whether a CLI is running this session right now.
///
/// The load-bearing field on this struct. Importing a session already open
/// puts a second `--resume` on one file: the conversation gets duplicated
/// into it, both copies read each other's writes as work done elsewhere,
/// and the adopted one is billed for re-reading everything -- measured on
/// 2026-08-29 at 65 MB and 154 screenshots.
pub in_use: InUse,
/// Where it lives. Not serialized: the phone chooses by id and the server
/// resolves the path, so a path never crosses the wire either direction.
@@ -104,14 +42,7 @@ pub struct Importable {
pub path: String,
}
/// Asks `transport`'s machine which Claude Code sessions it has.
///
/// One command rather than one per file: over ssh each would be its own
/// connection and handshake. `stat -c` is GNU-specific, which is the thing to
/// change first if this ever meets a BSD.
pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
// Which sessions are open right now, before the files themselves.
//
// Claude Code writes a descriptor per live session at
// `~/.claude/sessions/<pid>.json`, and records `procStart` -- the kernel's
// start time for that pid -- for the same reason `session::process` does: a
@@ -119,35 +50,17 @@ pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
// otherwise mark a session as open for as long as something else held its
// number. Checking both is what makes this a measurement.
//
// The `LIVEKNOWN` line says the directory was there to be read at all.
// Without it an old CLI that keeps no descriptors would look exactly like a
// machine with nothing running.
//
// Then two questions per file, both answered from the end of it. A rename
// if there was one, grepped over the whole file rather than its tail
// because a session can be named early and talked in for hours after. Then
// the last several things a person said -- the *last*, because the question
// this answers is "which one was I just in", and several because the final
// ones are often the CLI's own.
//
// Tool results are excluded rather than typed messages included, and the
// difference matters: a tool result is *also* a user record, so grepping
// the type alone gave a session that ended mid-tool a tail of empty records.
// But matching only a string `content` was worse -- a message carrying an
// attachment stores its text in a list, so that reading lost twenty rows
// rather than two. Excluding `tool_use_id` keeps both shapes of a real
// message and drops the one that is not.
let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#);
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
parse_listing(&transport.capture(&launch).await?)
}
/// The same listing, for one session named by id.
///
/// Importing needs everything a row holds, and used to get it by listing
/// *every* session and searching the result -- a full read of every transcript
/// on the machine, seconds of it, to answer a question about one file, paid
/// once per import in a batch. Same script, same parsing, one glob narrower.
pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> {
if !is_session_id(id) {
return Ok(None);
@@ -163,12 +76,6 @@ pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>>
.find(|candidate| candidate.id == id))
}
/// What the machine is asked, over whichever set of files `glob` names.
///
/// One script with the glob substituted rather than two that drift: a row has
/// to mean the same thing whether it came from a listing or a lookup. The glob
/// is this module's own text; the only thing that crosses from outside is the
/// id, which stays an argument and is checked by [`is_session_id`] first.
fn listing_script(glob: &str) -> String {
// `replace` rather than `format!`: this is shell, so it is full of braces,
// and every one would have to be doubled to survive a format string --
@@ -201,7 +108,6 @@ for f in {glob}; do
done
"#;
/// Rows out of what [`listing_script`] printed, with `in_use` filled in.
fn parse_listing(found: &str) -> Result<Vec<Importable>> {
let mut live = std::collections::HashSet::new();
let mut checkable = false;
@@ -225,15 +131,6 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
// addresses: `--resume` takes it, deleting globs for it, the in-flight
// registry is keyed on it, and the phone keys its list on it -- which
// turned two rows sharing an id into a crash rather than a confusion.
//
// It is a real state of the machine, not corruption: resuming from a
// different working directory makes the CLI write a second file under that
// directory's project folder with the same id. One is then usually a stub
// of a few hundred bytes.
//
// So the copy with the most in it wins, and the row's `cwd` comes from that
// same copy. Ties go to the more recent, and the *stub* is often the more
// recent, so size has to be the first key rather than the tie-break.
sessions.sort_by(|a, b| {
b.lines
.cmp(&a.lines)
@@ -242,15 +139,10 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
let mut seen = std::collections::HashSet::new();
sessions.retain(|session| seen.insert(session.id.clone()));
// Most recent first, and only that. Naming was tried as the first key and
// is a worse list: it buries what somebody was just doing under everything
// they ever named. A name still shows, as the row's title and as a word
// beside it.
sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified));
Ok(sessions)
}
/// One line of [`list`]'s output, or nothing if it is not one.
fn parse_row(line: &str) -> Option<Importable> {
let mut fields = line.splitn(6, '\t');
let modified: f64 = fields.next()?.trim().parse().ok()?;
@@ -279,17 +171,12 @@ fn parse_row(line: &str) -> Option<Importable> {
if !is_hidden(&record)
&& let Some(text) = first_line_of(&record)
{
// Kept rather than broken out of: these arrive oldest first, so the
// last to survive the filter is the most recent thing said.
said = Some(text);
}
}
Some(Importable {
id,
// Filled in by `list`, which is the only thing that knows: it takes one
// command to ask a machine, and asking per row would be one ssh
// connection each.
in_use: InUse::Unknown,
cwd: cwd.unwrap_or_default(),
// A name somebody typed outranks anything read out of the conversation,
@@ -318,9 +205,6 @@ fn context_tokens(usage: &str) -> Option<u64> {
if usage.trim().is_empty() {
return None;
}
// The leading quote matters: without it `"input_tokens"` also matches
// inside `"cache_read_input_tokens"`, and the same number is counted three
// times.
let field = |name: &str| -> u64 {
usage
.split_once(&format!("\"{name}\":"))
@@ -338,12 +222,6 @@ fn context_tokens(usage: &str) -> Option<u64> {
))
}
/// The first line of what a person typed, short enough for a list row.
///
/// None for the CLI's own plumbing. A slash command, the caveat wrapped around
/// a local command's output, and an injected reminder are all stored as
/// ordinary user records without `isMeta` -- so titling by "first user record"
/// gave a list where most rows read `<command-name>/clear</command-name>`.
fn first_line_of(record: &Value) -> Option<String> {
let text = text_of(record.get("message")?.get("content")?);
let first = text.lines().find(|line| !line.trim().is_empty())?.trim();
@@ -354,17 +232,11 @@ fn first_line_of(record: &Value) -> Option<String> {
(!trimmed.is_empty()).then_some(trimmed)
}
/// Records the transcript should not show: a subagent's private conversation,
/// and the CLI's own injected notes. The same rule the live translator applies
/// -- a sidechain is another agent talking to itself, and duplicating it would
/// show the reader two conversations interleaved as one.
fn is_hidden(record: &Value) -> bool {
record.get("isSidechain").and_then(Value::as_bool) == Some(true)
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
}
/// Whether a `tool_result` block says the call itself failed.
///
/// One reader for the field rather than one per caller: the live
/// translator (`translate.rs`) and this replay of the CLI's own file look
/// at the same block shape, and a call drawn as failed in one and as
@@ -392,12 +264,6 @@ fn text_of(content: &Value) -> String {
}
}
/// Whether a directory the machine recorded is still there.
///
/// A session's recorded cwd can outlive the directory: these files go back
/// months, and a checkout that moved leaves every session from before it
/// pointing at a path that is gone. Resuming into one fails at `cd` before the
/// CLI starts.
pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
if path.is_empty() {
return false;
@@ -411,8 +277,6 @@ pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
transport.capture(&launch).await.is_ok()
}
/// Reads the tail of one session's file, as the raw JSONL.
///
/// `tail` rather than the whole file, and as [`Launch`] arguments rather than a
/// shell string, so the path is an argument and never syntax.
///
@@ -431,15 +295,6 @@ pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
.with_context(|| format!("reading {path}"))
}
/// Claude Code's stored JSONL as this project's events.
///
/// A partial first line is expected and ignored: `tail -n` cuts at a line
/// boundary, but the *file* may have been appended to since.
///
/// `session_dir` is where images found along the way are written, the same
/// place and by the same function the live translator uses -- so a screenshot
/// looks identical whether it was watched happening or replayed afterwards.
/// Only the *reference* reaches the phone.
pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
let mut events = Vec::new();
// What the newest record that had an opinion says the session is doing.
@@ -482,14 +337,6 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
events
}
/// A message from another agent, as the CLI reports one.
///
/// Measured from a real session file (2026-08-29): the record is a `user` one
/// marked `isMeta`, and its `origin` carries `kind: "peer"`, the sending
/// session's `name`, and the message as `body`. The message content beside it
/// is the same text wrapped in a preamble written for the model rather than for
/// a person, so the body is what a reader is shown.
///
/// Shared with the live driver, which finds the same `origin` object on a
/// different record -- so this reads the object and not the record around it.
/// One function because it is one wire format: two copies would drift the first
@@ -508,27 +355,10 @@ pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
.unwrap_or("another session")
.to_string(),
text: origin.get("body").and_then(Value::as_str)?.to_string(),
// The session file has it in the right place already.
turn_start: None,
})
}
/// Whether this record means the session is working, as far as the file can
/// say.
///
/// The one thing a session file does not contain is the CLI saying "this turn
/// is over": there is no `result` record. What there is instead is why the last
/// assistant message stopped -- `tool_use` means a call is being made and more
/// is coming, anything else means the model has finished talking. Anything on
/// the user's side means the session has something to answer.
///
/// `None` is the third answer and it matters: a record that says nothing about
/// the turn leaves the status alone rather than voting for idle.
///
/// What this cannot see is a session that stopped existing mid-turn -- its
/// file's last record still says `tool_use`, so it reads as working forever.
/// Nothing in the file distinguishes that from a model thinking, and inventing
/// a timeout would replace a stale reading with a confident wrong one.
fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
use super::driver::SessionStatus;
match record.get("type").and_then(Value::as_str)? {
@@ -551,14 +381,10 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
// same voice.
if let Value::Array(blocks) = content {
for block in blocks {
// A picture the person attached to their own message rather than
// one a tool produced. Same block shape, one level up.
push_images(events, std::slice::from_ref(block), session_dir, None);
if block.get("type").and_then(Value::as_str) == Some("tool_result")
&& let Some(id) = block.get("tool_use_id").and_then(Value::as_str)
{
// Before the tool's own row, matching the live translator: a
// screenshot belongs to the call that took it.
if let Some(Value::Array(parts)) = block.get("content") {
push_images(events, parts, session_dir, Some(id));
}
@@ -584,10 +410,6 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
}
}
/// Saves every image block in `parts` and references each one. `about` is the
/// call the images came out of, or `None` for one a person attached to their
/// own message -- the same distinction the live translator makes, so replayed
/// history draws a screenshot under the call that took it.
fn push_images(
events: &mut Vec<Event>,
parts: &[Value],
@@ -638,20 +460,6 @@ fn push_assistant(events: &mut Vec<Event>, content: &Value) {
}
}
/// Removes each id it is given and prints one `<id>\t<state>` line per id.
///
/// The three states are every way removing one id can end: `deleted` if at
/// least one file went, `missing` if the glob matched nothing, `failed` if an
/// `rm` refused. "Not there" is deliberately kept apart from "it broke" --
/// only one of them is worth retrying.
///
/// Every copy of each id, not the first. The same id can name a file under two
/// project directories, and stopping at the first left the other behind, so the
/// row came back on the next listing after a delete that reported success.
/// `failed` therefore sticks once set.
///
/// Ids arrive as arguments rather than in the script text; `is_session_id` is
/// what keeps one from globbing its way out of the projects directory.
const DELETE_SCRIPT: &str = r#"
for id do
state=missing
@@ -667,8 +475,6 @@ for id do
done
"#;
/// Deletes sessions [`list`] reported, and says what happened to each.
///
/// By id, resolved on the machine against what it actually has, so the caller
/// never names a file -- the same rule importing follows, and it matters more
/// here: this one removes something.
@@ -690,9 +496,6 @@ pub async fn delete(
transport: &Transport,
ids: &[String],
) -> Result<HashMap<String, Result<(), String>>> {
// Refused here rather than on the machine: `is_session_id` is what keeps an
// id from walking out of the projects directory. It fails only itself --
// one malformed id is not a reason to leave the other five in place.
let (safe, mut outcomes): (Vec<&String>, HashMap<String, Result<(), String>>) =
ids.iter().fold(
(Vec::new(), HashMap::new()),
@@ -712,16 +515,6 @@ pub async fn delete(
return Ok(outcomes);
}
// The file name *is* the id, so the machine can find it by name. This used
// to call `list` and search its output, which is correct and costs a full
// read of every transcript on the machine -- around four seconds against a
// gigabyte of them, per delete.
//
// Every copy of each id, not the first: the same id can name a file under
// two project directories, and stopping at the first left the other behind.
//
// Each id prints its own verdict rather than the loop exiting on the first
// failure, which would leave every id after it unexplained.
let mut args = vec![
"-c".to_string(),
DELETE_SCRIPT.to_string(),
@@ -730,8 +523,6 @@ pub async fn delete(
args.extend(safe.iter().map(|id| (*id).clone()));
let launch = Launch::new("sh", args, None);
// A failure to run the script at all is the machine being unreachable,
// which is true of every id in the batch rather than of any one of them.
let reported = transport
.capture(&launch)
.await
@@ -765,22 +556,10 @@ pub async fn delete(
Ok(outcomes)
}
/// Whether an id is one of ours to put in a shell glob.
///
/// Both places that resolve an id interpolate it into
/// `$HOME/.claude/projects/*/"$1".jsonl`. That is an argument rather than
/// script text, so a shell cannot be talked into running something -- but a `/`
/// or a `..` inside it still walks the glob out of the directory the id is
/// supposed to name, and [`delete`] removes whatever it lands on.
///
/// Claude Code names each transcript with a uuid, so hex and dashes is the
/// whole alphabet. Refused rather than escaped.
fn is_session_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
}
/// 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.
@@ -793,8 +572,6 @@ pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cursor {
/// Server-side only, 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.
@@ -823,7 +600,6 @@ pub fn write_cursor(session_dir: &std::path::Path, cursor: &Cursor) {
}
}
/// 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?;
@@ -833,17 +609,6 @@ pub async fn line_count(transport: &Transport, path: &str) -> Result<usize> {
.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
@@ -854,9 +619,6 @@ pub async fn line_count(transport: &Transport, path: &str) -> Result<usize> {
/// 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<u64> {
// The same guard `delete` explains, applied to the other member of the
// set: this one only reads, but a glob that can leave the directory is
// worth closing in both places rather than in the dangerous one only.
if !is_session_id(session_id) {
return None;
}
@@ -883,8 +645,6 @@ done
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(
transport: &Transport,
path: &str,
@@ -907,17 +667,6 @@ pub async fn replay_after(
mod tests {
use super::*;
/// One session id, two files, one row.
///
/// Resuming a session from a different working directory makes the CLI
/// write a second file with the same id under that directory's project
/// folder, so this is an ordinary state of a machine rather than a
/// corrupt one. Everything downstream addresses a session by id, and
/// the phone keys its list on it, so two rows sharing one was a crash.
///
/// The stub is deliberately the *newer* of the two here, because that
/// is how the real case looked: ordering by recency alone picks the
/// near-empty copy and describes the session by the wrong cwd.
#[test]
fn a_session_recorded_under_two_projects_is_offered_once() {
let id = "3114dee1-2f95-4de0-9c04-3d6fcc594afe";
@@ -933,17 +682,9 @@ mod tests {
assert_eq!(rows.len(), 1, "one id is one row: {rows:#?}");
assert_eq!(rows[0].lines, 412, "the conversation, not the stub");
// The cwd has to come from the copy that was kept, because that is
// the directory `--resume` will find those 412 lines under.
assert_eq!(rows[0].cwd, "/home/bob/repos/survey");
}
/// The guard on the only thing this module ever puts in a glob.
///
/// Worth a test of its own because what it protects is a `rm`: `delete`
/// resolves an id straight to `$HOME/.claude/projects/*/"$1".jsonl`, so
/// an id that can contain a slash or a `..` is an id that can name a
/// file outside the directory and have it removed.
#[test]
fn a_session_id_cannot_walk_out_of_the_projects_directory() {
assert!(is_session_id("5ecf21da-d53f-4a11-9c0d-000000000100"));
@@ -955,19 +696,10 @@ mod tests {
assert!(!is_session_id("a.b"));
assert!(!is_session_id("a*"));
assert!(!is_session_id("a b"));
// Empty would glob to the directory itself, and a long one is not a
// uuid whatever else it is.
assert!(!is_session_id(""));
assert!(!is_session_id(&"a".repeat(65)));
}
/// One batch, one invocation, one verdict per id -- including for the
/// two cases a single-id delete never had to keep apart from the rest:
/// an id recorded under two project directories (both copies must go,
/// and it still reports once) and an id that is not there at all.
///
/// Runs the real script against a temporary `$HOME`, because what is
/// being checked is the shell, not the Rust around it.
#[test]
fn a_batch_deletes_every_copy_and_reports_each_id_once() {
let home = tempfile::tempdir().expect("tempdir");
@@ -992,21 +724,17 @@ mod tests {
String::from_utf8_lossy(&output.stdout),
format!("{one}\tdeleted\n{twice}\tdeleted\n{absent}\tmissing\n"),
);
// The second copy is the one a per-id delete used to leave behind.
assert!(!projects.join("b").join(format!("{twice}.jsonl")).exists());
assert!(!projects.join("a").join(format!("{twice}.jsonl")).exists());
assert!(!projects.join("a").join(format!("{one}.jsonl")).exists());
}
/// A 1x1 PNG, base64 -- the smallest thing with a real header.
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk\
YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
#[test]
fn replayed_screenshots_are_saved_and_referenced() {
let dir = tempfile::tempdir().expect("tempdir");
// The shape a screenshot actually has in these files: an image
// part inside a tool result, beside its text.
let line = format!(
r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_1","content":[{{"type":"text","text":"took a screenshot"}},{{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{PNG}"}}}}]}}]}}}}"#
);
@@ -1016,12 +744,8 @@ mod tests {
panic!("a replayed screenshot must become an image event: {events:?}");
};
assert!(image.ends_with(".png"));
// On disk, where the files route serves it from -- the phone
// fetches it only when something draws it.
assert!(dir.path().join("files").join(image).is_file());
// And it comes before the tool row it belongs to, so it does not
// read as belonging to whatever happened next.
assert!(
matches!(events.get(1), Some(Event::ToolEnd { .. })),
"{events:?}"
@@ -1030,19 +754,12 @@ mod tests {
#[test]
fn context_tokens_add_the_input_side_only() {
// The shape the CLI records, as captured from a real transcript.
let usage = r#""usage":{"input_tokens":2,"cache_creation_input_tokens":703,"cache_read_input_tokens":142228,"output_tokens":587,"output_tokens_details":{"thinking_tokens":0"#;
// 2 + 703 + 142228. Output is not context to carry forward, so it
// is not in the total; if it were, this would read 143520.
assert_eq!(context_tokens(usage), Some(142_933));
// The leading quote is load-bearing: without it "input_tokens"
// matches inside both cache field names and the prompt figure gets
// counted three times.
let only_cache = r#""usage":{"cache_read_input_tokens":100,"output_tokens":9"#;
assert_eq!(context_tokens(only_cache), Some(100));
// No assistant turn yet is not a context of zero.
assert_eq!(context_tokens(""), None);
assert_eq!(context_tokens(" "), None);
}
@@ -1052,8 +769,6 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"plain output"}]}}"#;
let events = events_from(line, dir.path());
// The result, and the turn state it implies: a tool has answered,
// so the model is about to be asked again.
assert_eq!(events.len(), 2, "{events:?}");
assert_eq!(
events[1],
@@ -1061,14 +776,11 @@ mod tests {
state: super::super::driver::SessionStatus::Running
}
);
// No stray directory for a session that never produced one.
assert!(!dir.path().join("files").exists());
}
#[test]
fn a_message_from_another_agent_is_kept_and_named() {
// The real shape, from a session file: the CLI marks these meta,
// and everything a reader needs is in `origin`.
let dir = tempfile::tempdir().expect("tempdir");
let line = r#"{"type":"user","isMeta":true,"origin":{"kind":"peer","from":"uds:/run/user/1000/cc-socks/605.sock","verifiedPeerPid":605,"name":"dev-updater-f5","fromMode":"prompting","body":"Pull before you touch AGENTS.md."},"message":{"role":"user","content":"Another Claude session sent a message:\n<cross-session-message from-name=\"dev-updater-f5\">\nPull before you touch AGENTS.md.\n</cross-session-message>"}}"#;
let events = events_from(line, dir.path());
@@ -1076,13 +788,11 @@ mod tests {
events[0],
Event::PeerMessage {
from: "dev-updater-f5".to_string(),
// The body, not the wrapper the model is given.
text: "Pull before you touch AGENTS.md.".to_string(),
turn_start: None,
},
"{events:?}"
);
// And it counts as the session having been given something.
assert_eq!(
events[1],
Event::Status {
@@ -1119,9 +829,6 @@ mod tests {
"a turn that has finished talking is over"
);
// A subagent's own messages are not the session's turn, and a
// record with no stop reason is not an answer -- neither may
// overrule what the conversation itself last said.
let sidechain = r#"{"type":"assistant","isSidechain":true,"message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"sub"}]}}"#;
let unknown = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"?"}]}}"#;
assert_eq!(
@@ -1129,7 +836,6 @@ mod tests {
Some(SessionStatus::Running)
);
// And nothing at all to go on says nothing, rather than idle.
assert_eq!(state(r#"{"type":"summary","summary":"x"}"#), None);
}
}
+1 -137
View File
@@ -1,33 +1,3 @@
//! The llama.cpp driver: a `llama-server` process per session, spoken to over
//! its OpenAI-compatible HTTP API and translated into the common event model.
//!
//! Two things make this shaped differently from the Claude driver.
//!
//! **It is spawned but not spoken to over stdio.** The process is started
//! through the same [`Transport`] as any other and then reached over HTTP on a
//! loopback port. That is the second half of what a transport is -- "run this"
//! plus "reach this port" -- and it is what lets a session run on another
//! machine: [`Transport::reserve_port`] hands back a port the server binds
//! *there* and one that reaches it *here*, and the ssh connection carrying the
//! command carries the tunnel between them. The far `llama-server` binds
//! loopback only, so a model is never served to that machine's network.
//!
//! **The model file is the far machine's, not this one's.** A remote setup
//! names its own models directory (`SshConfig::models_dir`, defaulting to where
//! this backend keeps its downloads), and the file is looked for *there* -- so
//! a session naming a model that machine does not have says so, instead of
//! starting a server that will never load one. Downloading to another machine
//! is not built; the model gets there however anything else does.
//!
//! **The server is stateless between requests**, so the whole conversation goes
//! with every one. It is rebuilt from the session's transcript rather than kept
//! in this struct, which is not tidiness: a copy in driver memory is invisible
//! to a second device and gone when this process restarts.
//!
//! That leaves the Claude driver as the odd one out rather than this one -- the
//! CLI's own memory of a conversation is a cache in front of the same
//! transcript. Resolve any inconsistency in this direction.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -46,7 +16,6 @@ use crate::config::{ProviderConfig, SessionConfig};
/// is generous -- the failure it exists for is a server that will never answer.
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// One turn in the conversation this driver keeps on the server's behalf.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Message {
role: String,
@@ -55,28 +24,16 @@ struct Message {
pub struct LlamaDriver {
sink: EventSink,
/// Where this session's own llama-server answers.
endpoint: String,
/// Where the conversation is read back from, one line per event.
transcript: PathBuf,
/// Sampling settings chosen at spawn, sent with every request.
sampling: serde_json::Map<String, serde_json::Value>,
/// Set by [`Driver::interrupt`]; the streaming loop checks it between
/// chunks and stops, leaving what was generated in the transcript.
cancel: Arc<AtomicBool>,
/// Where this session's process record lives, so [`Driver::stop`] can find
/// the server it has to end.
session_dir: PathBuf,
}
impl LlamaDriver {
/// Takes charge of this session's `llama-server`: the one already loaded if
/// there is one, otherwise a new one.
///
/// One entry point, for the reason `ClaudeDriver::launch` gives, expensive
/// in a different currency: two servers holding the same model is twice the
/// memory, and the second would bind a different port while the phone kept
/// talking to the first.
#[allow(clippy::too_many_arguments)]
pub fn launch(
meta: &SessionConfig,
@@ -86,9 +43,6 @@ impl LlamaDriver {
transcript: &Path,
session_dir: &Path,
sink: EventSink,
// llama.cpp has no notion of a Task call, so this is accepted only
// to keep one shape across every driver's launch -- see
// `SUBAGENTS.md`'s "Server layout".
_subagents: Arc<super::subagent::Subagents>,
) -> Result<Self> {
let model = meta.model.as_deref().context(
@@ -120,17 +74,12 @@ impl LlamaDriver {
));
}
// Where it listens on its own machine, and where that is reached
// from here -- the same number when that machine is this one.
let forward = transport
.reserve_port()
.context("finding a port for llama-server")?;
let mut args: Vec<String> = vec![
"-m".into(),
path.clone(),
// Loopback there, whichever machine there is: what reaches it
// from outside that machine is the ssh tunnel and nothing
// else.
"--host".into(),
"127.0.0.1".into(),
"--port".into(),
@@ -152,10 +101,6 @@ impl LlamaDriver {
let program = provider.program();
let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward);
// Its output goes to files, not pipes. Not only so the process can
// outlive this server: nothing ever read those pipes, so a chatty
// llama-server filled the 64 KB buffer and blocked mid-load with no sign
// of why.
let child = transport.spawn(
&launch,
Streams::Detached {
@@ -203,8 +148,6 @@ impl LlamaDriver {
))
}
/// The driver for a `llama-server` at `endpoint`, however it got there.
///
/// Shared by starting one and adopting one, because everything after "there
/// is a server at this address" is identical -- including waiting for it to
/// answer, which an adopted one still owes: a recorded pid says a process
@@ -217,9 +160,6 @@ impl LlamaDriver {
session_dir: &Path,
sink: EventSink,
) -> Self {
// Loading is slow enough to be worth saying so: the session shows as
// running until the model is in memory, rather than looking ready and
// refusing the first message.
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
});
@@ -273,9 +213,6 @@ impl LlamaDriver {
}
}
/// Where llama-server's own output goes. One file for both streams: it is
/// diagnostics nobody parses, and interleaving them is how it reads in a
/// terminal anyway.
const SERVER_LOG: &str = "llama-server.log";
/// How often a loaded server is checked for still being there. Slower than the
@@ -283,8 +220,6 @@ const SERVER_LOG: &str = "llama-server.log";
/// to notice a server that has gone.
const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
/// An owner-only log opened for appending, so the two streams pointed at
/// it do not overwrite each other and a reattach keeps what came before.
fn log_file(path: &Path) -> Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
@@ -295,21 +230,12 @@ fn log_file(path: &Path) -> Result<std::fs::File> {
.with_context(|| format!("opening {}", path.display()))
}
/// Reports the server going away, for as long as the session is there to report
/// it to.
///
/// Polled rather than waited on, for the reason the Claude driver gives: after a
/// restart this server is not the process's parent, so liveness has to be a
/// question asked of the record -- and asking it two different ways is how the
/// two answers come to disagree.
fn watch(session_dir: PathBuf, sink: EventSink) {
std::thread::spawn(move || {
loop {
std::thread::sleep(WATCH_INTERVAL);
match process::recorded(&session_dir) {
Some((_, process::Liveness::Alive)) => {}
// Nothing recorded means the session was stopped or deleted
// deliberately, and whoever did that has already said so.
None => return,
Some((_, process::Liveness::Dead)) => {
let _ = sink.send(Event::Error {
@@ -348,8 +274,6 @@ impl Driver for LlamaDriver {
let cancel = Arc::clone(&self.cancel);
cancel.store(false, Ordering::Relaxed);
// Its own thread: the request blocks for as long as the model takes to
// generate, which is the whole point of streaming it.
std::thread::spawn(move || {
// Nothing is ever held back here -- there is no queue to wait in --
// so the message is taken the moment it arrives. Said anyway,
@@ -372,9 +296,6 @@ impl Driver for LlamaDriver {
role: "user".into(),
content: text,
});
// The reply is not stored: the deltas below are the durable record,
// so the next turn reads back exactly what the phone was shown --
// including a partial one that was interrupted.
if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) {
let _ = sink.send(Event::Error {
message: format!("{err:#}"),
@@ -386,16 +307,12 @@ impl Driver for LlamaDriver {
});
}
fn answer_question(&self, _id: &str, _answers: &[String]) {
// Nothing here asks questions: this driver has no tools.
}
fn answer_question(&self, _id: &str, _answers: &[String]) {}
fn interrupt(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
// Nothing to forward: this process has no notion of what the conversation
// is called, and the rename has already happened where the name lives.
fn set_title(&self, _title: &str) {}
fn set_permission_mode(&self, _mode: &str) {
@@ -430,15 +347,9 @@ impl Driver for LlamaDriver {
}
fn clear(&self) {
// All of it. `conversation` folds from the last of these, so recording
// the marker *is* the reset -- there is no driver state to keep in step
// with it, which is the same property that makes a second device see the
// same conversation this one does.
let _ = self.sink.send(Event::Cleared);
}
/// Stops generating and leaves the server loaded.
///
/// Worth being deliberate about, because the cost points the other way from
/// the Claude driver's: a `llama-server` holds its whole model in memory, so
/// a leaked one is gigabytes nobody is using. It is left anyway, because the
@@ -458,12 +369,6 @@ impl Driver for LlamaDriver {
}
}
/// The conversation so far, folded out of the transcript.
///
/// Consecutive `AssistantText` deltas are one assistant turn, closed by the next
/// user message -- which is also what makes an interrupted reply come back as
/// the partial text the phone actually saw.
///
/// This must stay a pure function of the transcript and must never re-render
/// earlier turns. llama.cpp caches the prompt prefix, so a growing conversation
/// reprocesses almost nothing -- but only while every turn is byte-identical to
@@ -475,9 +380,6 @@ fn conversation(path: &Path) -> Vec<Message> {
};
let mut messages: Vec<Message> = Vec::new();
let mut pending = String::new();
// Everything before the last clear is still in the transcript and is
// deliberately not in the conversation. Folding from zero would put it back,
// which is the whole of what clearing had to undo.
let events = match events.iter().rposition(|e| e.event == Event::Cleared) {
Some(at) => &events[at + 1..],
None => &events[..],
@@ -509,8 +411,6 @@ fn conversation(path: &Path) -> Vec<Message> {
messages
}
/// Where a model key resolves to on disk, refusing anything that climbs
/// out of the models directory -- the key arrives from a phone.
fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
let mut path = models_dir.to_path_buf();
for part in key.split('/') {
@@ -525,29 +425,16 @@ fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
Ok(path)
}
/// The model file's path **on the machine that will serve it**, confirmed to be
/// there.
///
/// One function rather than a local check and hope for the other case: the same
/// question has to be asked of two filesystems. The remote answer is measured
/// for the reason the local one is -- a missing file otherwise becomes a
/// `llama-server` that starts, fails to load, and reports as a session that
/// never became ready, which reads as the machine being slow.
///
/// One blocking round trip on a remote spawn, which is what the spawn is
/// already paying to start ssh. The alternative is a path built here from a `~`
/// this machine cannot expand.
fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<String> {
let Transport::Ssh { name, .. } = transport else {
return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned());
};
// The same directory the spawn screen listed for this machine, and one
// function for the same reason: a list from one place and a load from
// another is a model that appears and then fails.
let dir = crate::models::dir_on(transport, models_dir);
// Checked here rather than in the script: `..` in a key would walk out of
// the models directory on a machine this server can start processes on,
// and the phone is where the key comes from.
for part in key.split('/') {
if part.is_empty() || part == "." || part == ".." {
bail!("\"{key}\" is not a model key this can resolve");
@@ -580,8 +467,6 @@ fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<Strin
}
}
/// Polls until the server says it is ready, or gives up.
///
/// Watches the process as well as the port, because the two failures need
/// different words and one of them is common: a model that will not load,
/// a port already taken on the far machine, a `llama-server` too old for
@@ -616,8 +501,6 @@ fn wait_until_ready(endpoint: &str, session_dir: &Path) -> Result<()> {
}
}
/// The end of `llama-server`'s own log, for a failure message.
///
/// Its account of what went wrong is the useful half -- "failed to load
/// model", "bind: Address already in use" -- and on a remote session it
/// is the only half, since nobody reading the phone can open a file on
@@ -636,7 +519,6 @@ fn log_tail(session_dir: &Path) -> String {
)
}
/// How much of that log to carry into a message somebody reads on a phone.
const LOG_TAIL_LINES: usize = 6;
/// One streamed completion: posts the conversation, emits each delta as it
@@ -666,16 +548,12 @@ fn generate(
let reader = std::io::BufReader::new(response.body_mut().as_reader());
let mut tokens = 0u64;
// The prompt side only, which is what the model is holding -- the same
// definition the other dialects report, so one word on the phone means one
// thing whichever kind of session it is.
let mut context = None;
for line in std::io::BufRead::lines(reader) {
if cancel.load(Ordering::Relaxed) {
break;
}
let line = line.context("reading the generation stream")?;
// Server-sent events: the payload lines are the ones that matter.
let Some(payload) = line.strip_prefix("data: ") else {
continue;
};
@@ -723,8 +601,6 @@ mod tests {
use super::*;
use crate::session::transcript::Transcript;
/// Writes a transcript the way the pump does, so the fold is tested against
/// the real file format rather than a hand-built vector.
fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
@@ -777,10 +653,6 @@ mod tests {
}
#[test]
/// The interrupted case, which decides what a resumed conversation is built
/// from: whatever the phone was shown. The deltas that arrived before the
/// stop are in the transcript, so they are in the prompt -- the model is
/// never told it said something the user did not see.
fn an_interrupted_reply_stays_in_the_conversation() {
let (_dir, path) = transcript_with(&[
Event::UserMessage {
@@ -801,9 +673,6 @@ mod tests {
}
#[test]
/// Events this driver does not produce must not disturb the fold: a
/// transcript can carry errors and status changes from a session that
/// was, say, relaunched.
fn other_events_are_not_part_of_the_conversation() {
let (_dir, path) = transcript_with(&[
Event::Status {
@@ -832,9 +701,6 @@ mod tests {
}
#[test]
/// Clearing decides what the *model* is given, not just what the phone
/// draws. Everything above the marker stays in the transcript and none of it
/// is sent.
fn the_conversation_starts_after_the_last_clear() {
let (_dir, path) = transcript_with(&[
Event::UserMessage {
@@ -862,8 +728,6 @@ mod tests {
}
#[test]
/// The *last* one, so clearing twice does not resurrect what the
/// first clear dropped.
fn only_the_newest_clear_counts() {
let (_dir, path) = transcript_with(&[
Event::UserMessage {
File diff suppressed because it is too large. Load diff
-46
View File
@@ -1,26 +1,9 @@
//! What is being done to a machine's Claude Code sessions right now.
//!
//! Importing and deleting used to be whatever the phone was in the middle of:
//! the request was the work, so leaving the screen cancelled it and coming back
//! showed no sign it had ever started. Sessions half-imported that way are the
//! expensive kind of missing -- the row is back in the list looking untouched,
//! and taking it again is the second `--resume` the import path exists to
//! prevent.
//!
//! So the work runs here, on the server, and this is the record of it. The
//! phone reads that record two ways and needs both: every row of the importable
//! listing carries what is happening to it, which is what a phone that was
//! asleep has to go on; and [`Registry::subscribe`] is the live stream, which is
//! what makes a screen change by itself. A broadcast has no memory, and a
//! listing is only true when it was fetched.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::Serialize;
use tokio::sync::broadcast;
/// What is being done to an importable session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Operation {
@@ -29,8 +12,6 @@ pub enum Operation {
}
impl Operation {
/// The word a row shows while this runs. Fixed here rather than in the app
/// so the two ends cannot disagree about what a state is called.
pub fn label(self) -> &'static str {
match self {
Self::Importing => "importing",
@@ -39,11 +20,6 @@ impl Operation {
}
}
/// One change to what is in flight, as it goes out on the stream.
///
/// The three states are every way an operation ends, including the two easy to
/// leave out: still running, finished, and failed. There is deliberately no
/// "unknown" -- this is the server's own work, so not knowing would be a bug.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum Change {
@@ -64,8 +40,6 @@ pub enum Change {
}
impl Change {
/// Which machine this is about, so a stream scoped to one can drop the rest.
/// Every variant carries it; matching here keeps that fact in one place.
pub fn setup(&self) -> &str {
match self {
Self::Started { setup, .. }
@@ -75,7 +49,6 @@ impl Change {
}
}
/// Everything in flight, and the last failure against each session.
#[derive(Debug)]
pub struct Registry {
running: Mutex<HashMap<(String, String), Operation>>,
@@ -92,16 +65,12 @@ impl Default for Registry {
Self {
running: Mutex::new(HashMap::new()),
failures: Mutex::new(HashMap::new()),
// Enough that a phone watching one screen cannot lag behind a batch
// of any size somebody would start by hand.
changes: broadcast::channel(256).0,
}
}
}
impl Registry {
/// Marks an operation as running and announces it.
///
/// The returned guard is how it stops being marked: settle it with
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
/// a failure. Dropping without settling means the task was cancelled or
@@ -122,20 +91,16 @@ impl Registry {
}
}
/// What is happening to this session, if anything is.
pub fn running(&self, setup: &str, session: &str) -> Option<Operation> {
let key = (setup.to_string(), session.to_string());
self.running.lock().unwrap().get(&key).copied()
}
/// How the last operation on this session failed, if it did.
pub fn failure(&self, setup: &str, session: &str) -> Option<String> {
let key = (setup.to_string(), session.to_string());
self.failures.lock().unwrap().get(&key).cloned()
}
/// Forgets failures against sessions the machine no longer has. Called from
/// the listing, which is the only place that knows what is still there.
pub fn prune(&self, setup: &str, present: &[String]) {
self.failures
.lock()
@@ -145,14 +110,11 @@ impl Registry {
});
}
/// Every change as it happens. See the module note on why this is not the
/// only way the phone finds out.
pub fn subscribe(&self) -> broadcast::Receiver<Change> {
self.changes.subscribe()
}
}
/// An operation that is running, and its way back out of the registry.
pub struct InFlight {
registry: Arc<Registry>,
key: (String, String),
@@ -219,8 +181,6 @@ mod tests {
assert!(matches!(changes.try_recv(), Ok(Change::Finished { .. })));
}
/// A failure outlives the operation, because the phone that needs it may not
/// have been listening when it happened.
#[test]
fn a_failure_is_kept_until_something_replaces_or_prunes_it() {
let registry = Arc::new(Registry::default());
@@ -234,11 +194,9 @@ mod tests {
Some("no such session")
);
// Still on the machine, so the failure is still about something.
registry.prune("local", &["abc".to_string()]);
assert!(registry.failure("local", "abc").is_some());
// Another machine's listing says nothing about this one's.
registry.prune("other", &[]);
assert!(registry.failure("local", "abc").is_some());
@@ -246,8 +204,6 @@ mod tests {
assert!(registry.failure("local", "abc").is_none());
}
/// Trying again clears the last failure, so a row cannot show an error from
/// before the attempt somebody is currently watching.
#[test]
fn starting_again_clears_the_previous_failure() {
let registry = Arc::new(Registry::default());
@@ -260,8 +216,6 @@ mod tests {
second.succeeded();
}
/// A task that is cancelled or panics must not leave a row saying something
/// is still happening to it.
#[test]
fn dropping_an_unsettled_operation_reports_a_failure() {
let registry = Arc::new(Registry::default());
-97
View File
@@ -1,28 +1,3 @@
//! What a session's process is, and how far this server has read it --
//! written down so a *later* run of this server can find the same process
//! rather than start a second one.
//!
//! Stopping the backend must not kill a turn that is in flight, so session
//! processes are left running and adopted again on the way back up. That only
//! works if "is this still mine?" has an answer, which is what this module is.
//!
//! **A pid is not an identity.** Pids are reused, so adopting one by number
//! alone eventually means treating a stranger's process as a session -- never
//! resuming the real conversation, and signalling something unrelated when the
//! session is deleted. The kernel's start time for that pid is recorded beside
//! it; the pair is unique for as long as the machine has been up.
//!
//! **How to reach it again belongs here too**, in the same record and the same
//! write, because it answers the other half of the same question. Splitting
//! them would be two files that can disagree about one process. What it takes
//! differs by driver, so it is a typed [`Detail`] rather than a union of every
//! driver's fields.
//!
//! The record is rewritten in place as reading advances. A crash during that
//! write leaves a record that does not parse, which is read as "no live
//! process" -- so the failure is the old behaviour rather than a wrong
//! adoption.
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
@@ -31,34 +6,21 @@ use serde::{Deserialize, Serialize};
const RECORD_FILE: &str = "process.json";
/// A process this server started and expects to outlive it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Record {
pub pid: u32,
/// The kernel's start time for `pid`, in clock ticks since boot. See the
/// module comment: this is what makes the pid an identity.
pub started: u64,
/// What the driver needs in order to pick this process back up.
#[serde(flatten)]
pub detail: Detail,
}
/// How a reattaching driver reaches a process it did not start.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Detail {
/// Spoken to over stdio, which outlives the server as files in the session
/// directory. `stdout_read` is how many bytes of the stdout log have
/// already become events: everything after it is what a reattaching server
/// owes the conversation.
Stdio { stdout_read: u64 },
/// Spoken to over HTTP on a loopback port, which is all it takes to find
/// it again -- there is no stream to be partway through.
Http { port: u16 },
}
/// Whether a recorded process is still there.
///
/// Three answers rather than a boolean, because "I could not find out" is a
/// real one and is not the same as "no". Treating it as "no" is what would
/// start a second process against a conversation that already has one.
@@ -83,7 +45,6 @@ impl Record {
pub fn liveness(&self) -> Liveness {
match stat_of(self.pid) {
// A different start time is a reused pid, so definitely not ours.
Ok(Some(stat)) if stat.started == self.started => {
if stat.exited {
Liveness::Dead
@@ -122,20 +83,6 @@ pub fn live(session_dir: &Path) -> Option<Record> {
}
}
/// Writes `record` where [`live`] will find it, atomically -- to a neighbouring
/// file, renamed over the real name, so a reader sees either the whole old
/// record or the whole new one.
///
/// Writing in place would not be, and the consequence is severe rather than
/// untidy. `fs::write` truncates before it fills, so a crash inside that window
/// leaves no readable record -- and a missing record reads as "nothing is
/// running", which is the single answer that makes the next launch start a
/// *second* process against a conversation that already has one. The window is
/// not rare: this runs on every read that makes progress, so many times a
/// second while a turn is producing output.
///
/// Errors are logged rather than returned: this runs on the reading path, and a
/// session that cannot save its position is still worth having.
pub fn write(session_dir: &Path, record: &Record) {
let path = path(session_dir);
let text = match serde_json::to_string(record) {
@@ -145,14 +92,11 @@ pub fn write(session_dir: &Path, record: &Record) {
return;
}
};
// Beside the real file so the rename stays within one filesystem, which is
// what makes it atomic.
let temp = path.with_extension("json.new");
let written = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
// Owner-only, like everything else in a session directory.
.mode(0o600)
.open(&temp)
.and_then(|mut file| {
@@ -176,8 +120,6 @@ pub fn size_of(path: &Path) -> u64 {
std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
}
/// Forgets the recorded process -- for one confirmed dead, or a session
/// being deleted. The path out for [`write`].
pub fn clear(session_dir: &Path) {
let path = path(session_dir);
if let Err(err) = std::fs::remove_file(&path)
@@ -222,8 +164,6 @@ pub fn stop(record: &Record, grace: std::time::Duration) {
/// One deadline for all of them rather than one each: they were signalled
/// together, so waiting is bounded by the grace period however many there are.
pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
/// How often to look. Short enough that a process that goes at once costs
/// nothing noticeable, and long enough not to spin.
const LOOK: std::time::Duration = std::time::Duration::from_millis(20);
let deadline = std::time::Instant::now() + grace;
@@ -260,14 +200,8 @@ fn signal(pid: u32, signal: libc::c_int) {
}
}
/// What `/proc` says about a pid.
struct Stat {
/// The kernel's start time in clock ticks since boot -- see
/// [`Record::started`].
started: u64,
/// State `Z`: the process has ended, and the kernel is keeping its entry
/// only until somebody collects the exit status.
///
/// Read rather than ignored, because that entry has the same pid *and* the
/// same start time, so a finished process goes on answering "still there"
/// for as long as nothing reaps it -- which makes `Exited` unsayable: the
@@ -276,31 +210,15 @@ struct Stat {
exited: bool,
}
/// The kernel's start time for `pid`, in clock ticks since boot.
///
/// Field 22 of `/proc/<pid>/stat`, counted from the closing parenthesis of
/// field 2 rather than from the start of the line: a process's name is field 2,
/// it is wrapped in parentheses, and it may itself contain spaces and
/// parentheses. Splitting the whole line on whitespace reads the wrong field
/// for anything with a space in its name.
///
/// Three outcomes, and they are not the same: `Ok(None)` is "no such process",
/// `Err` is "could not find out". Collapsing the second into the first is what
/// would let a machine without a readable `/proc` look like a machine with
/// nothing running on it. Linux-specific, like `import`'s use of GNU `stat`.
fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => stat,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
// A `/proc` entry that exists but does not have the shape this reads is not
// a process that has gone away; it is a reading this code cannot make.
let unreadable =
|| std::io::Error::new(std::io::ErrorKind::InvalidData, "unreadable /proc stat");
let after_name = stat.rsplit_once(')').ok_or_else(unreadable)?.1;
// Field 3 is the first after the name, so the state is the first here and
// field 22 is the 20th.
let mut fields = after_name.split_whitespace();
let exited = fields.next().ok_or_else(unreadable)? == "Z";
let started = fields
@@ -311,9 +229,6 @@ fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
Ok(Some(Stat { started, exited }))
}
/// Reads `path` from `from`, returning what is there and where reading reached.
/// A file truncated or replaced under us reads from the start, since the offset
/// no longer means anything in it.
pub fn read_from(path: &Path, from: u64) -> Result<(Vec<u8>, u64)> {
use std::io::{Read, Seek, SeekFrom};
let mut file = match std::fs::File::open(path) {
@@ -345,8 +260,6 @@ mod tests {
.expect("this process has a start time");
assert_eq!(mine.liveness(), Liveness::Alive);
// The same pid with a different start time is a different process --
// which is the whole reason the start time is recorded.
let recycled = Record {
started: mine.started + 1,
..mine.clone()
@@ -362,12 +275,10 @@ mod tests {
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record.clone()));
// A shorter value must not leave a readable tail of the longer one.
record.detail = Detail::Stdio { stdout_read: 1 };
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record.clone()));
// And the other shape round trips through the same file.
record.detail = Detail::Http { port: 8080 };
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record));
@@ -381,8 +292,6 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let mut record =
Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 }).expect("start time");
// Rewritten the way the reader rewrites it: constantly, as the position
// advances. Each one must land whole.
for read in [1u64, 4096, 2, 999_999] {
record.detail = Detail::Stdio { stdout_read: read };
write(dir.path(), &record);
@@ -392,8 +301,6 @@ mod tests {
"after offset {read}"
);
}
// The rename is what makes it atomic; a leftover neighbour would mean it
// had not happened.
let stray: Vec<_> = std::fs::read_dir(dir.path())
.expect("read dir")
.filter_map(Result::ok)
@@ -408,7 +315,6 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(live(dir.path()), None);
// Pid 0 is never a process we started.
write(
dir.path(),
&Record {
@@ -433,14 +339,11 @@ mod tests {
assert_eq!(bytes, b"world");
assert_eq!(read, 11);
// An offset past the end means the file was replaced, so the offset
// describes a file that no longer exists.
std::fs::write(&path, b"new").expect("truncate");
let (bytes, read) = read_from(&path, 11).expect("read");
assert_eq!(bytes, b"new");
assert_eq!(read, 3);
// A missing file is not an error: the process has said nothing.
let (bytes, read) = read_from(&dir.path().join("nope"), 7).expect("read");
assert!(bytes.is_empty());
assert_eq!(read, 7);
-60
View File
@@ -1,16 +1,3 @@
//! A session's subagents -- see `SUBAGENTS.md`.
//!
//! **A subagent is a second transcript owned by a session, in the same event
//! model, with no process and no controls.** It shares the transcript file
//! format, the paging routes, and the SSE stream with a session by
//! addressing, not by copying: `Transcript`, `read_window` and `catch_up`
//! work on a subagent's file unchanged.
//!
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
//! where `<id>` is the Task tool_use id that started it -- unique, stable
//! across a backend restart, and already the key the parent side uses. Only
//! ids matching [`is_subagent_id`] are ever turned into a path.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
@@ -23,9 +10,6 @@ use tokio::sync::broadcast;
use super::driver::{Event, SessionStatus};
use super::transcript::{SeqEvent, Transcript};
/// Fan-out buffer for one subagent's SSE subscribers. Smaller than a
/// session's: a subagent's whole conversation is usually a handful of tool
/// calls, not an hours-long session.
const EVENT_BUFFER: usize = 64;
/// Whether `id` is safe to become a path segment under a session's
@@ -52,7 +36,6 @@ struct Meta {
created: f64,
}
/// One row of `GET /sessions/{id}/subagents`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubagentInfo {
@@ -63,8 +46,6 @@ pub struct SubagentInfo {
pub last_activity: f64,
}
/// One subagent: its own transcript and broadcast, same shape as a
/// session's but with no driver behind it.
pub struct Subagent {
dir: PathBuf,
transcript: Mutex<Transcript>,
@@ -88,10 +69,6 @@ impl Subagent {
self.events.subscribe()
}
/// Whether this subagent's last recorded status is not `Exited` --
/// what decides whether a further child line reopens it (see
/// `Subagents::reopen`) rather than continuing straight through. See
/// `SUBAGENTS.md`'s lifecycle.
pub fn is_open(&self) -> bool {
*self.status.lock().unwrap() != SessionStatus::Exited
}
@@ -103,8 +80,6 @@ impl Subagent {
if let Event::Status { state } = &entry.event {
*self.status.lock().unwrap() = *state;
}
// No subscribers is fine; the transcript already has it,
// same as a session's pump.
let _ = self.events.send(entry);
}
Err(err) => tracing::error!("subagent transcript append failed: {err:#}"),
@@ -112,16 +87,7 @@ impl Subagent {
}
}
/// Every subagent one session has started, keyed by the Task tool_use id
/// that names it.
///
/// Lives beside a session's driver rather than inside it: a claude driver
/// holds an `Arc` to this and routes child lines into it; echo uses it for
/// its `/subagent` rig; llama ignores it, since it has no notion of a Task
/// call. One instance per live session, built at launch and handed to
/// whichever driver replaces it across a stop/start.
pub struct Subagents {
/// The session's own directory; subagents live under `<dir>/subagents`.
dir: PathBuf,
live: Mutex<HashMap<String, Arc<Subagent>>>,
}
@@ -188,10 +154,6 @@ impl Subagents {
)?;
}
}
// A freshly created subagent is running by construction (its only
// lines so far are `Status::Running` and maybe its prompt); a
// reopened one takes whatever the file last said, since this
// `Transcript` has not been appended to yet in this process.
let status = if existed {
transcript.last_status().unwrap_or(SessionStatus::Running)
} else {
@@ -206,10 +168,6 @@ impl Subagents {
}))
}
/// Starts a subagent unless one is already known by this id -- see
/// `SUBAGENTS.md`'s lifecycle: created at the Task call or at the first
/// child line, whichever comes first, and never twice. A bad id is
/// refused rather than turned into a path.
pub fn start(&self, id: &str, title: &str, prompt: Option<&str>) {
if !is_subagent_id(id) {
tracing::debug!("refusing to start a subagent with a bad id {id:?}");
@@ -241,8 +199,6 @@ impl Subagents {
if !self.subagents_dir().join(id).join("meta.json").is_file() {
return None;
}
// Title and prompt are ignored: the directory already exists, so
// `open_or_create` reads its own meta rather than using either.
match self.open_or_create(id, "", None) {
Ok(subagent) => {
self.live
@@ -300,8 +256,6 @@ impl Subagents {
}
}
/// The parent session's process is gone, so nothing still open here has
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4.
pub fn finish_all(&self) {
let subagents: Vec<Arc<Subagent>> = self.live.lock().unwrap().values().cloned().collect();
for subagent in subagents {
@@ -313,12 +267,6 @@ impl Subagents {
}
}
/// Every subagent under this session's directory, oldest first --
/// `GET /sessions/{id}/subagents`. Read straight from disk rather than
/// from `live`, so a subagent from before this process started (or one
/// this run has not yet touched) still shows up; one file read per
/// subagent, which is fine at the handful a session usually has.
///
/// `session_running` is what turns a subagent whose last status is
/// `Running` into `Unknown`: its process was the session's, and the
/// session has none.
@@ -328,7 +276,6 @@ impl Subagents {
.filter_map(Result::ok)
.filter_map(|entry| info_of(&entry.path(), session_running))
.collect(),
// No directory is no subagents, not a fault worth reporting.
Err(_) => Vec::new(),
};
rows.sort_by(|a, b| {
@@ -366,9 +313,6 @@ fn info_of(subagent_dir: &Path, session_running: bool) -> Option<SubagentInfo> {
})
}
/// How many subagents a session has, for `SessionInfo::subagents`: a
/// directory listing, so the session list stays cheap and only the
/// dedicated route pays for reading a status out of each one.
pub fn count(session_dir: &Path) -> usize {
fs::read_dir(session_dir.join("subagents"))
.map(|entries| entries.filter_map(Result::ok).count())
@@ -426,7 +370,6 @@ mod tests {
},
);
}
// A fresh registry, the way a backend restart builds one.
let subagents = Subagents::new(dir.path().to_path_buf());
let subagent = subagents.get("toolu_2").expect("reopened");
assert!(subagent.is_open());
@@ -438,7 +381,6 @@ mod tests {
);
let events =
crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read");
// Status, UserMessage, two AssistantText deltas, seq continuing.
assert_eq!(events.len(), 4);
assert_eq!(events.last().unwrap().seq, 4);
}
@@ -451,7 +393,6 @@ mod tests {
subagents.finish("toolu_3");
let subagent = subagents.get("toolu_3").unwrap();
assert!(!subagent.is_open());
// On disk too, not only in the live cache `is_open` reads.
assert_eq!(
Transcript::open(&subagent.transcript_path())
.expect("reopen")
@@ -459,7 +400,6 @@ mod tests {
Some(SessionStatus::Exited)
);
// Finishing an id that was never a subagent is a no-op, not a panic.
subagents.finish("never-started");
}
-157
View File
@@ -1,11 +1,3 @@
//! Append-only JSONL event log, one per session, with monotonically
//! increasing sequence numbers -- the phone's resume cursor.
//!
//! One line per event: `{"seq":N,"ts":...,"type":...,...}`. The writer
//! assigns sequence numbers; readers replay everything after a cursor.
//! Reopening an existing file continues the numbering, which is what makes
//! a backend restart invisible to a phone holding a cursor.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::ops::Range;
@@ -17,9 +9,6 @@ use serde::Deserialize;
use super::driver::{Event, SessionStatus, context_after};
// `SeqEvent` moved to `event-model` on 2026-09-04 along with the rest of the
// event model, so `client-core` can read the same wire shape; re-exported
// here since every caller in this crate reaches it through this module.
pub use event_model::SeqEvent;
pub struct Transcript {
@@ -31,8 +20,6 @@ pub struct Transcript {
}
impl Transcript {
/// Opens (or creates) the log at `path`, continuing the sequence from
/// the last line if one exists.
pub fn open(path: &Path) -> Result<Self> {
// One pass for all three answers. They are wanted at the same moment
// by the same caller, and reading the file again for each doubled the
@@ -43,8 +30,6 @@ impl Transcript {
Event::Status { state } => Some(state),
_ => None,
});
// Owner-only: a transcript is the whole conversation, including
// whatever the session read, wrote, or was told.
let file = OpenOptions::new()
.create(true)
.append(true)
@@ -56,17 +41,12 @@ impl Transcript {
next_seq: last_seq + 1,
last_status,
last_activity: existing.last().map(|entry| entry.ts),
// Folded rather than read off the newest usage entry: a clear or
// a compaction after it is what the answer is, and those events
// carry no usage of their own.
context_tokens: existing
.iter()
.fold(None, |current, entry| context_after(current, &entry.event)),
})
}
/// The state the session was last reported to be in, as of opening.
///
/// Read from the file rather than assumed, because a server that has just
/// restarted has been told nothing. Assuming idle claimed a session was
/// waiting for you when it had exited hours earlier.
@@ -76,14 +56,6 @@ impl Transcript {
self.last_status
}
/// When this session last did anything, as of opening.
///
/// Read from the file for the reason [`Transcript::last_status`] is, and it
/// is the same mistake in the other direction: taking the clock instead
/// said every session it relaunched had been active this second. On the
/// phone that is every row reading "just now" and the list -- sorted by
/// this -- in an order that means nothing.
///
/// `None` for a transcript with no lines, which is a session that genuinely
/// has not done anything. Its caller answers with when the session was
/// created, not with the clock.
@@ -91,8 +63,6 @@ impl Transcript {
self.last_activity
}
/// How much context the session was holding, as of opening.
///
/// `None` for a transcript nothing has been measured in. That is not zero:
/// a server that has just restarted has been told nothing, and answering
/// zero would draw an empty context for a conversation that may be nearly
@@ -120,16 +90,6 @@ impl Transcript {
}
}
/// A window of the transcript ending just before `before`, newest-biased.
///
/// The screen opens on the end of a conversation, and the end is all it can
/// show at once. Replaying the whole file to get there costs one network frame
/// per event -- on an 863-event import that was several seconds of messages
/// arriving oldest-first, which reads as the app loading top-down.
///
/// `before` pages backwards for history somebody actually scrolls to. Only the
/// window is parsed; see [`Indexed`] for why that is the whole cost.
///
/// `after` is a floor: nothing at or below it is returned, and the page stops
/// there rather than at `limit`. A phone holding a cached run passes the end of
/// what it already has, so the page is exactly the gap and never overlaps its
@@ -153,13 +113,7 @@ pub fn read_window(
Some(after) => indexed.first_at_or_after(after.saturating_add(1))?,
None => 0,
};
// A floor above the window is an empty page, not a walk backwards past it.
let start = start.min(end);
// Coalescing counts *rows*, not events, and would misread the newest
// window: a message still streaming there would fold to one event whose seq
// is its first delta, and the phone resumes its live stream from the newest
// seq it applied -- so the deltas the coalesced event hid would replay and
// double. Only settled history (`before` set) is safe.
if coalesce && before.is_some() {
indexed.parse_coalesced(start, end, limit)
} else {
@@ -167,37 +121,18 @@ pub fn read_window(
}
}
/// How far behind a reconnecting subscriber can be and still be handed the
/// backlog one event at a time.
///
/// Past this it is served better by rebuilding from the newest window. The
/// events are the same either way; what differs is that one arrives as a single
/// window and the other as thousands of frames a screen renders one by one. Set
/// well above a screenful so an ordinary blip still streams continuously.
pub const CATCH_UP_LIMIT: usize = 200;
/// What a subscriber asking for "everything after my cursor" gets back.
///
/// Two answers rather than one list, because they mean different things to the
/// screen holding the cursor: one continues what it has, the other replaces it.
/// Collapsing them would leave the client splicing a window onto rows it has no
/// way to know are no longer adjacent -- a seam that looks like ordinary output.
#[derive(Debug, Clone, PartialEq)]
pub enum CatchUp {
/// The events after the cursor, continuing what the subscriber holds.
Continue(Vec<SeqEvent>),
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the newest
/// window, replacing whatever it holds. Earlier history is still there to be
/// paged backwards through.
Restart(Vec<SeqEvent>),
}
/// Everything after `after`, or the newest `limit` when that is more.
///
/// The window is chosen before anything is parsed, which matters most in the
/// case that looks least interesting: a subscriber with no cursor asks for the
/// whole conversation and is handed the last [`CATCH_UP_LIMIT`] events of it,
/// so parsing the discarded prefix is the whole file's work for a screenful.
pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
let Some(indexed) = Indexed::read(path)? else {
return Ok(CatchUp::Continue(Vec::new()));
@@ -210,9 +145,6 @@ pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
Ok(CatchUp::Continue(indexed.parse(start..end)?))
}
/// Replays every event with `seq > after`, oldest first. A missing file is
/// an empty transcript, not an error -- the session just hasn't produced an
/// event yet.
pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
let Some(indexed) = Indexed::read(path)? else {
return Ok(Vec::new());
@@ -221,32 +153,13 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
indexed.parse(start..indexed.lines.len())
}
/// The transcript's lines located but not read, so a reader can find the range
/// it wants and parse only that.
///
/// Both readers above want a *range* of the file, and both used to reach it by
/// parsing every line and discarding the ones outside it -- the cost that grows
/// with the conversation rather than with the answer. Measured on a 21 MB,
/// 24,000-event transcript, one page took **500 ms of server time to return
/// 600 KB**, and the same 500 ms whichever page was asked for. A phone paging
/// back pays it per page, and every stream reconnect pays it again to discover
/// there is nothing new.
///
/// Sequence numbers only ever increase, so the boundary of a range is a
/// bisection: this parses one line per halving, and the caller parses only what
/// it returns. The file is still read whole, which is a deliberate stop --
/// going further means a chunked backwards reader, and locating a line is not
/// what the half-second was going to.
struct Indexed<'a> {
path: &'a Path,
text: String,
/// Byte range of each non-blank line, in the order they were written.
lines: Vec<Range<usize>>,
}
impl<'a> Indexed<'a> {
/// `None` for a file that isn't there, which is a session that has not
/// produced an event yet rather than a failure.
fn read(path: &'a Path) -> Result<Option<Self>> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
@@ -270,9 +183,6 @@ impl<'a> Indexed<'a> {
Ok(Some(Self { path, text, lines }))
}
/// The index of the first line numbered `seq` or higher, or the end when
/// every line is older than that.
///
/// A bisection, which is only correct because the file is in sequence order.
/// A line that cannot be read is reported here rather than silently treated
/// as out of range, because the answer would be a window off by however much
@@ -290,7 +200,6 @@ impl<'a> Indexed<'a> {
Ok(low)
}
/// One line's sequence number, without building the event on it.
fn seq_at(&self, index: usize) -> Result<u64> {
#[derive(Deserialize)]
struct JustSeq {
@@ -317,24 +226,8 @@ impl<'a> Indexed<'a> {
.with_context(|| format!("bad transcript line in {}", self.path.display()))
}
/// The newest `limit` *rows* ending at line `end`, with each run of
/// consecutive [`Event::AssistantText`] deltas concatenated into one.
///
/// A reply is stored a token at a time, so a window counted in events is a
/// fraction of a row for a reply and a whole row for a tool call, and the
/// phone can neither predict how much a page will show nor fill a screen
/// without folding a page of near-duplicate events. Counted in rows, a page
/// is a page.
///
/// A run takes the seq and time of its *oldest* delta, matching the phone's
/// own rule -- so anchors and the `before` cursor land where they always
/// did. A run cut by the `limit` is emitted as the partial it is, and the
/// phone's `healSplitMessage` welds it to the next page. `start` is the same
/// kind of cut from the other end.
fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
// Newest first while walking back, reversed to transcript order at the end.
let mut out: Vec<SeqEvent> = Vec::new();
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first.
let mut run: Option<(u64, f64, Vec<String>)> = None;
let flush = |run: &mut Option<(u64, f64, Vec<String>)>, out: &mut Vec<SeqEvent>| {
if let Some((seq, ts, mut deltas)) = run.take() {
@@ -350,9 +243,6 @@ impl<'a> Indexed<'a> {
};
let mut index = end;
while index > start {
// A row is counted when it lands in `out`; an open run is the row
// being gathered, so stopping while one is open would drop the
// deltas already read. Break only between rows.
if out.len() >= limit && run.is_none() {
break;
}
@@ -368,7 +258,6 @@ impl<'a> Indexed<'a> {
None => run = Some((entry.seq, entry.ts, vec![delta])),
}
} else {
// The run above this event (newer) is complete: it is a row, and so is this event.
flush(&mut run, &mut out);
out.push(entry);
}
@@ -406,7 +295,6 @@ mod tests {
assert_eq!(replay[0].event, text("b"));
assert_eq!(replay[1].seq, 3);
// A cursor at or past the end replays nothing.
assert!(read_after(&path, 3).expect("read").is_empty());
}
@@ -435,15 +323,12 @@ mod tests {
.expect("append");
}
// Within the limit the subscriber keeps what it has.
let CatchUp::Continue(events) = catch_up(&path, 7, 5).expect("catch up") else {
panic!("a backlog of 3 should continue");
};
assert_eq!(events.len(), 3);
assert_eq!(events[0].seq, 8);
// Past it, the newest window replaces what it has -- and it is the
// newest, not the oldest, that survives the trim.
let CatchUp::Restart(events) = catch_up(&path, 0, 5).expect("catch up") else {
panic!("a backlog of 10 should restart");
};
@@ -451,9 +336,6 @@ mod tests {
assert_eq!(events[0].seq, 6);
assert_eq!(events[4].seq, 10);
// Exactly at the limit is still a continuation: the boundary belongs to
// the cheaper answer, so a client is not reset for being one event
// behind the threshold.
assert!(matches!(
catch_up(&path, 5, 5).expect("catch up"),
CatchUp::Continue(_)
@@ -465,8 +347,6 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
// Nothing recorded yet: no prior state to report, which is not the same
// as reporting idle.
assert_eq!(Transcript::open(&path).expect("open").last_status(), None);
let mut transcript = Transcript::open(&path).expect("open");
@@ -486,13 +366,11 @@ mod tests {
2.0,
)
.expect("append");
// Events after the last status must not hide it.
transcript.append(text("trailing"), 3.0).expect("append");
drop(transcript);
let reopened = Transcript::open(&path).expect("reopen");
assert_eq!(reopened.last_status(), Some(SessionStatus::Exited));
// And the same pass still continues the numbering.
assert_eq!(reopened.next_seq, 4);
}
@@ -507,22 +385,18 @@ mod tests {
.expect("append");
}
// No cursor is the newest page, which is what opening a session asks for.
let newest = read_window(&path, None, None, 3, false).expect("window");
assert_eq!(
newest.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
[8, 9, 10]
);
// Then backwards from the oldest of those, exclusive: the page a phone
// scrolling up asks for must not repeat the row it is scrolling from.
let older = read_window(&path, Some(8), None, 3, false).expect("window");
assert_eq!(
older.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
[5, 6, 7]
);
// Asking for more than there is gives what there is, rather than failing.
assert_eq!(
read_window(&path, None, None, 100, false)
.expect("window")
@@ -530,8 +404,6 @@ mod tests {
10
);
// Nothing before the first event, which is how the phone learns to stop
// paging. An empty answer here is the end of the history, not a fault.
assert!(
read_window(&path, Some(1), None, 3, false)
.expect("window")
@@ -555,24 +427,18 @@ mod tests {
.expect("append");
}
// The floor is exclusive, like the SSE route's `after`, and it -- not the
// limit -- is what the page stops at. This is the gap between a phone's
// cached run and the window on its screen, fetched exactly.
let page = read_window(&path, Some(9), Some(5), 100, false).expect("window");
assert_eq!(
page.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
[6, 7, 8]
);
// A limit smaller than the gap still bites; the floor is a bound, not a
// replacement for one.
let page = read_window(&path, Some(9), Some(2), 3, false).expect("window");
assert_eq!(
page.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
[6, 7, 8]
);
// A floor at or above the window is an empty page, not a walk past it.
assert!(
read_window(&path, Some(4), Some(9), 10, false)
.expect("window")
@@ -599,9 +465,6 @@ mod tests {
)
.expect("append"); // seq 5
// Cut inside the run: what comes back is the deltas above the floor, seq'd
// at the first of them -- the partial the phone's `healSplitMessage` welds
// onto the rest, the same as a run cut by the limit.
let rows = read_window(&path, Some(6), Some(2), 10, true).expect("window");
assert_eq!(rows.len(), 2);
assert!(matches!(
@@ -617,7 +480,6 @@ mod tests {
}
));
// And with no floor the whole run is one row, as before.
let rows = read_window(&path, Some(6), None, 10, true).expect("window");
assert_eq!(rows.len(), 2);
assert!(matches!(
@@ -631,8 +493,6 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
let mut transcript = Transcript::open(&path).expect("open");
// Two replies of three deltas each, split by a tool call: the shape a turn writes, and
// the one an event-counted page cannot see a row of.
for d in ["a", "b", "c"] {
transcript.append(text(d), 0.0).expect("append"); // seq 1..3
}
@@ -650,12 +510,8 @@ mod tests {
transcript.append(text(d), 0.0).expect("append"); // seq 5..7
}
// Three rows asked for, three rows returned -- each delta run one event -- where a raw
// window of three would have shown one and a half tokens of the newer reply.
let rows = read_window(&path, Some(8), None, 3, true).expect("window");
assert_eq!(rows.len(), 3);
// A run keeps its oldest delta's seq, so the phone anchors and pages from where it always
// did.
assert!(matches!(
&rows[0],
SeqEvent { seq: 1, event: Event::AssistantText { delta }, .. } if delta == "abc"
@@ -673,11 +529,9 @@ mod tests {
SeqEvent { seq: 5, event: Event::AssistantText { delta }, .. } if delta == "def"
));
// The next page pages from the oldest row's seq and returns the rest, no repeat, no gap.
let older = read_window(&path, Some(1), None, 3, true).expect("window");
assert!(older.is_empty());
// The newest window never coalesces even when asked: the live cursor depends on real seqs.
let newest = read_window(&path, None, None, 2, true).expect("window");
assert_eq!(newest.iter().map(|e| e.seq).collect::<Vec<_>>(), [6, 7]);
}
@@ -686,17 +540,6 @@ mod tests {
fn a_line_read_back_is_the_line_that_was_written() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
// A timestamp with enough digits to be lost: the clock produces these all day, and this
// one is real (2026-09-04). serde_json's default float parser is not correctly rounded,
// so it read this back as ...0755 and every reader got a line one bit different from the
// one in the file -- while the SSE stream, which serializes the same struct, had already
// sent the original. Two answers to "what is line 1", indistinguishable by eye.
//
// Nothing on screen showed it: a `ts` is drawn as a relative time. What found it was the
// phone's transcript cache, which keeps the line it was sent and checks it against the
// server's own answer before resuming a stream from it -- so the mismatch turned into a
// cache thrown away and a transcript downloaded again, silently and only sometimes. The
// `float_roundtrip` feature in Cargo.toml is the fix; this is what keeps it.
let mut transcript = Transcript::open(&path).expect("open");
transcript
.append(text("hello"), 1788546972.6030757)
-76
View File
@@ -1,24 +1,3 @@
//! Where a session's process runs, and the only place that knows how.
//!
//! A driver says *what* to run -- a [`Launch`] -- and hands it here. Whether
//! that becomes a child of this process or an `ssh host …` invocation is
//! settled in this module, so a driver carries no transport knowledge and a
//! second one cannot forget to handle the remote case. It also means the
//! wrapping is honest about drivers that run nothing at all: `EchoDriver`
//! builds no [`Launch`], so there is no host for it to appear to honour.
//!
//! The quoting, the forced ssh options and the remote script are
//! `crate::ssh`'s: this module decides *which* transport, that one knows what a
//! correct ssh invocation is.
//!
//! A transport is therefore two operations rather than one: **run this** and
//! **reach this port**. The second is what a managed `llama-server` needs -- it
//! is spawned as a process and then spoken to over HTTP -- and it is a no-op
//! locally, where the port a program binds is already one this machine can
//! dial. Over ssh it is an `-L` tunnel on the same connection that runs the
//! command, so the model server binds loopback on the far machine and is never
//! exposed to its network. See [`Transport::reserve_port`].
use std::path::{Path, PathBuf};
use std::process::Stdio;
@@ -36,10 +15,6 @@ pub struct Launch {
pub program: String,
pub args: Vec<String>,
pub cwd: Option<PathBuf>,
/// A port this program will listen on, and the port that reaches it
/// from here -- see [`Transport::reserve_port`], which is the only
/// thing that should produce one.
///
/// On the launch rather than in [`Transport::spawn`]'s signature
/// because it is part of what is being run: a caller that needs to
/// reach the process it is starting says so once, where it says
@@ -66,23 +41,9 @@ impl Launch {
}
}
/// How a launched process's standard streams are connected.
///
/// The choice is not the transport's and not the driver's dialect: it is
/// whether the process is expected to outlive this server. A probe answers
/// within one call, so pipes this server drains are right. A session is a
/// conversation somebody is having, so its streams live in the session
/// directory where a later run of this server can pick them up.
pub enum Streams {
/// Pipes owned by this server; the child is killed when they drop.
Piped,
/// The same, except that stdin is already open on something this server
/// holds -- the file being copied to another machine. Bytes this process has
/// in memory do not need this: [`Streams::Piped`] gives a pipe to write them
/// into as the child reads.
PipedFrom(Stdio),
/// Files -- and, for stdin, a fifo the child itself holds open so it never
/// reads EOF -- that outlast this process.
Detached {
stdin: Stdio,
stdout: Stdio,
@@ -90,18 +51,12 @@ pub enum Streams {
},
}
/// The machine a session's process runs on.
pub enum Transport {
/// The machine this server is running on.
Here,
/// Reached with the system `ssh` client. Owns its entry rather than
/// borrowing it, so a session keeps working against the config it was
/// spawned with even if the setup is edited afterwards.
Ssh { name: String, ssh: SshConfig },
}
impl Transport {
/// The transport a setup describes; a setup with no `ssh` is here.
pub fn for_setup(setup: &crate::config::SetupConfig) -> Self {
match &setup.ssh {
Some(ssh) => Self::Ssh {
@@ -112,10 +67,6 @@ impl Transport {
}
}
/// Starts `launch` with its streams connected as `streams` says. The failure
/// names what to check, and the two transports fail for genuinely different
/// reasons -- a missing ssh client here versus a program not on the remote
/// PATH -- so each says its own thing.
pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result<Child> {
let host = match self {
Self::Here => None,
@@ -149,9 +100,6 @@ impl Transport {
stderr,
} => {
command.stdin(stdin).stdout(stdout).stderr(stderr);
// No `kill_on_drop`: outliving this server is the point. Its own
// process group as well, so a signal sent to the server's group
// does not travel to a session meant to survive being stopped.
command.process_group(0);
}
}
@@ -198,9 +146,6 @@ impl Transport {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
/// Runs `launch` with `input` on its stdin and reports everything it
/// produced -- stdout as bytes, stderr as text, and the exit status.
///
/// The one description of "run this there, with this on stdin", so that
/// shipping an attachment and writing a file through the explorer are the
/// same operation rather than two. It is also the only capture that hands
@@ -211,9 +156,6 @@ impl Transport {
/// Bytes rather than a `String`, because a file's contents are not text
/// until something has checked, and lossy decoding would replace the
/// evidence that they are not.
///
/// `Err` means the process could not be started at all; a process that ran
/// and failed is a [`Captured`] with a status saying so.
pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result<Captured> {
let (streams, to_write) = match input {
Input::None => (Streams::Piped, None),
@@ -245,13 +187,6 @@ impl Transport {
})
}
/// Picks a port for a launched program to serve on, and the port that
/// reaches it from here.
///
/// The "reach this port" half of what a transport is. Locally there is
/// one port and the OS chooses it, by binding and letting go -- racy
/// in principle, and nothing on this machine is hunting for ports.
///
/// Over ssh the near end is chosen the same way and the far end is a
/// guess, because there is no portable way to ask a machine for a free
/// port that does not race with binding it anyway. It is taken from
@@ -273,7 +208,6 @@ impl Transport {
})
}
/// How to say where this runs, for a log line a person reads.
pub fn describe(&self) -> String {
match self {
Self::Here => "on this machine".to_string(),
@@ -282,13 +216,8 @@ impl Transport {
}
}
/// Where a port on another machine is guessed from: high enough to be out
/// of the way of services, and below the 32768-60999 Linux hands out to
/// outgoing connections, which is where a guess would most often collide.
const FAR_PORTS: std::ops::Range<u16> = 20000..30000;
/// What a command is given on its standard input.
///
/// Three cases rather than an `Option<Stdio>` because they are three genuinely
/// different arrangements and only this knows which: nothing to say, bytes this
/// process is holding, or a file it has open. The last is how a
@@ -300,18 +229,13 @@ pub enum Input {
File(std::fs::File),
}
/// Everything a finished command produced, including the status.
pub struct Captured {
pub status: std::process::ExitStatus,
pub stdout: Vec<u8>,
/// Trimmed, and what a failure is reported as: ssh's own refusals and a
/// tool's own message about the file it could not open are both the useful
/// half of why something did not work.
pub stderr: String,
}
impl Captured {
/// The stdout of a command that succeeded, or the machine's own words.
pub fn ok(self) -> Result<Vec<u8>> {
if self.status.success() {
return Ok(self.stdout);
-61
View File
@@ -1,42 +1,15 @@
//! Finding out what a machine can run, rather than being told.
//!
//! The phone adds a machine by giving connection details; this asks the machine
//! itself which of the known programs it has, and the answer becomes its
//! providers. That is a security property, not a convenience: **no route accepts
//! a command from the phone.** If it did, the enrolled token could introduce
//! arbitrary programs to run on every machine a setup names.
//!
//! It is also the better interface: nobody wants to type an absolute path on a
//! phone keyboard, and a machine that has moved its binaries answers correctly
//! on the next probe.
//!
//! The cost is that a program somewhere unusual is invisible. The escape hatch
//! is editing `config.ron` on the backend, which is exactly the authority the
//! phone is not being given.
use anyhow::Result;
use crate::config::{DriverKind, ProviderConfig};
use crate::session::transport::{Launch, Transport};
/// What is looked for, and what finding it makes. Extending this is how a new
/// driver becomes discoverable -- one row, not a branch anywhere. The name is
/// what the provider gets called, so it is what the phone shows and what a
/// session stores.
const PROBES: &[(&str, &str, DriverKind)] = &[
("claude-cli", "claude", DriverKind::ClaudeCli),
// Named for the program rather than for where it runs: it runs
// wherever the setup is, and "local" was true only while a llama
// session could not be spawned on another machine.
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
];
/// Models offered for a discovered Claude CLI. A shortcut list for the spawn
/// screen, not a restriction -- the field stays free text.
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
/// Asks `transport`'s machine which of [`PROBES`] it has.
///
/// One round trip rather than one per program: over ssh each would be a separate
/// connection and handshake. `command -v` is POSIX and a shell builtin, so it
/// works whatever is installed -- and `|| true` keeps a missing program from
@@ -51,9 +24,6 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
let found = transport.capture(&launch).await.map_err(explain)?;
let mut providers = Vec::new();
// Echo runs inside this server, so it exists exactly where this server does
// and nowhere else. Offering it on a remote machine would be a choice that
// changes nothing.
if matches!(transport, Transport::Here) {
providers.push(ProviderConfig {
name: crate::config::ECHO_PROVIDER.to_string(),
@@ -73,9 +43,6 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
providers.push(ProviderConfig {
name: (*name).to_string(),
kind: *kind,
// The resolved path rather than the bare name: PATH under a
// non-interactive ssh session is not the one a person sees when they
// log in, so "it is on my PATH" is not enough.
command: Some(path.to_string()),
models: match kind {
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
@@ -86,16 +53,6 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
Ok(providers)
}
/// Adds what to do to failures whose own wording does not say.
///
/// ssh's messages are written for someone at a terminal on the backend, which is
/// exactly who is not reading this one. Host key verification is the case that
/// matters: **every** machine fails it the first time, so without this, adding a
/// machine from the phone looks broken rather than unfinished.
///
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays
/// at its default, so a first connection is a decision somebody makes on the
/// backend with the key in front of them.
fn explain(err: anyhow::Error) -> anyhow::Error {
let message = format!("{err:#}");
if message.contains("Host key verification failed") {
@@ -136,8 +93,6 @@ pub fn id_from(label: &str) -> String {
}
}
/// Normalises what a phone keyboard produced: trims, drops blanks, and
/// expands a leading `~` the way a shell would.
pub fn tidy(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
@@ -152,20 +107,11 @@ pub fn tidy(value: &str) -> Option<String> {
})
}
/// The inverse of [`tidy`]'s expansion: an absolute path under this machine's
/// home, written back as `~/…`, so that a working directory reads on a phone the
/// way it is written by hand.
///
/// Applied only to paths on **this** machine. `$HOME` here says nothing about
/// the home directory of a machine reached over ssh, so a remote path is stored
/// exactly as it was typed and the remote shell is what expands it.
pub fn shorten_home(path: &str) -> String {
let Some(home) = std::env::home_dir() else {
return path.to_string();
};
let home = home.to_string_lossy();
// The separator has to be part of the match, or `/home/bobby` would be read
// as a path inside `/home/bob`.
match path.strip_prefix(home.as_ref()) {
Some("") => "~".to_string(),
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
@@ -173,8 +119,6 @@ pub fn shorten_home(path: &str) -> String {
}
}
/// Runs a launch to completion and returns its stdout as text.
///
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
/// failure reported as the machine's own words (ssh's "Permission denied" is the
/// useful half of why a setup cannot be reached), and the output read as text
@@ -192,9 +136,6 @@ impl Transport {
mod tests {
use super::*;
/// The two halves of a home-relative path, which have to be inverses: what
/// is stored is what the phone draws, and what the phone sends back is what
/// a process is started in.
#[test]
fn a_home_path_shortens_and_expands_back() {
let Some(home) = std::env::home_dir() else {
@@ -206,8 +147,6 @@ mod tests {
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
// Not a prefix match on the characters: a sibling directory whose name
// merely starts with the home directory's is not inside it.
let sibling = format!("{}-backup/notes", home.to_string_lossy());
assert_eq!(shorten_home(&sibling), sibling);
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
-109
View File
@@ -1,48 +1,24 @@
//! Building the command a driver actually spawns -- locally, or wrapped in
//! `ssh` when the session names a host to run on.
//!
//! A driver speaks JSONL over a child process's stdio and doesn't care what
//! that child is, so a remote session is the identical command with `ssh host …`
//! in front.
//!
//! Uses the system `ssh` client rather than a Rust SSH library, so
//! `~/.ssh/config`, agents and jump hosts all keep working and there is only
//! one place to configure connections.
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::SshConfig;
/// A port on the machine a command runs on, and the port that reaches it from
/// the backend.
///
/// The second half of what a transport is (PLAN.md's SSH section): "run this"
/// plus "reach this port". Locally the two numbers are one and nothing is
/// forwarded; over ssh the connection carries an `-L` tunnel, so a model server
/// binds loopback on the far machine and is never exposed to its network.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Forward {
/// What the launched program should listen on, on its own machine.
pub there: u16,
/// What this machine connects to. The same number as `there` when the
/// program runs here.
pub here: u16,
}
/// Options forced onto every connection. `BatchMode` makes a missing key fail
/// immediately with a readable message instead of hanging on a password prompt
/// nothing can answer; the keepalives turn a silently dropped link into a
/// process exit, which the session reports as `exited` rather than hanging.
const SSH_OPTIONS: [&str; 3] = [
"BatchMode=yes",
"ServerAliveInterval=30",
"ServerAliveCountMax=3",
];
/// Builds the child process for `program args…`, run in `cwd`, either on this
/// machine (`ssh` absent) or on the machine it describes.
///
/// Stdio is left alone: how the streams are connected is the caller's decision
/// and differs by more than the transport does -- a probe wants pipes it will
/// drain, a session wants files that outlive this server.
@@ -61,11 +37,6 @@ pub fn command(
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
// Expanded here for the same reason `quote_path` expands it on the
// far side: a working directory typed as `~/repos/ai-app` has to
// mean the same thing whichever machine runs it. There is no shell
// in this branch, so nothing else would -- `current_dir` would be
// handed the literal one-character directory `~`.
command.current_dir(expand_home(cwd));
}
return command;
@@ -73,17 +44,7 @@ pub fn command(
let mut command = Command::new("ssh");
if let Some(forward) = forward {
// A forwarded process is not spoken to over stdio, and that changes how
// it is shut down. Everything else here is a CLI reading its stdin, so
// killing the ssh client ends it; a `llama-server` never reads its own,
// so the same kill left it running on the far machine with the model
// loaded -- measured 2026-09-04, an orphan per stopped session. A pty
// is what makes sshd hang the far side up. `-tt` because this client
// has no terminal to inherit one from. The cost is a log that arrives
// through a line discipline, which nothing parses.
command.arg("-tt");
// Loopback at both ends: the far side binds 127.0.0.1, so what it
// serves is reachable only through this connection.
command.args([
"-L",
&format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there),
@@ -93,8 +54,6 @@ pub fn command(
// arrive as "the model never became ready".
command.args(["-o", "ExitOnForwardFailure=yes"]);
} else {
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
// CRLF translation, ^C handling) into something the parser can't read.
command.arg("-T");
}
for option in SSH_OPTIONS {
@@ -108,8 +67,6 @@ pub fn command(
}
if let Some(identity) = &ssh.identity_file {
command.arg("-i").arg(identity);
// Without this, ssh may offer an agent key first and authenticate as
// somebody else entirely -- silently, and with different permissions.
command.args(["-o", "IdentitiesOnly=yes"]);
}
command.arg(&ssh.address);
@@ -117,10 +74,6 @@ pub fn command(
command
}
/// The single argument handed to the remote login shell. `exec` so the CLI
/// replaces that shell: the process the connection is attached to is then the
/// CLI itself, and dropping the connection takes it down rather than leaving an
/// orphan behind a live wrapper.
fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
let mut script = String::new();
if let Some(cwd) = cwd {
@@ -137,12 +90,6 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
script
}
/// A path with a leading `~` replaced by this machine's home directory.
///
/// The local half of the rule [`quote_path`] states for the remote one, and
/// deliberately the same shape: the tilde is expanded, `~user` is not, and
/// nothing else in the path gains a meaning. A machine with no home directory
/// leaves the path alone, which fails with the operating system's own message.
pub(crate) fn expand_home(path: &Path) -> PathBuf {
let Some(rest) = path.to_str().and_then(|p| {
if p == "~" {
@@ -159,22 +106,11 @@ pub(crate) fn expand_home(path: &Path) -> PathBuf {
}
}
/// Quotes a path, expanding a leading `~` and nothing else.
///
/// [`quote`] is right for every other word crossing to the remote side and
/// wrong for exactly one character. `~` means "expand me", and single quotes
/// are what stop expansion -- so a working directory typed as `~/repos/ai-app`
/// arrived as the literal four-character directory `~`, and the remote shell
/// said it did not exist, which reads like the path being wrong.
///
/// `"$HOME"` rather than handing the tilde to the shell unquoted: the variable
/// is expanded, the expansion is not re-split or globbed because it is
/// double-quoted, and everything after it stays single-quoted and literal.
/// `$HOME` is set by every shell this can land in, including the fish login
/// shell on the dev VM, so this does not depend on the remote shell being POSIX.
///
/// `~user` is deliberately not handled: there is no portable expansion for it,
/// and inventing one would mean guessing another account's home directory.
pub(crate) fn quote_path(path: &str) -> String {
if path == "~" {
return "\"$HOME\"".to_string();
@@ -185,13 +121,7 @@ pub(crate) fn quote_path(path: &str) -> String {
}
}
/// Single-quotes one word for a POSIX shell. Everything crossing to the remote
/// side goes through here: paths, model names and prompts-as-arguments are all
/// attacker-adjacent input in a server whose whole job is running commands, and
/// unquoted they would be shell syntax rather than data.
pub(crate) fn quote(word: &str) -> String {
// Inside single quotes every character is literal except `'` itself, which
// is closed, escaped, and reopened.
format!("'{}'", word.replace('\'', r"'\''"))
}
@@ -203,7 +133,6 @@ mod tests {
args.iter().map(|arg| arg.to_string()).collect()
}
/// The rendered argv, for asserting on what would actually run.
fn argv(command: &Command) -> Vec<String> {
std::iter::once(command.get_program())
.chain(command.get_args())
@@ -211,9 +140,6 @@ mod tests {
.collect()
}
/// A host with nothing configured but a name to dial, so `~/.ssh/config`
/// decides everything else -- the case that proves this adds no flags of its
/// own when it was not told to.
fn bare_host() -> SshConfig {
SshConfig {
address: "vm".to_string(),
@@ -263,7 +189,6 @@ mod tests {
assert!(rendered.contains(&"IdentitiesOnly=yes".to_string()));
assert!(rendered.contains(&"2222".to_string()));
assert!(rendered.contains(&"/home/me/.ssh/id_ai".to_string()));
// The host, then exactly one argument: the remote script.
assert_eq!(rendered[rendered.len() - 2], "bob@10.0.2.15");
assert_eq!(
rendered[rendered.len() - 1],
@@ -276,17 +201,9 @@ mod tests {
let ssh = bare_host();
let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'");
// No -i means no IdentitiesOnly: ~/.ssh/config decides instead.
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
}
/// The second half of a transport: the connection that runs the command also
/// carries the port that reaches it.
///
/// Both ends are pinned to loopback, which is what keeps a model server off
/// the far machine's network -- asserted rather than trusted, because
/// dropping the addresses is a one-word edit that still works on a machine
/// nobody else can reach.
#[test]
fn a_forwarded_port_rides_the_same_connection_as_the_command() {
let ssh = bare_host();
@@ -306,53 +223,33 @@ mod tests {
.expect("a forward");
assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242");
assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string()));
// The half that is easy to lose: without a pty the far process outlives
// the connection, because nothing closes a stdin it never reads.
assert!(rendered.contains(&"-tt".to_string()));
assert!(!rendered.contains(&"-T".to_string()));
// Options come before the host, or ssh reads them as part of the
// remote command.
assert!(forward < rendered.len() - 2);
assert_eq!(
rendered.last().unwrap(),
"exec 'llama-server' '--port' '24242'"
);
// Nothing forwarded is nothing added: every other session is one of
// these, and an -L on it would bind a port for no reason.
let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
assert!(!plain.contains(&"-L".to_string()));
// And a session that *is* spoken to over stdio keeps its raw pipe.
assert!(plain.contains(&"-T".to_string()));
assert!(!plain.contains(&"-tt".to_string()));
}
/// The one character quoting must not swallow. A working directory typed as
/// `~/repos/ai-app` was arriving as the literal directory `~`, and the
/// remote shell reported it missing -- which reads as the path being wrong
/// rather than the quoting being wrong, and cost an evening.
#[test]
fn a_leading_tilde_expands_and_nothing_else_does() {
assert_eq!(quote_path("~"), "\"$HOME\"");
assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'");
// Only leading, and only its own segment: a tilde anywhere else is an
// ordinary character in a filename, and `~user` has no portable
// expansion so it stays literal.
assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/x'");
assert_eq!(quote_path("~user/x"), "'~user/x'");
// And it reaches the script the remote shell is handed.
assert_eq!(
remote_script("claude", &args(["-p"]), Some(Path::new("~/repos/ai-app"))),
"cd \"$HOME\"/'repos/ai-app' && exec 'claude' '-p'",
);
}
/// The same character, on the transport with no shell to expand it. The
/// local branch runs the program directly, so a working directory of
/// `~/repos/ai-app` would reach `current_dir` as the literal one-character
/// directory `~`. The two transports have to agree about what a tilde means
/// or a path is only portable by accident.
#[test]
fn a_local_cwd_expands_its_tilde_the_same_way() {
let Some(home) = std::env::home_dir() else {
@@ -363,7 +260,6 @@ mod tests {
home.join("repos/ai-app")
);
assert_eq!(expand_home(Path::new("~")), home);
// Leading only, and its own segment only -- `quote_path`'s rule.
assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x"));
assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x"));
@@ -379,9 +275,6 @@ mod tests {
#[test]
fn shell_metacharacters_cross_as_data_not_syntax() {
// Expanding $HOME must not open a door for anything else: the rest stays
// single-quoted, so this remains one absurd path rather than three
// commands.
assert_eq!(
quote_path("~/'; touch /tmp/pwned; '"),
r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#,
@@ -393,8 +286,6 @@ mod tests {
assert_eq!(quote("$(whoami)"), "'$(whoami)'");
assert_eq!(quote("it's"), r"'it'\''s'");
// The end-to-end version of the same worry: a working directory that
// tries to close the quote and start a new command.
let ssh = bare_host();
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil), None));
+51 -249
View File
@@ -1,34 +1,3 @@
//! Usage-limit reporting -- the same numbers as Claude Code's `/usage`.
//!
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
//! token from Claude Code's local credential store. The endpoint is
//! undocumented and has changed before, so everything here is best-effort:
//! every field is optional, and failure degrades to an "unavailable" snapshot
//! with the reason, never an error that breaks the screen.
//!
//! Two rules learned from others hitting this endpoint: send `User-Agent:
//! claude-code/<version>` (without it, requests land in an aggressively
//! rate-limited bucket) and poll no more often than every 180 s. The cache
//! below enforces the latter across any number of phone refreshes; there is no
//! background poll at all.
//!
//! One [`UsageProvider`] per paid service, so a second service later is a new
//! impl behind the same snapshot shape, not a parallel screen.
//!
//! **Asked of the machine that spends the tokens, not of this one.** A session
//! runs wherever its setup says, so the account being billed is that machine's.
//! In the layout this project aims at, `ai-server` is on the host, the host has
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
//! the screen could show would be an account with no sessions. Credentials are
//! read through the session `Transport`, one snapshot per setup that offers
//! Claude.
//!
//! The token is read *to* the backend and the HTTP call is made from here, so
//! the far machine needs nothing beyond a shell and the wire format stays in
//! one place. The cost is that a remote machine's token is in this process's
//! memory for the length of a fetch, which is the same trust the backend
//! already has over that machine.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -44,31 +13,22 @@ const MIN_POLL_INTERVAL: Duration = Duration::from_secs(180);
/// Matched to the CLI version the wire formats were pinned against.
const USER_AGENT: &str = "claude-code/2.1.237";
/// One rate-limit window, as the phone renders it: a labeled bar.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageWindow {
/// The API's own word for which window this is -- `session` for the
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind it
/// starts sending.
///
/// Carried beside the label because a caller that wants one particular
/// window has to ask for it without matching on display text: the label is
/// written for a person and would silently select nothing the day it
/// changes.
pub kind: String,
pub label: String,
/// 0-100.
pub percent: f64,
/// ISO-8601, as the API sends it; absent for windows that never reset.
#[serde(skip_serializing_if = "Option::is_none")]
pub resets_at: Option<String>,
/// Whether this window is currently the binding one.
pub active: bool,
}
/// What came back when a machine was asked about its limits.
///
/// Four answers rather than a flag and a message, because the screen has to
/// treat them differently. "Nobody is logged in here" is a machine working
/// exactly as configured, while "I could not reach it" is a fault worth
@@ -78,14 +38,9 @@ pub struct UsageWindow {
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum UsageState {
/// Numbers were fetched; `windows` has them.
Ok,
/// The machine answered and has no Claude credentials. A choice, not a
/// fault: nothing to report and nothing to fix.
NotLoggedIn,
/// The machine could not be asked at all.
Unreachable { detail: String },
/// The machine is logged in, but the usage endpoint did not answer.
Failed { detail: String },
}
@@ -93,11 +48,7 @@ pub enum UsageState {
#[serde(rename_all = "camelCase")]
pub struct UsageSnapshot {
pub provider: String,
/// Which machine these are the numbers for. The point of the whole module:
/// they belong to an account on a particular box.
pub setup: String,
/// That machine's current label, resolved when the snapshot is built, so
/// renaming a setup renames it here too.
pub setup_name: String,
#[serde(flatten)]
pub state: UsageState,
@@ -112,16 +63,11 @@ pub struct UsageSnapshot {
/// labels a row with, and [`crate::config::DriverKind::usage_provider`],
/// which is how a session says which of those rows is about it.
pub const CLAUDE: &str = "claude";
/// The invented one, for testing the screens that draw these -- see
/// [`Fixture`].
pub const ECHO: &str = "echo";
pub trait UsageProvider: Send + Sync {
fn name(&self) -> &'static str;
/// Blocking -- call off the async workers.
fn fetch(&self) -> UsageSnapshot;
/// How long an answer from this one may be reused.
///
/// A property of the provider rather than of the cache, because what
/// sets it is what asking costs: [`ClaudeUsage`] makes a network call
/// against an endpoint that rate-limits impatient callers, and the
@@ -133,23 +79,13 @@ pub trait UsageProvider: Send + Sync {
}
}
/// Reads the numbers behind Claude Code's `/usage` from one machine, using the
/// credentials that machine stores -- nothing to configure, and it reports on
/// exactly the account whose CLI runs the sessions there.
pub struct ClaudeUsage {
pub setup: String,
pub setup_name: String,
/// How to reach that machine. `Here` for the backend's own.
pub transport: Transport,
/// The CLI to run there, for the one thing this asks of it: refreshing its
/// own expired token. The provider's, so a machine with the CLI somewhere
/// odd is asked at the same path its sessions run.
pub program: String,
}
/// Where Claude Code keeps its credentials, as a shell word rather than a path:
/// `$HOME` is expanded by the shell on the machine being asked, which is the
/// only place that knows what it is.
const CREDENTIALS: &str = "$HOME/.claude/.credentials.json";
impl ClaudeUsage {
@@ -164,9 +100,6 @@ impl ClaudeUsage {
}
}
/// The machine's stored OAuth token, or which of the two ways of not having
/// one this is. Read through `sh -c` so `$HOME` resolves on the far machine;
/// a path built here would be this machine's home directory.
fn access_token(&self) -> Result<String, UsageState> {
let launch = Launch::new(
"sh",
@@ -186,10 +119,59 @@ impl ClaudeUsage {
.as_str()
.map(String::from)
})
// A file that exists but carries no token is the same situation as
// no file: nobody has logged in here yet.
.ok_or(UsageState::NotLoggedIn)
}
fn call(&self, token: &str) -> Result<Value, Refused> {
let text = ureq::get(USAGE_URL)
.header("Authorization", &format!("Bearer {token}"))
.header("anthropic-beta", "oauth-2025-04-20")
.header("User-Agent", USER_AGENT)
.call()
.and_then(|mut response| response.body_mut().read_to_string())
// The error string can embed the URL but never the token.
.map_err(|err| match err {
ureq::Error::StatusCode(401) => Refused::Unauthorized,
other => Refused::Other(why(&other)),
})?;
serde_json::from_str(&text)
.map_err(|err| Refused::Other(format!("usage endpoint sent non-JSON: {err}")))
}
/// **The CLI does the refresh, never this.** Anthropic's OAuth rotates the
/// refresh token, so whoever refreshes second presents a dead one and the
/// machine is logged out until somebody runs `/login` on it -- and the
/// machine we would be refreshing on is usually one with a live session of
/// its own. Running the CLI keeps it the only writer of
/// `.credentials.json`.
fn after_cli_refresh(&self, stale: &str) -> Result<Value, UsageState> {
let launch = Launch::new(&self.program, vec!["doctor".to_string()], None);
if let Err(err) = self.transport.capture_blocking(&launch) {
return Err(UsageState::Failed {
detail: format!(
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
self.setup_name, self.program
),
});
}
let fresh = self.access_token()?;
if fresh == stale {
return Err(self.still_expired());
}
self.call(&fresh).map_err(|err| match err {
Refused::Unauthorized => self.still_expired(),
Refused::Other(detail) => UsageState::Failed { detail },
})
}
fn still_expired(&self) -> UsageState {
UsageState::Failed {
detail: format!(
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
self.setup_name, self.program
),
}
}
}
impl UsageProvider for ClaudeUsage {
@@ -216,8 +198,6 @@ impl UsageProvider for ClaudeUsage {
}
}
/// Why one call to the usage endpoint did not produce numbers.
///
/// 401 is apart from the rest because it is the only one with a way out: the
/// endpoint answered, and it means the access token has expired rather than
/// that anything is broken.
@@ -226,79 +206,6 @@ enum Refused {
Other(String),
}
impl ClaudeUsage {
/// One call to the endpoint with one token.
fn call(&self, token: &str) -> Result<Value, Refused> {
let text = ureq::get(USAGE_URL)
.header("Authorization", &format!("Bearer {token}"))
.header("anthropic-beta", "oauth-2025-04-20")
.header("User-Agent", USER_AGENT)
.call()
.and_then(|mut response| response.body_mut().read_to_string())
// The error string can embed the URL but never the token.
.map_err(|err| match err {
ureq::Error::StatusCode(401) => Refused::Unauthorized,
other => Refused::Other(why(&other)),
})?;
serde_json::from_str(&text)
.map_err(|err| Refused::Other(format!("usage endpoint sent non-JSON: {err}")))
}
/// Have the machine's own CLI refresh its token, then ask once more.
///
/// **The CLI does the refresh, never this.** Anthropic's OAuth rotates the
/// refresh token, so whoever refreshes second presents a dead one and the
/// machine is logged out until somebody runs `/login` on it -- and the
/// machine we would be refreshing on is usually one with a live session of
/// its own. Running the CLI keeps it the only writer of
/// `.credentials.json`.
///
/// `doctor` rather than the `auth status` it reads like, measured against
/// this CLI (2.1.258) on 2026-09-05 with a deliberately invalid token:
/// `auth status` reports `loggedIn: true` off the file alone and never
/// touches the network, so it would have refreshed nothing while looking
/// like it had. `doctor` resolves the account, which is what makes it
/// refresh, and it spends no quota. The same probe showed what a *failed*
/// refresh does -- the CLI blanks both tokens -- so this must stay on the
/// 401 path, where the access token is already dead, and never be used to
/// refresh speculatively.
///
/// Only a token that actually changed is retried, so a CLI that refreshed
/// nothing costs one call rather than two, and this cannot become a loop.
fn after_cli_refresh(&self, stale: &str) -> Result<Value, UsageState> {
let launch = Launch::new(&self.program, vec!["doctor".to_string()], None);
if let Err(err) = self.transport.capture_blocking(&launch) {
return Err(UsageState::Failed {
detail: format!(
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
self.setup_name, self.program
),
});
}
let fresh = self.access_token()?;
if fresh == stale {
return Err(self.still_expired());
}
self.call(&fresh).map_err(|err| match err {
Refused::Unauthorized => self.still_expired(),
Refused::Other(detail) => UsageState::Failed { detail },
})
}
/// A login the CLI could not renew: the one state here somebody has to act
/// on, so it says where and what to run.
fn still_expired(&self) -> UsageState {
UsageState::Failed {
detail: format!(
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
self.setup_name, self.program
),
}
}
}
/// What a failed call to the usage endpoint should say.
///
/// A status is not a network fault and must not be reported as one: the
/// endpoint answered. 401 never reaches here -- it has its own way out in
/// [`ClaudeUsage::after_cli_refresh`] -- so what is left is a refusal nobody
@@ -310,19 +217,9 @@ fn why(err: &ureq::Error) -> String {
}
}
/// Which kind of "no credentials" a failed read was.
///
/// The distinction is the point of having both states. `cat` failing because
/// the file is not there is a machine nobody has logged in on -- a decision
/// somebody made, with nothing to fix. Anything else is a machine this server
/// could not ask, which is a fault and reads as one.
///
/// Matched on the shell's own words rather than an exit status because there is
/// only one that survives being wrapped in `sh -c` and passed back through ssh.
fn why_no_credentials(detail: &str) -> UsageState {
// "No such file or directory" is GNU and BSD coreutils; busybox says "can't
// open". Anything unrecognised is treated as unreachable, which is the
// answer that gets looked at rather than ignored.
let missing = ["No such file", "no such file", "can't open", "cannot open"];
if missing.iter().any(|phrase| detail.contains(phrase)) {
UsageState::NotLoggedIn
@@ -333,9 +230,6 @@ fn why_no_credentials(detail: &str) -> UsageState {
}
}
/// Pulls the `limits` array apart, defensively: entries with no percent are
/// skipped, and unknown kinds keep their raw name as the label rather than
/// being dropped -- a new window appearing should show up, not vanish.
fn parse_windows(body: &Value) -> Vec<UsageWindow> {
let Some(limits) = body.get("limits").and_then(Value::as_array) else {
return Vec::new();
@@ -377,9 +271,6 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
.collect()
}
/// An invented answer, so the screens that draw these can be exercised
/// without an account.
///
/// Every state the usage bar and the usage dialog can be in is otherwise
/// reachable only by spending somebody's quota or by breaking a machine:
/// a number near the top, a machine nobody has logged into, one that
@@ -389,21 +280,13 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
/// with `/usage` (see `session::echo`), which is the same bargain the
/// rest of that driver makes: the fixture is invented, what is real is
/// the path it travels.
///
/// Shared by the session layer, which writes it, and [`UsageMonitor`],
/// which reads it. Empty until something sets it, and an empty fixture
/// produces no snapshot at all -- an echo session meters nothing, and
/// nothing is what the phone should draw.
#[derive(Clone, Default)]
pub struct Fixture {
said: Arc<Mutex<Option<Reported>>>,
}
/// What a meter answered: which of the four states it is in, and whatever
/// windows go with it. Empty for every state but [`UsageState::Ok`].
type Reported = (UsageState, Vec<UsageWindow>);
/// How long the invented five-hour window has left, when nothing says.
const FIXTURE_MINUTES: i64 = 125;
impl Fixture {
@@ -419,11 +302,6 @@ impl Fixture {
self.said.lock().unwrap().clone()
}
/// Acts on the words typed after `/usage`, and says what it did.
///
/// The vocabulary lives here rather than in the echo driver because
/// these are this module's states: a driver spelling them out would
/// be a second place that has to learn about a fifth one.
pub fn command(&self, words: &str) -> String {
let mut words = words.split_whitespace();
let Some(first) = words.next() else {
@@ -471,8 +349,6 @@ impl Fixture {
}
}
/// The three windows Claude reports today, invented around one number.
///
/// Three rather than one because the bar under a session header reads the
/// five-hour window and the dialog behind the button draws all of them,
/// and a fixture with one window leaves half the screen untested. The
@@ -480,12 +356,7 @@ impl Fixture {
/// -- which is what colours the button -- is still the one asked for.
fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
let resets_at = match reset {
// The state a real response is in between blocks: there is no
// window running, so there is nothing to reset. It is not a
// missing value, and the phone words it differently.
Some("never") | Some("none") => None,
// A timestamp that arrives and cannot be read, which is the one
// case that really is "we could not find out".
Some("unreadable") | Some("bad") => Some("whenever it feels like it".to_string()),
other => Some(reset_in(
other
@@ -518,17 +389,12 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
]
}
/// `minutes` from now, in the format the real endpoint sends.
fn reset_in(minutes: i64) -> String {
let at = time::OffsetDateTime::now_utc() + time::Duration::minutes(minutes);
at.format(&time::format_description::well_known::Rfc3339)
// Formatting a timestamp cannot fail for any input this builds;
// saying so beats a fixture that silently has no reset time.
.unwrap_or_else(|_| "unformattable".to_string())
}
/// One line naming what a fixture is currently claiming, for the reply
/// the echo session writes back.
fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
match state {
UsageState::Ok => match windows.first() {
@@ -548,8 +414,6 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
}
}
/// The fixture, as a provider, so it travels the same route and the same
/// cache as a real meter rather than being spliced in at the screen.
struct EchoUsage {
setup: String,
setup_name: String,
@@ -584,15 +448,11 @@ impl UsageProvider for EchoUsage {
}
}
/// Read from memory, and set by somebody who is about to look at the
/// screen it changes.
fn poll_interval(&self) -> Duration {
Duration::ZERO
}
}
/// Which paid services a machine can be asked about.
///
/// Derived from what the setup says it can run, so a machine with no Claude
/// provider is not asked about Claude limits -- it has none, and a row saying
/// so would be a fact about nothing.
@@ -608,9 +468,6 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
let Some(name) = provider.kind.usage_provider() else {
continue;
};
// A machine offering two Claude providers has one account, not
// two: the meter belongs to the machine and the service, which is
// exactly what the cache is keyed by.
if found.iter().any(|already| already.name() == name) {
continue;
}
@@ -621,9 +478,6 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
transport: Transport::for_setup(setup),
program: provider.program().to_string(),
})),
// Nothing at all until a test has asked for something: an
// echo session costs nothing, so the honest answer is no row
// rather than a row saying zero.
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
setup: setup.id.clone(),
setup_name: setup.name.clone(),
@@ -635,22 +489,11 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
found
}
/// One machine's numbers for one service, and when they were fetched.
///
/// Keyed by the machine and the service rather than by position: the set is not
/// fixed at startup -- setups are added, renamed and removed from the phone --
/// and a positional cache would hand one machine's numbers to another the
/// moment the list shifted.
type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
#[derive(Default)]
/// The cache in front of whatever machines exist: at most one real fetch per
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
pub struct UsageMonitor {
cache: Mutex<Cached>,
/// The invented meter an echo session can put up; empty unless one
/// has. Shared with the session layer, which is where the command
/// that sets it is typed -- see [`Fixture`].
fixture: Fixture,
}
@@ -662,12 +505,6 @@ impl UsageMonitor {
}
}
/// One snapshot per machine that offers a paid service, in the order the
/// machines are configured.
///
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
/// holding the manager, so this module stays below the session layer rather
/// than reaching up into it.
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
let mut fresh = Vec::new();
for setup in setups {
@@ -676,17 +513,11 @@ impl UsageMonitor {
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
&& fetched.elapsed() < provider.poll_interval()
{
// Cached numbers, but the machine's *name* is read fresh: a
// rename should show immediately rather than waiting out a
// poll interval it has nothing to do with.
let mut snapshot = snapshot.clone();
snapshot.setup_name = setup.name.clone();
fresh.push(snapshot);
continue;
}
// Fetched without the lock held: this makes a network call per
// machine, and holding the cache across them would serialise
// every phone asking for the screen behind the slowest ssh.
let snapshot = provider.fetch();
self.cache
.lock()
@@ -695,7 +526,6 @@ impl UsageMonitor {
fresh.push(snapshot);
}
}
// Machines that have gone away should not keep their numbers alive.
let live: std::collections::HashSet<&str> =
setups.iter().map(|setup| setup.id.as_str()).collect();
self.cache
@@ -725,9 +555,6 @@ mod tests {
transport: Transport::for_setup(&unreachable_setup()),
program: "/opt/claude".to_string(),
};
// The machine cannot be reached, so the refresh attempt fails there
// rather than at the endpoint -- and the message still has to name the
// machine and the command, since that is all anybody gets to act on.
let UsageState::Failed { detail } = provider
.after_cli_refresh("stale")
.expect_err("an unreachable machine cannot refresh anything")
@@ -750,7 +577,6 @@ mod tests {
#[test]
fn parses_the_limits_array_defensively() {
// Trimmed from a live 2026-08-24 response.
let body: Value = serde_json::from_str(
r#"{"limits":[
{"kind":"session","group":"session","percent":70,"severity":"normal","resets_at":"2026-08-25T04:29:59+00:00","scope":null,"is_active":true},
@@ -768,13 +594,10 @@ mod tests {
assert!(windows[0].active);
assert_eq!(windows[1].label, "Weekly (all models)");
assert_eq!(windows[2].label, "Weekly (Fable)");
// Unknown kinds surface under their raw name instead of vanishing.
assert_eq!(windows[3].label, "mystery_new_window");
assert_eq!(windows[3].resets_at, None);
}
/// A setup naming a machine that cannot be dialled, so nothing here touches
/// the network beyond ssh failing to resolve it.
fn unreachable_setup() -> SetupConfig {
SetupConfig {
id: "far".to_string(),
@@ -805,9 +628,6 @@ mod tests {
program: "claude".to_string(),
};
let snapshot = provider.fetch();
// The distinction the old single `error` string could not make: this
// machine was never reached, which is not the same as a machine that
// answered and has nobody logged in.
assert!(
matches!(snapshot.state, UsageState::Unreachable { .. }),
"{:?}",
@@ -820,8 +640,6 @@ mod tests {
#[test]
fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() {
// What a real shell says when nobody has logged in on that machine.
// Nothing to fix, so it must not read as an error.
assert_eq!(
why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"),
UsageState::NotLoggedIn
@@ -831,16 +649,12 @@ mod tests {
UsageState::NotLoggedIn
);
// What ssh says when the machine is not there. Worth chasing, and the
// detail is carried so somebody can.
let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused");
assert!(
matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")),
"{refused:?}"
);
// Anything unrecognised errs towards the state that gets looked at,
// rather than silently claiming nobody is logged in.
assert!(matches!(
why_no_credentials("something nobody has seen before"),
UsageState::Unreachable { .. }
@@ -856,16 +670,10 @@ mod tests {
command: None,
models: vec![],
}];
// A machine with no Claude on it has no Claude limits, and a row
// reporting on it would be a fact about nothing. Echo included:
// an echo session spends nothing, so until a fixture says
// otherwise there is no meter to report.
let unset = Fixture::new();
assert!(providers_for(&echo_only, &unset).is_empty());
assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1);
// And with one set, that machine has exactly the invented meter
// -- under the name the session's `usageProvider` will name.
let fixture = Fixture::new();
fixture.command("42");
let found = providers_for(&echo_only, &fixture);
@@ -873,12 +681,9 @@ mod tests {
assert_eq!(found[0].name(), ECHO);
assert_eq!(DriverKind::Echo.usage_provider(), Some(ECHO));
assert_eq!(DriverKind::ClaudeCli.usage_provider(), Some(CLAUDE));
// A local model costs nothing to run, so it meters nothing.
assert_eq!(DriverKind::LlamaCpp.usage_provider(), None);
}
/// The states the fixture exists to make reachable, and the one thing
/// it must not do: invent a reset time for a window that has none.
#[test]
fn the_fixture_says_each_state_the_screens_have_to_draw() {
let fixture = Fixture::new();
@@ -891,8 +696,6 @@ mod tests {
assert_eq!(windows[0].percent, 42.0);
assert!(windows[0].resets_at.is_some());
// Between blocks: no reset time, which the phone words as the
// window not running rather than as a time it could not read.
fixture.command("42 never");
assert_eq!(fixture.read().expect("set").1[0].resets_at, None);
@@ -905,7 +708,6 @@ mod tests {
fixture.command("off");
assert!(fixture.read().is_none());
// A word it does not know changes nothing and says what it takes.
fixture.command("42");
let refused = fixture.command("sideways");
assert!(