From db933e4232f4bb0761969c34045f06f6d9ea0bbc Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Wed, 11 Nov 2026 14:10:02 -0500
Subject: [PATCH] feat(agent): an Agent tab, a session drawer page, and a
bubble over the game (#2376)
* feat(agent): an Agent tab in the launcher that talks to Claude Code outside any session
* feat(agent): the agent in the session drawer and as a bubble over the game
* fix(agent): connect loopback adb, show unread replies, and fit the drawer page to the screen
* feat(agent): first-run setup in the Agent tab, a harness choice with Codex, and no permission prompts
* fix(agent): install helpers off the main thread and offer sign-in only after the install
* fix(agent): connect adb only for commands that use it and run setup checks one at a time
* fix(agent): show why a setup check failed
* fix(agent): each harness has its own setup state and conversation
* fix(agent): start the adb server before connecting and retry the connection check
* fix(agent): switching harness clears the previous harness's setup results
* feat(agent): a header, a composer with send and stop, and Markdown replies in the agent chat
* fix(agent): leave room under the chat composer
* feat(agent): continue the conversation after a Lab build restarts the app
* feat(agent): show and remove what the agent stores in the Linux userland
* fix(agent): the build tools take about 5 GB
* feat(agent): DroidDeck opens on the Agent tab after restarting on a Lab build
* feat(agent): conversation history, model and plan mode, live replies, full tool input and output, processes, attachments, queued messages and a Stop that ends the turn
* fix(agent): check the Lab build less often while nothing is happening
* fix(agent): an old Lab build's progress is not shown as a failure, and a restart counts as success
* feat(agent): rebuild a kept Lab build from Updates, and tidy the chat's tool rows, costs, processes and times
* feat(agent): a setup step that prepares fast Lab builds for the installed version, kept alive while it runs
* fix(agent): the notification names a prepare and shows its percent without repeating the title
* fix(agent): run the agent's Lab builds in the app's own process and continue the conversation that asked after the restart
* fix(agent): setup's prepare runs the build itself instead of handing it to the app
* feat(agent): a turn that ends without a reply says so and offers Retry, the resume is retried once, and each turn's raw output is kept
* fix(agent): keep the previous turn's raw output too
* fix(agent): a resumed Claude Code turn no longer ends at the result for a background task the last run left behind
* fix(agent): install Codex's code-mode host with it, without which Codex runs no commands
* feat(agent): setup and storage collapse into a summary card like Setup's, and Codex tool rows show the command without its shell wrapper
* feat(agent): drop the agent bubble over sessions for now; the drawer keeps the agent
* fix(agent): the Agent tab shows once the Linux runtime is downloaded, even while its proot pack is still being fetched
* fix(agent): before the install brings adb, the connection step checks the app's own pairing instead of failing with adb not found
* fix(agent): proot's binding warnings are not shown as a setup step's reason
* fix(agent): the session drawer's Agent page fills the height under the drawer's header instead of a guess from the screen height
* fix(agent): write the browser and GitHub helpers before every setup step, not only the install
* fix(agent): find Wireless debugging's current port when the saved one no longer answers, so sign-ins open the browser after it restarts
* fix(agent): only take a Wireless debugging port that answers, since mDNS can still advertise an old one
* fix(agent): find a loopback Wireless debugging port by scanning when mDNS advertises none
* fix(agent): keep a Codex turn going through reconnect notices and read gh login's status reliably
* fix(agent): time out hung setup checks and keep their results and actions with their harness
* fix(agent): keep the draft across settings and show the Agent tab once the runtime installs
* fix(agent): keep the backslash when quoting apostrophes in a URL the browser helper opens
* fix(agent): refresh apt's package lists before installing, since a fresh Linux runtime ships without them
* fix(agent): sign-in links open through the app itself, so they work without Wireless debugging
* fix(agent): the connection step passes only when adb really reaches the device, not on adb's own error
* fix(agent): say Wireless debugging isn't reachable instead of adb's error
* fix(agent): keep prompts, tool ids, attachments and retries with their own turn and chat
* fix(agent): the service stops only once foreground, and Rebuild waits for a ready agent
* fix(agent): the Codex install says what it is downloading, so a long download doesn't look stuck
* fix(agent): pass the prompt to both CLIs the way they were tested on devices
* fix(agent): the browser helper uses the running app's files directory, falls back to adb when the app cannot open a link, and the link is not logged
---
.gitattributes | 1 +
app/src/main/AndroidManifest.xml | 1 +
app/src/main/assets/agent/android-open | 27 +
app/src/main/assets/agent/dd-gh-login | 27 +
.../main/java/com/droiddeck/launcher/App.kt | 2 +
.../droiddeck/launcher/FrontEndActivity.kt | 3 +
.../com/droiddeck/launcher/agent/AgentAdb.kt | 44 ++
.../launcher/agent/AgentAttachments.kt | 67 +++
.../droiddeck/launcher/agent/AgentChats.kt | 138 +++++
.../droiddeck/launcher/agent/AgentHarness.kt | 282 +++++++++
.../com/droiddeck/launcher/agent/AgentHost.kt | 534 ++++++++++++++++++
.../launcher/agent/AgentHostService.kt | 118 ++++
.../launcher/agent/AgentProcesses.kt | 105 ++++
.../droiddeck/launcher/agent/AgentSetup.kt | 175 ++++++
.../droiddeck/launcher/agent/AgentStorage.kt | 95 ++++
.../droiddeck/launcher/agent/LabProgress.kt | 53 ++
.../droiddeck/launcher/core/WirelessAdbFix.kt | 52 +-
.../com/droiddeck/launcher/ui/AgentEntries.kt | 307 ++++++++++
.../com/droiddeck/launcher/ui/AgentPage.kt | 393 +++++++++++++
.../com/droiddeck/launcher/ui/AgentPanels.kt | 182 ++++++
.../droiddeck/launcher/ui/AgentSetupView.kt | 215 +++++++
.../droiddeck/launcher/ui/FrontEndContent.kt | 4 +
.../com/droiddeck/launcher/ui/FrontEndRail.kt | 8 +-
.../droiddeck/launcher/ui/FrontEndSetup.kt | 12 +-
.../launcher/ui/SessionDrawerPage.kt | 8 +-
.../droiddeck/launcher/ui/SessionOverlay.kt | 11 +-
.../com/droiddeck/launcher/ui/UpdatesPage.kt | 10 +-
app/src/main/res/values/strings.xml | 95 ++++
.../droiddeck/launcher/agent/AgentHostTest.kt | 190 +++++++
.../launcher/agent/AgentStorageTest.kt | 29 +
.../launcher/ui/AgentMarkdownTest.kt | 27 +
.../launcher/ui/SessionDrawerPageTest.kt | 7 +
tools/agent/setup | 1 +
33 files changed, 3210 insertions(+), 13 deletions(-)
create mode 100644 app/src/main/assets/agent/android-open
create mode 100644 app/src/main/assets/agent/dd-gh-login
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentAdb.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentAttachments.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentChats.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentHarness.kt
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/agent/AgentProcesses.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentSetup.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentStorage.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/LabProgress.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AgentEntries.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AgentPanels.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AgentSetupView.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/agent/AgentStorageTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/AgentMarkdownTest.kt
diff --git a/.gitattributes b/.gitattributes
index 019aa1e8f..43e03c53c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -12,3 +12,4 @@ app/src/main/assets/desktop-removal/*.txt text eol=lf
*.pdf binary
app/src/main/resources/ea/graphql/*.gql -text
+app/src/main/assets/agent/* text eol=lf
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 514b4d914..0f3addcb7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -303,6 +303,7 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:theme="@style/SessionTheme" />
+
/dev/null
+if [ -d "$inbox" ] && printf '%s\n' "$url" > "$inbox/open-url.part" && mv -f "$inbox/open-url.part" "$inbox/open-url"; then
+ i=0
+ while [ $i -lt 30 ] && [ -e "$inbox/open-url" ]; do
+ sleep 0.2
+ i=$((i + 1))
+ done
+ if [ -e "$inbox/open-url" ]; then
+ rm -f "$inbox/open-url"
+ elif [ -e "$inbox/open-url.failed" ]; then
+ rm -f "$inbox/open-url.failed"
+ else
+ sleep 0.3
+ [ -e "$inbox/open-url.failed" ] && rm -f "$inbox/open-url.failed" || exit 0
+ fi
+fi
+serial=${ADB_SERIAL:-127.0.0.1:5555}
+adb connect "$serial" >/dev/null 2>&1
+quoted=$(printf "%s" "$url" | sed "s/'/'\\\\''/g")
+adb -s "$serial" shell "am start --display ${DD_BROWSER_DISPLAY:-0} -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
+ echo "Open this on any device: $url" >&2
diff --git a/app/src/main/assets/agent/dd-gh-login b/app/src/main/assets/agent/dd-gh-login
new file mode 100644
index 000000000..86ff4707e
--- /dev/null
+++ b/app/src/main/assets/agent/dd-gh-login
@@ -0,0 +1,27 @@
+#!/bin/bash
+# GitHub sign-in through Android's browser, with the one-time code typed in for the person.
+serial=${ADB_SERIAL:-127.0.0.1:5555}
+display=${DD_BROWSER_DISPLAY:-0}
+adb connect "$serial" >/dev/null 2>&1
+coproc GH { BROWSER=true gh auth login --hostname github.com --git-protocol https --web 2>&1; }
+# bash drops GH and GH_PID once gh exits, which can be before the loop reads its last line.
+ghpid=$GH_PID
+exec {ghout}<&"${GH[0]}" {ghin}>&"${GH[1]}"
+typed=
+while IFS= read -r line <&"$ghout"; do
+ printf '%s\n' "$line"
+ code=$(grep -oE '[A-Z0-9]{4}-[A-Z0-9]{4}' <<<"$line" | head -1)
+ if [ -n "$code" ] && [ -z "$typed" ]; then
+ typed=1
+ echo >&"$ghin"
+ adb -s "$serial" shell "am start --display $display -a android.intent.action.VIEW -d https://github.com/login/device" >/dev/null 2>&1
+ # Chrome drops characters from `input text` and from fast key events on this page.
+ keys=
+ for ((i = 0; i < ${#code}; i++)); do
+ c=${code:i:1}
+ [ "$c" = - ] || keys+="input -d $display keyevent KEYCODE_$c; sleep 0.4; "
+ done
+ (sleep 7; adb -s "$serial" shell "$keys" >/dev/null 2>&1) &
+ fi
+done
+wait "$ghpid"
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index b4c1621a4..79f42d118 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -42,6 +42,8 @@ class App : Application() {
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" ||
process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return
CrashHandler.install(this)
+ com.droiddeck.launcher.agent.AgentHost.refresh(this)
+ if (com.droiddeck.launcher.agent.AgentHost.available) com.droiddeck.launcher.lab.LabNotice.openOnInstall = "agent"
com.droiddeck.launcher.lab.LabNotice.started(this)
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
index f429efcbf..55fdf3f1f 100644
--- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
@@ -1105,6 +1105,8 @@ class FrontEndActivity : ComponentActivity() {
gameSpaceOffered = savedInstanceState?.getBoolean(STATE_GAME_SPACE_OFFERED) == true
if (savedInstanceState == null) readIntegrationIntent(intent)
readGameIntent(intent)
+ // A Lab build is usually the agent's work, so the app comes back where the agent is.
+ if (savedInstanceState == null && navRequest == null && com.droiddeck.launcher.lab.LabOverlay.promoted && com.droiddeck.launcher.agent.AgentHost.available) navRequest = "agent"
gameSyncFolder = GameFileSync.folder(this)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
@@ -3079,6 +3081,7 @@ class FrontEndActivity : ComponentActivity() {
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
}
refresh()
+ com.droiddeck.launcher.agent.AgentHost.refresh(this)
if (ok == true && LinuxRuntime.isInstalled(this)) protons.checkUpdates()
}
}, "runtime-operation").start()
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentAdb.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentAdb.kt
new file mode 100644
index 000000000..223bb07a8
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentAdb.kt
@@ -0,0 +1,44 @@
+package com.droiddeck.launcher.agent
+
+import android.content.Context
+import com.droiddeck.launcher.core.WirelessAdbFix
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import java.io.File
+
+/**
+ * Loopback adb for the agent, so droiddeckctl in the Linux userland reaches the app. It reuses
+ * the Wireless debugging pairing DroidDeck already made (for the phantom process fix): the paired
+ * key goes to the guest as an adb vendor key, and the serial is the paired TLS connect port.
+ * Without a pairing it falls back to adbd on 127.0.0.1:5555, which some devices and `adb tcpip`
+ * leave listening.
+ */
+object AgentAdb {
+ data class Link(val serial: String, val vendorKeys: String, val paired: Boolean)
+
+ private const val LEGACY = "127.0.0.1:5555"
+ private const val VENDOR_KEY = "/root/.android/droiddeck-paired.adbkey"
+
+ /** The display the browser opens on for sign-ins: the one the agent is being used on. */
+ @Volatile var browserDisplay = 0
+
+ /** Blocks while it looks up the connect port when none is saved; call it off the main thread. */
+ fun prepare(app: Context): Link {
+ val key = File(app.noBackupFilesDir, "wireless-adb-key.pem")
+ val address = WirelessAdbFix.liveConnectAddress(app) ?: WirelessAdbFix.savedConnectionAddress(app)
+ if (address == null || !key.isFile) return Link(LEGACY, VENDOR_KEY, false)
+ val guestKey = File(LinuxRuntime.rootDir(app), VENDOR_KEY.removePrefix("/"))
+ runCatching {
+ if (!guestKey.isFile || !guestKey.readBytes().contentEquals(key.readBytes())) {
+ guestKey.parentFile?.mkdirs()
+ guestKey.writeBytes(key.readBytes())
+ guestKey.setReadable(false, false)
+ guestKey.setReadable(true, true)
+ }
+ }
+ val host = address.substringBeforeLast(':')
+ val port = address.substringAfterLast(':').toIntOrNull()
+ ?: WirelessAdbFix.findConnectPort(app, host.trim('[', ']'))
+ ?: return Link(LEGACY, VENDOR_KEY, false)
+ return Link("$host:$port", VENDOR_KEY, true)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentAttachments.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentAttachments.kt
new file mode 100644
index 000000000..6c843df79
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentAttachments.kt
@@ -0,0 +1,67 @@
+package com.droiddeck.launcher.agent
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.compose.runtime.mutableStateListOf
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import java.io.File
+
+/**
+ * Files the user attaches to a message, copied into the Linux userland so the agent's own tools
+ * can read them (Claude Code's Read shows images and PDFs to the model).
+ */
+object AgentAttachments {
+ /** Guest paths waiting to go with the next message. */
+ val pending = mutableStateListOf()
+
+ private const val NEW = "new"
+ private const val GUEST_DIR = "/root/.cache/droiddeck/attachments"
+ private val IMAGE = setOf("png", "jpg", "jpeg", "webp", "gif", "bmp")
+
+ fun hostDir(context: Context) = File(LinuxRuntime.rootDir(context.applicationContext), GUEST_DIR.removePrefix("/"))
+
+ fun host(context: Context, guestPath: String) = File(LinuxRuntime.rootDir(context.applicationContext), guestPath.removePrefix("/"))
+
+ fun isImage(path: String) = path.substringAfterLast('.', "").lowercase() in IMAGE
+
+ /** Copies [uris] for [chat] (or a new conversation) on the calling thread; returns their guest paths. */
+ fun copy(context: Context, chat: String?, uris: List): List {
+ val app = context.applicationContext
+ val folder = chat ?: NEW
+ val dir = File(hostDir(app), folder).apply { mkdirs() }
+ return uris.mapNotNull { uri ->
+ runCatching {
+ val name = displayName(app, uri).replace(Regex("[^A-Za-z0-9._-]"), "_").ifEmpty { "file" }
+ var target = File(dir, name)
+ var n = 1
+ while (target.exists()) target = File(dir, "${name.substringBeforeLast('.')}-${n++}${name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }}")
+ app.contentResolver.openInputStream(uri)!!.use { input -> target.outputStream().use { input.copyTo(it) } }
+ "$GUEST_DIR/$folder/${target.name}"
+ }.getOrNull()
+ }
+ }
+
+ /** Moves files attached before [chat] existed into its folder, so deleting the chat removes them; returns their new guest paths. */
+ fun adopt(context: Context, chat: String, files: List): List = files.map { path ->
+ if (!path.startsWith("$GUEST_DIR/$NEW/")) return@map path
+ val dir = File(hostDir(context), chat).apply { mkdirs() }
+ val moved = File(dir, path.substringAfterLast('/'))
+ if (host(context, path).renameTo(moved)) "$GUEST_DIR/$chat/${moved.name}" else path
+ }
+
+ /** Deletes copies that will not be sent. */
+ fun discard(context: Context, files: List) = files.forEach { host(context, it).delete() }
+
+ /** The message the CLI gets: the user's text, then where the attached files are. */
+ fun prompt(text: String, files: List): String {
+ if (files.isEmpty()) return text
+ val list = files.joinToString("\n") { "- $it" }
+ return "$text\n\nAttached files (read them with your tools):\n$list".trim()
+ }
+
+ private fun displayName(context: Context, uri: Uri): String =
+ context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
+ if (c.moveToFirst()) c.getString(0) else null
+ } ?: uri.lastPathSegment?.substringAfterLast('/') ?: "file"
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentChats.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentChats.kt
new file mode 100644
index 000000000..e228f1aa5
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentChats.kt
@@ -0,0 +1,138 @@
+package com.droiddeck.launcher.agent
+
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import com.droiddeck.launcher.agent.AgentHost.Entry
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * Conversations kept on the device, one JSON Lines file each in `files/agent/chats`: a header
+ * line with the harness, a line with the CLI's conversation id once it has one, then the entries.
+ */
+object AgentChats {
+ data class Chat(val id: String, val harness: String, val title: String, val updated: Long)
+
+ class Loaded(val harness: String?, val conversation: String?, val entries: List)
+
+ private const val MAX_OUTPUT = 24_000
+
+ fun dir(context: Context) = File(context.filesDir, "agent/chats")
+
+ fun file(context: Context, id: String) = File(dir(context), "$id.jsonl")
+
+ fun newId(context: Context): String {
+ var stamp = System.currentTimeMillis()
+ while (file(context, stamp.toString()).exists()) stamp++
+ return stamp.toString()
+ }
+
+ /** Newest first; chats with nothing asked yet are left out. */
+ fun list(context: Context): List = dir(context).listFiles { f -> f.name.endsWith(".jsonl") }.orEmpty()
+ .mapNotNull { f ->
+ val loaded = read(f)
+ val title = loaded.entries.firstNotNullOfOrNull { (it as? Entry.Asked)?.text } ?: return@mapNotNull null
+ Chat(f.name.removeSuffix(".jsonl"), loaded.harness ?: ClaudeCode.id, title.lineSequence().first().take(120), f.lastModified())
+ }
+ .sortedByDescending { it.updated }
+
+ fun delete(context: Context, id: String) {
+ file(context, id).delete()
+ File(AgentAttachments.hostDir(context), id).deleteRecursively()
+ }
+
+ fun start(file: File, harness: String) {
+ file.parentFile?.mkdirs()
+ file.writeText(JSONObject().put("harness", harness).toString() + "\n")
+ }
+
+ fun appendConversation(file: File, conversation: String) =
+ runCatching { file.appendText(JSONObject().put("conversation", conversation).toString() + "\n") }
+
+ fun append(file: File, entry: Entry) = runCatching { file.appendText(encode(entry) + "\n") }
+
+ /** The transcript before chats were kept, moved into the first chat. */
+ fun migrate(context: Context, harness: String, conversation: String?): String? {
+ val old = File(context.filesDir, "agent/transcript.jsonl")
+ if (!old.isFile) return null
+ val id = newId(context)
+ val target = file(context, id)
+ start(target, harness)
+ conversation?.let { appendConversation(target, it) }
+ target.appendText(old.readText())
+ old.delete()
+ return id
+ }
+
+ @VisibleForTesting
+ internal fun encode(entry: Entry): String = when (entry) {
+ is Entry.Asked -> JSONObject().put("asked", entry.text).apply { if (entry.files.isNotEmpty()) put("files", JSONArray(entry.files)) }
+ is Entry.Said -> JSONObject().put("said", entry.text)
+ is Entry.Thought -> JSONObject().put("thought", entry.text)
+ is Entry.Result -> JSONObject().put("result", entry.id).put("output", entry.output).put("failed", entry.failed)
+ is Entry.Used -> JSONObject().put("used", entry.tool).put("detail", entry.detail).put("id", entry.id).put("input", entry.input)
+ .apply { entry.output?.let { put("output", it).put("failed", entry.failed) } }
+ is Entry.Finished -> JSONObject().put("finished", true).put("error", entry.error ?: JSONObject.NULL).put("stopped", entry.stopped).apply {
+ entry.usage?.let { put("ms", it.durationMs).put("cost", it.costUsd).put("in", it.inputTokens).put("out", it.outputTokens) }
+ }
+ }.toString()
+
+ @VisibleForTesting
+ internal fun read(file: File): Loaded {
+ var harness: String? = null
+ var conversation: String? = null
+ val entries = mutableListOf()
+ runCatching {
+ file.forEachLine { line ->
+ val o = runCatching { JSONObject(line) }.getOrNull() ?: return@forEachLine
+ when {
+ o.has("harness") -> harness = o.getString("harness")
+ o.has("conversation") -> conversation = o.getString("conversation")
+ else -> decode(o)?.let { merge(entries, it) }
+ }
+ }
+ }
+ return Loaded(harness, conversation, entries)
+ }
+
+ private fun decode(o: JSONObject): Entry? = when {
+ o.has("asked") -> Entry.Asked(o.getString("asked"), o.optJSONArray("files")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty())
+ o.has("said") -> Entry.Said(o.getString("said"))
+ o.has("thought") -> Entry.Thought(o.getString("thought"))
+ o.has("used") -> Entry.Used(o.optString("id"), o.getString("used"), o.optString("detail"), o.optString("input"),
+ if (o.has("output")) o.optString("output") else null, o.optBoolean("failed"))
+ o.has("result") -> Entry.Result(o.getString("result"), o.optString("output"), o.optBoolean("failed"))
+ o.has("finished") -> Entry.Finished(
+ if (o.isNull("error")) null else o.optString("error"), o.optBoolean("stopped"),
+ if (o.has("ms")) AgentHost.Usage(o.optLong("ms"), o.optDouble("cost", 0.0), o.optLong("in"), o.optLong("out")) else null,
+ )
+ else -> null
+ }
+
+ /**
+ * A tool's result is shown on the call it answers, and a call seen again (an updated todo list)
+ * replaces it. Only the current turn is searched: Codex numbers its items again in each process.
+ */
+ fun merge(entries: MutableList, entry: Entry): Boolean {
+ val turn = entries.indexOfLast { it is Entry.Asked }
+ if (entry is Entry.Used && entry.id.isNotEmpty()) {
+ val same = entries.indexOfLast { it is Entry.Used && it.id == entry.id }
+ if (same > turn) {
+ entries[same] = entry
+ return true
+ }
+ }
+ if (entry !is Entry.Result) {
+ entries += entry
+ return true
+ }
+ val index = entries.indexOfLast { it is Entry.Used && it.id == entry.id && entry.id.isNotEmpty() }
+ if (index <= turn) return false
+ entries[index] = (entries[index] as Entry.Used).copy(output = clip(entry.output), failed = entry.failed)
+ return true
+ }
+
+ /** Long outputs keep their end, where errors and summaries usually are. */
+ fun clip(output: String) = if (output.length <= MAX_OUTPUT) output else "…\n" + output.takeLast(MAX_OUTPUT)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentHarness.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentHarness.kt
new file mode 100644
index 000000000..de45d4b51
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentHarness.kt
@@ -0,0 +1,282 @@
+package com.droiddeck.launcher.agent
+
+import androidx.annotation.VisibleForTesting
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+
+/** One coding agent CLI in the Linux userland, run one process per turn. */
+interface AgentHarness {
+ val id: String
+ val name: String
+ /** Absolute guest path of the CLI. */
+ val binary: String
+ /** Guest paths of everything the CLI needs installed, [binary] included. */
+ val files: List get() = listOf(binary)
+ /** A guest shell script that installs the CLI. */
+ val install: String
+ /** A guest command whose exit status is 0 when the CLI is signed in. */
+ val signedIn: List
+ /** A guest command that signs in through $BROWSER. */
+ val signIn: List
+ /** Whether [Options.plan] means anything to this CLI. */
+ val plans: Boolean
+ /** Models to offer besides the CLI's default; [rootfs] is where the CLI's own files can be read. */
+ fun models(rootfs: File): List
+ /** The guest command for one turn; [conversation] is null for the first one. */
+ fun turn(message: String, conversation: String?, options: Options): List
+ /** One line of the CLI's output, or null when it carries nothing to show. */
+ fun parse(line: String): AgentEvent?
+
+ data class Model(val id: String, val label: String)
+
+ data class Options(val model: String? = null, val plan: Boolean = false, val brief: String? = null)
+
+ companion object {
+ val all: List = listOf(ClaudeCode, Codex)
+ fun byId(id: String?): AgentHarness = all.firstOrNull { it.id == id } ?: ClaudeCode
+ }
+}
+
+/** What one line of output carries: whole entries, or text still being written. */
+data class AgentEvent(
+ val conversation: String? = null,
+ val entries: List = emptyList(),
+ val text: String? = null,
+ val thinking: String? = null,
+ /** Tokens the conversation's context holds after this step. */
+ val context: Long? = null,
+ val usage: AgentHost.Usage? = null,
+)
+
+/** Claude Code: `claude -p` with stream-json output, resumed by session id. */
+object ClaudeCode : AgentHarness {
+ override val id = "claude"
+ override val name = "Claude Code"
+ override val binary = "/root/.local/bin/claude"
+ override val install = "curl -fsSL https://claude.ai/install.sh | bash"
+ override val signedIn = listOf("sh", "-c", "$binary auth status | grep -q '\"loggedIn\": true'")
+ override val signIn = listOf(binary, "auth", "login", "--claudeai")
+ override val plans = true
+
+ override fun models(rootfs: File) = listOf(
+ AgentHarness.Model("opus", "Opus"), AgentHarness.Model("sonnet", "Sonnet"), AgentHarness.Model("haiku", "Haiku"),
+ )
+
+ // DroidDeck runs the agent unattended on the user's own device; permission prompts have no one to answer them.
+ override fun turn(message: String, conversation: String?, options: AgentHarness.Options) = buildList {
+ addAll(listOf(binary, "-p", message, "--output-format", "stream-json", "--verbose", "--include-partial-messages"))
+ addAll(listOf("--permission-mode", if (options.plan) "plan" else "bypassPermissions"))
+ options.model?.let { addAll(listOf("--model", it)) }
+ options.brief?.let { addAll(listOf("--append-system-prompt", it)) }
+ if (conversation != null) addAll(listOf("--resume", conversation))
+ }
+
+ override fun parse(line: String): AgentEvent? {
+ val o = runCatching { JSONObject(line) }.getOrNull() ?: return null
+ val conversation = o.optString("session_id").takeIf { it.isNotEmpty() }
+ return when (o.optString("type")) {
+ "stream_event" -> {
+ val delta = o.optJSONObject("event")?.takeIf { it.optString("type") == "content_block_delta" }?.optJSONObject("delta") ?: return null
+ when (delta.optString("type")) {
+ "text_delta" -> AgentEvent(conversation, text = delta.optString("text"))
+ "thinking_delta" -> AgentEvent(conversation, thinking = delta.optString("thinking"))
+ else -> null
+ }
+ }
+ "assistant" -> {
+ val message = o.optJSONObject("message")
+ val content = message?.optJSONArray("content") ?: JSONArray()
+ val entries = (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 { AgentHost.Entry.Said(it) }
+ "thinking" -> block.optString("thinking").trim().takeIf { it.isNotEmpty() }?.let { AgentHost.Entry.Thought(it) }
+ "tool_use" -> {
+ val input = block.optJSONObject("input")
+ AgentHost.Entry.Used(block.optString("id"), block.optString("name"), detail(input), fullInput(block.optString("name"), input))
+ }
+ else -> null
+ }
+ }
+ val usage = message?.optJSONObject("usage")
+ val context = usage?.let { it.optLong("input_tokens") + it.optLong("cache_read_input_tokens") + it.optLong("cache_creation_input_tokens") + it.optLong("output_tokens") }
+ AgentEvent(conversation, entries, context = context?.takeIf { it > 0 })
+ }
+ "user" -> {
+ val content = o.optJSONObject("message")?.optJSONArray("content") ?: return conversation?.let { AgentEvent(it) }
+ val results = (0 until content.length()).mapNotNull { i ->
+ val block = content.optJSONObject(i)?.takeIf { it.optString("type") == "tool_result" } ?: return@mapNotNull null
+ AgentHost.Entry.Result(block.optString("tool_use_id"), resultText(block.opt("content")), block.optBoolean("is_error"))
+ }
+ AgentEvent(conversation, results)
+ }
+ // A resumed session first answers background tasks the last run left behind; that result is not the turn's.
+ "result" -> if (o.optJSONObject("origin")?.optString("kind") == "task-notification") conversation?.let { AgentEvent(it) } else AgentEvent(
+ conversation,
+ listOf(AgentHost.Entry.Finished(if (o.optBoolean("is_error")) o.optString("result").ifEmpty { o.optString("subtype") } else null)),
+ usage = AgentHost.Usage(
+ o.optLong("duration_ms"), o.optDouble("total_cost_usd", 0.0),
+ o.optJSONObject("usage")?.let { it.optLong("input_tokens") + it.optLong("cache_read_input_tokens") + it.optLong("cache_creation_input_tokens") } ?: 0,
+ o.optJSONObject("usage")?.optLong("output_tokens") ?: 0,
+ ),
+ )
+ else -> conversation?.let { AgentEvent(it) }
+ }
+ }
+
+ private fun resultText(content: Any?): String = when (content) {
+ is String -> content
+ is JSONArray -> (0 until content.length()).mapNotNull { i ->
+ val part = content.optJSONObject(i) ?: return@mapNotNull null
+ when (part.optString("type")) {
+ "text" -> part.optString("text")
+ "image" -> "[image]"
+ else -> null
+ }
+ }.joinToString("\n")
+ null, JSONObject.NULL -> ""
+ else -> content.toString()
+ }
+
+ private fun detail(input: JSONObject?): String {
+ input ?: return ""
+ for (key in listOf("command", "file_path", "pattern", "url", "query", "description", "prompt")) {
+ input.optString(key).takeIf { it.isNotEmpty() }?.let { return firstLine(it) }
+ }
+ return ""
+ }
+
+ @VisibleForTesting
+ internal fun fullInput(tool: String, input: JSONObject?): String {
+ input ?: return ""
+ return when {
+ tool == "Bash" -> input.optString("command")
+ tool == "Edit" -> "${input.optString("file_path")}\n\n- ${input.optString("old_string").replace("\n", "\n- ")}\n\n+ ${input.optString("new_string").replace("\n", "\n+ ")}"
+ tool == "Write" -> "${input.optString("file_path")}\n\n${input.optString("content")}"
+ tool == "TodoWrite" -> todos(input.optJSONArray("todos"))
+ else -> input.toString(2)
+ }
+ }
+
+ private fun todos(list: JSONArray?) = (0 until (list?.length() ?: 0)).mapNotNull { i ->
+ val todo = list?.optJSONObject(i) ?: return@mapNotNull null
+ val mark = when (todo.optString("status")) { "completed" -> "[x]"; "in_progress" -> "[~]"; else -> "[ ]" }
+ "$mark ${todo.optString("content")}"
+ }.joinToString("\n")
+}
+
+/** OpenAI Codex: `codex exec --json`, resumed by thread id. */
+object Codex : AgentHarness {
+ override val id = "codex"
+ override val name = "Codex"
+ override val binary = "/root/.local/bin/codex"
+ // Code mode runs in a helper that each release ships as its own download.
+ private const val HOST = "/root/.local/bin/codex-code-mode-host"
+ override val files get() = listOf(binary, HOST)
+ override val install = """
+ set -e
+ release=${'$'}(curl -fsSL https://api.github.com/repos/openai/codex/releases/latest)
+ mkdir -p /root/.local/bin
+ for name in codex codex-code-mode-host; do
+ url=${'$'}(printf '%s' "${'$'}release" | grep -o "https://[^\"]*/${'$'}name-aarch64-unknown-linux-musl.tar.gz" | head -1)
+ echo "Downloading ${'$'}name"
+ curl -fsSL "${'$'}url" | tar -xz -C /tmp
+ install -m755 /tmp/${'$'}name-aarch64-unknown-linux-musl /root/.local/bin/${'$'}name
+ done
+ """.trimIndent()
+ override val signedIn = listOf(binary, "login", "status")
+ override val signIn = listOf(binary, "login")
+ override val plans = false
+
+ /** Codex keeps the models its account can use in `~/.codex/models_cache.json` once it has run. */
+ override fun models(rootfs: File): List = runCatching {
+ val o = JSONObject(File(rootfs, "root/.codex/models_cache.json").readText())
+ val list = o.optJSONArray("models") ?: JSONArray()
+ (0 until list.length()).mapNotNull { i ->
+ val m = list.optJSONObject(i) ?: return@mapNotNull null
+ val slug = m.optString("slug").ifEmpty { m.optString("id") }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
+ if (m.has("visibility") && m.optString("visibility") != "list") return@mapNotNull null
+ AgentHarness.Model(slug, m.optString("display_name").ifEmpty { slug })
+ }
+ }.getOrDefault(emptyList())
+
+ // Codex's own sandbox needs Landlock and seccomp, which proot does not provide.
+ override fun turn(message: String, conversation: String?, options: AgentHarness.Options) = buildList {
+ addAll(listOf(binary, "exec"))
+ if (conversation != null) addAll(listOf("resume", conversation))
+ addAll(listOf("--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check"))
+ options.model?.let { addAll(listOf("-m", it)) }
+ // codex exec has no system prompt option, so a new conversation starts with the brief.
+ add(if (conversation == null && options.brief != null) "${options.brief}\n\n$message" else message)
+ }
+
+ override fun parse(line: String): AgentEvent? {
+ val o = runCatching { JSONObject(line) }.getOrNull() ?: return null
+ val item = o.optJSONObject("item")
+ val id = item?.optString("id").orEmpty()
+ val entries = when (o.optString("type")) {
+ "thread.started" -> return AgentEvent(o.optString("thread_id").takeIf { it.isNotEmpty() })
+ "item.started" -> when (item?.optString("type")) {
+ "command_execution" -> shellCommand(item.optString("command")).let { listOf(AgentHost.Entry.Used(id, "Shell", firstLine(it), it)) }
+ "mcp_tool_call" -> listOf(AgentHost.Entry.Used(id, item.optString("tool").ifEmpty { "Tool" }, item.optString("server"), item.optJSONObject("arguments")?.toString(2).orEmpty()))
+ "web_search" -> listOf(AgentHost.Entry.Used(id, "Search", item.optString("query"), item.optString("query")))
+ else -> emptyList()
+ }
+ "item.updated" -> when (item?.optString("type")) {
+ "todo_list" -> listOf(todoEntry(id, item))
+ else -> emptyList()
+ }
+ "item.completed" -> when (item?.optString("type")) {
+ "agent_message" -> item.optString("text").trim().takeIf { it.isNotEmpty() }?.let { listOf(AgentHost.Entry.Said(it)) }.orEmpty()
+ "reasoning" -> item.optString("text").trim().takeIf { it.isNotEmpty() }?.let { listOf(AgentHost.Entry.Thought(it)) }.orEmpty()
+ "command_execution" -> listOf(AgentHost.Entry.Result(id, item.optString("aggregated_output"),
+ item.has("exit_code") && !item.isNull("exit_code") && item.optInt("exit_code") != 0))
+ "mcp_tool_call" -> listOf(AgentHost.Entry.Result(id, item.optJSONObject("result")?.toString(2) ?: item.optJSONObject("error")?.optString("message").orEmpty(),
+ item.optString("status") == "failed"))
+ "web_search" -> listOf(AgentHost.Entry.Result(id, "", false))
+ "file_change" -> listOf(
+ AgentHost.Entry.Used(id, "Edit", changedFiles(item), changedFiles(item).replace(", ", "\n")),
+ AgentHost.Entry.Result(id, item.optString("status"), item.optString("status") == "failed"),
+ )
+ "todo_list" -> listOf(todoEntry(id, item))
+ "error" -> listOf(AgentHost.Entry.Said("⚠ " + item.optString("message")))
+ else -> emptyList()
+ }
+ "turn.completed" -> return AgentEvent(
+ entries = listOf(AgentHost.Entry.Finished(null)),
+ usage = o.optJSONObject("usage")?.let { AgentHost.Usage(0, 0.0, it.optLong("input_tokens") + it.optLong("cached_input_tokens"), it.optLong("output_tokens")) },
+ )
+ "turn.failed" -> listOf(AgentHost.Entry.Finished(o.optJSONObject("error")?.optString("message") ?: "turn failed"))
+ // codex reports a dropped stream it is retrying as an error event, and the turn goes on.
+ "error" -> o.optString("message").let { message ->
+ if (message.startsWith("Reconnecting...")) emptyList() else listOf(AgentHost.Entry.Finished(message.ifEmpty { "error" }))
+ }
+ else -> emptyList()
+ }
+ return if (entries.isEmpty()) null else AgentEvent(entries = entries)
+ }
+
+ private fun todoEntry(id: String, item: JSONObject): AgentHost.Entry.Used {
+ val items = item.optJSONArray("items") ?: JSONArray()
+ val text = (0 until items.length()).mapNotNull { i ->
+ val todo = items.optJSONObject(i) ?: return@mapNotNull null
+ "${if (todo.optBoolean("completed")) "[x]" else "[ ]"} ${todo.optString("text")}"
+ }.joinToString("\n")
+ return AgentHost.Entry.Used("$id-todo", "TodoWrite", "", text, output = "")
+ }
+
+ private fun changedFiles(item: JSONObject): String {
+ val changes = item.optJSONArray("changes") ?: return ""
+ return (0 until changes.length()).mapNotNull { changes.optJSONObject(it)?.optString("path") }.joinToString(", ").take(400)
+ }
+}
+
+@VisibleForTesting
+internal fun firstLine(text: String) = text.lineSequence().first().take(160)
+
+private val SHELL_WRAPPER = Regex("^/bin/(?:ba)?sh -l?c '(.*)'$", RegexOption.DOT_MATCHES_ALL)
+
+/** Codex runs each command as `/bin/bash -lc ''`; the command is what the user wants to read. */
+@VisibleForTesting
+internal fun shellCommand(command: String) = SHELL_WRAPPER.find(command)?.groupValues?.get(1)?.replace("'\''", "'") ?: command
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt
new file mode 100644
index 000000000..bbb99b744
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt
@@ -0,0 +1,534 @@
+package com.droiddeck.launcher.agent
+
+import android.content.Context
+import android.os.Environment
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Log
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.session.SessionPrefs
+import java.io.File
+import java.nio.file.Files
+
+/**
+ * The launcher's agent: a coding agent CLI ([AgentHarness]) from the Linux userland, run headless
+ * one process per turn, outside any game or desktop session. Conversations are kept on disk
+ * ([AgentChats]), so a restart (a Lab build, a crash) continues the same one.
+ */
+object AgentHost {
+ sealed interface Entry {
+ data class Asked(val text: String, val files: List = emptyList()) : Entry
+ data class Said(val text: String) : Entry
+ data class Thought(val text: String) : Entry
+ /** A tool call; [output] stays null until its result arrives. */
+ data class Used(val id: String, val tool: String, val detail: String, val input: String = "", val output: String? = null, val failed: Boolean = false) : Entry
+ data class Result(val id: String, val output: String, val failed: Boolean) : Entry
+ data class Finished(val error: String?, val stopped: Boolean = false, val usage: Usage? = null) : Entry
+ }
+
+ data class Usage(val durationMs: Long, val costUsd: Double, val inputTokens: Long, val outputTokens: Long)
+
+ /** A message typed while the agent was working, sent when the turn ends. */
+ data class Queued(val text: String, val files: List)
+
+ enum class State { MISSING, IDLE, WORKING }
+
+ private const val TAG = "AgentHost"
+ private const val WORKSPACE = "/root/DroidDeck"
+ private const val TICK_MS = 1_000L
+ private const val IDLE_TICK_MS = 5_000L
+
+ /** Where the agent runs and what the user sees, which no file in the checkout tells it. */
+ @VisibleForTesting
+ internal const val BRIEF = "You run inside DroidDeck on the user's handheld, in its Linux userland (Ubuntu under proot, aarch64), " +
+ "and the user talks to you from DroidDeck's Agent tab, often on a small screen, so keep replies short. " +
+ "droiddeckctl (in the /root/DroidDeck checkout, tools/droiddeckctl) inspects and drives the app over loopback adb (\$ADB_SERIAL). " +
+ "dd-lab-build builds the checkout as a Lab build and restarts DroidDeck on it (docs/lab-builds.md). DroidDeck runs the build in a " +
+ "process of its own and shows the user its progress and time left; dd-lab-build follows that build's log until it is done. " +
+ "Run it with run_in_background and keep checking its output, so the user can talk to you meanwhile. " +
+ "The restart ends this process, and this conversation continues on the new build. " +
+ "Files the user attaches are listed at the end of their message, under /root/.cache/droiddeck/attachments."
+
+ val entries = mutableStateListOf()
+ val queue = mutableStateListOf()
+ var state by mutableStateOf(State.IDLE)
+ private set
+ var installed by mutableStateOf(false)
+ private set
+ /** The Linux runtime is there, so the agent can be set up. */
+ var available by mutableStateOf(false)
+ private set
+ var harness by mutableStateOf(ClaudeCode)
+ private set
+ /** The chosen model for [harness]; null is the CLI's default. */
+ var model by mutableStateOf(null)
+ private set
+ var plan by mutableStateOf(false)
+ private set
+ var chat by mutableStateOf(null)
+ private set
+ /** A reply finished since the conversation was last on screen. */
+ var unseen by mutableStateOf(false)
+ private set
+ var failed by mutableStateOf(false)
+ private set
+ /** Text and thinking of the reply being written. */
+ var live by mutableStateOf("")
+ private set
+ var liveThinking by mutableStateOf("")
+ private set
+ /** When the current turn started, on [SystemClock.elapsedRealtime]. */
+ var turnStarted by mutableLongStateOf(0L)
+ private set
+ var now by mutableLongStateOf(0L)
+ private set
+ /** Tokens in the conversation's context after its last step. */
+ var context by mutableLongStateOf(0L)
+ private set
+ var lab by mutableStateOf(null)
+ private set
+
+ private class Turn(val process: Process, val pid: Int)
+
+ private val main = Handler(Looper.getMainLooper())
+ private var turn: Turn? = null
+ /** Counts turns, so a process that starts after its turn was stopped is not adopted by the next one. */
+ private var generation = 0
+ private var labProcess: Process? = null
+ /** The turn now running resends a message that got no reply, so it is not resent again. */
+ private var retrying = false
+ private var resend: Runnable? = null
+ /** The running Lab build was asked for by the current turn, so stopping the turn stops it. */
+ private var labFromTurn = false
+ private var conversation: String? = null
+ private var loaded = false
+ private var appContext: Context? = null
+
+ val turnPid: Int get() = turn?.pid ?: 0
+
+ fun seen() {
+ unseen = false
+ }
+
+ fun refresh(context: Context) {
+ val app = context.applicationContext
+ appContext = app
+ File(app.filesDir, "agent").mkdirs()
+ harness = AgentHarness.byId(prefs(app).getString(HARNESS_KEY, null))
+ model = prefs(app).getString(modelKey(harness), null)
+ plan = prefs(app).getBoolean(PLAN_KEY, false)
+ available = LinuxRuntime.hasRootfs(app)
+ installed = harness.files.all { guestFileExists(app, it) }
+ if (!installed) state = State.MISSING else if (state == State.MISSING) state = State.IDLE
+ if (!loaded) {
+ loaded = true
+ val migrated = AgentChats.migrate(app, harness.id, prefs(app).getString(LEGACY_CONVERSATION_KEY, null))
+ if (migrated != null) prefs(app).edit().remove(LEGACY_CONVERSATION_KEY).putString(CHAT_KEY, migrated).apply()
+ prefs(app).getString(CHAT_KEY, null)?.let { load(app, it) }
+ resumeAfterRestart(app)
+ tick()
+ }
+ }
+
+ private fun load(app: Context, id: String) {
+ val file = AgentChats.file(app, id)
+ if (!file.isFile) return
+ val loaded = AgentChats.read(file)
+ if (chat != id) AgentAttachments.pending.clear()
+ chat = id
+ conversation = loaded.conversation
+ entries.clear()
+ entries.addAll(loaded.entries)
+ context = 0
+ }
+
+ /** Opens a kept conversation, switching to the CLI it belongs to. */
+ fun open(context: Context, id: String) {
+ val app = context.applicationContext
+ if (state == State.WORKING || id == chat) return
+ val harnessId = AgentChats.read(AgentChats.file(app, id)).harness
+ if (harnessId != null && harnessId != harness.id) {
+ if (AgentSetup.busy) return
+ prefs(app).edit().putString(HARNESS_KEY, harnessId).apply()
+ AgentSetup.forget()
+ refresh(app)
+ }
+ load(app, id)
+ prefs(app).edit().putString(CHAT_KEY, id).apply()
+ }
+
+ fun delete(context: Context, id: String) {
+ if (id == chat) {
+ if (state == State.WORKING) return
+ forget(context)
+ }
+ AgentChats.delete(context.applicationContext, id)
+ }
+
+ /**
+ * A Lab build restarts the app, which ends the agent's process mid-turn. dd-lab-build leaves
+ * [RESUME] when the app's own agent ran it, so the same conversation picks up on the new build.
+ */
+ private fun resumeAfterRestart(app: Context) {
+ val marker = File(app.filesDir, RESUME)
+ val claimed = File(app.filesDir, "$RESUME.claimed")
+ if (!marker.renameTo(claimed)) return
+ claimed.delete()
+ val asked = prefs(app).getString(LAB_CHAT_KEY, null)
+ prefs(app).edit().remove(LAB_CHAT_KEY).apply()
+ if (!installed) return
+ // The conversation that asked for the build hears about it, even if its turn had already ended.
+ if (asked != null && asked != chat && AgentChats.file(app, asked).isFile) {
+ load(app, asked)
+ prefs(app).edit().putString(CHAT_KEY, asked).apply()
+ }
+ if (asked != null || interrupted(entries)) send(app, app.getString(R.string.agent_resumed))
+ }
+
+ /** True when DroidDeck restarted in the middle of one of the agent's turns. */
+ fun resuming(context: Context) = File(context.applicationContext.filesDir, RESUME).isFile
+
+ @VisibleForTesting
+ internal fun interrupted(entries: List) = entries.isNotEmpty() && entries.last() !is Entry.Finished
+
+ /** Switches the agent CLI; a conversation belongs to one CLI, so a new one starts. */
+ fun choose(context: Context, choice: AgentHarness) {
+ if (choice == harness || state == State.WORKING || AgentSetup.busy) return
+ forget(context)
+ AgentSetup.forget()
+ prefs(context).edit().putString(HARNESS_KEY, choice.id).apply()
+ refresh(context)
+ }
+
+ fun chooseModel(context: Context, id: String?) {
+ model = id
+ prefs(context).edit().putString(modelKey(harness), id).apply()
+ }
+
+ fun choosePlan(context: Context, on: Boolean) {
+ plan = on
+ prefs(context).edit().putBoolean(PLAN_KEY, on).apply()
+ }
+
+ fun models(context: Context) = harness.models(LinuxRuntime.rootDir(context.applicationContext))
+
+ /** Sends [text] now, or after the current turn when the agent is working. */
+ fun send(context: Context, text: String, files: List = emptyList()) {
+ val app = context.applicationContext
+ val message = text.trim()
+ if (message.isEmpty() && files.isEmpty()) return
+ cancelResend()
+ if (state == State.WORKING) {
+ queue += Queued(message, files)
+ return
+ }
+ if (state != State.IDLE) return
+ val id = chat ?: AgentChats.newId(app).also { id ->
+ AgentChats.start(AgentChats.file(app, id), harness.id)
+ chat = id
+ conversation = null
+ prefs(app).edit().putString(CHAT_KEY, id).apply()
+ }
+ val attached = AgentAttachments.adopt(app, id, files)
+ record(app, Entry.Asked(message, attached))
+ state = State.WORKING
+ val gen = ++generation
+ live = ""
+ liveThinking = ""
+ turnStarted = SystemClock.elapsedRealtime()
+ AgentHostService.start(app)
+ val current = harness
+ val prompt = AgentAttachments.prompt(message, attached)
+ val command = current.turn(prompt, conversation, AgentHarness.Options(model, plan && current.plans, BRIEF))
+ val workdir = workspace(app)
+ Thread({
+ val child = try {
+ guest(app, command, workdir).start()
+ } catch (e: Exception) {
+ Log.w(TAG, "could not start ${current.name}", e)
+ main.post { if (generation == gen) finish(app, e.message ?: "could not start") }
+ return@Thread
+ }
+ val started = Turn(child, AgentProcesses.pidOf(child))
+ main.post { if (generation == gen && state == State.WORKING && turn == null) turn = started else AgentProcesses.killTree(started.pid) }
+ child.outputStream.close()
+ var ended = false
+ val raw = runCatching {
+ val log = File(app.filesDir, TURN_LOG).apply { parentFile?.mkdirs() }
+ log.renameTo(File(app.filesDir, "$TURN_LOG.1"))
+ log.bufferedWriter()
+ }.getOrNull()
+ runCatching {
+ child.inputStream.bufferedReader().forEachLine { line ->
+ runCatching { raw?.apply { write(line); newLine(); flush() } }
+ val event = current.parse(line) ?: return@forEachLine
+ if (event.entries.any { it is Entry.Finished }) ended = true
+ main.post { if (turn === started) apply(app, event) }
+ }
+ }
+ runCatching { raw?.close() }
+ val status = runCatching { child.waitFor() }.getOrDefault(-1)
+ main.post {
+ if (turn === started) {
+ turn = null
+ if (state == State.WORKING && !ended) finish(app, app.getString(R.string.agent_exited, current.name, status))
+ }
+ }
+ }, "agent-output").start()
+ }
+
+ /** Ends the turn now: its process and everything it started, a build included. */
+ fun stop(context: Context) {
+ val app = context.applicationContext
+ val running = turn
+ turn = null
+ queue.clear()
+ if (labFromTurn && lab?.running == true) cancelLabBuild(app)
+ if (running != null) {
+ AgentProcesses.killTree(running.pid)
+ running.process.destroy()
+ }
+ if (state == State.WORKING) finish(app, null, stopped = true)
+ }
+
+ fun unqueue(item: Queued) {
+ queue.remove(item)
+ }
+
+ /** Starts a new conversation; the current one stays in the history. */
+ fun forget(context: Context) {
+ stop(context)
+ entries.clear()
+ if (chat != null) AgentAttachments.pending.clear()
+ chat = null
+ conversation = null
+ this.context = 0
+ prefs(context).edit().remove(CHAT_KEY).apply()
+ state = if (installed) State.IDLE else State.MISSING
+ }
+
+ /**
+ * A process for [command] in the Linux userland, run the way the store helpers run theirs:
+ * proot over the installed rootfs, without a session. Loopback adb is connected first so
+ * droiddeckctl reaches the app.
+ */
+ fun guest(app: Context, command: List, workdir: String = "/root", connectAdb: Boolean = true): ProcessBuilder {
+ val adb = AgentAdb.prepare(app)
+ val env = listOf(
+ "/usr/bin/env", "HOME=/root", "PATH=/root/.local/bin:/usr/local/bin:/usr/bin:/bin", "LANG=C.UTF-8",
+ "ADB_SERIAL=${adb.serial}", "ADB_VENDOR_KEYS=${adb.vendorKeys}",
+ "DD_AGENT=1", "DD_AGENT_INBOX=${File(app.filesDir, "agent").path}", "BROWSER=/root/.local/bin/android-open", "DD_BROWSER_DISPLAY=${AgentAdb.browserDisplay}",
+ )
+ val shell = listOf(
+ "/bin/sh", "-c",
+ // proot ends the adb server with the command, so each command that needs adb connects it.
+ (if (connectAdb) "adb start-server >/dev/null 2>&1; adb connect \"\$ADB_SERIAL\" >/dev/null 2>&1; " else "") +
+ "cd \"$1\" 2>/dev/null; shift; exec \"$@\"", "sh", workdir,
+ )
+ val proot = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), env + shell + command)
+ return ProcessBuilder(proot).redirectErrorStream(true).apply {
+ 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) }
+ }
+ }
+ }
+
+ fun guestFileExists(context: Context, path: String): Boolean {
+ val file = File(LinuxRuntime.rootDir(context), path.removePrefix("/"))
+ return file.exists() || Files.isSymbolicLink(file.toPath())
+ }
+
+ private fun workspace(app: Context) = if (guestFileExists(app, WORKSPACE)) WORKSPACE else "/root"
+
+ private fun apply(app: Context, event: AgentEvent) {
+ event.conversation?.takeIf { it != conversation }?.let { id ->
+ conversation = id
+ chat?.let { AgentChats.appendConversation(AgentChats.file(app, it), id) }
+ }
+ event.text?.let { live += it }
+ event.thinking?.let { liveThinking += it }
+ event.context?.let { context = it }
+ for (entry in event.entries) when (entry) {
+ is Entry.Finished -> finish(app, entry.error, usage = event.usage)
+ else -> {
+ if (entry is Entry.Said) live = ""
+ if (entry is Entry.Thought) liveThinking = ""
+ record(app, entry)
+ }
+ }
+ AgentHostService.update(app)
+ }
+
+ private fun finish(app: Context, error: String?, stopped: Boolean = false, usage: Usage? = null) {
+ if (state != State.WORKING) return
+ turn = null
+ if (!stopped) labFromTurn = false
+ live = ""
+ liveThinking = ""
+ record(app, Entry.Finished(error, stopped, usage))
+ state = State.IDLE
+ unseen = true
+ failed = error != null
+ AgentHostService.stop(app)
+ // Claude Code now and then ends a resumed turn at once without reading the message; nothing
+ // happened, so the message is sent once more.
+ val asked = entries.lastOrNull { it is Entry.Asked } as? Entry.Asked
+ if (!stopped && error == null && asked != null && answerless(entries) && !retrying) {
+ retrying = true
+ val retryChat = chat
+ resend = Runnable {
+ resend = null
+ if (state == State.IDLE && chat == retryChat) send(app, asked.text, asked.files) else retrying = false
+ }.also { main.postDelayed(it, RETRY_MS) }
+ return
+ }
+ retrying = false
+ if (!stopped && queue.isNotEmpty()) {
+ val next = queue.removeAt(0)
+ send(app, next.text, next.files)
+ }
+ }
+
+ /** A message typed or retried by the user replaces the automatic resend. */
+ private fun cancelResend() {
+ val pending = resend ?: return
+ main.removeCallbacks(pending)
+ resend = null
+ retrying = false
+ }
+
+ private fun record(app: Context, entry: Entry) {
+ if (!AgentChats.merge(entries, entry)) return
+ chat?.let { AgentChats.append(AgentChats.file(app, it), entry) }
+ }
+
+ /** The last turn ended without saying or doing anything. */
+ fun answerless(entries: List): Boolean {
+ val start = entries.indexOfLast { it is Entry.Asked }
+ if (start < 0) return false
+ val turn = entries.subList(start + 1, entries.size)
+ return turn.lastOrNull() is Entry.Finished && turn.none { it is Entry.Said || it is Entry.Used || it is Entry.Thought } &&
+ (turn.last() as Entry.Finished).let { it.error == null && !it.stopped }
+ }
+
+ /** Sends the last message again, after a turn that ended without a reply. */
+ fun retry(context: Context) {
+ val asked = entries.lastOrNull { it is Entry.Asked } as? Entry.Asked ?: return
+ send(context, asked.text, asked.files)
+ }
+
+ /** The tool call still running in this turn, if any. */
+ fun running(): Entry.Used? {
+ if (state != State.WORKING) return null
+ val start = entries.indexOfLast { it is Entry.Asked }
+ return entries.withIndex().lastOrNull { (i, e) -> i > start && e is Entry.Used && e.output == null }?.value as? Entry.Used
+ }
+
+ /** The latest todo list the agent wrote in this conversation. */
+ fun todos(): String? = entries.lastOrNull { it is Entry.Used && it.tool == "TodoWrite" }?.let { (it as Entry.Used).input }
+
+ private fun tick() {
+ now = SystemClock.elapsedRealtime()
+ appContext?.let { app ->
+ claimLabRequest(app)
+ claimOpenUrl(app)
+ // proot comes from an add-on pack fetched after the app starts, so the runtime can become usable later.
+ if (!available && state != State.WORKING) available = LinuxRuntime.hasRootfs(app)
+ lab = LabProgress.read(LinuxRuntime.rootDir(app))
+ AgentHostService.update(app)
+ }
+ val busy = state == State.WORKING || lab != null || AgentSetup.status.values.any { it == AgentSetup.Status.WORKING }
+ main.postDelayed(::tick, if (busy) TICK_MS else IDLE_TICK_MS)
+ }
+
+ /**
+ * android-open, the userland's $BROWSER, leaves a URL here for the app to open, so sign-ins
+ * work without Wireless debugging.
+ */
+ private fun claimOpenUrl(app: Context) {
+ val request = File(app.filesDir, OPEN_URL)
+ if (!request.isFile) return
+ val claimed = File(app.filesDir, "$OPEN_URL.claimed")
+ if (!request.renameTo(claimed)) return
+ val url = runCatching { claimed.readText().trim() }.getOrNull()
+ claimed.delete()
+ if (url == null || !(url.startsWith("https://") || url.startsWith("http://"))) return
+ val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
+ .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(AgentAdb.browserDisplay).toBundle()
+ runCatching { app.startActivity(intent, options) }
+ .recoverCatching { app.startActivity(intent) }
+ .onFailure {
+ Log.w(TAG, "could not open a sign-in link: ${it.javaClass.simpleName}")
+ runCatching { File(app.filesDir, "$OPEN_URL.failed").writeText("") }
+ }
+ }
+
+ /**
+ * dd-lab-build run by the agent hands its build to the app: the turn's processes end with the
+ * turn, and a build must outlive a turn that ends early or is stopped.
+ */
+ private fun claimLabRequest(app: Context) {
+ val request = File(app.filesDir, LAB_REQUEST)
+ if (!request.isFile) return
+ val claimed = File(app.filesDir, "$LAB_REQUEST.claimed")
+ if (!request.renameTo(claimed)) return
+ val o = runCatching { org.json.JSONObject(claimed.readText()) }.getOrNull()
+ claimed.delete()
+ if (o == null || labProcess?.isAlive == true) return
+ val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()
+ val cwd = o.optString("cwd").ifEmpty { WORKSPACE }
+ labFromTurn = state == State.WORKING
+ chat?.let { prefs(app).edit().putString(LAB_CHAT_KEY, it).apply() }
+ AgentHostService.setupStarted(app)
+ Thread({
+ runCatching {
+ val child = guest(app, listOf("sh", "-c",
+ "mkdir -p /root/.cache; DD_LAB_DETACHED=1 exec dd-lab-build \"\$@\" > /root/.cache/dd-lab-build.log 2>&1", "dd-lab-build") + args, cwd).start()
+ main.post { labProcess = child }
+ child.outputStream.close()
+ child.inputStream.readBytes()
+ child.waitFor()
+ }.onFailure { Log.w(TAG, "could not run a Lab build", it) }
+ main.post {
+ labProcess = null
+ labFromTurn = false
+ AgentHostService.setupEnded(app)
+ }
+ }, "agent-lab-build").start()
+ }
+
+ fun cancelLabBuild(context: Context) {
+ val progress = lab ?: return
+ LabProgress.cancel(LinuxRuntime.rootDir(context.applicationContext), progress)
+ lab = LabProgress.read(LinuxRuntime.rootDir(context.applicationContext))
+ }
+
+ private const val LEGACY_CONVERSATION_KEY = "session_id"
+ private const val HARNESS_KEY = "harness"
+ private const val CHAT_KEY = "chat"
+ private const val PLAN_KEY = "plan"
+ private const val RESUME = "agent/resume"
+ private const val LAB_REQUEST = "agent/lab-request"
+ private const val OPEN_URL = "agent/open-url"
+ private const val LAB_CHAT_KEY = "lab_chat"
+ /** The raw output of the latest turn, for working out what a CLI did when the chat shows nothing. */
+ private const val TURN_LOG = "agent/turn.log"
+ private const val RETRY_MS = 3_000L
+
+ private fun modelKey(harness: AgentHarness) = "model_${harness.id}"
+
+ internal fun prefs(context: Context) = context.getSharedPreferences("agent", Context.MODE_PRIVATE)
+}
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..e8f3036d1
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt
@@ -0,0 +1,118 @@
+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 android.os.SystemClock
+import com.droiddeck.launcher.FrontEndActivity
+import com.droiddeck.launcher.R
+
+/** Keeps the app process alive while the agent works, and says what it is doing, with a way to stop it. */
+class AgentHostService : Service() {
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_STOP) {
+ if (AgentHost.lab?.running == true) AgentHost.cancelLabBuild(this)
+ AgentHost.stop(this)
+ return START_NOT_STICKY
+ }
+ awaiting = (awaiting - 1).coerceAtLeast(0)
+ startForeground(NOTIFICATION_ID, build(this))
+ if (!started && awaiting == 0) stopSelf()
+ return START_NOT_STICKY
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "agent"
+ private const val NOTIFICATION_ID = 4107
+ private const val ACTION_STOP = "com.droiddeck.launcher.agent.STOP"
+ private var lastText: String? = null
+ @Volatile private var started = false
+ /**
+ * startForegroundService calls not yet answered by onStartCommand. Stopping the service
+ * before it calls startForeground crashes the app, so it then stops itself instead.
+ */
+ private var awaiting = 0
+ /** Setup steps that keep the app alive while they run, besides the agent's turn. */
+ private var setupSteps = 0
+
+ fun start(context: Context) {
+ lastText = null
+ started = true
+ runCatching { context.startForegroundService(Intent(context, AgentHostService::class.java)) }
+ .onSuccess { awaiting++ }
+ }
+
+ fun stop(context: Context) {
+ if (setupSteps > 0) return
+ started = false
+ if (awaiting == 0) context.stopService(Intent(context, AgentHostService::class.java))
+ }
+
+ fun setupStarted(context: Context) {
+ setupSteps++
+ start(context)
+ }
+
+ fun setupEnded(context: Context) {
+ setupSteps = (setupSteps - 1).coerceAtLeast(0)
+ if (AgentHost.state != AgentHost.State.WORKING) stop(context)
+ }
+
+ /** Refreshes the notification with the current step; unchanged text is not re-posted. */
+ fun update(context: Context) {
+ if (!started) return
+ val text = describe(context)
+ if (text == lastText) return
+ lastText = text
+ context.getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, build(context))
+ }
+
+ private fun describe(context: Context): String {
+ val lab = AgentHost.lab?.takeIf { it.running }
+ if (lab != null) return context.getString(R.string.agent_lab_progress, lab.permille / 10, minutes(lab.etaSeconds.toLong().coerceAtLeast(0)))
+ if (AgentHost.state != AgentHost.State.WORKING) return context.getString(R.string.agent_setting_up)
+ val tool = AgentHost.running()
+ val elapsed = minutes((SystemClock.elapsedRealtime() - AgentHost.turnStarted) / 1000)
+ return if (tool != null) "${tool.tool} · ${tool.detail}".take(120) + " · $elapsed" else context.getString(R.string.agent_thinking_for, elapsed)
+ }
+
+ private fun minutes(seconds: Long) = if (seconds < 60) "${seconds.coerceAtLeast(0)}s" else "${seconds / 60}m ${seconds % 60}s"
+
+ private fun build(context: Context): Notification {
+ val manager = context.getSystemService(NotificationManager::class.java)
+ manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, context.getString(R.string.agent_channel_name),
+ NotificationManager.IMPORTANCE_LOW).apply { setShowBadge(false) })
+ val open = PendingIntent.getActivity(context, 0,
+ Intent(context, 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 stop = PendingIntent.getService(context, 1, Intent(context, AgentHostService::class.java).setAction(ACTION_STOP),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ val lab = AgentHost.lab?.takeIf { it.running }
+ return Notification.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_stat_session)
+ .setContentTitle(context.getString(when {
+ lab?.prepare == true -> R.string.agent_lab_preparing
+ lab != null -> R.string.agent_lab_building
+ else -> R.string.agent_working
+ }))
+ .setContentText(describe(context))
+ .setContentIntent(open)
+ .addAction(Notification.Action.Builder(null, context.getString(R.string.agent_stop), stop).build())
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setShowWhen(false)
+ .apply { if (lab != null) setProgress(1000, lab.permille, lab.stage != "building") }
+ .setCategory(Notification.CATEGORY_PROGRESS)
+ .build()
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentProcesses.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentProcesses.kt
new file mode 100644
index 000000000..c845db681
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentProcesses.kt
@@ -0,0 +1,105 @@
+package com.droiddeck.launcher.agent
+
+import android.os.Process
+import android.os.SystemClock
+import androidx.annotation.VisibleForTesting
+import java.io.File
+
+/**
+ * Linux userland processes, read from /proc: proot runs them as real processes under the app's
+ * own uid, so the app can list and end them without a shell.
+ */
+object AgentProcesses {
+ data class Proc(
+ val pid: Int, val ppid: Int, val name: String, val args: String,
+ val seconds: Long, val rssKb: Long, val cpuPercent: Int, val agent: Boolean,
+ )
+
+ private const val HZ = 100L
+ private var lastTicks = emptyMap()
+ private var lastAt = 0L
+
+ @VisibleForTesting
+ internal class Stat(val pid: Int, val ppid: Int, val ticks: Long, val startTicks: Long, val rssPages: Long)
+
+ /** Guest processes, those under [turnPid] (the agent's current turn) first. */
+ @Synchronized
+ fun snapshot(turnPid: Int): List {
+ val me = Process.myPid()
+ val uid = Process.myUid()
+ val stats = HashMap()
+ val args = HashMap()
+ for (dir in File("/proc").listFiles().orEmpty()) {
+ val pid = dir.name.toIntOrNull() ?: continue
+ if (pid == me) continue
+ val stat = runCatching { parseStat(File(dir, "stat").readText()) }.getOrNull() ?: continue
+ if (!owned(dir, uid)) continue
+ stats[pid] = stat
+ args[pid] = runCatching { File(dir, "cmdline").readBytes().toString(Charsets.UTF_8).trimEnd('\u0000').replace('\u0000', ' ') }.getOrDefault("")
+ }
+ val now = SystemClock.elapsedRealtime()
+ val uptimeTicks = now * HZ / 1000
+ val elapsed = (now - lastAt).coerceAtLeast(1)
+ fun isProot(pid: Int) = args[pid].orEmpty().substringBefore(' ').endsWith("proot.so") || args[pid].orEmpty().substringBefore(' ').endsWith("/proot")
+ fun ancestors(pid: Int) = generateSequence(stats[pid]?.ppid) { stats[it]?.ppid }.takeWhile { it > 1 }.take(64)
+ val out = stats.values.filter { s ->
+ !isProot(s.pid) && args[s.pid].orEmpty().isNotEmpty() && ancestors(s.pid).any { isProot(it) }
+ }.map { s ->
+ val full = args[s.pid].orEmpty()
+ val cpu = lastTicks[s.pid]?.let { ((s.ticks - it) * 1000 * 100 / HZ / elapsed).toInt().coerceIn(0, 800) } ?: 0
+ Proc(
+ s.pid, s.ppid, full.substringBefore(' ').substringAfterLast('/'), full,
+ ((uptimeTicks - s.startTicks) / HZ).coerceAtLeast(0), s.rssPages * 4, cpu,
+ turnPid > 0 && ancestors(s.pid).any { it == turnPid },
+ )
+ }
+ lastTicks = stats.mapValues { it.value.ticks }
+ lastAt = now
+ return out.sortedWith(compareByDescending { it.agent }.thenBy { it.seconds })
+ }
+
+ /** Ends [pid] and everything it started, children first so none is left to be re-parented. */
+ fun killTree(pid: Int) {
+ if (pid <= 0) return
+ val children = HashMap>()
+ for (dir in File("/proc").listFiles().orEmpty()) {
+ val child = dir.name.toIntOrNull() ?: continue
+ val stat = runCatching { parseStat(File(dir, "stat").readText()) }.getOrNull() ?: continue
+ children.getOrPut(stat.ppid) { mutableListOf() } += child
+ }
+ val order = mutableListOf()
+ fun walk(p: Int) {
+ children[p].orEmpty().forEach(::walk)
+ order += p
+ }
+ walk(pid)
+ order.forEach { runCatching { Process.sendSignal(it, Process.SIGNAL_KILL) } }
+ }
+
+ fun alive(pid: Int) = pid > 0 && File("/proc/$pid").exists()
+
+ /** Processes whose command line contains [text], for ending a build the app did not start. */
+ fun find(text: String): List = File("/proc").listFiles().orEmpty().mapNotNull { dir ->
+ val pid = dir.name.toIntOrNull() ?: return@mapNotNull null
+ if (pid == Process.myPid()) return@mapNotNull null
+ val args = runCatching { File(dir, "cmdline").readBytes().toString(Charsets.UTF_8).replace('\u0000', ' ') }.getOrDefault("")
+ pid.takeIf { args.contains(text) && owned(dir, Process.myUid()) }
+ }
+
+ private fun owned(dir: File, uid: Int) = runCatching {
+ File(dir, "status").useLines { lines -> lines.first { it.startsWith("Uid:") }.split(Regex("\\s+"))[1].toInt() == uid }
+ }.getOrDefault(false)
+
+ /** `/proc//stat`: the name in parentheses may hold spaces, so fields count from its end. */
+ @VisibleForTesting
+ internal fun parseStat(text: String): Stat {
+ val pid = text.substringBefore(' ').toInt()
+ val f = text.substring(text.lastIndexOf(')') + 2).split(' ')
+ return Stat(pid, f[1].toInt(), f[11].toLong() + f[12].toLong(), f[19].toLong(), f[21].toLong())
+ }
+
+ /** The pid of a [java.lang.Process] the app started. */
+ fun pidOf(process: java.lang.Process): Int =
+ runCatching { process.javaClass.getDeclaredField("pid").apply { isAccessible = true }.getInt(process) }.getOrNull()
+ ?: Regex("pid=(\\d+)").find(process.toString())?.groupValues?.get(1)?.toInt() ?: 0
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentSetup.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentSetup.kt
new file mode 100644
index 000000000..47efab507
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentSetup.kt
@@ -0,0 +1,175 @@
+package com.droiddeck.launcher.agent
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+/**
+ * First-run setup for the agent, as steps with a check and an action each. The first three are
+ * needed to talk to the agent; GitHub and the build tools are only needed to open pull requests
+ * and to make Lab builds on the device.
+ */
+object AgentSetup {
+ enum class Step(val required: Boolean) { CONNECTION(true), INSTALL(true), SIGN_IN(true), GITHUB(false), BUILD_TOOLS(false), PREPARE(false) }
+ enum class Status { UNKNOWN, DONE, TODO, WORKING, FAILED }
+
+ val status = mutableStateMapOf()
+ /** The last line of output from the step that is running or just failed. */
+ val detail = mutableStateMapOf()
+ var runtimeInstalled by mutableStateOf(true)
+ private set
+
+ val ready: Boolean get() = Step.entries.filter { it.required }.all { status[it] == Status.DONE }
+
+ /** A step's action is running; its result belongs to the harness it started for. */
+ val busy: Boolean get() = status.values.any { it == Status.WORKING }
+
+ /** Set once the required steps have all passed, so the chat opens without waiting for the checks. */
+ fun completed(context: Context) = AgentHost.prefs(context).getBoolean(completedKey(AgentHost.harness), false)
+
+ private fun completedKey(harness: AgentHarness) = "setup_completed_${harness.id}"
+
+ private val main = Handler(Looper.getMainLooper())
+ private const val REPO = "https://github.com/Droid-Deck/DroidDeck.git"
+ private const val CHECKOUT = "/root/DroidDeck"
+ private val HELPERS = listOf("android-open", "dd-gh-login")
+ private const val PROBE_TIMEOUT_S = 60L
+
+ fun check(context: Context) {
+ val app = context.applicationContext
+ runtimeInstalled = LinuxRuntime.isInstalled(app)
+ if (!runtimeInstalled) return
+ AgentHost.refresh(app)
+ val harness = AgentHost.harness
+ val probes = mutableListOf>>()
+ for (step in Step.entries) if (status[step] != Status.WORKING) {
+ when (step) {
+ Step.INSTALL -> set(step, if (harness.files.all { AgentHost.guestFileExists(app, it) }) Status.DONE else Status.TODO)
+ Step.BUILD_TOOLS -> set(step, if (buildToolsInstalled(app)) Status.DONE else Status.TODO)
+ Step.PREPARE -> set(step, if (prepared(app)) Status.DONE else Status.TODO)
+ // The install brings the userland's adb; until then the app's own pairing is all there is to check.
+ Step.CONNECTION -> if (!AgentHost.guestFileExists(app, "/usr/bin/adb")) {
+ detail.remove(step)
+ Thread({
+ val live = com.droiddeck.launcher.core.WirelessAdbFix.liveConnectAddress(app) != null
+ set(step, if (live) Status.DONE else Status.TODO)
+ }, "agent-connection").start()
+ } else probes += step to listOf("sh", "-c",
+ "for i in 1 2 3; do adb connect \"\$ADB_SERIAL\" >/dev/null 2>&1; " +
+ "state=\$(adb -s \"\$ADB_SERIAL\" get-state 2>&1); echo \"\$state\"; [ \"\$state\" = device ] && exit 0; sleep 1; done; exit 1")
+ Step.SIGN_IN -> if (AgentHost.guestFileExists(app, harness.binary)) probes += step to harness.signedIn else set(step, Status.TODO)
+ Step.GITHUB -> probes += step to listOf("gh", "auth", "status")
+ }
+ }
+ Thread({ for ((step, command) in probes) probe(app, harness, step, command) }, "agent-checks").start()
+ }
+
+ /** Runs [step]'s action. [CONNECTION] is the app's own pairing flow, so the caller opens it. */
+ fun run(context: Context, step: Step) {
+ val app = context.applicationContext
+ val harness = AgentHost.harness
+ val command = when (step) {
+ Step.CONNECTION -> return
+ Step.INSTALL -> {
+ listOf("sh", "-c", "set -e; export DEBIAN_FRONTEND=noninteractive; " +
+ "apt-get update; apt-get install -y --no-install-recommends git gh adb curl ca-certificates python3; ${harness.install}")
+ }
+ Step.SIGN_IN -> harness.signIn
+ Step.GITHUB -> listOf("dd-gh-login")
+ Step.BUILD_TOOLS -> listOf("sh", "-c", "set -e; [ -d $CHECKOUT/.git ] || git clone --filter=blob:none $REPO $CHECKOUT; " +
+ "cd $CHECKOUT; [ -x tools/agent/setup ] || { echo 'This DroidDeck version has no tools/agent/setup yet'; exit 1; }; tools/agent/setup")
+ Step.PREPARE -> listOf("sh", "-c", "cd $CHECKOUT && DD_LAB_DETACHED=1 dd-lab-build --prepare")
+ }
+ set(step, Status.WORKING)
+ detail.remove(step)
+ AgentHostService.setupStarted(app)
+ Thread({
+ val result = runCatching {
+ installHelpers(app)
+ val child = AgentHost.guest(app, command).start()
+ child.outputStream.close()
+ child.inputStream.bufferedReader().forEachLine { line ->
+ val text = line.trim()
+ if (text.isNotEmpty() && !noise(text)) main.post { detail[step] = text.take(160) }
+ }
+ child.waitFor()
+ }
+ main.post {
+ AgentHostService.setupEnded(app)
+ if (result.getOrNull() == 0) {
+ detail.remove(step)
+ status[step] = Status.UNKNOWN
+ check(app)
+ } else {
+ status[step] = Status.FAILED
+ if (result.isFailure) detail[step] = result.exceptionOrNull()?.message ?: "failed"
+ }
+ }
+ }, "agent-setup-${step.name.lowercase()}").start()
+ }
+
+ fun buildToolsInstalled(app: Context) = AgentHost.guestFileExists(app, "/root/aapt2/aapt2") && AgentHost.guestFileExists(app, "$CHECKOUT/.git")
+
+ /** dd-lab-build compiled this install's commit, so a Lab build compiles only what changed. */
+ private fun prepared(app: Context) = runCatching {
+ File(LinuxRuntime.rootDir(app), "root/.cache/dd-lab-build-base").readText().trim()
+ .startsWith(com.droiddeck.launcher.lab.LabOverlay.apkCommit.take(7))
+ }.getOrDefault(false)
+
+ /** Drops what the checks found, which belonged to the previous harness. */
+ fun forget() {
+ status.clear()
+ detail.clear()
+ }
+
+ private fun probe(app: Context, harness: AgentHarness, step: Step, command: List) {
+ var output = ""
+ val ok = runCatching {
+ val child = AgentHost.guest(app, command, connectAdb = step == Step.CONNECTION).start()
+ child.outputStream.close()
+ val reader = Thread({ output = child.inputStream.bufferedReader().readText() }, "agent-check-output").apply { isDaemon = true; start() }
+ val exited = child.waitFor(PROBE_TIMEOUT_S, TimeUnit.SECONDS)
+ if (!exited) {
+ AgentProcesses.killTree(AgentProcesses.pidOf(child))
+ child.destroyForcibly()
+ }
+ reader.join(2_000)
+ exited && child.exitValue() == 0
+ }.onFailure { output = it.message.orEmpty() }.getOrDefault(false)
+ main.post {
+ if (AgentHost.harness != harness) return@post
+ if (status[step] != Status.WORKING) status[step] = if (ok) Status.DONE else Status.TODO
+ val last = output.lines().lastOrNull { it.isNotBlank() && !noise(it.trim()) }?.trim()?.take(160)
+ when {
+ ok -> detail.remove(step)
+ step == Step.CONNECTION -> detail[step] = app.getString(com.droiddeck.launcher.R.string.agent_connection_off)
+ step.required && last != null -> detail[step] = last
+ }
+ if (ready) AgentHost.prefs(app).edit().putBoolean(completedKey(harness), true).apply()
+ }
+ }
+
+ /** proot warns about its bindings on every start; that says nothing about the step. */
+ private fun noise(line: String) = line.startsWith("proot warning")
+
+ private fun set(step: Step, value: Status) {
+ if (Looper.myLooper() == Looper.getMainLooper()) status[step] = value else main.post { status[step] = value }
+ }
+
+ /** Written before every step, so the helpers a step relies on are there and match this DroidDeck version. */
+ private fun installHelpers(app: Context) {
+ val bin = File(LinuxRuntime.rootDir(app), "root/.local/bin").apply { mkdirs() }
+ for (name in HELPERS) {
+ val target = File(bin, name)
+ app.assets.open("agent/$name").use { input -> target.outputStream().use { input.copyTo(it) } }
+ target.setExecutable(true, false)
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentStorage.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentStorage.kt
new file mode 100644
index 000000000..9eacc4a6b
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentStorage.kt
@@ -0,0 +1,95 @@
+package com.droiddeck.launcher.agent
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.mutableStateMapOf
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import java.io.File
+import java.io.IOException
+import java.nio.file.FileVisitResult
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.SimpleFileVisitor
+import java.nio.file.attribute.BasicFileAttributes
+
+/**
+ * What the agent adds to the Linux userland, measured and removable piece by piece. Sign-ins
+ * (~/.claude, ~/.codex, gh's config) are left alone, so removing a CLI and installing it again
+ * does not sign out.
+ */
+object AgentStorage {
+ enum class Item(val paths: List) {
+ CLAUDE_CODE(listOf("/root/.local/share/claude", "/root/.local/bin/claude", "/root/cc")),
+ CODEX(listOf("/root/.local/bin/codex", "/root/.local/bin/codex-code-mode-host")),
+ BUILD_TOOLS(listOf("/root/android-sdk", "/root/aapt2", "/root/x86root", "/root/.gradle")),
+ SOURCE(listOf("/root/DroidDeck")),
+ CACHES(listOf("/root/.cache", "/root/.npm")),
+ }
+
+ /** Bytes per item; missing while it is being measured. */
+ val sizes = mutableStateMapOf- ()
+ val removing = mutableStateMapOf
- ()
+
+ private val main = Handler(Looper.getMainLooper())
+
+ fun measure(context: Context) {
+ val root = LinuxRuntime.rootDir(context.applicationContext)
+ Thread({
+ for (item in Item.entries) {
+ val bytes = item.paths.sumOf { size(File(root, it.removePrefix("/")).toPath()) }
+ main.post { sizes[item] = bytes }
+ }
+ }, "agent-storage").start()
+ }
+
+ fun remove(context: Context, item: Item) {
+ val app = context.applicationContext
+ val root = LinuxRuntime.rootDir(app)
+ removing[item] = true
+ Thread({
+ for (path in item.paths) runCatching { delete(File(root, path.removePrefix("/")).toPath()) }
+ main.post {
+ removing.remove(item)
+ AgentHost.refresh(app)
+ AgentSetup.check(app)
+ measure(app)
+ }
+ }, "agent-storage-remove").start()
+ }
+
+ /** Walks without following links, so a link out of the rootfs (into /sdcard, say) counts and removes only itself. */
+ @VisibleForTesting
+ internal fun size(path: Path): Long {
+ if (!Files.exists(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return 0
+ var total = 0L
+ Files.walkFileTree(path, object : SimpleFileVisitor() {
+ override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
+ total += attrs.size()
+ return FileVisitResult.CONTINUE
+ }
+ override fun visitFileFailed(file: Path, exc: IOException) = FileVisitResult.CONTINUE
+ })
+ return total
+ }
+
+ @VisibleForTesting
+ internal fun delete(path: Path) {
+ if (!Files.exists(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
+ Files.walkFileTree(path, object : SimpleFileVisitor() {
+ override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
+ Files.deleteIfExists(file)
+ return FileVisitResult.CONTINUE
+ }
+ override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
+ Files.deleteIfExists(dir)
+ return FileVisitResult.CONTINUE
+ }
+ override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
+ runCatching { Files.deleteIfExists(file) }
+ return FileVisitResult.CONTINUE
+ }
+ })
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/LabProgress.kt b/app/src/main/java/com/droiddeck/launcher/agent/LabProgress.kt
new file mode 100644
index 000000000..ff16eeebd
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/LabProgress.kt
@@ -0,0 +1,53 @@
+package com.droiddeck.launcher.agent
+
+import androidx.annotation.VisibleForTesting
+import org.json.JSONObject
+import java.io.File
+
+/** A Lab build in progress, as dd-lab-build reports it in `~/.cache/dd-lab-build.json`. */
+data class LabProgress(
+ val stage: String, val prepare: Boolean, val permille: Int, val task: String,
+ val done: Int, val total: Int, val etaSeconds: Int, val elapsedSeconds: Int, val pid: Int,
+) {
+ val running get() = stage in RUNNING
+ val failed get() = stage == "failed"
+
+ companion object {
+ private val RUNNING = setOf("checking", "fetching", "sdk", "planning", "building", "packaging", "restarting")
+ private const val PATH = "root/.cache/dd-lab-build.json"
+ /** A finished build stays on screen this long. */
+ private const val SHOW_DONE_MS = 60_000L
+
+ /** The build, while it runs and shortly after; null when there is none to show. */
+ fun read(rootfs: File): LabProgress? {
+ val file = File(rootfs, PATH)
+ if (!file.isFile) return null
+ val parsed = parse(runCatching { file.readText() }.getOrNull() ?: return null) ?: return null
+ val progress = when {
+ !parsed.running || AgentProcesses.alive(parsed.pid) -> parsed
+ // The restart ends the app and the script with it, so a restart that got that far succeeded.
+ parsed.stage == "restarting" -> parsed.copy(stage = "ready")
+ else -> parsed.copy(stage = "failed", task = "stopped")
+ }
+ return progress.takeIf { it.running || System.currentTimeMillis() - file.lastModified() < SHOW_DONE_MS }
+ }
+
+ /** Ends the build and Gradle's daemon, which keeps compiling after its client is gone. */
+ fun cancel(rootfs: File, progress: LabProgress) {
+ AgentProcesses.killTree(progress.pid)
+ AgentProcesses.find("GradleDaemon").forEach(AgentProcesses::killTree)
+ runCatching {
+ File(rootfs, PATH).writeText(JSONObject().put("stage", "failed").put("task", "stopped").put("pid", 0).toString())
+ }
+ }
+
+ @VisibleForTesting
+ internal fun parse(json: String): LabProgress? = runCatching {
+ val o = JSONObject(json)
+ LabProgress(
+ o.getString("stage"), o.optString("mode") == "prepare", o.optInt("permille"), o.optString("task"),
+ o.optInt("done"), o.optInt("total"), o.optInt("eta", -1), o.optInt("elapsed"), o.optInt("pid"),
+ )
+ }.getOrNull()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt
index fcb5d0889..784b39d74 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt
@@ -41,6 +41,7 @@ object WirelessAdbFix {
writeAtomically(context, key, identity.second)
}
+ @Synchronized
private fun writeAtomically(context: Context, destination: File, contents: ByteArray) {
val temporary = File(destination.parentFile, "${destination.name}.tmp")
temporary.writeBytes(contents)
@@ -70,6 +71,52 @@ object WirelessAdbFix {
"$host:${saved.port ?: ""}"
}
+ /**
+ * The saved pairing's connect address, looked up again when it no longer answers: Wireless
+ * debugging moves to another port whenever it restarts.
+ */
+ fun liveConnectAddress(context: Context): String? {
+ val app = context.applicationContext
+ val saved = savedConnection(app) ?: return null
+ val host = if (':' in saved.host) "[${saved.host}]" else saved.host
+ saved.port?.takeIf { answers(saved.host, it) }?.let { return "$host:$it" }
+ val loopback = runCatching { InetAddress.getByName(saved.host).isLoopbackAddress }.getOrDefault(false)
+ val port = if (loopback) loopbackAdbPort(app) else findConnectPort(app, saved.host, accept = { answers(saved.host, it) })
+ port ?: return null
+ saveConnection(app, saved.host, port)
+ return "$host:$port"
+ }
+
+ /**
+ * Wireless debugging's port on this device. Apps can read neither service.adb.tls.port nor the
+ * socket table, and mDNS often advertises nothing, so the ephemeral range Linux gives adbd is
+ * scanned on loopback (closed ports refuse at once) and the open ports are tried with our key.
+ */
+ private fun loopbackAdbPort(app: Context): Int? {
+ val open = java.util.concurrent.ConcurrentLinkedQueue()
+ val workers = 32
+ val range = EPHEMERAL_PORTS
+ (0 until workers).map { worker ->
+ Thread {
+ var port = range.first + worker
+ while (port <= range.last) {
+ if (answers(LOOPBACK, port, 150)) open += port
+ port += workers
+ }
+ }.apply { start() }
+ }.forEach { it.join() }
+ loadOrCreateIdentity(app)
+ return open.sorted().firstOrNull { port ->
+ runCatching { Kadb.create(LOOPBACK, port, 1_000, 3_000).use { it.shell("true").exitCode == 0 } }.getOrDefault(false)
+ }
+ }
+
+ private val EPHEMERAL_PORTS = 32768..60999
+
+ private fun answers(host: String, port: Int, timeoutMs: Int = 500) = runCatching {
+ java.net.Socket().use { it.connect(java.net.InetSocketAddress(host, port), timeoutMs) }
+ }.isSuccess
+
private fun saveConnection(context: Context, host: String, port: Int) {
writeAtomically(
context,
@@ -94,7 +141,8 @@ object WirelessAdbFix {
.invoke(null, "service.adb.tls.port") as? String
}.getOrNull()?.trim()?.toIntOrNull()?.takeIf { it in 1..65535 }
- fun findConnectPort(context: Context, pairedHost: String, timeoutSeconds: Long = 12): Int? {
+ /** [accept] can pass over stale services that still advertise a port Wireless debugging left. */
+ fun findConnectPort(context: Context, pairedHost: String, timeoutSeconds: Long = 12, accept: (Int) -> Boolean = { true }): Int? {
val manager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
val paired = runCatching { InetAddress.getByName(pairedHost) }.getOrNull() ?: return null
val matches: (InetAddress) -> Boolean =
@@ -122,7 +170,7 @@ object WirelessAdbFix {
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host
- if (address != null && matches(address) && finished.compareAndSet(false, true)) {
+ if (address != null && matches(address) && accept(serviceInfo.port) && finished.compareAndSet(false, true)) {
resolvedPort.set(serviceInfo.port)
latch.countDown()
} else {
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AgentEntries.kt b/app/src/main/java/com/droiddeck/launcher/ui/AgentEntries.kt
new file mode 100644
index 000000000..63dde3887
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentEntries.kt
@@ -0,0 +1,307 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Description
+import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material.icons.outlined.ExpandLess
+import androidx.compose.material.icons.outlined.ExpandMore
+import androidx.compose.material.icons.outlined.Psychology
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.agent.AgentAttachments
+import com.droiddeck.launcher.agent.AgentHost
+import com.droiddeck.launcher.agent.AgentHost.Entry
+
+@Composable
+internal fun AgentEntry(entry: Entry, running: Boolean, last: Boolean = false) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ when (entry) {
+ is Entry.Asked -> Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (entry.files.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { entry.files.forEach { AttachmentChip(it, null) } }
+ if (entry.text.isNotEmpty()) SelectionContainer {
+ Text(entry.text, fontSize = 15.sp, color = pal.onPrimary,
+ modifier = Modifier.widthIn(max = 560.dp).clip(Shape16).background(pal.primary).padding(horizontal = 14.dp, vertical = 10.dp))
+ }
+ }
+ is Entry.Said -> SelectionContainer { AgentMarkdown(entry.text, Modifier.widthIn(max = 760.dp).padding(horizontal = 4.dp)) }
+ is Entry.Thought -> AgentThought(entry.text, live = false)
+ is Entry.Used -> if (entry.tool == "TodoWrite") AgentTodos(entry.input) else AgentToolCall(entry, running)
+ is Entry.Result -> Unit
+ is Entry.Finished -> if (last && AgentHost.answerless(AgentHost.entries)) AgentNoReply() else AgentTurnEnd(entry)
+ }
+}
+
+@Composable
+private fun AgentTurnEnd(entry: Entry.Finished) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val text = when {
+ entry.stopped -> stringResource(R.string.agent_stopped)
+ entry.error != null -> stringResource(R.string.agent_failed, entry.error)
+ else -> entry.usage?.let { u ->
+ buildList {
+ if (u.durationMs > 0) add(clock(u.durationMs / 1000))
+ if (u.costUsd >= 0.005) add("$" + String.format(java.util.Locale.US, "%.2f", u.costUsd))
+ if (u.inputTokens > 0 || u.outputTokens > 0) add(stringResource(R.string.agent_tokens, tokens(u.inputTokens), tokens(u.outputTokens)))
+ }.joinToString(" · ")
+ }
+ } ?: return
+ if (text.isEmpty()) return
+ Text(text, fontSize = 12.sp, color = if (entry.error != null && !entry.stopped) colors.error else pal.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp))
+}
+
+@Composable
+private fun AgentNoReply() {
+ val context = LocalContext.current
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(horizontal = 4.dp)) {
+ Text(stringResource(R.string.agent_no_reply), fontSize = 13.sp, color = AttentionAmber)
+ SecondaryButton(stringResource(R.string.agent_retry), compact = true) { AgentHost.retry(context) }
+ }
+}
+
+internal fun tokens(n: Long) = if (n >= 1000) "${n / 1000}k" else n.toString()
+
+@Composable
+internal fun AgentThought(text: String, live: Boolean) {
+ val pal = LocalPalette.current
+ var open by rememberSaveable(text.take(40)) { mutableStateOf(false) }
+ Column(Modifier.padding(horizontal = 4.dp).animateContentSize()) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clip(Shape12).clickable { open = !open }.padding(vertical = 4.dp)) {
+ Icon(Icons.Outlined.Psychology, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
+ Text(stringResource(if (live) R.string.agent_thinking else R.string.agent_thought), fontSize = 13.sp, color = pal.onSurfaceVariant,
+ modifier = Modifier.padding(start = 6.dp))
+ Icon(if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
+ }
+ if (open || live) Text(
+ if (open) text else text.takeLast(240), fontSize = 13.sp, fontStyle = FontStyle.Italic, color = pal.onSurfaceVariant, lineHeight = 19.sp,
+ maxLines = if (open) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 760.dp).padding(start = 22.dp),
+ )
+ }
+}
+
+@Composable
+private fun AgentTodos(list: String) {
+ val pal = LocalPalette.current
+ val colors = MaterialTheme.colorScheme
+ Column(
+ Modifier.widthIn(max = 560.dp).padding(horizontal = 4.dp).clip(Shape12).background(pal.surfaceVariant).padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ for (line in list.lines().filter { it.isNotBlank() }) {
+ val done = line.startsWith("[x]")
+ val doing = line.startsWith("[~]")
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.size(14.dp).clip(Shape12).background(when {
+ done -> pal.good
+ doing -> pal.primary
+ else -> pal.line
+ }))
+ Text(line.drop(4), fontSize = 13.sp, color = if (done) pal.onSurfaceVariant else colors.onSurface,
+ fontWeight = if (doing) FontWeight.SemiBold else null, modifier = Modifier.padding(start = 8.dp))
+ }
+ }
+ }
+}
+
+/** A tool call: one line with its status, opening to the full input and output. */
+@Composable
+private fun AgentToolCall(entry: Entry.Used, running: Boolean) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ var open by rememberSaveable(entry.id.ifEmpty { entry.detail }) { mutableStateOf(false) }
+ Column(
+ Modifier.fillMaxWidth().padding(horizontal = 4.dp).clip(Shape12).background(pal.surfaceVariant).animateContentSize(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().clickable { open = !open }.testTag("agent-tool").padding(horizontal = 10.dp, vertical = 8.dp),
+ ) {
+ Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) {
+ when {
+ running -> CircularProgressIndicator(Modifier.size(13.dp), strokeWidth = 2.dp, color = pal.primary)
+ entry.failed -> Icon(Icons.Outlined.ErrorOutline, null, tint = colors.error, modifier = Modifier.size(16.dp))
+ entry.output != null -> Icon(Icons.Outlined.CheckCircle, null, tint = pal.good, modifier = Modifier.size(16.dp))
+ else -> Icon(Icons.Outlined.Close, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
+ }
+ }
+ Text(entry.tool, fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold, color = pal.primary, modifier = Modifier.padding(start = 8.dp))
+ Text(entry.detail, fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
+ maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(start = 8.dp))
+ Icon(if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
+ }
+ if (open) Column(Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (entry.input.isNotBlank()) CodeBox(stringResource(R.string.agent_tool_input), entry.input, colors.onSurface)
+ when {
+ entry.output == null && running -> Text(stringResource(R.string.agent_tool_running), fontSize = 12.sp, color = pal.onSurfaceVariant)
+ entry.output == null -> Text(stringResource(R.string.agent_tool_no_result), fontSize = 12.sp, color = pal.onSurfaceVariant)
+ entry.output.isBlank() -> Text(stringResource(R.string.agent_tool_empty), fontSize = 12.sp, color = pal.onSurfaceVariant)
+ else -> CodeBox(stringResource(R.string.agent_tool_output), entry.output, if (entry.failed) colors.error else colors.onSurface)
+ }
+ }
+ }
+}
+
+@Composable
+private fun CodeBox(label: String, text: String, color: Color) {
+ val pal = LocalPalette.current
+ Column {
+ Text(label, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = pal.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
+ SelectionContainer {
+ Text(
+ text, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = color, lineHeight = 17.sp,
+ modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).clip(Shape12).background(MaterialTheme.colorScheme.surface)
+ .verticalScroll(rememberScrollState()).padding(10.dp),
+ )
+ }
+ }
+}
+
+/** An attached file: a thumbnail for images, the name otherwise; [onRemove] adds a remove mark. */
+@Composable
+internal fun AttachmentChip(path: String, onRemove: (() -> Unit)?) {
+ val context = LocalContext.current
+ val pal = LocalPalette.current
+ Box {
+ if (AgentAttachments.isImage(path)) AsyncImage(
+ model = AgentAttachments.host(context, path), contentDescription = path.substringAfterLast('/'), contentScale = ContentScale.Crop,
+ modifier = Modifier.size(72.dp).clip(Shape12).background(pal.surfaceVariant),
+ ) else Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.heightIn(min = 40.dp).clip(Shape12).background(pal.surfaceVariant).padding(horizontal = 10.dp, vertical = 8.dp),
+ ) {
+ Icon(Icons.Outlined.Description, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
+ Text(path.substringAfterLast('/'), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, maxLines = 1,
+ overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 200.dp).padding(start = 6.dp, end = if (onRemove != null) 18.dp else 0.dp))
+ }
+ if (onRemove != null) Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.align(Alignment.TopEnd).padding(3.dp).size(20.dp).clip(Shape12).background(MaterialTheme.colorScheme.surface).clickable(onClick = onRemove),
+ ) {
+ Icon(Icons.Outlined.Close, stringResource(R.string.agent_remove_attachment), tint = pal.onSurfaceVariant, modifier = Modifier.size(14.dp))
+ }
+ }
+}
+
+/** The Markdown agents write: headings, lists, fenced code, `code` and **bold**. */
+@Composable
+internal fun AgentMarkdown(text: String, modifier: Modifier) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ for (block in markdownBlocks(text)) when (block) {
+ is MdBlock.Code -> Text(block.text, fontSize = 13.sp, fontFamily = FontFamily.Monospace, color = colors.onSurface,
+ modifier = Modifier.fillMaxWidth().clip(Shape12).background(pal.surfaceVariant).padding(12.dp))
+ is MdBlock.Line -> Text(
+ inlineMarkdown(block.text, pal.primary2, pal.surfaceVariant),
+ fontSize = if (block.heading) 17.sp else 15.sp,
+ fontWeight = if (block.heading) FontWeight.Bold else null,
+ lineHeight = 22.sp, color = colors.onSurface,
+ modifier = if (block.bullet) Modifier.padding(start = 12.dp) else Modifier,
+ )
+ }
+ }
+}
+
+internal sealed interface MdBlock {
+ data class Line(val text: String, val heading: Boolean = false, val bullet: Boolean = false) : MdBlock
+ data class Code(val text: String) : MdBlock
+}
+
+private val HEADING = Regex("^#{1,6}\\s+")
+private val BULLET = Regex("^\\s*[-*+]\\s+")
+
+internal fun markdownBlocks(text: String): List {
+ val out = mutableListOf()
+ val code = StringBuilder()
+ var inCode = false
+ for (raw in text.lines()) {
+ if (raw.trimStart().startsWith("```")) {
+ if (inCode) {
+ out += MdBlock.Code(code.toString().trimEnd('\n'))
+ code.clear()
+ }
+ inCode = !inCode
+ continue
+ }
+ if (inCode) {
+ code.append(raw).append('\n')
+ continue
+ }
+ val line = raw.trimEnd()
+ if (line.isBlank()) continue
+ val heading = HEADING.find(line)
+ val bullet = BULLET.find(line)
+ out += when {
+ heading != null -> MdBlock.Line(line.substring(heading.range.last + 1), heading = true)
+ bullet != null -> MdBlock.Line("• " + line.substring(bullet.range.last + 1), bullet = true)
+ else -> MdBlock.Line(line)
+ }
+ }
+ if (inCode && code.isNotEmpty()) out += MdBlock.Code(code.toString().trimEnd('\n'))
+ return out
+}
+
+internal fun inlineMarkdown(text: String, codeColor: Color, codeBackground: Color): AnnotatedString = buildAnnotatedString {
+ var i = 0
+ while (i < text.length) {
+ val boldEnd = if (text.startsWith("**", i)) text.indexOf("**", i + 2) else -1
+ val tickEnd = if (text[i] == '`') text.indexOf('`', i + 1) else -1
+ when {
+ boldEnd > i + 2 -> {
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text, i + 2, boldEnd) }
+ i = boldEnd + 2
+ }
+ tickEnd > i + 1 -> {
+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = codeColor, background = codeBackground)) { append(text, i + 1, tickEnd) }
+ i = tickEnd + 1
+ }
+ else -> append(text[i++])
+ }
+ }
+}
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..9956d0469
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt
@@ -0,0 +1,393 @@
+package com.droiddeck.launcher.ui
+
+import androidx.activity.compose.LocalActivityResultRegistryOwner
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+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.height
+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.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.BasicTextField
+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.material.icons.outlined.AttachFile
+import androidx.compose.material.icons.outlined.AutoAwesome
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.ExpandMore
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material.icons.outlined.Memory
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.Stop
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+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.focus.onFocusChanged
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.agent.AgentAttachments
+import com.droiddeck.launcher.agent.AgentHost
+import com.droiddeck.launcher.agent.AgentHost.Entry
+import com.droiddeck.launcher.agent.AgentSetup
+
+internal enum class AgentPanel { NONE, MODEL, HISTORY, PROCESSES }
+
+/** Slash commands the app handles itself; others go to the CLI, which has its own. */
+private val LOCAL_COMMANDS = listOf("/new", "/model", "/plan", "/history", "/processes", "/stop")
+private val CLI_COMMANDS = listOf("/compact", "/cost", "/review", "/init")
+
+@Composable
+internal fun AgentPage(modifier: Modifier, inputModifier: Modifier = Modifier, visible: Boolean = true) {
+ val context = LocalContext.current
+ remember { AgentHost.refresh(context) }
+ LaunchedEffect(AgentHost.unseen, visible) { if (visible) AgentHost.seen() }
+ remember { AgentSetup.check(context) }
+ var setup by rememberSaveable { mutableStateOf(false) }
+ val draft = rememberSaveable { mutableStateOf("") }
+ if (setup || AgentHost.state == AgentHost.State.MISSING || !(AgentSetup.ready || AgentSetup.completed(context))) {
+ AgentSetupView(modifier, onDone = { setup = false })
+ return
+ }
+ var panel by rememberSaveable { mutableStateOf(AgentPanel.NONE) }
+ Column(modifier.fillMaxSize()) {
+ AgentHeader(panel, { panel = if (panel == it) AgentPanel.NONE else it }, onSetup = { setup = true })
+ when (panel) {
+ AgentPanel.HISTORY -> AgentHistory(Modifier.weight(1f)) { panel = AgentPanel.NONE }
+ AgentPanel.PROCESSES -> AgentProcessList(Modifier.weight(1f))
+ else -> {
+ if (panel == AgentPanel.MODEL) AgentModelPicker()
+ AgentConversation(Modifier.weight(1f))
+ AgentStatus()
+ AgentComposer(draft, inputModifier) { panel = it }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AgentHeader(panel: AgentPanel, onPanel: (AgentPanel) -> Unit, onSetup: () -> Unit) {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val working = AgentHost.state == AgentHost.State.WORKING
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.rail_agent), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
+ val model = AgentHost.models(context).firstOrNull { it.id == AgentHost.model }?.label ?: AgentHost.model ?: stringResource(R.string.agent_model_default)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clip(Shape12).clickable { onPanel(AgentPanel.MODEL) }.padding(vertical = 2.dp).testTag("agent-model"),
+ ) {
+ val parts = listOfNotNull(AgentHost.harness.name, model, stringResource(R.string.agent_plan_short).takeIf { AgentHost.plan && AgentHost.harness.plans })
+ Text(parts.joinToString(" · "), fontSize = 13.sp, color = if (panel == AgentPanel.MODEL) pal.primary else pal.onSurfaceVariant)
+ Icon(Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
+ }
+ }
+ HeaderAction(Icons.Outlined.History, stringResource(R.string.agent_history), selected = panel == AgentPanel.HISTORY, tag = "agent-history") { onPanel(AgentPanel.HISTORY) }
+ HeaderAction(Icons.Outlined.Memory, stringResource(R.string.agent_processes), selected = panel == AgentPanel.PROCESSES, tag = "agent-processes") { onPanel(AgentPanel.PROCESSES) }
+ HeaderAction(Icons.Outlined.Add, stringResource(R.string.agent_new), enabled = !working && AgentHost.entries.isNotEmpty(), tag = "agent-new") {
+ AgentHost.forget(context)
+ if (panel != AgentPanel.NONE) onPanel(panel)
+ }
+ HeaderAction(Icons.Outlined.Settings, stringResource(R.string.agent_setup_title), enabled = !working, tag = "agent-settings", onClick = onSetup)
+ }
+}
+
+@Composable
+internal fun HeaderAction(icon: ImageVector, label: String, enabled: Boolean = true, selected: Boolean = false, tag: String? = null, onClick: () -> Unit) {
+ val pal = LocalPalette.current
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.padding(start = 8.dp).size(44.dp).clip(Shape12)
+ .background(if (selected) pal.primary.copy(alpha = 0.2f) else pal.surfaceVariant)
+ .clickable(enabled = enabled, onClick = onClick)
+ .then(if (tag != null) Modifier.testTag(tag) else Modifier),
+ ) {
+ Icon(icon, label, tint = when {
+ selected -> pal.primary
+ enabled -> pal.onBackground
+ else -> pal.onSurfaceVariant.copy(alpha = 0.5f)
+ }, modifier = Modifier.size(22.dp))
+ }
+}
+
+@Composable
+private fun AgentConversation(modifier: Modifier) {
+ val pal = LocalPalette.current
+ val list = rememberLazyListState()
+ val working = AgentHost.state == AgentHost.State.WORKING
+ val atBottom by remember { derivedStateOf { !list.canScrollForward } }
+ val items = AgentHost.entries.size + (if (working) 1 else 0)
+ LaunchedEffect(items) { if (items > 0) list.animateScrollToItem(items - 1) }
+ LaunchedEffect(AgentHost.live.length / 80, AgentHost.liveThinking.length / 80) {
+ if (atBottom && items > 0) list.scrollToItem(items - 1, Int.MAX_VALUE / 2)
+ }
+ if (AgentHost.entries.isEmpty() && !working) {
+ Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Icon(Icons.Outlined.AutoAwesome, null, tint = pal.primary, modifier = Modifier.size(40.dp))
+ Text(stringResource(R.string.agent_empty), fontSize = 15.sp, color = pal.onSurfaceVariant,
+ textAlign = TextAlign.Center, modifier = Modifier.widthIn(max = 460.dp).padding(top = 12.dp))
+ }
+ return
+ }
+ val start = AgentHost.entries.indexOfLast { it is Entry.Asked }
+ LazyColumn(modifier.fillMaxWidth(), state = list, verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(AgentHost.entries.size) { i ->
+ val entry = AgentHost.entries[i]
+ AgentEntry(entry, running = working && i > start && entry is Entry.Used && entry.output == null, last = !working && i == AgentHost.entries.lastIndex)
+ }
+ if (working) item { AgentLive() }
+ }
+}
+
+/** The reply being written, and what the agent is doing right now. */
+@Composable
+private fun AgentLive() {
+ val pal = LocalPalette.current
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (AgentHost.liveThinking.isNotBlank()) AgentThought(AgentHost.liveThinking, live = true)
+ if (AgentHost.live.isNotBlank()) AgentMarkdown(AgentHost.live, Modifier.widthIn(max = 760.dp).padding(horizontal = 4.dp))
+ val running = AgentHost.running()
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(4.dp)) {
+ CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = pal.primary)
+ val elapsed = clock((AgentHost.now - AgentHost.turnStarted) / 1000)
+ Text(
+ if (running != null) stringResource(R.string.agent_running, running.tool, elapsed) else stringResource(R.string.agent_thinking_for, elapsed),
+ fontSize = 13.sp, color = pal.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+internal fun clock(seconds: Long): String {
+ val s = seconds.coerceAtLeast(0)
+ return when {
+ s < 60 -> "${s}s"
+ s < 3600 -> "${s / 60}m ${s % 60}s"
+ else -> "${s / 3600}h ${s % 3600 / 60}m"
+ }
+}
+
+/** Above the composer: a Lab build's progress, the todo item in progress, and queued messages. */
+@Composable
+private fun AgentStatus() {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val lab = AgentHost.lab
+ if (lab != null) {
+ var armed by remember(lab.pid) { mutableStateOf(false) }
+ Column(
+ Modifier.fillMaxWidth().padding(top = 10.dp).clip(Shape14).background(colors.surface).border(1.dp, if (lab.failed) AttentionAmber else pal.line, Shape14)
+ .padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-lab"),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(if (lab.prepare) R.string.agent_lab_preparing else R.string.agent_lab_building) + " · " + labStage(lab),
+ fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (lab.failed) AttentionAmber else colors.onBackground,
+ )
+ val facts = buildList {
+ if (lab.running && lab.total > 0) add("${lab.done}/${lab.total}")
+ add(clock(lab.elapsedSeconds.toLong()))
+ if (lab.running && lab.etaSeconds >= 0) add(stringResource(R.string.agent_lab_left, clock(lab.etaSeconds.toLong())))
+ }
+ Text(facts.joinToString(" · "), fontSize = 12.5.sp, color = pal.onSurfaceVariant)
+ }
+ if (lab.running) SecondaryButton(stringResource(if (armed) R.string.agent_lab_cancel_confirm else R.string.agent_lab_cancel), compact = true) {
+ if (armed) AgentHost.cancelLabBuild(context) else armed = true
+ }
+ }
+ if (lab.running) {
+ LinearProgressIndicator(
+ progress = { lab.permille / 1000f }, color = pal.primary, trackColor = pal.surfaceVariant,
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
+ )
+ if (lab.task.isNotEmpty()) Text(lab.task, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
+ maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
+ }
+ }
+ }
+ val todos = AgentHost.todos()?.lines()?.filter { it.isNotBlank() }
+ if (AgentHost.state == AgentHost.State.WORKING && !todos.isNullOrEmpty()) {
+ val doing = todos.firstOrNull { it.startsWith("[~]") } ?: todos.firstOrNull { it.startsWith("[ ]") }
+ val done = todos.count { it.startsWith("[x]") }
+ if (doing != null) Text(
+ "${doing.drop(4)} · $done/${todos.size}", fontSize = 13.sp, color = pal.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 8.dp, start = 4.dp),
+ )
+ }
+ for (queued in AgentHost.queue) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 8.dp).fillMaxWidth().clip(Shape12).background(pal.surfaceVariant).padding(start = 12.dp),
+ ) {
+ Text(stringResource(R.string.agent_queued, queued.text), fontSize = 13.sp, color = pal.onSurfaceVariant, maxLines = 1,
+ overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
+ Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clickable { AgentHost.unqueue(queued) }) {
+ Icon(Icons.Outlined.Close, stringResource(R.string.agent_unqueue), tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun labStage(lab: com.droiddeck.launcher.agent.LabProgress) = when (lab.stage) {
+ "building" -> stringResource(R.string.agent_lab_stage_building, lab.permille / 10)
+ "packaging" -> stringResource(R.string.agent_lab_stage_packaging)
+ "restarting" -> stringResource(R.string.agent_lab_stage_restarting)
+ "ready", "prepared" -> stringResource(R.string.agent_lab_stage_ready)
+ "failed" -> stringResource(R.string.agent_lab_stage_failed, lab.task)
+ else -> stringResource(R.string.agent_lab_stage_starting)
+}
+
+@Composable
+private fun AgentComposer(draftState: MutableState, inputModifier: Modifier, onPanel: (AgentPanel) -> Unit) {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ var draft by draftState
+ val working = AgentHost.state == AgentHost.State.WORKING
+ val send = send@{
+ val text = draft.trim()
+ when (text.substringBefore(' ')) {
+ "/new" -> AgentHost.forget(context)
+ "/model" -> onPanel(AgentPanel.MODEL)
+ "/plan" -> AgentHost.choosePlan(context, !AgentHost.plan)
+ "/history" -> onPanel(AgentPanel.HISTORY)
+ "/processes" -> onPanel(AgentPanel.PROCESSES)
+ "/stop" -> AgentHost.stop(context)
+ else -> {
+ if (text.isEmpty() && AgentAttachments.pending.isEmpty()) return@send
+ AgentHost.send(context, text, AgentAttachments.pending.toList())
+ AgentAttachments.pending.clear()
+ }
+ }
+ draft = ""
+ }
+ if (draft.startsWith("/") && !draft.contains(' ')) {
+ val matches = (LOCAL_COMMANDS + CLI_COMMANDS).filter { it.startsWith(draft) && it != draft }
+ if (matches.isNotEmpty()) Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(top = 10.dp).fillMaxWidth().horizontalScroll(rememberScrollState()),
+ ) {
+ for (command in matches) Text(
+ command, fontSize = 13.sp, fontFamily = FontFamily.Monospace, color = pal.primary,
+ modifier = Modifier.clip(Shape12).background(pal.surfaceVariant).clickable { draft = "$command " }.padding(horizontal = 12.dp, vertical = 8.dp),
+ )
+ }
+ }
+ if (AgentAttachments.pending.isNotEmpty()) Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(top = 10.dp).fillMaxWidth().horizontalScroll(rememberScrollState()),
+ ) {
+ for (path in AgentAttachments.pending.toList()) AttachmentChip(path) { AgentAttachments.pending.remove(path) }
+ }
+ var focused by remember { mutableStateOf(false) }
+ Row(
+ verticalAlignment = Alignment.Bottom,
+ modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 16.dp).clip(Shape16).background(colors.surface)
+ .border(1.dp, if (focused) pal.primary else pal.line, Shape16).padding(start = 6.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
+ ) {
+ if (LocalActivityResultRegistryOwner.current != null) AttachButton()
+ Box(Modifier.weight(1f).padding(start = 8.dp, top = 11.dp, bottom = 11.dp)) {
+ if (draft.isEmpty()) Text(
+ stringResource(if (working) R.string.agent_hint_working else R.string.agent_hint), fontSize = 16.sp,
+ color = pal.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
+ )
+ BasicTextField(
+ draft, { draft = it },
+ Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.testTag("agent-input").then(inputModifier),
+ textStyle = TextStyle(fontSize = 16.sp, color = colors.onSurface),
+ cursorBrush = SolidColor(pal.primary),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(onSend = { send() }),
+ maxLines = 6,
+ )
+ }
+ if (working) RoundButton(Icons.Outlined.Stop, stringResource(R.string.agent_stop), pal.error.copy(alpha = 0.22f), colors.error, "agent-stop") {
+ AgentHost.stop(context)
+ }
+ val canSend = draft.isNotBlank() || AgentAttachments.pending.isNotEmpty()
+ RoundButton(
+ Icons.AutoMirrored.Outlined.Send, stringResource(if (working) R.string.agent_queue else R.string.agent_send),
+ if (canSend) pal.primary else pal.surfaceVariant, if (canSend) pal.onPrimary else pal.onSurfaceVariant, "agent-send", enabled = canSend,
+ ) { send() }
+ }
+}
+
+@Composable
+private fun AttachButton() {
+ val context = LocalContext.current
+ val pal = LocalPalette.current
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
+ if (uris.isEmpty()) return@rememberLauncherForActivityResult
+ val chat = AgentHost.chat
+ Thread({
+ val paths = AgentAttachments.copy(context, chat, uris)
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
+ if (AgentHost.chat == chat) AgentAttachments.pending.addAll(paths) else AgentAttachments.discard(context, paths)
+ }
+ }, "agent-attach").start()
+ }
+ RoundButton(Icons.Outlined.AttachFile, stringResource(R.string.agent_attach), pal.surfaceVariant, pal.onBackground, "agent-attach") {
+ runCatching { picker.launch(arrayOf("*/*")) }
+ }
+}
+
+@Composable
+private fun RoundButton(
+ icon: ImageVector, label: String, background: androidx.compose.ui.graphics.Color, tint: androidx.compose.ui.graphics.Color,
+ tag: String, enabled: Boolean = true, onClick: () -> Unit,
+) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.padding(start = 6.dp).size(46.dp).clip(CircleShape).background(background)
+ .clickable(enabled = enabled, onClick = onClick).testTag(tag),
+ ) {
+ Icon(icon, label, tint = tint, modifier = Modifier.size(22.dp))
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AgentPanels.kt b/app/src/main/java/com/droiddeck/launcher/ui/AgentPanels.kt
new file mode 100644
index 000000000..fd9b9bf8c
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentPanels.kt
@@ -0,0 +1,182 @@
+package com.droiddeck.launcher.ui
+
+import android.text.format.DateUtils
+import android.text.format.Formatter
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+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.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.agent.AgentChats
+import com.droiddeck.launcher.agent.AgentHarness
+import com.droiddeck.launcher.agent.AgentHost
+import com.droiddeck.launcher.agent.AgentProcesses
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+
+/** The model and plan mode for the next turns; a conversation keeps going with whatever is chosen. */
+@Composable
+internal fun AgentModelPicker() {
+ val context = LocalContext.current
+ val pal = LocalPalette.current
+ val models = remember(AgentHost.harness) { AgentHost.models(context) }
+ Column(Modifier.fillMaxWidth().padding(bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
+ val choices = listOf(AgentHarness.Model("", stringResource(R.string.agent_model_default))) + models
+ for (choice in choices) {
+ val id = choice.id.ifEmpty { null }
+ if (id == AgentHost.model) PrimaryButton(choice.label, compact = true) {}
+ else SecondaryButton(choice.label, compact = true) { AgentHost.chooseModel(context, id) }
+ }
+ }
+ if (AgentHost.harness.plans) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp)) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.agent_plan), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
+ Text(stringResource(R.string.agent_plan_hint), fontSize = 12.5.sp, color = pal.onSurfaceVariant)
+ }
+ ToggleSwitch(AgentHost.plan, label = stringResource(R.string.agent_plan)) { AgentHost.choosePlan(context, it) }
+ }
+ }
+}
+
+@Composable
+internal fun AgentHistory(modifier: Modifier, onOpened: () -> Unit) {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ var version by remember { mutableIntStateOf(0) }
+ val chats by produceState(emptyList(), version, AgentHost.entries.size) {
+ value = withContext(Dispatchers.IO) { AgentChats.list(context) }
+ }
+ if (chats.isEmpty()) {
+ Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.agent_history_empty), fontSize = 15.sp, color = pal.onSurfaceVariant)
+ }
+ return
+ }
+ val working = AgentHost.state == AgentHost.State.WORKING
+ LazyColumn(modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
+ items(chats, key = { it.id }) { chat ->
+ var armed by remember(chat.id) { mutableStateOf(false) }
+ val current = chat.id == AgentHost.chat
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().heightIn(min = 62.dp)
+ .clickable(enabled = !working || current) {
+ AgentHost.open(context, chat.id)
+ onOpened()
+ }
+ .background(if (current) pal.primary.copy(alpha = 0.12f) else colors.surface)
+ .padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-chat"),
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(chat.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(
+ if (armed) stringResource(R.string.agent_history_delete_warning)
+ else AgentHarness.byId(chat.harness).name + " · " + when {
+ System.currentTimeMillis() - chat.updated < 60_000 -> stringResource(R.string.agent_just_now)
+ else -> DateUtils.getRelativeTimeSpanString(chat.updated, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
+ },
+ fontSize = 12.5.sp, color = if (armed) AttentionAmber else pal.onSurfaceVariant,
+ )
+ }
+ SecondaryButton(
+ stringResource(if (armed) R.string.agent_storage_confirm else R.string.agent_history_delete), compact = true,
+ enabled = !(current && working),
+ ) {
+ if (armed) {
+ AgentHost.delete(context, chat.id)
+ version++
+ } else armed = true
+ }
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
+ }
+ }
+}
+
+/** Processes in the Linux userland, the agent's own first, each with a way to end it. */
+@Composable
+internal fun AgentProcessList(modifier: Modifier) {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ var refresh by remember { mutableIntStateOf(0) }
+ var procs by remember { mutableStateOf
?>(null) }
+ LaunchedEffect(refresh) {
+ while (true) {
+ procs = withContext(Dispatchers.IO) { AgentProcesses.snapshot(AgentHost.turnPid) }
+ delay(2_000)
+ }
+ }
+ val list = procs ?: return
+ if (list.isEmpty()) {
+ Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.agent_processes_empty), fontSize = 15.sp, color = pal.onSurfaceVariant)
+ }
+ return
+ }
+ LazyColumn(modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
+ items(list, key = { it.pid }) { proc ->
+ var open by remember(proc.pid) { mutableStateOf(false) }
+ Column(Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-process")) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(proc.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
+ if (proc.agent) Text(stringResource(R.string.agent_process_agent), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = pal.primary,
+ modifier = Modifier.clip(Shape12).background(pal.primary.copy(alpha = 0.15f)).padding(horizontal = 6.dp, vertical = 1.dp))
+ }
+ Text(
+ listOf("pid ${proc.pid}", clock(proc.seconds), Formatter.formatShortFileSize(context, proc.rssKb * 1024), "${proc.cpuPercent}% CPU").joinToString(" · "),
+ fontSize = 12.5.sp, color = pal.onSurfaceVariant,
+ )
+ }
+ SecondaryButton(stringResource(R.string.agent_process_stop), compact = true) {
+ AgentProcesses.killTree(proc.pid)
+ refresh++
+ }
+ }
+ SelectionContainer {
+ Text(proc.args, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
+ maxLines = if (open) 40 else 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
+ }
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AgentSetupView.kt b/app/src/main/java/com/droiddeck/launcher/ui/AgentSetupView.kt
new file mode 100644
index 000000000..857e2edce
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentSetupView.kt
@@ -0,0 +1,215 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.PairingGuideActivity
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.agent.AgentHarness
+import com.droiddeck.launcher.agent.AgentHost
+import com.droiddeck.launcher.agent.AgentSetup
+import com.droiddeck.launcher.agent.AgentStorage
+import android.text.format.Formatter
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import com.droiddeck.launcher.agent.AgentSetup.Status
+import com.droiddeck.launcher.agent.AgentSetup.Step
+import com.droiddeck.launcher.core.WirelessAdbPairingService
+
+@Composable
+internal fun AgentSetupView(modifier: Modifier, onDone: () -> Unit = {}) {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ LaunchedEffect(AgentHost.harness) {
+ AgentSetup.check(context)
+ AgentStorage.measure(context)
+ }
+ val lifecycle = LocalLifecycleOwner.current.lifecycle
+ DisposableEffect(lifecycle) {
+ var left = false
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_PAUSE) left = true
+ if (event == Lifecycle.Event.ON_RESUME && left) {
+ left = false
+ AgentSetup.check(context)
+ }
+ }
+ lifecycle.addObserver(observer)
+ onDispose { lifecycle.removeObserver(observer) }
+ }
+ Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ Text(stringResource(R.string.agent_setup_title), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
+ if (!AgentSetup.runtimeInstalled) {
+ Note(stringResource(R.string.agent_setup_runtime))
+ return@Column
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ for (harness in AgentHarness.all) {
+ val chosen = harness == AgentHost.harness
+ if (chosen) PrimaryButton(harness.name, compact = true) {} else SecondaryButton(harness.name, enabled = !AgentSetup.busy, compact = true) { AgentHost.choose(context, harness) }
+ }
+ }
+ val steps = Step.entries
+ val done = steps.count { AgentSetup.status[it] == Status.DONE }
+ val busy = steps.any { AgentSetup.status[it] == Status.WORKING }
+ var details by rememberSaveable { mutableStateOf(false) }
+ val summary = if (AgentSetup.ready) buildList {
+ add(stringResource(R.string.agent_summary_signed_in, AgentHost.harness.name))
+ if (AgentSetup.status[Step.GITHUB] == Status.DONE) add(stringResource(R.string.agent_step_github))
+ if (AgentSetup.status[Step.BUILD_TOOLS] == Status.DONE) add(stringResource(R.string.agent_summary_lab))
+ if (AgentSetup.status[Step.PREPARE] == Status.DONE) add(stringResource(R.string.agent_summary_fast))
+ }.joinToString(" · ") else steps.firstOrNull { it.required && AgentSetup.status[it] != Status.DONE }?.let { labels(it, AgentHost.harness.name).first }.orEmpty()
+ ReadinessCard(
+ done, steps.size, summary, details || !AgentSetup.ready || busy, { details = !details },
+ title = stringResource(if (AgentSetup.ready) R.string.agent_summary_ready else R.string.agent_summary_not_ready),
+ ready = AgentSetup.ready,
+ ) {
+ steps.forEachIndexed { index, step ->
+ val status = AgentSetup.status[step] ?: Status.UNKNOWN
+ val (title, hint) = labels(step, AgentHost.harness.name)
+ val lab = AgentHost.lab?.takeIf { it.prepare && it.running }
+ val detail = if (step == Step.PREPARE && lab != null)
+ stringResource(R.string.agent_prepare_progress, lab.permille / 10, clock(lab.etaSeconds.toLong().coerceAtLeast(0)))
+ else AgentSetup.detail[step] ?: hint
+ CheckRow(
+ when (status) {
+ Status.DONE -> CheckState.OK
+ Status.WORKING, Status.UNKNOWN -> CheckState.BUSY
+ Status.TODO, Status.FAILED -> CheckState.WARN
+ },
+ title, detail, divider = index < steps.lastIndex,
+ ) {
+ val blocked = (step in setOf(Step.SIGN_IN, Step.GITHUB, Step.BUILD_TOOLS) && AgentSetup.status[Step.INSTALL] != Status.DONE) ||
+ (step == Step.PREPARE && AgentSetup.status[Step.BUILD_TOOLS] != Status.DONE)
+ if ((status == Status.TODO || status == Status.FAILED) && !blocked) {
+ val label = stringResource(if (status == Status.FAILED) R.string.agent_setup_retry else action(step))
+ val run = { if (step == Step.CONNECTION) pair(context) else AgentSetup.run(context, step) }
+ if (step.required) PrimaryButton(label, compact = true, onClick = run) else SecondaryButton(label, compact = true, onClick = run)
+ }
+ }
+ }
+ }
+ if (AgentSetup.ready || AgentSetup.completed(context)) PrimaryButton(stringResource(R.string.agent_setup_done), main = true, onClick = onDone)
+ StorageCard()
+ }
+}
+
+@Composable
+private fun StorageCard() {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val items = AgentStorage.Item.entries.filter { (AgentStorage.sizes[it] ?: 0L) > 0L }
+ if (items.isEmpty()) return
+ val total = items.sumOf { AgentStorage.sizes[it] ?: 0L }
+ var open by rememberSaveable { mutableStateOf(false) }
+ Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp)) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.agent_storage_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
+ Text(Formatter.formatShortFileSize(context, total), fontSize = 13.sp, color = pal.onSurfaceVariant)
+ }
+ SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true) { open = !open }
+ }
+ if (open) items.forEachIndexed { index, item ->
+ if (index == 0) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
+ var armed by remember(item) { mutableStateOf(false) }
+ val busy = AgentStorage.removing[item] == true
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp),
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(storageTitle(item)), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
+ Text(
+ if (armed) stringResource(storageWarning(item)) else Formatter.formatShortFileSize(context, AgentStorage.sizes[item] ?: 0L),
+ fontSize = 13.sp, color = if (armed) AttentionAmber else pal.onSurfaceVariant,
+ )
+ }
+ SecondaryButton(
+ stringResource(if (armed) R.string.agent_storage_confirm else R.string.agent_storage_remove),
+ enabled = !busy && AgentHost.state != AgentHost.State.WORKING, compact = true,
+ ) {
+ if (armed) {
+ armed = false
+ AgentStorage.remove(context, item)
+ } else armed = true
+ }
+ }
+ if (index < items.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
+ }
+ }
+}
+
+private fun storageTitle(item: AgentStorage.Item) = when (item) {
+ AgentStorage.Item.CLAUDE_CODE -> R.string.agent_storage_claude
+ AgentStorage.Item.CODEX -> R.string.agent_storage_codex
+ AgentStorage.Item.BUILD_TOOLS -> R.string.agent_step_build
+ AgentStorage.Item.SOURCE -> R.string.agent_storage_source
+ AgentStorage.Item.CACHES -> R.string.agent_storage_caches
+}
+
+private fun storageWarning(item: AgentStorage.Item) = when (item) {
+ AgentStorage.Item.SOURCE -> R.string.agent_storage_source_warning
+ AgentStorage.Item.CACHES -> R.string.agent_storage_caches_warning
+ else -> R.string.agent_storage_reinstall_warning
+}
+
+@Composable
+private fun labels(step: Step, harness: String): Pair = when (step) {
+ Step.CONNECTION -> stringResource(R.string.agent_step_connection) to stringResource(R.string.agent_step_connection_hint)
+ Step.INSTALL -> stringResource(R.string.agent_step_install, harness) to stringResource(R.string.agent_step_install_hint)
+ Step.SIGN_IN -> stringResource(R.string.agent_step_sign_in, harness) to stringResource(R.string.agent_step_sign_in_hint)
+ Step.GITHUB -> stringResource(R.string.agent_step_github) to stringResource(R.string.agent_step_github_hint)
+ Step.BUILD_TOOLS -> stringResource(R.string.agent_step_build) to stringResource(R.string.agent_step_build_hint)
+ Step.PREPARE -> stringResource(R.string.agent_step_prepare) to stringResource(R.string.agent_step_prepare_hint)
+}
+
+private fun action(step: Step): Int = when (step) {
+ Step.CONNECTION -> R.string.agent_action_pair
+ Step.INSTALL -> R.string.agent_action_install
+ Step.SIGN_IN, Step.GITHUB -> R.string.agent_action_sign_in
+ Step.BUILD_TOOLS -> R.string.agent_action_install
+ Step.PREPARE -> R.string.agent_action_prepare
+}
+
+/** The pairing DroidDeck already uses for the phantom process fix; the agent reuses its key. */
+private fun pair(context: android.content.Context) {
+ if (!WirelessAdbPairingService.notificationsEnabled(context)) {
+ context.startActivity(android.content.Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
+ .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
+ .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK))
+ return
+ }
+ WirelessAdbPairingService.start(context)
+ PairingGuideActivity.start(context, null, false)
+}
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..5358b221e 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.available) 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/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
index 3532d3e77..79ce76913 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
@@ -84,7 +84,7 @@ import com.droiddeck.launcher.stores.download.StoreDownloadTier
import com.droiddeck.launcher.R
-private enum class CheckState { OK, WARN, BUSY }
+internal enum class CheckState { OK, WARN, BUSY }
private const val STORAGE_TAB = 3
private const val LAUNCHER_TAB = 2
@@ -97,7 +97,7 @@ private const val GLASSES_TAB = 6
private const val SYNC_TAB = 4
@Composable
-private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
+internal fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = when (state) {
@@ -690,10 +690,12 @@ internal fun SetupPanel(
}
@Composable
-private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
+internal fun ReadinessCard(
+ passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, title: String? = null, ready: Boolean = passing == total,
+ checks: @Composable () -> Unit,
+) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
- val ready = passing == total
Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -709,7 +711,7 @@ private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean,
}
Column(Modifier.weight(1f)) {
Text(
- stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
+ title ?: stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
)
if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt
index 07289bdf2..a0d75f027 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt
@@ -9,12 +9,16 @@ internal enum class SessionDrawerPage(val firstControl: String) {
EFFECTS("look"),
VR("steamvr"),
GAMES("anisotropy"),
+ AGENT("agent-input"),
SESSION("suspend"),
}
-internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List =
+internal fun sessionDrawerPages(
+ hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
+): List =
SessionDrawerPage.entries.filter {
- (hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES)
+ (hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) &&
+ (hasAgent || it != SessionDrawerPage.AGENT)
}
internal fun List.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
index 7315817c1..575257823 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
@@ -43,6 +43,7 @@ import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
+import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.ScreenShare
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PictureInPictureAlt
@@ -172,6 +173,7 @@ private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.VR -> R.string.vr_title
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
+ SessionDrawerPage.AGENT -> R.string.rail_agent
SessionDrawerPage.SESSION -> R.string.mode_session
}
@@ -184,6 +186,7 @@ private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.VR -> VrHeadsetIcon
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
+ SessionDrawerPage.AGENT -> Icons.Outlined.AutoAwesome
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
}
@@ -550,7 +553,12 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
glassesPages, glassesTab, modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
tabModifier = { key -> focus.track(page, "glasses-tab-$key").testTag("glasses-tab-$key") },
) { selectedGlassesTab = it }
- Column(
+ // The agent's chat scrolls itself and keeps its composer at the bottom, so it takes the height left.
+ if (page == SessionDrawerPage.AGENT) AgentPage(
+ Modifier.weight(1f).fillMaxWidth().padding(top = 8.dp, start = 14.dp, end = 14.dp),
+ inputModifier = focus.track(page, "agent-input"),
+ visible = open,
+ ) else Column(
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll.getValue(page)),
) { CompositionLocalProvider(LocalChipMinWidth provides 96.dp, LocalCompactSettings provides true) {
when (page) {
@@ -762,6 +770,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
track = { key -> focus.track(page, key) },
)
}
+ SessionDrawerPage.AGENT -> Unit
SessionDrawerPage.SESSION -> {
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt
index a1af06a86..990d085f1 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt
@@ -285,7 +285,15 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions, sessionRunning: B
tests.forEach { t -> TestRow(t, u.follow.pr == t.pr) { ua.onFollow(Follow(Channel.TEST, t.pr)) } }
}
}
- LabBuilds(sessionRunning)
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val agentReady = com.droiddeck.launcher.agent.AgentHost.installed &&
+ (com.droiddeck.launcher.agent.AgentSetup.ready || com.droiddeck.launcher.agent.AgentSetup.completed(context))
+ LabBuilds(sessionRunning, onRebuild = if (agentReady) { info ->
+ com.droiddeck.launcher.agent.AgentHost.send(context, context.getString(R.string.agent_rebuild_lab, info.label))
+ context.startActivity(android.content.Intent(context, com.droiddeck.launcher.FrontEndActivity::class.java)
+ .putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent")
+ .addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT))
+ } else null)
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 309d3e415..fd395216d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1144,6 +1144,101 @@
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
+ Stop
+ Storage
+ Remove
+ Remove now
+ Claude Code
+ Codex
+ DroidDeck source
+ Download caches
+ Changes that are not pushed are lost
+ Builds download these again
+ Setup can install it again; sign-ins are kept
+ DroidDeck restarted on your Lab build. Continue where you left off.
+ Ask it why a game won\'t start, to explain a log, or to change DroidDeck and try the change on this device.
+ Failed: %1$s
+ %1$s ended (exit %2$d)
+ Stopped
+ Ended without a reply
+ Retry
+ Setting up the agent
+ Rebuild my Lab build %1$s for the installed version of DroidDeck and restart on it.
+ %1$s in · %2$s out
+ Thinking
+ Thought
+ Thinking · %1$s
+ %1$s · %2$s
+ INPUT
+ OUTPUT
+ Running…
+ No result: the turn ended first
+ No output
+ Type to queue the next message
+ Queue
+ Next: %1$s
+ Don\'t send
+ Attach
+ Remove
+ Default
+ Plan first
+ Plan
+ Proposes a plan without changing anything
+ Conversations
+ No conversations yet
+ Just now
+ Delete
+ Deletes it and its attachments
+ Processes
+ Nothing is running in the Linux userland
+ AGENT
+ Stop
+ Lab build
+ Preparing Lab builds
+ %1$d%% · about %2$s left
+ about %1$s left
+ Cancel
+ Cancel build
+ Starting
+ %1$d%%
+ Packaging
+ Restarting DroidDeck on it
+ Done
+ Failed: %1$s
+ Set up the agent
+ Install the Linux runtime in Setup first.
+ Retry
+ Start chatting
+ Device connection
+ Wireless debugging, so the agent can see and drive DroidDeck
+ Install %1$s
+ Into the Linux userland, with git, gh and adb
+ Sign in to %1$s
+ Opens your browser
+ GitHub
+ Optional, to open pull requests
+ Build tools
+ Optional, for Lab builds on this device · about 5 GB
+ Agent ready
+ Finish setting up the agent
+ Signed in to %1$s
+ Lab builds
+ fast builds
+ Fast Lab builds
+ Compiles this version ahead, so Lab builds take minutes · about 20 minutes once
+ Compiling · %1$d%% · about %2$s left
+ Prepare
+ Wireless debugging isn\'t reachable; Pair turns it on
+ Pair
+ Install
+ Sign in
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..23a176c20
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt
@@ -0,0 +1,190 @@
+package com.droiddeck.launcher.agent
+
+import com.droiddeck.launcher.agent.AgentHost.Entry
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+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 claudeInitCarriesOnlyTheConversation() {
+ val event = ClaudeCode.parse("""{"type":"system","subtype":"init","session_id":"abc","tools":[]}""")!!
+ assertEquals("abc", event.conversation)
+ assertEquals(emptyList(), event.entries)
+ }
+
+ @Test fun claudeTextThinkingAndToolUseBecomeEntries() {
+ val event = ClaudeCode.parse(
+ """{"type":"assistant","session_id":"abc","message":{"content":[
+ {"type":"text","text":" Looking at the log. "},
+ {"type":"tool_use","id":"t1","name":"Bash","input":{"command":"droiddeckctl state\nmore"}},
+ {"type":"thinking","thinking":"why"}],"usage":{"input_tokens":10,"cache_read_input_tokens":90,"output_tokens":5}}}""",
+ )!!
+ assertEquals(listOf(Entry.Said("Looking at the log."), Entry.Used("t1", "Bash", "droiddeckctl state", "droiddeckctl state\nmore"), Entry.Thought("why")), event.entries)
+ assertEquals(105L, event.context)
+ }
+
+ @Test fun claudeStreamsTextAsItIsWritten() {
+ val event = ClaudeCode.parse("""{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}}""")!!
+ assertEquals("Hel", event.text)
+ assertEquals("hm", ClaudeCode.parse("""{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"hm"}}}""")!!.thinking)
+ assertNull(ClaudeCode.parse("""{"type":"stream_event","event":{"type":"message_start"}}"""))
+ }
+
+ @Test fun claudeToolResultsAnswerTheirCall() {
+ val event = ClaudeCode.parse(
+ """{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"ok"}],"is_error":true}]}}""",
+ )!!
+ assertEquals(listOf(Entry.Result("t1", "ok", true)), event.entries)
+ val entries = mutableListOf(Entry.Used("t1", "Bash", "ls", "ls"))
+ assertTrue(AgentChats.merge(entries, event.entries.single()))
+ assertEquals(Entry.Used("t1", "Bash", "ls", "ls", "ok", true), entries.single())
+ assertFalse(AgentChats.merge(entries, Entry.Result("unknown", "x", false)))
+ }
+
+ @Test fun aClaudeResultEndsTheTurnWithItsUsage() {
+ val event = ClaudeCode.parse("""{"type":"result","subtype":"success","is_error":false,"result":"done","duration_ms":4200,"total_cost_usd":0.05,"usage":{"input_tokens":3,"output_tokens":7}}""")!!
+ assertEquals(listOf(Entry.Finished(null)), event.entries)
+ assertEquals(AgentHost.Usage(4200, 0.05, 3, 7), event.usage)
+ assertEquals(listOf(Entry.Finished("Invalid API key")),
+ ClaudeCode.parse("""{"type":"result","subtype":"success","is_error":true,"result":"Invalid API key"}""")!!.entries)
+ assertNull(ClaudeCode.parse("not json"))
+ }
+
+ @Test fun aResultForALeftoverBackgroundTaskDoesNotEndTheTurn() {
+ val event = ClaudeCode.parse("""{"type":"result","is_error":false,"num_turns":0,"session_id":"s","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0},"origin":{"kind":"task-notification"}}""")
+ assertTrue(event == null || event.entries.none { it is Entry.Finished })
+ }
+
+ @Test fun claudeTurnsBypassPermissionsOrPlanAndTakeAModel() {
+ val first = ClaudeCode.turn("hi", null, AgentHarness.Options())
+ assertTrue("bypassPermissions" in first && "--resume" !in first && "--model" !in first && "--include-partial-messages" in first)
+ val next = ClaudeCode.turn("hi", "abc", AgentHarness.Options(model = "opus", plan = true))
+ assertEquals("abc", next[next.indexOf("--resume") + 1])
+ assertEquals("opus", next[next.indexOf("--model") + 1])
+ assertEquals("plan", next[next.indexOf("--permission-mode") + 1])
+ }
+
+ @Test fun claudeShowsWholeEditsAndTodos() {
+ assertEquals("a.kt\n\n- old\n\n+ new", ClaudeCode.fullInput("Edit", org.json.JSONObject("""{"file_path":"a.kt","old_string":"old","new_string":"new"}""")))
+ assertEquals("[x] read\n[~] fix\n[ ] test", ClaudeCode.fullInput("TodoWrite", org.json.JSONObject(
+ """{"todos":[{"content":"read","status":"completed"},{"content":"fix","status":"in_progress"},{"content":"test","status":"pending"}]}""")))
+ }
+
+ @Test fun codexEventsBecomeEntries() {
+ assertEquals("t1", Codex.parse("""{"type":"thread.started","thread_id":"t1"}""")!!.conversation)
+ assertEquals(listOf(Entry.Used("i1", "Shell", "uname -m", "uname -m")),
+ Codex.parse("""{"type":"item.started","item":{"id":"i1","type":"command_execution","command":"uname -m","status":"in_progress"}}""")!!.entries)
+ assertEquals(listOf(Entry.Result("i1", "aarch64\n", false)),
+ Codex.parse("""{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"uname -m","aggregated_output":"aarch64\n","exit_code":0}}""")!!.entries)
+ assertEquals(listOf(Entry.Said("It is aarch64.")),
+ Codex.parse("""{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"It is aarch64."}}""")!!.entries)
+ assertEquals(Entry.Used("i3", "Edit", "a.kt, b.kt", "a.kt\nb.kt"),
+ Codex.parse("""{"type":"item.completed","item":{"id":"i3","type":"file_change","status":"completed","changes":[{"path":"a.kt"},{"path":"b.kt"}]}}""")!!.entries.first())
+ assertEquals(listOf(Entry.Finished(null)), Codex.parse("""{"type":"turn.completed","usage":{}}""")!!.entries)
+ assertEquals(listOf(Entry.Finished("quota")), Codex.parse("""{"type":"turn.failed","error":{"message":"quota"}}""")!!.entries)
+ assertNull(Codex.parse("""{"type":"error","message":"Reconnecting... 1/5 (stream disconnected before completion)"}"""))
+ assertEquals(listOf(Entry.Finished("unauthorized")), Codex.parse("""{"type":"error","message":"unauthorized"}""")!!.entries)
+ assertNull(Codex.parse("""{"type":"item.started","item":{"type":"reasoning"}}"""))
+ }
+
+ @Test fun codexTurnsSkipItsSandboxResumeAndTakeAModel() {
+ assertEquals(listOf("/root/.local/bin/codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "hi"),
+ Codex.turn("hi", null, AgentHarness.Options()))
+ assertEquals(listOf("/root/.local/bin/codex", "exec", "resume", "t1", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-m", "gpt", "hi"),
+ Codex.turn("hi", "t1", AgentHarness.Options(model = "gpt")))
+ }
+
+ @Test fun aToolIdUsedAgainInALaterTurnIsANewCall() {
+ val entries = mutableListOf(Entry.Asked("one"), Entry.Used("item_0", "Shell", "ls", "ls", "a"), Entry.Finished(null), Entry.Asked("two"))
+ assertFalse(AgentChats.merge(entries, Entry.Result("item_0", "late", false)))
+ assertTrue(AgentChats.merge(entries, Entry.Used("item_0", "Shell", "pwd", "pwd")))
+ assertTrue(AgentChats.merge(entries, Entry.Result("item_0", "/root", false)))
+ assertEquals(Entry.Used("item_0", "Shell", "ls", "ls", "a"), entries[1])
+ assertEquals(Entry.Used("item_0", "Shell", "pwd", "pwd", "/root"), entries.last())
+ }
+
+ @Test fun filesAttachedBeforeTheFirstMessageMoveIntoTheChat() {
+ val context = org.robolectric.RuntimeEnvironment.getApplication()
+ val dir = AgentAttachments.hostDir(context)
+ File(dir, "new").mkdirs()
+ File(dir, "new/a.png").writeText("x")
+ val moved = AgentAttachments.adopt(context, "42", listOf("/root/.cache/droiddeck/attachments/new/a.png", "/root/.cache/droiddeck/attachments/7/b.txt"))
+ assertEquals(listOf("/root/.cache/droiddeck/attachments/42/a.png", "/root/.cache/droiddeck/attachments/7/b.txt"), moved)
+ assertTrue(File(dir, "42/a.png").isFile)
+ assertFalse(File(dir, "new/a.png").exists())
+ AgentChats.delete(context, "42")
+ assertFalse(File(dir, "42").exists())
+ }
+
+ @Test fun codexShellWrappersAreUnwrapped() {
+ assertEquals("uname -m", shellCommand("/bin/bash -lc 'uname -m'"))
+ assertEquals("echo 'hi'", shellCommand("/bin/bash -lc 'echo '\''hi'\'''"))
+ assertEquals("ls", shellCommand("ls"))
+ }
+
+ @Test fun aTurnWithoutAnEndWasInterrupted() {
+ assertTrue(AgentHost.interrupted(listOf(Entry.Asked("hi"), Entry.Used("1", "Bash", "dd-lab-build"))))
+ assertEquals(false, AgentHost.interrupted(listOf(Entry.Asked("hi"), Entry.Finished(null))))
+ }
+
+ @Test fun aChatRoundTripsThroughItsFile() {
+ val file = Files.createTempFile("chat", ".jsonl").toFile()
+ AgentChats.start(file, "claude")
+ AgentChats.appendConversation(file, "abc")
+ val written = listOf(
+ Entry.Asked("hi", listOf("/root/x.png")), Entry.Said("hello"), Entry.Thought("hm"), Entry.Used("t1", "Read", "/root/x", "{}"),
+ Entry.Result("t1", "contents", false), Entry.Finished(null, usage = AgentHost.Usage(1, 0.5, 2, 3)), Entry.Finished("boom", stopped = true),
+ )
+ written.forEach { AgentChats.append(file, it) }
+ file.appendText("broken line\n")
+ val loaded = AgentChats.read(file)
+ assertEquals("claude", loaded.harness)
+ assertEquals("abc", loaded.conversation)
+ assertEquals(listOf(
+ Entry.Asked("hi", listOf("/root/x.png")), Entry.Said("hello"), Entry.Thought("hm"), Entry.Used("t1", "Read", "/root/x", "{}", "contents", false),
+ Entry.Finished(null, usage = AgentHost.Usage(1, 0.5, 2, 3)), Entry.Finished("boom", stopped = true),
+ ), loaded.entries)
+ assertEquals(emptyList(), AgentChats.read(File(file.path + ".missing")).entries)
+ }
+
+ @Test fun aTurnThatEndsWithNothingIsAnswerless() {
+ assertTrue(AgentHost.answerless(listOf(Entry.Said("x"), Entry.Asked("go on"), Entry.Finished(null))))
+ assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Said("ok"), Entry.Finished(null))))
+ assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Finished("boom"))))
+ assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Finished(null, stopped = true))))
+ }
+
+ @Test fun longOutputKeepsItsEnd() {
+ val clipped = AgentChats.clip("a".repeat(30_000) + "END")
+ assertTrue(clipped.endsWith("END") && clipped.length < 30_000)
+ }
+
+ @Test fun attachmentsAreListedForTheAgent() {
+ assertEquals("look", AgentAttachments.prompt("look", emptyList()))
+ assertEquals("look\n\nAttached files (read them with your tools):\n- /root/a.png", AgentAttachments.prompt("look", listOf("/root/a.png")))
+ }
+
+ @Test fun procStatFieldsCountFromTheEndOfTheName() {
+ val stat = AgentProcesses.parseStat("1234 (java (x) y) S 1200 1234 1234 0 -1 4194304 10 0 0 0 150 50 0 0 20 0 30 0 98765 1000000 2048 18446744073709551615")
+ assertEquals(1200, stat.ppid)
+ assertEquals(200L, stat.ticks)
+ assertEquals(98765L, stat.startTicks)
+ assertEquals(2048L, stat.rssPages)
+ }
+
+ @Test fun labProgressIsRead() {
+ val p = LabProgress.parse("""{"stage":"building","mode":"build","permille":420,"task":":app:compileReleaseKotlin","done":120,"total":300,"eta":95,"elapsed":30,"pid":77}""")!!
+ assertEquals(420, p.permille)
+ assertTrue(p.running && !p.prepare)
+ assertEquals(95, p.etaSeconds)
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentStorageTest.kt
new file mode 100644
index 000000000..dc1220654
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentStorageTest.kt
@@ -0,0 +1,29 @@
+package com.droiddeck.launcher.agent
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
+import org.junit.Test
+import java.nio.file.Files
+
+class AgentStorageTest {
+ @Test fun removingAFolderKeepsWhatItsLinksPointAt() {
+ val base = Files.createTempDirectory("agent-storage")
+ val outside = Files.createDirectories(base.resolve("sdcard"))
+ Files.write(outside.resolve("save.dat"), ByteArray(100))
+ val item = Files.createDirectories(base.resolve("rootfs/android-sdk"))
+ Files.write(item.resolve("a.jar"), ByteArray(10))
+ val linked = runCatching { Files.createSymbolicLink(item.resolve("saves"), outside) }.isSuccess
+ assumeTrue("symbolic links need privileges on this OS", linked)
+
+ assertTrue(AgentStorage.size(item) < 100)
+ AgentStorage.delete(item)
+ assertFalse(Files.exists(item))
+ assertTrue(Files.exists(outside.resolve("save.dat")))
+ }
+
+ @Test fun aMissingPathHasNoSize() {
+ assertEquals(0L, AgentStorage.size(Files.createTempDirectory("agent-storage").resolve("gone")))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/ui/AgentMarkdownTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/AgentMarkdownTest.kt
new file mode 100644
index 000000000..c7ad34d89
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/ui/AgentMarkdownTest.kt
@@ -0,0 +1,27 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.ui.graphics.Color
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AgentMarkdownTest {
+ @Test fun blocksSplitHeadingsBulletsAndCode() {
+ val blocks = markdownBlocks("## Done\n\n- one\n* two\n```kotlin\nval a = 1\n\nval b = 2\n```\nplain")
+ assertEquals(
+ listOf(
+ MdBlock.Line("Done", heading = true),
+ MdBlock.Line("• one", bullet = true),
+ MdBlock.Line("• two", bullet = true),
+ MdBlock.Code("val a = 1\n\nval b = 2"),
+ MdBlock.Line("plain"),
+ ),
+ blocks,
+ )
+ }
+
+ @Test fun inlineMarkersAreDroppedAndUnclosedOnesKept() {
+ val text = inlineMarkdown("a **b** `c` d ** e ` f", Color.Red, Color.Black)
+ assertEquals("a b c d ** e ` f", text.text)
+ assertEquals(2, text.spanStyles.size)
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt
index 35561a41d..15888d289 100644
--- a/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt
@@ -26,4 +26,11 @@ class SessionDrawerPageTest {
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
}
}
+
+ @Test fun theAgentPageComesBeforeSessionOnlyWhenTheAgentIsInstalled() {
+ val with = sessionDrawerPages(false, hasAgent = true)
+ assertEquals(SessionDrawerPage.AGENT, with.step(SessionDrawerPage.GAMES, 1))
+ assertEquals(SessionDrawerPage.SESSION, with.step(SessionDrawerPage.AGENT, 1))
+ assertFalse(SessionDrawerPage.AGENT in sessionDrawerPages(false, hasAgent = false))
+ }
}
diff --git a/tools/agent/setup b/tools/agent/setup
index 49dd82592..0b1f66f6a 100755
--- a/tools/agent/setup
+++ b/tools/agent/setup
@@ -8,6 +8,7 @@ export DEBIAN_FRONTEND=noninteractive
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
+apt-get update
apt-get install -y --no-install-recommends git gh adb python3 zip unzip curl openjdk-17-jdk-headless qemu-user zipalign apksigner
command -v claude >/dev/null || [ -x "$HOME/.local/bin/claude" ] ||