feat(agent): an Agent tab in the launcher that talks to Claude Code outside any session

This commit is contained in:
Kurt Himebauch committed 2026-11-10 00:40:35 -05:00
1 parent 2e34213b28
commit 71481ac2ff
8 files changed
+470 -2

No files matched your search

+1
View File
@@ -302,6 +302,7 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:theme="@style/SessionTheme" />
<service android:name=".agent.AgentHostService" android:exported="false" />
<service
android:name=".session.SessionService"
android:exported="false"
@@ -0,0 +1,230 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.Writer
import java.nio.file.Files
/**
* The launcher's agent: Claude Code from the Linux userland, run headless with stream-json on
* stdin and stdout, outside any game or desktop session. The conversation id and transcript are
* kept on disk, so a restart (a Lab build, a crash) continues the same conversation.
*/
object AgentHost {
sealed interface Entry {
data class Asked(val text: String) : Entry
data class Said(val text: String) : Entry
data class Used(val tool: String, val detail: String) : Entry
data class Finished(val error: String?) : Entry
}
enum class State { MISSING, IDLE, WORKING }
private const val TAG = "AgentHost"
private const val CLAUDE = "/root/.local/bin/claude"
private const val WORKSPACE = "/root/DroidDeck"
val entries = mutableStateListOf<Entry>()
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<Entry>)
@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<Entry> = 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())
}
@@ -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))
}
}
}
@@ -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))
}
}
}
@@ -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
@@ -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<Int, ImageVector> = 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
+8
View File
@@ -1141,6 +1141,14 @@
<string name="lab_off_title">Lab build turned off</string>
<string name="lab_off_other_install">It was built for another version of DroidDeck. It is kept in Updates.</string>
<string name="lab_off_did_not_start">DroidDeck did not start on it twice</string>
<string name="rail_agent">Agent</string>
<string name="agent_channel_name">Agent</string>
<string name="agent_working">Agent is working</string>
<string name="agent_missing">Install Claude Code in the Linux userland to use the agent.</string>
<string name="agent_hint">Ask about a game, a crash, or a change to DroidDeck</string>
<string name="agent_send">Send</string>
<string name="agent_new">New conversation</string>
<string name="agent_failed">Stopped: %1$s</string>
<string name="upd_pr">PR #%1$d</string>
<string name="upd_not_built">Not built for this copy of DroidDeck</string>
<string name="upd_updated">Updated %1$s</string>
@@ -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<Entry>(), 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<Entry>(), AgentHost.readTranscript(File(file.path + ".missing")))
}
}