Providers and hosts: what runs, and where, as independent choices

A session now names a provider (what: driver kind, command, models) and
optionally a host (where: an ssh target). Keeping them independent is
what the real setup needs -- the backend runs where the phone can reach
it, which isn't where the CLI is installed -- and it means any provider
can be sent to any host rather than a machine being baked into one.

The first provider is claude-cli, named for the CLI rather than bare
"claude", which would suggest the credit-billed API. A fresh config is
seeded with it so a new install has something to spawn and a worked
example to edit; echo stays a built-in provider needing no config.

ssh.rs builds the child process either way: locally, or `ssh -T` with
BatchMode and keepalives, every argument single-quoted for the remote
shell (a working directory that tries to close the quote and start a
command is covered by a test), and `exec` so dropping the connection
takes the CLI down instead of orphaning it.

App: the spawn screen reads /providers and /hosts instead of hardcoded
lists, so config changes need no rebuild. Chip rows are FlowRow, fixing
the reported bug where a row of models that didn't fit wrapped *inside*
each chip -- one letter of "haiku" per line -- rather than onto a second
line.

Verified: 29 tests, clippy clean; the same claude-cli provider run once
locally and once over ssh, with the remote one visibly in a different
environment; an unknown host name refused with the configured list; and
the spawn screen on the emulator showing server-driven providers, hosts,
and models that wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 03:34:33 -04:00
1 parent 91bbc73ae5
commit fff1fb49e8
12 files changed
+833 -170

No files matched your search

