A setup is a machine, and it carries what that machine can run

Providers and hosts were two independent lists, and a session named one of
each. They were never independent: a provider only exists on a machine
where that program is installed, so the spawn screen offered the whole
cross-product, including "the Claude CLI on the box that hasn't got it".
The picker could not know, because nothing in the model said.

Now a setup is a machine -- optional ssh, plus the providers it has -- and
spawning is two choices in order: pick a setup, then one of its providers.
The impossible pairs stop being expressible rather than being validated
against. Provider names are unique within a setup and only within one, so
two machines can each have a `claude-cli`, which was previously either a
name collision or two entries called things like "claude" and "claude on
the vm".

It also settles the "Run on" problem properly. That control was offered for
every provider but honoured only by the Claude driver -- an echo session
sent to a host ran locally and said otherwise. There is no such control
now: the machine is chosen first, and echo is a provider of the setup with
no ssh, where it belongs, since it runs in-process and has no transport to
cross.

The built-in echo provider is gone as a concept. It used to be conjured at
read time and never written to the file, which meant a provider nobody
could see or edit; it is now seeded into the config on first run alongside
claude-cli. What the file says is what there is, and deleting it is a
choice rather than a state to be repaired.

A config in the old shape is refused with instructions rather than loaded.
`Config` defaults unknown fields away, so `providers:` and `hosts:` would
otherwise have vanished into an empty config that was then seeded over --
a migration nobody would notice until their setups were gone.

Verified against a running server and on the emulator: a fresh install
seeds "this machine" with echo and claude-cli and the file reads cleanly;
a two-setup config lists both with their own providers; spawning on a
setup works and the session row names it; asking for a provider a setup
lacks says which it offers, and an unknown setup says which exist. On the
phone, selecting "dev vm" narrows the provider chips to that machine's one
and shows its address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 12:56:14 -04:00
1 parent 7cf36005ae
commit ecac404fd4
10 files changed
+422 -328

No files matched your search

