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:
1 parent
91bbc73ae5
commit
fff1fb49e8
12 files changed
+833
-170
No files matched your search
@@ -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}") }
|
||||
),
|
||||
});
|
||||
|
||||
+122
-32
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user