ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,671 @@
|
||||
//! 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.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// The name a session's image is stored and served under -- returned by
|
||||
/// `POST /attachments` for an upload, minted by a driver for one a tool
|
||||
/// produced, and fetched back from `/sessions/{id}/files/{ref}`. Both
|
||||
/// directions use the one id so the transcript renders them identically.
|
||||
pub type ImageRef = String;
|
||||
|
||||
/// One choice offered in answer to a [`Event::Question`].
|
||||
///
|
||||
/// More than a label because the reader is deciding, not confirming: what
|
||||
/// an option means, and what picking it would produce, are the things that
|
||||
/// decide it. Both are optional -- a permission's Allow and Deny mean
|
||||
/// exactly what they say.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
/// A sentence about what this option means.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// A block to show as written -- a mockup, a diff, a config file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
impl QuestionOption {
|
||||
/// An option that is only its label, which is most of them.
|
||||
pub fn plain(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a session can tell the outside world. Every event is
|
||||
/// appended to the session's transcript with a sequence number, then fanned
|
||||
/// out to SSE subscribers; the phone renders purely from this stream, so
|
||||
/// reconnecting is just "events after seq N" -- no separate history path
|
||||
/// to drift from the live one.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
||||
// inside them. Both are needed and only the first is obvious: every field
|
||||
// here was a single lowercase word until `pre_tokens` arrived, so a
|
||||
// multi-word field went out as snake_case, the app looked for camelCase and
|
||||
// found nothing, and the event still rendered -- as the "no counts were
|
||||
// reported" case, which is a state it is allowed to be in. A wire mismatch
|
||||
// that lands on a plausible state is invisible; anything added below with a
|
||||
// two-word field would have hit the same thing.
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum Event {
|
||||
/// What the user sent, written into the transcript by the manager (not
|
||||
/// by drivers) so every device renders the full conversation from the
|
||||
/// one stream. Recorded when the session reads the message, which is
|
||||
/// what `MessageTaken` reports.
|
||||
UserMessage {
|
||||
/// The [`Event::MessageQueued`] this resolves, when it waited.
|
||||
///
|
||||
/// A message sent between turns is read at once and never queued,
|
||||
/// so this is `None` for most of them. It is the pair to the id on
|
||||
/// `MessageQueued` and exists for the same reason `CommandSent`
|
||||
/// carries one: the phone has a bubble on screen for the waiting
|
||||
/// message and needs to know *which* one this is, rather than
|
||||
/// matching on the text and clearing the wrong one when the same
|
||||
/// thing was sent twice.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
/// What was attached to it, by the ref the files route serves.
|
||||
///
|
||||
/// On the message rather than beside it. These used to be their own
|
||||
/// `Image` events emitted just before, which drew a person's
|
||||
/// screenshot as a row of its own floating above the bubble that
|
||||
/// sent it -- and left the phone to decide, from nothing but
|
||||
/// adjacency, which message an image belonged to. Belonging is not
|
||||
/// something to infer when the sender knew.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
images: Vec<ImageRef>,
|
||||
},
|
||||
/// A message accepted from the phone that the session cannot read yet.
|
||||
///
|
||||
/// Recorded, unlike the message itself, and that difference is the
|
||||
/// point. The *message* belongs in the transcript where the session
|
||||
/// read it -- see `MessageTaken` -- but something has to say it is
|
||||
/// waiting, and it has to be the server that says it: the phone used
|
||||
/// to remember its own outgoing messages, so leaving the session
|
||||
/// screen or restarting the app showed nothing pending when something
|
||||
/// was, which reads as "nothing queued" rather than "I have forgotten".
|
||||
///
|
||||
/// Carries no row of its own. It is resolved by the `UserMessage`
|
||||
/// bearing the same id, exactly as `CommandQueued` is resolved by
|
||||
/// `CommandSent`.
|
||||
MessageQueued {
|
||||
id: String,
|
||||
text: String,
|
||||
/// Carried for the same reason [`Event::UserMessage`] carries it,
|
||||
/// and it matters more here: a waiting message is on screen for as
|
||||
/// long as the turn runs, so its attachment has nowhere else to be.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
images: Vec<ImageRef>,
|
||||
},
|
||||
/// A driver has taken one of the user's messages and started reading
|
||||
/// it. The manager turns this into the `UserMessage` above, so it
|
||||
/// never reaches a phone itself.
|
||||
///
|
||||
/// It exists because sending and being read are not the same moment. A
|
||||
/// message sent into a running turn waits for that turn to finish, and
|
||||
/// until then the session has not seen it -- so recording it among
|
||||
/// things already read puts it in the transcript above output that
|
||||
/// predates it, and leaves a phone drawing it as still waiting with
|
||||
/// nothing coming to say otherwise.
|
||||
MessageTaken {
|
||||
/// The `MessageQueued` this answers, or `None` when it never
|
||||
/// waited. Carried through onto the `UserMessage`.
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
/// Carried through onto the `UserMessage` with everything else.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
images: Vec<ImageRef>,
|
||||
},
|
||||
/// Streaming assistant text; the phone renders the concatenation as
|
||||
/// markdown.
|
||||
AssistantText {
|
||||
delta: String,
|
||||
},
|
||||
ToolStart {
|
||||
id: String,
|
||||
tool: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
ToolUpdate {
|
||||
id: String,
|
||||
output: String,
|
||||
},
|
||||
ToolEnd {
|
||||
id: String,
|
||||
output: String,
|
||||
},
|
||||
/// An image the session produced or was sent, saved under the session
|
||||
/// dir and referenced by id; the phone fetches it by URL.
|
||||
Image {
|
||||
#[serde(rename = "ref")]
|
||||
image: ImageRef,
|
||||
/// The tool call whose result carried it, when one did.
|
||||
///
|
||||
/// A screenshot belongs under the call that took it, not floating
|
||||
/// beside it -- the reader has to pair them by position otherwise,
|
||||
/// and position is exactly what a page boundary breaks. `None` for
|
||||
/// an image a person attached to their own message, which belongs
|
||||
/// to no call.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
about: Option<String>,
|
||||
},
|
||||
/// Anything the session needs a human for: AskUserQuestion, and
|
||||
/// permission requests, are the same shape with different options.
|
||||
Question {
|
||||
id: String,
|
||||
prompt: String,
|
||||
/// A few words naming what the question is about, when the asker
|
||||
/// offered one -- a tag beside the question rather than part of
|
||||
/// it. `None` for a permission, which is about the call above it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
header: Option<String>,
|
||||
options: Vec<QuestionOption>,
|
||||
/// Whether several options may be chosen at once.
|
||||
///
|
||||
/// Here rather than left for a phone to work out from the dialect
|
||||
/// underneath: how many answers a question takes is a fact about
|
||||
/// the question, and the alternative was the app parsing Claude
|
||||
/// Code's tool input to find out -- one dialect's schema, written
|
||||
/// out a second time in Kotlin, where no other dialect could
|
||||
/// reach it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
multi_select: bool,
|
||||
/// The tool call this is permission for, when it is one.
|
||||
///
|
||||
/// The CLI's `can_use_tool` request carries the `tool_use_id` of
|
||||
/// the call it is asking about, so a phone can draw the ask on the
|
||||
/// tool's own row rather than as a second card repeating its
|
||||
/// input. `None` for anything that is not about a tool --
|
||||
/// AskUserQuestion, and an echo session's question.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
about: Option<String>,
|
||||
},
|
||||
/// A message another agent sent this session.
|
||||
///
|
||||
/// Its own kind rather than a `UserMessage`, because it is not
|
||||
/// something the reader said and a transcript that renders it in their
|
||||
/// voice is claiming they did. It also explains what would otherwise
|
||||
/// be inexplicable: a session that starts working on something nobody
|
||||
/// on this phone asked for.
|
||||
PeerMessage {
|
||||
/// The sending session's own name, which is what the reader
|
||||
/// recognises it by -- the socket path it came from is not.
|
||||
from: String,
|
||||
text: String,
|
||||
},
|
||||
/// The manager's record of a question being answered, so a rendered
|
||||
/// question card resolves on every device, not just the one that
|
||||
/// answered it.
|
||||
///
|
||||
/// A list because a question can take several answers, and one that
|
||||
/// took one is the list of length one rather than a different shape.
|
||||
/// What a dialect makes of that -- Claude Code's answers map holds a
|
||||
/// string, so several become one line -- is that dialect's business
|
||||
/// and is done where it talks to it.
|
||||
Answered {
|
||||
id: String,
|
||||
answers: Vec<String>,
|
||||
},
|
||||
Status {
|
||||
state: SessionStatus,
|
||||
},
|
||||
/// What the session is set to, as the session itself reports it.
|
||||
///
|
||||
/// Asking for a change and having one are different things, and only
|
||||
/// this one is a measurement: a model name the dialect does not know,
|
||||
/// a mode it refuses, or a driver whose model is fixed at startup all
|
||||
/// leave a request that was sent and nothing that changed. Reporting
|
||||
/// from the request instead put the answer on the phone before the
|
||||
/// question had been answered, and left it there when the answer was
|
||||
/// no.
|
||||
///
|
||||
/// Either field alone, because the two are confirmed separately and
|
||||
/// by different things -- the CLI echoes a mode change, and names the
|
||||
/// model it resolved an alias to when a session starts.
|
||||
Settings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
permission_mode: Option<String>,
|
||||
},
|
||||
/// Per-turn token counts, where the dialect reports them.
|
||||
UsageDelta {
|
||||
/// What this turn cost: the tokens it was charged for.
|
||||
tokens: u64,
|
||||
/// What the model was holding when the turn ended -- see
|
||||
/// [`context_tokens`] for what goes into it.
|
||||
///
|
||||
/// Carried on the event rather than summed by whoever is reading,
|
||||
/// because it is not a sum: a conversation's context goes *down*
|
||||
/// at a compaction and a clear, so adding turns up would report a
|
||||
/// figure the session stopped being true of long ago. It is also
|
||||
/// the number a reader is asking about -- how much room is left
|
||||
/// before the next compaction -- rather than what has been spent
|
||||
/// getting here.
|
||||
///
|
||||
/// `None` where the dialect did not say, which every reader has to
|
||||
/// be able to draw: a turn whose usage the CLI omitted leaves the
|
||||
/// context unmeasured rather than unchanged, and entries written
|
||||
/// before this existed have no answer at all.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
context: Option<u64>,
|
||||
},
|
||||
/// A compaction that finished, and how much context it recovered.
|
||||
///
|
||||
/// The counts are the point, and a spinner is not: what a reader wants
|
||||
/// afterwards is that the session went from a million tokens to ten
|
||||
/// thousand, which is measured rather than estimated. They are
|
||||
/// optional because the record has shipped without them, and "the
|
||||
/// compaction happened, we don't know by how much" is a state this
|
||||
/// has to be able to say -- filling in a plausible number would make
|
||||
/// it indistinguishable from one that was counted.
|
||||
Compacted {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pre_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
post_tokens: Option<u64>,
|
||||
/// What asked for it, in the dialect's own word -- `auto` when the
|
||||
/// session compacted on its own. Carried rather than reduced to a
|
||||
/// bool so an unrecognised trigger stays unrecognised: an
|
||||
/// automatic compaction is the one worth naming, because it
|
||||
/// explains a wait nobody asked for, and defaulting the unknown
|
||||
/// case to "you asked for this" would explain it away.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
trigger: Option<String>,
|
||||
},
|
||||
/// A command the session was asked to run on itself, held because it
|
||||
/// cannot run yet.
|
||||
///
|
||||
/// These are not messages: `/compact` and `/rename` are instructions
|
||||
/// to the session about itself, and a session in the middle of a turn
|
||||
/// reads a line written to it as something the model should see. So
|
||||
/// they wait for the turn to end, and this is what a phone draws
|
||||
/// while they do -- otherwise pressing Compact during a long turn
|
||||
/// does nothing visible for minutes and looks like it was missed.
|
||||
CommandQueued {
|
||||
id: String,
|
||||
/// What to show for it: the command as a person would type it.
|
||||
text: String,
|
||||
},
|
||||
/// The same command, now handed to the session. Its [`CommandQueued`]
|
||||
/// stops being pending when this arrives, matched by `id`; a command
|
||||
/// that ran immediately has only this.
|
||||
CommandSent {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
/// The conversation was cleared: everything above this is still in
|
||||
/// the record but is no longer in the session's context.
|
||||
///
|
||||
/// Nothing is deleted. A transcript is the thing a person scrolls
|
||||
/// back through, and a session that dropped its history from the
|
||||
/// screen as well as from the model would lose the only copy the
|
||||
/// phone has -- so this is a divider, not a truncation, and the
|
||||
/// events before it stay exactly where they were.
|
||||
///
|
||||
/// It is also what makes clearing mean the same thing for every
|
||||
/// driver, which is why the marker lives here rather than in one
|
||||
/// dialect: `llama` folds its conversation out of the transcript and
|
||||
/// simply folds from the last one of these, and `claude` starts a new
|
||||
/// CLI conversation behind it.
|
||||
///
|
||||
/// **Load-bearing, not decorative.** For any driver that rebuilds its
|
||||
/// conversation from the transcript, this marker decides what the
|
||||
/// model is given -- dropping it, or treating it as something only
|
||||
/// the phone draws, silently puts a cleared conversation back in
|
||||
/// front of the model at full cost. Today `llama::conversation` is
|
||||
/// the only fold that reads it, which is the reason to write this
|
||||
/// down rather than leave it to be inferred from a second example
|
||||
/// that does not exist yet.
|
||||
Cleared,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How much the model was holding, from the three figures a turn reports.
|
||||
///
|
||||
/// The input side only -- prompt plus both cache figures. A cached token
|
||||
/// is cheaper but it is still one the model was given, so all three count;
|
||||
/// output is left out because it is what the turn produced rather than
|
||||
/// what continuing from here has to carry.
|
||||
///
|
||||
/// One function so the definition cannot drift, because it is extracted in
|
||||
/// two quite different ways: the live translators have the usage object
|
||||
/// parsed, and `import::context_tokens` scans it out of a raw line without
|
||||
/// parsing, since those files reach tens of megabytes.
|
||||
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
||||
input + cache_creation + cache_read
|
||||
}
|
||||
|
||||
/// The context after `event`, given what it was before.
|
||||
///
|
||||
/// The whole rule in one place, because three readers need the same
|
||||
/// answer: the pump keeping a live session's figure, the transcript
|
||||
/// seeding it at startup, and the phone folding the same events into what
|
||||
/// it draws. Written here beside the events it reads so a fourth reader
|
||||
/// finds it.
|
||||
///
|
||||
/// The two that *lower* it are the point. A clear takes the conversation
|
||||
/// away and a compaction replaces it with a summary, so a figure measured
|
||||
/// before either 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.
|
||||
///
|
||||
/// `None` is "we don't know", which is a state each of them can reach:
|
||||
/// nothing has been measured yet, a compaction finished without saying
|
||||
/// how much it recovered, or a clear left a conversation nobody has
|
||||
/// counted since.
|
||||
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
||||
match event {
|
||||
// `or`, so a turn the dialect reported no usage for leaves the last
|
||||
// measurement standing: it is stale by a turn, which every context
|
||||
// figure is, rather than wrong.
|
||||
Event::UsageDelta { context, .. } => context.or(current),
|
||||
Event::Compacted { post_tokens, .. } => *post_tokens,
|
||||
Event::Cleared => None,
|
||||
_ => current,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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` is the escape for a dialect's own commands --
|
||||
/// `/context`, `/usage` -- which only the thing running the session can
|
||||
/// interpret.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SessionCommand {
|
||||
Compact,
|
||||
Clear,
|
||||
SetTitle(String),
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
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(),
|
||||
Self::Clear => "/clear".to_string(),
|
||||
Self::SetTitle(title) => format!("/rename {title}"),
|
||||
Self::Raw(text) => text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs it. Called only at a boundary -- see [`Event::CommandQueued`].
|
||||
pub fn apply(&self, driver: &dyn Driver) {
|
||||
match self {
|
||||
Self::Compact => driver.compact(),
|
||||
Self::Clear => driver.clear(),
|
||||
Self::SetTitle(title) => driver.set_title(title),
|
||||
Self::Raw(text) => driver.run_command(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SessionStatus {
|
||||
Idle,
|
||||
Running,
|
||||
AwaitingInput,
|
||||
Compacting,
|
||||
Exited,
|
||||
/// There is a process recorded for this session and the machine will
|
||||
/// not say whether it is still running.
|
||||
///
|
||||
/// Its own state rather than the nearest of the others, because both
|
||||
/// neighbours are lies with consequences: `Exited` invites starting a
|
||||
/// second process against a conversation that may already have one,
|
||||
/// and `Idle` claims a session is waiting for you when nobody has
|
||||
/// checked. It resolves itself -- the driver keeps asking -- so what
|
||||
/// it means to a reader is "wait", not "act".
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Where a driver reports events. Unbounded because producers are child
|
||||
/// processes a slow phone must never be able to stall; the transcript file
|
||||
/// 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, images: Vec<ImageRef>);
|
||||
/// Answers one question with everything that was chosen, in the order
|
||||
/// it was offered. One answer is a list of one; 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 actually changes the setting owes an
|
||||
// [`Event::Settings`] once it has -- that event, and not the request,
|
||||
// is what the manager and the phone read. One that cannot change it
|
||||
// owes an [`Event::Error`] saying why; saying nothing leaves a phone
|
||||
// showing a setting nobody applied.
|
||||
|
||||
/// 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 own config, which is
|
||||
/// what a phone lists and the only place the name has to be. So a
|
||||
/// driver whose process has no notion of a name does nothing here and
|
||||
/// says nothing -- there is no failure to report, and an error beside
|
||||
/// a rename that plainly worked would be a puzzle rather than a
|
||||
/// warning.
|
||||
///
|
||||
/// Claude Code has one: `--name` when a session is created 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.
|
||||
///
|
||||
/// For the ones this app has no opinion about -- `/context`, `/usage`,
|
||||
/// anything a CLI adds next month. A driver whose process has 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 instead.
|
||||
///
|
||||
/// Like [`Driver::compact`] and [`Driver::set_title`], this is called
|
||||
/// only when the session is between turns; the waiting is done above,
|
||||
/// once, for every driver.
|
||||
fn run_command(&self, text: &str);
|
||||
/// pi: native compaction; claude: `/compact`.
|
||||
fn compact(&self);
|
||||
|
||||
/// Drops the conversation so far without ending the session.
|
||||
///
|
||||
/// The cheap half of managing a long session, and the reason 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 a single 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 when it comes
|
||||
/// back.
|
||||
///
|
||||
/// This is deliberately not a shutdown. A backend restart -- a
|
||||
/// rebuild, a service restart, a crash -- must not end a turn that is
|
||||
/// in flight, so a session's process outlives the server that started
|
||||
/// it and is found again through `session::process`. A driver with no
|
||||
/// process of its own has nothing to do here.
|
||||
///
|
||||
/// 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 sees every line it wrote and every line that came back, and it
|
||||
/// updates this the instant it writes rather than when output returns.
|
||||
/// The manager's `SessionStatus` cannot answer it -- that is built from
|
||||
/// what has been *recorded*, so between writing a line and the CLI's
|
||||
/// first output it still reads idle, 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
|
||||
}
|
||||
|
||||
fn detach(&self);
|
||||
/// End the process for good, because it must not survive this. The
|
||||
/// path out for everything [`detach`] preserves.
|
||||
///
|
||||
/// Two callers, and the difference between them is only what is being
|
||||
/// ended: a session being deleted, whose conversation goes with it, and
|
||||
/// a throwaway session at a server's exit, whose transcript stays and
|
||||
/// whose process does not (see [`SessionConfig::throwaway`]).
|
||||
///
|
||||
/// [`detach`]: Driver::detach
|
||||
/// [`SessionConfig::throwaway`]: crate::config::SessionConfig::throwaway
|
||||
fn stop(&self);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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 {
|
||||
pre_tokens: Some(28719),
|
||||
post_tokens: Some(1125),
|
||||
trigger: Some("manual".to_string()),
|
||||
})
|
||||
.expect("serialize");
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"type": "compacted",
|
||||
"preTokens": 28719,
|
||||
"postTokens": 1125,
|
||||
"trigger": "manual",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
assert_eq!(
|
||||
after(
|
||||
Some(500),
|
||||
Event::UsageDelta {
|
||||
tokens: 12,
|
||||
context: Some(30_100),
|
||||
}
|
||||
),
|
||||
Some(30_100)
|
||||
);
|
||||
assert_eq!(
|
||||
after(
|
||||
Some(128_402),
|
||||
Event::Compacted {
|
||||
pre_tokens: Some(128_402),
|
||||
post_tokens: Some(9_617),
|
||||
trigger: Some("auto".to_string()),
|
||||
}
|
||||
),
|
||||
Some(9_617)
|
||||
);
|
||||
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, and the
|
||||
// one thing that is certainly wrong is the figure from before it.
|
||||
assert_eq!(
|
||||
after(
|
||||
Some(128_402),
|
||||
Event::Compacted {
|
||||
pre_tokens: None,
|
||||
post_tokens: None,
|
||||
trigger: None,
|
||||
}
|
||||
),
|
||||
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),
|
||||
Event::UsageDelta {
|
||||
tokens: 12,
|
||||
context: None,
|
||||
}
|
||||
),
|
||||
Some(30_100)
|
||||
);
|
||||
|
||||
// Everything else leaves it alone.
|
||||
assert_eq!(
|
||||
after(
|
||||
Some(30_100),
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
}
|
||||
),
|
||||
Some(30_100)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
//! The phase-1 fake driver: no child process, just events. It exists to
|
||||
//! prove the whole pipe -- spawn, transcript, SSE cursors, questions,
|
||||
//! interrupts, compaction -- before any AI is involved, and stays useful afterwards as
|
||||
//! a connectivity check that costs no tokens.
|
||||
//!
|
||||
//! Behavior: 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.
|
||||
//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them
|
||||
//! looks like when a screen groups them. `gap` is seconds between one
|
||||
//! call and the next, default none: it is what makes a run *grow* while
|
||||
//! somebody is looking at it, which is the only way to reach the state
|
||||
//! where a call opened on its own gains a neighbour.
|
||||
//! - `/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 and the screen has to keep it out of
|
||||
//! the collapsed group around it.
|
||||
//! - `/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]` -- a message from another agent, which otherwise takes
|
||||
//! two live sessions and one of them deciding to write.
|
||||
//! - `/compact` -- a compaction, start to finish. Typed rather than
|
||||
//! pressed, because the real dialects take it as a typed command too and
|
||||
//! the phone no longer has a button for it.
|
||||
//!
|
||||
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
||||
//! that renders echo sessions correctly renders the real thing.
|
||||
//!
|
||||
//! - `/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: paragraphs of
|
||||
//! different lengths, single tool calls, runs of adjacent ones, images
|
||||
//! and a peer message. 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.
|
||||
//!
|
||||
//! `/slow` earns its place: a queued message, a Stop button, a spinner
|
||||
//! where the answer will go are all states that only exist mid-turn, and
|
||||
//! the obvious way to get one -- ask a real model to sleep -- does not
|
||||
//! work. It declines, reasonably, and answers instantly instead, so the
|
||||
//! state never arrives and the attempt still costs a turn on somebody's
|
||||
//! account. A driver that can be *told* to take its time costs nothing and
|
||||
//! is the same every run.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus};
|
||||
|
||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||
/// streaming in the UI, 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, and a large one
|
||||
/// takes minutes. Three seconds -- what this was -- is too short to look
|
||||
/// at the row that only exists while a compaction is running, and too
|
||||
/// short to watch its elapsed count reach two digits.
|
||||
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 and the call ends when the last of them is answered.
|
||||
struct PendingQuestion {
|
||||
id: String,
|
||||
call: Option<String>,
|
||||
}
|
||||
|
||||
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 -- the status
|
||||
/// dropped to idle immediately, so a phone had nothing to show as
|
||||
/// pending. Holding it here is what makes echo able to stand in.
|
||||
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 images 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 they were
|
||||
/// asked. A list because `/ask` puts up to four on one tool call, the
|
||||
/// way AskUserQuestion does, and the turn resumes when the last of
|
||||
/// them is answered rather than the first.
|
||||
pending_questions: Mutex<Vec<PendingQuestion>>,
|
||||
/// 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. The
|
||||
/// numbers are invented like everything else here; what is real is
|
||||
/// which way they move.
|
||||
context: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl EchoDriver {
|
||||
/// A short run of ordinary calls, to sit either side of something.
|
||||
///
|
||||
/// Three, because two is the fewest that groups and three makes it
|
||||
/// obvious the group is a group -- and because the point of the
|
||||
/// fixture is what a question looks like with work around it.
|
||||
fn some_calls(&self, label: &str) {
|
||||
for index in 0..3 {
|
||||
let id = format!("echo-{label}-{index}-{}", super::random_hex());
|
||||
self.emit(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "echo-tool".to_string(),
|
||||
input: serde_json::json!({ "step": format!("{label} {index}") }),
|
||||
});
|
||||
self.emit(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("{label} step {index} finished"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: one question with four options
|
||||
/// reads fine even when the options are laid out badly. 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",
|
||||
"Which colour scheme should the transcript use?",
|
||||
false,
|
||||
vec![
|
||||
QuestionOption {
|
||||
label: "Catppuccin Mocha (Recommended)".to_string(),
|
||||
description: Some(
|
||||
"What the app uses now: a dark base with muted accents.".to_string(),
|
||||
),
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Solarized Dark".to_string(),
|
||||
description: Some(
|
||||
"Lower contrast, warmer. Easier at night, harder in sun.".to_string(),
|
||||
),
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "High contrast".to_string(),
|
||||
description: Some(
|
||||
"Pure black behind white text, for reading outdoors.".to_string(),
|
||||
),
|
||||
preview: Some(
|
||||
"background: #000000\nforeground: #ffffff\naccent: #ffd700"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"Collapsed",
|
||||
"Which of these should be shown collapsed by default?",
|
||||
true,
|
||||
vec![
|
||||
QuestionOption {
|
||||
label: "Tool calls".to_string(),
|
||||
description: Some("A run of them becomes one card.".to_string()),
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Peer messages".to_string(),
|
||||
description: Some("Messages from other agents.".to_string()),
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Compaction notes".to_string(),
|
||||
description: Some("What a compaction recovered.".to_string()),
|
||||
preview: None,
|
||||
},
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let call = format!("echo-ask-{}", super::random_hex());
|
||||
self.emit(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
self.some_calls("before");
|
||||
self.emit(Event::ToolStart {
|
||||
id: call.clone(),
|
||||
tool: "AskUserQuestion".to_string(),
|
||||
input: serde_json::json!({"questions": asked
|
||||
.iter()
|
||||
.map(|(header, question, multi, options)| serde_json::json!({
|
||||
"question": question,
|
||||
"header": header,
|
||||
"multiSelect": multi,
|
||||
"options": options,
|
||||
}))
|
||||
.collect::<Vec<_>>()}),
|
||||
});
|
||||
for (index, (header, question, multi, options)) in asked.into_iter().enumerate() {
|
||||
let id = format!("{call}#{index}");
|
||||
self.pending_questions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(PendingQuestion {
|
||||
id: id.clone(),
|
||||
call: Some(call.clone()),
|
||||
});
|
||||
self.emit(Event::Question {
|
||||
id,
|
||||
prompt: question.to_string(),
|
||||
header: Some(header.to_string()),
|
||||
options,
|
||||
multi_select: multi,
|
||||
// The call that asked, so all of it draws as one thing --
|
||||
// which is the whole point of the fixture.
|
||||
about: Some(call.clone()),
|
||||
});
|
||||
}
|
||||
self.emit(Event::Status {
|
||||
state: SessionStatus::AwaitingInput,
|
||||
});
|
||||
}
|
||||
|
||||
/// One typed line, whether it arrived as a message or as a command.
|
||||
///
|
||||
/// `announce` is the difference and it is the whole of it: 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, images: Vec<ImageRef>, 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 or the states
|
||||
// it exists to exercise are not the app's real ones.
|
||||
let id = super::random_hex();
|
||||
self.queued
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((id.clone(), text.clone(), images.clone()));
|
||||
if announce {
|
||||
self.emit(Event::MessageQueued { id, text, images });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Answered on the spot rather than in the turn below, because a
|
||||
// peer message is not a turn: it is something that arrives, and
|
||||
// what is being exercised is the row it becomes. The message that
|
||||
// asked for it is still announced -- every driver owes exactly one
|
||||
// `MessageTaken` per message, and a command that quietly vanishes
|
||||
// from the transcript is the one thing echo must not model.
|
||||
if let Some(rest) = text.strip_prefix("/peer") {
|
||||
if announce {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
images: images.clone(),
|
||||
});
|
||||
}
|
||||
self.emit(Event::PeerMessage {
|
||||
from: "dev-updater-f5".to_string(),
|
||||
text: if rest.trim().is_empty() {
|
||||
"Pull before you touch AGENTS.md -- I pushed three commits to it \
|
||||
in the last hour, and origin/main has moved since you last looked.\n\n\
|
||||
The tree is clean as of now, but it was not for most of that time."
|
||||
.to_string()
|
||||
} else {
|
||||
rest.trim().to_string()
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The same word the real CLI takes, so a phone drives both the same
|
||||
// way. `Driver::compact` is what the manager's own route calls;
|
||||
// this is the typed path onto it.
|
||||
if text.trim() == "/compact" {
|
||||
if announce {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text,
|
||||
images,
|
||||
});
|
||||
}
|
||||
self.compact();
|
||||
return;
|
||||
}
|
||||
|
||||
if text.trim() == "/ask" {
|
||||
if announce {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text,
|
||||
images,
|
||||
});
|
||||
}
|
||||
self.ask_user_question();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(rest) = text.strip_prefix("/question") {
|
||||
let id = format!("q-{}", super::random_hex());
|
||||
let prompt = if rest.trim().is_empty() {
|
||||
"Echo asks: proceed?".to_string()
|
||||
} else {
|
||||
format!("Echo asks: {}", rest.trim())
|
||||
};
|
||||
self.pending_questions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(PendingQuestion {
|
||||
id: id.clone(),
|
||||
call: None,
|
||||
});
|
||||
self.emit(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
self.emit(Event::Question {
|
||||
id,
|
||||
prompt,
|
||||
header: None,
|
||||
options: vec![QuestionOption::plain("Yes"), QuestionOption::plain("No")],
|
||||
multi_select: false,
|
||||
about: None,
|
||||
});
|
||||
self.emit(Event::Status {
|
||||
state: SessionStatus::AwaitingInput,
|
||||
});
|
||||
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 and this
|
||||
// exists to produce a run.
|
||||
let count = words
|
||||
.next()
|
||||
.and_then(|w| w.parse().ok())
|
||||
.unwrap_or(3usize)
|
||||
.clamp(2, 12);
|
||||
// How long to wait between calls, default none. A run that
|
||||
// arrives all at once cannot exercise anything about a run
|
||||
// *growing*: the case worth watching is a call somebody has
|
||||
// opened and is reading when the next one turns it into a
|
||||
// group, and 50ms apart is faster than anybody can open one.
|
||||
let gap = Duration::from_secs(
|
||||
words
|
||||
.next()
|
||||
.and_then(|w| w.parse().ok())
|
||||
.unwrap_or(0u64)
|
||||
.clamp(0, 30),
|
||||
);
|
||||
(count, gap)
|
||||
});
|
||||
let run_tool = if many_tools.is_some() {
|
||||
None
|
||||
} else {
|
||||
text.strip_prefix("/tool")
|
||||
.map(|rest| rest.trim().to_string())
|
||||
};
|
||||
// Seconds to stay running before answering, default 30. Clamped
|
||||
// rather than trusted: this is a test affordance, and a session
|
||||
// pinned running for an hour by a typo is a worse outcome than a
|
||||
// short wait.
|
||||
let stream = text
|
||||
.strip_prefix("/stream")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(400).clamp(1, 4000));
|
||||
let mixed = text
|
||||
.strip_prefix("/mixed")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
||||
let linger = text.strip_prefix("/slow").map(|rest| {
|
||||
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
|
||||
});
|
||||
let fail = text
|
||||
.strip_prefix("/error")
|
||||
.map(|rest| rest.trim().to_string());
|
||||
let busy = Arc::clone(&self.busy);
|
||||
let queued = Arc::clone(&self.queued);
|
||||
let context = Arc::clone(&self.context);
|
||||
let dir = self.session_dir.clone();
|
||||
busy.store(true, Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let send = |event: Event| {
|
||||
let _ = sink.send(event);
|
||||
};
|
||||
let finish = || finish_turn(&sink, &queued, &busy);
|
||||
// Echo takes a message the instant it gets one, but it says so
|
||||
// anyway: a driver that skips this leaves the phone holding a
|
||||
// message it thinks is still queued, and the point of an echo
|
||||
// provider is that it behaves like the real ones.
|
||||
if announce {
|
||||
send(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
images: images.clone(),
|
||||
});
|
||||
}
|
||||
send(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
|
||||
if let Some(linger) = linger {
|
||||
// A delta a second: visibly alive rather than merely slow,
|
||||
// which is what the states being looked at accompany.
|
||||
let seconds = linger.as_secs();
|
||||
for remaining in (1..=seconds).rev() {
|
||||
send(Event::AssistantText {
|
||||
delta: format!("still working, {remaining}s\n"),
|
||||
});
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
send(Event::AssistantText {
|
||||
delta: "done.".to_string(),
|
||||
});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(message) = fail {
|
||||
send(Event::Error {
|
||||
message: if message.is_empty() {
|
||||
"echo was asked to fail".to_string()
|
||||
} else {
|
||||
message
|
||||
},
|
||||
});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((count, gap)) = many_tools {
|
||||
for i in 1..=count {
|
||||
if i > 1 {
|
||||
tokio::time::sleep(gap).await;
|
||||
}
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: if i % 2 == 0 { "Read" } else { "Bash" }.to_string(),
|
||||
input: serde_json::json!({
|
||||
"command": format!("grep -rn 'call {i}' /tmp | head -3"),
|
||||
"file_path": format!("/tmp/call-{i}.txt"),
|
||||
"description": format!("The {i} of {count} calls in this run"),
|
||||
"timeout": 5000,
|
||||
}),
|
||||
});
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("call {i} finished"),
|
||||
});
|
||||
}
|
||||
finish();
|
||||
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;
|
||||
send(Event::AssistantText {
|
||||
delta: format!("{i}{} ", "x".repeat(len)),
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(beats) = mixed {
|
||||
for beat in 1..=beats {
|
||||
write_beat(&sink, &dir, beat).await;
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(input) = run_tool {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "echo-tool".to_string(),
|
||||
input: serde_json::json!({ "input": input }),
|
||||
});
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
send(Event::ToolUpdate {
|
||||
id: id.clone(),
|
||||
output: "working...".to_string(),
|
||||
});
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("echoed: {input}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Word-at-a-time so streaming is visibly streaming.
|
||||
for word in format!("You said: {text}").split_inclusive(' ') {
|
||||
send(Event::AssistantText {
|
||||
delta: word.to_string(),
|
||||
});
|
||||
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,
|
||||
// which is enough to watch it climb between compactions.
|
||||
let spent = text.split_whitespace().count() as u64;
|
||||
send(Event::UsageDelta {
|
||||
tokens: spent,
|
||||
context: Some(context.fetch_add(spent + 100, Ordering::SeqCst) + spent + 100),
|
||||
});
|
||||
finish();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn new(sink: EventSink, session_dir: PathBuf) -> Self {
|
||||
let driver = Self {
|
||||
sink,
|
||||
pending_questions: Mutex::new(Vec::new()),
|
||||
context: Arc::new(AtomicU64::new(0)),
|
||||
busy: Arc::new(AtomicBool::new(false)),
|
||||
queued: Arc::new(Mutex::new(Vec::new())),
|
||||
session_dir,
|
||||
};
|
||||
driver.emit(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
driver
|
||||
}
|
||||
|
||||
/// Sends are infallible from the driver's point of view: a closed sink
|
||||
/// means the session is being torn down, and there is nobody left to
|
||||
/// report to.
|
||||
fn emit(&self, event: Event) {
|
||||
let _ = self.sink.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 16x10 checkerboard, the smallest thing that is recognisably an image
|
||||
/// rather than a blank rectangle.
|
||||
///
|
||||
/// Embedded rather than generated because the alternative is a PNG encoder
|
||||
/// in a test rig, and drawn at the transcript's fixed thumbnail height
|
||||
/// anyway -- what a scroll test needs from an image is that it occupies an
|
||||
/// image's worth of space, not that it is pretty.
|
||||
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);
|
||||
};
|
||||
match beat % 5 {
|
||||
// A paragraph, of three lengths, because a list of uniform rows
|
||||
// hides exactly the faults that uneven ones expose.
|
||||
1 => {
|
||||
let words = match beat % 3 {
|
||||
0 => 12,
|
||||
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 is a wall that looks identical at every
|
||||
// offset, which makes it impossible to tell a scroll of one
|
||||
// line from a scroll of ten -- by eye or by comparing frames.
|
||||
let body: String = (0..words)
|
||||
.map(|w| {
|
||||
let len = 3 + (w * 7 + beat * 3) % 14;
|
||||
format!("{beat}.{w}{} ", "x".repeat(len))
|
||||
})
|
||||
.collect();
|
||||
send(Event::AssistantText {
|
||||
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 {
|
||||
id: id.clone(),
|
||||
tool: "Read".to_string(),
|
||||
input: serde_json::json!({ "file_path": format!("/tmp/beat-{beat}.txt") }),
|
||||
});
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
||||
});
|
||||
}
|
||||
// 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());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
|
||||
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
|
||||
});
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}, call {i} of 3"),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
id: id.clone(),
|
||||
tool: "Screenshot".to_string(),
|
||||
input: serde_json::json!({ "description": format!("beat {beat}") }),
|
||||
});
|
||||
let part = serde_json::json!({
|
||||
"source": {"media_type": "image/png", "data": SAMPLE_PNG}
|
||||
});
|
||||
if let Some(name) = super::claude::translate::save_image(session_dir, &part) {
|
||||
send(Event::Image {
|
||||
image: name,
|
||||
about: Some(id.clone()),
|
||||
});
|
||||
}
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}: captured"),
|
||||
});
|
||||
}
|
||||
// Somebody else's voice, which is its own row shape.
|
||||
_ => {
|
||||
send(Event::PeerMessage {
|
||||
from: format!("beat-{beat}-peer"),
|
||||
text: format!("Message {beat} from another session, for the row it becomes."),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// A message written during a turn and waiting for it to end: the id of the
|
||||
/// `MessageQueued` that announced it, what it said, and what was attached to
|
||||
/// it. All three, because all three are what the `MessageTaken` at the other
|
||||
/// end owes -- named rather than written out at each of the four places that
|
||||
/// mention it.
|
||||
type Held = (String, String, Vec<ImageRef>);
|
||||
|
||||
/// Ending a turn is also when anything held during it is taken up -- the
|
||||
/// moment a real CLI would have injected it. One place, because a turn has
|
||||
/// several ways to end (a reply, an interrupt, a compaction) and every one
|
||||
/// of them owes the same answer.
|
||||
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
|
||||
let held = std::mem::take(&mut *queued.lock().unwrap());
|
||||
for (id, text, images) in held {
|
||||
// Announced before it is answered, in that order: a phone showing
|
||||
// the message as pending needs the signal that it has been read,
|
||||
// and the answer is meaningless above a message still drawn as
|
||||
// waiting.
|
||||
let _ = sink.send(Event::MessageTaken {
|
||||
id: Some(id),
|
||||
text: text.clone(),
|
||||
images,
|
||||
});
|
||||
let _ = sink.send(Event::AssistantText {
|
||||
delta: format!("\n(taken from the queue) You said: {text}"),
|
||||
});
|
||||
}
|
||||
busy.store(false, Ordering::SeqCst);
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
}
|
||||
|
||||
impl Driver for EchoDriver {
|
||||
fn between_turns(&self) -> bool {
|
||||
!self.busy.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
// Announced, because this is a message: every driver owes exactly
|
||||
// one `MessageTaken` per message, and one that quietly vanishes
|
||||
// from the transcript is the thing echo must not model. A command
|
||||
// owes none -- the manager has already recorded that it was sent,
|
||||
// and announcing it again drew the same line twice, once in each
|
||||
// colour.
|
||||
self.handle(text, images, 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);
|
||||
}
|
||||
|
||||
fn answer_question(&self, id: &str, answers: &[String]) {
|
||||
let answer = answers.join(", ");
|
||||
let (answered, waiting) = {
|
||||
let mut pending = self.pending_questions.lock().unwrap();
|
||||
let Some(at) = pending.iter().position(|question| question.id == id) else {
|
||||
self.emit(Event::Error {
|
||||
message: format!("no question {id} is awaiting an answer"),
|
||||
});
|
||||
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()
|
||||
.is_some_and(|call| pending.iter().any(|q| q.call.as_ref() == Some(call)));
|
||||
(answered, waiting)
|
||||
};
|
||||
if waiting {
|
||||
return;
|
||||
}
|
||||
if let Some(call) = answered.call {
|
||||
self.emit(Event::ToolEnd {
|
||||
id: call,
|
||||
output: format!("answered: {answer}"),
|
||||
});
|
||||
// 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 {
|
||||
delta: format!("You answered: {answer}"),
|
||||
});
|
||||
}
|
||||
self.emit(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
}
|
||||
|
||||
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 it belongs to has already
|
||||
// happened where the name lives. See `Driver::set_title`.
|
||||
fn set_title(&self, _title: &str) {}
|
||||
|
||||
fn set_permission_mode(&self, mode: &str) {
|
||||
self.emit(Event::Error {
|
||||
message: format!("an echo session asks for nothing, so {mode} changes nothing"),
|
||||
});
|
||||
}
|
||||
|
||||
fn set_model(&self, model: &str) {
|
||||
self.emit(Event::Error {
|
||||
message: format!("echo sessions have no model to change to {model}"),
|
||||
});
|
||||
}
|
||||
|
||||
/// 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. `Compacting` and `Compacted` are states a
|
||||
/// screen has to draw, and the only other way to reach them 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);
|
||||
let busy = Arc::clone(&self.busy);
|
||||
let context = Arc::clone(&self.context);
|
||||
busy.store(true, Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let _ = sink.send(Event::Status {
|
||||
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),
|
||||
post_tokens: Some(9_617),
|
||||
trigger: Some("manual".to_string()),
|
||||
});
|
||||
finish_turn(&sink, &queued, &busy);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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) {}
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
//! 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 anyhow::{Context, Result, bail, ensure};
|
||||
use serde::Serialize;
|
||||
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 regardless
|
||||
/// of what is shown here. So this is a display budget, not a fidelity one
|
||||
/// -- and it needs to be a budget, because these files reach tens of
|
||||
/// megabytes (the session this feature was written in was 39 MB) and every
|
||||
/// line of it 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 would put the dangerous case behind the safe
|
||||
/// word, which is how the expensive version of this happens: an import
|
||||
/// that looks permitted, of a session that is being written to.
|
||||
#[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 --
|
||||
/// prompt plus both cache figures -- which is the closest thing to
|
||||
/// "what continuing this costs", and unlike the size it is a number
|
||||
/// the CLI itself 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. A 77 MB file whose context is 10k tokens is
|
||||
/// cheap to continue; a smaller one that has never compacted may not
|
||||
/// be.
|
||||
///
|
||||
/// `None` when no assistant turn has recorded usage yet -- which is
|
||||
/// not zero, and is why this is an option rather than a default.
|
||||
pub context_tokens: Option<u64>,
|
||||
/// Size of the file, in bytes.
|
||||
///
|
||||
/// Reported because it is the only thing on a row that 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, which is a line count that looks unremarkable.
|
||||
///
|
||||
/// Shown rather than warned about. Importing a large session is a
|
||||
/// choice somebody is entitled to make, and marking it would be the
|
||||
/// interface nagging about a decision already taken -- but they should
|
||||
/// be able to see what they are taking on.
|
||||
pub bytes: u64,
|
||||
/// Whether [`title`](Self::title) is a name somebody chose rather than
|
||||
/// something read out of the conversation. Sorted on, and 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 that is
|
||||
/// already open puts a second `--resume` on one file: the whole
|
||||
/// conversation gets duplicated into it, both copies then 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, from importing the session the importing agent
|
||||
/// was itself running in.
|
||||
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 in
|
||||
/// either direction.
|
||||
#[serde(skip)]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Asks `transport`'s machine which Claude Code sessions it has.
|
||||
///
|
||||
/// One command rather than one per file, for the reason `setups::discover`
|
||||
/// gives: over ssh each would be its own connection and handshake.
|
||||
///
|
||||
/// `stat -c` is GNU-specific, which is fine for the machines here and 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 the pid is the file name. It
|
||||
// also records `procStart` -- the kernel's start time for that pid --
|
||||
// for the same reason `session::process` does: a pid on its own is
|
||||
// reused, so a descriptor left behind by a CLI that crashed would
|
||||
// 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, which is the one
|
||||
// mistake this check exists to prevent.
|
||||
//
|
||||
// Then two questions per file, both answered from the end of it.
|
||||
//
|
||||
// A rename, if there was one: `/rename` appends a `custom-title`
|
||||
// record, and a name somebody chose beats anything inferred from the
|
||||
// conversation. 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*, not the
|
||||
// first: the question a list like this answers is "which one was I
|
||||
// just in", and every session's opening line is the least distinctive
|
||||
// thing about it. Several, because the final ones are often the CLI's
|
||||
// own -- a slash command, the caveat wrapped around its output -- and
|
||||
// one of those identifies nothing.
|
||||
//
|
||||
// Tool results are excluded rather than typed messages included, and
|
||||
// the difference matters: a tool result is *also* a user record --
|
||||
// it is how the API models one -- so grepping the type alone gave a
|
||||
// session that ended mid-tool a tail of empty records and a row
|
||||
// saying nothing was said, when plenty was. 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 = r#"
|
||||
if [ -d "$HOME/.claude/sessions" ]; then
|
||||
printf 'LIVEKNOWN\n'
|
||||
for s in "$HOME"/.claude/sessions/*.json; do
|
||||
[ -f "$s" ] || continue
|
||||
pid=${s##*/}; pid=${pid%.json}
|
||||
[ -d "/proc/$pid" ] || continue
|
||||
start=$(awk '{ n=index($0,") "); $0=substr($0,n+2); print $20 }' "/proc/$pid/stat" 2>/dev/null)
|
||||
[ -n "$start" ] || continue
|
||||
grep -q "\"procStart\":\"$start\"" "$s" || continue
|
||||
sid=$(grep -o '"sessionId":"[^"]*"' "$s" | head -1 | cut -d'"' -f4)
|
||||
[ -n "$sid" ] && printf 'LIVE\t%s\n' "$sid"
|
||||
done
|
||||
fi
|
||||
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\t%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" \
|
||||
"$(wc -l < "$f")" "$(stat -c %s "$f" 2>/dev/null || echo 0)" \
|
||||
"$(grep -o '"usage":{[^}]*' "$f" 2>/dev/null | tail -1)" "$f"
|
||||
grep '"type":"custom-title"' "$f" 2>/dev/null | tail -1 | tr '\n' '\037'
|
||||
grep '"type":"user"' "$f" 2>/dev/null | grep -v '"tool_use_id"' | tail -12 | tr '\n' '\037'
|
||||
printf '\n'
|
||||
done
|
||||
"#;
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script.to_string()], None);
|
||||
let found = transport.capture(&launch).await?;
|
||||
|
||||
let mut live = std::collections::HashSet::new();
|
||||
let mut checkable = false;
|
||||
for line in found.lines() {
|
||||
if line.trim() == "LIVEKNOWN" {
|
||||
checkable = true;
|
||||
} else if let Some(id) = line.strip_prefix("LIVE\t") {
|
||||
live.insert(id.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut sessions: Vec<Importable> = found.lines().filter_map(parse_row).collect();
|
||||
for session in &mut sessions {
|
||||
session.in_use = match (checkable, live.contains(&session.id)) {
|
||||
(_, true) => InUse::Yes,
|
||||
(true, false) => InUse::No,
|
||||
(false, false) => InUse::Unknown,
|
||||
};
|
||||
}
|
||||
// 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, and the reason to open this screen is
|
||||
// almost always to pick up where they left off. A name still shows,
|
||||
// as the row's title and as a word beside it -- being easier to
|
||||
// recognise is what a name is for, and it does not need the order too.
|
||||
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()?;
|
||||
let lines: usize = fields.next()?.trim().parse().ok()?;
|
||||
let bytes: u64 = fields.next()?.trim().parse().ok()?;
|
||||
let context_tokens = context_tokens(fields.next()?);
|
||||
let path = fields.next()?.to_string();
|
||||
let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string();
|
||||
|
||||
let mut named = None;
|
||||
let mut said = None;
|
||||
let mut cwd = None;
|
||||
for record in fields
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split('\u{1f}')
|
||||
.filter_map(|record| serde_json::from_str::<Value>(record).ok())
|
||||
{
|
||||
if cwd.is_none() {
|
||||
cwd = record.get("cwd").and_then(Value::as_str).map(String::from);
|
||||
}
|
||||
if let Some(custom) = record.get("customTitle").and_then(Value::as_str) {
|
||||
named = Some(custom.to_string());
|
||||
continue;
|
||||
}
|
||||
if !is_hidden(&record)
|
||||
&& let Some(text) = first_line_of(&record)
|
||||
{
|
||||
// Kept rather than broken out of: these arrive oldest first,
|
||||
// so the last one to survive the filter is the most recent
|
||||
// thing that was actually 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, because they chose it to answer this exact
|
||||
// question.
|
||||
named: named.is_some(),
|
||||
title: named
|
||||
.or(said)
|
||||
.unwrap_or_else(|| "(no messages)".to_string()),
|
||||
modified,
|
||||
lines,
|
||||
bytes,
|
||||
context_tokens,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// The input tokens named in one `usage` object, added up.
|
||||
///
|
||||
/// Prompt plus cache creation plus cache read: all three are context the
|
||||
/// model was given -- the definition is [`driver::context_tokens`]; this
|
||||
/// is the same three figures dug out of a raw line rather than a parsed
|
||||
/// one, because these files reach tens of megabytes.
|
||||
///
|
||||
/// `None` for an empty blob, meaning no assistant turn has recorded usage.
|
||||
/// Missing individual fields count as zero, which is what an absent
|
||||
/// category means; an unparseable one does the same rather than
|
||||
/// discarding the figures that did read.
|
||||
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 gets counted
|
||||
// three times.
|
||||
let field = |name: &str| -> u64 {
|
||||
usage
|
||||
.split_once(&format!("\"{name}\":"))
|
||||
.map(|(_, rest)| rest.trim_start())
|
||||
.and_then(|rest| {
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
digits.parse().ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
};
|
||||
Some(driver::context_tokens(
|
||||
field("input_tokens"),
|
||||
field("cache_creation_input_tokens"),
|
||||
field("cache_read_input_tokens"),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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 the `isMeta` flag -- so titling
|
||||
/// by "first user record" gave a list where most rows read
|
||||
/// `<command-name>/clear</command-name>`, which identifies nothing. The
|
||||
/// caller offers several candidates for exactly this reason.
|
||||
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();
|
||||
if first.starts_with('<') {
|
||||
return None;
|
||||
}
|
||||
let trimmed: String = first.chars().take(90).collect();
|
||||
(!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 into this transcript 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)
|
||||
}
|
||||
|
||||
/// Concatenated text of a message's content, which is either a bare string
|
||||
/// or the API's list of blocks.
|
||||
fn text_of(content: &Value) -> String {
|
||||
match content {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Array(blocks) => blocks
|
||||
.iter()
|
||||
.filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|block| block.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a directory the machine recorded is still there.
|
||||
///
|
||||
/// Asked because a session's recorded cwd can outlive the directory: these
|
||||
/// files go back months, and a checkout that moved leaves every session
|
||||
/// from before the move pointing at a path that is gone. Resuming into one
|
||||
/// fails at `cd` before the CLI starts, which is a confusing way to meet a
|
||||
/// feature whose whole promise is "carry on where you left off".
|
||||
pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
||||
if path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let launch = Launch::new("test", vec!["-d".to_string(), path.to_string()], None);
|
||||
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.
|
||||
///
|
||||
/// Returns text rather than events because turning records into events has
|
||||
/// a side effect -- writing out the images they carry -- and it needs the
|
||||
/// session directory to write them into. That directory does not exist
|
||||
/// until the session is created, which is after this runs, so the
|
||||
/// conversion happens there instead. See [`events_from`].
|
||||
pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
||||
let launch = Launch::new(
|
||||
"tail",
|
||||
vec!["-n".to_string(), REPLAY_LINES.to_string(), path.to_string()],
|
||||
None,
|
||||
);
|
||||
transport
|
||||
.capture(&launch)
|
||||
.await
|
||||
.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, and a line
|
||||
/// that does not parse is one this reader has no opinion about.
|
||||
///
|
||||
/// `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 as it happened or
|
||||
/// replayed afterwards. It is only the *reference* that reaches the phone;
|
||||
/// the bytes are fetched from `/sessions/{id}/files/{ref}` when something
|
||||
/// actually draws them, and none of this is ever sent back to the CLI,
|
||||
/// which reads its own session file.
|
||||
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. Kept to the end rather than pushed as it is found, because
|
||||
// the answer is the last one and everything before it is history.
|
||||
let mut state = None;
|
||||
for line in text.lines() {
|
||||
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(peer) = peer_message(&record) {
|
||||
// Before `is_hidden`, which these records are: the CLI marks
|
||||
// them meta because they are not the user's own words, and
|
||||
// that is the reason to draw them differently rather than the
|
||||
// reason to drop them. A session working on something a phone
|
||||
// never asked for is otherwise unexplainable from the phone.
|
||||
state = turn_state(&record).or(state);
|
||||
events.push(peer);
|
||||
continue;
|
||||
}
|
||||
if is_hidden(&record) {
|
||||
continue;
|
||||
}
|
||||
state = turn_state(&record).or(state);
|
||||
let Some(message) = record.get("message") else {
|
||||
continue;
|
||||
};
|
||||
let Some(content) = message.get("content") else {
|
||||
continue;
|
||||
};
|
||||
match record.get("type").and_then(Value::as_str) {
|
||||
Some("user") => push_user(&mut events, content, session_dir),
|
||||
Some("assistant") => push_assistant(&mut events, content),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(state) = state {
|
||||
events.push(Event::Status { state });
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// A message from another agent, as the CLI records 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 itself as `body`. The
|
||||
/// message content beside it is the same text wrapped in an explanatory
|
||||
/// preamble and a `<cross-session-message>` tag, which is written for the
|
||||
/// model that has to read it rather than for a person -- so the body is
|
||||
/// what a reader is shown, and the name is who they are told sent it.
|
||||
fn peer_message(record: &Value) -> Option<Event> {
|
||||
let origin = record.get("origin")?;
|
||||
if origin.get("kind").and_then(Value::as_str) != Some("peer") {
|
||||
return None;
|
||||
}
|
||||
Some(Event::PeerMessage {
|
||||
from: origin
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("another session")
|
||||
.to_string(),
|
||||
text: origin.get("body").and_then(Value::as_str)?.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this record means the session is working, as far as it can be
|
||||
/// told from the file.
|
||||
///
|
||||
/// The one thing a session file does not contain is the CLI saying "this
|
||||
/// turn is over": there is no `result` record, only the messages. What
|
||||
/// there is instead is why the last assistant message stopped, and that
|
||||
/// answers it -- `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 of the conversation -- a person, a tool's result, another
|
||||
/// agent -- means the session has something to answer and is answering it.
|
||||
///
|
||||
/// `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. The
|
||||
/// same goes for a record whose reason for stopping is missing, which is
|
||||
/// what a future CLI adding a shape we do not know looks like.
|
||||
///
|
||||
/// 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 here 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)? {
|
||||
"user" => Some(SessionStatus::Running),
|
||||
"assistant" => match record["message"]
|
||||
.get("stop_reason")
|
||||
.and_then(Value::as_str)?
|
||||
{
|
||||
"tool_use" => Some(SessionStatus::Running),
|
||||
_ => Some(SessionStatus::Idle),
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::Path) {
|
||||
// A tool result arrives as a user record, because that is how the API
|
||||
// models it -- but it is the other half of a tool call, not something
|
||||
// a person said, and showing it as a message would put the reader's
|
||||
// own words and a command's output in the 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, and after
|
||||
// the result it reads as belonging to whatever came next.
|
||||
if let Some(Value::Array(parts)) = block.get("content") {
|
||||
push_images(events, parts, session_dir, Some(id));
|
||||
}
|
||||
events.push(Event::ToolEnd {
|
||||
id: id.to_string(),
|
||||
output: text_of(block.get("content").unwrap_or(&Value::Null)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = text_of(content);
|
||||
if !text.trim().is_empty() {
|
||||
// Replayed from the CLI's own file: it was read long ago, so
|
||||
// there is no waiting bubble for it to resolve.
|
||||
// The images in this record are saved and referenced separately just
|
||||
// above, because a replayed message's pictures came out of somebody
|
||||
// else's file rather than out of this app's composer -- there is no
|
||||
// upload here whose refs could ride on the message.
|
||||
events.push(Event::UserMessage {
|
||||
id: None,
|
||||
text,
|
||||
images: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 exactly as a live one does.
|
||||
fn push_images(
|
||||
events: &mut Vec<Event>,
|
||||
parts: &[Value],
|
||||
session_dir: &std::path::Path,
|
||||
about: Option<&str>,
|
||||
) {
|
||||
for part in parts {
|
||||
if part.get("type").and_then(Value::as_str) == Some("image")
|
||||
&& let Some(name) = super::claude::translate::save_image(session_dir, part)
|
||||
{
|
||||
events.push(Event::Image {
|
||||
image: name,
|
||||
about: about.map(String::from),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_assistant(events: &mut Vec<Event>, content: &Value) {
|
||||
let Value::Array(blocks) = content else {
|
||||
return;
|
||||
};
|
||||
for block in blocks {
|
||||
match block.get("type").and_then(Value::as_str) {
|
||||
Some("text") => {
|
||||
if let Some(text) = block.get("text").and_then(Value::as_str)
|
||||
&& !text.is_empty()
|
||||
{
|
||||
events.push(Event::AssistantText {
|
||||
delta: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("tool_use") => {
|
||||
if let (Some(id), Some(name)) = (
|
||||
block.get("id").and_then(Value::as_str),
|
||||
block.get("name").and_then(Value::as_str),
|
||||
) {
|
||||
events.push(Event::ToolStart {
|
||||
id: id.to_string(),
|
||||
tool: name.to_string(),
|
||||
input: block.get("input").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes one of the sessions [`list`] reported.
|
||||
///
|
||||
/// By id, resolved here against what the machine actually has, so the
|
||||
/// caller never names a file -- the same rule importing follows, and it
|
||||
/// matters more here: this one removes something.
|
||||
///
|
||||
/// Irreversible, and the caller is expected to have said so. Claude Code
|
||||
/// keeps no copy: the JSONL *is* the session, so deleting it ends any
|
||||
/// chance of resuming that conversation, including from an ai-app session
|
||||
/// that was already importing it.
|
||||
pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
|
||||
// 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, so a batch of ten took the
|
||||
// best part of a minute doing nothing but re-reading the same files.
|
||||
// `context_of` below already resolved an id the cheap way; this is the
|
||||
// same lookup, and the two now agree.
|
||||
ensure!(is_session_id(id), "not a Claude Code session id: {id}");
|
||||
let script = r#"
|
||||
for f in "$HOME"/.claude/projects/*/"$1".jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
rm -f "$f" || exit 1
|
||||
printf '%s\n' "$f"
|
||||
exit 0
|
||||
done
|
||||
"#;
|
||||
let launch = Launch::new(
|
||||
"sh",
|
||||
vec![
|
||||
"-c".to_string(),
|
||||
script.to_string(),
|
||||
"sh".to_string(),
|
||||
id.to_string(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
// Nothing on stdout means the loop found no such file. Said here rather
|
||||
// than by exiting non-zero, because a non-zero exit is reported as the
|
||||
// machine being unreachable -- which is a different thing from the
|
||||
// session not being there, and only one of them is worth retrying.
|
||||
let removed = transport
|
||||
.capture(&launch)
|
||||
.await
|
||||
.with_context(|| format!("deleting Claude Code session {id}"))?;
|
||||
if removed.trim().is_empty() {
|
||||
bail!("no Claude Code session {id} on that machine");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether an id is one of ours to put in a shell glob.
|
||||
///
|
||||
/// Both places that resolve an id to a file 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. [`delete`] is where that would be fatal, because it
|
||||
/// removes whatever it lands on, and it is exactly the reason `delete` used
|
||||
/// to resolve ids by searching a listing instead.
|
||||
///
|
||||
/// Claude Code names each transcript with a uuid, so hex and dashes is the
|
||||
/// whole alphabet. Refused rather than escaped: an id that is not one of
|
||||
/// these did not come from the list this app showed.
|
||||
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: nothing here is waiting on it, and the events arrive on the same
|
||||
/// stream as everything else once they do.
|
||||
pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Where an imported session came from, and how much of it has been shown.
|
||||
///
|
||||
/// Kept beside the session rather than in its config, because it is a
|
||||
/// position in someone else's file rather than anything the person chose,
|
||||
/// and it changes constantly.
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cursor {
|
||||
/// Server-side only, and resolved once at import. Nothing accepts a
|
||||
/// path from the phone; this is the path *we* found.
|
||||
pub path: String,
|
||||
/// Lines of that file already accounted for -- whether replayed into
|
||||
/// the transcript or skipped because this session wrote them itself.
|
||||
pub lines: usize,
|
||||
}
|
||||
|
||||
const CURSOR_FILE: &str = "import.json";
|
||||
|
||||
pub fn read_cursor(session_dir: &std::path::Path) -> Option<Cursor> {
|
||||
let text = std::fs::read_to_string(session_dir.join(CURSOR_FILE)).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
pub fn write_cursor(session_dir: &std::path::Path, cursor: &Cursor) {
|
||||
let path = session_dir.join(CURSOR_FILE);
|
||||
match serde_json::to_string(cursor) {
|
||||
Ok(text) => {
|
||||
if let Err(err) = std::fs::write(&path, text) {
|
||||
tracing::error!(
|
||||
"couldn't persist the import cursor to {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::error!("couldn't serialize the import cursor: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many lines the source file has now.
|
||||
pub async fn line_count(transport: &Transport, path: &str) -> Result<usize> {
|
||||
let launch = Launch::new("wc", vec!["-l".to_string(), path.to_string()], None);
|
||||
let out = transport.capture(&launch).await?;
|
||||
out.split_whitespace()
|
||||
.next()
|
||||
.and_then(|n| n.parse().ok())
|
||||
.with_context(|| format!("couldn't read a line count out of {out:?}"))
|
||||
}
|
||||
|
||||
/// What the CLI's own file says a session is holding, for a session this
|
||||
/// server has no measurement of.
|
||||
///
|
||||
/// A restarting server has been told nothing, and a session that has not
|
||||
/// taken a turn since will not tell it -- so a conversation that is nearly
|
||||
/// full reads as one nobody has counted until somebody sends a message to
|
||||
/// it. The CLI records the figure on every assistant message, so it is
|
||||
/// there to be read rather than waited for, and reading it is a
|
||||
/// measurement rather than a guess: the same three fields, from the same
|
||||
/// file, that the import list reports.
|
||||
///
|
||||
/// A clear needs no special case here even though it makes the last usage
|
||||
/// in a file stale. Clearing gives the CLI a *new* session id, which the
|
||||
/// reader persists as the resume token, so this looks in a file that has
|
||||
/// no usage in it yet and answers `None` -- which is the true answer.
|
||||
///
|
||||
/// `None` for every way it cannot be read: no resume token, no file, a
|
||||
/// machine that cannot be reached, or a file with no assistant turn in it.
|
||||
/// Not knowing is a state the status row draws, so there is nothing to be
|
||||
/// gained by inventing a number here.
|
||||
pub async fn context_of(transport: &Transport, session_id: &str) -> Option<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;
|
||||
}
|
||||
// The id crosses as an argument rather than as script text: it comes
|
||||
// from the CLI, but it reaches a shell on a machine that may not be
|
||||
// this one, and the rule there is that data never becomes syntax.
|
||||
let script = r#"
|
||||
for f in "$HOME"/.claude/projects/*/"$1".jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
grep -o '"usage":{[^}]*' "$f" | tail -1
|
||||
exit 0
|
||||
done
|
||||
"#;
|
||||
let launch = Launch::new(
|
||||
"sh",
|
||||
vec![
|
||||
"-c".to_string(),
|
||||
script.to_string(),
|
||||
"sh".to_string(),
|
||||
session_id.to_string(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
context_tokens(&transport.capture(&launch).await.ok()?)
|
||||
}
|
||||
|
||||
/// Events from the lines after `after`, which is a 0-based count of lines
|
||||
/// already accounted for.
|
||||
pub async fn replay_after(
|
||||
transport: &Transport,
|
||||
path: &str,
|
||||
after: usize,
|
||||
session_dir: &std::path::Path,
|
||||
) -> Result<Vec<Event>> {
|
||||
let launch = Launch::new(
|
||||
"tail",
|
||||
vec![format!("-n+{}", after + 1), path.to_string()],
|
||||
None,
|
||||
);
|
||||
let text = transport
|
||||
.capture(&launch)
|
||||
.await
|
||||
.with_context(|| format!("reading {path} from line {}", after + 1))?;
|
||||
Ok(events_from(&text, session_dir))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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"));
|
||||
assert!(is_session_id("deadbeef"));
|
||||
|
||||
assert!(!is_session_id("../../../etc/passwd"));
|
||||
assert!(!is_session_id("a/b"));
|
||||
assert!(!is_session_id(".."));
|
||||
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)));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 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}"}}}}]}}]}}}}"#
|
||||
);
|
||||
|
||||
let events = events_from(&line, dir.path());
|
||||
let Some(Event::Image { image, .. }) = events.first() else {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_with_no_image_writes_nothing() {
|
||||
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],
|
||||
Event::Status {
|
||||
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());
|
||||
assert_eq!(
|
||||
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(),
|
||||
},
|
||||
"{events:?}"
|
||||
);
|
||||
// And it counts as the session having been given something.
|
||||
assert_eq!(
|
||||
events[1],
|
||||
Event::Status {
|
||||
state: super::super::driver::SessionStatus::Running
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_record_says_whether_the_session_is_working() {
|
||||
use super::super::driver::SessionStatus;
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let asked = r#"{"type":"user","message":{"role":"user","content":"do the thing"}}"#;
|
||||
let calling = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}"#;
|
||||
let done = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"done"}]}}"#;
|
||||
|
||||
let state = |text: &str| {
|
||||
events_from(text, dir.path())
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
Event::Status { state } => Some(state),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
assert_eq!(state(asked), Some(SessionStatus::Running));
|
||||
assert_eq!(
|
||||
state(&[asked, calling].join("\n")),
|
||||
Some(SessionStatus::Running)
|
||||
);
|
||||
assert_eq!(
|
||||
state(&[asked, calling, done].join("\n")),
|
||||
Some(SessionStatus::Idle),
|
||||
"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!(
|
||||
state(&[asked, calling, sidechain, unknown].join("\n")),
|
||||
Some(SessionStatus::Running)
|
||||
);
|
||||
|
||||
// And nothing at all to go on says nothing, rather than idle.
|
||||
assert_eq!(state(r#"{"type":"summary","summary":"x"}"#), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
//! 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, and
|
||||
//! both are worth knowing before changing anything here.
|
||||
//!
|
||||
//! **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 case the transport's doc comment
|
||||
//! flags: a remote llama-server would need its port forwarded as well as
|
||||
//! its command wrapped, which is not built, so a session on an ssh host
|
||||
//! is refused rather than silently talking to the wrong machine.
|
||||
//!
|
||||
//! **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, and the app is meant to work across devices.
|
||||
//! The transcript is already the source of truth for everything else, and
|
||||
//! this makes it the source of truth for the prompt too.
|
||||
//!
|
||||
//! 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, not a second truth. Anyone tempted to "fix" the
|
||||
//! inconsistency should resolve it in this direction.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
|
||||
use super::process;
|
||||
use super::transport::{Launch, Streams, Transport};
|
||||
use crate::config::{ProviderConfig, SessionConfig};
|
||||
|
||||
/// How long to wait for a model to load before giving up on it. Loading
|
||||
/// is mostly disk, and a large quantised model on a cold cache is
|
||||
/// genuinely slow, so this is generous -- the failure it exists for is a
|
||||
/// server that will never answer, not one that is taking its time.
|
||||
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,
|
||||
content: String,
|
||||
}
|
||||
|
||||
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 -- the
|
||||
/// choice is not the caller's and a second process is the expensive
|
||||
/// mistake. Here it is 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.
|
||||
pub fn launch(
|
||||
meta: &SessionConfig,
|
||||
provider: &ProviderConfig,
|
||||
transport: &Transport,
|
||||
models_dir: &Path,
|
||||
transcript: &Path,
|
||||
session_dir: &Path,
|
||||
sink: EventSink,
|
||||
) -> Result<Self> {
|
||||
if !matches!(transport, Transport::Here) {
|
||||
bail!(
|
||||
"llama.cpp sessions can only run on this machine for now: the model is served \
|
||||
over HTTP, and forwarding that port to another host isn't built yet."
|
||||
);
|
||||
}
|
||||
let model = meta.model.as_deref().context(
|
||||
"a llama.cpp session needs a model -- one of the downloaded ones, by its key",
|
||||
)?;
|
||||
let path = model_path(models_dir, model)?;
|
||||
|
||||
// Already loaded and still running: keep talking to it. The
|
||||
// health poll below is what confirms it is really answering, so
|
||||
// adopting a pid whose server has wedged still reports as a
|
||||
// failure rather than as a session that silently never replies.
|
||||
if let Some(process::Record {
|
||||
detail: process::Detail::Http { port },
|
||||
pid,
|
||||
..
|
||||
}) = process::live(session_dir)
|
||||
{
|
||||
tracing::info!(
|
||||
"session {} reattaching to the llama-server it left loaded (pid {pid}, port {port})",
|
||||
meta.id
|
||||
);
|
||||
return Ok(Self::attached(
|
||||
format!("http://127.0.0.1:{port}"),
|
||||
meta,
|
||||
model,
|
||||
transcript,
|
||||
session_dir,
|
||||
sink,
|
||||
));
|
||||
}
|
||||
|
||||
let port = free_port().context("finding a port for llama-server")?;
|
||||
let mut args: Vec<String> = vec![
|
||||
"-m".into(),
|
||||
path.to_string_lossy().into_owned(),
|
||||
"--host".into(),
|
||||
"127.0.0.1".into(),
|
||||
"--port".into(),
|
||||
port.to_string(),
|
||||
];
|
||||
// Settings that belong to the server because they decide how the
|
||||
// model is loaded; the sampling ones ride on each request instead,
|
||||
// so changing them later needn't reload anything.
|
||||
for (key, flag) in [
|
||||
("contextSize", "-c"),
|
||||
("gpuLayers", "-ngl"),
|
||||
("threads", "-t"),
|
||||
] {
|
||||
if let Some(value) = meta.params.get(key) {
|
||||
args.push(flag.to_string());
|
||||
args.push(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let program = provider.command.as_deref().unwrap_or("llama-server");
|
||||
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
||||
// 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 {
|
||||
stdin: std::process::Stdio::null(),
|
||||
stdout: log_file(&session_dir.join(SERVER_LOG))?.into(),
|
||||
stderr: log_file(&session_dir.join(SERVER_LOG))?.into(),
|
||||
},
|
||||
)?;
|
||||
let pid = child
|
||||
.id()
|
||||
.context("llama-server exited before it could be recorded")?;
|
||||
tracing::info!(
|
||||
"session {} running {program} for {model} on 127.0.0.1:{port} as pid {pid}",
|
||||
meta.id
|
||||
);
|
||||
// Reaped so it does not become a zombie while this server is still
|
||||
// its parent; the health poll and the record are what actually say
|
||||
// whether the session is alive, because after a restart there is no
|
||||
// `Child` here to ask.
|
||||
tokio::spawn(async move {
|
||||
let mut child = child;
|
||||
let _ = child.wait().await;
|
||||
});
|
||||
|
||||
let record = process::Record::of(pid, process::Detail::Http { port })
|
||||
.context("llama-server was gone before its start time could be read")?;
|
||||
process::write(session_dir, &record);
|
||||
|
||||
Ok(Self::attached(
|
||||
format!("http://127.0.0.1:{port}"),
|
||||
meta,
|
||||
model,
|
||||
transcript,
|
||||
session_dir,
|
||||
sink,
|
||||
))
|
||||
}
|
||||
|
||||
/// 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 exists, not that its model is loaded.
|
||||
fn attached(
|
||||
endpoint: String,
|
||||
meta: &SessionConfig,
|
||||
model: &str,
|
||||
transcript: &Path,
|
||||
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, then goes idle, rather
|
||||
// than looking ready and refusing the first message.
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
{
|
||||
let sink = sink.clone();
|
||||
let endpoint = endpoint.clone();
|
||||
let model = model.to_string();
|
||||
let session_dir = session_dir.to_path_buf();
|
||||
std::thread::spawn(move || match wait_until_ready(&endpoint) {
|
||||
Ok(()) => {
|
||||
tracing::info!("{model} loaded and answering at {endpoint}");
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
watch(session_dir, sink);
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = sink.send(Event::Error {
|
||||
message: format!("{model} never became ready: {err:#}"),
|
||||
});
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
});
|
||||
process::clear(&session_dir);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut sampling = serde_json::Map::new();
|
||||
for (key, field) in [
|
||||
("temperature", "temperature"),
|
||||
("topP", "top_p"),
|
||||
("topK", "top_k"),
|
||||
("maxTokens", "max_tokens"),
|
||||
] {
|
||||
if let Some(raw) = meta.params.get(key)
|
||||
&& let Ok(number) = raw.parse::<f64>()
|
||||
{
|
||||
sampling.insert(field.to_string(), json!(number));
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
sink,
|
||||
endpoint,
|
||||
transcript: transcript.to_path_buf(),
|
||||
sampling,
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 Claude driver's stdout poll because nothing is waiting
|
||||
/// on it: this only has to notice a server that has gone, and a few
|
||||
/// seconds late costs nothing.
|
||||
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()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.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 and has nothing
|
||||
/// to wait on, 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 {
|
||||
message: "llama-server exited".to_string(),
|
||||
});
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
});
|
||||
process::clear(&session_dir);
|
||||
return;
|
||||
}
|
||||
Some((_, process::Liveness::Unknown)) => {
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Unknown,
|
||||
});
|
||||
}
|
||||
}
|
||||
if sink.is_closed() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl Driver for LlamaDriver {
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
if !images.is_empty() {
|
||||
let _ = self.sink.send(Event::Error {
|
||||
message: "this model can't be sent images".to_string(),
|
||||
});
|
||||
}
|
||||
let sink = self.sink.clone();
|
||||
let endpoint = self.endpoint.clone();
|
||||
let transcript = self.transcript.clone();
|
||||
let sampling = self.sampling.clone();
|
||||
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, because this is what records it: see `MessageTaken`.
|
||||
let _ = sink.send(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
// Never any: this driver refuses images above, and saying
|
||||
// so is what the refusal above is for.
|
||||
images: Vec::new(),
|
||||
});
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
// Everything before this message, plus this message. Read
|
||||
// rather than remembered, and `text` is appended here rather
|
||||
// than waited for, because the message's own transcript entry
|
||||
// is still on its way when this runs.
|
||||
let mut messages = conversation(&transcript);
|
||||
messages.push(Message {
|
||||
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:#}"),
|
||||
});
|
||||
}
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn answer_question(&self, _id: &str, _answers: &[String]) {
|
||||
// Nothing here asks questions: this driver has no tools, so no
|
||||
// permission prompts and no AskUserQuestion.
|
||||
}
|
||||
|
||||
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 it belongs to has already
|
||||
// happened where the name lives. See `Driver::set_title`.
|
||||
fn set_title(&self, _title: &str) {}
|
||||
|
||||
fn set_permission_mode(&self, _mode: &str) {
|
||||
let _ = self.sink.send(Event::Error {
|
||||
message: "a llama.cpp session runs no tools, so there is nothing for a permission \
|
||||
mode to govern."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn set_model(&self, _model: &str) {
|
||||
let _ = self.sink.send(Event::Error {
|
||||
message: "a llama.cpp session's model is fixed when it starts, because the server \
|
||||
loads one model into memory. Spawn another session to use a different one."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn run_command(&self, text: &str) {
|
||||
let _ = self.sink.send(Event::Error {
|
||||
message: format!(
|
||||
"a llama.cpp session has no commands of its own, so {text} means nothing to it."
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
fn compact(&self) {
|
||||
let _ = self.sink.send(Event::Error {
|
||||
message: "llama.cpp has no compaction. Clear the session instead, which costs nothing."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
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 is asymmetric and
|
||||
/// 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 alternative is
|
||||
/// unloading and reloading that model on every backend restart --
|
||||
/// minutes of disk, for a session somebody is in the middle of. The
|
||||
/// record is what keeps it from being *nobody's*: the next run of this
|
||||
/// server adopts it rather than starting a second one.
|
||||
fn detach(&self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
if let Some(record) = process::live(&self.session_dir) {
|
||||
process::stop(&record, process::STOP_GRACE);
|
||||
}
|
||||
process::clear(&self.session_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, rather than
|
||||
/// vanishing or being invented.
|
||||
///
|
||||
/// 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 last time. Changing how an old turn is
|
||||
/// rendered silently reprocesses the whole history on every message.
|
||||
fn conversation(path: &Path) -> Vec<Message> {
|
||||
let Ok(events) = crate::session::transcript::read_after(path, 0) else {
|
||||
return Vec::new();
|
||||
};
|
||||
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 here 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[..],
|
||||
};
|
||||
for event in events.iter().cloned() {
|
||||
match event.event {
|
||||
Event::UserMessage { text, .. } => {
|
||||
if !pending.is_empty() {
|
||||
messages.push(Message {
|
||||
role: "assistant".into(),
|
||||
content: std::mem::take(&mut pending),
|
||||
});
|
||||
}
|
||||
messages.push(Message {
|
||||
role: "user".into(),
|
||||
content: text,
|
||||
});
|
||||
}
|
||||
Event::AssistantText { delta } => pending.push_str(&delta),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
messages.push(Message {
|
||||
role: "assistant".into(),
|
||||
content: pending,
|
||||
});
|
||||
}
|
||||
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('/') {
|
||||
if part.is_empty() || part == "." || part == ".." {
|
||||
bail!("\"{key}\" is not a model key this can resolve");
|
||||
}
|
||||
path.push(part);
|
||||
}
|
||||
if !path.is_file() {
|
||||
bail!("no downloaded model called \"{key}\" -- download it first");
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// An unused loopback port, by asking the OS for one and letting it go.
|
||||
///
|
||||
/// Racy in principle: something else could take it between here and
|
||||
/// llama-server binding. In practice nothing on this machine is hunting
|
||||
/// for ports, and the alternative -- parsing the port back out of the
|
||||
/// server's log -- couples us to its output format for no real gain.
|
||||
fn free_port() -> Result<u16> {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||
Ok(listener.local_addr()?.port())
|
||||
}
|
||||
|
||||
/// Polls until the server says it is ready, or gives up.
|
||||
fn wait_until_ready(endpoint: &str) -> Result<()> {
|
||||
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||
let url = format!("{endpoint}/health");
|
||||
loop {
|
||||
if let Ok(response) = ureq::get(&url).call()
|
||||
&& response.status() == 200
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
bail!("gave up after {}s", READY_TIMEOUT.as_secs());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
/// One streamed completion: posts the conversation, emits each delta as it
|
||||
/// arrives. Emits rather than returns: the transcript those events land
|
||||
/// in is what the next turn reads back, so there is nothing to hand up.
|
||||
fn generate(
|
||||
endpoint: &str,
|
||||
messages: &[Message],
|
||||
sampling: &serde_json::Map<String, serde_json::Value>,
|
||||
cancel: &AtomicBool,
|
||||
sink: &EventSink,
|
||||
) -> Result<()> {
|
||||
let mut body = json!({
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"stream_options": {"include_usage": true},
|
||||
});
|
||||
let map = body.as_object_mut().expect("built as an object");
|
||||
for (key, value) in sampling {
|
||||
map.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
let mut response = ureq::post(format!("{endpoint}/v1/chat/completions"))
|
||||
.header("Content-Type", "application/json")
|
||||
.send_json(&body)
|
||||
.context("asking llama-server to 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,
|
||||
// and blank lines separate events.
|
||||
let Some(payload) = line.strip_prefix("data: ") else {
|
||||
continue;
|
||||
};
|
||||
if payload.trim() == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
let Ok(chunk) = serde_json::from_str::<serde_json::Value>(payload) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(usage) = chunk.get("usage") {
|
||||
if let Some(total) = usage
|
||||
.get("total_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
{
|
||||
tokens = total;
|
||||
}
|
||||
if let Some(prompt) = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
{
|
||||
context = Some(prompt);
|
||||
}
|
||||
}
|
||||
let delta = chunk
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("delta"))
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !delta.is_empty() {
|
||||
let _ = sink.send(Event::AssistantText {
|
||||
delta: delta.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if tokens > 0 {
|
||||
let _ = sink.send(Event::UsageDelta { tokens, context });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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");
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for event in events {
|
||||
transcript.append(event.clone(), 0.0).expect("append");
|
||||
}
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deltas_between_user_messages_are_one_assistant_turn() {
|
||||
let (_dir, path) = transcript_with(&[
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "hello".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "hi ".into(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "there".into(),
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
},
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "again".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "yes".into(),
|
||||
},
|
||||
]);
|
||||
let messages = conversation(&path);
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| (m.role.as_str(), m.content.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
("user", "hello"),
|
||||
("assistant", "hi there"),
|
||||
("user", "again"),
|
||||
("assistant", "yes")
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[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, and
|
||||
/// never has a turn silently dropped from under it.
|
||||
fn an_interrupted_reply_stays_in_the_conversation() {
|
||||
let (_dir, path) = transcript_with(&[
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "count".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "one two".into(),
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
},
|
||||
]);
|
||||
let messages = conversation(&path);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[1].content, "one two");
|
||||
}
|
||||
|
||||
#[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 {
|
||||
state: SessionStatus::Running,
|
||||
},
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "hello".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
Event::Error {
|
||||
message: "something went wrong".into(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "still here".into(),
|
||||
},
|
||||
Event::UsageDelta {
|
||||
tokens: 12,
|
||||
context: Some(12),
|
||||
},
|
||||
]);
|
||||
let messages = conversation(&path);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].content, "hello");
|
||||
assert_eq!(messages[1].content, "still here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Clearing decides what the *model* is given, not just what the
|
||||
/// phone draws. Everything above the marker stays in the transcript
|
||||
/// -- a person can still scroll back to it -- and none of it is sent.
|
||||
fn the_conversation_starts_after_the_last_clear() {
|
||||
let (_dir, path) = transcript_with(&[
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "the long expensive conversation".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "at length".into(),
|
||||
},
|
||||
Event::Cleared,
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "a fresh start".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "cheaply".into(),
|
||||
},
|
||||
]);
|
||||
let messages = conversation(&path);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].content, "a fresh start");
|
||||
assert_eq!(messages[1].content, "cheaply");
|
||||
}
|
||||
|
||||
#[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 {
|
||||
id: None,
|
||||
text: "one".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
Event::Cleared,
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "two".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
Event::Cleared,
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "three".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
]);
|
||||
let messages = conversation(&path);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].content, "three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_key_cannot_climb_out_of_the_models_directory() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
for attempt in ["../../etc/passwd", "unsloth/../../escape.gguf", ""] {
|
||||
assert!(
|
||||
model_path(dir.path(), attempt).is_err(),
|
||||
"{attempt:?} should have been refused",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,483 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! The server deliberately outlives its own restarts badly and its
|
||||
//! children well: 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, which is longer than any of this lives.
|
||||
//!
|
||||
//! **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: not
|
||||
//! just "is my process still there" but "where do I pick it up". Splitting
|
||||
//! them would be two files that can disagree about one process. What that
|
||||
//! takes differs by driver -- a reading position into a log for one spoken
|
||||
//! to over stdio, a port for one spoken to over HTTP -- 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 (start one with
|
||||
//! `--resume`) rather than a wrong adoption.
|
||||
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
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 before it is in the
|
||||
/// transcript, 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 -- the expensive mistake this whole module exists to prevent -- so
|
||||
/// it has to be sayable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Liveness {
|
||||
Alive,
|
||||
Dead,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Record {
|
||||
/// The record for a process this server just started, or `None` when
|
||||
/// the kernel will not say when it started -- which is the same
|
||||
/// answer as "do not adopt this later", and the safe one.
|
||||
pub fn of(pid: u32, detail: Detail) -> Option<Self> {
|
||||
Some(Self {
|
||||
pid,
|
||||
started: stat_of(pid).ok().flatten()?.started,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the process this describes is still the one running under
|
||||
/// that pid.
|
||||
pub fn liveness(&self) -> Liveness {
|
||||
match stat_of(self.pid) {
|
||||
// A different start time is a reused pid, which is a different
|
||||
// process and so definitely not ours.
|
||||
Ok(Some(stat)) if stat.started == self.started => {
|
||||
if stat.exited {
|
||||
Liveness::Dead
|
||||
} else {
|
||||
Liveness::Alive
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) | Ok(None) => Liveness::Dead,
|
||||
Err(_) => Liveness::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path(session_dir: &Path) -> PathBuf {
|
||||
session_dir.join(RECORD_FILE)
|
||||
}
|
||||
|
||||
/// The recorded process and whether it is still there, or `None` when
|
||||
/// nothing usable is recorded.
|
||||
///
|
||||
/// A record that does not parse reads as no record: the only way to get
|
||||
/// one is a crash partway through writing it, and the safe reading of that
|
||||
/// is that this server has no claim on anything.
|
||||
pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> {
|
||||
let text = std::fs::read_to_string(path(session_dir)).ok()?;
|
||||
let record: Record = serde_json::from_str(text.trim_end()).ok()?;
|
||||
let liveness = record.liveness();
|
||||
Some((record, liveness))
|
||||
}
|
||||
|
||||
/// The recorded process if it is definitely still running.
|
||||
///
|
||||
/// One function rather than a read plus a liveness check at each caller:
|
||||
/// every caller wants the same question answered, and the one that forgets
|
||||
/// the second half is the one that starts a duplicate.
|
||||
pub fn live(session_dir: &Path) -> Option<Record> {
|
||||
match recorded(session_dir) {
|
||||
Some((record, Liveness::Alive)) => Some(record),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `record` where [`live`] will find it, atomically.
|
||||
///
|
||||
/// Written to a neighbouring file and renamed over the real name. The
|
||||
/// rename is what makes this safe: a reader sees either the whole old
|
||||
/// record or the whole new one, never a partial.
|
||||
///
|
||||
/// 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. That is the fault this whole module exists to prevent, and writing
|
||||
/// the record carelessly would reintroduce it at its own save point. The
|
||||
/// window is not rare either: 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 -- it
|
||||
/// just cannot be reattached to, which is what the log says.
|
||||
pub fn write(session_dir: &Path, record: &Record) {
|
||||
let path = path(session_dir);
|
||||
let text = match serde_json::to_string(record) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
tracing::error!("couldn't serialize the process record: {err}");
|
||||
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| {
|
||||
use std::io::Write;
|
||||
file.write_all(text.as_bytes())?;
|
||||
file.write_all(b"\n")
|
||||
})
|
||||
.and_then(|()| std::fs::rename(&temp, &path));
|
||||
if let Err(err) = written {
|
||||
tracing::error!(
|
||||
"couldn't record the session process in {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many bytes `path` holds, or 0 if it is not there.
|
||||
///
|
||||
/// Exists so a caller wanting only the length does not have to read the
|
||||
/// file to find it -- [`read_from`] with a large offset answers the
|
||||
/// question, but allocates the whole file on the way.
|
||||
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)
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!("couldn't remove {}: {err}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Grace period between asking a session's process to stop and killing it.
|
||||
///
|
||||
/// Here rather than beside each caller: it is a property of stopping one of
|
||||
/// these, and two drivers plus the manager had written the same five seconds
|
||||
/// down separately, which is three places for it to drift.
|
||||
pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Asks it to stop, then makes sure. Used where a leaked process must
|
||||
/// actually end: a deleted session, or one being replaced.
|
||||
///
|
||||
/// SIGTERM first because the CLI writes its own session file on the way
|
||||
/// out and a SIGKILL would cost whatever it had not flushed; SIGKILL after
|
||||
/// the grace period because a session the phone has deleted must not still
|
||||
/// be running when it looks again.
|
||||
pub fn stop(record: &Record, grace: std::time::Duration) {
|
||||
if record.liveness() != Liveness::Alive {
|
||||
return;
|
||||
}
|
||||
signal(record.pid, libc::SIGTERM);
|
||||
let record = record.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(grace).await;
|
||||
kill_if_still_there(&record, grace);
|
||||
});
|
||||
}
|
||||
|
||||
/// Waits for processes already asked to stop, and kills whichever have
|
||||
/// not, for a caller that is about to exit.
|
||||
///
|
||||
/// The waiting cannot be [`stop`]'s here, and that is the whole reason
|
||||
/// this exists: the kill it leaves behind is a timer inside the tokio
|
||||
/// runtime, and a runtime that is shutting down never runs it. That is
|
||||
/// how the backend's original `shutdown_all` leaked the processes it had
|
||||
/// just asked to stop -- it reported them stopped, too, which is worse
|
||||
/// than not asking.
|
||||
///
|
||||
/// 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, and a server does not sit for a minute on the way out.
|
||||
pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
|
||||
/// How often to look. Short enough that the ordinary case -- 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;
|
||||
for record in records {
|
||||
while record.liveness() == Liveness::Alive && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(LOOK);
|
||||
}
|
||||
kill_if_still_there(record, grace);
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of both paths above: a process that was asked to stop and did
|
||||
/// not is killed. Written once because the two callers differ only in how
|
||||
/// they wait, and a grace period that means one thing in one of them and
|
||||
/// something else in the other is exactly the drift `STOP_GRACE` was
|
||||
/// gathered here to prevent.
|
||||
fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
|
||||
if record.liveness() == Liveness::Alive {
|
||||
tracing::warn!(
|
||||
"session process {} did not stop within {:?}; killing it",
|
||||
record.pid,
|
||||
grace
|
||||
);
|
||||
signal(record.pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
fn signal(pid: u32, signal: libc::c_int) {
|
||||
// SAFETY: `kill` with a positive pid touches only that process, and
|
||||
// the pid came from a record whose start time was just confirmed to
|
||||
// match -- so it is still the process this server started, not a
|
||||
// reused number. A failure (already gone) is nothing to act on.
|
||||
unsafe {
|
||||
libc::kill(pid as libc::pid_t, signal);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 therefore 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`.
|
||||
/// 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 the entry it leaves behind has
|
||||
/// the same pid *and* the same start time, so a process that has
|
||||
/// plainly finished goes on answering "still there" for as long as
|
||||
/// nothing reaps it. None of this module's callers want that answer: a
|
||||
/// session whose CLI has exited is over whether or not the status has
|
||||
/// been collected, and reporting it alive makes `Exited` unsayable --
|
||||
/// the session shows `unknown`, its Start button never appears, and
|
||||
/// stopping it says there is nothing to stop.
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
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, which is the other thing entirely.
|
||||
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
|
||||
.nth(18)
|
||||
.ok_or_else(unreadable)?
|
||||
.parse()
|
||||
.map_err(|_| unreadable())?;
|
||||
Ok(Some(Stat { started, exited }))
|
||||
}
|
||||
|
||||
/// Reads `path` from `from`, returning what is there and where reading
|
||||
/// reached. A file that has been 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) {
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok((Vec::new(), from)),
|
||||
Err(err) => return Err(err).with_context(|| format!("open {}", path.display())),
|
||||
};
|
||||
let len = file
|
||||
.metadata()
|
||||
.with_context(|| format!("stat {}", path.display()))?
|
||||
.len();
|
||||
let from = if from > len { 0 } else { from };
|
||||
file.seek(SeekFrom::Start(from))
|
||||
.with_context(|| format!("seek {}", path.display()))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
let read = from + bytes.len() as u64;
|
||||
Ok((bytes, read))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn this_process_is_alive_and_a_wrong_start_time_is_not() {
|
||||
let mine = Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 })
|
||||
.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()
|
||||
};
|
||||
assert_eq!(recycled.liveness(), Liveness::Dead);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_round_trips_through_the_padded_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut record = Record::of(std::process::id(), Detail::Stdio { stdout_read: 4096 })
|
||||
.expect("start time");
|
||||
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));
|
||||
|
||||
clear(dir.path());
|
||||
assert_eq!(live(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_leaves_no_temporary_behind_and_stays_readable() {
|
||||
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);
|
||||
assert_eq!(
|
||||
live(dir.path()),
|
||||
Some(record.clone()),
|
||||
"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)
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name != RECORD_FILE)
|
||||
.collect();
|
||||
assert!(stray.is_empty(), "left behind {stray:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_or_unreadable_record_is_not_live() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
assert_eq!(live(dir.path()), None);
|
||||
|
||||
// Pid 0 is never a process we started.
|
||||
write(
|
||||
dir.path(),
|
||||
&Record {
|
||||
pid: 0,
|
||||
started: 1,
|
||||
detail: Detail::Stdio { stdout_read: 0 },
|
||||
},
|
||||
);
|
||||
assert_eq!(live(dir.path()), None);
|
||||
|
||||
std::fs::write(dir.path().join(RECORD_FILE), "not json").expect("write");
|
||||
assert_eq!(live(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_resumes_from_an_offset_and_restarts_on_truncation() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("stdout.log");
|
||||
std::fs::write(&path, b"hello world").expect("write");
|
||||
|
||||
let (bytes, read) = read_from(&path, 6).expect("read");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//! 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;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::driver::{Event, SessionStatus, context_after};
|
||||
|
||||
/// One transcript line: an [`Event`] plus its position and time. The event
|
||||
/// is flattened so the wire shape stays one flat object.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SeqEvent {
|
||||
pub seq: u64,
|
||||
/// Epoch seconds.
|
||||
pub ts: f64,
|
||||
#[serde(flatten)]
|
||||
pub event: Event,
|
||||
}
|
||||
|
||||
pub struct Transcript {
|
||||
file: File,
|
||||
next_seq: u64,
|
||||
last_status: Option<SessionStatus>,
|
||||
last_activity: Option<f64>,
|
||||
context_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
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 cost of starting every session -- which is paid per session,
|
||||
// at the point a restart is trying to be quick.
|
||||
let existing = read_after(path, 0)?;
|
||||
let last_seq = existing.last().map(|entry| entry.seq).unwrap_or(0);
|
||||
let last_status = existing.iter().rev().find_map(|entry| match entry.event {
|
||||
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)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("open transcript {}", path.display()))?;
|
||||
Ok(Self {
|
||||
file,
|
||||
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 yet and this is the only thing
|
||||
/// it knows. Assuming idle claimed a session was waiting for you when
|
||||
/// it had exited hours earlier, and would now also claim it of one
|
||||
/// whose process is still mid-turn.
|
||||
///
|
||||
/// `None` for a transcript that never carried a status, which is a new
|
||||
/// session and genuinely has no prior state.
|
||||
pub fn last_status(&self) -> Option<SessionStatus> {
|
||||
self.last_status
|
||||
}
|
||||
|
||||
/// When this session last did anything, as of opening.
|
||||
///
|
||||
/// Read from the file for the same reason [`Transcript::last_status`]
|
||||
/// is, and it is the same mistake in the other direction: a restarting
|
||||
/// server has been told nothing, and 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 -- which is sorted by
|
||||
/// this -- coming back in an order that means nothing, with the
|
||||
/// conversation somebody was in the middle of buried among sessions
|
||||
/// untouched for days.
|
||||
///
|
||||
/// `None` for a transcript with no lines in it, which is a session that
|
||||
/// genuinely has not done anything yet. Its caller answers that with
|
||||
/// when the session was created -- not with the clock, which would say
|
||||
/// a session nobody has ever sent anything to was active a moment ago,
|
||||
/// every time this server started.
|
||||
pub fn last_activity(&self) -> Option<f64> {
|
||||
self.last_activity
|
||||
}
|
||||
|
||||
/// How much context the session was holding, as of opening.
|
||||
///
|
||||
/// `None` for a transcript nothing has been measured in -- a new
|
||||
/// session, one whose dialect never reported usage, or one whose last
|
||||
/// word on the subject was a clear. That is not zero, and it is why
|
||||
/// this is an option: 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 full.
|
||||
pub fn context_tokens(&self) -> Option<u64> {
|
||||
self.context_tokens
|
||||
}
|
||||
|
||||
/// Appends `event`, assigning it the next sequence number. Flushed per
|
||||
/// event: each line is tiny, and the transcript is the source of truth
|
||||
/// a crash must not lose the tail of.
|
||||
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
|
||||
let entry = SeqEvent {
|
||||
seq: self.next_seq,
|
||||
ts,
|
||||
event,
|
||||
};
|
||||
let mut line = serde_json::to_string(&entry).context("serialize event")?;
|
||||
line.push('\n');
|
||||
self.file
|
||||
.write_all(line.as_bytes())
|
||||
.context("append to transcript")?;
|
||||
self.next_seq += 1;
|
||||
Ok(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/// A window of the transcript ending just before `before`, newest-biased.
|
||||
///
|
||||
/// The screen opens on the end of a conversation, not the start of it, 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 because that is exactly what it was doing.
|
||||
///
|
||||
/// `before` pages backwards for history somebody actually scrolls to. Only
|
||||
/// the window is parsed; see [`Indexed`] for why that is the whole cost of
|
||||
/// this call.
|
||||
pub fn read_window(path: &Path, before: Option<u64>, limit: usize) -> Result<Vec<SeqEvent>> {
|
||||
let Some(indexed) = Indexed::read(path)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let end = match before {
|
||||
Some(before) => indexed.first_at_or_after(before)?,
|
||||
None => indexed.lines.len(),
|
||||
};
|
||||
indexed.parse(end.saturating_sub(limit)..end)
|
||||
}
|
||||
|
||||
/// 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 its view from the newest
|
||||
/// window than by receiving everything it missed. 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 (`transcript`'s page is 80) so an ordinary blip -- a
|
||||
/// phone asleep, a tunnel reconnecting, a backend restart -- still streams
|
||||
/// continuously, and only a genuine backlog changes mode.
|
||||
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 already has, the
|
||||
/// other replaces it. Collapsing them into a list would leave the client
|
||||
/// splicing a window onto rows it has no way to know are no longer
|
||||
/// adjacent to it -- a seam that looks exactly 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, exactly as it is when a
|
||||
/// session is first opened.
|
||||
Restart(Vec<SeqEvent>),
|
||||
}
|
||||
|
||||
/// Everything after `after`, or the newest `limit` when that is more than
|
||||
/// `limit` events.
|
||||
///
|
||||
/// The window is chosen before anything is parsed, which matters most in
|
||||
/// the case that looks least interesting: a subscriber with no cursor at
|
||||
/// all asks for the whole conversation and is going to be handed the last
|
||||
/// [`CATCH_UP_LIMIT`] events of it. Parsing the discarded prefix first is
|
||||
/// the whole file's worth of work to produce 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()));
|
||||
};
|
||||
let end = indexed.lines.len();
|
||||
let start = indexed.first_at_or_after(after.saturating_add(1))?;
|
||||
if end - start > limit {
|
||||
return Ok(CatchUp::Restart(indexed.parse(end - limit..end)?));
|
||||
}
|
||||
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());
|
||||
};
|
||||
let start = indexed.first_at_or_after(after.saturating_add(1))?;
|
||||
indexed.parse(start..indexed.lines.len())
|
||||
}
|
||||
|
||||
/// The transcript's lines located but not read, so that a reader can find
|
||||
/// the range it wants and parse only that.
|
||||
///
|
||||
/// Both readers above want a *range* of the file -- everything after a
|
||||
/// cursor, or the window before one -- and both used to reach it by parsing
|
||||
/// every line and discarding the ones outside it. That is 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 it took the same 500 ms whichever page was asked
|
||||
/// for, since the work was the file rather than the window. A phone paging
|
||||
/// back through history pays it per page, and every stream reconnect pays
|
||||
/// it again to discover there is nothing new.
|
||||
///
|
||||
/// Sequence numbers only ever increase -- the writer assigns them, one per
|
||||
/// appended line, continuing from the last on reopen -- so the boundary of
|
||||
/// a range is a bisection. This parses one line per halving, and the caller
|
||||
/// parses only what it is going to return. The file is still read whole,
|
||||
/// which is a deliberate stop: finding the tail without reading forwards
|
||||
/// 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,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| format!("read transcript {}", path.display()));
|
||||
}
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < text.len() {
|
||||
let end = text[start..]
|
||||
.find('\n')
|
||||
.map(|at| start + at)
|
||||
.unwrap_or(text.len());
|
||||
if !text[start..end].trim().is_empty() {
|
||||
lines.push(start..end);
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
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; it is append-only and nothing else writes it. 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 of the file the bad line hid.
|
||||
fn first_at_or_after(&self, seq: u64) -> Result<usize> {
|
||||
let (mut low, mut high) = (0, self.lines.len());
|
||||
while low < high {
|
||||
let middle = (low + high) / 2;
|
||||
if self.seq_at(middle)? < seq {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
seq: u64,
|
||||
}
|
||||
let line = &self.text[self.lines[index].clone()];
|
||||
let entry: JustSeq = serde_json::from_str(line)
|
||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))?;
|
||||
Ok(entry.seq)
|
||||
}
|
||||
|
||||
fn parse(&self, range: Range<usize>) -> Result<Vec<SeqEvent>> {
|
||||
self.lines[range]
|
||||
.iter()
|
||||
.map(|at| {
|
||||
serde_json::from_str(&self.text[at.clone()])
|
||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::driver::{QuestionOption, SessionStatus};
|
||||
|
||||
fn text(delta: &str) -> Event {
|
||||
Event::AssistantText {
|
||||
delta: delta.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigns_increasing_seqs_and_replays_after_a_cursor() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
assert_eq!(transcript.append(text("a"), 1.0).expect("append").seq, 1);
|
||||
assert_eq!(transcript.append(text("b"), 2.0).expect("append").seq, 2);
|
||||
assert_eq!(transcript.append(text("c"), 3.0).expect("append").seq, 3);
|
||||
|
||||
let replay = read_after(&path, 1).expect("read");
|
||||
assert_eq!(replay.len(), 2);
|
||||
assert_eq!(replay[0].seq, 2);
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_continues_the_numbering() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
transcript.append(text("a"), 1.0).expect("append");
|
||||
transcript.append(text("b"), 2.0).expect("append");
|
||||
drop(transcript);
|
||||
|
||||
let mut reopened = Transcript::open(&path).expect("reopen");
|
||||
assert_eq!(reopened.append(text("c"), 3.0).expect("append").seq, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_backlog_continues_and_a_long_one_restarts() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for n in 0..10 {
|
||||
transcript
|
||||
.append(text(&n.to_string()), 0.0)
|
||||
.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");
|
||||
};
|
||||
assert_eq!(events.len(), 5);
|
||||
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(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_reports_the_state_it_was_last_left_in() {
|
||||
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");
|
||||
transcript
|
||||
.append(
|
||||
Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
},
|
||||
1.0,
|
||||
)
|
||||
.expect("append");
|
||||
transcript
|
||||
.append(
|
||||
Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_is_the_events_before_a_cursor_and_nothing_else() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for n in 1..=10 {
|
||||
transcript
|
||||
.append(text(&n.to_string()), 0.0)
|
||||
.expect("append");
|
||||
}
|
||||
|
||||
// No cursor is the newest page, which is what opening a session asks for.
|
||||
let newest = read_window(&path, None, 3).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), 3).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, 100).expect("window").len(), 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), 3).expect("window").is_empty());
|
||||
assert!(
|
||||
read_window(&dir.path().join("nope.jsonl"), None, 3)
|
||||
.expect("window")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_reads_as_empty() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
assert!(
|
||||
read_after(&dir.path().join("nope.jsonl"), 0)
|
||||
.expect("read")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_every_event_shape() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let events = vec![
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "hi".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
text("hello"),
|
||||
Event::ToolStart {
|
||||
id: "t1".into(),
|
||||
tool: "bash".into(),
|
||||
input: serde_json::json!({"command": "ls"}),
|
||||
},
|
||||
Event::ToolUpdate {
|
||||
id: "t1".into(),
|
||||
output: "partial".into(),
|
||||
},
|
||||
Event::ToolEnd {
|
||||
id: "t1".into(),
|
||||
output: "done".into(),
|
||||
},
|
||||
Event::Image {
|
||||
image: "img1".into(),
|
||||
about: None,
|
||||
},
|
||||
Event::Question {
|
||||
id: "q1".into(),
|
||||
prompt: "Allow?".into(),
|
||||
header: None,
|
||||
options: vec![QuestionOption::plain("Yes"), QuestionOption::plain("No")],
|
||||
multi_select: false,
|
||||
about: None,
|
||||
},
|
||||
Event::Answered {
|
||||
id: "q1".into(),
|
||||
answers: vec!["Yes".into()],
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
},
|
||||
Event::UsageDelta {
|
||||
tokens: 42,
|
||||
context: Some(42),
|
||||
},
|
||||
Event::Error {
|
||||
message: "boom".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for event in &events {
|
||||
transcript.append(event.clone(), 0.0).expect("append");
|
||||
}
|
||||
|
||||
let replayed: Vec<Event> = read_after(&path, 0)
|
||||
.expect("read")
|
||||
.into_iter()
|
||||
.map(|entry| entry.event)
|
||||
.collect();
|
||||
assert_eq!(replayed, events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! 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 nothing to wrap and
|
||||
//! no host for it to appear to honour.
|
||||
//!
|
||||
//! The quoting, the forced ssh options and the remote script are
|
||||
//! `crate::ssh`'s, which this dispatches to. That split is deliberate:
|
||||
//! this module decides *which* transport, that one knows what a correct
|
||||
//! ssh invocation is.
|
||||
//!
|
||||
//! Known second operation, not built because nothing needs it yet: a
|
||||
//! managed `llama-server` is spawned as a process but then spoken to over
|
||||
//! HTTP, so a remote one needs a forwarded port (`ssh -L`) as well. A
|
||||
//! transport is eventually "run this" plus "reach this port", where the
|
||||
//! second is a no-op locally. See PLAN.md's SSH section.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::process::Child;
|
||||
|
||||
use crate::config::SshConfig;
|
||||
|
||||
/// What a driver needs run in order to exist as a process.
|
||||
///
|
||||
/// Deliberately just the three things every transport can carry. Anything
|
||||
/// a particular machine needs -- a port, a key, extra ssh options -- is
|
||||
/// the transport's own configuration, not something a driver states.
|
||||
pub struct Launch {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Launch {
|
||||
pub fn new(program: impl Into<String>, args: Vec<String>, cwd: Option<&Path>) -> Self {
|
||||
Self {
|
||||
program: program.into(),
|
||||
args,
|
||||
cwd: cwd.map(Path::to_path_buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 is
|
||||
/// asked a question and answers within one call, so pipes this server
|
||||
/// drains are right and dying with it is 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 again --
|
||||
/// see `session::process`.
|
||||
pub enum Streams {
|
||||
/// Pipes owned by this server; the child is killed when they drop.
|
||||
Piped,
|
||||
/// 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,
|
||||
stderr: Stdio,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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. Carries the
|
||||
/// setup's name only to say where things are running.
|
||||
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 {
|
||||
name: setup.name.clone(),
|
||||
ssh: ssh.clone(),
|
||||
},
|
||||
None => Self::Here,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 that is not on the remote PATH -- so each says its own
|
||||
/// thing rather than one message hedging between them.
|
||||
pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result<Child> {
|
||||
let host = match self {
|
||||
Self::Here => None,
|
||||
Self::Ssh { ssh, .. } => Some(ssh),
|
||||
};
|
||||
let mut command = tokio::process::Command::from(crate::ssh::command(
|
||||
host,
|
||||
&launch.program,
|
||||
&launch.args,
|
||||
launch.cwd.as_deref(),
|
||||
));
|
||||
match streams {
|
||||
Streams::Piped => {
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
}
|
||||
Streams::Detached {
|
||||
stdin,
|
||||
stdout,
|
||||
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 -- which is how a terminal or a
|
||||
// supervisor stops it -- does not travel to a session that
|
||||
// is meant to survive being stopped.
|
||||
command.process_group(0);
|
||||
}
|
||||
}
|
||||
command.spawn().with_context(|| match self {
|
||||
Self::Ssh { name, .. } => format!(
|
||||
"couldn't start ssh to run \"{}\" on {name} -- is the ssh client installed \
|
||||
here?",
|
||||
launch.program,
|
||||
),
|
||||
Self::Here => format!(
|
||||
"couldn't run \"{}\" on this machine -- is it installed and on PATH? If it \
|
||||
lives on another machine, give the session a host to run on.",
|
||||
launch.program,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `launch` to completion and returns its stdout, blocking.
|
||||
///
|
||||
/// The synchronous twin of `capture`, for callers that are already on a
|
||||
/// blocking task and would otherwise need a runtime to ask a machine a
|
||||
/// question. Both build the invocation the same way -- see
|
||||
/// `crate::ssh::command` -- so there is still only one description of
|
||||
/// what running something on another machine means.
|
||||
pub fn capture_blocking(&self, launch: &Launch) -> Result<String> {
|
||||
let host = match self {
|
||||
Self::Here => None,
|
||||
Self::Ssh { ssh, .. } => Some(ssh),
|
||||
};
|
||||
let output =
|
||||
crate::ssh::command(host, &launch.program, &launch.args, launch.cwd.as_deref())
|
||||
.output()
|
||||
.with_context(|| {
|
||||
format!("couldn't run \"{}\" {}", launch.program, self.describe())
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
anyhow::bail!(if stderr.is_empty() {
|
||||
format!("couldn't reach it ({})", output.status)
|
||||
} else {
|
||||
stderr
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
Self::Ssh { name, ssh } => format!("on {name} ({})", ssh.address),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user