feat: add android control plane app

This commit is contained in:
Codex
2026-03-23 13:55:46 +08:00
parent 06a3d10c88
commit 0453c1b8ce
24 changed files with 2766 additions and 0 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -22,6 +22,7 @@ Boss 是一个面向多设备开发协作的 agent control plane。
- 文件持久化状态存储
- SSE 实时事件流
- Web 控制台
- Android 主控 APPJetpack 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

View File

@@ -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")
}

1
android-app/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Boss Android v1 does not need additional release rules yet.

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_boss_badge"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.BossAndroid"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.BossAndroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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)
}
}
}
}

View File

@@ -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<Session> = emptyList(),
val messages: List<Message> = emptyList(),
val tasks: List<TaskItem> = emptyList(),
val workers: List<WorkerNode> = emptyList(),
val approvals: List<ApprovalRequest> = emptyList(),
val events: List<BossEvent> = 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<String> = emptyList(),
val dependencyIds: List<String> = 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<String> = 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<String, JsonElement> = emptyMap(),
)

View File

@@ -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<UnitPayload>(
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<UnitPayload>(baseUrl, "/api/sessions/$sessionId/archive")
return getBootstrap(baseUrl)
}
suspend fun restoreSession(baseUrl: String, sessionId: String): AppStatePayload {
post<UnitPayload>(baseUrl, "/api/sessions/$sessionId/restore")
return getBootstrap(baseUrl)
}
suspend fun registerWorker(
baseUrl: String,
name: String,
os: String,
capabilities: List<String>,
): 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<UnitPayload>(baseUrl, "/api/workers/$workerId/offline")
return getBootstrap(baseUrl)
}
suspend fun pauseTask(baseUrl: String, taskId: String): AppStatePayload {
post<TaskItem>(baseUrl, "/api/tasks/$taskId/pause")
return getBootstrap(baseUrl)
}
suspend fun cancelTask(baseUrl: String, taskId: String): AppStatePayload {
post<TaskItem>(baseUrl, "/api/tasks/$taskId/cancel")
return getBootstrap(baseUrl)
}
suspend fun resumeTask(baseUrl: String, taskId: String): AppStatePayload {
post<TaskItem>(baseUrl, "/api/tasks/$taskId/resume")
return getBootstrap(baseUrl)
}
suspend fun requeueTask(baseUrl: String, taskId: String): AppStatePayload {
post<TaskItem>(baseUrl, "/api/tasks/$taskId/requeue")
return getBootstrap(baseUrl)
}
suspend fun respondApproval(
baseUrl: String,
approvalId: String,
approved: Boolean,
): AppStatePayload {
post<ApprovalRequest>(
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<UnitPayload>(baseUrl, "/api/reconcile")
return getBootstrap(baseUrl)
}
private suspend inline fun <reified T> get(baseUrl: String, path: String): T = request(
method = "GET",
baseUrl = baseUrl,
path = path,
body = null,
)
private suspend inline fun <reified T> post(
baseUrl: String,
path: String,
body: JsonElement? = null,
): T = request(
method = "POST",
baseUrl = baseUrl,
path = path,
body = body,
)
private suspend inline fun <reified T> 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<T>("{}")
}
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<ErrorPayload>(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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<Session> = emptyList(),
val messages: List<Message> = emptyList(),
val tasks: List<TaskItem> = emptyList(),
val approvals: List<ApprovalRequest> = emptyList(),
val workers: List<WorkerNode> = emptyList(),
val events: List<BossEvent> = 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<BossUiState> = _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"
}
}

View File

@@ -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,
)
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="#111827"
android:pathData="M16,18c0,-6.627 5.373,-12 12,-12h40c6.627,0 12,5.373 12,12v60c0,6.627 -5.373,12 -12,12H28c-6.627,0 -12,-5.373 -12,-12z" />
<path
android:fillColor="#F4B740"
android:pathData="M28,26c0,-3.314 2.686,-6 6,-6h28c9.941,0 18,8.059 18,18s-8.059,18 -18,18H34c-3.314,0 -6,-2.686 -6,-6z" />
<path
android:fillColor="#FFF9EC"
android:pathData="M38,34h22c3.314,0 6,2.686 6,6s-2.686,6 -6,6H38z" />
<path
android:fillColor="#1F2937"
android:pathData="M38,60h18c7.732,0 14,6.268 14,14v2H38z" />
</vector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Boss</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.BossAndroid" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

View File

@@ -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
}

View File

@@ -0,0 +1,4 @@
android.nonTransitiveRClass=true
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

Binary file not shown.

View File

@@ -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

248
android-app/gradlew vendored Executable file
View File

@@ -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" "$@"

93
android-app/gradlew.bat vendored Normal file
View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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