diff --git a/PLAN.md b/PLAN.md index afed546..8285d26 100644 --- a/PLAN.md +++ b/PLAN.md @@ -221,6 +221,13 @@ phone and auto-resume need no Codex branch. This is the CLI's local protocol and is treated defensively for the same reason as Claude's undocumented usage endpoint: missing fields or a refusal degrade to an unavailable snapshot. +The model picker asks the selected setup's Codex app-server for `model/list` +when it is opened. The catalog is account- and CLI-version-specific, so it is +never copied into the app or inferred from another setup; a lookup failure is +shown as unavailable while the free-text escape remains. Permission choices +are likewise reported per provider: Codex offers its read-only, +workspace-write and full-access modes, while Claude keeps its own modes. + ### The llama driver One `llama-server` per session, started through the same `Transport` as any diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 4d89a3b..a102b21 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -330,7 +330,13 @@ fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: Li // // 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) +data class Provider( + val name: String, + val kind: String, + val models: List, + val permissionModes: List, + val defaultPermissionMode: String?, +) /** * A machine, and what it can run. [address] is absent for the backend itself. @@ -345,13 +351,17 @@ data class Setup( val providers: List, ) -private fun parseProvider(provider: JSONObject) = - Provider( +private fun parseProvider(provider: JSONObject): Provider { + val kind = provider.getString("kind") + return Provider( name = provider.getString("name"), - kind = provider.getString("kind"), + kind = kind, // Omitted entirely when the provider offers none. models = provider.optJSONArray("models")?.strings().orEmpty(), + permissionModes = provider.optJSONArray("permissionModes")?.strings().orEmpty(), + defaultPermissionMode = provider.optString("defaultPermissionMode").ifEmpty { null }, ) +} private fun parseSetup(setup: JSONObject) = Setup( @@ -1083,16 +1093,6 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) ) {} } -/** - * Common permission intents, in the order they give up asking. Each coding CLI maps these onto its - * own flags: "manual" asks, "acceptEdits" and "auto" allow ordinary work, "bypassPermissions" - * accepts everything, and "plan" makes no changes. - * - * One list for every screen that offers them -- spawn, import, and the session's own picker -- - * because three copies had already drifted: the import screen was missing "plan". - */ -val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") - /** * What a new session's thinking level is when nothing chose one, or null for the CLI's own. * @@ -1294,6 +1294,19 @@ fun fetchSetupModels(settings: ServerSettings, setupId: String): List = + requestFromServer( + settings, + "/setups/${setupId.urlEncoded()}/providers/${provider.urlEncoded()}/models", + ) { connection -> + JSONArray(connection.inputStream.bufferedReader().readText()).strings() + } + fun fetchModels(settings: ServerSettings): Models = requestFromServer(settings, "/models") { connection -> val body = JSONObject(connection.inputStream.bufferedReader().readText()) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index c8845c7..3102053 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -100,9 +100,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows // themselves, not a flag, so the dialog can say what it is about. var confirming by remember { mutableStateOf?>(null) } - // Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty - // times. - var permissionMode by remember { mutableStateOf("auto") } + // Set from the selected Claude provider rather than repeated in the app. + var permissionMode by remember { mutableStateOf("") } // When each row last slid upwards, as a plain map rather than state: nothing is drawn from it, // so a tap reading it needs no recomposition. val movedAt = remember { mutableMapOf() } @@ -208,6 +207,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } + LaunchedEffect(chosen?.id, provider?.name) { + permissionMode = provider?.defaultPermissionMode.orEmpty() + } /** Continues [targets] in the background, leaving the screen where it is. */ fun importAll(targets: List) { @@ -375,7 +377,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } else { ChipGroup( label = "Permissions", - options = PERMISSION_MODES, + options = provider?.permissionModes.orEmpty(), selected = permissionMode, onSelect = { permissionMode = it }, ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 9179c3a..fbc6dea 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -305,6 +305,7 @@ fun SessionScreen( // The models this provider actually offers, asked of the server rather than listed here: a // hardcoded list is a claim about a machine. var offeredModels by remember { mutableStateOf>(emptyList()) } + var offeredPermissionModes by remember { mutableStateOf>(emptyList()) } val lifecycleOwner = LocalLifecycleOwner.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } @@ -1017,22 +1018,28 @@ fun SessionScreen( // Only for the model picker, which a subagent does not have. if (!isSubagent) { - LaunchedEffect(summary.setupName, summary.provider) { - offeredModels = - try { - withContext(Dispatchers.IO) { - fetchSetups(settings) - .firstOrNull { it.name == summary.setupName } - ?.providers - ?.firstOrNull { it.name == summary.provider } - ?.models - .orEmpty() - } - } catch (_: Exception) { - // Not worth reporting: the picker simply has nothing to offer, which is - // visible. - emptyList() + LaunchedEffect(summary.setup, summary.provider) { + val provider = runCatching { + withContext(Dispatchers.IO) { + fetchSetups(settings) + .firstOrNull { it.id == summary.setup } + ?.providers + ?.firstOrNull { it.name == summary.provider } } + } + .getOrNull() + offeredPermissionModes = provider?.permissionModes.orEmpty() + offeredModels = + provider + ?.let { + runCatching { + withContext(Dispatchers.IO) { + fetchProviderModels(settings, summary.setup, summary.provider) + } + } + .getOrDefault(emptyList()) + } + .orEmpty() } } @@ -1840,13 +1847,21 @@ fun SessionScreen( }, ) } - PickerButton( - current = permissionMode, - options = PERMISSION_MODES, - onPick = { chosen -> - act { setSessionPermissionMode(settings, summary.id, chosen) } - }, - ) + if (offeredPermissionModes.isNotEmpty()) { + PickerButton( + current = permissionMode, + options = offeredPermissionModes, + onPick = { chosen -> + act { + setSessionPermissionMode( + settings, + summary.id, + chosen, + ) + } + }, + ) + } } // The same filled shape as the button beside it, not an outlined one: these // are two things you can do about the session, and weighting one as diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 04ae660..d102fbb 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -57,10 +57,12 @@ fun SpawnScreen( var providerName by remember { mutableStateOf(null) } var title by remember { mutableStateOf("") } var model by remember { mutableStateOf("") } + var providerModels by remember { mutableStateOf>(emptyList()) } + var providerModelsLoading by remember { mutableStateOf(false) } + var providerModelsError by remember { mutableStateOf(null) } var cwd by remember { mutableStateOf("") } - // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and - // answering "allow Bash?" dozens of times per task is what this app exists to avoid. - var permissionMode by remember { mutableStateOf("auto") } + // Set only after the selected provider reports its own default. An empty value is not sent. + var permissionMode by remember { mutableStateOf("") } // Null until the server has been asked, and null again if it answers "no level chosen" -- the // two are told apart by [defaultsAsked], because a picker that shows a level before the answer // arrives is one you can spawn at without having chosen it. @@ -70,10 +72,8 @@ fun SpawnScreen( // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in // form worth keeping, and that one leaves nothing to fill in. var spawnError by remember { mutableStateOf(null) } - // The models on the *chosen machine*, for a llama provider to choose between. Kept separate - // from the setups: a Claude session needs none, so failing to list them must not stop the - // screen rendering. Refetched when the machine changes, because a model is a file on one - // machine -- see [fetchSetupModels]. + // GGUFs on the chosen machine, for a llama provider to choose between. Kept separate from a + // coding CLI's provider catalog and refetched when the machine changes. var models by remember { mutableStateOf>(emptyList()) } var modelKey by remember { mutableStateOf(null) } var contextSize by remember { mutableStateOf("") } @@ -145,6 +145,28 @@ fun SpawnScreen( val isCodingCli = isClaude || isCodex val isLlama = current?.kind == "llama_cpp" + LaunchedEffect(setup?.id, current?.name) { + model = "" + providerModels = emptyList() + providerModelsError = null + permissionMode = current?.defaultPermissionMode.orEmpty() + if (isCodingCli) { + providerModelsLoading = true + try { + providerModels = + withContext(Dispatchers.IO) { + fetchProviderModels(settings, setup.id, current.name) + } + } catch (e: ApiException) { + providerModelsError = e.message + } finally { + providerModelsLoading = false + } + } else { + providerModelsLoading = false + } + } + // The machine first, because it decides what can be run at all. ChipGroup( label = "Setup", @@ -240,14 +262,34 @@ fun SpawnScreen( } if (isCodingCli) { - 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 }, - ) + when { + providerModelsLoading -> + Text( + "Loading model choices…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + providerModelsError != null -> + Text( + "Model choices unavailable: $providerModelsError", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + providerModels.isEmpty() -> + Text( + "This setup reported no selectable models.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> { + Spacer(Modifier.height(16.dp)) + ChipGroup( + label = "Model", + options = providerModels, + selected = model.ifEmpty { null }, + onSelect = { chosen -> model = if (model == chosen) "" else chosen }, + ) + } } Spacer(Modifier.height(8.dp)) OutlinedTextField( @@ -271,7 +313,7 @@ fun SpawnScreen( ChipGroup( label = "Permissions", - options = PERMISSION_MODES, + options = current.permissionModes, selected = permissionMode, onSelect = { permissionMode = it }, ) diff --git a/server/src/config.rs b/server/src/config.rs index 1798839..c63d667 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -258,6 +258,26 @@ impl DriverKind { Self::Echo | Self::LlamaCpp => false, } } + + /// Permission choices the phone can offer for this kind, in display order. + /// The driver remains responsible for translating these stable values into + /// its CLI's arguments or control protocol. + pub fn permission_modes(self) -> &'static [&'static str] { + match self { + Self::ClaudeCli => &["manual", "acceptEdits", "auto", "bypassPermissions", "plan"], + Self::CodexCli => &["workspace-write", "read-only", "danger-full-access"], + Self::Echo | Self::LlamaCpp => &[], + } + } + + /// The mode used when a new-session form first selects this kind. + pub fn default_permission_mode(self) -> Option<&'static str> { + match self { + Self::ClaudeCli => Some("auto"), + Self::CodexCli => Some("workspace-write"), + Self::Echo | Self::LlamaCpp => None, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/server/src/routes.rs b/server/src/routes.rs index 1853747..b5f5477 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -8,6 +8,7 @@ //! POST /setups/probe dry run {ssh?}: what would be found there //! GET /setups/{id} one machine, for refetching after a change //! GET /setups/{id}/models GGUFs on that machine, for a llama session +//! GET /setups/{id}/providers/{provider}/models models a CLI currently offers //! GET /setups/{id}/dir?path=P entries of directory P, and P resolved //! GET /setups/{id}/file?path=P content of file P, or why not //! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256 @@ -127,6 +128,10 @@ pub fn router(manager: Arc) -> Router { ) // The models on the machine a setup names, for a llama session there. .route("/setups/{id}/models", get(setup_models)) + .route( + "/setups/{id}/providers/{provider}/models", + get(provider_models), + ) // The filesystem of the machine a setup names. Under the setup // rather than under a session because a filesystem is a property of // a machine; a session only says where to start looking. @@ -290,6 +295,9 @@ struct ProviderInfo { name: String, kind: crate::config::DriverKind, models: Vec, + permission_modes: Vec<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + default_permission_mode: Option<&'static str>, } async fn list_setups(State(manager): State>) -> axum::Json> { @@ -308,6 +316,8 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo { name: provider.name, kind: provider.kind, models: provider.models, + permission_modes: provider.kind.permission_modes().to_vec(), + default_permission_mode: provider.kind.default_permission_mode(), }) .collect(), } @@ -412,6 +422,8 @@ async fn probe_setup( name: provider.name, kind: provider.kind, models: provider.models, + permission_modes: provider.kind.permission_modes().to_vec(), + default_permission_mode: provider.kind.default_permission_mode(), }) .collect(), )) @@ -570,6 +582,24 @@ async fn setup_models( .map_err(from_machine) } +/// The models a CLI provider currently offers on the setup's machine. +/// Codex answers from its live account catalog; providers with a configured +/// shortcut list return that list. +async fn provider_models( + State(manager): State>, + UrlPath((id, provider_name)): UrlPath<(String, String)>, +) -> Result>, ApiError> { + let setup = setup_by_id(&manager, &id)?; + let provider = setup.provider(&provider_name).ok_or_else(|| { + ApiError::NotFound(format!("no provider {provider_name} on {}", setup.name)) + })?; + let transport = crate::session::transport::Transport::for_setup(&setup); + crate::setups::provider_models(&transport, provider) + .await + .map(axum::Json) + .map_err(from_machine) +} + /// What is in a directory, and what that directory resolved to. async fn list_dir( State(manager): State>, diff --git a/server/src/session/codex.rs b/server/src/session/codex.rs index 69237cb..86feb02 100644 --- a/server/src/session/codex.rs +++ b/server/src/session/codex.rs @@ -303,14 +303,16 @@ fn start_process( let settings = inner.settings.lock().unwrap(); let mut args = Vec::new(); match settings.permission_mode.as_deref() { - Some("bypassPermissions") => { + Some("bypassPermissions" | "danger-full-access") => { args.push("--dangerously-bypass-approvals-and-sandbox".to_string()); } Some("manual") => { args.extend(["--ask-for-approval".to_string(), "on-request".to_string()]); } - Some("auto" | "acceptEdits") => args.push("--approve-for-me".to_string()), - Some("plan") => args.extend([ + Some("auto" | "acceptEdits" | "workspace-write") => { + args.push("--approve-for-me".to_string()); + } + Some("plan" | "read-only") => args.extend([ "--ask-for-approval".to_string(), "never".to_string(), "--sandbox".to_string(), diff --git a/server/src/session/transport.rs b/server/src/session/transport.rs index 0054340..1aeeeab 100644 --- a/server/src/session/transport.rs +++ b/server/src/session/transport.rs @@ -91,6 +91,7 @@ pub enum Streams { } /// The machine a session's process runs on. +#[derive(Clone)] pub enum Transport { /// The machine this server is running on. Here, diff --git a/server/src/setups.rs b/server/src/setups.rs index 9faaedd..e982053 100644 --- a/server/src/setups.rs +++ b/server/src/setups.rs @@ -14,7 +14,8 @@ //! is editing `config.ron` on the backend, which is exactly the authority the //! phone is not being given. -use anyhow::Result; +use anyhow::{Context, Result}; +use serde_json::{Value, json}; use crate::config::{DriverKind, ProviderConfig}; use crate::session::transport::{Launch, Transport}; @@ -87,6 +88,60 @@ pub async fn discover(transport: &Transport) -> Result> { Ok(providers) } +/// Models the selected provider currently offers on this machine. +/// +/// Codex's catalog is account- and CLI-version-specific, so it is asked at the +/// moment the picker opens rather than copied into `config.ron`. Other +/// providers retain the shortcut list discovery stored for them. +pub async fn provider_models( + transport: &Transport, + provider: &ProviderConfig, +) -> Result> { + if provider.kind != DriverKind::CodexCli { + return Ok(provider.models.clone()); + } + let transport = transport.clone(); + let program = provider.program().to_string(); + tokio::task::spawn_blocking(move || { + let launch = Launch::new(program, vec!["app-server".into(), "--stdio".into()], None); + let initial = json!({ + "id": 1, + "method": "initialize", + "params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}} + }); + let requests = [ + json!({"method": "initialized"}), + json!({"id": 2, "method": "model/list", "params": {"includeHidden": false, "limit": 100}}), + ]; + let answer = transport.request_json_blocking(&launch, &initial, &requests, 2)?; + parse_codex_models(&answer) + }) + .await? +} + +fn parse_codex_models(answer: &Value) -> Result> { + if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) { + anyhow::bail!("Codex could not list models: {message}"); + } + let entries = answer + .pointer("/result/data") + .and_then(Value::as_array) + .context("Codex returned no model catalog")?; + let mut models = entries + .iter() + .filter(|entry| { + !entry + .get("hidden") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .filter_map(|entry| entry.get("model").and_then(Value::as_str)) + .map(str::to_string) + .collect::>(); + models.dedup(); + Ok(models) +} + /// Adds what to do to failures whose own wording does not say. /// /// ssh's messages are written for someone at a terminal on the backend, which is @@ -213,4 +268,26 @@ mod tests { assert_eq!(shorten_home(&sibling), sibling); assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts"); } + + #[test] + fn codex_models_are_the_selectable_non_hidden_catalog_entries() { + let answer = json!({"result": {"data": [ + {"model": "gpt-small", "hidden": false}, + {"model": "gpt-hidden", "hidden": true}, + {"model": "gpt-large", "hidden": false} + ]}}); + assert_eq!( + parse_codex_models(&answer).unwrap(), + vec!["gpt-small".to_string(), "gpt-large".to_string()] + ); + } + + #[test] + fn a_failed_codex_catalog_is_not_reported_as_an_empty_one() { + let answer = json!({"error": {"message": "login required"}}); + assert_eq!( + parse_codex_models(&answer).unwrap_err().to_string(), + "Codex could not list models: login required" + ); + } }