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

@@ -88,10 +88,11 @@ fun <T> requestFromServer(
}
}
// One row of GET /sessions.
// One row of GET /sessions. `provider` is what runs it, `host` where --
// the two are independent, so a session names both.
data class SessionSummary(
val id: String,
val kind: String,
val provider: String,
val title: String,
val host: String?,
val model: String?,
@@ -99,28 +100,61 @@ data class SessionSummary(
val lastActivity: Double,
)
private fun parseSession(session: JSONObject) = SessionSummary(
id = session.getString("id"),
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"),
)
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { connection ->
val sessions = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until sessions.length()).map { i ->
val session = sessions.getJSONObject(i)
SessionSummary(
id = session.getString("id"),
kind = session.getString("kind"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
(0 until sessions.length()).map { parseSession(sessions.getJSONObject(it)) }
}
// What the server offers, so the spawn screen has no hardcoded lists: a
// provider or host added to the server's config.json appears here with no
// app rebuild.
data class Provider(val name: String, val kind: String, val models: List<String>)
data class RemoteHost(val name: String, val address: String)
fun fetchProviders(settings: ServerSettings): List<Provider> =
requestFromServer(settings, "/providers") { connection ->
val providers = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until providers.length()).map { i ->
val provider = providers.getJSONObject(i)
val models = provider.optJSONArray("models")
Provider(
name = provider.getString("name"),
kind = provider.getString("kind"),
models = (0 until (models?.length() ?: 0)).map { models!!.getString(it) },
)
}
}
/** Spawns a session and returns it as the list would show it. */
fun fetchHosts(settings: ServerSettings): List<RemoteHost> =
requestFromServer(settings, "/hosts") { connection ->
val hosts = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until hosts.length()).map { i ->
val host = hosts.getJSONObject(i)
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.
*/
fun spawnSession(
settings: ServerSettings,
kind: String,
provider: String,
title: String,
host: String? = null,
model: String? = null,
cwd: String? = null,
permissionMode: String? = null,
@@ -129,22 +163,15 @@ fun spawnSession(
settings,
"/sessions",
method = "POST",
jsonBody = JSONObject().put("kind", kind).put("title", title).apply {
jsonBody = JSONObject().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)
}.toString(),
readTimeoutMs = 30000,
) { connection ->
val session = JSONObject(connection.inputStream.bufferedReader().readText())
SessionSummary(
id = session.getString("id"),
kind = session.getString("kind"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
parseSession(JSONObject(connection.inputStream.bufferedReader().readText()))
}
fun sendMessage(
@@ -177,7 +177,13 @@ private fun SessionCard(
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
listOfNotNull(session.kind, session.host, session.model).joinToString(" · "),
// Provider, then where it runs -- "on <host>" rather
// than a bare name, so a host isn't mistaken for a model.
listOfNotNull(
session.provider,
session.host?.let { "on $it" },
session.model,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -224,7 +224,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Text(summary.title, style = MaterialTheme.typography.titleMedium)
Text(
listOfNotNull(
summary.kind,
summary.provider,
summary.host?.let { "on $it" },
summary.model,
if (totalTokens > 0) "$totalTokens tok" else null,
).joinToString(" · "),
@@ -2,6 +2,8 @@ package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -11,12 +13,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -29,19 +33,21 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// The session kinds this build can spawn; phase 4 adds "pi". A new kind is
// another entry here plus its fields below -- never a parallel screen.
private val KINDS = listOf("claude", "echo")
// Claude Code 2.x permission modes. "manual" asks for everything (each ask
// arrives on the phone as a question card); the others are the CLI's own
// escalating levels of autonomy.
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
// Model shortcuts the CLI accepts; free text is also fine (full model ids).
private val CLAUDE_MODELS = listOf("default", "fable", "opus", "sonnet", "haiku")
/** Runs on the backend machine itself -- the "no host" case. */
private const val LOCAL_HOST_LABEL = "backend"
/** The spawn screen: kind, per-kind fields, go. */
/**
* The spawn screen: what to run, where to run it, and the per-kind fields.
*
* Providers and hosts both come from the server, so adding either to its
* config.json shows up here with no app rebuild -- and because they are
* independent, any provider can be sent to any host.
*/
@Composable
fun SpawnScreen(
settings: ServerSettings,
@@ -49,14 +55,39 @@ fun SpawnScreen(
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var kind by remember { mutableStateOf(KINDS.first()) }
var providers by remember { mutableStateOf<List<Provider>>(emptyList()) }
var hosts by remember { mutableStateOf<List<RemoteHost>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var provider by remember { mutableStateOf<Provider?>(null) }
var host by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("default") }
var model by remember { mutableStateOf("") }
var cwd by remember { mutableStateOf("") }
var permissionMode by remember { mutableStateOf("manual") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
try {
val loaded = withContext(Dispatchers.IO) {
fetchProviders(settings) to fetchHosts(settings)
}
providers = loaded.first
hosts = loaded.second
provider = providers.firstOrNull()
} catch (e: ApiException) {
error = e.message
}
loading = false
}
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(
@@ -68,13 +99,32 @@ fun SpawnScreen(
}
Spacer(Modifier.height(16.dp))
Text("Kind", style = MaterialTheme.typography.labelLarge)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
KINDS.forEach { candidate ->
FilterChip(
selected = kind == candidate,
onClick = { kind = candidate },
label = { Text(candidate) },
if (loading) {
CircularProgressIndicator()
return@Column
}
ChipGroup(
label = "Provider",
options = providers.map { it.name },
selected = current?.name,
onSelect = { name -> provider = providers.first { it.name == 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 {
Text(
it.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -88,18 +138,24 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(),
)
if (kind == "claude") {
Spacer(Modifier.height(16.dp))
Text("Model", style = MaterialTheme.typography.labelLarge)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CLAUDE_MODELS.forEach { candidate ->
FilterChip(
selected = model == candidate,
onClick = { model = candidate },
label = { Text(candidate) },
)
}
if (isClaude) {
if (current.models.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
ChipGroup(
label = "Model",
options = current.models,
selected = model.ifEmpty { null },
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = model,
onValueChange = { model = it },
label = { Text("Model (blank = the CLI's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
@@ -112,20 +168,12 @@ fun SpawnScreen(
)
Spacer(Modifier.height(16.dp))
Text("Permissions", style = MaterialTheme.typography.labelLarge)
// Two rows rather than horizontal scroll: all the choices stay
// visible, and bypassPermissions shouldn't be pickable blind.
for (chunk in PERMISSION_MODES.chunked(3)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
chunk.forEach { candidate ->
FilterChip(
selected = permissionMode == candidate,
onClick = { permissionMode = candidate },
label = { Text(candidate) },
)
}
}
}
ChipGroup(
label = "Permissions",
options = PERMISSION_MODES,
selected = permissionMode,
onSelect = { permissionMode = it },
)
}
Spacer(Modifier.height(24.dp))
@@ -136,17 +184,19 @@ fun SpawnScreen(
Button(
onClick = {
val chosen = current ?: return@Button
busy = true
scope.launch {
try {
val spawned = withContext(Dispatchers.IO) {
spawnSession(
settings,
kind,
title.trim(),
model = model.takeIf { kind == "claude" && it != "default" },
cwd = cwd.takeIf { kind == "claude" },
permissionMode = permissionMode.takeIf { kind == "claude" },
provider = chosen.name,
title = title.trim(),
host = host,
model = model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
)
}
onSpawned(spawned)
@@ -156,7 +206,38 @@ fun SpawnScreen(
}
}
},
enabled = !busy,
enabled = !busy && current != null,
) { Text(if (busy) "Spawning..." else "Spawn") }
}
}
/**
* A labeled row of choices that wraps onto as many lines as it needs.
*
* FlowRow rather than Row: a plain Row gives every chip an equal share of
* a single line, so once the options don't fit, the text inside each one
* wraps to one character per line instead of the row wrapping.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ChipGroup(
label: String,
options: List<String>,
selected: String?,
onSelect: (String) -> Unit,
) {
Text(label, style = MaterialTheme.typography.labelLarge)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
options.forEach { option ->
FilterChip(
selected = selected == option,
onClick = { onSelect(option) },
label = { Text(option) },
)
}
}
}