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 dba837d..cb2a296 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -111,12 +111,23 @@ fun fetchSessions(settings: ServerSettings): List = } /** Spawns a session and returns it as the list would show it. */ -fun spawnSession(settings: ServerSettings, kind: String, title: String): SessionSummary = +fun spawnSession( + settings: ServerSettings, + kind: String, + title: String, + model: String? = null, + cwd: String? = null, + permissionMode: String? = null, +): SessionSummary = requestFromServer( settings, "/sessions", method = "POST", - jsonBody = JSONObject().put("kind", kind).put("title", title).toString(), + jsonBody = JSONObject().put("kind", kind).put("title", title).apply { + if (!model.isNullOrBlank()) put("model", model) + if (!cwd.isNullOrBlank()) put("cwd", cwd) + if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) + }.toString(), ) { connection -> val session = JSONObject(connection.inputStream.bufferedReader().readText()) SessionSummary( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt index 9407cc4..a3cf058 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt @@ -23,16 +23,16 @@ import javax.net.ssl.X509TrustManager * the APK, so photographing the terminal leaks only the (rotatable) token. */ const val PINNED_CA_PEM = """-----BEGIN CERTIFICATE----- -MIIBrjCCAVWgAwIBAgIUGomDFgIrkwxX9504lixc8kM83I0wCgYIKoZIzj0EAwIw +MIIBvzCCAWWgAwIBAgIUGCxNZPJIfzGQipZGxWVZ8vMYZXgwCgYIKoZIzj0EAwIw LTETMBEGA1UECgwKYWktYXBwIGRldjEWMBQGA1UEAwwNYWktYXBwIGRldiBDQTAe -Fw0yNjA4MjUwMDQ5NDBaFw0zNjA4MjIwMDQ5NDBaMC0xEzARBgNVBAoMCmFpLWFw +Fw0yNjA4MjUwMTI2MDRaFw0zNjA4MjIwMTI2MDRaMC0xEzARBgNVBAoMCmFpLWFw cCBkZXYxFjAUBgNVBAMMDWFpLWFwcCBkZXYgQ0EwWTATBgcqhkjOPQIBBggqhkjO -PQMBBwNCAARW8deDZhiVxUDo1TyGMIpOpvu45vei8Vd5rWFNgSOl80h8TQ8/v8fI -tcacAGiPK0OUDOPb6iSaSMS8QEtfPB8+o1MwUTAdBgNVHQ4EFgQUFnEtKeV8et8Z -O7/ihnMOckS45qwwHwYDVR0jBBgwFoAUFnEtKeV8et8ZO7/ihnMOckS45qwwDwYD -VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiBppx+X09AjEJ8X24KxHYaX -4NRE+8OlxqJCrkuAM7tLagIgND6JvA5K0WjlfZxomla45C5Vd91j2jdIPmM+UkEa -Wb4= +PQMBBwNCAARE6qRKz1HeCzcvmdT6ztwTR2w4DGP97aaYJhp3z+es6dceNXdpP1qx +3DlazArgYLjOcNOHTqonj4H5NwHfeP4So2MwYTAdBgNVHQ4EFgQUL9VAISmEPDhJ +dHnl5iS10qLbcekwHwYDVR0jBBgwFoAUL9VAISmEPDhJdHnl5iS10qLbcekwDwYD +VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIh +AL7Z0wfT0pXD08J6GNbPVfy/PB3EoUtIwA9z9rYGPEhZAiBeLPiXCvIbWWTpsjRr +Uk5VTHJchx0xXCdTRSJo2BEh7Q== -----END CERTIFICATE----- """ 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 82e3b6c..8ab3e22 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -1,5 +1,6 @@ package com.example.aiapp +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.FilterChip import androidx.compose.material3.MaterialTheme @@ -26,13 +29,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -// The session kinds this build can spawn. Phase 2 adds "claude" (with -// model / working directory / permission-mode fields), phase 4 "pi" -- -// extending this list and the per-kind fields, not adding a parallel -// screen. -private val KINDS = listOf("echo") +// 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") -/** The spawn screen: kind, title, go. */ +// 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") + +/** The spawn screen: kind, per-kind fields, go. */ @Composable fun SpawnScreen( settings: ServerSettings, @@ -42,10 +51,13 @@ fun SpawnScreen( val scope = rememberCoroutineScope() var kind by remember { mutableStateOf(KINDS.first()) } var title by remember { mutableStateOf("") } + var model by remember { mutableStateOf("default") } + var cwd by remember { mutableStateOf("") } + var permissionMode by remember { mutableStateOf("manual") } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - Column(Modifier.fillMaxSize().padding(16.dp)) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( "New session", @@ -57,14 +69,13 @@ fun SpawnScreen( Spacer(Modifier.height(16.dp)) Text("Kind", style = MaterialTheme.typography.labelLarge) - Row { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { KINDS.forEach { candidate -> FilterChip( selected = kind == candidate, onClick = { kind = candidate }, label = { Text(candidate) }, ) - Spacer(Modifier.height(0.dp)) } } Spacer(Modifier.height(16.dp)) @@ -73,10 +84,49 @@ fun SpawnScreen( value = title, onValueChange = { title = it }, label = { Text("Title") }, - placeholder = { Text("Echo session") }, singleLine = true, 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) }, + ) + } + } + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = cwd, + onValueChange = { cwd = it }, + label = { Text("Working directory") }, + placeholder = { Text("/home/…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + 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) }, + ) + } + } + } + } Spacer(Modifier.height(24.dp)) error?.let { @@ -90,7 +140,14 @@ fun SpawnScreen( scope.launch { try { val spawned = withContext(Dispatchers.IO) { - spawnSession(settings, kind, title.trim()) + spawnSession( + settings, + kind, + title.trim(), + model = model.takeIf { kind == "claude" && it != "default" }, + cwd = cwd.takeIf { kind == "claude" }, + permissionMode = permissionMode.takeIf { kind == "claude" }, + ) } onSpawned(spawned) } catch (e: ApiException) { diff --git a/gen-dev-cert.sh b/gen-dev-cert.sh index de6f4af..ec0ba06 100755 --- a/gen-dev-cert.sh +++ b/gen-dev-cert.sh @@ -61,8 +61,13 @@ if [ -f ca.pem ]; then else echo "==> Generating CA key + self-signed CA certificate" openssl ecparam -name prime256v1 -genkey -noout -out ca-key.pem + # Explicit keyUsage: strict verifiers (e.g. Python 3.14's ssl) reject a + # CA without it, and Android could follow -- cheap to be proper now, + # expensive to regenerate after phones have pinned it. openssl req -new -x509 -key ca-key.pem -out ca.pem -days 3650 \ - -subj "/O=ai-app dev/CN=ai-app dev CA" + -subj "/O=ai-app dev/CN=ai-app dev CA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" fi echo "==> Generating leaf key + CSR for $SERVER_IP (+ dev addresses)" @@ -73,6 +78,9 @@ openssl req -new -key leaf-key.pem -out leaf.csr \ echo "==> Signing leaf certificate with the dev CA" cat > leaf.ext <>, UrlPath(id): UrlPath, axum::Json(body): axum::Json, ) -> Result { - lookup(&manager, &id)?.set_model(&body.model); + manager.set_session_model(&id, &body.model).map_err(bad_request)?; Ok(StatusCode::NO_CONTENT) } diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs new file mode 100644 index 0000000..ad42565 --- /dev/null +++ b/server/src/session/claude.rs @@ -0,0 +1,736 @@ +//! The Claude Code driver: `claude -p` speaking stream-json on stdio, +//! translated into the common event model. +//! +//! Wire format pinned against CLI 2.1.237 by probing (2026-08-24; scripts +//! summarized here since they live outside the repo): +//! +//! - Outbound: `system/init` (carries `session_id`, the `--resume` token), +//! `stream_event` (raw API deltas; `text_delta` is the streaming text), +//! consolidated `assistant` messages (their `tool_use` blocks have the +//! complete input), `user` messages with `tool_result` blocks, a `result` +//! per turn (usage + cost), `control_request` for anything needing a +//! human, `control_response` answering ours. +//! - Permission prompts require the hidden `--permission-prompt-tool stdio` +//! flag; they arrive as `control_request{subtype:can_use_tool}` and are +//! answered with `{behavior:"allow",updatedInput}` or +//! `{behavior:"deny",message}`. `AskUserQuestion` uses the same shape, +//! with the chosen labels added to `updatedInput` as +//! `answers:{: