diff --git a/.gitignore b/.gitignore
index 198d467..96a03be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,7 @@ dist
.boss-session
.playwright-cli
npm-debug.log*
+android-app/.gradle
+android-app/build
+android-app/app/build
+android-app/local.properties
diff --git a/README.md b/README.md
index e4fbd24..c682ade 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ Boss 是一个面向多设备开发协作的 agent control plane。
- 文件持久化状态存储
- SSE 实时事件流
- Web 控制台
+- Android 主控 APP(Jetpack Compose)
- `boss-worker` 模拟执行器
- `boss-worker` 外部命令执行模式,可接本地 Codex / Claude / 自定义脚本
- AI Glasses 云服务器一键部署脚本
@@ -132,6 +133,8 @@ npm run worker -- \
- `./scripts/codex_executor.sh`
- `./scripts/claude_executor.sh`
+- `./scripts/codex_executor.ps1`
+- `./scripts/claude_executor.ps1`
例如:
@@ -170,6 +173,40 @@ BOSS_SERVER_URL=http://111.231.132.51/boss ./scripts/boss_chat.sh status
这条 CLI 入口后面也很容易改造成 Telegram / Slack / 企业微信 webhook。
+3. Android 主控 APP
+仓库现在已经带了一个原生安卓端,适合把“对话、切换设备、绑定设备、审批、看任务”统一放到手机里完成。
+
+首次构建前,先在 `android-app` 目录满足其中一个条件:
+
+- 设置 `ANDROID_HOME` 或 `ANDROID_SDK_ROOT`
+- 或手工创建 `android-app/local.properties`,内容类似:
+
+```properties
+sdk.dir=/Users/yourname/Library/Android/sdk
+```
+
+然后构建 debug 包:
+
+```bash
+cd android-app
+./gradlew assembleDebug
+```
+
+输出 APK:
+
+```bash
+android-app/app/build/outputs/apk/debug/app-debug.apk
+```
+
+安卓端当前包含:
+
+- 会话创建、切换、持续对话
+- 任务分组查看、暂停、恢复、取消、重排
+- 审批查看与批准/拒绝
+- 设备列表、设备聚焦切换、设备下线
+- 绑定新设备并生成启动命令
+- 云端 Boss 地址切换与重排入口
+
一键本地 demo:
```bash
diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
new file mode 100644
index 0000000..4609879
--- /dev/null
+++ b/android-app/app/build.gradle.kts
@@ -0,0 +1,73 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "site.hyzq.bossandroid"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "site.hyzq.bossandroid"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.compose.foundation:foundation:1.7.5")
+ implementation("androidx.compose.material3:material3:1.3.1")
+ implementation("androidx.compose.material:material-icons-extended:1.7.5")
+ implementation("androidx.compose.ui:ui:1.7.5")
+ implementation("androidx.compose.ui:ui-tooling-preview:1.7.5")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+
+ debugImplementation("androidx.compose.ui:ui-tooling:1.7.5")
+ debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5")
+}
diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro
new file mode 100644
index 0000000..b5e8e8d
--- /dev/null
+++ b/android-app/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Boss Android v1 does not need additional release rules yet.
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5ed859
--- /dev/null
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/MainActivity.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/MainActivity.kt
new file mode 100644
index 0000000..cd28652
--- /dev/null
+++ b/android-app/app/src/main/java/site/hyzq/bossandroid/MainActivity.kt
@@ -0,0 +1,23 @@
+package site.hyzq.bossandroid
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.lifecycle.viewmodel.compose.viewModel
+import site.hyzq.bossandroid.ui.BossApp
+import site.hyzq.bossandroid.ui.BossViewModel
+import site.hyzq.bossandroid.ui.theme.BossAndroidTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ BossAndroidTheme {
+ val bossViewModel: BossViewModel = viewModel()
+ BossApp(viewModel = bossViewModel)
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt
new file mode 100644
index 0000000..9040e81
--- /dev/null
+++ b/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt
@@ -0,0 +1,105 @@
+package site.hyzq.bossandroid.model
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+
+@Serializable
+data class AppStatePayload(
+ val sessions: List = emptyList(),
+ val messages: List = emptyList(),
+ val tasks: List = emptyList(),
+ val workers: List = emptyList(),
+ val approvals: List = emptyList(),
+ val events: List = emptyList(),
+)
+
+@Serializable
+data class HealthPayload(
+ val status: String = "unknown",
+ val sessions: Int = 0,
+ val workers: Int = 0,
+)
+
+@Serializable
+data class Session(
+ val id: String,
+ val title: String,
+ val status: String,
+ val activeObjective: String = "",
+ val lastPlannerSummary: String = "",
+ val createdAt: String,
+ val updatedAt: String,
+)
+
+@Serializable
+data class Message(
+ val id: String,
+ val sessionId: String,
+ val role: String,
+ val channel: String,
+ val content: String,
+ val createdAt: String,
+)
+
+@Serializable
+data class TaskItem(
+ val id: String,
+ val sessionId: String,
+ val parentTaskId: String? = null,
+ val title: String,
+ val description: String,
+ val kind: String,
+ val status: String,
+ val priority: String,
+ val requiredOs: String,
+ val requiredCapabilities: List = emptyList(),
+ val dependencyIds: List = emptyList(),
+ val assignedWorkerId: String? = null,
+ val approvalStatus: String = "not_required",
+ val progressPercent: Int = 0,
+ val summary: String = "",
+ val currentStep: String = "",
+ val nextStep: String = "",
+ val createdAt: String,
+ val updatedAt: String,
+)
+
+@Serializable
+data class WorkerNode(
+ val id: String,
+ val name: String,
+ val os: String,
+ val capabilities: List = emptyList(),
+ val status: String,
+ val currentTaskId: String? = null,
+ val load: Int = 0,
+ val lastSeenAt: String,
+ val createdAt: String,
+ val updatedAt: String,
+)
+
+@Serializable
+data class ApprovalRequest(
+ val id: String,
+ val sessionId: String,
+ val taskId: String,
+ val kind: String,
+ val summary: String,
+ val riskLevel: String,
+ val status: String,
+ val requester: String,
+ val responder: String? = null,
+ val createdAt: String,
+ val updatedAt: String,
+)
+
+@Serializable
+data class BossEvent(
+ val id: String,
+ val sessionId: String? = null,
+ val taskId: String? = null,
+ val source: String,
+ val type: String,
+ val timestamp: String,
+ val payload: Map = emptyMap(),
+)
diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt
new file mode 100644
index 0000000..9910150
--- /dev/null
+++ b/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt
@@ -0,0 +1,215 @@
+package site.hyzq.bossandroid.network
+
+import java.io.IOException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.putJsonArray
+import site.hyzq.bossandroid.model.AppStatePayload
+import site.hyzq.bossandroid.model.ApprovalRequest
+import site.hyzq.bossandroid.model.HealthPayload
+import site.hyzq.bossandroid.model.Session
+import site.hyzq.bossandroid.model.TaskItem
+import site.hyzq.bossandroid.model.WorkerNode
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+
+class BossApi(
+ private val client: OkHttpClient = OkHttpClient(),
+ private val json: Json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ },
+) {
+ suspend fun getHealth(baseUrl: String): HealthPayload = get(baseUrl, "/api/health")
+
+ suspend fun getBootstrap(baseUrl: String): AppStatePayload = get(baseUrl, "/api/bootstrap")
+
+ suspend fun createSession(baseUrl: String, title: String): Session = post(
+ baseUrl = baseUrl,
+ path = "/api/sessions",
+ body = buildJsonObject {
+ put("title", JsonPrimitive(title))
+ },
+ )
+
+ suspend fun addMessage(baseUrl: String, sessionId: String, content: String): AppStatePayload {
+ post(
+ baseUrl = baseUrl,
+ path = "/api/sessions/$sessionId/messages",
+ body = buildJsonObject {
+ put("content", JsonPrimitive(content))
+ put("channel", JsonPrimitive("android"))
+ },
+ )
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun archiveSession(baseUrl: String, sessionId: String): AppStatePayload {
+ post(baseUrl, "/api/sessions/$sessionId/archive")
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun restoreSession(baseUrl: String, sessionId: String): AppStatePayload {
+ post(baseUrl, "/api/sessions/$sessionId/restore")
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun registerWorker(
+ baseUrl: String,
+ name: String,
+ os: String,
+ capabilities: List,
+ ): WorkerNode = post(
+ baseUrl = baseUrl,
+ path = "/api/workers/register",
+ body = buildJsonObject {
+ put("name", JsonPrimitive(name))
+ put("os", JsonPrimitive(os))
+ putJsonArray("capabilities") {
+ capabilities.forEach { add(JsonPrimitive(it)) }
+ }
+ },
+ )
+
+ suspend fun markWorkerOffline(baseUrl: String, workerId: String): AppStatePayload {
+ post(baseUrl, "/api/workers/$workerId/offline")
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun pauseTask(baseUrl: String, taskId: String): AppStatePayload {
+ post(baseUrl, "/api/tasks/$taskId/pause")
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun cancelTask(baseUrl: String, taskId: String): AppStatePayload {
+ post(baseUrl, "/api/tasks/$taskId/cancel")
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun resumeTask(baseUrl: String, taskId: String): AppStatePayload {
+ post(baseUrl, "/api/tasks/$taskId/resume")
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun requeueTask(baseUrl: String, taskId: String): AppStatePayload {
+ post(baseUrl, "/api/tasks/$taskId/requeue")
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun respondApproval(
+ baseUrl: String,
+ approvalId: String,
+ approved: Boolean,
+ ): AppStatePayload {
+ post(
+ baseUrl = baseUrl,
+ path = "/api/approvals/$approvalId/respond",
+ body = buildJsonObject {
+ put("approved", JsonPrimitive(approved))
+ put("responder", JsonPrimitive("android-app"))
+ },
+ )
+ return getBootstrap(baseUrl)
+ }
+
+ suspend fun reconcile(baseUrl: String): AppStatePayload {
+ post(baseUrl, "/api/reconcile")
+ return getBootstrap(baseUrl)
+ }
+
+ private suspend inline fun get(baseUrl: String, path: String): T = request(
+ method = "GET",
+ baseUrl = baseUrl,
+ path = path,
+ body = null,
+ )
+
+ private suspend inline fun post(
+ baseUrl: String,
+ path: String,
+ body: JsonElement? = null,
+ ): T = request(
+ method = "POST",
+ baseUrl = baseUrl,
+ path = path,
+ body = body,
+ )
+
+ private suspend inline fun request(
+ method: String,
+ baseUrl: String,
+ path: String,
+ body: JsonElement?,
+ ): T = withContext(Dispatchers.IO) {
+ val normalizedBaseUrl = normalizeBaseUrl(baseUrl)
+ val requestBuilder = Request.Builder()
+ .url("$normalizedBaseUrl$path")
+
+ if (method == "POST") {
+ val payload = json.encodeToString(body ?: JsonObject(emptyMap()))
+ requestBuilder.post(payload.toRequestBody(JSON_MEDIA_TYPE))
+ }
+
+ if (method == "GET") {
+ requestBuilder.get()
+ }
+
+ client.newCall(requestBuilder.build()).execute().use { response ->
+ val responseBody = response.body?.string().orEmpty()
+ if (!response.isSuccessful) {
+ throw IOException(parseErrorMessage(response.code, responseBody))
+ }
+ if (responseBody.isBlank()) {
+ return@withContext json.decodeFromString("{}")
+ }
+ return@withContext json.decodeFromString(responseBody)
+ }
+ }
+
+ private fun normalizeBaseUrl(input: String): String {
+ val trimmed = input.trim().trimEnd('/')
+ if (trimmed.isBlank()) {
+ return DEFAULT_BASE_URL
+ }
+ return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
+ trimmed
+ } else {
+ "http://$trimmed"
+ }
+ }
+
+ private fun parseErrorMessage(status: Int, body: String): String {
+ return try {
+ val payload = json.decodeFromString(body)
+ payload.message.ifBlank { "$status request failed" }
+ } catch (_: Exception) {
+ body.ifBlank { "$status request failed" }
+ }
+ }
+
+ @Serializable
+ private data class ErrorPayload(
+ val error: String = "",
+ val message: String = "",
+ )
+
+ @Serializable
+ private data class UnitPayload(
+ val ok: Boolean = true,
+ )
+
+ companion object {
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
+ const val DEFAULT_BASE_URL = "http://111.231.132.51/boss"
+ }
+}
diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt
new file mode 100644
index 0000000..939ade6
--- /dev/null
+++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt
@@ -0,0 +1,1273 @@
+package site.hyzq.bossandroid.ui
+
+import androidx.compose.foundation.background
+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.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Approval
+import androidx.compose.material.icons.outlined.ChatBubbleOutline
+import androidx.compose.material.icons.outlined.Computer
+import androidx.compose.material.icons.outlined.Devices
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.SyncAlt
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import java.time.Duration
+import java.time.OffsetDateTime
+import java.time.format.DateTimeFormatter
+import site.hyzq.bossandroid.model.ApprovalRequest
+import site.hyzq.bossandroid.model.BossEvent
+import site.hyzq.bossandroid.model.Message
+import site.hyzq.bossandroid.model.Session
+import site.hyzq.bossandroid.model.TaskItem
+import site.hyzq.bossandroid.model.WorkerNode
+
+private val TaskGroups = listOf(
+ "进行中" to listOf("assigned", "running"),
+ "等待处理" to listOf("planning", "queued"),
+ "等待审批或阻塞" to listOf("waiting_approval", "blocked", "paused"),
+ "已完成" to listOf("completed"),
+ "已结束" to listOf("failed", "cancelled"),
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BossApp(
+ viewModel: BossViewModel,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val clipboard = LocalClipboardManager.current
+
+ LaunchedEffect(uiState.notice?.id) {
+ val notice = uiState.notice ?: return@LaunchedEffect
+ snackbarHostState.showSnackbar(notice.message)
+ viewModel.dismissNotice()
+ }
+
+ val selectedSession = uiState.sessions.firstOrNull { it.id == uiState.selectedSessionId }
+ val selectedWorker = uiState.workers.firstOrNull { it.id == uiState.selectedWorkerId }
+
+ Scaffold(
+ contentWindowInsets = WindowInsets.navigationBars,
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("Boss 主控")
+ Text(
+ text = topBarSubtitle(uiState, selectedSession, selectedWorker),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { viewModel.refresh(showSpinner = true) }) {
+ Icon(Icons.Outlined.Refresh, contentDescription = "刷新")
+ }
+ },
+ )
+ },
+ bottomBar = {
+ NavigationBar {
+ NavigationBarItem(
+ selected = uiState.section == MainSection.CONVERSATIONS,
+ onClick = { viewModel.selectSection(MainSection.CONVERSATIONS) },
+ icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = null) },
+ label = { Text("会话") },
+ )
+ NavigationBarItem(
+ selected = uiState.section == MainSection.DEVICES,
+ onClick = { viewModel.selectSection(MainSection.DEVICES) },
+ icon = { Icon(Icons.Outlined.Devices, contentDescription = null) },
+ label = { Text("设备") },
+ )
+ NavigationBarItem(
+ selected = uiState.section == MainSection.SETTINGS,
+ onClick = { viewModel.selectSection(MainSection.SETTINGS) },
+ icon = { Icon(Icons.Outlined.Settings, contentDescription = null) },
+ label = { Text("设置") },
+ )
+ }
+ },
+ snackbarHost = {
+ SnackbarHost(hostState = snackbarHostState)
+ },
+ ) { padding ->
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ when (uiState.section) {
+ MainSection.CONVERSATIONS -> ConversationsScreen(
+ uiState = uiState,
+ selectedSession = selectedSession,
+ selectedWorker = selectedWorker,
+ onSelectSession = viewModel::selectSession,
+ onSelectTab = viewModel::selectConversationTab,
+ onCreateSession = viewModel::createSession,
+ onSendMessage = viewModel::sendMessage,
+ onArchiveSession = viewModel::archiveSelectedSession,
+ onRestoreSession = viewModel::restoreSelectedSession,
+ onPauseTask = viewModel::pauseTask,
+ onResumeTask = viewModel::resumeTask,
+ onCancelTask = viewModel::cancelTask,
+ onRequeueTask = viewModel::requeueTask,
+ onApprove = { viewModel.respondApproval(it, true) },
+ onReject = { viewModel.respondApproval(it, false) },
+ onClearWorkerFocus = { viewModel.selectWorker(null) },
+ )
+
+ MainSection.DEVICES -> DevicesScreen(
+ uiState = uiState,
+ clipboard = clipboard,
+ onBindWorker = viewModel::registerWorker,
+ onSelectWorker = viewModel::selectWorker,
+ onMarkOffline = viewModel::markWorkerOffline,
+ onClearGeneratedCommand = viewModel::clearGeneratedCommand,
+ )
+
+ MainSection.SETTINGS -> SettingsScreen(
+ uiState = uiState,
+ onSaveBaseUrl = viewModel::saveBaseUrl,
+ onRefresh = { viewModel.refresh(showSpinner = true) },
+ onReconcile = viewModel::reconcile,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConversationsScreen(
+ uiState: BossUiState,
+ selectedSession: Session?,
+ selectedWorker: WorkerNode?,
+ onSelectSession: (String) -> Unit,
+ onSelectTab: (ConversationTab) -> Unit,
+ onCreateSession: (String) -> Unit,
+ onSendMessage: (String) -> Unit,
+ onArchiveSession: () -> Unit,
+ onRestoreSession: () -> Unit,
+ onPauseTask: (String) -> Unit,
+ onResumeTask: (String) -> Unit,
+ onCancelTask: (String) -> Unit,
+ onRequeueTask: (String) -> Unit,
+ onApprove: (String) -> Unit,
+ onReject: (String) -> Unit,
+ onClearWorkerFocus: () -> Unit,
+) {
+ val sessionMessages = uiState.messages.filter { it.sessionId == selectedSession?.id }
+ val sessionTasks = uiState.tasks.filter { it.sessionId == selectedSession?.id }
+ val sessionApprovals = uiState.approvals.filter { it.sessionId == selectedSession?.id }
+ val sessionEvents = uiState.events.filter { event ->
+ event.sessionId == selectedSession?.id || event.sessionId == null
+ }.take(8)
+ val filteredTasks = sessionTasks.filter { task ->
+ uiState.selectedWorkerId == null || task.assignedWorkerId == uiState.selectedWorkerId
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ CreateSessionCard(onCreateSession = onCreateSession)
+ SessionSelector(
+ sessions = uiState.sessions,
+ selectedSessionId = uiState.selectedSessionId,
+ onSelectSession = onSelectSession,
+ )
+
+ if (selectedWorker != null) {
+ FocusBanner(
+ title = "当前设备视角",
+ body = "已切换到 ${selectedWorker.name},任务列表会按这个设备过滤。",
+ actionLabel = "清除",
+ onAction = onClearWorkerFocus,
+ )
+ }
+
+ if (selectedSession == null) {
+ EmptyStateCard(
+ title = "还没有可用会话",
+ body = "先创建一个项目会话,然后在这里持续对话、改需求、审批和看进度。",
+ )
+ return@Column
+ }
+
+ SessionSummaryCard(
+ session = selectedSession,
+ workerCount = uiState.workers.size,
+ onArchive = onArchiveSession,
+ onRestore = onRestoreSession,
+ )
+
+ TabRow(selectedTabIndex = uiState.conversationTab.ordinal) {
+ ConversationTab.entries.forEach { tab ->
+ Tab(
+ selected = uiState.conversationTab == tab,
+ onClick = { onSelectTab(tab) },
+ text = {
+ Text(
+ text = when (tab) {
+ ConversationTab.CHAT -> "对话"
+ ConversationTab.TASKS -> "任务"
+ ConversationTab.APPROVALS -> "审批"
+ },
+ )
+ },
+ )
+ }
+ }
+
+ when (uiState.conversationTab) {
+ ConversationTab.CHAT -> ChatTab(
+ sessionMessages = sessionMessages,
+ sessionEvents = sessionEvents,
+ onSendMessage = onSendMessage,
+ )
+
+ ConversationTab.TASKS -> TasksTab(
+ tasks = filteredTasks,
+ workers = uiState.workers,
+ onPauseTask = onPauseTask,
+ onResumeTask = onResumeTask,
+ onCancelTask = onCancelTask,
+ onRequeueTask = onRequeueTask,
+ )
+
+ ConversationTab.APPROVALS -> ApprovalsTab(
+ approvals = sessionApprovals,
+ tasks = sessionTasks,
+ onApprove = onApprove,
+ onReject = onReject,
+ )
+ }
+ }
+}
+
+@Composable
+private fun DevicesScreen(
+ uiState: BossUiState,
+ clipboard: androidx.compose.ui.platform.ClipboardManager,
+ onBindWorker: (String, String, String, String, String) -> Unit,
+ onSelectWorker: (String?) -> Unit,
+ onMarkOffline: (String) -> Unit,
+ onClearGeneratedCommand: () -> Unit,
+) {
+ val selectedWorker = uiState.workers.firstOrNull { it.id == uiState.selectedWorkerId }
+ val relatedTasks = remember(uiState.tasks) {
+ uiState.tasks.associateBy { it.id }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ BindDeviceCard(
+ baseUrl = uiState.baseUrl,
+ generatedCommand = uiState.generatedCommand,
+ clipboard = clipboard,
+ onBindWorker = onBindWorker,
+ onDismissCommand = onClearGeneratedCommand,
+ )
+
+ if (selectedWorker != null) {
+ FocusBanner(
+ title = "当前设备焦点",
+ body = "任务页已经切换到 ${selectedWorker.name} 的执行视角。",
+ actionLabel = "取消聚焦",
+ onAction = { onSelectWorker(null) },
+ )
+ }
+
+ if (uiState.workers.isEmpty()) {
+ EmptyStateCard(
+ title = "还没有绑定设备",
+ body = "先在上面填写设备名称和操作系统,Boss 会生成对应的启动命令给你的 Windows 或 Mac。",
+ )
+ } else {
+ SectionHeading("已绑定设备")
+ uiState.workers.forEach { worker ->
+ val task = worker.currentTaskId?.let { relatedTasks[it] }
+ WorkerCard(
+ worker = worker,
+ currentTask = task,
+ selected = worker.id == uiState.selectedWorkerId,
+ onSelect = { onSelectWorker(worker.id) },
+ onMarkOffline = { onMarkOffline(worker.id) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SettingsScreen(
+ uiState: BossUiState,
+ onSaveBaseUrl: (String) -> Unit,
+ onRefresh: () -> Unit,
+ onReconcile: () -> Unit,
+) {
+ var baseUrl by remember(uiState.baseUrl) { mutableStateOf(uiState.baseUrl) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ OutlinedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("主控地址", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(
+ "默认已经指向云端 Boss。你也可以改成自己的内网地址或其它部署环境。",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = baseUrl,
+ onValueChange = { baseUrl = it },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text("Server URL") },
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(onClick = { onSaveBaseUrl(baseUrl) }) {
+ Text("保存地址")
+ }
+ OutlinedButton(onClick = onRefresh) {
+ Text("立即刷新")
+ }
+ }
+ }
+ }
+
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text("系统状态", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ MetricRow("连接状态", if (uiState.isRefreshing) "同步中" else "在线")
+ MetricRow("会话数", uiState.health?.sessions?.toString() ?: uiState.sessions.size.toString())
+ MetricRow("设备数", uiState.health?.workers?.toString() ?: uiState.workers.size.toString())
+ MetricRow("最后同步", uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步")
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(onClick = onReconcile) {
+ Icon(Icons.Outlined.SyncAlt, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text("强制重排")
+ }
+ }
+ }
+ }
+
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("APP 范围", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(
+ "这个安卓版本已经覆盖了主控对话、设备绑定、设备切换、任务查看、审批处理和主控地址切换。下一步可以继续补推送通知、SSE 实时流和扫码绑定。",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CreateSessionCard(
+ onCreateSession: (String) -> Unit,
+) {
+ var title by rememberSaveable { mutableStateOf("") }
+
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("新建会话", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(
+ "先建立一个项目会话,然后所有需求变更、追进度和审批都从这个上下文继续。",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = title,
+ onValueChange = { title = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("会话标题") },
+ placeholder = { Text("例如:安卓端接入 Boss 主控") },
+ keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
+ )
+ Button(
+ onClick = {
+ onCreateSession(title)
+ title = ""
+ },
+ ) {
+ Text("创建会话")
+ }
+ }
+ }
+}
+
+@Composable
+private fun SessionSelector(
+ sessions: List,
+ selectedSessionId: String?,
+ onSelectSession: (String) -> Unit,
+) {
+ if (sessions.isEmpty()) {
+ return
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SectionHeading("项目会话")
+ Row(
+ modifier = Modifier.horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ sessions.forEach { session ->
+ FilterChip(
+ selected = session.id == selectedSessionId,
+ onClick = { onSelectSession(session.id) },
+ label = {
+ Text(
+ text = session.title,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SessionSummaryCard(
+ session: Session,
+ workerCount: Int,
+ onArchive: () -> Unit,
+ onRestore: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
+ ) {
+ Column(
+ modifier = Modifier.padding(18.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(session.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
+ Text(
+ "最近更新 ${formatRelative(session.updatedAt)}",
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
+ )
+ }
+ StatusChip(label = if (session.status == "archived") "已归档" else "活跃", tone = session.status)
+ }
+
+ if (session.activeObjective.isNotBlank()) {
+ Text("当前目标:${session.activeObjective}")
+ }
+ Text(
+ text = if (session.lastPlannerSummary.isBlank()) {
+ "Boss 还没有产出新的规划摘要。先发一条消息,系统就会开始拆任务。"
+ } else {
+ session.lastPlannerSummary
+ },
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.86f),
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ AssistChip(
+ onClick = {},
+ label = { Text("$workerCount 台设备在线管理") },
+ leadingIcon = { Icon(Icons.Outlined.Computer, contentDescription = null) },
+ )
+ if (session.status == "archived") {
+ OutlinedButton(onClick = onRestore) {
+ Text("恢复会话")
+ }
+ } else {
+ OutlinedButton(onClick = onArchive) {
+ Text("归档会话")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChatTab(
+ sessionMessages: List,
+ sessionEvents: List,
+ onSendMessage: (String) -> Unit,
+) {
+ var message by rememberSaveable { mutableStateOf("") }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("和 Boss 对话", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(
+ value = message,
+ onValueChange = { message = it },
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 3,
+ label = { Text("需求变化 / 新任务 / 追问进度") },
+ placeholder = { Text("例如:先别做 A,改成优先修登录问题。") },
+ keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
+ )
+ Button(
+ onClick = {
+ onSendMessage(message)
+ message = ""
+ },
+ ) {
+ Text("发送给 Boss")
+ }
+ }
+ }
+
+ SectionHeading("对话记录")
+ if (sessionMessages.isEmpty()) {
+ EmptyStateCard(
+ title = "还没有消息",
+ body = "发第一条需求,Boss 就会开始拆任务并持续回传进度。",
+ )
+ } else {
+ sessionMessages.forEach { messageItem ->
+ MessageCard(messageItem)
+ }
+ }
+
+ SectionHeading("最近事件")
+ if (sessionEvents.isEmpty()) {
+ EmptyStateCard(title = "暂无事件", body = "等待 Manager 或设备 worker 产生活动。")
+ } else {
+ sessionEvents.forEach { event ->
+ EventCard(event)
+ }
+ }
+ }
+}
+
+@Composable
+private fun TasksTab(
+ tasks: List,
+ workers: List,
+ onPauseTask: (String) -> Unit,
+ onResumeTask: (String) -> Unit,
+ onCancelTask: (String) -> Unit,
+ onRequeueTask: (String) -> Unit,
+) {
+ if (tasks.isEmpty()) {
+ EmptyStateCard(
+ title = "还没有任务",
+ body = "Boss 收到你的消息后,会自动生成任务树并调度到设备。",
+ )
+ return
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ TaskGroups.forEach { (title, statuses) ->
+ val groupTasks = tasks.filter { it.status in statuses }
+ if (groupTasks.isEmpty()) {
+ return@forEach
+ }
+
+ SectionHeading(title)
+ groupTasks.forEach { task ->
+ TaskCard(
+ task = task,
+ worker = workers.firstOrNull { it.id == task.assignedWorkerId },
+ onPauseTask = onPauseTask,
+ onResumeTask = onResumeTask,
+ onCancelTask = onCancelTask,
+ onRequeueTask = onRequeueTask,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ApprovalsTab(
+ approvals: List,
+ tasks: List,
+ onApprove: (String) -> Unit,
+ onReject: (String) -> Unit,
+) {
+ if (approvals.isEmpty()) {
+ EmptyStateCard(
+ title = "暂无审批",
+ body = "高风险动作会自动出现在这里,比如危险命令、重写关键文件或合并策略变化。",
+ )
+ return
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ approvals.forEach { approval ->
+ ApprovalCard(
+ approval = approval,
+ taskTitle = tasks.firstOrNull { it.id == approval.taskId }?.title,
+ onApprove = onApprove,
+ onReject = onReject,
+ )
+ }
+ }
+}
+
+@Composable
+private fun BindDeviceCard(
+ baseUrl: String,
+ generatedCommand: GeneratedWorkerCommand?,
+ clipboard: androidx.compose.ui.platform.ClipboardManager,
+ onBindWorker: (String, String, String, String, String) -> Unit,
+ onDismissCommand: () -> Unit,
+) {
+ var name by rememberSaveable { mutableStateOf("") }
+ var os by rememberSaveable { mutableStateOf("macos") }
+ var capabilities by rememberSaveable { mutableStateOf("terminal,test") }
+ var workspace by rememberSaveable { mutableStateOf("") }
+ var executor by rememberSaveable { mutableStateOf("codex") }
+
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("绑定新设备", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(
+ "在这里登记设备身份,Boss 会直接生成对应启动命令。适合把 Windows、Mac 和 Linux 全部挂到同一个主控下。",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("设备名称") },
+ placeholder = { Text("例如:win-codex-01") },
+ )
+ OsSelector(selectedOs = os, onSelect = { os = it })
+ ExecutorSelector(selectedExecutor = executor, onSelect = { executor = it })
+ OutlinedTextField(
+ value = capabilities,
+ onValueChange = { capabilities = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("能力标签") },
+ placeholder = { Text("terminal,test,browser") },
+ )
+ OutlinedTextField(
+ value = workspace,
+ onValueChange = { workspace = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("项目目录(可选)") },
+ placeholder = { Text(if (os == "windows") "C:\\repo\\boss" else "/Users/you/repo/boss") },
+ )
+ Text(
+ "当前主控:$baseUrl",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Button(onClick = {
+ onBindWorker(name, os, capabilities, executor, workspace)
+ name = ""
+ }) {
+ Text("绑定并生成命令")
+ }
+
+ if (generatedCommand != null) {
+ Spacer(Modifier.height(4.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(
+ "${generatedCommand.workerName} 已就绪",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ "把下面这条命令贴到对应设备的 ${generatedCommand.shellLabel} 里即可启动 worker。",
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(16.dp),
+ )
+ .padding(12.dp),
+ ) {
+ Text(
+ text = generatedCommand.command,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(onClick = {
+ clipboard.setText(AnnotatedString(generatedCommand.command))
+ }) {
+ Text("复制命令")
+ }
+ TextButton(onClick = onDismissCommand) {
+ Text("收起")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun WorkerCard(
+ worker: WorkerNode,
+ currentTask: TaskItem?,
+ selected: Boolean,
+ onSelect: () -> Unit,
+ onMarkOffline: () -> Unit,
+) {
+ val health = workerHealth(worker)
+ OutlinedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.outlinedCardColors(
+ containerColor = if (selected) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(worker.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text("${worker.os} · 负载 ${worker.load}", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ StatusChip(label = health.label, tone = health.tone)
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ worker.capabilities.forEach { capability ->
+ AssistChip(onClick = {}, label = { Text(capability) })
+ }
+ }
+
+ Text(
+ text = currentTask?.let {
+ "当前任务:${it.title}"
+ } ?: "当前任务:空闲",
+ )
+ Text(
+ "最近心跳 ${formatRelative(worker.lastSeenAt)}",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(onClick = onSelect) {
+ Text(if (selected) "已聚焦" else "切换到此设备")
+ }
+ OutlinedButton(onClick = onMarkOffline) {
+ Text("下线设备")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageCard(
+ message: Message,
+) {
+ val containerColor = when (message.role) {
+ "user" -> MaterialTheme.colorScheme.primaryContainer
+ "manager" -> MaterialTheme.colorScheme.secondaryContainer
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(roleLabel(message.role), fontWeight = FontWeight.SemiBold)
+ Text(formatClock(message.createdAt), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Text(message.content)
+ Text(
+ "入口:${message.channel}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun EventCard(
+ event: BossEvent,
+) {
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(14.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(event.type, fontWeight = FontWeight.SemiBold)
+ Text(formatClock(event.timestamp), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Text("${event.source} · ${formatRelative(event.timestamp)}", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
+
+@Composable
+private fun TaskCard(
+ task: TaskItem,
+ worker: WorkerNode?,
+ onPauseTask: (String) -> Unit,
+ onResumeTask: (String) -> Unit,
+ onCancelTask: (String) -> Unit,
+ onRequeueTask: (String) -> Unit,
+) {
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(task.kind, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ StatusChip(label = statusLabel(task.status), tone = task.status)
+ }
+
+ LinearProgressIndicator(
+ progress = { task.progressPercent.coerceIn(0, 100) / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ MetricRow("执行设备", worker?.name ?: "待分配")
+ MetricRow("当前步骤", task.currentStep.ifBlank { "等待开始" })
+ MetricRow("下一步", task.nextStep.ifBlank { "等待计划" })
+ if (task.summary.isNotBlank()) {
+ Text(task.summary)
+ }
+ if (task.description.isNotBlank()) {
+ Text(
+ task.description,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ when (task.status) {
+ "paused" -> OutlinedButton(onClick = { onResumeTask(task.id) }) { Text("恢复") }
+ "completed", "cancelled", "failed" -> OutlinedButton(onClick = { onRequeueTask(task.id) }) {
+ Text("重新排队")
+ }
+ else -> OutlinedButton(onClick = { onPauseTask(task.id) }) { Text("暂停") }
+ }
+
+ if (task.status !in listOf("completed", "cancelled")) {
+ TextButton(onClick = { onCancelTask(task.id) }) {
+ Text("取消")
+ }
+ }
+
+ if (task.status in listOf("running", "assigned", "failed")) {
+ TextButton(onClick = { onRequeueTask(task.id) }) {
+ Text("重排")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ApprovalCard(
+ approval: ApprovalRequest,
+ taskTitle: String?,
+ onApprove: (String) -> Unit,
+ onReject: (String) -> Unit,
+) {
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(taskTitle ?: "未命名任务", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(approval.kind, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ StatusChip(label = riskLabel(approval.riskLevel), tone = approval.riskLevel)
+ }
+ Text(approval.summary)
+ Text(
+ "状态:${statusLabel(approval.status)} · ${formatRelative(approval.updatedAt)}",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ if (approval.status == "pending") {
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(onClick = { onApprove(approval.id) }) {
+ Icon(Icons.Outlined.Approval, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text("批准")
+ }
+ OutlinedButton(onClick = { onReject(approval.id) }) {
+ Text("拒绝")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FocusBanner(
+ title: String,
+ body: String,
+ actionLabel: String,
+ onAction: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.16f)),
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, fontWeight = FontWeight.SemiBold)
+ Text(body, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ TextButton(onClick = onAction) {
+ Text(actionLabel)
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyStateCard(
+ title: String,
+ body: String,
+) {
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(18.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(body, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
+
+@Composable
+private fun OsSelector(
+ selectedOs: String,
+ onSelect: (String) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("设备系统", fontWeight = FontWeight.SemiBold)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf("windows", "macos", "linux").forEach { os ->
+ FilterChip(
+ selected = selectedOs == os,
+ onClick = { onSelect(os) },
+ label = { Text(os) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExecutorSelector(
+ selectedExecutor: String,
+ onSelect: (String) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("执行器", fontWeight = FontWeight.SemiBold)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf("codex", "claude").forEach { executor ->
+ FilterChip(
+ selected = selectedExecutor == executor,
+ onClick = { onSelect(executor) },
+ label = { Text(executor) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatusChip(
+ label: String,
+ tone: String,
+) {
+ val colors = when (tone) {
+ "running", "assigned", "live", "approved" -> Pair(Color(0xFF0F766E), Color(0xFFE6FFFB))
+ "queued", "planning", "idle", "medium" -> Pair(Color(0xFF9A6700), Color(0xFFFFF2CF))
+ "paused", "blocked", "waiting_approval", "pending", "lagging" -> Pair(Color(0xFF92400E), Color(0xFFFFE7C2))
+ "failed", "cancelled", "rejected", "offline", "stale", "high" -> Pair(Color(0xFFB42318), Color(0xFFFFE2E0))
+ "archived" -> Pair(Color(0xFF475467), Color(0xFFF2F4F7))
+ else -> Pair(Color(0xFF475467), Color(0xFFECEFF3))
+ }
+
+ Box(
+ modifier = Modifier
+ .background(colors.second, RoundedCornerShape(999.dp))
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ ) {
+ Text(
+ text = label,
+ color = colors.first,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+}
+
+@Composable
+private fun SectionHeading(title: String) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+}
+
+@Composable
+private fun MetricRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(value, fontWeight = FontWeight.SemiBold)
+ }
+}
+
+private fun topBarSubtitle(
+ uiState: BossUiState,
+ selectedSession: Session?,
+ selectedWorker: WorkerNode?,
+): String {
+ return when (uiState.section) {
+ MainSection.CONVERSATIONS -> selectedSession?.title ?: "在手机上直接和 Boss 对话"
+ MainSection.DEVICES -> selectedWorker?.let { "设备焦点:${it.name}" } ?: "绑定、切换和检查设备"
+ MainSection.SETTINGS -> "云端地址、健康状态与重排"
+ }
+}
+
+private fun roleLabel(role: String): String {
+ return when (role) {
+ "user" -> "你"
+ "manager" -> "Boss"
+ else -> "系统"
+ }
+}
+
+private fun statusLabel(status: String): String {
+ return when (status) {
+ "planning" -> "规划中"
+ "queued" -> "排队中"
+ "assigned" -> "已分配"
+ "running" -> "执行中"
+ "blocked" -> "阻塞"
+ "paused" -> "已暂停"
+ "waiting_approval" -> "待审批"
+ "completed" -> "已完成"
+ "failed" -> "失败"
+ "cancelled" -> "已取消"
+ "pending" -> "待处理"
+ "approved" -> "已批准"
+ "rejected" -> "已拒绝"
+ else -> status
+ }
+}
+
+private fun riskLabel(risk: String): String {
+ return when (risk) {
+ "high" -> "高风险"
+ "medium" -> "中风险"
+ else -> "低风险"
+ }
+}
+
+private data class WorkerHealth(
+ val tone: String,
+ val label: String,
+)
+
+private fun workerHealth(worker: WorkerNode): WorkerHealth {
+ if (worker.status == "offline") {
+ return WorkerHealth(tone = "offline", label = "离线")
+ }
+
+ val ageSeconds = runCatching {
+ Duration.between(OffsetDateTime.parse(worker.lastSeenAt), OffsetDateTime.now()).seconds
+ }.getOrDefault(0)
+
+ return when {
+ ageSeconds > 30 -> WorkerHealth(tone = "stale", label = "疑似掉线")
+ ageSeconds > 10 -> WorkerHealth(tone = "lagging", label = "连接抖动")
+ worker.status == "busy" -> WorkerHealth(tone = "running", label = "执行中")
+ else -> WorkerHealth(tone = "idle", label = "在线空闲")
+ }
+}
+
+private fun formatClock(raw: String): String {
+ return runCatching {
+ OffsetDateTime.parse(raw).format(DateTimeFormatter.ofPattern("HH:mm"))
+ }.getOrDefault(raw)
+}
+
+private fun formatRelative(raw: String): String {
+ val seconds = runCatching {
+ Duration.between(OffsetDateTime.parse(raw), OffsetDateTime.now()).seconds
+ }.getOrDefault(0)
+
+ return when {
+ seconds < 10 -> "刚刚"
+ seconds < 60 -> "${seconds} 秒前"
+ seconds < 3600 -> "${seconds / 60} 分钟前"
+ seconds < 86400 -> "${seconds / 3600} 小时前"
+ else -> "${seconds / 86400} 天前"
+ }
+}
+
+private fun formatLocalTimestamp(timestamp: Long): String {
+ return runCatching {
+ java.time.Instant.ofEpochMilli(timestamp)
+ .atZone(java.time.ZoneId.systemDefault())
+ .format(DateTimeFormatter.ofPattern("MM-dd HH:mm"))
+ }.getOrDefault(timestamp.toString())
+}
diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt
new file mode 100644
index 0000000..c49b017
--- /dev/null
+++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt
@@ -0,0 +1,471 @@
+package site.hyzq.bossandroid.ui
+
+import android.app.Application
+import android.content.Context
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlin.coroutines.cancellation.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import site.hyzq.bossandroid.model.AppStatePayload
+import site.hyzq.bossandroid.model.ApprovalRequest
+import site.hyzq.bossandroid.model.BossEvent
+import site.hyzq.bossandroid.model.HealthPayload
+import site.hyzq.bossandroid.model.Message
+import site.hyzq.bossandroid.model.Session
+import site.hyzq.bossandroid.model.TaskItem
+import site.hyzq.bossandroid.model.WorkerNode
+import site.hyzq.bossandroid.network.BossApi
+
+enum class MainSection {
+ CONVERSATIONS,
+ DEVICES,
+ SETTINGS,
+}
+
+enum class ConversationTab {
+ CHAT,
+ TASKS,
+ APPROVALS,
+}
+
+data class UiNotice(
+ val id: Long,
+ val message: String,
+)
+
+data class GeneratedWorkerCommand(
+ val workerName: String,
+ val shellLabel: String,
+ val command: String,
+)
+
+data class BossUiState(
+ val baseUrl: String = BossApi.DEFAULT_BASE_URL,
+ val section: MainSection = MainSection.CONVERSATIONS,
+ val conversationTab: ConversationTab = ConversationTab.CHAT,
+ val sessions: List = emptyList(),
+ val messages: List = emptyList(),
+ val tasks: List = emptyList(),
+ val approvals: List = emptyList(),
+ val workers: List = emptyList(),
+ val events: List = emptyList(),
+ val selectedSessionId: String? = null,
+ val selectedWorkerId: String? = null,
+ val health: HealthPayload? = null,
+ val isRefreshing: Boolean = false,
+ val generatedCommand: GeneratedWorkerCommand? = null,
+ val notice: UiNotice? = null,
+ val lastSyncedAt: Long? = null,
+)
+
+class BossViewModel(
+ application: Application,
+) : AndroidViewModel(application) {
+ private val api = BossApi()
+ private val prefs = application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private val refreshMutex = Mutex()
+ private val _uiState = MutableStateFlow(
+ BossUiState(
+ baseUrl = sanitizeBaseUrl(prefs.getString(KEY_BASE_URL, BossApi.DEFAULT_BASE_URL).orEmpty()),
+ ),
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var pollingJob: Job? = null
+
+ init {
+ refresh(showSpinner = true)
+ startPolling()
+ }
+
+ fun selectSection(section: MainSection) {
+ _uiState.update { it.copy(section = section) }
+ }
+
+ fun selectConversationTab(tab: ConversationTab) {
+ _uiState.update { it.copy(conversationTab = tab) }
+ }
+
+ fun selectSession(sessionId: String) {
+ _uiState.update { current ->
+ current.copy(
+ selectedSessionId = sessionId,
+ section = MainSection.CONVERSATIONS,
+ )
+ }
+ }
+
+ fun selectWorker(workerId: String?) {
+ _uiState.update { current ->
+ current.copy(
+ selectedWorkerId = workerId,
+ section = if (workerId == null) current.section else MainSection.DEVICES,
+ )
+ }
+ }
+
+ fun saveBaseUrl(input: String) {
+ val sanitized = sanitizeBaseUrl(input)
+ prefs.edit().putString(KEY_BASE_URL, sanitized).apply()
+ _uiState.update { it.copy(baseUrl = sanitized) }
+ refresh(showSpinner = true)
+ }
+
+ fun dismissNotice() {
+ _uiState.update { it.copy(notice = null) }
+ }
+
+ fun clearGeneratedCommand() {
+ _uiState.update { it.copy(generatedCommand = null) }
+ }
+
+ fun refresh(showSpinner: Boolean = false) {
+ viewModelScope.launch {
+ refreshSnapshot(showSpinner = showSpinner)
+ }
+ }
+
+ fun createSession(title: String) {
+ runMutation(successMessage = "已创建新会话。") {
+ val session = api.createSession(currentBaseUrl(), title.trim().ifBlank { "未命名项目" })
+ val snapshot = api.getBootstrap(currentBaseUrl())
+ applySnapshot(snapshot, preferredSessionId = session.id)
+ }
+ }
+
+ fun sendMessage(content: String) {
+ val sessionId = uiState.value.selectedSessionId ?: return
+ val message = content.trim()
+ if (message.isBlank()) {
+ publishNotice("消息不能为空。")
+ return
+ }
+
+ runMutation(successMessage = "需求已发送给 Boss。") {
+ val snapshot = api.addMessage(currentBaseUrl(), sessionId, message)
+ applySnapshot(snapshot, preferredSessionId = sessionId)
+ }
+ }
+
+ fun archiveSelectedSession() {
+ val sessionId = uiState.value.selectedSessionId ?: return
+ runMutation(successMessage = "会话已归档。") {
+ val snapshot = api.archiveSession(currentBaseUrl(), sessionId)
+ applySnapshot(snapshot, preferredSessionId = sessionId)
+ }
+ }
+
+ fun restoreSelectedSession() {
+ val sessionId = uiState.value.selectedSessionId ?: return
+ runMutation(successMessage = "会话已恢复。") {
+ val snapshot = api.restoreSession(currentBaseUrl(), sessionId)
+ applySnapshot(snapshot, preferredSessionId = sessionId)
+ }
+ }
+
+ fun registerWorker(
+ name: String,
+ os: String,
+ capabilitiesInput: String,
+ executorType: String,
+ workspaceInput: String,
+ ) {
+ val normalizedName = name.trim()
+ if (normalizedName.isBlank()) {
+ publishNotice("设备名称不能为空。")
+ return
+ }
+
+ val capabilities = capabilitiesInput
+ .split(",")
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .ifEmpty { listOf("terminal") }
+
+ runMutation(successMessage = "设备已绑定到 Boss。") {
+ val worker = api.registerWorker(
+ baseUrl = currentBaseUrl(),
+ name = normalizedName,
+ os = os,
+ capabilities = capabilities,
+ )
+ val snapshot = api.getBootstrap(currentBaseUrl())
+ applySnapshot(snapshot, preferredWorkerId = worker.id)
+ _uiState.update { current ->
+ current.copy(
+ generatedCommand = buildWorkerCommand(
+ baseUrl = current.baseUrl,
+ worker = worker,
+ executorType = executorType,
+ workspaceInput = workspaceInput,
+ ),
+ )
+ }
+ }
+ }
+
+ fun markWorkerOffline(workerId: String) {
+ runMutation(successMessage = "设备已下线。") {
+ val snapshot = api.markWorkerOffline(currentBaseUrl(), workerId)
+ applySnapshot(snapshot)
+ }
+ }
+
+ fun pauseTask(taskId: String) {
+ runTaskMutation(taskId, "任务已暂停。") {
+ api.pauseTask(currentBaseUrl(), taskId)
+ }
+ }
+
+ fun resumeTask(taskId: String) {
+ runTaskMutation(taskId, "任务已恢复并重新排队。") {
+ api.resumeTask(currentBaseUrl(), taskId)
+ }
+ }
+
+ fun cancelTask(taskId: String) {
+ runTaskMutation(taskId, "任务已取消。") {
+ api.cancelTask(currentBaseUrl(), taskId)
+ }
+ }
+
+ fun requeueTask(taskId: String) {
+ runTaskMutation(taskId, "任务已重新排队。") {
+ api.requeueTask(currentBaseUrl(), taskId)
+ }
+ }
+
+ fun respondApproval(approvalId: String, approved: Boolean) {
+ val feedback = if (approved) "审批已通过。" else "审批已拒绝。"
+ runMutation(successMessage = feedback) {
+ val snapshot = api.respondApproval(currentBaseUrl(), approvalId, approved)
+ applySnapshot(snapshot)
+ }
+ }
+
+ fun reconcile() {
+ runMutation(successMessage = "系统已触发一次重排。") {
+ val snapshot = api.reconcile(currentBaseUrl())
+ applySnapshot(snapshot)
+ }
+ }
+
+ private fun runTaskMutation(
+ taskId: String,
+ successMessage: String,
+ operation: suspend () -> AppStatePayload,
+ ) {
+ val sessionId = uiState.value.tasks.firstOrNull { it.id == taskId }?.sessionId
+ runMutation(successMessage = successMessage) {
+ val snapshot = operation()
+ applySnapshot(snapshot, preferredSessionId = sessionId)
+ }
+ }
+
+ private fun runMutation(
+ successMessage: String,
+ operation: suspend () -> Unit,
+ ) {
+ viewModelScope.launch {
+ try {
+ _uiState.update { it.copy(isRefreshing = true) }
+ operation()
+ publishNotice(successMessage)
+ } catch (error: Throwable) {
+ if (error is CancellationException) {
+ throw error
+ }
+ publishNotice(error.message ?: "请求失败。")
+ } finally {
+ _uiState.update { it.copy(isRefreshing = false) }
+ }
+ }
+ }
+
+ private suspend fun refreshSnapshot(showSpinner: Boolean) {
+ refreshMutex.withLock {
+ try {
+ if (showSpinner) {
+ _uiState.update { it.copy(isRefreshing = true) }
+ }
+ val snapshot = api.getBootstrap(currentBaseUrl())
+ applySnapshot(snapshot)
+ } catch (error: Throwable) {
+ if (error is CancellationException) {
+ throw error
+ }
+ if (showSpinner) {
+ publishNotice(error.message ?: "无法连接 Boss 控制面。")
+ }
+ } finally {
+ _uiState.update { it.copy(isRefreshing = false) }
+ }
+ }
+ }
+
+ private fun applySnapshot(
+ snapshot: AppStatePayload,
+ preferredSessionId: String? = uiState.value.selectedSessionId,
+ preferredWorkerId: String? = uiState.value.selectedWorkerId,
+ ) {
+ val sessions = snapshot.sessions.sortedByDescending { it.updatedAt }
+ val messages = snapshot.messages.sortedBy { it.createdAt }
+ val tasks = snapshot.tasks.sortedByDescending { it.updatedAt }
+ val approvals = snapshot.approvals.sortedByDescending { it.updatedAt }
+ val workers = snapshot.workers.sortedBy { it.name.lowercase() }
+ val events = snapshot.events.sortedByDescending { it.timestamp }
+
+ val selectedSessionId = preferredSessionId
+ ?.takeIf { candidate -> sessions.any { it.id == candidate } }
+ ?: sessions.firstOrNull { it.status == "active" }?.id
+ ?: sessions.firstOrNull()?.id
+
+ val selectedWorkerId = preferredWorkerId
+ ?.takeIf { candidate -> workers.any { it.id == candidate } }
+
+ _uiState.update { current ->
+ current.copy(
+ sessions = sessions,
+ messages = messages,
+ tasks = tasks,
+ approvals = approvals,
+ workers = workers,
+ events = events,
+ health = HealthPayload(
+ status = "ok",
+ sessions = sessions.size,
+ workers = workers.size,
+ ),
+ selectedSessionId = selectedSessionId,
+ selectedWorkerId = selectedWorkerId,
+ lastSyncedAt = System.currentTimeMillis(),
+ )
+ }
+ }
+
+ private fun publishNotice(message: String) {
+ _uiState.update { current ->
+ current.copy(
+ notice = UiNotice(
+ id = System.currentTimeMillis(),
+ message = message,
+ ),
+ )
+ }
+ }
+
+ private fun currentBaseUrl(): String = uiState.value.baseUrl
+
+ private fun startPolling() {
+ pollingJob?.cancel()
+ pollingJob = viewModelScope.launch {
+ while (isActive) {
+ delay(POLL_INTERVAL_MS)
+ refreshSnapshot(showSpinner = false)
+ }
+ }
+ }
+
+ private fun sanitizeBaseUrl(input: String): String {
+ val trimmed = input.trim().trimEnd('/')
+ if (trimmed.isBlank()) {
+ return BossApi.DEFAULT_BASE_URL
+ }
+ return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
+ trimmed
+ } else {
+ "http://$trimmed"
+ }
+ }
+
+ private fun buildWorkerCommand(
+ baseUrl: String,
+ worker: WorkerNode,
+ executorType: String,
+ workspaceInput: String,
+ ): GeneratedWorkerCommand {
+ val capabilityFlags = worker.capabilities.joinToString(" ") { capability ->
+ "--capability ${quoteForShell(capability, worker.os)}"
+ }
+
+ val workspace = workspaceInput.trim().ifBlank {
+ if (worker.os == "windows") {
+ "C:\\path\\to\\project"
+ } else {
+ "/path/to/project"
+ }
+ }
+
+ val normalizedBaseUrl = sanitizeBaseUrl(baseUrl)
+ return if (worker.os == "windows") {
+ val executor = when (executorType) {
+ "claude" -> "powershell -ExecutionPolicy Bypass -File .\\scripts\\claude_executor.ps1"
+ else -> "powershell -ExecutionPolicy Bypass -File .\\scripts\\codex_executor.ps1"
+ }
+ GeneratedWorkerCommand(
+ workerName = worker.name,
+ shellLabel = "PowerShell",
+ command = buildString {
+ append("npm run worker -- --name ")
+ append(quoteForShell(worker.name, worker.os))
+ append(" --os ")
+ append(worker.os)
+ append(' ')
+ append(capabilityFlags)
+ append(" --mode command --workspace ")
+ append(quoteForShell(workspace, worker.os))
+ append(" --executor ")
+ append(quoteForShell(executor, worker.os))
+ append(" --server ")
+ append(quoteForShell(normalizedBaseUrl, worker.os))
+ },
+ )
+ } else {
+ val executor = when (executorType) {
+ "claude" -> "./scripts/claude_executor.sh"
+ else -> "./scripts/codex_executor.sh"
+ }
+ GeneratedWorkerCommand(
+ workerName = worker.name,
+ shellLabel = "Terminal",
+ command = buildString {
+ append("npm run worker -- --name ")
+ append(quoteForShell(worker.name, worker.os))
+ append(" --os ")
+ append(worker.os)
+ append(' ')
+ append(capabilityFlags)
+ append(" --mode command --workspace ")
+ append(quoteForShell(workspace, worker.os))
+ append(" --executor ")
+ append(quoteForShell(executor, worker.os))
+ append(" --server ")
+ append(quoteForShell(normalizedBaseUrl, worker.os))
+ },
+ )
+ }
+ }
+
+ private fun quoteForShell(value: String, os: String): String {
+ return if (os == "windows") {
+ "\"${value.replace("\"", "`\"")}\""
+ } else {
+ "'${value.replace("'", "'\"'\"'")}'"
+ }
+ }
+
+ companion object {
+ private const val KEY_BASE_URL = "boss_base_url"
+ private const val POLL_INTERVAL_MS = 4_500L
+ private const val PREFS_NAME = "boss_android"
+ }
+}
diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/theme/Theme.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/theme/Theme.kt
new file mode 100644
index 0000000..69a9651
--- /dev/null
+++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/theme/Theme.kt
@@ -0,0 +1,55 @@
+package site.hyzq.bossandroid.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+private val LightPalette = lightColorScheme(
+ primary = Color(0xFFB7791F),
+ onPrimary = Color(0xFFFFFBF5),
+ primaryContainer = Color(0xFFF4D9AA),
+ onPrimaryContainer = Color(0xFF2A1800),
+ secondary = Color(0xFF23575A),
+ onSecondary = Color(0xFFF2FFFE),
+ secondaryContainer = Color(0xFFC3ECEB),
+ onSecondaryContainer = Color(0xFF082022),
+ tertiary = Color(0xFF394B7A),
+ background = Color(0xFFF7F3EA),
+ surface = Color(0xFFFFFBF5),
+ surfaceVariant = Color(0xFFE9E0CF),
+ onSurface = Color(0xFF151515),
+ onSurfaceVariant = Color(0xFF4F4638),
+ error = Color(0xFFB3261E),
+)
+
+private val DarkPalette = darkColorScheme(
+ primary = Color(0xFFF4B740),
+ onPrimary = Color(0xFF3F2800),
+ primaryContainer = Color(0xFF6C4800),
+ onPrimaryContainer = Color(0xFFFFE2A8),
+ secondary = Color(0xFF8FD4D1),
+ onSecondary = Color(0xFF003738),
+ secondaryContainer = Color(0xFF145153),
+ onSecondaryContainer = Color(0xFFC4F1EE),
+ tertiary = Color(0xFFBBC7FF),
+ background = Color(0xFF111318),
+ surface = Color(0xFF171A20),
+ surfaceVariant = Color(0xFF2B313B),
+ onSurface = Color(0xFFEAE2D5),
+ onSurfaceVariant = Color(0xFFD1C6B4),
+ error = Color(0xFFFFB4AB),
+)
+
+@Composable
+fun BossAndroidTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit,
+) {
+ MaterialTheme(
+ colorScheme = if (darkTheme) DarkPalette else LightPalette,
+ content = content,
+ )
+}
diff --git a/android-app/app/src/main/res/drawable/ic_boss_badge.xml b/android-app/app/src/main/res/drawable/ic_boss_badge.xml
new file mode 100644
index 0000000..967719b
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_boss_badge.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..4bed3aa
--- /dev/null
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Boss
+
diff --git a/android-app/app/src/main/res/values/styles.xml b/android-app/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..1874453
--- /dev/null
+++ b/android-app/app/src/main/res/values/styles.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/android-app/app/src/main/res/xml/network_security_config.xml b/android-app/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..d4780f8
--- /dev/null
+++ b/android-app/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts
new file mode 100644
index 0000000..0c51a70
--- /dev/null
+++ b/android-app/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+}
diff --git a/android-app/gradle.properties b/android-app/gradle.properties
new file mode 100644
index 0000000..af6bf88
--- /dev/null
+++ b/android-app/gradle.properties
@@ -0,0 +1,4 @@
+android.nonTransitiveRClass=true
+android.useAndroidX=true
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/android-app/gradle/wrapper/gradle-wrapper.jar b/android-app/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..d997cfc
Binary files /dev/null and b/android-app/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android-app/gradle/wrapper/gradle-wrapper.properties b/android-app/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b82aa23
--- /dev/null
+++ b/android-app/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android-app/gradlew b/android-app/gradlew
new file mode 100755
index 0000000..0262dcb
--- /dev/null
+++ b/android-app/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android-app/gradlew.bat b/android-app/gradlew.bat
new file mode 100644
index 0000000..e509b2d
--- /dev/null
+++ b/android-app/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts
new file mode 100644
index 0000000..e5e5987
--- /dev/null
+++ b/android-app/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "BossAndroid"
+include(":app")
diff --git a/scripts/claude_executor.ps1 b/scripts/claude_executor.ps1
new file mode 100644
index 0000000..3ac138f
--- /dev/null
+++ b/scripts/claude_executor.ps1
@@ -0,0 +1,37 @@
+$ErrorActionPreference = "Stop"
+
+$workspace = if ($env:BOSS_WORKSPACE) { $env:BOSS_WORKSPACE } else { (Get-Location).Path }
+$taskTitle = if ($env:BOSS_TASK_TITLE) { $env:BOSS_TASK_TITLE } else { "Untitled task" }
+$taskKind = if ($env:BOSS_TASK_KIND) { $env:BOSS_TASK_KIND } else { "general" }
+$taskDescription = if ($env:BOSS_TASK_DESCRIPTION) { $env:BOSS_TASK_DESCRIPTION } else { "No description provided." }
+
+if (-not (Get-Command claude -ErrorAction SilentlyContinue)) {
+ Write-Error "claude CLI not found in PATH"
+}
+
+Set-Location $workspace
+
+$prompt = @"
+You are the device-side execution worker for Boss.
+
+Task title: $taskTitle
+Task kind: $taskKind
+Task description:
+$taskDescription
+
+Work only inside this workspace:
+$workspace
+
+Expectations:
+- Make the smallest correct change that moves the task forward.
+- If you modify code, mention validation or remaining risks in the final summary.
+- If the task is research-only, summarize findings and next steps instead of forcing edits.
+"@
+
+$extraFlags = @()
+if ($env:BOSS_CLAUDE_FLAGS) {
+ $extraFlags = $env:BOSS_CLAUDE_FLAGS -split "\s+"
+}
+
+& claude --print @extraFlags $prompt
+exit $LASTEXITCODE
diff --git a/scripts/codex_executor.ps1 b/scripts/codex_executor.ps1
new file mode 100644
index 0000000..2cb0b2e
--- /dev/null
+++ b/scripts/codex_executor.ps1
@@ -0,0 +1,37 @@
+$ErrorActionPreference = "Stop"
+
+$workspace = if ($env:BOSS_WORKSPACE) { $env:BOSS_WORKSPACE } else { (Get-Location).Path }
+$taskTitle = if ($env:BOSS_TASK_TITLE) { $env:BOSS_TASK_TITLE } else { "Untitled task" }
+$taskKind = if ($env:BOSS_TASK_KIND) { $env:BOSS_TASK_KIND } else { "general" }
+$taskDescription = if ($env:BOSS_TASK_DESCRIPTION) { $env:BOSS_TASK_DESCRIPTION } else { "No description provided." }
+
+if (-not (Get-Command codex -ErrorAction SilentlyContinue)) {
+ Write-Error "codex CLI not found in PATH"
+}
+
+Set-Location $workspace
+
+$prompt = @"
+You are the device-side execution worker for Boss.
+
+Task title: $taskTitle
+Task kind: $taskKind
+Task description:
+$taskDescription
+
+Work only inside this workspace:
+$workspace
+
+Expectations:
+- Make the smallest correct change that moves the task forward.
+- If you modify code, mention validation or remaining risks in the final summary.
+- If the task is research-only, summarize findings and next steps instead of forcing edits.
+"@
+
+$extraFlags = @()
+if ($env:BOSS_CODEX_FLAGS) {
+ $extraFlags = $env:BOSS_CODEX_FLAGS -split "\s+"
+}
+
+& codex exec @extraFlags $prompt
+exit $LASTEXITCODE