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