Phase 2 core: ClaudeDriver over stream-json, permissions and questions on the phone

The second driver behind the same trait: claude -p with stream-json both
ways, the hidden --permission-prompt-tool stdio flag (without which no
permission ever reaches a client), text deltas streamed from raw API
events, tool_use/tool_result mapped to tool events, and can_use_tool
control requests surfaced as Question events -- plain permissions as
Allow/Deny, AskUserQuestion as one Question per sub-question with the
chosen labels sent back in updatedInput.answers keyed by question text
(wire shapes pinned by live probes against CLI 2.1.237, recorded in the
module doc). The CLI session id is persisted per session dir, so a
backend restart respawns with --resume and loses nothing. set_model
rides the control protocol and persists through the manager; the spawn
screen grows model/cwd/permission-mode fields.

Also: the dev CA now carries proper keyUsage/basicConstraints
extensions (strict verifiers reject it otherwise) -- regenerated and
re-pinned before any real phone has installed the app.

Verified: 20 unit tests + clippy clean; scripted end-to-end over the
HTTP API (AskUserQuestion round trip, Bash permission allow, streaming,
restart with --resume remembering earlier work, delete); and on the
emulator, a live haiku session asking Tea-or-coffee and acknowledging
the tapped answer.

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-24 21:31:50 -04:00
1 parent f3cebeea78
commit 95d389e2b8
10 files changed
+882 -37

No files matched your search

@@ -111,12 +111,23 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
}
/** 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(
@@ -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-----
"""
@@ -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<String?>(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) {