From 71481ac2ff85daede80dd458ffe85234c4e38df3 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:45:00 -0500 Subject: [PATCH] feat(agent): an Agent tab in the launcher that talks to Claude Code outside any session --- app/src/main/AndroidManifest.xml | 1 + .../com/droiddeck/launcher/agent/AgentHost.kt | 230 ++++++++++++++++++ .../launcher/agent/AgentHostService.kt | 51 ++++ .../com/droiddeck/launcher/ui/AgentPage.kt | 113 +++++++++ .../droiddeck/launcher/ui/FrontEndContent.kt | 4 + .../com/droiddeck/launcher/ui/FrontEndRail.kt | 8 +- app/src/main/res/values/strings.xml | 8 + .../droiddeck/launcher/agent/AgentHostTest.kt | 57 +++++ 8 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5bb0dc0d..82839c09d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -302,6 +302,7 @@ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" android:theme="@style/SessionTheme" /> + () + var state by mutableStateOf(State.IDLE) + private set + var installed by mutableStateOf(false) + private set + + private val main = Handler(Looper.getMainLooper()) + private var process: Process? = null + private var input: Writer? = null + private var loaded = false + + fun refresh(context: Context) { + val claude = File(LinuxRuntime.rootDir(context), CLAUDE.removePrefix("/")) + installed = claude.exists() || Files.isSymbolicLink(claude.toPath()) + if (!installed) state = State.MISSING else if (state == State.MISSING) state = State.IDLE + if (!loaded) { + loaded = true + entries.addAll(readTranscript(transcript(context))) + } + } + + fun send(context: Context, text: String) { + val app = context.applicationContext + val message = text.trim() + if (message.isEmpty() || state != State.IDLE) return + val writer = input?.takeIf { process?.isAlive == true } ?: start(app) ?: return + record(app, Entry.Asked(message)) + state = State.WORKING + AgentHostService.start(app) + Thread({ + runCatching { + writer.write(userMessage(message)) + writer.write("\n") + writer.flush() + }.onFailure { e -> + Log.w(TAG, "could not reach the agent", e) + main.post { finish(app, e.message ?: "agent stopped") } + } + }, "agent-send").start() + } + + fun stop() { + process?.destroy() + process = null + input = null + } + + fun forget(context: Context) { + stop() + entries.clear() + transcript(context).delete() + prefs(context).edit().remove(SESSION_KEY).apply() + state = if (installed) State.IDLE else State.MISSING + } + + private fun start(app: Context): Writer? { + val workspace = if (File(LinuxRuntime.rootDir(app), WORKSPACE.removePrefix("/")).isDirectory) WORKSPACE else "/root" + val resume = prefs(app).getString(SESSION_KEY, null) + val claude = buildList { + addAll(listOf(CLAUDE, "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose")) + if (resume != null) addAll(listOf("--resume", resume)) + } + val guest = listOf( + "/usr/bin/env", "HOME=/root", "PATH=/root/.local/bin:/usr/local/bin:/usr/bin:/bin", + "ADB_SERIAL=127.0.0.1:5555", "BROWSER=/root/.local/bin/android-open", + "/bin/sh", "-c", "cd \"$1\" && shift && exec \"$@\"", "sh", workspace, + ) + claude + val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), guest) + val builder = ProcessBuilder(command).redirectErrorStream(true) + builder.environment().apply { + remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH") + put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path) + put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path) + if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1") + LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } + } + val child = try { + builder.start() + } catch (e: Exception) { + Log.w(TAG, "could not start the agent", e) + record(app, Entry.Finished(e.message ?: "could not start")) + return null + } + process = child + val writer = child.outputStream.bufferedWriter() + input = writer + Thread({ + runCatching { + child.inputStream.bufferedReader().forEachLine { line -> + val event = parse(line) ?: return@forEachLine + main.post { apply(app, child, event) } + } + } + main.post { + if (process === child) { + process = null + input = null + if (state == State.WORKING) finish(app, "agent stopped") + } + } + }, "agent-output").start() + return writer + } + + private fun apply(app: Context, child: Process, event: Event) { + if (process !== child) return + event.session?.let { prefs(app).edit().putString(SESSION_KEY, it).apply() } + event.entries.forEach { if (it is Entry.Finished) finish(app, it.error) else record(app, it) } + } + + private fun finish(app: Context, error: String?) { + record(app, Entry.Finished(error)) + state = State.IDLE + AgentHostService.stop(app) + } + + private fun record(app: Context, entry: Entry) { + entries += entry + runCatching { transcript(app).apply { parentFile?.mkdirs() }.appendText(encode(entry) + "\n") } + } + + private const val SESSION_KEY = "session_id" + + private fun prefs(context: Context) = context.getSharedPreferences("agent", Context.MODE_PRIVATE) + + private fun transcript(context: Context) = File(context.filesDir, "agent/transcript.jsonl") + + @VisibleForTesting + internal data class Event(val session: String?, val entries: List) + + @VisibleForTesting + internal fun userMessage(text: String): String = JSONObject() + .put("type", "user") + .put("message", JSONObject().put("role", "user").put("content", JSONArray().put(JSONObject().put("type", "text").put("text", text)))) + .toString() + + /** One stream-json line from Claude Code, or null when it carries nothing to show. */ + @VisibleForTesting + internal fun parse(line: String): Event? { + val o = runCatching { JSONObject(line) }.getOrNull() ?: return null + val session = o.optString("session_id").takeIf { it.isNotEmpty() } + val entries = when (o.optString("type")) { + "assistant" -> { + val content = o.optJSONObject("message")?.optJSONArray("content") ?: JSONArray() + (0 until content.length()).mapNotNull { i -> + val block = content.optJSONObject(i) ?: return@mapNotNull null + when (block.optString("type")) { + "text" -> block.optString("text").trim().takeIf { it.isNotEmpty() }?.let { Entry.Said(it) } + "tool_use" -> Entry.Used(block.optString("name"), toolDetail(block.optJSONObject("input"))) + else -> null + } + } + } + "result" -> listOf(Entry.Finished( + if (o.optBoolean("is_error")) o.optString("result").ifEmpty { o.optString("subtype") } else null, + )) + else -> emptyList() + } + return if (session == null && entries.isEmpty()) null else Event(session, entries) + } + + private fun toolDetail(input: JSONObject?): String { + input ?: return "" + for (key in listOf("command", "file_path", "pattern", "url", "description")) { + input.optString(key).takeIf { it.isNotEmpty() }?.let { return it.lineSequence().first().take(160) } + } + return "" + } + + @VisibleForTesting + internal fun encode(entry: Entry): String = when (entry) { + is Entry.Asked -> JSONObject().put("asked", entry.text) + is Entry.Said -> JSONObject().put("said", entry.text) + is Entry.Used -> JSONObject().put("used", entry.tool).put("detail", entry.detail) + is Entry.Finished -> JSONObject().put("finished", true).put("error", entry.error ?: JSONObject.NULL) + }.toString() + + @VisibleForTesting + internal fun readTranscript(file: File): List = runCatching { + file.readLines().mapNotNull { line -> + val o = runCatching { JSONObject(line) }.getOrNull() ?: return@mapNotNull null + when { + o.has("asked") -> Entry.Asked(o.getString("asked")) + o.has("said") -> Entry.Said(o.getString("said")) + o.has("used") -> Entry.Used(o.getString("used"), o.optString("detail")) + o.has("finished") -> Entry.Finished(if (o.isNull("error")) null else o.optString("error")) + else -> null + } + } + }.getOrDefault(emptyList()) +} diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt new file mode 100644 index 000000000..4f172f652 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.agent + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import com.droiddeck.launcher.FrontEndActivity +import com.droiddeck.launcher.R + +/** Keeps the app process alive while the agent works and the launcher is in the background. */ +class AgentHostService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val manager = getSystemService(NotificationManager::class.java) + manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.agent_channel_name), + NotificationManager.IMPORTANCE_LOW).apply { setShowBadge(false) }) + val open = PendingIntent.getActivity(this, 0, + Intent(this, FrontEndActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + .putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent"), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val notification = Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_session) + .setContentTitle(getString(R.string.agent_working)) + .setContentIntent(open) + .setOngoing(true) + .setShowWhen(false) + .setCategory(Notification.CATEGORY_PROGRESS) + .build() + startForeground(NOTIFICATION_ID, notification) + return START_NOT_STICKY + } + + companion object { + private const val CHANNEL_ID = "agent" + private const val NOTIFICATION_ID = 4107 + + fun start(context: Context) { + runCatching { context.startForegroundService(Intent(context, AgentHostService::class.java)) } + } + + fun stop(context: Context) { + context.stopService(Intent(context, AgentHostService::class.java)) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt new file mode 100644 index 000000000..8349c27fd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt @@ -0,0 +1,113 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.agent.AgentHost +import com.droiddeck.launcher.agent.AgentHost.Entry + +@Composable +internal fun AgentPage(modifier: Modifier) { + val context = LocalContext.current + remember { AgentHost.refresh(context) } + if (AgentHost.state == AgentHost.State.MISSING) { + Column(modifier) { Note(stringResource(R.string.agent_missing)) } + return + } + val colors = MaterialTheme.colorScheme + val list = rememberLazyListState() + var draft by rememberSaveable { mutableStateOf("") } + val working = AgentHost.state == AgentHost.State.WORKING + val send = { + if (!working && draft.isNotBlank()) { + AgentHost.send(context, draft) + draft = "" + } + } + LaunchedEffect(AgentHost.entries.size) { + if (AgentHost.entries.isNotEmpty()) list.animateScrollToItem(AgentHost.entries.size - 1) + } + Column(modifier.fillMaxSize()) { + LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = list, verticalArrangement = Arrangement.spacedBy(8.dp)) { + itemsIndexed(AgentHost.entries) { _, entry -> AgentEntry(entry) } + if (working) item { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp)) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp)) { + IconButton(onClick = { AgentHost.forget(context) }, enabled = !working) { + Icon(Icons.Outlined.Add, stringResource(R.string.agent_new)) + } + OutlinedTextField( + draft, { draft = it }, Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.agent_hint)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { send() }), + maxLines = 4, + ) + IconButton(onClick = send, enabled = !working && draft.isNotBlank()) { + Icon(Icons.AutoMirrored.Outlined.Send, stringResource(R.string.agent_send), tint = colors.primary) + } + } + } +} + +@Composable +private fun AgentEntry(entry: Entry) { + val colors = MaterialTheme.colorScheme + when (entry) { + is Entry.Asked -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Text(entry.text, fontSize = 15.sp, color = colors.onPrimaryContainer, + modifier = Modifier.widthIn(max = 560.dp).clip(Shape12).background(colors.primaryContainer).padding(12.dp)) + } + is Entry.Said -> Text(entry.text, fontSize = 15.sp, color = colors.onSurface, modifier = Modifier.widthIn(max = 720.dp).padding(horizontal = 4.dp)) + is Entry.Used -> Text( + if (entry.detail.isEmpty()) entry.tool else "${entry.tool} ${entry.detail}", + fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant, maxLines = 2, + modifier = Modifier.padding(horizontal = 4.dp), + ) + is Entry.Finished -> entry.error?.let { + Text(stringResource(R.string.agent_failed, it), fontSize = 13.sp, color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 1c4536fc0..2259a2b34 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -270,6 +270,10 @@ private fun Content( DesktopPage(s, a, modifier, onSelect) return } + if (selected == "agent") { + AgentPage(modifier.padding(horizontal = padH).padding(top = padV)) + return + } if (selected == "android-apps" && s.isHomeApp) { AndroidAppsPage(s, modifier, onAndroidAppClick) return diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index ecc597c93..17e6b488b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -47,6 +47,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.LocalMall @@ -123,7 +124,9 @@ internal fun BottomBar( internal fun navigationKeys(s: FrontEndState) = buildList { add("steam"); add("games") if (s.gameStoresEnabled) add("stores") - add("desktop"); add("components") + add("desktop") + if (com.droiddeck.launcher.agent.AgentHost.installed) add("agent") + add("components") if (s.isHomeApp) add("android-apps") add("setup"); add("updates") } @@ -156,7 +159,7 @@ internal fun SideRail( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp), ) { - val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0) + val count = navigationKeys(s).size BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp) val shown = Hops.railPreview ?: selected @@ -197,6 +200,7 @@ internal fun railLabelIcon(key: String): Pair = when (key) { "stores" -> R.string.rail_stores to Icons.Outlined.LocalMall "desktop" -> R.string.rail_desktop to Icons.Outlined.DesktopWindows "components" -> R.string.rail_components to Icons.Outlined.Layers + "agent" -> R.string.rail_agent to Icons.Outlined.AutoAwesome "android-apps" -> R.string.rail_apps to Icons.Outlined.Apps "setup" -> R.string.rail_setup to Icons.Outlined.Tune else -> R.string.rail_updates to Icons.Outlined.SystemUpdate diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3bc07cd3f..519883471 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1141,6 +1141,14 @@ Lab build turned off It was built for another version of DroidDeck. It is kept in Updates. DroidDeck did not start on it twice + Agent + Agent + Agent is working + Install Claude Code in the Linux userland to use the agent. + Ask about a game, a crash, or a change to DroidDeck + Send + New conversation + Stopped: %1$s PR #%1$d Not built for this copy of DroidDeck Updated %1$s diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt new file mode 100644 index 000000000..032be581f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.agent + +import com.droiddeck.launcher.agent.AgentHost.Entry +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class AgentHostTest { + @Test fun theInitLineCarriesOnlyTheConversation() { + val event = AgentHost.parse("""{"type":"system","subtype":"init","session_id":"abc","tools":[]}""")!! + assertEquals("abc", event.session) + assertEquals(emptyList(), event.entries) + } + + @Test fun assistantTextAndToolUseBecomeEntries() { + val event = AgentHost.parse( + """{"type":"assistant","session_id":"abc","message":{"content":[ + {"type":"text","text":" Looking at the log. "}, + {"type":"tool_use","name":"Bash","input":{"command":"droiddeckctl state\nmore"}}, + {"type":"thinking","thinking":"hidden"}]}}""", + )!! + assertEquals(listOf(Entry.Said("Looking at the log."), Entry.Used("Bash", "droiddeckctl state")), event.entries) + } + + @Test fun aResultEndsTheTurn() { + assertEquals(listOf(Entry.Finished(null)), AgentHost.parse("""{"type":"result","subtype":"success","is_error":false,"result":"done"}""")!!.entries) + assertEquals(listOf(Entry.Finished("Invalid API key")), + AgentHost.parse("""{"type":"result","subtype":"success","is_error":true,"result":"Invalid API key"}""")!!.entries) + } + + @Test fun linesWithNothingToShowAreSkipped() { + assertNull(AgentHost.parse("not json")) + assertNull(AgentHost.parse("""{"type":"user","message":{"content":[{"type":"tool_result","content":"ok"}]}}""")) + } + + @Test fun aMessageIsSentAsOneStreamJsonUserLine() { + val o = JSONObject(AgentHost.userMessage("why did it crash?")) + assertEquals("user", o.getString("type")) + assertEquals("why did it crash?", o.getJSONObject("message").getJSONArray("content").getJSONObject(0).getString("text")) + } + + @Test fun theTranscriptSurvivesARestart() { + val entries = listOf(Entry.Asked("hi"), Entry.Said("hello"), Entry.Used("Read", "/root/x"), Entry.Finished(null), Entry.Finished("stopped")) + val file = Files.createTempFile("transcript", ".jsonl").toFile() + file.writeText(entries.joinToString("\n") { AgentHost.encode(it) } + "\nbroken line\n") + assertEquals(entries, AgentHost.readTranscript(file)) + assertEquals(emptyList(), AgentHost.readTranscript(File(file.path + ".missing"))) + } +}