Compare commits

...
Author SHA1 Message Date
Miguel Palhas c90bdfbe42 feat(maestro): open terminal pane from a tapped sidebar session
Tapping a session in the sidebar now opens TerminalScreen full-screen
instead of just closing the drawer. windowSoftInputMode=adjustResize
lets WindowInsets.isImeVisible drive the key bar's visibility.
2026-09-13 01:07:32 +01:00
Miguel Palhas 27b6e647c1 feat(maestro): add Android terminal pane composable
TerminalWebView wraps a WebView around the bundled xterm.js page, with
a JS bridge (maestroKey/maestroToggleMod/maestroFocus) the native key
bar drives; onModifierConsumed reports back when the page's own JS
folds an armed Ctrl/Alt into a soft-keyboard keystroke, so the native
toggle stays in sync. TerminalScreen composes the WebView with the key
bar, showing the bar only while WindowInsets.isImeVisible.
2026-09-13 01:07:23 +01:00
Miguel Palhas 0631e69ef0 feat(maestro): add Android terminal extra key bar composable
Termux-style row for the soft keyboard (issue #266): Tab, Esc, one-shot
Ctrl/Alt modifiers, and the four cursor arrows, scrollable horizontally
for narrow screens.
2026-09-13 01:07:15 +01:00
Miguel Palhas f98c637cd9 feat(maestro): add Android terminal attach URL builder
TerminalPageUrl builds the file:///android_asset/terminal/index.html
URL for one session, with the WS /api/attach/:id URL Routes already
knows how to build passed through as an encoded query param.
2026-09-13 01:07:07 +01:00
Miguel Palhas 554182d956 feat(maestro): bundle xterm.js terminal page for Android WebView pane
Vendors @xterm/xterm 5.5.0 and @xterm/addon-fit 0.10.0 as static assets
plus a small HTML/JS page that owns the WS /api/attach/:id connection
directly, the same shape as the web SPA's TerminalPane.tsx. Bundled
locally so the pane only needs LAN reachability to the daemon, not
internet access to a CDN.
2026-09-13 01:06:59 +01:00
Miguel Palhas 391efc72d2 feat(maestro): add SpawnSheet bottom sheet composable (#269)
Preset chip row, searchable project list, debounced directory
autocomplete, and a branch/worktree picker matching the web spawn
modal, wired to SpawnViewModel and a self-contained snackbar for
submit errors.
2026-09-13 00:54:02 +01:00
Miguel Palhas cf582be306 feat(maestro): add spawn form model and view model (#269)
Pure request-building logic ported from web/src/components/spawn/model.ts,
plus a testable SpawnViewModel wiring presets, projects, debounced dir
autocomplete, branch loading, and session creation to MaestroClient.
2026-09-13 00:49:55 +01:00
Miguel Palhas 3403224dec feat(maestro): wire sidebar drawer into MainActivity
ModalNavigationDrawer opened by a hamburger button in the top bar,
holding SidebarContent bound to the app's DaemonLink.catalog.
2026-09-13 00:39:15 +01:00
Miguel Palhas a9c3cfe6d0 feat(maestro): add sidebar composables (#265)
ActivityRail (coloured gutter bar, blink on AwaitingInput, flash on
Error), SessionCard (two-line card with a long-press context menu for
rename/kill/spawn-similar), FolderNode (collapsible repo group), and
SidebarContent assembling the drawer's session list from the
CatalogStore built in #264.
2026-09-13 00:39:13 +01:00
Miguel Palhas 3ef1decbdd feat(maestro): add sidebar session formatting helpers
Pure Kotlin ports of the web/TUI SIDEBAR.md logic for the Android
sidebar: duration floor-format, program-name extraction (env/sudo/env
wrappers, npx/pnpm/yarn dlx, path), diff-stat notation, and
name/name_source/provisional-name reads. Covered by unit tests so they
run without an Android SDK.
2026-09-13 00:39:09 +01:00
Miguel Palhas b22539bd5b feat(maestro): wire DaemonLink into MainActivity, allow cleartext traffic
Reads the EXTRA_DAEMON_URL intent extra, starts DaemonLink, and shows its
LinkState in the placeholder screen so the connection indicator has
something to bind to. usesCleartextTraffic is required for the daemon's
plain-HTTP LAN endpoint; ACCESS_NETWORK_STATE is required for
ConnectivityManager.registerNetworkCallback.
2026-09-13 00:24:41 +01:00
Miguel Palhas 8f0d73d89c feat(maestro): add Android daemon connection state machine
Ports the DaemonLink state machine from web/src/components/daemon/DaemonLink.tsx
and crates/maestro-tui/src/daemon_link.rs to Android: LinkState (Connecting,
Online, Reconnecting, Down, Updating), build-stamp identity comparison, a
session catalog folded from the events WebSocket, and exponential backoff
(500ms-10s) in place of the flat poll the LAN clients use, since a phone's
network changes are bigger and longer-lived than a wifi blip.

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

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

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

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

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

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

Closes #261

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 21:21:36 +01:00
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 Palhas fa3d6c82b2 Merge remote-tracking branch 'origin/main' into chord
ci / web (push) Failing after 26s
ci / flake (push) Successful in 4m6s
ci / rust (push) Successful in 7m29s
2026-09-12 14:10:59 +01:00
Miguel PalhasandClaude Opus 4.6 ebe25a1a55 feat(maestro): server-side prompt queue for structured sessions
Prompts sent while a turn is active are queued on the daemon instead of
in the browser's React state. The queue is persisted in the session
sidecar and drained as the next turn when the current one completes.

Fixes the bug where queued messages were delivered to the wrong session
when switching focus in the UI: the client-side queue captured the new
sessionId in its drain closure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 14:10:55 +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
naps62-yolo 8cbe8510ac fix(maestro): truncate long commands in tool group header (#258)
ci / web (push) Failing after 56s
ci / flake (push) Successful in 5m2s
ci / rust (push) Successful in 9m34s
2026-09-12 11:08:04 +01:00
Miguel Palhas cd5bbcfcb1 Merge remote-tracking branch 'origin/main' into timbre-2
ci / web (push) Failing after 1m7s
ci / flake (push) Successful in 3m55s
ci / rust (push) Has been cancelled
2026-09-12 10:59:49 +01:00
Miguel PalhasandClaude Opus 4.6 cbbf526fc4 fix(maestro): persist auto_approve across daemon restarts
Structured sessions spawned with auto_approve lost the flag on daemon
restart because it lived only in the in-memory SessionRecord. Add it to
the Session struct (sidecar JSON) so recovery reads it back instead of
hardcoding false.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:59:44 +01:00
Miguel Palhas 9f5b71d563 Merge remote-tracking branch 'origin/main' into aeolian
ci / web (push) Failing after 29s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 3m9s
2026-09-12 10:42:10 +01:00
Miguel PalhasandClaude Opus 4.6 de603ccd20 feat(maestro): split tool groups on text boundaries
Tool rows interleaved with agent text now produce separate
collapsed groups instead of one per turn. The timeline reads
text → group → text → group, matching chronological order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:42:06 +01:00
Miguel Palhas c6af78e9dd feat(maestro): concatenate queued structured prompts into one turn
ci / web (push) Failing after 6s
ci / rust (push) Failing after 54s
ci / flake (push) Successful in 2m59s
Closes #257
2026-09-12 10:12:36 +01:00
Miguel PalhasandClaude Opus 4.6 7ab503e4f5 fix(maestro): update e2e test for force-kill worktree removal
ci / web (push) Failing after 6s
ci / rust (push) Failing after 53s
ci / flake (push) Successful in 2m47s
1b2e5f5 changed force kill to remove dirty worktrees but left the
test asserting the old keep-on-disk behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 08:38:51 +01:00
Miguel Palhas eaf11a7696 Merge branch 'maestoso'
ci / web (push) Failing after 27s
ci / rust (push) Failing after 1m1s
ci / flake (push) Successful in 3m35s
2026-09-10 23:15:45 +01:00
Miguel Palhas 4738233580 Merge remote-tracking branch 'origin/main' into lento
ci / web (push) Failing after 27s
ci / rust (push) Failing after 50s
ci / flake (push) Has been cancelled
2026-09-10 21:55:19 +01:00
Miguel PalhasandClaude Opus 4.6 cb25320ea0 feat(maestro): keep agent text visible, collapse only tool calls
Agent text and thought rows stay outside the collapsed tool group as
standalone transcript items instead of being absorbed into it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:55:06 +01:00
Miguel Palhas ff52dd9812 Merge branch 'lyre'
ci / web (push) Successful in 51s
ci / rust (push) Failing after 1m2s
ci / flake (push) Successful in 3m24s
2026-09-10 21:53:34 +01:00
Miguel PalhasandClaude Opus 4.6 f3a5cad789 feat(maestro): widen sidebar activity rail from 5px to 7px
Makes the coloured state bars more visible at a glance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:53:18 +01:00
Miguel Palhas fffd2a55f3 Merge remote-tracking branch 'origin/main' into maestoso 2026-09-10 21:52:26 +01:00
Miguel PalhasandClaude Opus 4.6 563a35d2ec fix(maestro): restore sidebar fill, pane title, and focus dimming after React port
ci / web (push) Failing after 6s
ci / rust (push) Failing after 48s
ci / flake (push) Has been cancelled
The React port replaced CSS class names with Tailwind utilities, breaking
three visual contracts:

- SessionCard used inline bg-* classes instead of .is-active/.is-open/
  .is-previewing that the styles.css cascade targets.
- PaneTitle used className "at-title" instead of "attach-title", so font,
  overflow, and child layout rules never applied.
- MultiView's Pane never applied .is-focused or .is-preview, so every
  pane matched :not(.is-focused) and was dimmed to 0.45 opacity.

Closes #256

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:51:53 +01:00
Miguel PalhasandClaude Opus 4.6 9dead5652e fix(maestro): reconnect attach/structured sockets on Android resume
The visibility recovery only reconnected the catalog and daemon-link
sockets. Per-pane attach and structured WebSockets relied on backoff
timers that Android suspends alongside the sockets, leaving the
terminal disconnected after backgrounding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:50:29 +01:00
Miguel Palhas cd2184b128 Merge remote-tracking branch 'origin/main' into modulation-2
ci / web (push) Failing after 7s
ci / rust (push) Failing after 1m3s
ci / flake (push) Successful in 3m30s
2026-09-10 17:29:49 +01:00
Miguel PalhasandClaude Opus 4.6 1b2e5f5f05 feat(maestro): force kill deletes dirty worktrees
Previously --force left the worktree on disk so the operator could
inspect it. Now it removes the worktree unconditionally — the
confirmation prompt already shows the breakdown, so the decision
happens before the delete, not after.

Natural exit still leaves dirty worktrees on disk (no operator
confirmation in that path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:26:04 +01:00
Miguel PalhasandClaude Opus 4.6 2c8956e634 fix(maestro): reset pending state when kill dialog transitions to force mode
ci / web (push) Successful in 38s
ci / flake (push) Successful in 3m51s
ci / rust (push) Has been cancelled
The onConfirm handler in Sidebar resolves (not rejects) when the daemon
refuses an unforced kill — it mutates state to switch to force mode and
returns. That left `pending` stuck at true in the KillDialog, so the
"Force kill" button rendered disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:24:34 +01:00
Miguel Palhas f2c0882c36 Merge remote-tracking branch 'origin/main' into xylophone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m40s
ci / rust (push) Has been cancelled
# Conflicts:
#	.claude/settings.local.json
2026-09-10 17:17:32 +01:00
Miguel PalhasandClaude Opus 4.6 c170cd9d60 fix(maestro): restore sidebar footer, fix spawn modal and session card regressions
Post-React-migration regressions: spawn submit button moved to modal
footer, sidebar footer restored (help/theme/palette/status/spawn),
session card stripped of redundant Tailwind, action buttons use proper
leaf-act/leaf-actions CSS structure for hover-only visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:17:22 +01:00
Miguel PalhasandClaude Opus 4.6 ebf943bf30 chore(maestro): stop tracking settings.local.json
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:50:31 +01:00
Miguel PalhasandClaude Opus 5 645aa2dea0 fix(maestro): drop border and background on composer stop button
ci / web (push) Failing after 6s
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:48:02 +01:00
Miguel Palhas 3a2513c78d Merge branch 'tuba-2'
ci / web (push) Successful in 34s
ci / flake (push) Successful in 5m32s
ci / rust (push) Successful in 8m1s
2026-09-10 16:39:27 +01:00
Miguel Palhas 7cb52927f2 Merge remote-tracking branch 'origin/main' into tuba-2 2026-09-10 16:38:37 +01:00
Miguel PalhasandClaude Opus 4.6 2724447a51 feat(maestro): show last tool and live narration in collapsed group header
The collapsed tool group now reads "N tool calls · Label 'arg'" so the
operator sees the current command at a glance. While a turn is active,
the most recent agent text stays outside the group as visible narration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:30 +01:00
Miguel PalhasandClaude Opus 4.6 142e9a4870 chore(maestro): update local tool permissions
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:24 +01:00
Miguel PalhasandClaude Opus 4.6 e5c17606d0 fix(maestro): update pnpmDeps hash for tailwindcss 4.3.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:35:37 +01:00
Miguel Palhas 979bfe2fbe chore(maestro): update local tool permissions
ci / web (push) Successful in 47s
ci / flake (push) Failing after 1m5s
ci / rust (push) Successful in 6m44s
2026-09-10 16:25:37 +01:00
Miguel PalhasandClaude Opus 4.6 8f96027878 ci(maestro): cancel in-progress builds on all branches
ci / web (push) Failing after 7s
ci / flake (push) Failing after 1m6s
ci / rust (push) Has been cancelled
Closes #255

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:24:38 +01:00
128 changed files with 6419 additions and 465 deletions
-18
View File
@@ -1,18 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo test:*)"
]
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ on:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
+3
View File
@@ -15,3 +15,6 @@ worktrees/
# `nix run .#dev` scratch state (tmux socket)
/.dev/
# Per-machine Claude Code tool permissions
.claude/settings.local.json
+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
+85
View File
@@ -0,0 +1,85 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "pt.naps.maestro"
compileSdk = 34
defaultConfig {
applicationId = "pt.naps.maestro"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
getByName("debug") {
storeFile = file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
debug {
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.turbine)
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()
}
}
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Maestro">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
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,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="stylesheet" href="vendor/xterm.css" />
<style>
html, body, #terminal { height: 100%; margin: 0; padding: 0; background: #121419; overflow: hidden; }
.xterm { padding: 4px; }
</style>
</head>
<body>
<div id="terminal"></div>
<script src="vendor/xterm.js"></script>
<script src="vendor/addon-fit.js"></script>
<script>
// Owns the `WS /api/attach/:id` connection directly, the same shape as
// web/src/components/terminal/TerminalPane.tsx — PTY bytes in binary
// frames both ways, `ClientControl` JSON in text frames. The native
// Android side (TerminalWebView.kt) never touches the socket; it only
// calls the maestro* bridge functions below for the extra key bar.
var params = new URLSearchParams(location.search);
var wsUrl = params.get("ws");
var term = new Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
scrollback: 2000,
theme: {
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
});
var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
var container = document.getElementById("terminal");
term.open(container);
fitAddon.fit();
var ws = null;
var awaitingReplay = true;
var armed = { ctrl: false, alt: false };
function sendControl(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function sendInput(data) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
}
function connect() {
awaitingReplay = true;
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = function () {
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
ws.onmessage = function (ev) {
if (typeof ev.data === "string") return; // control frame, nothing to render
var replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), function () {
if (replay) term.scrollToBottom();
});
};
ws.onclose = function (ev) {
if (ev.code === 1000) return;
term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
};
}
// Ctrl maps to the C0 control range (A-Z, [, \, ], ^, _); Alt prefixes ESC
// — the same encoding buildKeyBar/applyModsToChar use on the web client.
function applyModsToChar(ch, ctrl, alt) {
var out = ch;
if (ctrl) {
var code = ch.toUpperCase().charCodeAt(0);
if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64);
}
if (alt) out = "\x1b" + out;
return out;
}
// CSI cursor sequence with the modifier parameter xterm expects:
// `1;<mod>` where 3=alt, 5=ctrl, 7=ctrl+alt.
function cursorSeq(final, ctrl, alt) {
if (!ctrl && !alt) return "\x1b[" + final;
var mod = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
return "\x1b[1;" + mod + final;
}
term.onData(function (data) {
if ((armed.ctrl || armed.alt) && data.length === 1) {
var hadCtrl = armed.ctrl;
var hadAlt = armed.alt;
sendInput(applyModsToChar(data, hadCtrl, hadAlt));
armed.ctrl = false;
armed.alt = false;
if (window.AndroidBridge) {
if (hadCtrl) AndroidBridge.onModConsumed("ctrl");
if (hadAlt) AndroidBridge.onModConsumed("alt");
}
} else {
sendInput(data);
}
});
term.onResize(function (size) {
sendControl({ type: "Resize", cols: size.cols, rows: size.rows });
});
container.addEventListener("click", function () {
term.focus();
});
var ro = new ResizeObserver(function () {
try {
fitAddon.fit();
} catch (e) {
// container not measurable yet (e.g. mid-teardown) — next observation retries
}
});
ro.observe(container);
// Bridge called from TerminalWebViewController via evaluateJavascript —
// the native extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles.
window.maestroKey = function (key) {
var ctrl = armed.ctrl;
var alt = armed.alt;
var seq;
switch (key) {
case "Tab": seq = "\t"; break;
case "Esc": seq = "\x1b"; break;
case "Up": seq = cursorSeq("A", ctrl, alt); break;
case "Down": seq = cursorSeq("B", ctrl, alt); break;
case "Right": seq = cursorSeq("C", ctrl, alt); break;
case "Left": seq = cursorSeq("D", ctrl, alt); break;
default: return;
}
sendInput(seq);
armed.ctrl = false;
armed.alt = false;
};
window.maestroToggleMod = function (name) {
if (name === "ctrl" || name === "alt") armed[name] = !armed[name];
};
window.maestroFocus = function () {
term.focus();
};
connect();
</script>
</body>
</html>
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,142 @@
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.foundation.layout.width
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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 kotlinx.coroutines.launch
import pt.naps.maestro.connection.DaemonLink
import pt.naps.maestro.connection.DaemonPrefs
import pt.naps.maestro.connection.LinkState
import pt.naps.maestro.data.Session
import pt.naps.maestro.ui.sidebar.SidebarContent
import pt.naps.maestro.ui.sidebar.sessionName
import pt.naps.maestro.ui.terminal.TerminalScreen
import pt.naps.maestro.ui.theme.MaestroTheme
class MainActivity : ComponentActivity() {
private lateinit var daemonLink: DaemonLink
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val prefs = DaemonPrefs(applicationContext)
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
setContent {
MaestroTheme {
val linkState by daemonLink.state.collectAsState()
val baseUrl by prefs.baseUrl.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var openSession by remember { mutableStateOf<Session?>(null) }
val session = openSession
if (session != null) {
TerminalScreen(
daemonBaseUrl = baseUrl,
sessionId = session.id,
sessionTitle = sessionName(session),
onBack = { openSession = null },
)
} else {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
SidebarContent(
catalogStore = daemonLink.catalog,
onOpenSession = { s ->
openSession = s
scope.launch { drawerState.close() }
},
)
}
},
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Maestro") },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Text("☰")
}
},
)
},
) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
}
}
}
}
}
}
override fun onDestroy() {
daemonLink.close()
super.onDestroy()
}
}
@Composable
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Maestro",
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(id = R.string.placeholder_message),
)
Text(
text = "Daemon: ${linkState.name.lowercase()}",
)
}
}
@Preview(showBackground = true)
@Composable
private fun PlaceholderScreenPreview() {
MaestroTheme {
PlaceholderScreen()
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.api
import pt.naps.maestro.data.InTheWay
/** A `/api` failure with the daemon's own error envelope kept intact, so a
* caller can branch on [code] and render [unsavedWork] — the kill guard —
* rather than re-parsing [detail]. Mirrors the web client's
* `ApiRequestError`. */
class MaestroApiException(
val status: Int,
val code: String,
val detail: String,
val unsavedWork: InTheWay? = null,
) : Exception(buildMessage(status, code, detail)) {
private companion object {
fun buildMessage(status: Int, code: String, detail: String): String {
val info = if (code.isNotEmpty()) "$code: $detail" else detail
return if (info.isNotEmpty()) "$status — $info" else "$status"
}
}
}
@@ -0,0 +1,252 @@
package pt.naps.maestro.api
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import pt.naps.maestro.data.ApiError
import pt.naps.maestro.data.BranchInfo
import pt.naps.maestro.data.CreateSessionRequest
import pt.naps.maestro.data.DaemonInfo
import pt.naps.maestro.data.DirListing
import pt.naps.maestro.data.GitRoot
import pt.naps.maestro.data.NameBatch
import pt.naps.maestro.data.Project
import pt.naps.maestro.data.RecentDirs
import pt.naps.maestro.data.RenameSessionRequest
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SetFocusRequest
import pt.naps.maestro.data.StructuredAttachmentResponse
import pt.naps.maestro.data.StructuredApprovalDecision
import pt.naps.maestro.data.StructuredConfigRequest
import pt.naps.maestro.data.StructuredPromptRequest
import pt.naps.maestro.data.Uuid
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType()
/**
* Typed client for the daemon's `/api` surface (REST half). Mirrors
* `maestro_protocol::client::DaemonClient` / `web/src/api.ts`.
*
* There is no authentication layer: reaching the daemon's bind address is
* the authorization, same trust model as tmux itself.
*/
class MaestroClient(
private val baseUrl: String,
private val httpClient: OkHttpClient = OkHttpClient(),
) {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
suspend fun listSessions(): List<Session> = get(Routes.SESSIONS)
suspend fun createSession(request: CreateSessionRequest): Session =
post(Routes.SESSIONS, request)
/** `PATCH /api/sessions/:id`: rename a live session. An empty [name]
* clears the custom one, so the card falls back to the command. */
suspend fun renameSession(id: Uuid, name: String): Session =
patch(Routes.session(id), RenameSessionRequest(name))
/** `DELETE /api/sessions/:id`. With [force], the daemon overrides the
* kill refusal over a worktree holding unsaved work and leaves that
* worktree on disk. */
suspend fun killSession(id: Uuid, force: Boolean = false) {
val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList()
executeNoContent(newRequest("DELETE", Routes.session(id), query = query))
}
/** `POST /api/sessions/:id/focus`: report whether this client's pane is
* the one currently showing [id] — not merely highlighted in a
* sidebar. */
suspend fun setSessionFocus(id: Uuid, focused: Boolean) {
executeNoContent(
newRequest(
"POST",
Routes.sessionFocus(id),
body = json.encodeToString(SetFocusRequest(focused)),
),
)
}
/** `GET /api/dirs?path=`: one directory's immediate subdirectories.
* [path] may be empty, `~`, relative, or absolute — the daemon resolves
* it against its home dir. */
suspend fun listDir(path: String): DirListing =
get(Routes.DIRS, listOf(Routes.PATH_QUERY to path))
/** `GET /api/info`: identity + spawn presets of the connected daemon. */
suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO)
/** `GET /api/recent-dirs`: recently-used working dirs, newest-first and
* deduped by dir. */
suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS)
/** `GET /api/projects`: every discovered git repository/worktree,
* newest-activity-first. */
suspend fun listProjects(): List<Project> = get(Routes.PROJECTS)
/** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent
* when [path] isn't inside a repo. */
suspend fun gitRoot(path: String): GitRoot =
get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path))
/** `GET /api/names?count=`: a batch of fresh session handles. */
suspend fun fetchNames(count: Int): NameBatch =
get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString()))
/** `GET /api/branches?path=`: one repository's local branches, with
* worktree occupancy. */
suspend fun listBranches(path: String): List<BranchInfo> =
get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path))
/** Send a prompt to a structured session's ACP turn. `409
* turn_in_flight` when one is already active. */
suspend fun sendStructuredPrompt(id: Uuid, prompt: String) {
executeNoContent(
newRequest(
"POST",
Routes.structuredPrompt(id),
body = json.encodeToString(StructuredPromptRequest(prompt)),
),
)
}
/** Upload a file attachment for a structured session's next prompt. */
suspend fun uploadStructuredAttachment(
id: Uuid,
fileName: String,
contents: ByteArray,
): StructuredAttachmentResponse {
val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl()
.newBuilder()
.addQueryParameter("name", fileName)
.build()
val request = Request.Builder()
.url(url)
.post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE))
.build()
return decode(execute(request))
}
/** Cancel a structured session's active ACP turn. */
suspend fun cancelStructuredTurn(id: Uuid) {
executeNoContent(newRequest("POST", Routes.structuredCancel(id)))
}
/** Change one option advertised by a live ACP session. */
suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) {
executeNoContent(
newRequest(
"POST",
Routes.structuredConfig(id),
body = json.encodeToString(StructuredConfigRequest(configId, value)),
),
)
}
/** Answer a pending ACP permission request. Always resolves once the
* session itself is a live structured session — a nonce that is unknown
* or already consumed still answers success. */
suspend fun resolveStructuredApproval(
id: Uuid,
nonce: String,
decision: StructuredApprovalDecision,
) {
executeNoContent(
newRequest(
"POST",
Routes.structuredApproval(id, nonce),
body = json.encodeToString(decision),
),
)
}
// -- plumbing ------------------------------------------------------
private suspend inline fun <reified T> get(
path: String,
query: List<Pair<String, String>> = emptyList(),
): T = decode(execute(newRequest("GET", path, query = query)))
private suspend inline fun <reified B, reified T> post(path: String, body: B): T =
decode(execute(newRequest("POST", path, body = json.encodeToString(body))))
private suspend inline fun <reified B, reified T> patch(path: String, body: B): T =
decode(execute(newRequest("PATCH", path, body = json.encodeToString(body))))
private fun newRequest(
method: String,
path: String,
query: List<Pair<String, String>> = emptyList(),
body: String? = null,
): Request {
val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder()
for ((key, value) in query) urlBuilder.addQueryParameter(key, value)
// OkHttp refuses a null body on POST/PUT/PATCH even when the daemon
// expects none (e.g. the structured-turn cancel) — an empty body
// satisfies it without implying a JSON payload.
val requestBody = when {
body != null -> body.toRequestBody(JSON_MEDIA_TYPE)
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null)
else -> null
}
return Request.Builder()
.url(urlBuilder.build())
.method(method, requestBody)
.build()
}
private suspend fun execute(request: Request): Response =
suspendCancellableCoroutine { cont ->
val call = httpClient.newCall(request)
cont.invokeOnCancellation { call.cancel() }
call.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (!cont.isCancelled) cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
},
)
}
private suspend fun executeNoContent(request: Request) {
execute(request).use { if (!it.isSuccessful) throw toApiException(it) }
}
private inline fun <reified T> decode(response: Response): T =
response.use {
if (!it.isSuccessful) throw toApiException(it)
json.decodeFromString<T>(it.body?.string().orEmpty())
}
private fun toApiException(response: Response): MaestroApiException {
val text = response.body?.string().orEmpty()
val info = runCatching { json.decodeFromString<ApiError>(text) }.getOrNull()?.error
return MaestroApiException(
status = response.code,
code = info?.code ?: "",
detail = info?.message ?: text,
unsavedWork = info?.unsaved_work,
)
}
}
@@ -0,0 +1,175 @@
package pt.naps.maestro.api
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.ClientControl
import pt.naps.maestro.data.StructuredEvent
import pt.naps.maestro.data.Uuid
private val wsJson = Json {
ignoreUnknownKeys = true
isLenient = true
}
/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded
* [ClientControl] text message. */
sealed class AttachFrame {
data class Pty(val bytes: ByteArray) : AttachFrame()
data class Control(val message: ClientControl) : AttachFrame()
}
/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames
* both directions; control messages ([ClientControl]) travel in text
* frames. */
class AttachConnection internal constructor(
private val socket: WebSocket,
val frames: Flow<AttachFrame>,
) {
/** Type raw bytes into the session's pane, as if typed on an attached
* terminal. */
fun sendInput(bytes: ByteArray) {
socket.send(ByteString.of(*bytes))
}
fun resize(cols: Int, rows: Int) {
sendControl(ClientControl.Resize(cols, rows))
}
fun ping() {
sendControl(ClientControl.Ping)
}
private fun sendControl(message: ClientControl) {
socket.send(wsJson.encodeToString(message))
}
fun close() {
socket.close(1000, null)
}
}
/** A live `WS /api/structured/:id` connection: transcript/state snapshots
* and live deltas for one structured session. */
class StructuredConnection internal constructor(
private val socket: WebSocket,
val events: Flow<StructuredEvent>,
) {
fun close() {
socket.close(1000, null)
}
}
/** A live `WS /api/events` connection: catalog snapshot + session deltas. */
class EventsConnection internal constructor(
private val socket: WebSocket,
val events: Flow<CatalogEvent>,
) {
fun close() {
socket.close(1000, null)
}
}
/**
* WebSocket half of the daemon's `/api` surface. Mirrors the `*_url`
* helpers in `crates/maestro-protocol/src/routes.rs`.
*
* Each `connect*` call opens the socket immediately and returns a handle
* whose `Flow` starts buffering frames right away — a slow collector never
* misses a frame, it only sees it later. Call `close()` on the returned
* connection when done; there is no automatic reconnect.
*/
class MaestroWebSocket(
private val baseUrl: String,
private val httpClient: OkHttpClient = OkHttpClient(),
) {
/** `WS /api/events`: catalog snapshot + session deltas. */
fun connectEvents(): EventsConnection {
val channel = Channel<CatalogEvent>(Channel.BUFFERED)
val socket = open(
Routes.eventsWsUrl(baseUrl),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<CatalogEvent>(text) }
.onSuccess { channel.trySend(it) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return EventsConnection(socket, channel.receiveAsFlow())
}
/** `WS /api/attach/:id`: raw PTY bytes both directions, control messages
* as text JSON. */
fun connectAttach(id: Uuid): AttachConnection {
val channel = Channel<AttachFrame>(Channel.BUFFERED)
val socket = open(
Routes.attachWsUrl(baseUrl, id),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<ClientControl>(text) }
.onSuccess { channel.trySend(AttachFrame.Control(it)) }
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
channel.trySend(AttachFrame.Pty(bytes.toByteArray()))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return AttachConnection(socket, channel.receiveAsFlow())
}
/** `WS /api/structured/:id`: transcript/state snapshots and live deltas
* for one structured session. */
fun connectStructured(id: Uuid): StructuredConnection {
val channel = Channel<StructuredEvent>(Channel.BUFFERED)
val socket = open(
Routes.structuredWsUrl(baseUrl, id),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching { wsJson.decodeFromString<StructuredEvent>(text) }
.onSuccess { channel.trySend(it) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
channel.close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
channel.close()
}
},
)
return StructuredConnection(socket, channel.receiveAsFlow())
}
private fun open(url: String, listener: WebSocketListener): WebSocket {
val request = Request.Builder().url(url).build()
return httpClient.newWebSocket(request, listener)
}
}
@@ -0,0 +1,84 @@
package pt.naps.maestro.api
import pt.naps.maestro.data.Uuid
/** The daemon's `/api` surface: paths, and the scheme swap needed to reach
* the WebSocket endpoints. Mirrors `maestro-protocol::routes` /
* `web/src/routes.ts`. */
object Routes {
const val API_PREFIX = "/api"
const val SESSIONS = "/api/sessions"
const val INFO = "/api/info"
const val DIRS = "/api/dirs"
const val RECENT_DIRS = "/api/recent-dirs"
const val PROJECTS = "/api/projects"
const val GITROOT = "/api/gitroot"
const val NAMES = "/api/names"
const val BRANCHES = "/api/branches"
const val EVENTS = "/api/events"
/** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */
const val PATH_QUERY = "path"
/** Name of the query parameter `DELETE /api/sessions/:id` takes:
* `force=true` overrides the kill refusal over a guarded worktree. */
const val FORCE_QUERY = "force"
/** Name of the query parameter [NAMES] takes. */
const val COUNT_QUERY = "count"
fun session(id: Uuid): String = "$SESSIONS/$id"
fun sessionFocus(id: Uuid): String = "${session(id)}/focus"
fun attach(id: Uuid): String = "$API_PREFIX/attach/$id"
/** `WS /api/structured/:id` — transcript/state snapshots and live
* deltas. */
fun structured(id: Uuid): String = "$API_PREFIX/structured/$id"
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt"
fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments"
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel"
/** `POST /api/sessions/:id/structured/config` — change an ACP session
* option. */
fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config"
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a
* permission request. */
fun structuredApproval(id: Uuid, nonce: String): String =
"${session(id)}/structured/approvals/$nonce"
/** Strip a trailing slash so `{base}{path}` never doubles up. */
fun normalizeBase(base: String): String = base.trimEnd('/')
/** Join a daemon base URL and one of this object's paths. */
fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path"
/** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes
* `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes
* through. */
fun wsBase(httpBase: String): String {
val base = normalizeBase(httpBase)
return when {
base.startsWith("https://") -> "wss://${base.removePrefix("https://")}"
base.startsWith("http://") -> "ws://${base.removePrefix("http://")}"
base.startsWith("wss://") || base.startsWith("ws://") -> base
else -> throw IllegalArgumentException(
"daemon url has no supported scheme (want http/https/ws/wss): $httpBase",
)
}
}
fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS"
fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}"
fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}"
}
@@ -0,0 +1,32 @@
package pt.naps.maestro.connection
/**
* Exponential backoff for daemon reconnect attempts: doubles from
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
* change than the LAN blips the web/TUI clients ride out with a flat poll,
* so this backs off instead of hammering the daemon while nothing changed.
*/
class Backoff(
private val initialMs: Long = INITIAL_MS,
private val maxMs: Long = MAX_MS,
) {
private var currentMs = initialMs
/** Delay before the next attempt; advances the sequence for the one after. */
fun nextDelayMs(): Long {
val delay = currentMs
currentMs = (currentMs * 2).coerceAtMost(maxMs)
return delay
}
/** Back to [initialMs] after a successful connection. */
fun reset() {
currentMs = initialMs
}
companion object {
const val INITIAL_MS = 500L
const val MAX_MS = 10_000L
}
}
@@ -0,0 +1,49 @@
package pt.naps.maestro.connection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.Uuid
/**
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
* replaces the map outright; deltas mutate one entry.
*/
class CatalogStore {
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
* the caller re-fetches a full snapshot over REST and calls [replace],
* mirroring how the web client reacts to a dropped broadcast frame. */
fun apply(event: CatalogEvent): Long? =
when (event) {
is CatalogEvent.Snapshot -> {
_sessions.value = event.sessions.associateBy { it.id }
null
}
is CatalogEvent.SessionAdded -> {
_sessions.value = _sessions.value + (event.session.id to event.session)
null
}
is CatalogEvent.SessionUpdated -> {
_sessions.value = _sessions.value + (event.session.id to event.session)
null
}
is CatalogEvent.SessionRemoved -> {
_sessions.value = _sessions.value - event.id
null
}
is CatalogEvent.Lagged -> event.skipped
}
fun replace(sessions: List<Session>) {
_sessions.value = sessions.associateBy { it.id }
}
fun clear() {
_sessions.value = emptyMap()
}
}
@@ -0,0 +1,51 @@
package pt.naps.maestro.connection
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
/**
* Notifies [onAvailable] whenever a usable network appears — including the
* wifi/cellular handoff, which Android reports as one network disappearing
* and another becoming available rather than a single "changed" event.
* [DaemonLink] treats every call as "worth reconnecting for" rather than
* inspecting which network it is.
*/
class ConnectivityObserver(
context: Context,
private val onAvailable: () -> Unit,
) {
private val connectivityManager =
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable.invoke()
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
) {
onAvailable.invoke()
}
}
}
private var registered = false
fun start() {
if (registered) return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
registered = true
}
fun stop() {
if (!registered) return
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
registered = false
}
}
@@ -0,0 +1,37 @@
package pt.naps.maestro.connection
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
/** What happened between two `/api/info` replies. */
enum class RestartKind { SAME, RESTARTED, UPGRADED }
/**
* Compare two daemon replies. Mirrors `compareIdentity` in
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
* `crates/maestro-tui/src/daemon_link.rs`.
*
* The commit decides first: a different one is new server code. `instance`
* catches everything else — a restart of the same build — and is the only
* signal for a daemon built outside a git checkout, where the commit is
* always empty and an empty-vs-empty pair must never read as an upgrade.
*/
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
val a = before.build.commit
val b = after.build.commit
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
return RestartKind.RESTARTED
}
return RestartKind.SAME
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
fun formatRelease(build: BuildInfo): String {
if (build.commit.isEmpty()) return "unknown release"
val parts = mutableListOf(build.commit)
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
if (build.dirty) parts.add("(dirty)")
return parts.joinToString(" ")
}
@@ -0,0 +1,287 @@
package pt.naps.maestro.connection
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import pt.naps.maestro.api.EventsConnection
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.api.MaestroWebSocket
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
private const val GRACE_MS = 1_200L
/**
* Owns the connection to one daemon for the app's whole life: liveness,
* identity, and the session catalog. A Kotlin port of the state machine in
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
* — see `docs/RESTART.md` for the contract both mirror — with exponential
* [Backoff] standing in for their flat poll, since a phone's network changes
* are bigger and longer-lived than the LAN blips those clients ride out.
*
* Everything that can change the link — a probe landing, the events socket
* dropping, the app resuming, the network coming back, a new daemon URL —
* goes through one [signals] channel consumed by one coroutine, so state
* transitions never race each other.
*/
class DaemonLink(
context: Context,
private val prefs: DaemonPrefs,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val httpClient: OkHttpClient = OkHttpClient(),
) : DefaultLifecycleObserver {
private val appContext = context.applicationContext
private val _state = MutableStateFlow(LinkState.CONNECTING)
val state: StateFlow<LinkState> = _state.asStateFlow()
private val _build = MutableStateFlow<BuildInfo?>(null)
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
private val _attempts = MutableStateFlow(0)
val attempts: StateFlow<Int> = _attempts.asStateFlow()
val catalog = CatalogStore()
private val backoff = Backoff()
private val signals = Channel<Signal>(Channel.UNLIMITED)
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
private var generation = 0
private var currentBaseUrl: String = prefs.baseUrl.value
private var client = MaestroClient(currentBaseUrl, httpClient)
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
private var identity: DaemonInfo? = null
private var eventsJob: Job? = null
private var eventsConnection: EventsConnection? = null
private var retryJob: Job? = null
private var graceJob: Job? = null
private var loopJob: Job? = null
private var started = false
private sealed class Signal {
object Start : Signal()
object Resume : Signal()
object NetworkAvailable : Signal()
object SocketDropped : Signal()
data class BaseUrlChanged(val url: String) : Signal()
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
data class Lagged(val skipped: Long) : Signal()
}
/** Starts the link: subscribes to process lifecycle and connectivity, and
* kicks off the first probe. Call once. */
fun start() {
if (started) return
started = true
loopJob = scope.launch { runLoop() }
connectivity.start()
scope.launch(Dispatchers.Main.immediate) {
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
}
scope.launch {
prefs.baseUrl.collect { url ->
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
}
}
signals.trySend(Signal.Start)
}
/** Tears everything down. Only needed by tests and by an owner that is
* itself going away for good (this link is meant to outlive an Activity). */
fun close() {
connectivity.stop()
cancelRetry()
cancelGrace()
closeEvents()
loopJob?.cancel()
signals.close()
scope.launch(Dispatchers.Main.immediate) {
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
}
}
override fun onStart(owner: LifecycleOwner) {
signals.trySend(Signal.Resume)
}
private suspend fun runLoop() {
for (signal in signals) {
when (signal) {
is Signal.Start -> probeNow()
is Signal.Resume -> {
probeNow()
ensureEventsConnected()
}
is Signal.NetworkAvailable -> {
cancelRetry()
probeNow()
}
is Signal.SocketDropped -> handleSocketDropped()
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
is Signal.ProbeResult -> {
if (signal.generation == generation) handleProbeResult(signal.info)
}
is Signal.Lagged -> handleLagged()
}
}
}
private fun probeNow() {
val gen = generation
val currentClient = client
scope.launch {
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
signals.trySend(Signal.ProbeResult(gen, info))
}
}
private fun handleProbeResult(info: DaemonInfo?) {
if (info == null) {
onProbeFailed()
return
}
val before = identity
identity = info
_build.value = info.build
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
_state.value = LinkState.UPDATING
fullReconnect()
return
}
backoff.reset()
cancelRetry()
cancelGrace()
_attempts.value = 0
_state.value = LinkState.ONLINE
ensureEventsConnected()
}
private fun onProbeFailed() {
_attempts.value += 1
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
_state.value = LinkState.RECONNECTING
armGrace()
}
scheduleRetry()
}
private fun armGrace() {
if (graceJob?.isActive == true) return
graceJob = scope.launch {
delay(GRACE_MS)
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
}
}
private fun cancelGrace() {
graceJob?.cancel()
graceJob = null
}
private fun scheduleRetry() {
if (retryJob?.isActive == true) return
val delayMs = backoff.nextDelayMs()
retryJob = scope.launch {
delay(delayMs)
retryJob = null
probeNow()
}
}
private fun cancelRetry() {
retryJob?.cancel()
retryJob = null
backoff.reset()
}
/** A build-stamp change: drop the events socket and the catalog it fed,
* then probe again shortly. The re-probe sees the same identity it just
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
* the socket — this *is* the full reconnect. */
private fun fullReconnect() {
closeEvents()
catalog.clear()
backoff.reset()
scope.launch {
delay(200)
probeNow()
}
}
private fun handleSocketDropped() {
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
_state.value = LinkState.RECONNECTING
armGrace()
probeNow()
}
private fun handleBaseUrlChanged(url: String) {
currentBaseUrl = url
generation += 1
client = MaestroClient(url, httpClient)
socket = MaestroWebSocket(url, httpClient)
identity = null
_build.value = null
_attempts.value = 0
backoff.reset()
cancelRetry()
cancelGrace()
closeEvents()
catalog.clear()
_state.value = LinkState.CONNECTING
probeNow()
}
/** The broadcast channel dropped frames: re-fetch the full list over REST
* rather than trusting the map to still be complete. */
private fun handleLagged() {
val currentClient = client
scope.launch {
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
}
}
private fun ensureEventsConnected() {
if (eventsJob?.isActive == true) return
val connection = socket.connectEvents()
eventsConnection = connection
eventsJob = scope.launch {
try {
connection.events.collect { event ->
val skipped = catalog.apply(event)
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
}
signals.trySend(Signal.SocketDropped)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
signals.trySend(Signal.SocketDropped)
}
}
}
private fun closeEvents() {
eventsJob?.cancel()
eventsJob = null
eventsConnection?.close()
eventsConnection = null
}
}
@@ -0,0 +1,40 @@
package pt.naps.maestro.connection
import android.content.Context
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Where the daemon base URL lives between launches. A settings screen and
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
* reads [baseUrl] and reconnects on change.
*/
class DaemonPrefs(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private val _baseUrl =
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
fun setBaseUrl(url: String) {
val trimmed = url.trim()
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
prefs.edit { putString(KEY_BASE_URL, trimmed) }
_baseUrl.value = trimmed
}
companion object {
private const val FILE = "daemon_prefs"
private const val KEY_BASE_URL = "base_url"
/** Shown until the user points the app at their own daemon. */
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
/** Intent extra a launcher may set to point this launch at a specific
* daemon without going through settings. Persisted on read, so it
* sticks after the launch that carried it. */
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
}
}
@@ -0,0 +1,14 @@
package pt.naps.maestro.connection
/**
* Connection lifecycle to the daemon. Mirrors the states in
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
*
* - [CONNECTING] — first probe hasn't landed yet.
* - [ONLINE] — the daemon answered and identity is settled.
* - [RECONNECTING] — the link dropped, still inside the grace window.
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
*/
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
@@ -0,0 +1,43 @@
package pt.naps.maestro.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Session activity, orthogonal to [SessionStatus]. Mirrors
* `maestro_protocol::ActivityState`.
*
* Deserializes leniently: an unknown variant from a newer daemon decodes as
* [Idle] instead of failing the whole [Session].
*/
@Serializable(with = ActivityStateSerializer::class)
enum class ActivityState {
Active,
Idle,
AwaitingInput,
Error,
Starting,
}
object ActivityStateSerializer : KSerializer<ActivityState> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: ActivityState) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): ActivityState =
when (decoder.decodeString()) {
"Active" -> ActivityState.Active
"AwaitingInput" -> ActivityState.AwaitingInput
"Error" -> ActivityState.Error
"Starting" -> ActivityState.Starting
else -> ActivityState.Idle
}
}
@@ -0,0 +1,19 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** Structured failure body inside an [ApiError] envelope. Mirrors
* `maestro_protocol::ErrorInfo`. */
@Serializable
data class ErrorInfo(
val code: String,
val message: String,
/** Set only by the kill guard: what blocked an unforced
* `DELETE /api/sessions/:id`. */
val unsaved_work: InTheWay? = null,
)
/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`.
* Mirrors `maestro_protocol::ApiError`. */
@Serializable
data class ApiError(val error: ErrorInfo)
@@ -0,0 +1,17 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** One local branch of a repository, from `GET /api/branches`. A client
* disables the entry when [is_head] is set or [worktree] is present — git
* refuses to check the same branch out in two worktrees at once. Mirrors
* `maestro_protocol::BranchInfo`. */
@Serializable
data class BranchInfo(
/** Short branch name (`main`, `feature/foo`). */
val name: String,
/** Whether this branch is the main checkout's current HEAD. */
val is_head: Boolean,
/** Toplevel path of the linked worktree holding this branch, if any. */
val worktree: String? = null,
)
@@ -0,0 +1,37 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors
* `maestro_protocol::CatalogEvent`; tagged by a `type` field.
*
* A subscriber receives a single [Snapshot] immediately on connect, then a
* stream of deltas as sessions are created, updated, and killed. Skipped
* events (broadcast channel lag) surface as [Lagged].
*/
@Serializable
sealed class CatalogEvent {
@Serializable
@SerialName("Snapshot")
data class Snapshot(val sessions: List<Session>) : CatalogEvent()
@Serializable
@SerialName("SessionAdded")
data class SessionAdded(val session: Session) : CatalogEvent()
@Serializable
@SerialName("SessionRemoved")
data class SessionRemoved(val id: Uuid) : CatalogEvent()
@Serializable
@SerialName("SessionUpdated")
data class SessionUpdated(val session: Session) : CatalogEvent()
/** Server-issued notification that the broadcast queue dropped frames.
* Clients should re-fetch a full snapshot. */
@Serializable
@SerialName("Lagged")
data class Lagged(val skipped: Long) : CatalogEvent()
}
@@ -0,0 +1,30 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY
* bytes travel in binary frames and are not modeled here. Mirrors
* `maestro_protocol::ClientControl`; tagged by a `type` field. */
@Serializable
sealed class ClientControl {
@Serializable
@SerialName("Resize")
data class Resize(val cols: Int, val rows: Int) : ClientControl()
@Serializable
@SerialName("Ping")
object Ping : ClientControl()
@Serializable
@SerialName("Pong")
object Pong : ClientControl()
@Serializable
@SerialName("Error")
data class Error(
val code: String,
val message: String,
val unsaved_work: InTheWay? = null,
) : ClientControl()
}
@@ -0,0 +1,41 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors
* `maestro_protocol::DaemonInfo`. */
@Serializable
data class DaemonInfo(
val hostname: String = "",
/** The protocol version the daemon implements. */
val version: String = "",
/** Home dir of the user the daemon runs as; empty for older daemons. */
val home_dir: String = "",
val presets: List<SpawnPreset> = emptyList(),
val build: BuildInfo = BuildInfo(),
/** Identity of the daemon *process*, regenerated on every start — a
* changed value across a reconnect means the daemon restarted. */
val instance: String = "",
/** Canonical leader key the operator configured (`[tui].leader`), so a
* client binds the same chord the TUI does. Empty means "use your
* default". */
val leader: String = "",
)
/** A spawn-form command preset advertised by the daemon. */
@Serializable
data class SpawnPreset(
val id: String,
val label: String,
val command: String,
val auto_approve: Boolean = false,
)
/** Build provenance of the running daemon. Every field is empty when the
* binary was built outside a git checkout. */
@Serializable
data class BuildInfo(
val commit: String = "",
val commit_date: String = "",
val dirty: Boolean = false,
)
@@ -0,0 +1,33 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors
* `maestro_protocol::DirListing`. */
@Serializable
data class DirListing(
/** Resolved absolute directory that was listed (after ~/relative
* expansion). */
val path: String,
/** Immediate subdirectory names, sorted case-insensitively. */
val entries: List<String> = emptyList(),
/** True when the listing was capped server-side. */
val truncated: Boolean = false,
)
/** One deduplicated recent working dir for the spawn form's recents picker.
* Mirrors `maestro_protocol::RecentDir`. */
@Serializable
data class RecentDir(
val cwd: String,
val last_command: String,
val last_used: IsoTimestamp,
)
/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first
* and deduped by `cwd`. */
@Serializable
data class RecentDirs(
val dirs: List<RecentDir> = emptyList(),
val truncated: Boolean = false,
)
@@ -0,0 +1,9 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or
* `null` when the path isn't inside a repo. Mirrors
* `maestro_protocol::GitRoot`. */
@Serializable
data class GitRoot(val root: String? = null)
@@ -0,0 +1,7 @@
package pt.naps.maestro.data
/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */
typealias Uuid = String
/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime<Utc>`. */
typealias IsoTimestamp = String
@@ -0,0 +1,17 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** What currently blocks an unforced kill of a session's worktree,
* recomputed by the daemon on every read. Mirrors
* `maestro_protocol::InTheWay`. */
@Serializable
data class InTheWay(
val uncommitted_files: Int = 0,
val untracked_files: Int = 0,
val unpushed_commits: Int = 0,
/** Lines added across the tracked changes, staged and unstaged together. */
val insertions: Int = 0,
/** Lines removed across the same tracked changes. */
val deletions: Int = 0,
)
@@ -0,0 +1,8 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors
* `maestro_protocol::NameBatch`. */
@Serializable
data class NameBatch(val names: List<String>)
@@ -0,0 +1,40 @@
package pt.naps.maestro.data
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Where a session's stored name (`metadata["name"]`) came from. Mirrors
* `maestro_protocol::NameSource`.
*
* Deserializes leniently: an unknown spelling decodes as [User], the settled
* variant — a name wrongly painted as settled is only a missed dimming; one
* wrongly painted as provisional invites overwriting a name a person chose.
*/
@Serializable(with = NameSourceSerializer::class)
enum class NameSource {
User,
Generated,
Haiku,
}
object NameSourceSerializer : KSerializer<NameSource> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: NameSource) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): NameSource =
when (decoder.decodeString()) {
"Generated" -> NameSource.Generated
"Haiku" -> NameSource.Haiku
else -> NameSource.User
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
/** One discovered git repository (or linked worktree) for `GET
* /api/projects`. Mirrors `maestro_protocol::Project`. */
@Serializable
data class Project(
/** Absolute path of the repository/worktree toplevel. */
val path: String,
/** Basename of [path]. */
val name: String,
val is_worktree: Boolean = false,
/** The main checkout this worktree belongs to; `null` on plain repos. */
val base_repo: String? = null,
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
* any directory inside it; `null` when neither is known. */
val last_activity: IsoTimestamp? = null,
/** Command last spawned in this project, from spawn history. */
val last_command: String? = null,
)
@@ -0,0 +1,71 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Default for [Session.last_activity_at] / [Session.activity_since] when an
* older payload omits them — mirrors the daemon's Unix-epoch default. */
const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z"
/** A live or recently-dead tmux session managed by the daemon. Mirrors
* `maestro_protocol::Session`. */
@Serializable
data class Session(
val id: Uuid,
val cwd: String,
val command: String,
val env: Map<String, String> = emptyMap(),
val metadata: Map<String, JsonElement> = emptyMap(),
/** The worktree this session owns, when it was spawned with one. */
val worktree: SessionWorktree? = null,
val status: SessionStatus,
val created_at: IsoTimestamp,
/** Current foreground command in the pane (e.g. `claude`, `bash`), as the
* daemon's activity watcher observed it. `null` until observed. */
val foreground: String? = null,
/** Inferred activity dimension, orthogonal to [status]. */
val activity: ActivityState = ActivityState.Idle,
/** Short human string an agent reported alongside its activity — e.g. why
* it is in [ActivityState.Error]. `null` unless an agent reported one. */
val activity_detail: String? = null,
val last_activity_at: IsoTimestamp = EPOCH,
val activity_since: IsoTimestamp = EPOCH,
/** The pane's *current* working directory, distinct from [cwd] (the spawn
* dir). `null` until first observed. */
val current_cwd: String? = null,
/** What currently blocks an unforced kill of this session, as the
* daemon's watcher last probed it. `null` while clear or not yet probed. */
val blocked_work: InTheWay? = null,
/** When this session last became the pane a client was showing. `null`
* until the session has been focused at least once. */
val last_focused_at: IsoTimestamp? = null,
/** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and
* immutable for the session's life. */
val view: SessionView = SessionView.Terminal,
/** The ACP session id the selected provider adapter returned from
* `session/new`. Only a [SessionView.Structured] session whose handshake
* succeeded has one. */
val acp_session_id: String? = null,
/** When `true`, the daemon auto-approves every ACP permission request for
* this session instead of surfacing them to the client. */
val auto_approve: Boolean = false,
/** Prompts queued on the daemon while a turn was in flight or the adapter
* was still booting. */
val queued_prompts: List<String> = emptyList(),
)
@Serializable
enum class SessionStatus { Running, Dead }
/** A session's process shape: mirrors `maestro_protocol::SessionView`.
* `Terminal` is the default. */
@Serializable
enum class SessionView { Terminal, Structured }
/** A session's daemon-created worktree. `base_repo` is the repository's main
* checkout, which is what the session tree groups by. */
@Serializable
data class SessionWorktree(
val path: String,
val base_repo: String,
)
@@ -0,0 +1,43 @@
package pt.naps.maestro.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** `POST /api/sessions` body. Mirrors
* `maestro_protocol::CreateSessionRequest`. */
@Serializable
data class CreateSessionRequest(
val cwd: String,
val command: String,
val env: Map<String, String> = emptyMap(),
val metadata: Map<String, JsonElement> = emptyMap(),
val worktree: WorktreeSpec? = null,
/** The process shape to spawn. Defaults to a `Terminal` session. */
val view: SessionView = SessionView.Terminal,
/** When `true`, the daemon auto-approves every ACP permission request for
* this structured session. */
val auto_approve: Boolean = false,
)
/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors
* `maestro_protocol::WorktreeSpec`. */
@Serializable
data class WorktreeSpec(
val base_repo: String,
val branch: String,
/** When `false`, [branch] already exists and is checked out as-is.
* Defaults to `true` (create a new branch). */
val create_branch: Boolean = true,
)
/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name]
* clears the custom one, so the card falls back to the command. Mirrors
* `maestro_protocol::RenameSessionRequest`. */
@Serializable
data class RenameSessionRequest(val name: String)
/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is
* the one currently showing this session. Mirrors
* `maestro_protocol::SetFocusRequest`. */
@Serializable
data class SetFocusRequest(val focused: Boolean)
@@ -0,0 +1,136 @@
package pt.naps.maestro.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** The semantic shape of a [TranscriptRow]. Mirrors
* `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
@Serializable
enum class TranscriptRowKind {
@SerialName("user") User,
@SerialName("agent_text") AgentText,
@SerialName("agent_thought") AgentThought,
@SerialName("tool_call") ToolCall,
@SerialName("tool_diff") ToolDiff,
@SerialName("approval") Approval,
@SerialName("plan") Plan,
@SerialName("notice") Notice,
}
/** One client-neutral item in a structured session transcript. [data]
* preserves the ACP payload verbatim without committing to an ACP
* dependency. Mirrors `maestro_protocol::TranscriptRow`. */
@Serializable
data class TranscriptRow(
/** Stable within one session, so later deltas can address this row. */
val id: String,
val kind: TranscriptRowKind,
val data: JsonElement,
)
/** The complete transcript at a durable event-log position — a structured
* session's first `WS /api/structured/:id` message. Mirrors
* `maestro_protocol::TranscriptSnapshot`. */
@Serializable
data class TranscriptSnapshot(
val cursor: Long,
val rows: List<TranscriptRow> = emptyList(),
)
/** A change to one transcript row, addressed by [id]. Mirrors
* `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */
@Serializable
sealed class TranscriptDelta {
@Serializable
@SerialName("append")
data class Append(val row: TranscriptRow) : TranscriptDelta()
@Serializable
@SerialName("patch")
data class Patch(val row: TranscriptRow) : TranscriptDelta()
@Serializable
@SerialName("remove")
data class Remove(val id: String) : TranscriptDelta()
}
/** Per-turn indicators derived from the ACP stream — read verbatim, never
* re-derived client-side. Mirrors `maestro_protocol::TurnState`. */
@Serializable
data class TurnState(
val responding: Boolean = false,
val thinking: Boolean = false,
val active_tool_calls: List<String> = emptyList(),
/** Wall-clock start of the active prompt, so a reconnecting client keeps
* one continuous elapsed-time display instead of restarting at zero. */
val started_at: IsoTimestamp? = null,
val started_context_tokens: Long? = null,
)
/**
* A whole-session view derived from ACP notifications. Mirrors
* `maestro_protocol::ReducedState`.
*
* [pending_approval], [plan], [usage], [context] and [config_options] carry
* opaque ACP JSON — their shape is ACP's, not covered by this crate's own
* compatibility guarantee, so they are kept as [JsonElement] rather than
* modeled strongly.
*/
@Serializable
data class ReducedState(
val turn: TurnState = TurnState(),
val pending_approval: JsonElement? = null,
val plan: JsonElement? = null,
val usage: JsonElement? = null,
val context: JsonElement? = null,
val model: String? = null,
val mode: String? = null,
val config_options: JsonElement? = null,
/** Prompts queued on the daemon while a turn is in flight. */
val queued_prompts: List<String> = emptyList(),
)
/** Server messages on `WS /api/structured/:id`. Mirrors
* `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */
@Serializable
sealed class StructuredEvent {
@Serializable
@SerialName("transcript_snapshot")
data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent()
@Serializable
@SerialName("reduced_state")
data class ReducedStateEvent(val state: ReducedState) : StructuredEvent()
@Serializable
@SerialName("projection_update")
data class ProjectionUpdate(
val transcript: TranscriptDelta? = null,
val state: ReducedState,
) : StructuredEvent()
}
/** `POST /api/sessions/:id/structured/prompt` body. */
@Serializable
data class StructuredPromptRequest(val prompt: String)
/** `POST /api/sessions/:id/structured/config` body: change one option
* advertised by a live ACP session. */
@Serializable
data class StructuredConfigRequest(val config_id: String, val value: JsonElement)
/** Operator decision for a pending ACP permission request
* (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON
* string on the wire, not `{decision: ...}`. Mirrors
* `maestro_protocol::StructuredApprovalDecision`. */
@Serializable
enum class StructuredApprovalDecision {
@SerialName("allow_once") AllowOnce,
@SerialName("allow_for_session") AllowForSession,
@SerialName("deny") Deny,
}
/** Response of `POST /api/sessions/:id/structured/attachments`. */
@Serializable
data class StructuredAttachmentResponse(val path: String)
@@ -0,0 +1,91 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.InfiniteTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.ActivityState
/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in
* density-independent pixels rather than raw pixels. */
val ACTIVITY_RAIL_WIDTH = 7.dp
private val RAIL_ACTIVE = Color(0xFF3DDC84)
private val RAIL_IDLE = Color(0xFF9AA0A6)
private val RAIL_AWAITING = Color(0xFFF5A623)
private val RAIL_ERROR = Color(0xFFE5484D)
/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting`
* shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so
* rendering never fails (Precedence). */
fun activityColor(activity: ActivityState): Color =
when (activity) {
ActivityState.Active -> RAIL_ACTIVE
ActivityState.AwaitingInput -> RAIL_AWAITING
ActivityState.Error -> RAIL_ERROR
ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE
}
/** The accessible name the rail carries, since colour is never the only
* signal (docs/SIDEBAR.md). */
fun activityStateWord(activity: ActivityState): String =
when (activity) {
ActivityState.Active -> "Active"
ActivityState.Idle -> "Idle"
ActivityState.Starting -> "Starting"
ActivityState.AwaitingInput -> "Awaiting input"
ActivityState.Error -> "Error"
}
/** A held bar that pulses its alpha between 1 and [minAlpha] every
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
@Composable
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = minAlpha,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = periodMs, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "activity-rail-alpha",
)
return alpha
}
/** The card's rail: a coloured bar in the gutter, full card height. It paints
* for every card, `Idle` included, and carries motion only for states that
* need attention — a hard blink for `AwaitingInput`, a fast flash for
* `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */
@Composable
fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
val alpha = when (activity) {
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
else -> 1f
}
Box(
modifier = modifier
.fillMaxHeight()
.width(ACTIVITY_RAIL_WIDTH)
.semantics { contentDescription = activityStateWord(activity) }
.background(color.copy(alpha = alpha)),
)
}
@@ -0,0 +1,58 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded
* folder's cards are one row away, so it carries no highlight of its own —
* only a collapsed header is ever a keyboard-walk stop, but that distinction
* belongs to the caller driving the walk, not this composable. */
@Composable
fun FolderNode(
label: String,
collapsed: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
depthPadding: Int = 0,
content: @Composable () -> Unit,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (collapsed) "▸" else "▾",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
AnimatedVisibility(visible = !collapsed) {
Column { content() }
}
}
}
@@ -0,0 +1,242 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
/** docs/SIDEBAR.md: a card is always two lines behind an activity rail —
* line 1 the name, line 2 the adaptive meta line. Long-press opens a context
* menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill
* only through the callbacks the caller supplies. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SessionCard(
session: Session,
nowMs: Long,
modifier: Modifier = Modifier,
depthPadding: Int = 0,
onOpen: (Session) -> Unit = {},
onRename: (Session, String) -> Unit = { _, _ -> },
onKill: (Session) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
var menuExpanded by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf(false) }
var confirmingKill by remember { mutableStateOf(false) }
val running = session.status == SessionStatus.Running
val name = sessionName(session)
val muted = isProvisionalName(session)
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onOpen(session) },
onLongClick = { menuExpanded = true },
)
.padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp))
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = when {
!running -> MaterialTheme.colorScheme.onSurfaceVariant
muted -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
},
style = MaterialTheme.typography.bodyMedium,
)
MetaLine(session = session, nowMs = nowMs)
}
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
menuExpanded = false
renaming = true
},
)
DropdownMenuItem(
text = { Text("Spawn similar") },
onClick = {
menuExpanded = false
onSpawnSimilar(session)
},
)
DropdownMenuItem(
text = { Text("Kill") },
onClick = {
menuExpanded = false
confirmingKill = true
},
)
}
if (renaming) {
RenameDialog(
initial = customSessionName(session) ?: "",
onCommit = {
renaming = false
onRename(session, it)
},
onCancel = { renaming = false },
)
}
if (confirmingKill) {
KillConfirmDialog(
session = session,
onConfirm = {
confirmingKill = false
onKill(session)
},
onCancel = { confirmingKill = false },
)
}
}
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`
* — the line itself is unconditional, only its fragments are adaptive. */
@Composable
fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) {
val heldMs = activityHeldMs(session.activity_since, nowMs)
val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) {
activityDurationLabel(heldMs)
} else {
null
}
val blockers = sessionBlockers(session)
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (duration != null) {
Text(
text = duration,
color = activityColor(session.activity),
style = MaterialTheme.typography.labelSmall,
)
Spacer(Modifier.width(6.dp))
}
if (session.activity_detail != null) {
Text(
text = session.activity_detail,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(6.dp))
}
Text(
text = programName(session.command),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
)
if (blockers != null) {
Spacer(Modifier.width(6.dp))
BlockedFragment(blockers)
}
if (session.status == SessionStatus.Dead) {
Spacer(Modifier.width(6.dp))
Text(
text = "exited",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun BlockedFragment(blockers: InTheWay) {
Row(verticalAlignment = Alignment.CenterVertically) {
blockerStats(blockers).forEachIndexed { index, stat ->
if (index > 0) Spacer(Modifier.width(4.dp))
Text(
text = stat.text,
style = MaterialTheme.typography.labelSmall,
color = when (stat.kind) {
"add" -> MaterialTheme.colorScheme.primary
"del" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
@Composable
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Rename session") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
},
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@Composable
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
val blockers = sessionBlockers(session)
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Kill “${sessionName(session)}”?") },
text = {
Text(
if (blockers != null) {
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
} else {
"This ends the session's process. This can't be undone."
},
)
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@@ -0,0 +1,161 @@
package pt.naps.maestro.ui.sidebar
import java.time.Instant
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.NameSource
import pt.naps.maestro.data.Session
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
/** Payloads predating #102 default `activity_since` to the Unix epoch;
* anything implausibly old is "never observed" rather than a huge duration. */
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
/** Milliseconds held in the current activity state, or `null` when `since` is
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
fun activityHeldMs(since: String?, nowMs: Long): Long? {
if (since.isNullOrEmpty()) return null
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
if (t < PLAUSIBLE_EPOCH_MS) return null
return (nowMs - t).coerceAtLeast(0L)
}
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
* everywhere so the counter never shows a duration the state hasn't reached. */
fun activityDurationLabel(heldMs: Long): String {
val seconds = heldMs / 1000
if (seconds < 60) return "${seconds}s"
val minutes = seconds / 60
if (minutes < 60) return "${minutes}m"
return "${minutes / 60}h"
}
/** The session's own name (#125), without the command fallback: `null` for a
* session that was never given one. */
fun customSessionName(session: Session): String? {
val element = session.metadata["name"] ?: return null
val primitive = element as? JsonPrimitive ?: return null
if (!primitive.isString) return null
return primitive.content.takeIf { it.isNotEmpty() }
}
/** Line 1's name: `metadata.name` if present, else the command line. */
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
/** Where the session's name came from (#131), read leniently: anything
* unrecognised — including a session named before the field existed — reads
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
fun sessionNameSource(session: Session): NameSource {
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
return when (element.jsonPrimitive.content) {
"Generated" -> NameSource.Generated
"Haiku" -> NameSource.Haiku
else -> NameSource.User
}
}
/** Whether line 1 renders the name muted (#131): a handle the daemon
* generated at spawn and may still replace with one derived from the first
* prompt. */
fun isProvisionalName(session: Session): Boolean =
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
* picks the colour; `text` is what the card prints. */
data class BlockerStat(val kind: String, val text: String)
/** #83: this session's blocked-work breakdown when something actually blocks
* an unforced kill; `null` while clear or not yet probed. */
fun sessionBlockers(session: Session): InTheWay? {
val w = session.blocked_work ?: return null
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
return if (blocked) w else null
}
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
* only tracked change carries no line delta falls back to the file count
* (`~2`) rather than claiming `+0 -0`. */
fun blockerStats(w: InTheWay): List<BlockerStat> {
val stats = mutableListOf<BlockerStat>()
if (w.insertions > 0 || w.deletions > 0) {
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
} else if (w.uncommitted_files > 0) {
stats += BlockerStat("files", "~${w.uncommitted_files}")
}
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
return stats
}
/** The spelled-out phrasing shared by the kill refusal and every client's
* tooltip, so no two describe the same tree differently. */
fun formatInTheWay(w: InTheWay): String {
val parts = mutableListOf<String>()
fun push(n: Int, unit: String) {
if (n <= 0) return
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
}
push(w.uncommitted_files, "uncommitted file")
push(w.untracked_files, "untracked file")
push(w.unpushed_commits, "unpushed commit")
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
}
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
private val PLAIN_WRAPPERS = setOf("sudo", "env")
private val DLX_CAPABLE = setOf("pnpm", "yarn")
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
* dlx`), then basenames what is left. A command that is nothing but wrappers
* is returned as written rather than as an empty string. */
fun programName(command: String): String {
val trimmed = command.trim()
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (words.isEmpty()) return trimmed
var i = 0
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
i++
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
}
if (i >= words.size) return basename(words.last())
when {
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
words[i] == "npx" || words[i] == "bunx" -> i += 1
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
}
val program = words.getOrNull(i) ?: return basename(words.last())
return basename(program)
}
private fun basename(path: String): String = path.substringAfterLast('/')
/** Sessions are grouped by the repository they belong to (#265): a
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
* sorted by name. */
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
/** Group sessions by [sessionGroupKey], each group sorted by name and the
* groups themselves sorted by label. */
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
sessions
.groupBy(::sessionGroupKey)
.map { (key, members) ->
SessionGroup(
key = key,
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
sessions = members.sortedBy { sessionName(it).lowercase() },
)
}
.sortedBy { it.label.lowercase() }
@@ -0,0 +1,123 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import pt.naps.maestro.connection.CatalogStore
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.Uuid
/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked
* faster than that so a duration's cutover lands within one visible frame. */
@Composable
fun rememberTickingNowMs(periodMs: Long = 5_000L): Long {
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(periodMs) {
while (true) {
delay(periodMs)
now = System.currentTimeMillis()
}
}
return now
}
/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions
* grouped into collapsible folders by repository, each rendered as a
* two-line [SessionCard] behind its [ActivityRail]. */
@Composable
fun SidebarContent(
catalogStore: CatalogStore,
onOpenSession: (Session) -> Unit,
modifier: Modifier = Modifier,
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
onKillSession: (Uuid) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
val sessions by catalogStore.sessions.collectAsState()
SidebarContent(
sessions = sessions.values.toList(),
onOpenSession = onOpenSession,
modifier = modifier,
onRenameSession = onRenameSession,
onKillSession = onKillSession,
onSpawnSimilar = onSpawnSimilar,
)
}
/** Same as the [CatalogStore]-driven overload, but takes the session list
* directly — the shape used by tests and previews. */
@Composable
fun SidebarContent(
sessions: List<Session>,
onOpenSession: (Session) -> Unit,
modifier: Modifier = Modifier,
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
onKillSession: (Uuid) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
val nowMs = rememberTickingNowMs()
val groups = remember(sessions) { groupSessions(sessions) }
val collapsed = remember { mutableStateMapOf<String, Boolean>() }
Column(modifier = modifier.fillMaxSize()) {
Text(
text = "Sessions",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
if (groups.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(
text = "No sessions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
for (group in groups) {
val isCollapsed = collapsed[group.key] == true
item(key = "folder:${group.key}") {
FolderNode(
label = group.label,
collapsed = isCollapsed,
onToggle = { collapsed[group.key] = !isCollapsed },
depthPadding = 8,
) {
Column {
for (session in group.sessions) {
SessionCard(
session = session,
nowMs = nowMs,
depthPadding = 16,
onOpen = onOpenSession,
onRename = { s, name -> onRenameSession(s.id, name) },
onKill = { s -> onKillSession(s.id) },
onSpawnSimilar = onSpawnSimilar,
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,117 @@
package pt.naps.maestro.ui.spawn
import kotlinx.serialization.json.JsonPrimitive
import pt.naps.maestro.data.CreateSessionRequest
import pt.naps.maestro.data.SessionView
import pt.naps.maestro.data.SpawnPreset
import pt.naps.maestro.data.WorktreeSpec
/** Tools whose sessions can render as a structured (ACP) transcript instead
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
* `web/src/components/spawn/model.ts`. */
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
/** Commands that just launch another program, looked through to find the
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
/** Program a command line would run, stripped of leading `FOO=bar`
* assignments, known wrappers, its own arguments, and any path prefix. A
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
* client uses — good enough for the preset commands and the common
* `env FOO=1 claude` shapes the spawn form actually produces. */
internal fun resolveProgram(command: String): String {
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
var i = 0
while (i < words.size) {
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
if (i >= words.size) return ""
val word = words[i].substringAfterLast('/').lowercase()
if (word !in WRAPPER_PROGRAMS) return word
i++
while (i < words.size && words[i].startsWith("-")) i++
}
return ""
}
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
* `web/src/components/spawn/model.ts`. */
enum class WorktreeBranchMode { New, Existing }
/** The spawn form's current values, gathered at submit time. Mirrors
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
data class SpawnFormValues(
val command: String,
val cwd: String,
val name: String,
val structuredView: Boolean,
val autoApprove: Boolean,
val worktreeEnabled: Boolean,
val worktreeBranch: String,
val worktreeBranchMode: WorktreeBranchMode,
)
sealed interface SpawnBuildResult {
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
data class Error(val message: String) : SpawnBuildResult
}
/** Turns validated form values into a `POST /api/sessions` body, or an error
* message when something required is missing. Mirrors `buildSpawnRequest` in
* `web/src/components/spawn/model.ts`. */
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
val name = values.name.trim()
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
var worktree: WorktreeSpec? = null
if (values.worktreeEnabled) {
val branch = values.worktreeBranch.trim()
if (baseRepo.isNullOrBlank()) {
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
}
if (branch.isEmpty()) {
return SpawnBuildResult.Error(
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
else "Worktree requires a branch name.",
)
}
worktree = WorktreeSpec(
base_repo = baseRepo,
branch = branch,
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
)
}
return SpawnBuildResult.Ok(
CreateSessionRequest(
cwd = values.cwd,
command = values.command,
metadata = metadata,
worktree = worktree,
view = if (values.structuredView && supportsStructuredView(values.command)) {
SessionView.Structured
} else {
SessionView.Terminal
},
auto_approve = values.autoApprove,
),
)
}
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
SpawnPreset(id = "custom", label = "Custom…", command = ""),
)
@@ -0,0 +1,432 @@
package pt.naps.maestro.ui.spawn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.BranchInfo
import pt.naps.maestro.data.Project
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SpawnPreset
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
* a preset chip row, a searchable project list, a raw-path autocomplete
* fallback, a branch/worktree picker, a session name, and the
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
* [SpawnSheetContent] is the pure, state-driven body used by previews and
* tests. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpawnSheet(
viewModel: SpawnViewModel,
onDismiss: () -> Unit,
onSpawned: (Session) -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
val state by viewModel.state.collectAsState()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
SpawnSheetContent(
state = state,
onSelectPreset = viewModel::selectPreset,
onCustomCommandChange = viewModel::setCustomCommand,
onStructuredViewChange = viewModel::setStructuredView,
onProjectQueryChange = viewModel::setProjectQuery,
onSelectProject = viewModel::selectProject,
onCwdChange = viewModel::setCwd,
onPickDirSuggestion = viewModel::pickDirSuggestion,
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
onBranchModeChange = viewModel::setBranchMode,
onNewBranchChange = viewModel::setNewBranch,
onExistingBranchChange = viewModel::setExistingBranch,
onNameChange = viewModel::setName,
onReroll = viewModel::rerollName,
onDismissError = viewModel::dismissError,
onSubmit = { viewModel.submit(onSpawned) },
)
}
}
@Composable
fun SpawnSheetContent(
state: SpawnUiState,
onSelectPreset: (String) -> Unit,
onCustomCommandChange: (String) -> Unit,
onStructuredViewChange: (Boolean) -> Unit,
onProjectQueryChange: (String) -> Unit,
onSelectProject: (Project) -> Unit,
onCwdChange: (String) -> Unit,
onPickDirSuggestion: (String) -> Unit,
onWorktreeEnabledChange: (Boolean) -> Unit,
onBranchModeChange: (WorktreeBranchMode) -> Unit,
onNewBranchChange: (String) -> Unit,
onExistingBranchChange: (String) -> Unit,
onNameChange: (String) -> Unit,
onReroll: () -> Unit,
onDismissError: () -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.error) {
val message = state.error ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
onDismissError()
}
Box(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
PresetRow(
presets = state.presets,
selectedPresetId = state.selectedPresetId,
onSelectPreset = onSelectPreset,
)
if (state.isCustomSelected) {
OutlinedTextField(
value = state.customCommand,
onValueChange = onCustomCommandChange,
label = { Text("Command") },
placeholder = { Text("bash -l") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
ProjectPicker(
query = state.projectQuery,
onQueryChange = onProjectQueryChange,
projects = state.visibleProjects,
loading = state.projectsLoading,
selectedPath = state.selectedProjectPath,
homeDir = state.homeDir,
onSelectProject = onSelectProject,
)
DirAutocompleteField(
cwd = state.cwd,
suggestions = state.dirSuggestions,
onCwdChange = onCwdChange,
onPickSuggestion = onPickDirSuggestion,
)
BranchSection(
enabled = state.worktreeEnabled,
onEnabledChange = onWorktreeEnabledChange,
baseRepo = state.baseRepo,
mode = state.branchMode,
onModeChange = onBranchModeChange,
newBranch = state.newBranch,
onNewBranchChange = onNewBranchChange,
branches = state.branches,
branchesLoading = state.branchesLoading,
existingBranch = state.effectiveExistingBranch,
onExistingBranchChange = onExistingBranchChange,
)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = state.name,
onValueChange = onNameChange,
label = { Text("Session name") },
placeholder = { Text("named after your first prompt") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
TextButton(onClick = onReroll) { Text("Reroll") }
}
if (supportsStructuredView(state.command)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
Text("Structured view")
}
}
Button(
onClick = onSubmit,
enabled = !state.submitting,
modifier = Modifier.fillMaxWidth(),
) {
if (state.submitting) {
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
} else {
Text("Spawn")
}
}
}
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
Snackbar(snackbarData = it)
}
}
}
/** A row is its own small touch target: tap anywhere in it, not just the
* leading control, to select it. */
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
this.clickable(enabled = enabled, onClick = onClick)
@Composable
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Harness", style = MaterialTheme.typography.labelLarge)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
for (preset in presets) {
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
FilterChip(
selected = preset.id == selectedPresetId,
onClick = { onSelectPreset(preset.id) },
label = { Text(label) },
)
}
}
}
}
@Composable
private fun ProjectPicker(
query: String,
onQueryChange: (String) -> Unit,
projects: List<Project>,
loading: Boolean,
selectedPath: String?,
homeDir: String,
onSelectProject: (Project) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Project", style = MaterialTheme.typography.labelLarge)
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Filter projects…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
when {
loading -> Text(
"Loading projects…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
projects.isEmpty() -> Text(
"No projects match.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
items(projects, key = { it.path }) { project ->
ProjectRow(
project = project,
active = project.path == selectedPath,
homeDir = homeDir,
onClick = { onSelectProject(project) },
)
}
}
}
}
}
@Composable
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(background)
.clickableRow(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
abbreviateHome(parentDir(project.path), homeDir),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun abbreviateHome(path: String, homeDir: String): String =
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
@Composable
private fun DirAutocompleteField(
cwd: String,
suggestions: List<String>,
onCwdChange: (String) -> Unit,
onPickSuggestion: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedTextField(
value = cwd,
onValueChange = onCwdChange,
label = { Text("Or type a directory") },
placeholder = { Text("/home/user/project") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
items(suggestions) { entry ->
Text(
text = "$entry/",
modifier = Modifier
.fillMaxWidth()
.clickableRow { onPickSuggestion(entry) }
.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
@Composable
private fun BranchSection(
enabled: Boolean,
onEnabledChange: (Boolean) -> Unit,
baseRepo: String?,
mode: WorktreeBranchMode,
onModeChange: (WorktreeBranchMode) -> Unit,
newBranch: String,
onNewBranchChange: (String) -> Unit,
branches: List<BranchInfo>,
branchesLoading: Boolean,
existingBranch: String,
onExistingBranchChange: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
Text("Create git worktree")
}
if (!enabled) return@Column
if (baseRepo == null) {
Text(
"Choose a directory inside a git repository first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
Text("New branch")
Spacer(Modifier.width(16.dp))
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
Text("Existing branch")
}
if (mode == WorktreeBranchMode.New) {
OutlinedTextField(
value = newBranch,
onValueChange = onNewBranchChange,
label = { Text("New branch name") },
placeholder = { Text("feat/foo") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
} else {
when {
branchesLoading -> Text(
"Loading branches…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
branches.isEmpty() -> Text(
"No local branches found.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
items(branches, key = { it.name }) { branch ->
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
RadioButton(
selected = branch.name == existingBranch,
onClick = { onExistingBranchChange(branch.name) },
enabled = held == null,
)
Column {
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
if (held != null) {
Text(
"held by $held",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,283 @@
package pt.naps.maestro.ui.spawn
import java.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import pt.naps.maestro.api.MaestroApiException
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.data.BranchInfo
import pt.naps.maestro.data.Project
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SpawnPreset
/** Quiet period after the operator types a raw path before `/api/dirs` and
* `/api/gitroot` fire (#269). */
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
* `parentDir` in `web/src/paths.ts`. */
internal fun parentDir(path: String): String {
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
val slash = trimmed.lastIndexOf('/')
return when {
slash < 0 -> ""
slash == 0 -> "/"
else -> trimmed.substring(0, slash)
}
}
/** Splits a path into its directory and trailing (possibly partial) segment.
* Mirrors `splitPath` in `web/src/paths.ts`. */
internal fun splitPath(value: String): Pair<String, String> {
val slash = value.lastIndexOf('/')
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
}
data class SpawnUiState(
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
val selectedPresetId: String = "",
val customCommand: String = "",
val commandTouched: Boolean = false,
val structuredView: Boolean = false,
val structuredViewTouched: Boolean = false,
val homeDir: String = "",
val projects: List<Project> = emptyList(),
val projectsLoading: Boolean = true,
val projectQuery: String = "",
val selectedProjectPath: String? = null,
val cwd: String = "",
val dirSuggestions: List<String> = emptyList(),
val baseRepo: String? = null,
val worktreeEnabled: Boolean = false,
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
val newBranch: String = "",
val existingBranch: String = "",
val branches: List<BranchInfo> = emptyList(),
val branchesLoading: Boolean = false,
val name: String = "",
val submitting: Boolean = false,
val error: String? = null,
) {
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
val visibleProjects: List<Project>
get() {
val query = projectQuery.trim().lowercase()
val filtered = if (query.isEmpty()) {
projects
} else {
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
}
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
}
val effectiveExistingBranch: String
get() = existingBranch.ifEmpty {
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
}
}
/**
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
* for a short-lived form, and this keeps the class trivially testable with a
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
*/
class SpawnViewModel(
private val client: MaestroClient,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val _state = MutableStateFlow(SpawnUiState())
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
private var dirJob: Job? = null
private var branchesJob: Job? = null
init {
scope.launch {
val info = runCatching { client.getDaemonInfo() }.getOrNull()
_state.update { s ->
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
} else {
s.selectedPresetId
}
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
.withSyncedStructuredView()
}
}
scope.launch {
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
_state.update { it.copy(projects = projects, projectsLoading = false) }
}
}
fun selectPreset(id: String) {
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
}
fun setCustomCommand(text: String) {
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
}
fun setStructuredView(enabled: Boolean) {
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
}
/** Re-derives the structured-view checkbox from the command this state
* carries, unless the operator has already ticked/unticked it by hand. */
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
fun setProjectQuery(query: String) {
_state.update { it.copy(projectQuery = query) }
}
fun selectProject(project: Project) {
dirJob?.cancel()
_state.update {
it.copy(
selectedProjectPath = project.path,
cwd = project.path,
dirSuggestions = emptyList(),
baseRepo = project.base_repo ?: project.path,
branches = emptyList(),
existingBranch = "",
)
}
}
/** The operator typed a path by hand: clears any project pick, and
* schedules a debounced refresh of both the directory-suggestion
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
fun setCwd(raw: String) {
_state.update {
it.copy(
cwd = raw,
selectedProjectPath = null,
baseRepo = null,
branches = emptyList(),
existingBranch = "",
)
}
dirJob?.cancel()
dirJob = scope.launch {
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
refreshDirSuggestions(raw)
refreshBaseRepo(raw)
}
}
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
* drill-in gesture the web `DirAutocomplete` uses. */
fun pickDirSuggestion(entry: String) {
val (dir, _) = splitPath(_state.value.cwd)
setCwd("$dir$entry/")
}
private suspend fun refreshDirSuggestions(raw: String) {
val (dir, prefix) = splitPath(raw)
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
if (_state.value.cwd != raw) return // a newer edit superseded this one
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
_state.update { it.copy(dirSuggestions = filtered) }
}
private suspend fun refreshBaseRepo(raw: String) {
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
if (_state.value.cwd != raw) return // a newer edit superseded this one
_state.update { it.copy(baseRepo = root) }
}
fun setWorktreeEnabled(enabled: Boolean) {
_state.update { it.copy(worktreeEnabled = enabled) }
}
fun setBranchMode(mode: WorktreeBranchMode) {
_state.update { it.copy(branchMode = mode) }
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
}
fun setNewBranch(text: String) {
_state.update { it.copy(newBranch = text) }
}
fun setExistingBranch(text: String) {
_state.update { it.copy(existingBranch = text) }
}
private fun loadBranchesIfNeeded() {
val cwd = _state.value.cwd
if (cwd.isEmpty() || _state.value.branchesLoading) return
branchesJob?.cancel()
_state.update { it.copy(branchesLoading = true) }
branchesJob = scope.launch {
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
_state.update { it.copy(branches = branches, branchesLoading = false) }
}
}
fun setName(text: String) {
_state.update { it.copy(name = text) }
}
/** `GET /api/names?count=1`: replaces the session-name field with a
* freshly generated handle. */
fun rerollName() {
scope.launch {
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
if (handle != null) _state.update { it.copy(name = handle) }
}
}
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
* session. Any failure — validation or API — lands in
* [SpawnUiState.error] instead. */
fun submit(onSuccess: (Session) -> Unit) {
val s = _state.value
val values = SpawnFormValues(
command = s.command,
cwd = s.cwd,
name = s.name,
structuredView = s.structuredView,
autoApprove = s.activePreset?.auto_approve ?: false,
worktreeEnabled = s.worktreeEnabled,
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
worktreeBranchMode = s.branchMode,
)
when (val built = buildSpawnRequest(values, s.baseRepo)) {
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
is SpawnBuildResult.Ok -> {
_state.update { it.copy(submitting = true, error = null) }
scope.launch {
try {
val session = client.createSession(built.request)
_state.update { it.copy(submitting = false) }
onSuccess(session)
} catch (e: MaestroApiException) {
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
} catch (e: Exception) {
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
}
}
}
}
}
fun dismissError() {
_state.update { it.copy(error = null) }
}
}
@@ -0,0 +1,76 @@
package pt.naps.maestro.ui.terminal
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
* in the terminal page's own JS instead of sending a byte sequence. */
sealed class TerminalKeyAction(val label: String) {
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
}
/**
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
* — armed here, then folded into the very next key (tapped on this bar or
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
*/
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
TerminalKeyAction.Send("Esc", "Esc"),
TerminalKeyAction.Send("Tab", "Tab"),
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
TerminalKeyAction.Modifier("alt", "Alt"),
TerminalKeyAction.Send("↑", "Up"),
TerminalKeyAction.Send("↓", "Down"),
TerminalKeyAction.Send("←", "Left"),
TerminalKeyAction.Send("→", "Right"),
)
@Composable
fun TerminalKeyBar(
armed: Set<String>,
onSend: (String) -> Unit,
onToggleModifier: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
for (action in TERMINAL_KEY_ACTIONS) {
when (action) {
is TerminalKeyAction.Modifier -> {
val isArmed = action.name in armed
TextButton(onClick = { onToggleModifier(action.name) }) {
Text(
text = action.display,
color = if (isArmed) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
is TerminalKeyAction.Send -> {
TextButton(onClick = { onSend(action.key) }) {
Text(text = action.display)
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.ui.terminal
import java.net.URLEncoder
import pt.naps.maestro.api.Routes
import pt.naps.maestro.data.Uuid
/**
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
* loads for one session. The embedded xterm.js page reads its `ws` query
* param and opens that as its own `WS /api/attach/:id` connection directly
* — this object only computes the URL, it never touches the socket.
*/
object TerminalPageUrl {
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
return "$ASSET_URL?ws=$encoded"
}
}
@@ -0,0 +1,83 @@
package pt.naps.maestro.ui.terminal
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import pt.naps.maestro.data.Uuid
/**
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun TerminalScreen(
daemonBaseUrl: String,
sessionId: Uuid,
sessionTitle: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
var armed by remember { mutableStateOf(emptySet<String>()) }
val keyboardOpen = WindowInsets.isImeVisible
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(sessionTitle) },
navigationIcon = {
IconButton(onClick = onBack) {
Text(text = "←", style = MaterialTheme.typography.titleLarge)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
TerminalWebView(
pageUrl = pageUrl,
modifier = Modifier
.weight(1f)
.fillMaxSize(),
onReady = { controller = it },
onModifierConsumed = { name -> armed = armed - name },
)
if (keyboardOpen) {
TerminalKeyBar(
armed = armed,
onSend = { key ->
controller?.sendKey(key)
armed = emptySet()
},
onToggleModifier = { name ->
armed = if (name in armed) armed - name else armed + name
controller?.toggleModifier(name)
},
)
}
}
}
}
@@ -0,0 +1,89 @@
package pt.naps.maestro.ui.terminal
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.json.JSONObject
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
* xterm theme, so the WebView doesn't flash white while the page loads. */
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
/**
* Drives the terminal page loaded into a [TerminalWebView] from native code
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
* this controller only calls the `maestro*` bridge functions it exposes.
*/
class TerminalWebViewController internal constructor(private val webView: WebView) {
fun sendKey(key: String) {
val arg = JSONObject.quote(key)
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
}
fun toggleModifier(name: String) {
val arg = JSONObject.quote(name)
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
}
fun focus() {
webView.requestFocus()
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
}
}
/**
* Terminal pane composable for issue #266: a [WebView] loading the bundled
* xterm.js page (`assets/terminal/index.html`), which connects straight to
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
* the soft keyboard, so the native key bar can clear its toggle to match.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun TerminalWebView(
pageUrl: String,
modifier: Modifier = Modifier,
onReady: (TerminalWebViewController) -> Unit = {},
onModifierConsumed: (String) -> Unit = {},
) {
val mainHandler = remember { Handler(Looper.getMainLooper()) }
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
addJavascriptInterface(
object {
@JavascriptInterface
fun onModConsumed(name: String) {
mainHandler.post { onModifierConsumed(name) }
}
},
"AndroidBridge",
)
onReady(TerminalWebViewController(this))
loadUrl(pageUrl)
}
},
update = { webView ->
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
},
onRelease = { it.destroy() },
)
}
@@ -0,0 +1,11 @@
package pt.naps.maestro.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,46 @@
package pt.naps.maestro.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
)
@Composable
fun MaestroTheme(
darkTheme: Boolean = true,
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val useDarkTheme = darkTheme || isSystemInDarkTheme()
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
useDarkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,17 @@
package pt.naps.maestro.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
)
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1B1B1B"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Maestro</string>
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,11 @@
package pt.naps.maestro
import org.junit.Assert.assertEquals
import org.junit.Test
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Test
class BackoffTest {
@Test
fun `doubles from 500ms and caps at 10s`() {
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
val delays = List(8) { backoff.nextDelayMs() }
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
}
@Test
fun `reset returns to the initial delay`() {
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
repeat(4) { backoff.nextDelayMs() }
backoff.reset()
assertEquals(500L, backoff.nextDelayMs())
}
}
@@ -0,0 +1,60 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
class CatalogStoreTest {
private fun session(id: String) = Session(
id = id,
cwd = "/tmp",
command = "bash",
status = SessionStatus.Running,
created_at = "2026-05-24T12:00:00Z",
)
@Test
fun `snapshot replaces the map keyed by id`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
assertEquals(setOf("a", "b"), store.sessions.value.keys)
}
@Test
fun `added and updated upsert one entry`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(emptyList()))
store.apply(CatalogEvent.SessionAdded(session("a")))
assertEquals(1, store.sessions.value.size)
val renamed = session("a").copy(command = "zsh")
store.apply(CatalogEvent.SessionUpdated(renamed))
assertEquals("zsh", store.sessions.value.getValue("a").command)
}
@Test
fun `removed drops the entry`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
store.apply(CatalogEvent.SessionRemoved("a"))
assertEquals(setOf("b"), store.sessions.value.keys)
}
@Test
fun `lagged reports the skip count without touching the map`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
val skipped = store.apply(CatalogEvent.Lagged(3))
assertEquals(3L, skipped)
assertEquals(setOf("a"), store.sessions.value.keys)
}
@Test
fun `non-lagged events report no skip`() {
val store = CatalogStore()
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
}
}
@@ -0,0 +1,55 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Test
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
class DaemonIdentityTest {
private fun info(commit: String = "", instance: String = "") =
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
@Test
fun `same commit and instance is Same`() {
val a = info(commit = "abc123", instance = "i1")
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
}
@Test
fun `different commit is Upgraded`() {
val before = info(commit = "abc123", instance = "i1")
val after = info(commit = "def456", instance = "i2")
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
}
@Test
fun `same commit but different instance is Restarted`() {
val before = info(commit = "abc123", instance = "i1")
val after = info(commit = "abc123", instance = "i2")
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
}
@Test
fun `empty commits on both sides never read as an upgrade`() {
val before = info(commit = "", instance = "i1")
val after = info(commit = "", instance = "i2")
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
}
@Test
fun `format release names the commit and short date`() {
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
}
@Test
fun `dirty build is flagged`() {
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
}
@Test
fun `empty commit is unknown release`() {
assertEquals("unknown release", formatRelease(BuildInfo()))
}
}
@@ -0,0 +1,114 @@
package pt.naps.maestro.data
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Wire-format checks against fixtures mirrored from
* `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type
* that drifts from the Rust source of truth fails here first. */
class WireSerializationTest {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private fun legacySessionJson(extra: String = "") = """
{"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash",
"status":"Running","created_at":"2026-05-24T12:00:00Z"$extra}
""".trimIndent()
@Test
fun `legacy session payload defaults new fields`() {
val s = json.decodeFromString<Session>(legacySessionJson())
assertEquals(ActivityState.Idle, s.activity)
assertEquals(SessionView.Terminal, s.view)
assertNull(s.foreground)
assertNull(s.blocked_work)
assertEquals(EPOCH, s.last_activity_at)
}
@Test
fun `unknown activity state degrades to Idle`() {
val s = json.decodeFromString<Session>(legacySessionJson(""","activity":"QuantumEntangled""""))
assertEquals(ActivityState.Idle, s.activity)
}
@Test
fun `unknown name source degrades to User`() {
assertEquals(NameSource.User, json.decodeFromString<NameSource>("\"Rolled\""))
assertEquals(NameSource.Haiku, json.decodeFromString<NameSource>("\"Haiku\""))
}
@Test
fun `catalog event tags by type`() {
assertEquals(
"""{"type":"Lagged","skipped":3}""",
json.encodeToString<CatalogEvent>(CatalogEvent.Lagged(3)),
)
val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001")
assertEquals(removed, json.decodeFromString<CatalogEvent>(json.encodeToString<CatalogEvent>(removed)))
}
@Test
fun `client control tags by type`() {
assertEquals(
"""{"type":"Resize","cols":120,"rows":40}""",
json.encodeToString<ClientControl>(ClientControl.Resize(120, 40)),
)
assertEquals(ClientControl.Ping, json.decodeFromString<ClientControl>("""{"type":"Ping"}"""))
}
@Test
fun `structured approval decision is a bare string`() {
assertEquals(
StructuredApprovalDecision.AllowOnce,
json.decodeFromString<StructuredApprovalDecision>("\"allow_once\""),
)
assertEquals(
"\"allow_for_session\"",
json.encodeToString(StructuredApprovalDecision.AllowForSession),
)
}
@Test
fun `structured event tags snake_case`() {
val event = json.decodeFromString<StructuredEvent>(
"""{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""",
)
assertTrue(event is StructuredEvent.TranscriptSnapshotEvent)
assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor)
}
@Test
fun `transcript delta tags snake_case and round trips`() {
val row = TranscriptRow(
id = "tool:read-1",
kind = TranscriptRowKind.ToolCall,
data = json.parseToJsonElement("""{"name":"vendor.read"}"""),
)
val patch = TranscriptDelta.Patch(row)
val encoded = json.encodeToString<TranscriptDelta>(patch)
assertTrue(encoded.startsWith("""{"type":"patch","row":"""))
assertEquals(patch, json.decodeFromString<TranscriptDelta>(encoded))
assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought))
}
@Test
fun `rename request encodes plainly`() {
assertEquals(
"""{"name":"amber-falcon"}""",
json.encodeToString(RenameSessionRequest("amber-falcon")),
)
}
@Test
fun `api error decodes nested unsaved work with defaults`() {
val apiError = json.decodeFromString<ApiError>(
"""{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""",
)
assertEquals(3, apiError.error.unsaved_work?.uncommitted_files)
assertEquals(0, apiError.error.unsaved_work?.unpushed_commits)
}
}
@@ -0,0 +1,190 @@
package pt.naps.maestro.ui.sidebar
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
import pt.naps.maestro.data.SessionWorktree
private fun session(
command: String = "claude",
cwd: String = "/home/user/project",
metadata: Map<String, JsonPrimitive> = emptyMap(),
worktree: SessionWorktree? = null,
blockedWork: InTheWay? = null,
activitySince: String = "1970-01-01T00:00:00Z",
) = Session(
id = "s1",
cwd = cwd,
command = command,
metadata = metadata,
worktree = worktree,
status = SessionStatus.Running,
created_at = "2026-01-01T00:00:00Z",
blocked_work = blockedWork,
activity_since = activitySince,
)
class DurationFormattingTest {
@Test
fun `floors seconds under a minute`() {
assertEquals("30s", activityDurationLabel(30_000))
assertEquals("59s", activityDurationLabel(59_999))
}
@Test
fun `floors minutes under an hour`() {
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
}
@Test
fun `floors hours with no days unit`() {
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
}
@Test
fun `held ms is null for missing or implausible since`() {
assertNull(activityHeldMs(null, 1_000))
assertNull(activityHeldMs("", 1_000))
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
assertNull(activityHeldMs("not-a-timestamp", 1_000))
}
@Test
fun `held ms is the gap to now for a plausible timestamp`() {
val since = "2026-01-01T00:00:00Z"
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
assertEquals(31_000L, activityHeldMs(since, nowMs))
}
}
class SessionNameTest {
@Test
fun `falls back to the command when no name is set`() {
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
}
@Test
fun `uses the custom name when set`() {
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
assertEquals("api-work", sessionName(s))
}
@Test
fun `an empty stored name is treated as unset`() {
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
assertNull(customSessionName(s))
}
@Test
fun `generated name is provisional, user and haiku are not`() {
val generated = session(
metadata = mapOf(
"name" to JsonPrimitive("brave-otter"),
"name_source" to JsonPrimitive("Generated"),
),
)
val user = session(
metadata = mapOf(
"name" to JsonPrimitive("api-work"),
"name_source" to JsonPrimitive("User"),
),
)
val unknownSource = session(
metadata = mapOf(
"name" to JsonPrimitive("api-work"),
"name_source" to JsonPrimitive("something-new"),
),
)
assertTrue(isProvisionalName(generated))
assertFalse(isProvisionalName(user))
assertFalse(isProvisionalName(unknownSource))
}
}
class BlockerStatsTest {
@Test
fun `renders insertions and deletions in diff notation`() {
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
val stats = blockerStats(w).associate { it.kind to it.text }
assertEquals("+124", stats["add"])
assertEquals("-18", stats["del"])
assertEquals("?3", stats["untracked"])
assertEquals("↑2", stats["unpushed"])
assertNull(stats["files"])
}
@Test
fun `falls back to a file count when a tracked change carries no line delta`() {
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
val stats = blockerStats(w)
assertEquals(listOf(BlockerStat("files", "~2")), stats)
}
@Test
fun `session blockers are null when nothing blocks a kill`() {
assertNull(sessionBlockers(session(blockedWork = null)))
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
}
@Test
fun `format in the way spells out zero as no unsaved work`() {
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
}
}
class ProgramNameTest {
@Test
fun `drops arguments and basenames the path`() {
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
}
@Test
fun `looks through leading env assignments`() {
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
}
@Test
fun `looks through sudo and env wrappers with their own flags`() {
assertEquals("claude", programName("sudo claude"))
assertEquals("claude", programName("env -i FOO=bar claude"))
}
@Test
fun `looks through js-toolchain launchers`() {
assertEquals("claude-code", programName("npx claude-code"))
assertEquals("claude", programName("pnpm dlx claude"))
assertEquals("claude", programName("bunx claude"))
}
@Test
fun `a command that is nothing but wrappers is shown as written`() {
assertEquals("sudo", programName("sudo"))
}
}
class GroupingTest {
@Test
fun `groups by worktree base_repo when present`() {
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
val groups = groupSessions(listOf(a, b))
assertEquals(1, groups.size)
assertEquals("maestro", groups.single().label)
assertEquals(2, groups.single().sessions.size)
}
@Test
fun `falls back to cwd with no worktree`() {
val a = session(cwd = "/home/user/scratch")
val groups = groupSessions(listOf(a))
assertEquals("scratch", groups.single().label)
}
}
@@ -0,0 +1,107 @@
package pt.naps.maestro.ui.spawn
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.SessionView
private val base = SpawnFormValues(
command = "claude",
cwd = "/home/user/project",
name = "amber-fox",
structuredView = false,
autoApprove = false,
worktreeEnabled = false,
worktreeBranch = "",
worktreeBranchMode = WorktreeBranchMode.New,
)
class SpawnModelTest {
@Test
fun `builds a plain request with the picked cwd and command`() {
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
assertEquals("/home/user/project", result.request.cwd)
assertEquals("claude", result.request.command)
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
assertEquals(SessionView.Terminal, result.request.view)
}
@Test
fun `omits metadata name when the name is blank`() {
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
assertTrue(result.request.metadata.isEmpty())
}
@Test
fun `requests structured view for resolved claude and codex commands`() {
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
}
}
@Test
fun `does not request structured view for unsupported commands`() {
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
assertEquals(SessionView.Terminal, result.request.view)
}
@Test
fun `attaches a trimmed branch when worktree is enabled`() {
val result = buildSpawnRequest(
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
"/home/user/project",
) as SpawnBuildResult.Ok
assertEquals("/home/user/project", result.request.worktree?.base_repo)
assertEquals("feat/foo", result.request.worktree?.branch)
assertTrue(result.request.worktree?.create_branch == true)
}
@Test
fun `checks out an existing branch when the mode is existing`() {
val result = buildSpawnRequest(
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
"/home/user/project",
) as SpawnBuildResult.Ok
assertEquals(false, result.request.worktree?.create_branch)
}
@Test
fun `rejects an existing-mode worktree with no branch picked`() {
val result = buildSpawnRequest(
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
"/repo",
)
assertTrue(result is SpawnBuildResult.Error)
}
@Test
fun `rejects a missing working directory`() {
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
}
@Test
fun `rejects a missing command`() {
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
}
@Test
fun `rejects a worktree without an inferred base repo`() {
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
assertTrue(result is SpawnBuildResult.Error)
}
@Test
fun `rejects a worktree without a branch name`() {
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
assertTrue(result is SpawnBuildResult.Error)
}
@Test
fun `resolveProgram strips env assignments and paths`() {
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
assertEquals("claude", resolveProgram("claude"))
assertEquals("zsh", resolveProgram("zsh -l"))
}
}
@@ -0,0 +1,237 @@
package pt.naps.maestro.ui.spawn
import app.cash.turbine.test
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.data.Project
/** Routes each enqueued response by request path rather than arrival order —
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
* so a plain FIFO queue can hand either endpoint the other's response. */
private class RoutedDispatcher : Dispatcher() {
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
fun enqueue(path: String, response: MockResponse) {
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
}
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path.orEmpty().substringBefore('?')
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
}
}
class SpawnViewModelTest {
private lateinit var server: MockWebServer
private lateinit var dispatcher: RoutedDispatcher
private lateinit var client: MaestroClient
private lateinit var scope: CoroutineScope
@Before
fun setUp() {
dispatcher = RoutedDispatcher()
server = MockWebServer()
server.dispatcher = dispatcher
server.start()
client = MaestroClient(server.url("/").toString())
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
@After
fun tearDown() {
scope.cancel()
server.shutdown()
}
private fun jsonResponse(body: String) =
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
* every test needs before it can observe anything past init. */
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
}
private fun newViewModel() = SpawnViewModel(client, scope)
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
var result: SpawnUiState? = null
state.test {
var item = awaitItem()
while (!predicate(item)) item = awaitItem()
result = item
cancelAndIgnoreRemainingEvents()
}
return result ?: error("state predicate never satisfied")
}
@Test
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
enqueueInit(
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
{"id":"claude","label":"Claude","command":"claude"},
{"id":"custom","label":"Custom…","command":""}
],"instance":"i"}""",
)
val vm = newViewModel()
val settled = vm.awaitState { it.selectedPresetId == "claude" }
assertEquals("claude", settled.command)
assertTrue("structured view should auto-tick for claude", settled.structuredView)
assertEquals("/home/x", settled.homeDir)
}
@Test
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
val vm = newViewModel()
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
assertEquals(FALLBACK_PRESETS, settled.presets)
}
@Test
fun `selecting a preset updates the command and structured view`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
vm.selectPreset("zsh")
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
assertEquals("zsh", afterZsh.command)
assertTrue("zsh has no structured view", !afterZsh.structuredView)
vm.selectPreset("claude")
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
assertTrue(afterClaude.structuredView)
}
@Test
fun `manual structured view toggle survives a later preset change`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
vm.setStructuredView(false)
vm.selectPreset("codex")
val after = vm.awaitState { it.selectedPresetId == "codex" }
assertTrue("operator's untick should stick", !after.structuredView)
}
@Test
fun `selecting a project fills cwd and base repo`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
vm.selectProject(Project(path = "/repo", name = "repo"))
val after = vm.awaitState { it.cwd == "/repo" }
assertEquals("/repo", after.baseRepo)
assertEquals("/repo", after.selectedProjectPath)
}
@Test
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
vm.setCwd("/home/user/pro")
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
val withRepo = vm.awaitState { it.baseRepo != null }
assertEquals("/home/user/proj-a", withRepo.baseRepo)
}
@Test
fun `reroll fills the name field from the daemon`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
vm.rerollName()
val after = vm.awaitState { it.name == "amber-fox" }
assertEquals("amber-fox", after.name)
}
@Test
fun `submit rejects a missing working directory without a network call`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
var succeeded = false
vm.submit { succeeded = true }
val after = vm.awaitState { it.error != null }
assertNotNull(after.error)
assertTrue(!succeeded)
}
@Test
fun `submit posts the built request and reports the new session`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
vm.selectProject(Project(path = "/repo", name = "repo"))
vm.awaitState { it.cwd == "/repo" }
dispatcher.enqueue(
"/api/sessions",
jsonResponse(
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
),
)
var result: pt.naps.maestro.data.Session? = null
vm.submit { result = it }
val after = vm.awaitState { !it.submitting && it.error == null }
assertNull(after.error)
assertEquals("s1", result?.id)
var request = server.takeRequest()
while (request.method != "POST") request = server.takeRequest()
assertEquals("/api/sessions", request.path)
}
@Test
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
vm.selectProject(Project(path = "/repo", name = "repo"))
vm.awaitState { it.cwd == "/repo" }
dispatcher.enqueue(
"/api/sessions",
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
)
vm.submit {}
val after = vm.awaitState { it.error != null }
assertTrue(after.error!!.contains("cwd does not exist"))
}
}
@@ -0,0 +1,24 @@
package pt.naps.maestro.ui.terminal
import org.junit.Assert.assertTrue
import org.junit.Test
class TerminalKeyBarActionsTest {
@Test
fun `contains tab esc and all four arrows`() {
val labels = TERMINAL_KEY_ACTIONS.map { it.label }
assertTrue(labels.containsAll(listOf("Tab", "Esc", "↑", "↓", "←", "→")))
}
@Test
fun `ctrl and alt are modifiers rather than sends`() {
val modifierNames = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Modifier>().map { it.name }
assertTrue(modifierNames.containsAll(listOf("ctrl", "alt")))
}
@Test
fun `arrow keys carry the maestroKey names the terminal page expects`() {
val sendKeys = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Send>().map { it.key }
assertTrue(sendKeys.containsAll(listOf("Up", "Down", "Left", "Right", "Tab", "Esc")))
}
}
@@ -0,0 +1,29 @@
package pt.naps.maestro.ui.terminal
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TerminalPageUrlTest {
@Test
fun `builds asset url with the encoded attach ws url`() {
val url = TerminalPageUrl.build("http://192.168.1.5:7070", "abc-123")
assertEquals(
"file:///android_asset/terminal/index.html" +
"?ws=ws%3A%2F%2F192.168.1.5%3A7070%2Fapi%2Fattach%2Fabc-123",
url,
)
}
@Test
fun `swaps an https base to wss`() {
val url = TerminalPageUrl.build("https://daemon.example.com", "s1")
assertTrue(url.contains("wss%3A%2F%2Fdaemon.example.com"))
}
@Test
fun `strips a trailing slash from the daemon base url`() {
val url = TerminalPageUrl.build("http://host:7070/", "s1")
assertTrue(url.contains("ws%3A%2F%2Fhost%3A7070%2Fapi%2Fattach%2Fs1"))
}
}
+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
+42
View File
@@ -0,0 +1,42 @@
[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"
turbine = "1.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
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
@@ -133,7 +133,7 @@ pub struct KillArgs {
/// Session ID to kill.
pub session_id: Uuid,
/// End the session even when its worktree holds unsaved work (#81). The
/// worktree is left on disk — never removed.
/// worktree is removed along with the session.
#[arg(long)]
pub force: bool,
}
+2
View File
@@ -345,6 +345,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+7 -8
View File
@@ -482,14 +482,13 @@ async fn structured_prompt(
Path(id): Path<Uuid>,
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
StatusCode::NO_CONTENT.into_response()
}
Ok(QueueAcpPrompt::InFlight) => error_response(
StatusCode::CONFLICT,
"turn_in_flight",
"the structured session already has an active turn",
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(
StatusCode::TOO_MANY_REQUESTS,
"queue_full",
"the prompt queue has reached its capacity",
),
Ok(QueueAcpPrompt::Unavailable) => error_response(
StatusCode::CONFLICT,
+244 -87
View File
@@ -5,7 +5,7 @@
//! single point through which the API handlers and the per-session WS
//! handler interact with tmux.
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Weak};
@@ -15,8 +15,8 @@ use chrono::Utc;
use maestro_acp::{AcpAdapter, AcpSession, PermissionRequest, SpawnConfig};
use maestro_protocol::display::command_tool;
use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, ReducedState,
Session, SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
TranscriptRowKind, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
};
use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify};
@@ -122,9 +122,6 @@ pub struct SessionRecord {
/// When `true`, the permission pump auto-responds `AllowAlways` to every
/// ACP permission request without surfacing it to clients.
pub auto_approve: bool,
/// A prompt submitted while the adapter was still booting (`Starting`).
/// The background handshake task drains this on success.
pub pending_prompt: Option<String>,
}
impl SessionRecord {
@@ -149,6 +146,9 @@ struct StructuredActivityWatermark {
sequence: u64,
}
/// Maximum number of prompts that can be queued while a turn is in flight.
const PROMPT_QUEUE_CAP: usize = 20;
#[derive(Debug)]
struct AcpEventStream {
log: EventLog,
@@ -160,6 +160,10 @@ struct AcpEventStream {
/// claimed: it is queued onto ACP before another operation can proceed.
operation: Arc<Mutex<()>>,
dispatch: AcpDispatchState,
/// Server-side FIFO of prompts accepted while a turn was in flight or the
/// adapter was still booting. Drained as a single concatenated prompt when
/// the current turn completes. Persisted to the session sidecar.
prompt_queue: VecDeque<String>,
activity_sequence: u64,
}
@@ -168,6 +172,23 @@ impl AcpEventStream {
self.activity_sequence += 1;
(self.activity_sequence, self.projection.activity())
}
/// The reduced state with the prompt queue overlaid. Every broadcast and
/// snapshot should use this instead of `projection.state()` directly so
/// clients always see the current queue.
fn state_with_queue(&self) -> ReducedState {
let mut state = self.projection.state();
state.queued_prompts = self.prompt_queue.iter().cloned().collect();
state
}
/// Broadcast a projection update with the prompt queue overlaid into the
/// state. Every `projected_live.send` should go through this so clients
/// never see a stale queue.
fn broadcast(&self, mut update: ProjectionUpdate) {
update.state.queued_prompts = self.prompt_queue.iter().cloned().collect();
let _ = self.projected_live.send(update);
}
}
#[derive(Debug, Default)]
@@ -235,11 +256,14 @@ pub enum BeginAcpTurn {
/// Result of serializing a projected turn claim with its ACP prompt enqueue.
#[derive(Debug)]
pub enum QueueAcpPrompt {
/// The prompt was dispatched to ACP immediately (no turn was in flight).
Dispatched,
/// The prompt was accepted into the server-side queue and will be
/// dispatched when the current turn completes.
Queued,
/// The adapter is still booting; the prompt is held until it is ready.
Pending,
InFlight,
Unavailable,
/// The prompt queue has reached its capacity.
Full,
Failed(String),
}
@@ -662,7 +686,7 @@ impl SessionManager {
let live = stream.projected_live.subscribe();
Ok(Some((
stream.projection.snapshot(stream.log.last_seq()),
stream.projection.state(),
stream.state_with_queue(),
live,
)))
}
@@ -692,7 +716,7 @@ impl SessionManager {
return Ok(false);
};
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected);
stream.broadcast(projected);
drop(stream);
sync_structured_activity(&self.inner, id).await;
Ok(true)
@@ -717,7 +741,7 @@ impl SessionManager {
return Ok(BeginAcpTurn::InFlight);
}
let (turn, update) = stream.projection.begin_turn();
let _ = stream.projected_live.send(update);
stream.broadcast(update);
drop(stream);
sync_structured_activity(&self.inner, id).await;
Ok(BeginAcpTurn::Started(acp, turn))
@@ -733,15 +757,31 @@ impl SessionManager {
&self,
id: Uuid,
text: String,
row_metadata: Option<serde_json::Value>,
) -> Result<QueueAcpPrompt, SessionError> {
let (acp, stream) = {
let mut sessions = self.inner.sessions.lock().await;
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
// While the adapter is booting, stash the prompt for the
// background handshake task to drain on success.
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if record.is_starting_structured() {
record.pending_prompt = Some(text);
return Ok(QueueAcpPrompt::Pending);
// Adapter still booting — queue the prompt for drain on boot
// success. The AcpEventStream exists, just no ACP handle yet.
let stream = record
.acp_events
.clone()
.expect("structured session has events at Starting");
let mut stream = stream.lock().await;
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
return Ok(QueueAcpPrompt::Full);
}
stream.prompt_queue.push_back(text);
let update = ProjectionUpdate {
transcript: None,
state: stream.projection.state(),
};
stream.broadcast(update);
drop(stream);
self.persist_prompt_queue(id).await;
return Ok(QueueAcpPrompt::Queued);
}
if !record.is_live_structured() {
return Ok(QueueAcpPrompt::Unavailable);
@@ -762,19 +802,51 @@ impl SessionManager {
}
if let Some(turn) = stream.dispatch.queued_turn {
if stream.dispatch.cancelled_turn != Some(turn) {
return Ok(QueueAcpPrompt::InFlight);
// Turn is in flight — queue instead of rejecting.
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
return Ok(QueueAcpPrompt::Full);
}
stream.prompt_queue.push_back(text);
let update = ProjectionUpdate {
transcript: None,
state: stream.projection.state(),
};
stream.broadcast(update);
drop(stream);
self.persist_prompt_queue(id).await;
return Ok(QueueAcpPrompt::Queued);
}
Some((turn, stream.dispatch.settled.clone()))
} else {
if stream.projection.turn_active() {
return Ok(QueueAcpPrompt::InFlight);
// Projection says active but dispatch says idle —
// queue rather than rejecting.
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
return Ok(QueueAcpPrompt::Full);
}
stream.prompt_queue.push_back(text);
let update = ProjectionUpdate {
transcript: None,
state: stream.projection.state(),
};
stream.broadcast(update);
drop(stream);
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) {
@@ -789,9 +861,9 @@ impl SessionManager {
let logged = LoggedEvent { seq, event };
let projected = stream.projection.restore_row(row);
let _ = stream.live.send(logged);
let _ = stream.projected_live.send(projected);
stream.broadcast(projected);
let (turn, update) = stream.projection.begin_turn();
let _ = stream.projected_live.send(update);
stream.broadcast(update);
match acp.prompt(text.clone(), turn) {
Ok(done) => {
stream.dispatch.queued_turn = Some(turn);
@@ -809,7 +881,7 @@ impl SessionManager {
if let Ok(true) = self.claim_autoname(id).await {
crate::autoname::spawn_naming(self.clone(), id, text);
}
return Ok(QueueAcpPrompt::Queued);
return Ok(QueueAcpPrompt::Dispatched);
}
Err(message) => {
cancel_pending_approvals(&mut stream);
@@ -820,7 +892,7 @@ impl SessionManager {
"newly claimed turn remains current while stream is locked",
);
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected);
stream.broadcast(projected);
drop(stream);
sync_structured_activity(&self.inner, id).await;
return Ok(QueueAcpPrompt::Failed(message));
@@ -840,6 +912,27 @@ impl SessionManager {
}
}
/// Persist the current prompt queue to the session sidecar.
async fn persist_prompt_queue(&self, id: Uuid) {
let stream_arc = {
let sessions = self.inner.sessions.lock().await;
sessions.get(&id).and_then(|r| r.acp_events.clone())
};
let Some(stream_arc) = stream_arc else { return };
let queue: Vec<String> = stream_arc
.lock()
.await
.prompt_queue
.iter()
.cloned()
.collect();
let mut sessions = self.inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.queued_prompts = queue;
let _ = self.inner.sidecar.write(&record.session);
}
}
async fn finish_queued_acp_turn(&self, id: Uuid, turn: u64, lifecycle: TurnLifecycle) {
let stream = self
.inner
@@ -860,16 +953,33 @@ impl SessionManager {
stream.dispatch.cancelled_turn = None;
}
stream.dispatch.settled.notify_waiters();
if stream.projection.active_turn() != Some(turn) {
return;
}
if stream.projection.active_turn() == Some(turn) {
cancel_pending_approvals(&mut stream);
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected);
stream.broadcast(projected);
}
}
// Drain the prompt queue: concatenate all queued prompts into a single
// prompt and dispatch as the next turn. This must run even when the
// projection already ended the turn (e.g. cancel settled via the event
// stream before this function ran).
let queued_text = if stream.prompt_queue.is_empty() {
None
} else {
let texts: Vec<String> = stream.prompt_queue.drain(..).collect();
Some(texts.join("\n\n"))
};
drop(stream);
sync_structured_activity(&self.inner, id).await;
if let Some(text) = queued_text {
self.persist_prompt_queue(id).await;
// Cannot call queue_acp_prompt here (async cycle via the spawn
// inside queue_acp_prompt → finish_queued_acp_turn → here).
// Spawn an independent task instead.
let manager = self.clone();
drain_prompt_queue(manager, id, text);
}
}
async fn fail_cancelled_acp_turn(
@@ -983,7 +1093,7 @@ impl SessionManager {
.turn_lifecycle(TurnLifecycle::Cancel, turn)
.expect("active turn remains current while stream is locked");
let projected = persist_turn_update(&mut stream, projected);
let _ = stream.projected_live.send(projected);
stream.broadcast(projected);
drop(stream);
sync_structured_activity(&self.inner, id).await;
if let Some(turn) = cancelled_queued_turn {
@@ -1032,7 +1142,7 @@ impl SessionManager {
let outcome = permission_outcome(decision, &pending.options);
let _ = pending.response.send(outcome);
if let Some(update) = stream.projection.resolve_approval(nonce) {
let _ = stream.projected_live.send(update);
stream.broadcast(update);
}
drop(stream);
sync_structured_activity(&self.inner, id).await;
@@ -1340,6 +1450,7 @@ impl SessionManager {
pending_approvals: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: VecDeque::new(),
activity_sequence: 0,
}))
}
@@ -1404,6 +1515,8 @@ impl SessionManager {
last_focused_at: None,
acp_session_id: None,
view: req.view,
auto_approve: req.auto_approve,
queued_prompts: Vec::new(),
};
if let Err(e) = self.inner.sidecar.write(&session) {
@@ -1435,7 +1548,6 @@ impl SessionManager {
structured_activity_watermark: None,
acp_event_pump: None,
auto_approve: req.auto_approve,
pending_prompt: None,
},
);
}
@@ -1489,8 +1601,8 @@ impl SessionManager {
let acp = Arc::new(acp_session);
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
// Drain any prompt submitted while Starting.
let pending_prompt = {
// Drain any prompts queued while the adapter was Starting.
let queued_text = {
let mut sessions = inner.sessions.lock().await;
let Some(record) = sessions.get_mut(&id) else {
// Session was killed while we were booting.
@@ -1504,17 +1616,30 @@ impl SessionManager {
record.session.acp_session_id = Some(acp.session_id().to_string());
record.session.activity = ActivityState::Idle;
record.session.activity_since = Utc::now();
let queued = if let Some(ref stream) = record.acp_events {
let mut stream = stream.lock().await;
if stream.prompt_queue.is_empty() {
None
} else {
let texts: Vec<String> =
stream.prompt_queue.drain(..).collect();
Some(texts.join("\n\n"))
}
} else {
None
};
record.session.queued_prompts.clear();
let _ = inner.sidecar.write(&record.session);
let _ = inner.events.send(CatalogEvent::SessionUpdated {
session: record.session.clone(),
});
record.pending_prompt.take()
queued
};
if let Some(prompt) = pending_prompt {
if let Some(text) = queued_text {
let manager = SessionManager {
inner: inner.clone(),
};
let _ = manager.queue_acp_prompt(id, prompt).await;
let _ = manager.queue_acp_prompt(id, text, None).await;
}
}
Err(e) => {
@@ -1545,15 +1670,10 @@ impl SessionManager {
/// uncommitted tracked changes, untracked non-ignored files, or commits
/// reachable from no remote-tracking ref. Nothing is killed and nothing
/// is removed; the error names what blocked it and how much of it there
/// is. The session stays as the operator's handle on their own unfinished
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which leaves the tree alone rather than destroying it
/// (WORKTREES.md).
/// is.
///
/// With `force` (#81) the refusal — and nothing else — is overridden:
/// the session ends and the guarded worktree is left on disk, never
/// removed. A tree whose guard is clear never needed the override; force
/// removes it as usual.
/// With `force` (#81) the refusal is overridden: the session ends and
/// the worktree is removed, even when it holds unsaved work.
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
@@ -1588,7 +1708,7 @@ impl SessionManager {
sessions.remove(&id)
};
let record = removed.ok_or(SessionError::NotFound(id))?;
cleanup_record(&self.inner, id, record, alive, guarded).await;
cleanup_record(&self.inner, id, record, alive).await;
Ok(())
}
@@ -1683,6 +1803,8 @@ impl SessionManager {
// Unknown on adoption without a sidecar; matches every
// other field's forward-compat default.
view: maestro_protocol::SessionView::Terminal,
auto_approve: false,
queued_prompts: Vec::new(),
},
};
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -1720,8 +1842,7 @@ impl SessionManager {
acp_events: None,
structured_activity_watermark: None,
acp_event_pump: None,
auto_approve: false,
pending_prompt: None,
auto_approve: session.auto_approve,
},
);
let _ = self
@@ -1849,6 +1970,7 @@ impl SessionManager {
pending_approvals: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: session.queued_prompts.drain(..).collect(),
activity_sequence: 0,
})))
}
@@ -1896,21 +2018,45 @@ impl SessionManager {
focused_since: None,
awaiting_suppressed: false,
acp,
acp_events,
acp_events: acp_events.clone(),
structured_activity_watermark: None,
acp_event_pump: None,
// Recovery cannot know the original preset's flag; the
// operator re-enables it by restarting with the preset.
auto_approve: false,
pending_prompt: None,
auto_approve: session.auto_approve,
},
);
if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events, false);
start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
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. If none were queued, send a synthetic continue
// prompt so the agent resumes autonomously.
let queued_text = {
let stream = acp_events
.as_ref()
.expect("recovery with acp implies acp_events");
let mut stream = stream.lock().await;
if stream.prompt_queue.is_empty() {
None
} else {
let texts: Vec<String> = stream.prompt_queue.drain(..).collect();
Some(texts.join("\n\n"))
}
};
if let Some(text) = queued_text {
self.persist_prompt_queue(id).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
.inner
@@ -2257,19 +2403,22 @@ 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 {
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(Box::new(notification));
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 projected =
apply_persisted_event(&mut events.projection, &logged);
let _ = events.live.send(logged);
let _ = events.projected_live.send(projected);
events.broadcast(projected);
None
}
Err(error) => Some(error),
@@ -2287,6 +2436,18 @@ fn start_acp_event_pump(
break;
}
}
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);
}
}
}
let _ = done_tx.send(true);
});
done_rx
@@ -2439,7 +2600,7 @@ fn start_acp_permission_pump(
let outcome = record_permission_request(&mut events, request, response);
let fatal = matches!(outcome, RecordOutcome::Fatal);
if let RecordOutcome::Live(update) = outcome {
let _ = events.projected_live.send(*update);
events.broadcast(*update);
}
drop(events);
if fatal {
@@ -2532,7 +2693,7 @@ fn cancel_pending_approvals(events: &mut AcpEventStream) {
.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
}
if let Some(update) = events.projection.clear_approval() {
let _ = events.projected_live.send(update);
events.broadcast(update);
}
}
@@ -2882,6 +3043,18 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
/// Terminal sessions that die naturally do not survive a daemon restart:
/// [`SessionManager::recover`] adopts only what tmux still holds. Structured
/// sessions stopped for daemon shutdown retain their sidecars for ACP recovery.
/// Spawn an independent task to dispatch the next prompt from the drained
/// queue. Free function with an explicit `Box::pin` future to break the
/// compiler's async-cycle check: `queue_acp_prompt` → spawn →
/// `finish_queued_acp_turn` → here → `queue_acp_prompt`.
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, None).await;
});
tokio::spawn(fut);
}
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
if inner.shutting_down.load(Ordering::Acquire) {
return;
@@ -2935,7 +3108,9 @@ async fn mark_session_dead_with_worktree_settlement(
.get(&id)
.and_then(|record| record.acp_events.clone());
if let Some(stream) = stream {
cancel_pending_approvals(&mut *stream.lock().await);
let mut stream = stream.lock().await;
cancel_pending_approvals(&mut stream);
stream.prompt_queue.clear();
}
tracing::info!(%id, "session process gone — marking dead");
@@ -2962,6 +3137,7 @@ async fn mark_session_dead_with_worktree_settlement(
return false;
};
record.settled = true;
record.session.queued_prompts.clear();
record.session.clone()
};
@@ -2972,8 +3148,8 @@ async fn mark_session_dead_with_worktree_settlement(
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
/// would be lost through the kill's own predicate, then remove a pristine
/// tree. Anything holding unsaved work is left exactly where it is — the
/// same thing a forced kill does with a guarded tree (#81).
/// tree. Anything holding unsaved work is left on disk — there is no
/// operator confirmation on the natural-exit path.
///
/// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record.
@@ -3028,17 +3204,7 @@ async fn settle_worktree_on_exit(
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session).
///
/// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
/// session ends but the worktree is left on disk, so only the removal is
/// skipped.
async fn cleanup_record(
inner: &Arc<Inner>,
id: Uuid,
record: SessionRecord,
kill_tmux: bool,
keep_worktree: bool,
) {
async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
if kill_tmux {
let tmux = inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
@@ -3059,17 +3225,7 @@ async fn cleanup_record(
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if keep_worktree {
tracing::info!(
%id,
path = %path.display(),
"forced kill: worktree left on disk"
);
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
// The kill already passed the unsaved-work guard (#79), so this
// removal is unconditional; the manager's own nested-worktree
// refusal is all that can still leave a tree on disk, and it
// says where.
if let Err(e) = inner.worktree_mgr.remove(base, path) {
tracing::warn!(
%id,
path = %path.display(),
@@ -3145,6 +3301,7 @@ mod tests {
pending_approvals: std::collections::HashMap::new(),
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: std::collections::VecDeque::new(),
activity_sequence: 0,
}
}
+43 -31
View File
@@ -84,9 +84,8 @@ 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 &event.event {
maestro_daemon::sidecar::PersistedEvent::Notification(notification) => {
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
@@ -94,6 +93,19 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
},
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]",
}
}
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
@@ -259,10 +271,10 @@ 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::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
assert_eq!(
fx.manager
@@ -286,10 +298,10 @@ 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::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
assert_eq!(
fx.manager
@@ -426,10 +438,10 @@ 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::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
let (snapshot, _, _) = fx
@@ -516,15 +528,15 @@ 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::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
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,18 +567,18 @@ 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::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
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::Queued
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
@@ -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");
+6 -15
View File
@@ -293,7 +293,7 @@ async fn structured_attachments_preserve_bytes_and_reject_paths() {
}
#[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let mut env = fake.env();
@@ -310,6 +310,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.await
.unwrap();
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
// A second prompt while a turn is active is accepted into the queue.
let second = client
.post(url(
h.addr,
@@ -319,9 +320,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.send()
.await
.unwrap();
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = second.json().await.unwrap();
assert_eq!(error.error.code, "turn_in_flight");
assert_eq!(second.status(), reqwest::StatusCode::ACCEPTED);
let cancel = client
.post(url(
@@ -332,16 +331,8 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
.await
.unwrap();
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
let next = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "next"}))
.send()
.await
.unwrap();
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
// After cancel + settlement, the queued "second" prompt drains. Wait for
// both the first and the drained prompt to reach ACP.
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if fake.log().matches("recv session/prompt").count() == 2 {
@@ -351,7 +342,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
}
})
.await
.expect("both prompt commands reach ACP");
.expect("first and drained queued prompt both reach ACP");
let log = fake.log();
let first_prompt = log.find("recv session/prompt").unwrap();
let cancel = log.find("recv session/cancel").unwrap();
+2
View File
@@ -34,6 +34,8 @@ fn sample(id: Uuid) -> Session {
last_focused_at: None,
acp_session_id: None,
view: SessionView::Terminal,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+4 -4
View File
@@ -777,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
// Force overrides the refusal and nothing else: session gone, tree kept.
// Force overrides the refusal: session gone, tree removed.
client.kill_session_forced(s.id).await.expect("forced kill");
assert!(
@@ -789,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
.any(|x| x.id == s.id),
"the forced kill ends the session"
);
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
assert!(!wt.is_dir(), "a forced kill removes the worktree");
assert!(
git_worktree_list(base.path())
!git_worktree_list(base.path())
.iter()
.any(|l| l.contains("feature/local")),
"the kept tree stays registered in git"
"the removed tree is unregistered from git"
);
}
+2
View File
@@ -744,6 +744,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+16
View File
@@ -132,6 +132,16 @@ pub struct Session {
/// the two are minted by different processes with different lifetimes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>,
/// When `true` the daemon auto-approves every ACP permission request for
/// this session instead of surfacing them to the client. Set at spawn time
/// from the preset or the `CreateSessionRequest`; persisted so it survives
/// a daemon restart.
#[serde(default)]
pub auto_approve: bool,
/// Prompts queued on the daemon while a turn was in flight or the adapter
/// was still booting. Persisted so the queue survives a daemon restart.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queued_prompts: Vec<String>,
}
/// A session's process shape (#133): a plain terminal, or a structured agent
@@ -872,6 +882,10 @@ pub struct ReducedState {
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_options: Option<Value>,
/// Prompts queued on the daemon while a turn is in flight. Drained as
/// a single concatenated prompt when the current turn completes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queued_prompts: Vec<String>,
}
/// Server messages sent over a structured-session WebSocket.
@@ -1054,6 +1068,8 @@ mod tests {
last_focused_at: None,
view: SessionView::Terminal,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+2
View File
@@ -50,6 +50,8 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+12
View File
@@ -2216,6 +2216,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
})
.collect(),
});
@@ -2399,6 +2401,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}],
});
app.dispatch(Action::SidebarNext);
@@ -2428,6 +2432,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -3590,6 +3596,8 @@ mod tests {
blocked_work: None,
last_focused_at: focused,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
};
let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3870,6 +3878,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}],
});
app.dispatch(Action::KillHighlighted);
@@ -3907,6 +3917,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}],
});
}
+2
View File
@@ -225,6 +225,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+2
View File
@@ -811,6 +811,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
+4
View File
@@ -502,6 +502,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -1272,6 +1274,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
};
let sb = Sidebar {
sessions: vec![s],
+1 -1
View File
@@ -70,7 +70,7 @@ command. `<duration>` follows in the rail's colour when shown. There is no
state word and no second badge: the rail exists so the state is still legible
when a row is dimmed or the terminal renders the duration in the default fg.
In the **web client** the rail is a 5px bar pinned to the row's own left
In the **web client** the rail is a 7px bar pinned to the row's own left
edge, square corners, spanning the full card height edge to edge — it
shifts outward with nesting depth along with the row's own padding, since
it is positioned relative to the row. It carries motion only for states that
+28 -23
View File
@@ -189,17 +189,17 @@ choices, not daemon ones.
presentation for `Notice`. `Plan` is the exception: both clients omit it
from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays
outside as the prominent final answer. While a turn is still active
(responding or thinking), the most recent intermediate `AgentText` or
`AgentThought` in the group is kept outside as visible narration — replaced
by the next text row when one arrives, and absorbed into the group when the
turn ends. The header still reads "N tool calls" (counting only tool rows).
Opening the group reveals tools and intermediate text in chronological
order, with the text rendered in a subdued style.
- The web client groups each contiguous run of two or more tool rows into a
collapsed activity block. An `AgentText` or `AgentThought` row between
tools splits them into separate groups, so the timeline reads
text → group → text → group rather than lumping every tool in a turn into
one box. Text and thought rows stay outside groups as standalone transcript
items — the operator sees all reasoning without expanding. Resolved
approval rows between tools within a run are absorbed into the group. The
collapsed header reads "N tool calls · Label 'arg'" where the label and
argument come from the last tool row's `splitToolTitle` — the operator
sees which command is current at a glance. Opening the group reveals tool
rows in chronological order.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
@@ -353,21 +353,26 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn."
- Web clients may collect a visible FIFO of operator messages while a turn is
active. They submit each queued message only after the projected turn becomes
idle; the queue is client-local and unsent messages do not survive a reload.
- Prompts are accepted at any time: if the session is idle the daemon dispatches
immediately (`204`); if a turn is active the prompt is appended to a
server-side FIFO (`202 Accepted`, capped at 20 entries, `429` when full).
When the active turn completes, all queued prompts are concatenated with
`\n\n` and dispatched as the next turn. The queue is persisted in the session
sidecar and survives daemon restarts — clients never queue locally.
`ReducedState.queued_prompts` contains the current queue so every connected
client can render the pending messages.
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing in the daemon — one active turn per session, never a hidden
server-side queue of prompts.
operator's input. If that append fails, the prompt is not dispatched.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
prompt claiming "the active turn" while the old one is still outstanding
would make the next permission callback ambiguous between the two.
and ends the turn's client-visible lifecycle immediately. The prompt queue
is preserved: after the cancelled turn settles, the queued prompts drain as
the next turn. If the session dies instead (settlement timeout), the queue
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
its response), the daemon cannot simply forget it: ACP has not confirmed the
cancel yet, and a new prompt claiming "the active turn" while the old one is
still outstanding would make the next permission callback ambiguous between
the two.
- So a cancelled-but-still-dispatched turn must **settle within
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
resolves) or that grace period runs out. If it settles in time, the next
+10 -25
View File
@@ -75,27 +75,17 @@ The point of refusing rather than cleaning up is that the session stays as the
operator's handle on their own unfinished work. A session that is killed while
its worktree still matters leaves a directory nobody is looking at.
A consequence, accepted deliberately: a feature branch almost always has unpushed
commits, so `--force` is the ordinary way to end a session, not an exception.
That is only safe because force never removes what blocked.
### `--force`
`--force` overrides the refusal and nothing else. It ends the session and
**leaves the worktree on disk**, untouched. Force never removes a tree that
would have blocked. A tree whose guard is clear never needed the override, so a
forced kill removes it as usual — force changes what happens to blocked work,
not ordinary cleanup.
`--force` overrides the refusal and **removes the worktree**, even when it holds
unsaved work. The operator sees the breakdown in the confirmation prompt and
decides; once confirmed, the tree is gone.
In the web client the kill button sends the unforced `DELETE` straight away —
there is no pre-confirmation, because the daemon is the one that decides. Only
the refusal raises a prompt, and that prompt names what is in the way and offers
"Force kill", which repeats the request with `force=true`.
Ending a session and destroying its work are two separate acts. Maestro only
performs the first one. Removing a tree it left behind is the operator's own
`git worktree remove`, chosen while looking at exactly what would be lost.
### Exiting on its own
A session that ends by itself — the command finished, the operator typed `exit` —
@@ -113,17 +103,12 @@ once. A removed tree drops off the record, so dismissing the session afterwards
does not refuse: there is no guard left to trip.
A tree that was left alone stays attached to the dead record, where dismissing it
later goes through `--force`, which leaves it on disk too. The same holds when
the probe itself fails: nothing is destroyed.
later goes through `--force`, which removes it. The same holds when the probe
itself fails: nothing is destroyed.
## Nothing is removed on the operator's behalf
## Trees left behind by natural exit
There is no store of worktrees the daemon is holding, no list endpoint, and no
reclaim command. A tree left behind by a forced kill or a dirty exit is an
ordinary git worktree on disk; `git worktree list` finds it and
`git worktree remove` removes it.
That is deliberate. A tracked list of trees pending deletion is a second place
the truth can live, and it goes stale the moment somebody removes a directory by
hand. Git already knows which worktrees exist, and removing one is not a
decision Maestro is better placed to make.
A forced kill always removes the worktree. A natural exit with unsaved work
leaves the tree on disk — there is no operator confirmation in that path. Those
trees are ordinary git worktrees; `git worktree list` finds them and
`git worktree remove` removes them.

Some files were not shown because too many files have changed in this diff Show More