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
+392 -298

No files matched your search

@@ -106,13 +106,13 @@ private fun JSONArray.strings(): List<String> = (0 until length()).map { getStri
/** Percent-encodes a value going into a query string. */
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
// One row of GET /sessions. `provider` is what runs it, `host` where --
// the two are independent, so a session names both.
// One row of GET /sessions. A session names the machine it runs on and
// which of that machine's providers it runs.
data class SessionSummary(
val id: String,
val setup: String,
val provider: String,
val title: String,
val host: String?,
val model: String?,
val status: String,
val lastActivity: Double,
@@ -121,9 +121,9 @@ data class SessionSummary(
private fun parseSession(session: JSONObject) =
SessionSummary(
id = session.getString("id"),
setup = session.getString("setup"),
provider = session.getString("provider"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
@@ -133,43 +133,48 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
// What the server offers, so the spawn screen has no hardcoded lists: a
// provider or host added to the server's config.ron appears here with no
// app rebuild.
// setup added to the server's config.ron appears here with no app rebuild.
//
// One list rather than two. A provider only exists on a machine that has
// it installed, so offering machines and providers as independent choices
// would offer pairs that cannot work.
data class Provider(val name: String, val kind: String, val models: List<String>)
data class RemoteHost(val name: String, val address: String)
/** A machine, and what it can run. [address] is absent for the backend itself. */
data class Setup(val name: String, val address: String?, val providers: List<Provider>)
fun fetchProviders(settings: ServerSettings): List<Provider> =
requestFromServer(settings, "/providers") { connection ->
connection.jsonObjects { provider ->
fun fetchSetups(settings: ServerSettings): List<Setup> =
requestFromServer(settings, "/setups") { connection ->
connection.jsonObjects { setup ->
Setup(
name = setup.getString("name"),
address = setup.optString("address").ifEmpty { null },
providers =
setup.getJSONArray("providers").mapObjects { provider ->
Provider(
name = provider.getString("name"),
kind = provider.getString("kind"),
// Omitted entirely when the provider offers none.
models = provider.optJSONArray("models")?.strings().orEmpty(),
)
}
}
fun fetchHosts(settings: ServerSettings): List<RemoteHost> =
requestFromServer(settings, "/hosts") { connection ->
connection.jsonObjects { host ->
RemoteHost(name = host.getString("name"), address = host.getString("address"))
},
)
}
}
/**
* Spawns a session and returns it as the list would show it. [host] is the name of a configured
* host, or null to run on the backend machine itself.
* Spawns a session and returns it as the list would show it. [setup] names the machine and
* [provider] one of the things that machine offers.
*/
fun spawnSession(
settings: ServerSettings,
setup: String,
provider: String,
title: String,
host: String? = null,
model: String? = null,
cwd: String? = null,
permissionMode: String? = null,
params: Map<String, String> = emptyMap(),
): SessionSummary =
requestFromServer(
settings,
@@ -177,13 +182,16 @@ fun spawnSession(
method = "POST",
jsonBody =
JSONObject()
.put("setup", setup)
.put("provider", provider)
.put("title", title)
.apply {
if (!host.isNullOrBlank()) put("host", host)
if (!model.isNullOrBlank()) put("model", model)
if (!cwd.isNullOrBlank()) put("cwd", cwd)
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
if (params.isNotEmpty()) {
put("params", JSONObject(params.toMap<String, Any>()))
}
}
.toString(),
readTimeoutMs = 30000,
@@ -216,7 +216,7 @@ private fun SessionCard(
// than a bare name, so a host isn't mistaken for a model.
listOfNotNull(
session.provider,
session.host?.let { "on $it" },
"on ${session.setup}",
session.model,
)
.joinToString(" · "),
@@ -242,7 +242,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Text(
listOfNotNull(
summary.provider,
summary.host?.let { "on $it" },
"on ${summary.setup}",
summary.model,
if (totalTokens > 0) "$totalTokens tok" else null,
)
@@ -39,7 +39,6 @@ import kotlinx.coroutines.withContext
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
/** Runs on the backend machine itself -- the "no host" case. */
private const val LOCAL_HOST_LABEL = "backend"
/**
* The spawn screen: what to run, where to run it, and the per-kind fields.
@@ -57,10 +56,14 @@ fun SpawnScreen(
// What the form is made of, and whether we have it yet. A failure here
// is not the same as a server with nothing to offer, so it must not
// reach the pickers as empty lists -- see LoadState.
var options by remember { mutableStateOf<LoadState<SpawnOptions>>(LoadState.Loading) }
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var provider by remember { mutableStateOf<Provider?>(null) }
var host by remember { mutableStateOf<String?>(null) }
// Setup first, then one of its providers. Choosing a setup can
// invalidate the provider, so the provider is stored by name and
// resolved against the current setup rather than held as an object
// that could outlive the list it came from.
var setupName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") }
var cwd by remember { mutableStateOf("") }
@@ -74,23 +77,16 @@ fun SpawnScreen(
LaunchedEffect(Unit) {
options =
try {
val fetched =
withContext(Dispatchers.IO) {
SpawnOptions(fetchProviders(settings), fetchHosts(settings))
}
provider = fetched.providers.firstOrNull()
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
val first = fetched.firstOrNull()
setupName = first?.name
providerName = first?.providers?.firstOrNull()?.name
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
val current = provider
// Only the Claude CLI has models, a working directory, and permission
// modes; keying the extra fields on the kind rather than the provider
// name keeps a second Claude provider from needing anything here.
val isClaude = current?.kind == "claude_cli"
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
@@ -106,7 +102,7 @@ fun SpawnScreen(
// failure to fetch them leaves no form worth showing -- so this
// reports and stops, rather than offering empty pickers under an
// error message.
val (providers, hosts) =
val setups =
when (val state = options) {
is LoadState.Loading -> {
CircularProgressIndicator()
@@ -118,33 +114,56 @@ fun SpawnScreen(
}
is LoadState.Loaded -> state.value
}
val setup = setups.firstOrNull { it.name == setupName }
val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and
// permission modes; keying the extra fields on the kind rather
// than the provider name keeps a second Claude provider from
// needing anything here.
val isClaude = current?.kind == "claude_cli"
// The machine first, because it decides what can be run at all.
ChipGroup(
label = "Provider",
options = providers.map { it.name },
selected = current?.name,
onSelect = { name -> provider = providers.first { it.name == name } },
label = "Setup",
options = setups.map { it.name },
selected = setupName,
onSelect = { name ->
setupName = name
// The provider list changes with the machine, so a name
// carried over from the previous one would be a selection
// that isn't in the picker. Take that machine's first.
providerName =
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
},
)
// Always offered, whatever the provider: where a session runs is
// independent of what runs it.
ChipGroup(
label = "Run on",
options = listOf(LOCAL_HOST_LABEL) + hosts.map { it.name },
selected = host ?: LOCAL_HOST_LABEL,
onSelect = { name -> host = name.takeIf { it != LOCAL_HOST_LABEL } },
)
host?.let { chosen ->
hosts
.firstOrNull { it.name == chosen }
?.let {
setup?.address?.let {
Text(
it.address,
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// The address belongs to the setup above it, not to the
// provider label below; without this they read as one block.
Spacer(Modifier.height(8.dp))
}
// Only what this machine actually has. A setup with none says so
// rather than showing an empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) {
Text(
"\"${setup.name}\" has no providers configured.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Provider",
options = setup?.providers?.map { it.name }.orEmpty(),
selected = providerName,
onSelect = { providerName = it },
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
@@ -210,9 +229,9 @@ fun SpawnScreen(
withContext(Dispatchers.IO) {
spawnSession(
settings,
setup = setup?.name.orEmpty(),
provider = chosen.name,
title = title.trim(),
host = host,
model = model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
@@ -232,9 +251,6 @@ fun SpawnScreen(
}
}
/** What the spawn form is built from, fetched as one thing. */
private data class SpawnOptions(val providers: List<Provider>, val hosts: List<RemoteHost>)
/**
* A labeled row of choices that wraps onto as many lines as it needs.
*
+167 -91
View File
@@ -18,7 +18,7 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::private;
@@ -87,26 +87,48 @@ pub struct Config {
/// the credential. A list (of one, today) so per-device tokens with
/// individual revocation are a config entry later, not a migration.
pub tokens: Vec<TokenEntry>,
/// What can be spawned. See [`ProviderConfig`].
pub providers: Vec<ProviderConfig>,
/// Machines a session can be told to run on. See [`HostConfig`].
pub hosts: Vec<HostConfig>,
/// Every machine this server can run something on, and what each of
/// them can run. See [`SetupConfig`].
pub setups: Vec<SetupConfig>,
pub sessions: Vec<SessionConfig>,
}
/// One thing that can be spawned: which driver, and how to invoke it.
/// A machine, and the things it can run.
///
/// Deliberately says nothing about *where* it runs -- that is the
/// session's [`SessionConfig::host`], because the two are independent.
/// The same provider may run locally for one session and over SSH for the
/// next, and pinning a machine here would make "the Claude CLI" and "the
/// Claude CLI on that box" two different things to configure and pick
/// between.
/// This is the unit a session is spawned against: pick a setup, then one
/// of its providers. Grouping them this way is what stops the spawn
/// screen offering combinations that cannot work -- a provider only
/// exists on a machine where that program is installed, and the previous
/// model, which let any provider be paired with any host, offered the
/// whole cross-product including the impossible parts of it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupConfig {
/// What the spawn screen shows and sessions store. Unique; renaming
/// one orphans the sessions that reference it.
pub name: String,
/// How to reach it, absent for this machine. A setup with no `ssh` is
/// where the server itself runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<SshConfig>,
/// What can be spawned here. Names are unique within a setup, and only
/// within it: two machines may each have a `claude-cli`, which is the
/// point.
#[serde(default)]
pub providers: Vec<ProviderConfig>,
}
impl SetupConfig {
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
self.providers.iter().find(|provider| provider.name == name)
}
}
/// One thing a setup can run: which driver, and how to invoke it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
/// Shown on the spawn screen and stored by sessions that use it.
/// Unique; renaming one orphans the sessions that reference it.
pub name: String,
pub kind: DriverKind,
/// Override for the executable, for an install that isn't on PATH.
@@ -118,18 +140,15 @@ pub struct ProviderConfig {
pub models: Vec<String>,
}
/// A machine sessions can be run on, reached with the system `ssh` client
/// -- so `~/.ssh/config`, agents, and jump hosts all keep working, and
/// there is one place to configure connections (PLAN.md, rule 23).
/// How to reach a setup that isn't this machine, with the system `ssh`
/// client -- so `~/.ssh/config`, agents, and jump hosts all keep working,
/// and there is one place to configure connections (PLAN.md, rule 23).
///
/// Applies to any session of any provider: a remote session is the
/// identical command with `ssh host …` in front, and nothing downstream of
/// the spawn knows the difference.
/// A remote session is the identical command with `ssh host …` in front,
/// and nothing downstream of the spawn knows the difference.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostConfig {
/// What the spawn screen shows and the session stores.
pub name: String,
pub struct SshConfig {
/// `user@host`, or a `Host` alias from `~/.ssh/config`.
pub address: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -183,15 +202,13 @@ pub struct TokenEntry {
pub struct SessionConfig {
/// Stable identifier; names the session's directory and its routes.
pub id: String,
/// Name of the [`ProviderConfig`] this session runs. Stored rather
/// than the resolved driver so an edited provider (a new command path,
/// another model) takes effect on the next relaunch; a session whose
/// Name of the [`SetupConfig`] this session runs on.
pub setup: String,
/// Name of the provider within that setup. Both stored by name rather
/// than resolved, so an edited setup (a new command path, another
/// model) takes effect on the next relaunch; a session whose setup or
/// provider is gone reports as exited and can still be deleted.
pub provider: String,
/// Name of the [`HostConfig`] to run it on. Absent means the backend
/// machine itself. Independent of the provider by design.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
@@ -220,41 +237,71 @@ pub struct SessionConfig {
pub created: f64,
}
/// The name of the built-in echo provider. Always present, never written
/// to the config file: it needs no configuration and gives every install a
/// working session type to test the pipe with.
/// The name of the echo provider, and of the setup this machine gets on
/// first run.
///
/// Echo is seeded into the config rather than conjured at read time the
/// way it used to be. An implicit provider is one a person cannot see in
/// the file or edit from the phone, and the point of this app is that
/// configuration is visible and editable; if somebody deletes it, that was
/// a choice.
pub const ECHO_PROVIDER: &str = "echo";
pub const LOCAL_SETUP: &str = "this machine";
impl Config {
/// Every provider, built-in first. A configured provider named `echo`
/// wins, so the built-in can be redefined but never silently
/// duplicated.
pub fn providers(&self) -> Vec<ProviderConfig> {
let mut providers = Vec::new();
if !self.providers.iter().any(|p| p.name == ECHO_PROVIDER) {
providers.push(ProviderConfig {
pub fn setup(&self, name: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.name == name)
}
/// What a fresh install starts with: this machine, offering the echo
/// driver to prove the pipe and the Claude CLI to be useful.
///
/// Echo runs in-process, so it belongs to the setup with no ssh --
/// there is nothing for a transport to wrap, and offering it on a
/// remote machine would be a choice that changes nothing.
pub fn seed() -> SetupConfig {
SetupConfig {
name: LOCAL_SETUP.to_string(),
ssh: None,
providers: vec![
ProviderConfig {
name: ECHO_PROVIDER.to_string(),
kind: DriverKind::Echo,
command: None,
models: Vec::new(),
});
},
ProviderConfig {
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: None,
models: ["fable", "opus", "sonnet", "haiku"]
.iter()
.map(|m| (*m).to_string())
.collect(),
},
],
}
providers.extend(self.providers.iter().cloned());
providers
}
pub fn provider(&self, name: &str) -> Option<ProviderConfig> {
self.providers().into_iter().find(|p| p.name == name)
}
pub fn host(&self, name: &str) -> Option<HostConfig> {
self.hosts.iter().find(|host| host.name == name).cloned()
}
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => format::parse(&text)
.with_context(|| format!("{} is not valid config RON", path.display())),
Ok(text) => {
// `Config` defaults unknown fields away, so a file from
// before setups existed would load as "no setups at all"
// and be re-seeded over -- losing every configured
// provider and host without a word. Say so instead.
if text.contains("\nproviders:") || text.contains("\nhosts:") {
bail!(
"{} is in the old shape: `providers` and `hosts` were separate lists, \
and are now `setups`, each carrying the providers that machine has. \
Rewrite it as `setups: [(name: \"...\", providers: [...])]` -- a \
setup with no `ssh` is this machine.",
path.display(),
);
}
format::parse(&text)
.with_context(|| format!("{} is not valid config RON", path.display()))
}
// A first run has no config -- the normal starting state; a
// token is generated and saved on that first start.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
@@ -317,39 +364,41 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.ron");
// A missing file is the ordinary first-run state, not an error --
// and echo is offered even then, with nothing configured.
// A missing file is the ordinary first-run state, not an error.
// Nothing is conjured to fill it: the seed setup is written by the
// manager, so the file always says what there is.
let first_run = Config::load(&path).expect("load");
assert!(first_run.tokens.is_empty());
assert!(first_run.setups.is_empty());
assert!(first_run.sessions.is_empty());
assert_eq!(first_run.providers().len(), 1);
assert_eq!(
first_run.provider(ECHO_PROVIDER).expect("built-in").kind,
DriverKind::Echo
);
let config = Config {
tokens: vec![TokenEntry {
name: "phone".to_string(),
sha256: "ab".repeat(32),
}],
setups: vec![
Config::seed(),
SetupConfig {
name: "vm".to_string(),
ssh: Some(SshConfig {
address: "bob@10.0.2.15".to_string(),
port: Some(2222),
identity_file: None,
options: Vec::new(),
}),
providers: vec![ProviderConfig {
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: None,
models: vec!["haiku".to_string()],
}],
hosts: vec![HostConfig {
name: "vm".to_string(),
address: "bob@10.0.2.15".to_string(),
port: Some(2222),
identity_file: None,
options: Vec::new(),
}],
},
],
sessions: vec![SessionConfig {
id: "abc123".to_string(),
setup: "vm".to_string(),
provider: "claude-cli".to_string(),
host: Some("vm".to_string()),
title: "test".to_string(),
model: None,
cwd: None,
@@ -362,20 +411,28 @@ mod tests {
let loaded = Config::load(&path).expect("reload");
assert_eq!(loaded.tokens[0].name, "phone");
assert_eq!(loaded.sessions[0].id, "abc123");
assert_eq!(loaded.sessions[0].setup, "vm");
assert_eq!(loaded.sessions[0].provider, "claude-cli");
assert_eq!(loaded.sessions[0].host.as_deref(), Some("vm"));
assert_eq!(loaded.host("vm").expect("host").port, Some(2222));
// Built-in echo plus the configured one; any provider can run on
// any host, so they are listed independently.
assert_eq!(
loaded
.providers()
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>(),
["echo", "claude-cli"],
.setup("vm")
.expect("setup")
.ssh
.as_ref()
.expect("ssh")
.port,
Some(2222),
);
// The same provider name on two machines is the point, not a
// collision: names are unique within a setup and only within one.
assert!(
loaded
.setup(LOCAL_SETUP)
.expect("local")
.provider("claude-cli")
.is_some()
);
assert!(loaded.setup(LOCAL_SETUP).expect("local").ssh.is_none());
// The house rule both halves of `format` depend on: what is written
// is the *body* of the struct, with no outer parentheses and
@@ -398,21 +455,40 @@ mod tests {
}
#[test]
fn a_configured_echo_provider_replaces_the_built_in_one() {
let config = Config {
providers: vec![ProviderConfig {
name: ECHO_PROVIDER.to_string(),
kind: DriverKind::ClaudeCli,
command: Some("/opt/claude".to_string()),
models: Vec::new(),
}],
..Config::default()
};
// One entry, not two: the built-in is skipped rather than shadowed.
assert_eq!(config.providers().len(), 1);
/// A config from before setups existed must not load as an empty one.
/// `Config` defaults unknown fields away, so without this check the
/// old `providers` and `hosts` would vanish and be silently re-seeded
/// over -- the worst kind of migration, the sort nobody notices.
fn a_config_in_the_old_shape_is_refused_rather_than_emptied() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.ron");
std::fs::write(
&path,
"tokens: [],\nproviders: [\n (name: \"claude-cli\", kind: claude_cli),\n],\nhosts: [],\nsessions: [],\n",
)
.expect("write");
let err = Config::load(&path).expect_err("should refuse");
let message = format!("{err:#}");
assert!(message.contains("old shape"), "{message}");
assert!(message.contains("setups"), "{message}");
}
#[test]
/// What a fresh install can do before anybody configures anything:
/// echo to prove the pipe, and the Claude CLI to be useful. Both are
/// on the machine the server runs on, and echo belongs there because
/// it runs in-process -- there is no transport for it to cross.
fn the_seed_setup_is_this_machine_and_can_spawn_something() {
let seed = Config::seed();
assert_eq!(seed.name, LOCAL_SETUP);
assert!(seed.ssh.is_none());
assert_eq!(
config.provider(ECHO_PROVIDER).expect("provider").kind,
DriverKind::ClaudeCli
seed.provider(ECHO_PROVIDER).expect("echo").kind,
DriverKind::Echo
);
assert_eq!(
seed.provider("claude-cli").expect("claude").kind,
DriverKind::ClaudeCli,
);
}
}
+9 -3
View File
@@ -199,11 +199,17 @@ async fn main() -> Result<()> {
);
tracing::info!("config: {}", config_path.display());
tracing::info!("models: {}", models_dir.display());
for provider in manager.providers() {
for setup in manager.setups() {
match &setup.ssh {
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
// No parenthetical naming the local machine: the default
// setup is *called* "this machine", and the line read
// "setup this machine (this machine)".
None => tracing::info!(" setup \"{}\" runs here", setup.name),
}
for provider in &setup.providers {
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
}
for host in manager.hosts() {
tracing::info!(" host {} -> {}", host.name, host.address);
}
for info in manager.sessions() {
tracing::info!(
+33 -38
View File
@@ -3,10 +3,9 @@
//! wraps the whole router in.
//!
//! ```text
//! GET /providers what can be spawned
//! GET /hosts machines a session can be run on
//! GET /setups machines, each with what it can run
//! GET /sessions list (id, provider, title, model, status, last activity)
//! POST /sessions spawn {provider, title?, model?, cwd?, permissionMode?}
//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?}
//! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live
//! POST /sessions/{id}/message {text, attachmentIds?}
//! POST /sessions/{id}/answer {questionId, answer} (questions and permissions)
@@ -46,8 +45,7 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
pub fn router(manager: Arc<SessionManager>) -> Router {
Router::new()
.route("/providers", get(list_providers))
.route("/hosts", get(list_hosts))
.route("/setups", get(list_setups))
.route("/sessions", get(list_sessions).post(spawn_session))
.route("/sessions/{id}", delete(delete_session))
.route("/sessions/{id}/events", get(events))
@@ -113,9 +111,25 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
}
/// What the spawn screen needs to render itself, so the phone holds no
/// hardcoded list: an entry added to `config.ron` shows up with no app
/// rebuild. Providers and hosts are listed separately because they are
/// independent choices -- any provider can be run on any host.
/// hardcoded list: a setup added to `config.ron` shows up with no app
/// rebuild.
///
/// One list rather than two, because the choice is a pair and the halves
/// are not independent. A provider only exists on a machine that has it
/// installed, so listing providers and machines separately offered their
/// whole cross-product -- including "the Claude CLI on the box that hasn't
/// got it".
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SetupInfo {
name: String,
/// Where it runs, for telling two setups apart. Absent for the one
/// that is this machine.
#[serde(skip_serializing_if = "Option::is_none")]
address: Option<String>,
providers: Vec<ProviderInfo>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ProviderInfo {
@@ -124,12 +138,16 @@ struct ProviderInfo {
models: Vec<String>,
}
async fn list_providers(
State(manager): State<Arc<SessionManager>>,
) -> axum::Json<Vec<ProviderInfo>> {
async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> {
axum::Json(
manager
.providers()
.setups()
.into_iter()
.map(|setup| SetupInfo {
name: setup.name,
address: setup.ssh.map(|ssh| ssh.address),
providers: setup
.providers
.into_iter()
.map(|provider| ProviderInfo {
name: provider.name,
@@ -137,28 +155,6 @@ async fn list_providers(
models: provider.models,
})
.collect(),
)
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct HostInfo {
name: String,
/// Shown under the name so a host can be told apart from its label.
address: String,
}
/// Configured remote machines. Running on the backend itself is always
/// available and deliberately absent here -- it is the "no host" case, not
/// an entry that could be edited away.
async fn list_hosts(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<HostInfo>> {
axum::Json(
manager
.hosts()
.into_iter()
.map(|host| HostInfo {
name: host.name,
address: host.address,
})
.collect(),
)
@@ -167,10 +163,9 @@ async fn list_hosts(State(manager): State<Arc<SessionManager>>) -> axum::Json<Ve
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpawnRequest {
/// Which machine, and which of the things it offers.
setup: String,
provider: String,
/// Name of a configured host; absent runs on the backend machine.
#[serde(default)]
host: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
@@ -192,8 +187,8 @@ async fn spawn_session(
) -> Result<axum::Json<SessionInfo>, ApiError> {
let info = manager
.spawn_session(SpawnSpec {
setup: body.setup,
provider: body.provider,
host: body.host,
title: body.title,
model: body.model,
cwd: body.cwd,
+72 -82
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
let setup = inner
.config
.providers()
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>()
.join(", "),
.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,
+19 -14
View File
@@ -24,7 +24,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tokio::process::Child;
use crate::config::HostConfig;
use crate::config::SshConfig;
/// What a driver needs run in order to exist as a process.
///
@@ -51,17 +51,21 @@ impl Launch {
pub enum Transport {
/// The machine this server is running on.
Here,
/// Reached with the system `ssh` client. Owns its host entry rather
/// than borrowing it, so a session keeps working against the config it
/// was spawned with even if that entry is edited afterwards.
Ssh(HostConfig),
/// 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 session's configured host names; absent is [`Self::Here`].
pub fn for_host(host: Option<&HostConfig>) -> Self {
match host {
Some(host) => Self::Ssh(host.clone()),
/// 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,
}
}
@@ -75,14 +79,15 @@ impl Transport {
pub fn spawn(&self, launch: &Launch) -> Result<Child> {
let host = match self {
Self::Here => None,
Self::Ssh(host) => Some(host),
Self::Ssh { ssh, .. } => Some(ssh),
};
crate::ssh::command(host, &launch.program, &launch.args, launch.cwd.as_deref())
.spawn()
.with_context(|| match self {
Self::Ssh(host) => format!(
"couldn't start ssh to run \"{}\" on {} -- is the ssh client installed here?",
launch.program, host.name,
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 \
@@ -96,7 +101,7 @@ impl Transport {
pub fn describe(&self) -> String {
match self {
Self::Here => "on this machine".to_string(),
Self::Ssh(host) => format!("on {} ({})", host.name, host.address),
Self::Ssh { name, ssh } => format!("on {name} ({})", ssh.address),
}
}
}
+7 -9
View File
@@ -15,7 +15,7 @@ use std::process::Stdio;
use tokio::process::Command;
use crate::config::HostConfig;
use crate::config::SshConfig;
/// Options forced onto every connection. `BatchMode` makes a missing key
/// fail immediately with a readable message instead of hanging on a
@@ -29,14 +29,14 @@ const SSH_OPTIONS: [&str; 3] = [
];
/// Builds the child process for `program args…`, run in `cwd`, either on
/// this machine (`host` absent) or on `host`.
/// this machine (`ssh` absent) or on the machine it describes.
pub fn command(
host: Option<&HostConfig>,
remote: Option<&SshConfig>,
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Command {
let Some(ssh) = host else {
let Some(ssh) = remote else {
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
@@ -132,9 +132,8 @@ mod tests {
/// A host with nothing configured but a name to dial, so `~/.ssh/config`
/// decides everything else -- the case that proves this adds no flags of
/// its own when it was not told to.
fn bare_host() -> HostConfig {
HostConfig {
name: "vm".to_string(),
fn bare_host() -> SshConfig {
SshConfig {
address: "vm".to_string(),
port: None,
identity_file: None,
@@ -159,8 +158,7 @@ mod tests {
#[test]
fn a_session_with_a_host_wraps_the_same_command_in_ssh() {
let ssh = HostConfig {
name: "vm".to_string(),
let ssh = SshConfig {
address: "bob@10.0.2.15".to_string(),
port: Some(2222),
identity_file: Some("/home/me/.ssh/id_ai".into()),