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
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-11-11 14:10:02 -05:00
1 parent 61031e3c4b
commit db933e4232
33 files changed
+3210 -13

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -303,6 +303,7 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:theme="@style/SessionTheme" />
<service android:name=".agent.AgentHostService" android:exported="false" />
<service
android:name=".session.SessionService"
android:exported="false"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Opens a URL in Android's browser on the display the agent is being used on ($DD_BROWSER_DISPLAY).
# DroidDeck opens what is left in $DD_AGENT_INBOX/open-url; adb is the fallback when it cannot.
url=$1
[ -n "$url" ] || exit 1
inbox=${DD_AGENT_INBOX:-/data/user/0/com.droiddeck.launcher/files/agent}
rm -f "$inbox/open-url.failed" 2>/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
+27
View File
@@ -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"
@@ -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)
@@ -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()
@@ -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)
}
}
@@ -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<String>()
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<Uri>): List<String> {
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<String>): List<String> = 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<String>) = 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>): 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"
}
@@ -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<Entry>)
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<Chat> = 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<Entry>()
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: 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)
}
@@ -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<String> 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<String>
/** A guest command that signs in through $BROWSER. */
val signIn: List<String>
/** 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<Model>
/** The guest command for one turn; [conversation] is null for the first one. */
fun turn(message: String, conversation: String?, options: Options): List<String>
/** 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<AgentHarness> = 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<AgentHost.Entry> = 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<AgentHarness.Model> = 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 '<command>'`; the command is what the user wants to read. */
@VisibleForTesting
internal fun shellCommand(command: String) = SHELL_WRAPPER.find(command)?.groupValues?.get(1)?.replace("'\''", "'") ?: command
@@ -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<String> = 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<String>)
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<Entry>()
val queue = mutableStateListOf<Queued>()
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<AgentHarness>(ClaudeCode)
private set
/** The chosen model for [harness]; null is the CLI's default. */
var model by mutableStateOf<String?>(null)
private set
var plan by mutableStateOf(false)
private set
var chat by mutableStateOf<String?>(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<LabProgress?>(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<Entry>) = 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<String> = 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<String>, 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<Entry>): 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)
}
@@ -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()
}
}
}
@@ -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<Int, Long>()
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<Proc> {
val me = Process.myPid()
val uid = Process.myUid()
val stats = HashMap<Int, Stat>()
val args = HashMap<Int, String>()
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<Proc> { 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<Int, MutableList<Int>>()
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<Int>()
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<Int> = 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/<pid>/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
}
@@ -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<Step, Status>()
/** The last line of output from the step that is running or just failed. */
val detail = mutableStateMapOf<Step, String>()
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<Pair<Step, List<String>>>()
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<String>) {
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)
}
}
}
@@ -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<String>) {
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<Item, Long>()
val removing = mutableStateMapOf<Item, Boolean>()
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<Path>() {
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<Path>() {
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
}
})
}
}
@@ -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()
}
}
@@ -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<Int>()
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 {
@@ -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<MdBlock> {
val out = mutableListOf<MdBlock>()
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++])
}
}
}
@@ -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<String>, 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))
}
}
@@ -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<AgentChats.Chat>(), 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<List<AgentProcesses.Proc>?>(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))
}
}
}
@@ -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<String, String> = 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)
}
@@ -270,6 +270,10 @@ private fun Content(
DesktopPage(s, a, modifier, onSelect)
return
}
if (selected == "agent") {
AgentPage(modifier.padding(horizontal = padH).padding(top = padV))
return
}
if (selected == "android-apps" && s.isHomeApp) {
AndroidAppsPage(s, modifier, onAndroidAppClick)
return
@@ -47,6 +47,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LocalMall
@@ -123,7 +124,9 @@ internal fun BottomBar(
internal fun navigationKeys(s: FrontEndState) = buildList {
add("steam"); add("games")
if (s.gameStoresEnabled) add("stores")
add("desktop"); add("components")
add("desktop")
if (com.droiddeck.launcher.agent.AgentHost.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<Int, ImageVector> = when (key) {
"stores" -> R.string.rail_stores to Icons.Outlined.LocalMall
"desktop" -> R.string.rail_desktop to Icons.Outlined.DesktopWindows
"components" -> R.string.rail_components to Icons.Outlined.Layers
"agent" -> R.string.rail_agent to Icons.Outlined.AutoAwesome
"android-apps" -> R.string.rail_apps to Icons.Outlined.Apps
"setup" -> R.string.rail_setup to Icons.Outlined.Tune
else -> R.string.rail_updates to Icons.Outlined.SystemUpdate
@@ -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)
@@ -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<SessionDrawerPage> =
internal fun sessionDrawerPages(
hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
): List<SessionDrawerPage> =
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<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
@@ -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) {
@@ -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)
}
}
+95
View File
@@ -1144,6 +1144,101 @@
<string name="lab_off_title">Lab build turned off</string>
<string name="lab_off_other_install">It was built for another version of DroidDeck. It is kept in Updates.</string>
<string name="lab_off_did_not_start">DroidDeck did not start on it twice</string>
<string name="rail_agent">Agent</string>
<string name="agent_channel_name">Agent</string>
<string name="agent_working">Agent is working</string>
<string name="agent_missing">Install Claude Code in the Linux userland to use the agent.</string>
<string name="agent_hint">Ask about a game, a crash, or a change to DroidDeck</string>
<string name="agent_send">Send</string>
<string name="agent_new">New conversation</string>
<string name="agent_stop">Stop</string>
<string name="agent_storage_title">Storage</string>
<string name="agent_storage_remove">Remove</string>
<string name="agent_storage_confirm">Remove now</string>
<string name="agent_storage_claude">Claude Code</string>
<string name="agent_storage_codex">Codex</string>
<string name="agent_storage_source">DroidDeck source</string>
<string name="agent_storage_caches">Download caches</string>
<string name="agent_storage_source_warning">Changes that are not pushed are lost</string>
<string name="agent_storage_caches_warning">Builds download these again</string>
<string name="agent_storage_reinstall_warning">Setup can install it again; sign-ins are kept</string>
<string name="agent_resumed">DroidDeck restarted on your Lab build. Continue where you left off.</string>
<string name="agent_empty">Ask it why a game won\'t start, to explain a log, or to change DroidDeck and try the change on this device.</string>
<string name="agent_failed">Failed: %1$s</string>
<string name="agent_exited">%1$s ended (exit %2$d)</string>
<string name="agent_stopped">Stopped</string>
<string name="agent_no_reply">Ended without a reply</string>
<string name="agent_retry">Retry</string>
<string name="agent_setting_up">Setting up the agent</string>
<string name="agent_rebuild_lab">Rebuild my Lab build %1$s for the installed version of DroidDeck and restart on it.</string>
<string name="agent_tokens">%1$s in · %2$s out</string>
<string name="agent_thinking">Thinking</string>
<string name="agent_thought">Thought</string>
<string name="agent_thinking_for">Thinking · %1$s</string>
<string name="agent_running">%1$s · %2$s</string>
<string name="agent_tool_input">INPUT</string>
<string name="agent_tool_output">OUTPUT</string>
<string name="agent_tool_running">Running…</string>
<string name="agent_tool_no_result">No result: the turn ended first</string>
<string name="agent_tool_empty">No output</string>
<string name="agent_hint_working">Type to queue the next message</string>
<string name="agent_queue">Queue</string>
<string name="agent_queued">Next: %1$s</string>
<string name="agent_unqueue">Don\'t send</string>
<string name="agent_attach">Attach</string>
<string name="agent_remove_attachment">Remove</string>
<string name="agent_model_default">Default</string>
<string name="agent_plan">Plan first</string>
<string name="agent_plan_short">Plan</string>
<string name="agent_plan_hint">Proposes a plan without changing anything</string>
<string name="agent_history">Conversations</string>
<string name="agent_history_empty">No conversations yet</string>
<string name="agent_just_now">Just now</string>
<string name="agent_history_delete">Delete</string>
<string name="agent_history_delete_warning">Deletes it and its attachments</string>
<string name="agent_processes">Processes</string>
<string name="agent_processes_empty">Nothing is running in the Linux userland</string>
<string name="agent_process_agent">AGENT</string>
<string name="agent_process_stop">Stop</string>
<string name="agent_lab_building">Lab build</string>
<string name="agent_lab_preparing">Preparing Lab builds</string>
<string name="agent_lab_progress">%1$d%% · about %2$s left</string>
<string name="agent_lab_left">about %1$s left</string>
<string name="agent_lab_cancel">Cancel</string>
<string name="agent_lab_cancel_confirm">Cancel build</string>
<string name="agent_lab_stage_starting">Starting</string>
<string name="agent_lab_stage_building">%1$d%%</string>
<string name="agent_lab_stage_packaging">Packaging</string>
<string name="agent_lab_stage_restarting">Restarting DroidDeck on it</string>
<string name="agent_lab_stage_ready">Done</string>
<string name="agent_lab_stage_failed">Failed: %1$s</string>
<string name="agent_setup_title">Set up the agent</string>
<string name="agent_setup_runtime">Install the Linux runtime in Setup first.</string>
<string name="agent_setup_retry">Retry</string>
<string name="agent_setup_done">Start chatting</string>
<string name="agent_step_connection">Device connection</string>
<string name="agent_step_connection_hint">Wireless debugging, so the agent can see and drive DroidDeck</string>
<string name="agent_step_install">Install %1$s</string>
<string name="agent_step_install_hint">Into the Linux userland, with git, gh and adb</string>
<string name="agent_step_sign_in">Sign in to %1$s</string>
<string name="agent_step_sign_in_hint">Opens your browser</string>
<string name="agent_step_github">GitHub</string>
<string name="agent_step_github_hint">Optional, to open pull requests</string>
<string name="agent_step_build">Build tools</string>
<string name="agent_step_build_hint">Optional, for Lab builds on this device · about 5 GB</string>
<string name="agent_summary_ready">Agent ready</string>
<string name="agent_summary_not_ready">Finish setting up the agent</string>
<string name="agent_summary_signed_in">Signed in to %1$s</string>
<string name="agent_summary_lab">Lab builds</string>
<string name="agent_summary_fast">fast builds</string>
<string name="agent_step_prepare">Fast Lab builds</string>
<string name="agent_step_prepare_hint">Compiles this version ahead, so Lab builds take minutes · about 20 minutes once</string>
<string name="agent_prepare_progress">Compiling · %1$d%% · about %2$s left</string>
<string name="agent_action_prepare">Prepare</string>
<string name="agent_connection_off">Wireless debugging isn\'t reachable; Pair turns it on</string>
<string name="agent_action_pair">Pair</string>
<string name="agent_action_install">Install</string>
<string name="agent_action_sign_in">Sign in</string>
<string name="upd_pr">PR #%1$d</string>
<string name="upd_not_built">Not built for this copy of DroidDeck</string>
<string name="upd_updated">Updated %1$s</string>
@@ -0,0 +1,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<Entry>(), 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>(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>(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<Entry>(), 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)
}
}
@@ -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")))
}
}
@@ -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)
}
}
@@ -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))
}
}
+1
View File
@@ -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" ] ||