Compare commits

...
Author SHA1 Message Date
Miguel Palhas b22539bd5b feat(maestro): wire DaemonLink into MainActivity, allow cleartext traffic
Reads the EXTRA_DAEMON_URL intent extra, starts DaemonLink, and shows its
LinkState in the placeholder screen so the connection indicator has
something to bind to. usesCleartextTraffic is required for the daemon's
plain-HTTP LAN endpoint; ACCESS_NETWORK_STATE is required for
ConnectivityManager.registerNetworkCallback.
2026-09-13 00:24:41 +01:00
Miguel Palhas 8f0d73d89c feat(maestro): add Android daemon connection state machine
Ports the DaemonLink state machine from web/src/components/daemon/DaemonLink.tsx
and crates/maestro-tui/src/daemon_link.rs to Android: LinkState (Connecting,
Online, Reconnecting, Down, Updating), build-stamp identity comparison, a
session catalog folded from the events WebSocket, and exponential backoff
(500ms-10s) in place of the flat poll the LAN clients use, since a phone's
network changes are bigger and longer-lived than a wifi blip.

DaemonLink reacts to ProcessLifecycleOwner (foreground resume) and
ConnectivityManager (network change) to re-probe and reconnect. DaemonPrefs
persists the daemon base URL via SharedPreferences and exposes an intent
extra key for overriding it at launch.
2026-09-13 00:24:36 +01:00
Miguel Palhas c3e2faa7ee feat(maestro): add Android REST and WebSocket API clients
MaestroClient covers the daemon's REST surface: sessions CRUD, rename,
kill (with force), focus reporting, dirs/recent-dirs/projects/gitroot/
names/branches lookups, and the structured-session endpoints (prompt,
attachment upload, cancel, config, approval).

MaestroWebSocket covers all three WS connections — /api/events
(catalog deltas), /api/attach/:id (binary PTY frames both directions
plus ClientControl text frames for resize/ping), and
/api/structured/:id (transcript/state projections) — each exposing a
Flow of decoded events behind a connection handle.

Adds kotlinx-coroutines-android explicitly rather than relying on it
being pulled in transitively via lifecycle-runtime-ktx.

Compiled and exercised standalone against the same library versions
(no Android SDK available in this environment to run the app module
itself) to verify wire-format handling before landing.
2026-09-13 00:07:30 +01:00
Miguel Palhas 6ccd79baa0 feat(maestro): add Android wire-format data types
Kotlin data classes mirroring maestro-protocol's wire types (Session,
CatalogEvent, ClientControl, structured-session projections, etc.),
@Serializable with kotlinx-serialization. ActivityState and NameSource
carry custom lenient deserializers matching the Rust side's forward-
compat behavior: an unknown variant degrades to the neutral default
instead of failing the whole payload.

