Compare commits

...
Author SHA1 Message Date
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
Miguel Palhas a9717d01c8 Merge remote-tracking branch 'origin/main' into animato-2
ci / web (push) Failing after 27s
ci / flake (push) Successful in 4m15s
ci / rust (push) Successful in 7m30s
2026-09-12 20:50:52 +01:00
Miguel PalhasandClaude Opus 4.6 35a6ff9d0f fix(maestro): force-replace pane sockets on mobile resume
Mobile browsers report readyState === OPEN after background suspension
even though the OS killed the TCP connection. The per-pane visibility
handlers skipped reconnection in that case, leaving the pane frozen
until TCP keepalive fired (30–120 s on cellular).

Remove the readyState guard and unconditionally replace the socket on
visibility return — same pattern the events socket already uses. Also
let pageshow/online fire recovery without the visibilityState gate,
since some mobile browsers fire pageshow before updating that property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 20:50:48 +01:00
Miguel PalhasandClaude Opus 4.6 474000935a fix(maestro): box AcpUpdate::Notification and fix test unwrapping
ci / web (push) Failing after 33s
ci / flake (push) Successful in 4m42s
ci / rust (pull_request) Failing after 8s
ci / web (pull_request) Failing after 32s
ci / flake (pull_request) Successful in 1m13s
ci / rust (push) Successful in 8m25s
Box the Notification variant to satisfy clippy's large_enum_variant
lint, fix paren mismatches from the boxing pass, and update test
helpers to unwrap AcpUpdate before inspecting the notification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:59:03 +01:00
Miguel PalhasandClaude Opus 4.6 5f8cc2a140 fix(maestro): drain notifications before signaling turn-done
The event pump now handles AcpUpdate::TurnDone markers that arrive
in the same FIFO as notifications.  It fires the done oneshot only
after persisting every preceding notification, so finish_queued_acp_turn
can no longer race ahead of the transcript.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:54:00 +01:00
Miguel PalhasandClaude Opus 4.6 432967fe64 feat(maestro): route turn-done through ACP updates channel
The prompt response callback now sends an AcpUpdate::TurnDone marker
through the same unbounded channel as notifications instead of
signaling the done oneshot directly.  Since notifications and the
prompt response both run in the ACP dispatch loop in wire order,
TurnDone lands after every notification chunk for the turn.

The event pump (next commit) will fire the done oneshot only after
persisting all preceding notifications, closing the race where
finish_queued_acp_turn ran before the transcript was complete.

