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
+62
-8
@@ -3,8 +3,10 @@
|
||||
//! wraps the whole router in.
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /sessions list (id, kind, title, host, model, status, last activity)
|
||||
//! POST /sessions spawn {kind, title?, host?, model?, cwd?, permissionMode?}
|
||||
//! GET /providers what can be spawned
|
||||
//! GET /hosts machines a session can be run on
|
||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||
//! POST /sessions spawn {provider, title?, model?, cwd?, permissionMode?}
|
||||
//! 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)
|
||||
@@ -44,6 +46,8 @@ 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("/sessions", get(list_sessions).post(spawn_session))
|
||||
.route("/sessions/{id}", delete(delete_session))
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
@@ -106,15 +110,65 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
|
||||
axum::Json(manager.sessions())
|
||||
}
|
||||
|
||||
/// What the spawn screen needs to render itself, so the phone holds no
|
||||
/// hardcoded list: an entry added to `config.json` 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.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProviderInfo {
|
||||
name: String,
|
||||
kind: crate::config::DriverKind,
|
||||
models: Vec<String>,
|
||||
}
|
||||
|
||||
async fn list_providers(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
) -> axum::Json<Vec<ProviderInfo>> {
|
||||
axum::Json(
|
||||
manager
|
||||
.providers()
|
||||
.into_iter()
|
||||
.map(|provider| ProviderInfo {
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SpawnRequest {
|
||||
kind: crate::config::SessionKind,
|
||||
#[serde(default)]
|
||||
title: Option<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)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
cwd: Option<PathBuf>,
|
||||
@@ -128,15 +182,15 @@ async fn spawn_session(
|
||||
) -> Result<axum::Json<SessionInfo>, ApiError> {
|
||||
let info = manager
|
||||
.spawn_session(SpawnSpec {
|
||||
kind: body.kind,
|
||||
title: body.title,
|
||||
provider: body.provider,
|
||||
host: body.host,
|
||||
title: body.title,
|
||||
model: body.model,
|
||||
cwd: body.cwd,
|
||||
permission_mode: body.permission_mode,
|
||||
})
|
||||
.map_err(bad_request)?;
|
||||
tracing::info!("spawned {:?} session {} ({})", info.kind, info.id, info.title);
|
||||
tracing::info!("spawned {} session {} ({})", info.provider, info.id, info.title);
|
||||
Ok(axum::Json(info))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user