Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
5428cd75c9
commit
25370731d0
193 files changed
+693
-16219
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(¬ification).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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user