+74 -84
View File
@@ -25,7 +25,7 @@ use anyhow::{Context, Result, bail};
use serde::Serialize;
use tokio::sync::{broadcast, mpsc};
use crate::config::{Config, DriverKind, HostConfig, ProviderConfig, SessionConfig, TokenEntry};
use crate::config::{Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, TokenEntry};
use claude::ClaudeDriver;
use driver::{Driver, Event, ImageRef, SessionStatus};
use echo::EchoDriver;
@@ -47,9 +47,9 @@ pub fn now() -> f64 {
/// What the phone needs to spawn a session -- the spawn screen's fields.
pub struct SpawnSpec {
/// Which machine, and which of its providers.
pub setup: 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>,
@@ -64,9 +64,8 @@ pub struct SpawnSpec {
pub struct SessionInfo {
pub id: 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>,
/// The machine it runs on.
pub setup: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
@@ -172,7 +171,7 @@ impl LiveSession {
SessionInfo {
id: self.meta.id.clone(),
provider: self.meta.provider.clone(),
host: self.meta.host.clone(),
setup: self.meta.setup.clone(),
title: self.meta.title.clone(),
model: self.shared.model.lock().unwrap().clone(),
cwd: self.meta.cwd.clone(),
@@ -216,14 +215,8 @@ impl SessionManager {
// 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,
&models_dir,
)
match resolve(&config, meta).and_then(|(setup, provider)| {
launch(meta.clone(), &setup, &provider, &data_dir, &models_dir)
}) {
Ok(session) => {
live.insert(meta.id.clone(), session);
@@ -239,7 +232,7 @@ impl SessionManager {
models_dir,
inner: RwLock::new(Inner { config, live }),
};
manager.seed_providers()?;
manager.seed_setup()?;
Ok(manager)
}
@@ -247,24 +240,22 @@ impl SessionManager {
/// 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<()> {
/// Gives a fresh install something to spawn. Only ever fires when
/// there are no setups at all -- deleting the last one is a choice,
/// not a state to be repaired.
fn seed_setup(&self) -> Result<()> {
let mut inner = self.inner.write().unwrap();
if !inner.config.providers.is_empty() {
if !inner.config.setups.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.setups.push(Config::seed());
candidate.save(&self.config_path)?;
inner.config = candidate;
tracing::info!("no providers configured -- added a default \"claude-cli\" provider");
tracing::info!(
"no setups configured -- added \"{}\"",
crate::config::LOCAL_SETUP
);
Ok(())
}
@@ -311,8 +302,8 @@ impl SessionManager {
Some(session) => session.info(),
None => SessionInfo {
id: meta.id.clone(),
setup: meta.setup.clone(),
provider: meta.provider.clone(),
host: meta.host.clone(),
title: meta.title.clone(),
model: meta.model.clone(),
cwd: meta.cwd.clone(),
@@ -329,55 +320,45 @@ impl SessionManager {
}
/// 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()
/// Every machine this server can run something on, each with what it
/// can run. One list rather than two, because the pair is the choice.
pub fn setups(&self) -> Vec<SetupConfig> {
self.inner.read().unwrap().config.setups.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 setup = inner
.config
.setup(&spec.setup)
.with_context(|| {
format!(
"no setup named \"{}\" -- configured: {}",
spec.setup,
names(inner.config.setups.iter().map(|s| s.name.as_str())),
)
})?
.clone();
let provider = setup
.provider(&spec.provider)
.with_context(|| {
format!(
"setup \"{}\" has no provider named \"{}\" -- it offers: {}",
spec.setup,
spec.provider,
names(setup.providers.iter().map(|p| p.name.as_str())),
)
})?
.clone();
let id = unique_id(&inner.config);
let title = spec
.title
.filter(|title| !title.trim().is_empty())
.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(),
setup: setup.name.clone(),
provider: provider.name.clone(),
host: spec.host,
title,
model: spec.model.or_else(|| provider.models.first().cloned()),
cwd: spec.cwd,
@@ -388,8 +369,8 @@ impl SessionManager {
let session = launch(
meta.clone(),
&setup,
&provider,
host.as_ref(),
&self.data_dir,
&self.models_dir,
)?;
@@ -456,19 +437,28 @@ impl SessionManager {
/// 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))
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, ProviderConfig)> {
let setup = config
.setup(&meta.setup)
.with_context(|| format!("no setup named \"{}\"", meta.setup))?;
let provider = setup.provider(&meta.provider).with_context(|| {
format!(
"setup \"{}\" has no provider named \"{}\"",
meta.setup, meta.provider
)
})?;
Ok((setup.clone(), provider.clone()))
}
/// Names for a failure message: what there is, so the reader can see what
/// they meant instead of only that they were wrong.
fn names<'a>(all: impl Iterator<Item = &'a str>) -> String {
let all: Vec<_> = all.collect();
if all.is_empty() {
"none".to_string()
} else {
all.join(", ")
}
}
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
@@ -495,8 +485,8 @@ fn unique_id(config: &Config) -> String {
/// event pump connecting them.
fn launch(
meta: SessionConfig,
setup: &SetupConfig,
provider: &ProviderConfig,
host: Option<&HostConfig>,
data_dir: &Path,
models_dir: &Path,
) -> Result<Arc<LiveSession>> {
@@ -518,7 +508,7 @@ fn launch(
DriverKind::LlamaCpp => Box::new(LlamaDriver::spawn(
&meta,
provider,
&Transport::for_host(host),
&Transport::for_setup(setup),
models_dir,
&transcript_path,
sink.clone(),
@@ -526,7 +516,7 @@ fn launch(
DriverKind::ClaudeCli => Box::new(ClaudeDriver::spawn(
&meta,
provider,
&Transport::for_host(host),
&Transport::for_setup(setup),
&dir,
sink.clone(),
)?),
@@ -587,8 +577,8 @@ mod tests {
fn echo_spec() -> SpawnSpec {
SpawnSpec {
params: Default::default(),
setup: crate::config::LOCAL_SETUP.to_string(),
provider: crate::config::ECHO_PROVIDER.to_string(),
host: None,
title: None,
model: None,
cwd: None,