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

+50 -29
View File
@@ -24,17 +24,15 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot};
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
use crate::config::SessionConfig;
use crate::config::{HostConfig, ProviderConfig, SessionConfig};
/// Where the driver remembers its CLI session id between backend runs --
/// the whole crash-recovery story: respawning with `--resume <id>` picks
@@ -58,36 +56,54 @@ pub struct ClaudeDriver {
}
impl ClaudeDriver {
pub fn spawn(meta: &SessionConfig, session_dir: &Path, sink: EventSink) -> Result<Self> {
let mut command = Command::new("claude");
command
.arg("-p")
.arg("--verbose")
.args(["--input-format", "stream-json"])
.args(["--output-format", "stream-json"])
.arg("--include-partial-messages")
// Hidden but load-bearing: without it the CLI resolves
// permissions itself and nothing ever reaches the phone.
.args(["--permission-prompt-tool", "stdio"]);
pub fn spawn(
meta: &SessionConfig,
provider: &ProviderConfig,
host: Option<&HostConfig>,
session_dir: &Path,
sink: EventSink,
) -> Result<Self> {
let mut args: Vec<String> = ["-p", "--verbose"].iter().map(|a| a.to_string()).collect();
let mut push = |flag: &str, value: &str| {
args.push(flag.to_string());
args.push(value.to_string());
};
push("--input-format", "stream-json");
push("--output-format", "stream-json");
// Hidden but load-bearing: without it the CLI resolves permissions
// itself and nothing ever reaches the phone.
push("--permission-prompt-tool", "stdio");
if let Some(model) = &meta.model {
command.args(["--model", model]);
push("--model", model);
}
if let Some(mode) = &meta.permission_mode {
command.args(["--permission-mode", mode]);
push("--permission-mode", mode);
}
if let Some(resume) = read_resume_token(session_dir) {
command.args(["--resume", &resume]);
push("--resume", &resume);
}
if let Some(cwd) = &meta.cwd {
command.current_dir(cwd);
}
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
args.push("--include-partial-messages".to_string());
let program = provider.command.as_deref().unwrap_or("claude");
let cwd = meta.cwd.as_deref();
let where_it_runs = match host {
Some(host) => format!("on {} ({})", host.name, host.address),
None => "on this machine".to_string(),
};
let mut child = crate::ssh::command(host, program, &args, cwd)
.spawn()
.context("spawn claude (is the CLI installed and on PATH?)")?;
.with_context(|| match host {
Some(host) => format!(
"couldn't start ssh to run \"{program}\" on {} -- is the ssh client \
installed here?",
host.name
),
None => format!(
"couldn't run \"{program}\" on this machine -- is it installed and on \
PATH? If it lives on another machine, give the session a host to run on.",
),
})?;
tracing::info!("session {} running {program} {where_it_runs}", meta.id);
let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
@@ -119,14 +135,18 @@ impl ClaudeDriver {
));
// stderr is diagnostics only; surface it in the log, and keep the
// last line for the exit report below.
// last line for the exit report below. For a remote provider this
// is also where ssh's own failures arrive ("Permission denied",
// "Could not resolve hostname"), which are the ones a person
// actually needs to see.
let last_stderr = Arc::new(Mutex::new(String::new()));
{
let last_stderr = Arc::clone(&last_stderr);
let label = provider.name.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!("claude stderr: {line}");
tracing::warn!("{label} stderr: {line}");
*last_stderr.lock().unwrap() = line;
}
});
@@ -137,6 +157,7 @@ impl ClaudeDriver {
let (kill_tx, kill_rx) = oneshot::channel::<()>();
{
let sink = sink.clone();
let label = format!("{} {where_it_runs}", provider.name);
tokio::spawn(async move {
let status = tokio::select! {
status = child.wait() => status.ok(),
@@ -151,7 +172,7 @@ impl ClaudeDriver {
let detail = last_stderr.lock().unwrap().clone();
let _ = sink.send(Event::Error {
message: format!(
"claude exited with {status}{}",
"{label} exited with {status}{}",
if detail.is_empty() { String::new() } else { format!(": {detail}") }
),
});