+122 -32
View File
@@ -23,7 +23,7 @@ use anyhow::{Context, Result, bail};
use serde::Serialize;
use tokio::sync::{broadcast, mpsc};
use crate::config::{Config, SessionConfig, SessionKind, TokenEntry};
use crate::config::{Config, DriverKind, HostConfig, ProviderConfig, SessionConfig, TokenEntry};
use claude::ClaudeDriver;
use driver::{Driver, Event, ImageRef, SessionStatus};
use echo::EchoDriver;
@@ -40,9 +40,10 @@ pub fn now() -> f64 {
/// What the phone needs to spawn a session -- the spawn screen's fields.
pub struct SpawnSpec {
pub kind: SessionKind,
pub title: Option<String>,
pub provider: String,
/// Name of a configured host to run on; absent runs on this machine.
pub host: Option<String>,
pub title: Option<String>,
pub model: Option<String>,
pub cwd: Option<PathBuf>,
pub permission_mode: Option<String>,
@@ -53,10 +54,11 @@ pub struct SpawnSpec {
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub id: String,
pub kind: SessionKind,
pub title: String,
pub provider: String,
/// Name of the host it runs on; absent means the backend machine.
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -154,9 +156,9 @@ impl LiveSession {
fn info(&self) -> SessionInfo {
SessionInfo {
id: self.meta.id.clone(),
kind: self.meta.kind,
title: self.meta.title.clone(),
provider: self.meta.provider.clone(),
host: self.meta.host.clone(),
title: self.meta.title.clone(),
model: self.shared.model.lock().unwrap().clone(),
cwd: self.meta.cwd.clone(),
status: *self.shared.status.lock().unwrap(),
@@ -192,10 +194,13 @@ impl SessionManager {
let mut live = HashMap::new();
for meta in &config.sessions {
// One unlaunchable session (e.g. a corrupt transcript) shows as
// exited rather than taking the whole server down with it; it
// can still be deleted from the phone.
match launch(meta.clone(), &data_dir) {
// One unlaunchable session -- a corrupt transcript, an
// unreachable ssh host, a provider that was edited away --
// shows as exited rather than taking the whole server down
// with it, and can still be deleted from the phone.
match resolve(&config, meta)
.and_then(|(provider, host)| launch(meta.clone(), &provider, host.as_ref(), &data_dir))
{
Ok(session) => {
live.insert(meta.id.clone(), session);
}
@@ -204,11 +209,38 @@ impl SessionManager {
}
}
}
Ok(Self {
let manager = Self {
config_path,
data_dir,
inner: RwLock::new(Inner { config, live }),
})
};
manager.seed_providers()?;
Ok(manager)
}
/// Writes a starting `claude-cli` provider into a config that has none,
/// so a fresh install has something to spawn and a worked example of
/// the schema to edit. Runs local by default -- a session is given a
/// host when the CLI lives elsewhere, which is a per-session choice.
fn seed_providers(&self) -> Result<()> {
let mut inner = self.inner.write().unwrap();
if !inner.config.providers.is_empty() {
return Ok(());
}
let mut candidate = inner.config.clone();
candidate.providers.push(ProviderConfig {
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: None,
models: ["fable", "opus", "sonnet", "haiku"]
.iter()
.map(|model| model.to_string())
.collect(),
});
candidate.save(&self.config_path)?;
inner.config = candidate;
tracing::info!("no providers configured -- added a default \"claude-cli\" provider");
Ok(())
}
pub fn tokens(&self) -> Vec<TokenEntry> {
@@ -238,9 +270,9 @@ impl SessionManager {
Some(session) => session.info(),
None => SessionInfo {
id: meta.id.clone(),
kind: meta.kind,
title: meta.title.clone(),
provider: meta.provider.clone(),
host: meta.host.clone(),
title: meta.title.clone(),
model: meta.model.clone(),
cwd: meta.cwd.clone(),
status: SessionStatus::Exited,
@@ -255,25 +287,64 @@ impl SessionManager {
self.inner.read().unwrap().live.get(id).cloned()
}
/// Every provider this server offers, built-in echo included.
pub fn providers(&self) -> Vec<ProviderConfig> {
self.inner.read().unwrap().config.providers()
}
/// Every configured host a session can be run on. Running on the
/// backend itself is always available and is not in this list.
pub fn hosts(&self) -> Vec<HostConfig> {
self.inner.read().unwrap().config.hosts.clone()
}
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
let mut inner = self.inner.write().unwrap();
let provider = inner.config.provider(&spec.provider).with_context(|| {
format!(
"no provider named \"{}\" -- configured: {}",
spec.provider,
inner
.config
.providers()
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>()
.join(", "),
)
})?;
let id = unique_id(&inner.config);
let title = spec
.title
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| default_title(spec.kind));
.unwrap_or_else(|| format!("{} session", provider.name));
let host = match &spec.host {
Some(name) => Some(inner.config.host(name).with_context(|| {
format!(
"no host named \"{name}\" -- configured: {}",
inner
.config
.hosts
.iter()
.map(|host| host.name.clone())
.collect::<Vec<_>>()
.join(", "),
)
})?),
None => None,
};
let meta = SessionConfig {
id: id.clone(),
kind: spec.kind,
title,
provider: provider.name.clone(),
host: spec.host,
model: spec.model,
title,
model: spec.model.or_else(|| provider.models.first().cloned()),
cwd: spec.cwd,
permission_mode: spec.permission_mode,
created: now(),
};
let session = launch(meta.clone(), &self.data_dir)?;
let session = launch(meta.clone(), &provider, host.as_ref(), &self.data_dir)?;
let mut candidate = inner.config.clone();
candidate.sessions.push(meta);
if let Err(err) = candidate.save(&self.config_path) {
@@ -334,11 +405,22 @@ impl SessionManager {
}
}
fn default_title(kind: SessionKind) -> String {
match kind {
SessionKind::Echo => "Echo session".to_string(),
SessionKind::Claude => "Claude session".to_string(),
}
/// The provider and host a session's config names, or a message saying
/// which one is missing. Both are looked up fresh at every launch, so
/// editing either takes effect on the next respawn.
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(ProviderConfig, Option<HostConfig>)> {
let provider = config
.provider(&meta.provider)
.with_context(|| format!("no provider named \"{}\"", meta.provider))?;
let host = match &meta.host {
Some(name) => Some(
config
.host(name)
.with_context(|| format!("no host named \"{name}\""))?,
),
None => None,
};
Ok((provider, host))
}
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
@@ -363,7 +445,12 @@ fn unique_id(config: &Config) -> String {
/// Creates the session directory, opens its transcript (continuing the
/// sequence numbering if one exists), starts the driver, and spawns the
/// event pump connecting them.
fn launch(meta: SessionConfig, data_dir: &Path) -> Result<Arc<LiveSession>> {
fn launch(
meta: SessionConfig,
provider: &ProviderConfig,
host: Option<&HostConfig>,
data_dir: &Path,
) -> Result<Arc<LiveSession>> {
let dir = data_dir.join(&meta.id);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let transcript_path = dir.join("transcript.jsonl");
@@ -377,9 +464,11 @@ fn launch(meta: SessionConfig, data_dir: &Path) -> Result<Arc<LiveSession>> {
model: Mutex::new(meta.model.clone()),
});
let driver: Box<dyn Driver> = match meta.kind {
SessionKind::Echo => Box::new(EchoDriver::new(sink.clone())),
SessionKind::Claude => Box::new(ClaudeDriver::spawn(&meta, &dir, sink.clone())?),
let driver: Box<dyn Driver> = match provider.kind {
DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
DriverKind::ClaudeCli => {
Box::new(ClaudeDriver::spawn(&meta, provider, host, &dir, sink.clone())?)
}
};
tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone()));
@@ -431,9 +520,9 @@ mod tests {
fn echo_spec() -> SpawnSpec {
SpawnSpec {
kind: SessionKind::Echo,
title: None,
provider: crate::config::ECHO_PROVIDER.to_string(),
host: None,
title: None,
model: None,
cwd: None,
permission_mode: None,
@@ -486,7 +575,8 @@ mod tests {
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
assert_eq!(info.title, "Echo session");
// Untitled sessions are named after the provider that runs them.
assert_eq!(info.title, "echo session");
// Persisted: a fresh load of the config file knows the session.
let persisted = Config::load(&config_path).expect("reload config");
assert_eq!(persisted.sessions.len(), 1);