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