Closes #259

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:53:18 +01:00
Miguel PalhasandClaude Opus 4.6 2512b8be42 fix(maestro): apply tool-arg truncation fix to styles.css duplicate
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m24s
ci / rust (push) Successful in 7m49s
The previous fix (e3556bd) added `flex: 1 1 0` to `.st-tool-arg` in
`structured.css`, but `styles.css` has a duplicate copy of the same
rules that was left stale. Because both load, the unfixed copy in
`styles.css` can override the fix and re-enable horizontal scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:42:44 +01:00
Miguel PalhasandClaude Opus 4.6 e3556bd847 fix(maestro): truncate long commands in tool call header
ci / web (push) Failing after 30s
ci / flake (push) Successful in 4m5s
ci / rust (push) Successful in 7m30s
Same root cause as the tool-group-title fix — .st-tool-arg
defaulted to flex: 0 1 auto, so long commands pushed the row
past the container. Adding flex: 1 1 0 lets the ellipsis
truncation work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 19:03:06 +01:00
Miguel Palhas ce0e4e6820 Merge remote-tracking branch 'origin/main' into mandolin
ci / web (push) Failing after 45s
ci / flake (push) Successful in 4m17s
ci / rust (push) Successful in 8m4s
# Conflicts:
#	crates/maestro-daemon/src/local_api.rs
#	crates/maestro-daemon/src/session.rs
2026-09-12 17:54:13 +01:00
Miguel PalhasandClaude Opus 4.6 923911f631 feat(maestro): auto-continue ACP sessions after restart
After a graceful daemon restart, recovered structured sessions now
receive a synthetic continue prompt so the agent resumes without
manual intervention. The User row carries `"maestro": {"type":
"restart_continue"}` metadata for future UI differentiation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 11:30:36 +01:00
60 changed files with 2183 additions and 98 deletions
+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
+81
View File
@@ -0,0 +1,81 @@
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.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()
}
}
+24
View File
@@ -0,0 +1,24 @@
<?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="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="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,61 @@
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.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.ui.theme.MaestroTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaestroTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@Composable
fun PlaceholderScreen(modifier: Modifier = Modifier) {
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),
)
}
}
@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,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,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
+37
View File
@@ -0,0 +1,37 @@
[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-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")
+41 -16
View File
@@ -58,6 +58,24 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema;
/// Item on the updates channel. Notifications and turn-done markers share
/// the same FIFO so the event pump processes every notification for a turn
/// before seeing its done signal.
#[derive(Debug)]
pub enum AcpUpdate {
/// A raw `session/update` notification from the adapter.
Notification(Box<SessionNotification>),
/// The prompt response for `turn` arrived. All notifications for this
/// turn precede this marker on the wire, and therefore in this channel.
/// The event pump must fire `done` after persisting every preceding
/// notification.
TurnDone {
turn: u64,
result: Result<(), String>,
done: oneshot::Sender<Result<(), String>>,
},
}
/// ACP adapter selected from the structured session's resolved command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
@@ -354,7 +372,7 @@ pub struct AcpSession {
pid: u32,
commands: mpsc::UnboundedSender<Command>,
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
exit: watch::Receiver<Option<ExitStatus>>,
}
@@ -393,9 +411,9 @@ impl AcpSession {
wait_exit(&self.exit).await
}
/// The stream of raw `session/update` notifications, handed out once.
/// Notifications that arrived before the take are still queued on it.
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
/// The stream of updates (notifications + turn-done markers), handed out
/// once. Items that arrived before the take are still queued.
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<AcpUpdate>> {
self.updates.lock().expect("updates mutex").take()
}
@@ -772,7 +790,7 @@ fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> S
async fn run_connection<T>(
transport: T,
config: ConnectionConfig,
updates_tx: mpsc::UnboundedSender<SessionNotification>,
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
@@ -800,7 +818,7 @@ async fn run_connection<T>(
return Ok(());
}
// A dropped receiver only means nobody wants updates any more.
let _ = received_updates.send(notification);
let _ = received_updates.send(AcpUpdate::Notification(Box::new(notification)));
Ok(())
},
agent_client_protocol::on_receive_notification!(),
@@ -916,18 +934,18 @@ async fn run_connection<T>(
models.as_ref(),
)));
if let Some(modes) = &modes {
let _ = updates_tx.send(SessionNotification::new(
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
SessionId::new(session_id.as_str()),
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
modes.current_mode_id.clone(),
)),
));
))));
}
if !config_options.lock().expect("config options").is_empty() {
let _ = updates_tx.send(config_notification(
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(config_notification(
&session_id,
config_options.lock().expect("config options").clone(),
));
))));
}
let _ = ready_tx.send(Ok(session_id.clone()));
@@ -955,16 +973,23 @@ async fn run_connection<T>(
let done = Arc::new(Mutex::new(Some(done)));
let callback_done = done.clone();
let callback_turn = permission_turn.clone();
let callback_updates = updates_tx.clone();
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
let callback_done = callback_done.clone();
let callback_turn = callback_turn.clone();
let callback_updates = callback_updates.clone();
async move {
callback_turn
.lock()
.expect("permission turn state")
.finish(turn);
// Route through the same channel as notifications so the
// event pump sees this after every chunk for the turn.
let result = result.map(|_| ()).map_err(|e| e.to_string());
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string()));
if callback_updates.send(AcpUpdate::TurnDone { turn, result, done }).is_err() {
// Channel closed — nowhere left to signal.
}
}
Ok(())
}
@@ -998,7 +1023,7 @@ async fn run_connection<T>(
}).on_receiving_result(move |result| async move {
if result.is_ok() {
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
let _ = callback_updates.send(config_notification(&callback_session, options));
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
@@ -1017,13 +1042,13 @@ async fn run_connection<T>(
)).on_receiving_result(move |result| async move {
if result.is_ok() {
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
let _ = callback_updates.send(config_notification(&callback_session, options));
let _ = callback_updates.send(SessionNotification::new(
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
SessionId::new(callback_session.as_str()),
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
callback_value.as_value_id().expect("checked mode value").0.clone(),
)),
));
))));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
@@ -1041,7 +1066,7 @@ async fn run_connection<T>(
let mut current = callback_options.lock().expect("config options");
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
current.extend(response.config_options.clone());
let _ = callback_updates.send(config_notification(&callback_session, current.clone()));
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, current.clone()))));
}
if let Some(done) = callback_done.lock().expect("config sender").take() {
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
+11 -2
View File
@@ -193,6 +193,9 @@ async fn session_updates_reach_the_taken_receiver() {
.await
.expect("update within 5s")
.expect("channel open");
let maestro_acp::AcpUpdate::Notification(first) = first else {
panic!("expected Notification");
};
assert_eq!(first.session_id.0.as_ref(), session.session_id());
match first.update {
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
@@ -221,7 +224,10 @@ async fn advertised_settings_are_normalized_and_mutable() {
.await
.expect("settings update")
.expect("channel open");
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
let maestro_acp::AcpUpdate::Notification(n) = update else {
continue;
};
if let SessionUpdate::ConfigOptionUpdate(options) = n.update {
break options.config_options;
}
};
@@ -390,7 +396,10 @@ async fn drop_terminates_and_reaps_the_child() {
fake.wait_for_exit().await;
}
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
fn chunk_text(update: &maestro_acp::AcpUpdate) -> String {
let maestro_acp::AcpUpdate::Notification(n) = update else {
panic!("expected Notification, got {update:?}");
};
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => t.text.clone(),
+1 -1
View File
@@ -482,7 +482,7 @@ async fn structured_prompt(
Path(id): Path<Uuid>,
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
match s.manager.queue_acp_prompt(id, body.prompt, None).await {
Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
Ok(QueueAcpPrompt::Full) => error_response(
+65 -32
View File
@@ -757,6 +757,7 @@ impl SessionManager {
&self,
id: Uuid,
text: String,
row_metadata: Option<serde_json::Value>,
) -> Result<QueueAcpPrompt, SessionError> {
let (acp, stream) = {
let sessions = self.inner.sessions.lock().await;
@@ -833,12 +834,19 @@ impl SessionManager {
self.persist_prompt_queue(id).await;
return Ok(QueueAcpPrompt::Queued);
}
let mut data = serde_json::json!({
"content": {"type": "text", "text": text},
});
if let Some(extra) = &row_metadata {
if let (Some(base), Some(extra)) = (data.as_object_mut(), extra.as_object())
{
base.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone())));
}
}
let row = TranscriptRow {
id: format!("user:{}", Uuid::new_v4()),
kind: TranscriptRowKind::User,
data: serde_json::json!({
"content": {"type": "text", "text": text},
}),
data,
};
let event = PersistedEvent::User { row: row.clone() };
let seq = match stream.log.append(&event) {
@@ -1631,7 +1639,7 @@ impl SessionManager {
let manager = SessionManager {
inner: inner.clone(),
};
let _ = manager.queue_acp_prompt(id, text).await;
let _ = manager.queue_acp_prompt(id, text, None).await;
}
}
Err(e) => {
@@ -2022,7 +2030,9 @@ impl SessionManager {
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done);
}
// Drain any prompts that were queued before the daemon restarted.
// Drain any prompts that were queued before the daemon
// restarted. If none were queued, send a synthetic continue
// prompt so the agent resumes autonomously.
let queued_text = {
let stream = acp_events
.as_ref()
@@ -2037,7 +2047,15 @@ impl SessionManager {
};
if let Some(text) = queued_text {
self.persist_prompt_queue(id).await;
let _ = self.queue_acp_prompt(id, text).await;
let _ = self.queue_acp_prompt(id, text, None).await;
} else {
let _ = self
.queue_acp_prompt(
id,
"The daemon restarted. Continue where you left off.".into(),
Some(serde_json::json!({"maestro": {"type": "restart_continue"}})),
)
.await;
}
}
let _ = self
@@ -2385,34 +2403,49 @@ fn start_acp_event_pump(
let weak_inner = Arc::downgrade(inner);
let weak_acp = Arc::downgrade(acp);
tokio::spawn(async move {
while let Some(notification) = updates.recv().await {
let append_error = {
let mut events = events.lock().await;
let event = PersistedEvent::Notification(Box::new(notification));
match events.log.append(&event) {
Ok(seq) => {
let logged = LoggedEvent {
seq,
event: event.clone(),
};
let projected = apply_persisted_event(&mut events.projection, &logged);
let _ = events.live.send(logged);
events.broadcast(projected);
None
while let Some(update) = updates.recv().await {
match update {
maestro_acp::AcpUpdate::Notification(notification) => {
let append_error = {
let mut events = events.lock().await;
let event = PersistedEvent::Notification(notification);
match events.log.append(&event) {
Ok(seq) => {
let logged = LoggedEvent {
seq,
event: event.clone(),
};
let projected =
apply_persisted_event(&mut events.projection, &logged);
let _ = events.live.send(logged);
events.broadcast(projected);
None
}
Err(error) => Some(error),
}
};
if let Some(error) = append_error {
tracing::error!(%id, %error, "structured event log append failed");
if let Some(acp) = weak_acp.upgrade() {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
}
if let Some(inner) = weak_inner.upgrade() {
mark_session_dead(&inner, id).await;
}
break;
}
Err(error) => Some(error),
}
};
if let Some(error) = append_error {
tracing::error!(%id, %error, "structured event log append failed");
if let Some(acp) = weak_acp.upgrade() {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
maestro_acp::AcpUpdate::TurnDone {
turn: _,
result,
done,
} => {
// Every notification for this turn has been persisted and
// broadcast above. Signal the done oneshot so
// finish_queued_acp_turn runs with the transcript complete.
let _ = done.send(result);
}
if let Some(inner) = weak_inner.upgrade() {
mark_session_dead(&inner, id).await;
}
break;
}
}
let _ = done_tx.send(true);
@@ -3017,7 +3050,7 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
fn drain_prompt_queue(manager: SessionManager, id: Uuid, text: String) {
let fut: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> =
Box::pin(async move {
let _ = manager.queue_acp_prompt(id, text).await;
let _ = manager.queue_acp_prompt(id, text, None).await;
});
tokio::spawn(fut);
}
+43 -31
View File
@@ -84,15 +84,27 @@ impl Fake {
}
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
panic!("unexpected daemon event")
};
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
match &event.event {
maestro_daemon::sidecar::PersistedEvent::Notification(notification) => {
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
maestro_daemon::sidecar::PersistedEvent::User { row } => row
.data
.pointer("/content/text")
.and_then(serde_json::Value::as_str)
.expect("user row text"),
maestro_daemon::sidecar::PersistedEvent::Turn { row } => row
.data
.pointer("/maestroTurnMeta/outcome")
.and_then(serde_json::Value::as_str)
.unwrap_or("[turn]"),
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
}
}
@@ -259,7 +271,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "fail".into())
.queue_acp_prompt(session.id, "fail".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Dispatched
@@ -286,7 +298,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "succeed".into())
.queue_acp_prompt(session.id, "succeed".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Dispatched
@@ -426,7 +438,7 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "show my prompt".into())
.queue_acp_prompt(session.id, "show my prompt".into(), None)
.await
.expect("queue prompt"),
maestro_daemon::session::QueueAcpPrompt::Dispatched
@@ -516,7 +528,7 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "first".into())
.queue_acp_prompt(session.id, "first".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Dispatched
@@ -524,7 +536,7 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
let second = fx
.manager
.queue_acp_prompt(session.id, "second".into())
.queue_acp_prompt(session.id, "second".into(), None)
.await
.unwrap();
assert!(
@@ -555,7 +567,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "first".into())
.queue_acp_prompt(session.id, "first".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Dispatched
@@ -563,7 +575,7 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "second".into())
.queue_acp_prompt(session.id, "second".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Dispatched
@@ -655,25 +667,18 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
revived.status(session.id).await,
Some(SessionStatus::Running)
);
assert_eq!(
revived
.snapshot()
.await
.into_iter()
.find(|candidate| candidate.id == session.id)
.unwrap()
.activity,
ActivityState::Idle
);
let log = fake.log();
assert!(
log.contains(&format!("recv session/load sessionId={acp_id}")),
"{log}"
);
// Recovery adds: a continue-prompt User row, the adapter's post-load
// notification, and a turn-completed footer — 5 events total before the
// next manual tick.
wait_for_async(Duration::from_secs(5), || async {
revived
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 3)
.is_ok_and(|events| events.len() == 5)
})
.await;
@@ -682,7 +687,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
.await
.expect("replay read")
.expect("recovered event subscription");
assert_eq!(replay.len(), 3);
assert_eq!(replay.len(), 5);
assert!(live.try_recv().is_err(), "replay duplicated onto live");
let new_pid = fake.pid();
assert_ne!(new_pid, old_pid);
@@ -693,16 +698,23 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
.await
.expect("live event after recovery")
.expect("broadcast open");
assert_eq!(event.seq, 4);
assert_eq!(event.seq, 6);
assert_eq!(event_text(&event), "tick 1");
let stored = revived
.read_acp_events(session.id, EventCursor::Since(0), 10)
.expect("recovered log");
assert_eq!(stored.len(), 4, "session/load replay must not be appended");
assert_eq!(stored.len(), 6, "session/load replay must not be appended");
assert_eq!(
stored.iter().map(event_text).collect::<Vec<_>>(),
["hello from fake", "tick 1", "hello after load", "tick 1"]
[
"hello from fake",
"tick 1",
"The daemon restarted. Continue where you left off.",
"hello after load",
"completed",
"tick 1",
]
);
revived.kill(session.id, false).await.expect("kill");
+13 -3
View File
@@ -47,6 +47,7 @@ import {
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
requestCloseDrawer,
requestOpenSpawn,
requestToggleDrawer,
} from "./uiEvents.js";
@@ -113,15 +114,23 @@ function Shell() {
// a transient lost connection has already recovered.
useEffect(() => {
const recover = () => {
if (document.visibilityState !== "visible") return;
void linkStore.getState().probe();
catalogStore.getState().reconnect();
};
document.addEventListener("visibilitychange", recover);
// visibilitychange fires reliably on tab/app switch; guard it so we don't
// probe while the page is being hidden.
const onVisibility = () => {
if (document.visibilityState !== "visible") return;
recover();
};
// pageshow (bfcache restore) and online (network back) indicate the page
// is active — fire unconditionally since visibilityState may lag behind on
// some mobile browsers.
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("pageshow", recover);
window.addEventListener("online", recover);
return () => {
document.removeEventListener("visibilitychange", recover);
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pageshow", recover);
window.removeEventListener("online", recover);
};
@@ -174,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.
}
}}
/>
@@ -481,6 +481,7 @@
}
.st-tool-arg {
flex: 1 1 0;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
@@ -197,11 +197,12 @@ export function useStructuredSession(
};
// Mobile browsers suspend timers and silently kill WebSockets in the
// background. Kick a fresh connect when the page becomes visible again.
// background. The browser often still reports readyState === OPEN because
// TCP keepalive hasn't noticed the dead connection yet. Force-replace
// unconditionally — connect() closes the old socket first, and the server
// replays the full transcript on every reconnect so nothing is lost.
const onVisibility = () => {
if (document.visibilityState !== "visible" || disposed) return;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
cancelTimer();
setStatus("reconnecting");
connect();
};
+10 -5
View File
@@ -303,17 +303,22 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
ta?.addEventListener("blur", onTaBlur);
// Mobile browsers (Android especially) suspend timers and silently kill
// WebSockets when the app goes to background. The backoff timer set by
// onclose is frozen too, so recovery stalls until visibility returns. Kick
// a fresh connect immediately when the page becomes visible again.
// WebSockets when the app goes to background. The browser often still
// reports readyState === OPEN because TCP keepalive hasn't noticed the
// dead connection yet. Force-replace the socket unconditionally —
// a genuinely alive server socket gets a clean close, and the reconnect
// replays the scrollback so nothing is lost.
const onVisibility = () => {
if (document.visibilityState !== "visible" || disposed) return;
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) return;
// Cancel any pending backoff — we're reconnecting right now.
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Detach the old socket's handlers so its close event doesn't race
// with the fresh connection (same pattern as catalogStore.reconnect).
ws.onclose = null;
ws.onerror = null;
ws.close();
reconnectMs = INITIAL_RECONNECT_MS;
onStatusChange?.("reconnecting");
connect();
+6 -1
View File
@@ -2648,6 +2648,7 @@ input.leaf-rename:focus {
}
.st-tool-arg {
flex: 1 1 0;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
@@ -2893,7 +2894,11 @@ input.leaf-rename:focus {
}
.st-tool-group-title {
flex: 0 0 auto;
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
+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";