Verified against fixtures mirrored from maestro-protocol's own test
suite (tag placement, snake_case renames, legacy-payload defaults).
2026-09-13 00:07:19 +01:00
Miguel Palhas 4932cbf3b3 feat(maestro): scaffold Android project (Kotlin, Compose, Material 3)
Greenfield Gradle project under android/ — single-activity, Compose-only
UI, Kotlin DSL + version catalog throughout. MainActivity shows a
placeholder screen; no daemon connection yet (#262).

minSdk 26, targetSdk 34. OkHttp and kotlinx-serialization-json wired in
for the upcoming daemon client. Debug signing config uses a committed
debug.keystore so the APK installs without extra setup.
2026-09-12 23:46:55 +01:00
Miguel PalhasandClaude Opus 4.6 1ea463f6cf fix(maestro): collapse mobile drawer after spawning a session
ci / web (push) Failing after 28s
ci / flake (push) Successful in 4m6s
ci / rust (push) Successful in 7m37s
mountSpawned now fires a CLOSE_DRAWER_EVENT so the sidebar gets out of
the way once the new session lands. Covers fast-forward, modal, and
command-bar spawn paths.

Closes #261

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 23:31:52 +01:00
Miguel PalhasandClaude Opus 4.6 7c27a897ac feat(maestro): optimistic session kill in sidebar
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m13s
ci / rust (push) Successful in 7m25s
Close the kill dialog and remove the session card immediately on
confirm instead of waiting for the DELETE round-trip. Restores the
session if the daemon refuses with kill_guarded and re-opens the
dialog in force mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 21:21:36 +01:00
61 changed files with 2661 additions and 4 deletions
+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
+82
View File
@@ -0,0 +1,82 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "pt.naps.maestro"
compileSdk = 34
defaultConfig {
applicationId = "pt.naps.maestro"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
getByName("debug") {
storeFile = file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
debug {
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@@ -0,0 +1,19 @@
package pt.naps.maestro
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun placeholderScreen_showsAppName() {
composeTestRule.onNodeWithText("Maestro").assertExists()
}
}
+26
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" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Maestro">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Maestro">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,82 @@
package pt.naps.maestro
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import pt.naps.maestro.connection.DaemonLink
import pt.naps.maestro.connection.DaemonPrefs
import pt.naps.maestro.connection.LinkState
import pt.naps.maestro.ui.theme.MaestroTheme
class MainActivity : ComponentActivity() {
private lateinit var daemonLink: DaemonLink
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val prefs = DaemonPrefs(applicationContext)
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
setContent {
MaestroTheme {
val linkState by daemonLink.state.collectAsState()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
}
}
}
}
override fun onDestroy() {
daemonLink.close()
super.onDestroy()
}
}
@Composable
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Maestro",
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(id = R.string.placeholder_message),
)
Text(
text = "Daemon: ${linkState.name.lowercase()}",
)
}
}
@Preview(showBackground = true)
@Composable
private fun PlaceholderScreenPreview() {
MaestroTheme {
PlaceholderScreen()
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.api
import pt.naps.maestro.data.InTheWay
/** A `/api` failure with the daemon's own error envelope kept intact, so a
* caller can branch on [code] and render [unsavedWork] — the kill guard —
* rather than re-parsing [detail]. Mirrors the web client's
* `ApiRequestError`. */
class MaestroApiException(
val status: Int,
val code: String,
val detail: String,
val unsavedWork: InTheWay? = null,
) : Exception(buildMessage(status, code, detail)) {
private companion object {
fun buildMessage(status: Int, code: String, detail: String): String {
val info = if (code.isNotEmpty()) "$code: $detail" else detail
return if (info.isNotEmpty()) "$status — $info" else "$status"
}
}
}
@@ -0,0 +1,252 @@
package pt.naps.maestro.api
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import pt.naps.maestro.data.ApiError
import pt.naps.maestro.data.BranchInfo
import pt.naps.maestro.data.CreateSessionRequest
import pt.naps.maestro.data.DaemonInfo
import pt.naps.maestro.data.DirListing
import pt.naps.maestro.data.GitRoot
import pt.naps.maestro.data.NameBatch
import pt.naps.maestro.data.Project
import pt.naps.maestro.data.RecentDirs
import pt.naps.maestro.data.RenameSessionRequest
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SetFocusRequest
import pt.naps.maestro.data.StructuredAttachmentResponse
import pt.naps.maestro.data.StructuredApprovalDecision
import pt.naps.maestro.data.StructuredConfigRequest
import pt.naps.maestro.data.StructuredPromptRequest
import pt.naps.maestro.data.Uuid
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType()
/**
* Typed client for the daemon's `/api` surface (REST half). Mirrors
* `maestro_protocol::client::DaemonClient` / `web/src/api.ts`.
*
* There is no authentication layer: reaching the daemon's bind address is
* the authorization, same trust model as tmux itself.
*/
class MaestroClient(
private val baseUrl: String,
private val httpClient: OkHttpClient = OkHttpClient(),
) {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
suspend fun listSessions(): List<Session> = get(Routes.SESSIONS)
suspend fun createSession(request: CreateSessionRequest): Session =
post(Routes.SESSIONS, request)
/** `PATCH /api/sessions/:id`: rename a live session. An empty [name]
* clears the custom one, so the card falls back to the command. */
suspend fun renameSession(id: Uuid, name: String): Session =
patch(Routes.session(id), RenameSessionRequest(name))
/** `DELETE /api/sessions/:id`. With [force], the daemon overrides the
* kill refusal over a worktree holding unsaved work and leaves that
* worktree on disk. */
suspend fun killSession(id: Uuid, force: Boolean = false) {
val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList()
executeNoContent(newRequest("DELETE", Routes.session(id), query = query))
}
/** `POST /api/sessions/:id/focus`: report whether this client's pane is
* the one currently showing [id] — not merely highlighted in a
* sidebar. */
suspend fun setSessionFocus(id: Uuid, focused: Boolean) {
executeNoContent(
newRequest(
"POST",
Routes.sessionFocus(id),
body = json.encodeToString(SetFocusRequest(focused)),
),
)
}
/** `GET /api/dirs?path=`: one directory's immediate subdirectories.
* [path] may be empty, `~`, relative, or absolute — the daemon resolves
* it against its home dir. */
suspend fun listDir(path: String): DirListing =
get(Routes.DIRS, listOf(Routes.PATH_QUERY to path))
/** `GET /api/info`: identity + spawn presets of the connected daemon. */
suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO)
/** `GET /api/recent-dirs`: recently-used working dirs, newest-first and
* deduped by dir. */
suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS)
/** `GET /api/projects`: every discovered git repository/worktree,
* newest-activity-first. */
suspend fun listProjects(): List<Project> = get(Routes.PROJECTS)
/** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent
* when [path] isn't inside a repo. */
suspend fun gitRoot(path: String): GitRoot =
get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path))
/** `GET /api/names?count=`: a batch of fresh session handles. */
suspend fun fetchNames(count: Int): NameBatch =
get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString()))
/** `GET /api/branches?path=`: one repository's local branches, with
* worktree occupancy. */
suspend fun listBranches(path: String): List<BranchInfo> =
get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path))
/** Send a prompt to a structured session's ACP turn. `409
* turn_in_flight` when one is already active. */
suspend fun sendStructuredPrompt(id: Uuid, prompt: String) {
executeNoContent(
newRequest(
"POST",
Routes.structuredPrompt(id),
body = json.encodeToString(StructuredPromptRequest(prompt)),
),
)
}
/** Upload a file attachment for a structured session's next prompt. */
suspend fun uploadStructuredAttachment(
id: Uuid,
fileName: String,
contents: ByteArray,
): StructuredAttachmentResponse {
val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl()
.newBuilder()
.addQueryParameter("name", fileName)
.build()
val request = Request.Builder()
.url(url)
.post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE))
.build()
return decode(execute(request))
}
/** Cancel a structured session's active ACP turn. */
suspend fun cancelStructuredTurn(id: Uuid) {
executeNoContent(newRequest("POST", Routes.structuredCancel(id)))
}
/** Change one option advertised by a live ACP session. */
suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) {
executeNoContent(
newRequest(
"POST",
Routes.structuredConfig(id),
body = json.encodeToString(StructuredConfigRequest(configId, value)),
),
)
}
/** Answer a pending ACP permission request. Always resolves once the
* session itself is a live structured session — a nonce that is unknown
* or already consumed still answers success. */
suspend fun resolveStructuredApproval(
id: Uuid,
nonce: String,
decision: StructuredApprovalDecision,
) {
executeNoContent(
newRequest(
"POST",
Routes.structuredApproval(id, nonce),
body = json.encodeToString(decision),
),
)
}
// -- plumbing ------------------------------------------------------
private suspend inline fun <reified T> get(
path: String,
query: List<Pair<String, String>> = emptyList(),
): T = decode(execute(newRequest("GET", path, query = query)))
private suspend inline fun <reified B, reified T> post(path: String, body: B): T =
decode(execute(newRequest("POST", path, body = json.encodeToString(body))))
private suspend inline fun <reified B, reified T> patch(path: String, body: B): T =
decode(execute(newRequest("PATCH", path, body = json.encodeToString(body))))
private fun newRequest(
method: String,
path: String,
query: List<Pair<String, String>> = emptyList(),
body: String? = null,
): Request {
val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder()
for ((key, value) in query) urlBuilder.addQueryParameter(key, value)
// OkHttp refuses a null body on POST/PUT/PATCH even when the daemon
// expects none (e.g. the structured-turn cancel) — an empty body
// satisfies it without implying a JSON payload.
val requestBody = when {
body != null -> body.toRequestBody(JSON_MEDIA_TYPE)
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null)
else -> null
}
return Request.Builder()
.url(urlBuilder.build())
.method(method, requestBody)
.build()
}
private suspend fun execute(request: Request): Response =
suspendCancellableCoroutine { cont ->
val call = httpClient.newCall(request)
cont.invokeOnCancellation { call.cancel() }
call.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (!cont.isCancelled) cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
},
)
}
private suspend fun executeNoContent(request: Request) {
execute(request).use { if (!it.isSuccessful) throw toApiException(it) }
}
private inline fun <reified T> decode(response: Response): T =
response.use {
if (!it.isSuccessful) throw toApiException(it)
json.decodeFromString<T>(it.body?.string().orEmpty())
}
private fun toApiException(response: Response): MaestroApiException {
val text = response.body?.string().orEmpty()
val info = runCatching { json.decodeFromString<ApiError>(text) }.getOrNull()?.error
return MaestroApiException(
status = response.code,
code = info?.code ?: "",
detail = info?.message ?: text,
unsavedWork = info?.unsaved_work,
)
}
}
@@ -0,0 +1,175 @@
package pt.naps.maestro.api
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.ClientControl
import pt.naps.maestro.data.StructuredEvent
import pt.naps.maestro.data.Uuid
private val wsJson = Json {
ignoreUnknownKeys = true
isLenient = true
}
/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded
* [ClientControl] text message. */
sealed class AttachFrame {
data class Pty(val bytes: ByteArray) : AttachFrame()
data class Control(val message: ClientControl) : AttachFrame()
}
/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames
* both directions; control messages ([ClientControl]) travel in text
* frames. */
class AttachConnection internal constructor(
private val socket: WebSocket,
val frames: Flow<AttachFrame>,
) {
/** Type raw bytes into the session's pane, as if typed on an attached
* terminal. */
fun sendInput(bytes: ByteArray) {
socket.send(ByteString.of(*bytes))
}
fun resize(cols: Int, rows: Int) {
sendControl(ClientControl.Resize(cols, rows))
}
fun ping() {
sendControl(ClientControl.Ping)
}
private fun sendControl(message: ClientControl) {
socket.send(wsJson.encodeToString(message))
}
fun close() {
socket.close(1000, null)
}
}
/** A live `WS /api/structured/:id` connection: transcript/state snapshots
* and live deltas for one structured session. */
class StructuredConnection internal constructor(
private val socket: WebSocket,
val events: Flow<StructuredEvent>,
) {
fun close() {
socket.close(1000, null)
}
}
/** A live `WS /api/events` connection: catalog snapshot + session deltas. */
class EventsConnection internal constructor(
private val socket: WebSocket,
val events: Flow<CatalogEvent>,
) {
fun close() {
socket.close(1000, null)
}
}
/**
* WebSocket half of the daemon's `/api` surface. Mirrors the `*_url`
* helpers in `crates/maestro-protocol/src/routes.rs`.
*
* Each `connect*` call opens the socket immediately and returns a handle
* whose `Flow` starts buffering frames right away — a slow collector never
* misses a frame, it only sees it later. Call `close()` on the returned
* connection when done; there is no automatic reconnect.
*/
class MaestroWebSocket(
private val baseUrl: String,
private val httpClient: OkHttpClient = OkHttpClient(),
) {
/** `WS /api/events`: catalog snapshot + session deltas. */
fun connectEvents(): EventsConnection {
val channel = Channel<CatalogEvent>(Channel.BUFFERED)
val socket = open(
Routes.eventsWsUrl(baseUrl),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<CatalogEvent>(text) }
.onSuccess { channel.trySend(it) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return EventsConnection(socket, channel.receiveAsFlow())
}
/** `WS /api/attach/:id`: raw PTY bytes both directions, control messages
* as text JSON. */
fun connectAttach(id: Uuid): AttachConnection {
val channel = Channel<AttachFrame>(Channel.BUFFERED)
val socket = open(
Routes.attachWsUrl(baseUrl, id),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<ClientControl>(text) }
.onSuccess { channel.trySend(AttachFrame.Control(it)) }
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
channel.trySend(AttachFrame.Pty(bytes.toByteArray()))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return AttachConnection(socket, channel.receiveAsFlow())
}
/** `WS /api/structured/:id`: transcript/state snapshots and live deltas
* for one structured session. */
fun connectStructured(id: Uuid): StructuredConnection {
val channel = Channel<StructuredEvent>(Channel.BUFFERED)
val socket = open(
Routes.structuredWsUrl(baseUrl, id),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<StructuredEvent>(text) }
.onSuccess { channel.trySend(it) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return StructuredConnection(socket, channel.receiveAsFlow())
}
private fun open(url: String, listener: WebSocketListener): WebSocket {
val request = Request.Builder().url(url).build()
return httpClient.newWebSocket(request, listener)
}
}
@@ -0,0 +1,84 @@
package pt.naps.maestro.api
import pt.naps.maestro.data.Uuid
/** The daemon's `/api` surface: paths, and the scheme swap needed to reach
* the WebSocket endpoints. Mirrors `maestro-protocol::routes` /
* `web/src/routes.ts`. */
object Routes {
const val API_PREFIX = "/api"
const val SESSIONS = "/api/sessions"
const val INFO = "/api/info"
const val DIRS = "/api/dirs"
const val RECENT_DIRS = "/api/recent-dirs"
const val PROJECTS = "/api/projects"
const val GITROOT = "/api/gitroot"
const val NAMES = "/api/names"
const val BRANCHES = "/api/branches"
const val EVENTS = "/api/events"
/** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */
const val PATH_QUERY = "path"
/** Name of the query parameter `DELETE /api/sessions/:id` takes:
* `force=true` overrides the kill refusal over a guarded worktree. */
const val FORCE_QUERY = "force"
/** Name of the query parameter [NAMES] takes. */
const val COUNT_QUERY = "count"
fun session(id: Uuid): String = "$SESSIONS/$id"
fun sessionFocus(id: Uuid): String = "${session(id)}/focus"
fun attach(id: Uuid): String = "$API_PREFIX/attach/$id"
/** `WS /api/structured/:id` — transcript/state snapshots and live
* deltas. */
fun structured(id: Uuid): String = "$API_PREFIX/structured/$id"
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt"
fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments"
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel"
/** `POST /api/sessions/:id/structured/config` — change an ACP session
* option. */
fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config"
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a
* permission request. */
fun structuredApproval(id: Uuid, nonce: String): String =
"${session(id)}/structured/approvals/$nonce"
/** Strip a trailing slash so `{base}{path}` never doubles up. */
fun normalizeBase(base: String): String = base.trimEnd('/')
/** Join a daemon base URL and one of this object's paths. */
fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path"
/** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes
* `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes
* through. */
fun wsBase(httpBase: String): String {
val base = normalizeBase(httpBase)
return when {
base.startsWith("https://") -> "wss://${base.removePrefix("https://")}"
base.startsWith("http://") -> "ws://${base.removePrefix("http://")}"
base.startsWith("wss://") || base.startsWith("ws://") -> base
else -> throw IllegalArgumentException(
"daemon url has no supported scheme (want http/https/ws/wss): $httpBase",
)
}
}
fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS"
fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}"
fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}"
}
@@ -0,0 +1,32 @@
package pt.naps.maestro.connection
/**
* Exponential backoff for daemon reconnect attempts: doubles from
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
* change than the LAN blips the web/TUI clients ride out with a flat poll,
* so this backs off instead of hammering the daemon while nothing changed.
*/
class Backoff(
private val initialMs: Long = INITIAL_MS,
private val maxMs: Long = MAX_MS,
) {
private var currentMs = initialMs
/** Delay before the next attempt; advances the sequence for the one after. */
fun nextDelayMs(): Long {
val delay = currentMs
currentMs = (currentMs * 2).coerceAtMost(maxMs)
return delay
}
/** Back to [initialMs] after a successful connection. */
fun reset() {
currentMs = initialMs
}
companion object {
const val INITIAL_MS = 500L
const val MAX_MS = 10_000L
}
}
@@ -0,0 +1,49 @@
package pt.naps.maestro.connection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.Uuid
/**
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
* replaces the map outright; deltas mutate one entry.
*/
class CatalogStore {
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
* the caller re-fetches a full snapshot over REST and calls [replace],
* mirroring how the web client reacts to a dropped broadcast frame. */
fun apply(event: CatalogEvent): Long? =
when (event) {
is CatalogEvent.Snapshot -> {
_sessions.value = event.sessions.associateBy { it.id }
null
}
is CatalogEvent.SessionAdded -> {
_sessions.value = _sessions.value + (event.session.id to event.session)
null
}
is CatalogEvent.SessionUpdated -> {
_sessions.value = _sessions.value + (event.session.id to event.session)
null
}
is CatalogEvent.SessionRemoved -> {
_sessions.value = _sessions.value - event.id
null
}
is CatalogEvent.Lagged -> event.skipped
}
fun replace(sessions: List<Session>) {
_sessions.value = sessions.associateBy { it.id }
}
fun clear() {
_sessions.value = emptyMap()
}
}
@@ -0,0 +1,51 @@
package pt.naps.maestro.connection
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
/**
* Notifies [onAvailable] whenever a usable network appears — including the
* wifi/cellular handoff, which Android reports as one network disappearing
* and another becoming available rather than a single "changed" event.
* [DaemonLink] treats every call as "worth reconnecting for" rather than
* inspecting which network it is.
*/
class ConnectivityObserver(
context: Context,
private val onAvailable: () -> Unit,
) {
private val connectivityManager =
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable.invoke()
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
) {
onAvailable.invoke()
}
}
}
private var registered = false
fun start() {
if (registered) return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
registered = true
}
fun stop() {
if (!registered) return
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
registered = false
}
}
@@ -0,0 +1,37 @@
package pt.naps.maestro.connection
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
/** What happened between two `/api/info` replies. */
enum class RestartKind { SAME, RESTARTED, UPGRADED }
/**
* Compare two daemon replies. Mirrors `compareIdentity` in
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
* `crates/maestro-tui/src/daemon_link.rs`.
*
* The commit decides first: a different one is new server code. `instance`
* catches everything else — a restart of the same build — and is the only
* signal for a daemon built outside a git checkout, where the commit is
* always empty and an empty-vs-empty pair must never read as an upgrade.
*/
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
val a = before.build.commit
val b = after.build.commit
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
return RestartKind.RESTARTED
}
return RestartKind.SAME
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
fun formatRelease(build: BuildInfo): String {
if (build.commit.isEmpty()) return "unknown release"
val parts = mutableListOf(build.commit)
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
if (build.dirty) parts.add("(dirty)")
return parts.joinToString(" ")
}
@@ -0,0 +1,287 @@
package pt.naps.maestro.connection
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import pt.naps.maestro.api.EventsConnection
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.api.MaestroWebSocket
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
private const val GRACE_MS = 1_200L
/**
* Owns the connection to one daemon for the app's whole life: liveness,
* identity, and the session catalog. A Kotlin port of the state machine in
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
* — see `docs/RESTART.md` for the contract both mirror — with exponential
* [Backoff] standing in for their flat poll, since a phone's network changes
* are bigger and longer-lived than the LAN blips those clients ride out.
*
* Everything that can change the link — a probe landing, the events socket
* dropping, the app resuming, the network coming back, a new daemon URL —
* goes through one [signals] channel consumed by one coroutine, so state
* transitions never race each other.
*/
class DaemonLink(
context: Context,
private val prefs: DaemonPrefs,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val httpClient: OkHttpClient = OkHttpClient(),
) : DefaultLifecycleObserver {
private val appContext = context.applicationContext
private val _state = MutableStateFlow(LinkState.CONNECTING)
val state: StateFlow<LinkState> = _state.asStateFlow()
private val _build = MutableStateFlow<BuildInfo?>(null)
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
private val _attempts = MutableStateFlow(0)
val attempts: StateFlow<Int> = _attempts.asStateFlow()
val catalog = CatalogStore()
private val backoff = Backoff()
private val signals = Channel<Signal>(Channel.UNLIMITED)
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
private var generation = 0
private var currentBaseUrl: String = prefs.baseUrl.value
private var client = MaestroClient(currentBaseUrl, httpClient)
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
private var identity: DaemonInfo? = null
private var eventsJob: Job? = null
private var eventsConnection: EventsConnection? = null
private var retryJob: Job? = null
private var graceJob: Job? = null
private var loopJob: Job? = null
private var started = false
private sealed class Signal {
object Start : Signal()
object Resume : Signal()
object NetworkAvailable : Signal()
object SocketDropped : Signal()
data class BaseUrlChanged(val url: String) : Signal()
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
data class Lagged(val skipped: Long) : Signal()
}
/** Starts the link: subscribes to process lifecycle and connectivity, and
* kicks off the first probe. Call once. */
fun start() {
if (started) return
started = true
loopJob = scope.launch { runLoop() }
connectivity.start()
scope.launch(Dispatchers.Main.immediate) {
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
}
scope.launch {
prefs.baseUrl.collect { url ->
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
}
}
signals.trySend(Signal.Start)
}
/** Tears everything down. Only needed by tests and by an owner that is
* itself going away for good (this link is meant to outlive an Activity). */
fun close() {
connectivity.stop()
cancelRetry()
cancelGrace()
closeEvents()
loopJob?.cancel()
signals.close()
scope.launch(Dispatchers.Main.immediate) {
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
}
}
override fun onStart(owner: LifecycleOwner) {
signals.trySend(Signal.Resume)
}
private suspend fun runLoop() {
for (signal in signals) {
when (signal) {
is Signal.Start -> probeNow()
is Signal.Resume -> {
probeNow()
ensureEventsConnected()
}
is Signal.NetworkAvailable -> {
cancelRetry()
probeNow()
}
is Signal.SocketDropped -> handleSocketDropped()
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
is Signal.ProbeResult -> {
if (signal.generation == generation) handleProbeResult(signal.info)
}
is Signal.Lagged -> handleLagged()
}
}
}
private fun probeNow() {
val gen = generation
val currentClient = client
scope.launch {
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
signals.trySend(Signal.ProbeResult(gen, info))
}
}
private fun handleProbeResult(info: DaemonInfo?) {
if (info == null) {
onProbeFailed()
return
}
val before = identity
identity = info
_build.value = info.build
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
_state.value = LinkState.UPDATING
fullReconnect()
return
}
backoff.reset()
cancelRetry()
cancelGrace()
_attempts.value = 0
_state.value = LinkState.ONLINE
ensureEventsConnected()
}
private fun onProbeFailed() {
_attempts.value += 1
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
_state.value = LinkState.RECONNECTING
armGrace()
}
scheduleRetry()
}
private fun armGrace() {
if (graceJob?.isActive == true) return
graceJob = scope.launch {
delay(GRACE_MS)
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
}
}
private fun cancelGrace() {
graceJob?.cancel()
graceJob = null
}
private fun scheduleRetry() {
if (retryJob?.isActive == true) return
val delayMs = backoff.nextDelayMs()
retryJob = scope.launch {
delay(delayMs)
retryJob = null
probeNow()
}
}
private fun cancelRetry() {
retryJob?.cancel()
retryJob = null
backoff.reset()
}
/** A build-stamp change: drop the events socket and the catalog it fed,
* then probe again shortly. The re-probe sees the same identity it just
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
* the socket — this *is* the full reconnect. */
private fun fullReconnect() {
closeEvents()
catalog.clear()
backoff.reset()
scope.launch {
delay(200)
probeNow()
}
}
private fun handleSocketDropped() {
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
_state.value = LinkState.RECONNECTING
armGrace()
probeNow()
}
private fun handleBaseUrlChanged(url: String) {
currentBaseUrl = url
generation += 1
client = MaestroClient(url, httpClient)
socket = MaestroWebSocket(url, httpClient)
identity = null
_build.value = null
_attempts.value = 0
backoff.reset()
cancelRetry()
cancelGrace()
closeEvents()
catalog.clear()
_state.value = LinkState.CONNECTING
probeNow()
}
/** The broadcast channel dropped frames: re-fetch the full list over REST
* rather than trusting the map to still be complete. */
private fun handleLagged() {
val currentClient = client
scope.launch {
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
}
}
private fun ensureEventsConnected() {
if (eventsJob?.isActive == true) return
val connection = socket.connectEvents()
eventsConnection = connection
eventsJob = scope.launch {
try {
connection.events.collect { event ->
val skipped = catalog.apply(event)
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
}
signals.trySend(Signal.SocketDropped)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
signals.trySend(Signal.SocketDropped)
}
}
}
private fun closeEvents() {
eventsJob?.cancel()
eventsJob = null
eventsConnection?.close()
eventsConnection = null
}
}
@@ -0,0 +1,40 @@
package pt.naps.maestro.connection
import android.content.Context
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Where the daemon base URL lives between launches. A settings screen and
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
* reads [baseUrl] and reconnects on change.
*/
class DaemonPrefs(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private val _baseUrl =
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
fun setBaseUrl(url: String) {
val trimmed = url.trim()
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
prefs.edit { putString(KEY_BASE_URL, trimmed) }
_baseUrl.value = trimmed
}
companion object {
private const val FILE = "daemon_prefs"
private const val KEY_BASE_URL = "base_url"
/** Shown until the user points the app at their own daemon. */
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
/** Intent extra a launcher may set to point this launch at a specific
* daemon without going through settings. Persisted on read, so it
* sticks after the launch that carried it. */
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
}
}
@@ -0,0 +1,14 @@
package pt.naps.maestro.connection
/**
* Connection lifecycle to the daemon. Mirrors the states in
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
*
* - [CONNECTING] — first probe hasn't landed yet.
* - [ONLINE] — the daemon answered and identity is settled.
* - [RECONNECTING] — the link dropped, still inside the grace window.
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
*/
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
@@ -0,0 +1,43 @@
package pt.naps.maestro.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Session activity, orthogonal to [SessionStatus]. Mirrors
* `maestro_protocol::ActivityState`.
*
* Deserializes leniently: an unknown variant from a newer daemon decodes as
* [Idle] instead of failing the whole [Session].
*/
@Serializable(with = ActivityStateSerializer::class)
enum class ActivityState {
Active,
Idle,
AwaitingInput,
Error,
Starting,
}
object ActivityStateSerializer : KSerializer<ActivityState> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: ActivityState) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): ActivityState =
when (decoder.decodeString()) {
"Active" -> ActivityState.Active
"AwaitingInput" -> ActivityState.AwaitingInput
"Error" -> ActivityState.Error
"Starting" -> ActivityState.Starting
else -> ActivityState.Idle
}
}
@@ -0,0 +1,19 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** Structured failure body inside an [ApiError] envelope. Mirrors
* `maestro_protocol::ErrorInfo`. */
@Serializable
data class ErrorInfo(
val code: String,
val message: String,
/** Set only by the kill guard: what blocked an unforced
* `DELETE /api/sessions/:id`. */
val unsaved_work: InTheWay? = null,
)
/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`.
* Mirrors `maestro_protocol::ApiError`. */
@Serializable
data class ApiError(val error: ErrorInfo)
@@ -0,0 +1,17 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** One local branch of a repository, from `GET /api/branches`. A client
* disables the entry when [is_head] is set or [worktree] is present — git
* refuses to check the same branch out in two worktrees at once. Mirrors
* `maestro_protocol::BranchInfo`. */
@Serializable
data class BranchInfo(
/** Short branch name (`main`, `feature/foo`). */
val name: String,
/** Whether this branch is the main checkout's current HEAD. */
val is_head: Boolean,
/** Toplevel path of the linked worktree holding this branch, if any. */
val worktree: String? = null,
)
@@ -0,0 +1,37 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors
* `maestro_protocol::CatalogEvent`; tagged by a `type` field.
*
* A subscriber receives a single [Snapshot] immediately on connect, then a
* stream of deltas as sessions are created, updated, and killed. Skipped
* events (broadcast channel lag) surface as [Lagged].
*/
@Serializable
sealed class CatalogEvent {
@Serializable
@SerialName("Snapshot")
data class Snapshot(val sessions: List<Session>) : CatalogEvent()
@Serializable
@SerialName("SessionAdded")
data class SessionAdded(val session: Session) : CatalogEvent()
@Serializable
@SerialName("SessionRemoved")
data class SessionRemoved(val id: Uuid) : CatalogEvent()
@Serializable
@SerialName("SessionUpdated")
data class SessionUpdated(val session: Session) : CatalogEvent()
/** Server-issued notification that the broadcast queue dropped frames.
* Clients should re-fetch a full snapshot. */
@Serializable
@SerialName("Lagged")
data class Lagged(val skipped: Long) : CatalogEvent()
}
@@ -0,0 +1,30 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY
* bytes travel in binary frames and are not modeled here. Mirrors
* `maestro_protocol::ClientControl`; tagged by a `type` field. */
@Serializable
sealed class ClientControl {
@Serializable
@SerialName("Resize")
data class Resize(val cols: Int, val rows: Int) : ClientControl()
@Serializable
@SerialName("Ping")
object Ping : ClientControl()
@Serializable
@SerialName("Pong")
object Pong : ClientControl()
@Serializable
@SerialName("Error")
data class Error(
val code: String,
val message: String,
val unsaved_work: InTheWay? = null,
) : ClientControl()
}
@@ -0,0 +1,41 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors
* `maestro_protocol::DaemonInfo`. */
@Serializable
data class DaemonInfo(
val hostname: String = "",
/** The protocol version the daemon implements. */
val version: String = "",
/** Home dir of the user the daemon runs as; empty for older daemons. */
val home_dir: String = "",
val presets: List<SpawnPreset> = emptyList(),
val build: BuildInfo = BuildInfo(),
/** Identity of the daemon *process*, regenerated on every start — a
* changed value across a reconnect means the daemon restarted. */
val instance: String = "",
/** Canonical leader key the operator configured (`[tui].leader`), so a
* client binds the same chord the TUI does. Empty means "use your
* default". */
val leader: String = "",
)
/** A spawn-form command preset advertised by the daemon. */
@Serializable
data class SpawnPreset(
val id: String,
val label: String,
val command: String,
val auto_approve: Boolean = false,
)
/** Build provenance of the running daemon. Every field is empty when the
* binary was built outside a git checkout. */
@Serializable
data class BuildInfo(
val commit: String = "",
val commit_date: String = "",
val dirty: Boolean = false,
)
@@ -0,0 +1,33 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors
* `maestro_protocol::DirListing`. */
@Serializable
data class DirListing(
/** Resolved absolute directory that was listed (after ~/relative
* expansion). */
val path: String,
/** Immediate subdirectory names, sorted case-insensitively. */
val entries: List<String> = emptyList(),
/** True when the listing was capped server-side. */
val truncated: Boolean = false,
)
/** One deduplicated recent working dir for the spawn form's recents picker.
* Mirrors `maestro_protocol::RecentDir`. */
@Serializable
data class RecentDir(
val cwd: String,
val last_command: String,
val last_used: IsoTimestamp,
)
/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first
* and deduped by `cwd`. */
@Serializable
data class RecentDirs(
val dirs: List<RecentDir> = emptyList(),
val truncated: Boolean = false,
)
@@ -0,0 +1,9 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or
* `null` when the path isn't inside a repo. Mirrors
* `maestro_protocol::GitRoot`. */
@Serializable
data class GitRoot(val root: String? = null)
@@ -0,0 +1,7 @@
package pt.naps.maestro.data
/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */
typealias Uuid = String
/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime<Utc>`. */
typealias IsoTimestamp = String
@@ -0,0 +1,17 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** What currently blocks an unforced kill of a session's worktree,
* recomputed by the daemon on every read. Mirrors
* `maestro_protocol::InTheWay`. */
@Serializable
data class InTheWay(
val uncommitted_files: Int = 0,
val untracked_files: Int = 0,
val unpushed_commits: Int = 0,
/** Lines added across the tracked changes, staged and unstaged together. */
val insertions: Int = 0,
/** Lines removed across the same tracked changes. */
val deletions: Int = 0,
)
@@ -0,0 +1,8 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors
* `maestro_protocol::NameBatch`. */
@Serializable
data class NameBatch(val names: List<String>)
@@ -0,0 +1,40 @@
package pt.naps.maestro.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Where a session's stored name (`metadata["name"]`) came from. Mirrors
* `maestro_protocol::NameSource`.
*
* Deserializes leniently: an unknown spelling decodes as [User], the settled
* variant — a name wrongly painted as settled is only a missed dimming; one
* wrongly painted as provisional invites overwriting a name a person chose.
*/
@Serializable(with = NameSourceSerializer::class)
enum class NameSource {
User,
Generated,
Haiku,
}
object NameSourceSerializer : KSerializer<NameSource> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: NameSource) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): NameSource =
when (decoder.decodeString()) {
"Generated" -> NameSource.Generated
"Haiku" -> NameSource.Haiku
else -> NameSource.User
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** One discovered git repository (or linked worktree) for `GET
* /api/projects`. Mirrors `maestro_protocol::Project`. */
@Serializable
data class Project(
/** Absolute path of the repository/worktree toplevel. */
val path: String,
/** Basename of [path]. */
val name: String,
val is_worktree: Boolean = false,
/** The main checkout this worktree belongs to; `null` on plain repos. */
val base_repo: String? = null,
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
* any directory inside it; `null` when neither is known. */
val last_activity: IsoTimestamp? = null,
/** Command last spawned in this project, from spawn history. */
val last_command: String? = null,
)
@@ -0,0 +1,71 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Default for [Session.last_activity_at] / [Session.activity_since] when an
* older payload omits them — mirrors the daemon's Unix-epoch default. */
const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z"
/** A live or recently-dead tmux session managed by the daemon. Mirrors
* `maestro_protocol::Session`. */
@Serializable
data class Session(
val id: Uuid,
val cwd: String,
val command: String,
val env: Map<String, String> = emptyMap(),
val metadata: Map<String, JsonElement> = emptyMap(),
/** The worktree this session owns, when it was spawned with one. */
val worktree: SessionWorktree? = null,
val status: SessionStatus,
val created_at: IsoTimestamp,
/** Current foreground command in the pane (e.g. `claude`, `bash`), as the
* daemon's activity watcher observed it. `null` until observed. */
val foreground: String? = null,
/** Inferred activity dimension, orthogonal to [status]. */
val activity: ActivityState = ActivityState.Idle,
/** Short human string an agent reported alongside its activity — e.g. why
* it is in [ActivityState.Error]. `null` unless an agent reported one. */
val activity_detail: String? = null,
val last_activity_at: IsoTimestamp = EPOCH,
val activity_since: IsoTimestamp = EPOCH,
/** The pane's *current* working directory, distinct from [cwd] (the spawn
* dir). `null` until first observed. */
val current_cwd: String? = null,
/** What currently blocks an unforced kill of this session, as the
* daemon's watcher last probed it. `null` while clear or not yet probed. */
val blocked_work: InTheWay? = null,
/** When this session last became the pane a client was showing. `null`
* until the session has been focused at least once. */
val last_focused_at: IsoTimestamp? = null,
/** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and
* immutable for the session's life. */
val view: SessionView = SessionView.Terminal,
/** The ACP session id the selected provider adapter returned from
* `session/new`. Only a [SessionView.Structured] session whose handshake
* succeeded has one. */
val acp_session_id: String? = null,
/** When `true`, the daemon auto-approves every ACP permission request for
* this session instead of surfacing them to the client. */
val auto_approve: Boolean = false,
/** Prompts queued on the daemon while a turn was in flight or the adapter
* was still booting. */
val queued_prompts: List<String> = emptyList(),
)
@Serializable
enum class SessionStatus { Running, Dead }
/** A session's process shape: mirrors `maestro_protocol::SessionView`.
* `Terminal` is the default. */
@Serializable
enum class SessionView { Terminal, Structured }
/** A session's daemon-created worktree. `base_repo` is the repository's main
* checkout, which is what the session tree groups by. */
@Serializable
data class SessionWorktree(
val path: String,
val base_repo: String,
)
@@ -0,0 +1,43 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** `POST /api/sessions` body. Mirrors
* `maestro_protocol::CreateSessionRequest`. */
@Serializable
data class CreateSessionRequest(
val cwd: String,
val command: String,
val env: Map<String, String> = emptyMap(),
val metadata: Map<String, JsonElement> = emptyMap(),
val worktree: WorktreeSpec? = null,
/** The process shape to spawn. Defaults to a `Terminal` session. */
val view: SessionView = SessionView.Terminal,
/** When `true`, the daemon auto-approves every ACP permission request for
* this structured session. */
val auto_approve: Boolean = false,
)
/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors
* `maestro_protocol::WorktreeSpec`. */
@Serializable
data class WorktreeSpec(
val base_repo: String,
val branch: String,
/** When `false`, [branch] already exists and is checked out as-is.
* Defaults to `true` (create a new branch). */
val create_branch: Boolean = true,
)
/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name]
* clears the custom one, so the card falls back to the command. Mirrors
* `maestro_protocol::RenameSessionRequest`. */
@Serializable
data class RenameSessionRequest(val name: String)
/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is
* the one currently showing this session. Mirrors
* `maestro_protocol::SetFocusRequest`. */
@Serializable
data class SetFocusRequest(val focused: Boolean)
@@ -0,0 +1,136 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** The semantic shape of a [TranscriptRow]. Mirrors
* `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
@Serializable
enum class TranscriptRowKind {
@SerialName("user") User,
@SerialName("agent_text") AgentText,
@SerialName("agent_thought") AgentThought,
@SerialName("tool_call") ToolCall,
@SerialName("tool_diff") ToolDiff,
@SerialName("approval") Approval,
@SerialName("plan") Plan,
@SerialName("notice") Notice,
}
/** One client-neutral item in a structured session transcript. [data]
* preserves the ACP payload verbatim without committing to an ACP
* dependency. Mirrors `maestro_protocol::TranscriptRow`. */
@Serializable
data class TranscriptRow(
/** Stable within one session, so later deltas can address this row. */
val id: String,
val kind: TranscriptRowKind,
val data: JsonElement,
)
/** The complete transcript at a durable event-log position — a structured
* session's first `WS /api/structured/:id` message. Mirrors
* `maestro_protocol::TranscriptSnapshot`. */
@Serializable
data class TranscriptSnapshot(
val cursor: Long,
val rows: List<TranscriptRow> = emptyList(),
)
/** A change to one transcript row, addressed by [id]. Mirrors
* `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */
@Serializable
sealed class TranscriptDelta {
@Serializable
@SerialName("append")
data class Append(val row: TranscriptRow) : TranscriptDelta()
@Serializable
@SerialName("patch")
data class Patch(val row: TranscriptRow) : TranscriptDelta()
@Serializable
@SerialName("remove")
data class Remove(val id: String) : TranscriptDelta()
}
/** Per-turn indicators derived from the ACP stream — read verbatim, never
* re-derived client-side. Mirrors `maestro_protocol::TurnState`. */
@Serializable
data class TurnState(
val responding: Boolean = false,
val thinking: Boolean = false,
val active_tool_calls: List<String> = emptyList(),
/** Wall-clock start of the active prompt, so a reconnecting client keeps
* one continuous elapsed-time display instead of restarting at zero. */
val started_at: IsoTimestamp? = null,
val started_context_tokens: Long? = null,
)
/**
* A whole-session view derived from ACP notifications. Mirrors
* `maestro_protocol::ReducedState`.
*
* [pending_approval], [plan], [usage], [context] and [config_options] carry
* opaque ACP JSON — their shape is ACP's, not covered by this crate's own
* compatibility guarantee, so they are kept as [JsonElement] rather than
* modeled strongly.
*/
@Serializable
data class ReducedState(
val turn: TurnState = TurnState(),
val pending_approval: JsonElement? = null,
val plan: JsonElement? = null,
val usage: JsonElement? = null,
val context: JsonElement? = null,
val model: String? = null,
val mode: String? = null,
val config_options: JsonElement? = null,
/** Prompts queued on the daemon while a turn is in flight. */
val queued_prompts: List<String> = emptyList(),
)
/** Server messages on `WS /api/structured/:id`. Mirrors
* `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */
@Serializable
sealed class StructuredEvent {
@Serializable
@SerialName("transcript_snapshot")
data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent()
@Serializable
@SerialName("reduced_state")
data class ReducedStateEvent(val state: ReducedState) : StructuredEvent()
@Serializable
@SerialName("projection_update")
data class ProjectionUpdate(
val transcript: TranscriptDelta? = null,
val state: ReducedState,
) : StructuredEvent()
}
/** `POST /api/sessions/:id/structured/prompt` body. */
@Serializable
data class StructuredPromptRequest(val prompt: String)
/** `POST /api/sessions/:id/structured/config` body: change one option
* advertised by a live ACP session. */
@Serializable
data class StructuredConfigRequest(val config_id: String, val value: JsonElement)
/** Operator decision for a pending ACP permission request
* (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON
* string on the wire, not `{decision: ...}`. Mirrors
* `maestro_protocol::StructuredApprovalDecision`. */
@Serializable
enum class StructuredApprovalDecision {
@SerialName("allow_once") AllowOnce,
@SerialName("allow_for_session") AllowForSession,
@SerialName("deny") Deny,
}
/** Response of `POST /api/sessions/:id/structured/attachments`. */
@Serializable
data class StructuredAttachmentResponse(val path: String)
@@ -0,0 +1,11 @@
package pt.naps.maestro.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,46 @@
package pt.naps.maestro.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
)
@Composable
fun MaestroTheme(
darkTheme: Boolean = true,
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val useDarkTheme = darkTheme || isSystemInDarkTheme()
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
useDarkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,17 @@
package pt.naps.maestro.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
)
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1B1B1B"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Maestro</string>
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,11 @@
package pt.naps.maestro
import org.junit.Assert.assertEquals
import org.junit.Test
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Test
class BackoffTest {
@Test
fun `doubles from 500ms and caps at 10s`() {
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
val delays = List(8) { backoff.nextDelayMs() }
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
}
@Test
fun `reset returns to the initial delay`() {
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
repeat(4) { backoff.nextDelayMs() }
backoff.reset()
assertEquals(500L, backoff.nextDelayMs())
}
}
@@ -0,0 +1,60 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
class CatalogStoreTest {
private fun session(id: String) = Session(
id = id,
cwd = "/tmp",
command = "bash",
status = SessionStatus.Running,
created_at = "2026-05-24T12:00:00Z",
)
@Test
fun `snapshot replaces the map keyed by id`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
assertEquals(setOf("a", "b"), store.sessions.value.keys)
}
@Test
fun `added and updated upsert one entry`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(emptyList()))
store.apply(CatalogEvent.SessionAdded(session("a")))
assertEquals(1, store.sessions.value.size)
val renamed = session("a").copy(command = "zsh")
store.apply(CatalogEvent.SessionUpdated(renamed))
assertEquals("zsh", store.sessions.value.getValue("a").command)
}
@Test
fun `removed drops the entry`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
store.apply(CatalogEvent.SessionRemoved("a"))
assertEquals(setOf("b"), store.sessions.value.keys)
}
@Test
fun `lagged reports the skip count without touching the map`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
val skipped = store.apply(CatalogEvent.Lagged(3))
assertEquals(3L, skipped)
assertEquals(setOf("a"), store.sessions.value.keys)
}
@Test
fun `non-lagged events report no skip`() {
val store = CatalogStore()
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
}
}
@@ -0,0 +1,55 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Test
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
class DaemonIdentityTest {
private fun info(commit: String = "", instance: String = "") =
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
@Test
fun `same commit and instance is Same`() {
val a = info(commit = "abc123", instance = "i1")
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
}
@Test
fun `different commit is Upgraded`() {
val before = info(commit = "abc123", instance = "i1")
val after = info(commit = "def456", instance = "i2")
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
}
@Test
fun `same commit but different instance is Restarted`() {
val before = info(commit = "abc123", instance = "i1")
val after = info(commit = "abc123", instance = "i2")
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
}
@Test
fun `empty commits on both sides never read as an upgrade`() {
val before = info(commit = "", instance = "i1")
val after = info(commit = "", instance = "i2")
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
}
@Test
fun `format release names the commit and short date`() {
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
}
@Test
fun `dirty build is flagged`() {
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
}
@Test
fun `empty commit is unknown release`() {
assertEquals("unknown release", formatRelease(BuildInfo()))
}
}
@@ -0,0 +1,114 @@
package pt.naps.maestro.data
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Wire-format checks against fixtures mirrored from
* `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type
* that drifts from the Rust source of truth fails here first. */
class WireSerializationTest {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private fun legacySessionJson(extra: String = "") = """
{"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash",
"status":"Running","created_at":"2026-05-24T12:00:00Z"$extra}
""".trimIndent()
@Test
fun `legacy session payload defaults new fields`() {
val s = json.decodeFromString<Session>(legacySessionJson())
assertEquals(ActivityState.Idle, s.activity)
assertEquals(SessionView.Terminal, s.view)
assertNull(s.foreground)
assertNull(s.blocked_work)
assertEquals(EPOCH, s.last_activity_at)
}
@Test
fun `unknown activity state degrades to Idle`() {
val s = json.decodeFromString<Session>(legacySessionJson(""","activity":"QuantumEntangled""""))
assertEquals(ActivityState.Idle, s.activity)
}
@Test
fun `unknown name source degrades to User`() {
assertEquals(NameSource.User, json.decodeFromString<NameSource>("\"Rolled\""))
assertEquals(NameSource.Haiku, json.decodeFromString<NameSource>("\"Haiku\""))
}
@Test
fun `catalog event tags by type`() {
assertEquals(
"""{"type":"Lagged","skipped":3}""",
json.encodeToString<CatalogEvent>(CatalogEvent.Lagged(3)),
)
val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001")
assertEquals(removed, json.decodeFromString<CatalogEvent>(json.encodeToString<CatalogEvent>(removed)))
}
@Test
fun `client control tags by type`() {
assertEquals(
"""{"type":"Resize","cols":120,"rows":40}""",
json.encodeToString<ClientControl>(ClientControl.Resize(120, 40)),
)
assertEquals(ClientControl.Ping, json.decodeFromString<ClientControl>("""{"type":"Ping"}"""))
}
@Test
fun `structured approval decision is a bare string`() {
assertEquals(
StructuredApprovalDecision.AllowOnce,
json.decodeFromString<StructuredApprovalDecision>("\"allow_once\""),
)
assertEquals(
"\"allow_for_session\"",
json.encodeToString(StructuredApprovalDecision.AllowForSession),
)
}
@Test
fun `structured event tags snake_case`() {
val event = json.decodeFromString<StructuredEvent>(
"""{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""",
)
assertTrue(event is StructuredEvent.TranscriptSnapshotEvent)
assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor)
}
@Test
fun `transcript delta tags snake_case and round trips`() {
val row = TranscriptRow(
id = "tool:read-1",
kind = TranscriptRowKind.ToolCall,
data = json.parseToJsonElement("""{"name":"vendor.read"}"""),
)
val patch = TranscriptDelta.Patch(row)
val encoded = json.encodeToString<TranscriptDelta>(patch)
assertTrue(encoded.startsWith("""{"type":"patch","row":"""))
assertEquals(patch, json.decodeFromString<TranscriptDelta>(encoded))
assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought))
}
@Test
fun `rename request encodes plainly`() {
assertEquals(
"""{"name":"amber-falcon"}""",
json.encodeToString(RenameSessionRequest("amber-falcon")),
)
}
@Test
fun `api error decodes nested unsaved work with defaults`() {
val apiError = json.decodeFromString<ApiError>(
"""{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""",
)
assertEquals(3, apiError.error.unsaved_work?.uncommitted_files)
assertEquals(0, apiError.error.unsaved_work?.unpushed_commits)
}
}
+6
View File
@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.useAndroidX=true
kotlin.code.style=official
+38
View File
@@ -0,0 +1,38 @@
[versions]
agp = "8.5.2"
kotlin = "2.0.21"
coreKtx = "1.13.1"
lifecycleRuntimeKtx = "2.8.6"
activityCompose = "1.9.2"
composeBom = "2024.09.03"
navigationCompose = "2.8.2"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.7.3"
kotlinxCoroutines = "1.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
junit = { group = "junit", name = "junit", version = "4.13.2" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
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
Vendored Executable
+237
View File
@@ -0,0 +1,237 @@
#!/bin/sh
#
# Copyright © 2015-2021 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.
#
##############################################################################
#
# 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
# issues, 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 via the Gradle "wrapper" task
# (see https://docs.gradle.org/current/userguide/gradle_wrapper.html)
# 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##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || 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
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
# 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
which java >/dev/null 2>&1 || 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
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
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" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
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
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments that
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# 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~[^-A-Za-z0-9_+.]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@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
@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 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
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" -Dorg.gradle.appname=%APP_BASE_NAME% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
: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
+19
View File
@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Maestro"
include(":app")
+2
View File
@@ -47,6 +47,7 @@ import {
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
requestCloseDrawer,
requestOpenSpawn,
requestToggleDrawer,
} from "./uiEvents.js";
@@ -182,6 +183,7 @@ function Shell() {
(session: Session, opts?: { newTab?: boolean }) => {
layoutStore.getState().openSessionBeside(session.id, opts);
navigate({ kind: "attach", sessionId: session.id });
requestCloseDrawer();
},
[layoutStore, navigate],
);
+14 -1
View File
@@ -2,7 +2,7 @@
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../uiEvents.js";
import { CLOSE_DRAWER_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../uiEvents.js";
import { TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
import { Drawer } from "./Drawer.js";
@@ -103,6 +103,19 @@ describe("Drawer (#35)", () => {
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes on the close-drawer event (after spawn lands)", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes on a backdrop click", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
+3 -1
View File
@@ -10,7 +10,7 @@
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT, TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
import { CLOSE_DRAWER_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT, TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
export interface DrawerProps {
sidebar: ReactNode;
@@ -28,10 +28,12 @@ export function Drawer({ sidebar, children, className }: DrawerProps) {
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.addEventListener(OPEN_SPAWN_EVENT, onClose);
document.addEventListener(SPAWN_SIMILAR_EVENT, onClose);
document.addEventListener(CLOSE_DRAWER_EVENT, onClose);
return () => {
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.removeEventListener(OPEN_SPAWN_EVENT, onClose);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose);
document.removeEventListener(CLOSE_DRAWER_EVENT, onClose);
};
}, []);
+18 -2
View File
@@ -333,10 +333,25 @@ export function Sidebar(props: SidebarProps) {
onCancel={() => setKill(null)}
onConfirm={async () => {
const { session: s, force } = kill;
// Close the dialog immediately and optimistically remove the
// session from the sidebar so the operator isn't staring at a
// spinner while the daemon tears down the tmux session.
setKill(null);
const store = props.store;
if (store) {
const next = new Map(store.getState().sessions);
next.delete(s.id);
store.setState({ sessions: next });
}
try {
await killSession(s.id, force);
setKill(null);
} catch (e: unknown) {
// Restore the session so it reappears in the sidebar.
if (store) {
const restored = new Map(store.getState().sessions);
restored.set(s.id, s);
store.setState({ sessions: restored });
}
// The daemon refuses an unforced kill over a worktree holding
// unsaved work; that refusal needs its own prompt
// (docs/WORKTREES.md).
@@ -349,7 +364,8 @@ export function Sidebar(props: SidebarProps) {
});
return;
}
throw e;
// Nothing else we can usefully show — the session is back in
// the sidebar and the operator can retry.
}
}}
/>
+9
View File
@@ -57,6 +57,15 @@ export function requestToggleDrawer(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
}
/** Close the mobile drawer unconditionally — used after a spawn lands a new
* session so the content pane is visible. Unlike TOGGLE, this is idempotent:
* firing it when the drawer is already closed is a no-op. */
export const CLOSE_DRAWER_EVENT = "maestro-close-drawer";
export function requestCloseDrawer(): void {
document.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
}
/** Toggle the help overlay from a button click (the `?` key is handled by
* HelpOverlay's own command set). */
export const TOGGLE_HELP_EVENT = "maestro-toggle-help";