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
Miguel Palhas b6f60bfbae Merge remote-tracking branch 'origin/main' into grazioso
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-10 16:19:28 +01:00
Miguel PalhasandClaude Opus 4.6 a8a335eadb fix(maestro): remove cancel button border, center attach button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:19:24 +01:00
Miguel Palhas 048e273ff9 ci: retry after runner cache clear
ci / web (push) Successful in 1m0s
ci / flake (push) Failing after 2m19s
ci / rust (push) Has been cancelled
2026-09-10 16:18:05 +01:00
Miguel PalhasandClaude Opus 4.6 beef2049ed fix(maestro): update remaining id="app" references in web tests
ci / rust (push) Failing after 6s
ci / web (push) Successful in 37s
ci / flake (push) Failing after 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:02:02 +01:00
Miguel PalhasandClaude Opus 4.6 c30592e6b6 fix(maestro): update embedded_bundle test for React mount point
ci / web (push) Failing after 6s
ci / flake (push) Failing after 1m4s
ci / rust (push) Failing after 1m51s
The React migration changed the SPA root from id="app" to id="root".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 17:07:21 +01:00
Miguel PalhasandClaude Opus 4.6 abae81a0ce feat(web): React + Tailwind migration
ci / web (push) Successful in 37s
ci / flake (push) Failing after 1m6s
ci / rust (push) Failing after 1m56s
Replaces the 12.7k-line vanilla TypeScript SPA with React + Zustand +
Tailwind. 16 issues across milestone 48.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 17:01:49 +01:00
Miguel PalhasandClaude Opus 4.6 66a568818a fix(maestro): re-read session before sidecar write in mark_session_dead
ci / web (push) Failing after 6s
ci / flake (push) Failing after 55s
ci / rust (push) Successful in 6m7s
mark_session_dead_with_worktree_settlement cloned the session under the
lock, dropped it, did async work, then wrote the stale clone to the
sidecar. A haiku rename (or any other metadata update) that landed
between the clone and the write was silently overwritten, so the sidecar
reverted to name_source: Generated and the next daemon recovery
re-triggered autoname.

Re-read record.session under the lock at write time so the sidecar
always reflects the latest state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:14:34 +01:00
Miguel Palhas f3d6146884 Closes #254: port design kit specimen page to React 2026-09-09 15:13:48 +01:00
Miguel Palhas 35ae01db93 feat(maestro): port design kit specimen page to React
Replaces the deleted kit.ts + kit.css with DesignKit.tsx, gated behind
import.meta.env.DEV and loaded on ?kit. Shows the full tailwind.config.ts
token mapping (colours across all 6 palettes, typography, spacing,
radii, z-index) plus buttons, ActivityRail states, SessionCard states,
and terminal chrome, all switchable live through ThemeContext.

Closes #254
2026-09-09 15:11:48 +01:00
Miguel Palhas 8a05ef2eb7 feat(maestro): wire React app as sole web entry point
Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the
23 views it drove are gone; App.tsx composes the ported components —
sidebar, multi-view, command bar, spawn modal, help and restart
overlays, daemon link, drawer — around the catalog and layout stores.

The pure parts of four deleted modules moved next to the components
that still use them: the spawn-form model and spawnSimilar into
components/spawn, the transcript's markdown/format helpers and the diff
into components/structured, the help overlay's grouping into
components/dialogs. Document-level event names now live in uiEvents.ts.

Deduplicated two pairs the migration left side by side: the sidebar
uses dialogs/KillDialog and common/ContextMenu. It also listens for the
command bar's kill/rename events, which nothing had picked up since
catalog.ts went away.

styles.css stays as the token and base layer, with `#app` renamed to
`#root` and the pane-body rules re-keyed off the classes the React
panes actually carry.

Closes #253
2026-09-09 02:27:42 +01:00
Miguel Palhas ad71147bd9 Closes #252: port all tests to React Testing Library 2026-09-09 02:07:52 +01:00
Miguel Palhas e9c3882cea Closes #242: port daemon link and restart overlay to React 2026-09-09 02:07:49 +01:00
Miguel PalhasandClaude Sonnet 5 c9562ab5eb Closes #252: port vanilla-DOM tests to React Testing Library
Rewrites the 27 DOM-behavior test files (dialogs, sidebar, spawn form,
command bar, structured pane, catalog, multiview, theme, etc.) to use
screen/within/fireEvent/waitFor instead of manual querySelector and
dispatchEvent. Pure-logic test files (matcher, tree, activity, paths,
reconnect, keys, webgl, paneTitle, commands, daemonLink, events, layout,
persistence, previewSchedule, projectList, spawnSimilar, structuredDiff,
toolIcon, fastForward, spawnForm) are untouched, and preview.test.ts
needed no changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 02:06:31 +01:00
Miguel Palhas bc87ddee06 feat(web): port daemonLink and restartOverlay to React (#242)
DaemonLink.tsx holds the link state machine as a Zustand store (same
shape as catalogStore/connectionStore) plus a thin controller component
wiring the catalog store's `connected` field to it. RestartOverlay.tsx
renders the blocking modal for down/updating, reusing the existing CSS.

Closes #242
2026-09-09 01:59:49 +01:00
Miguel Palhas 554c9ebb31 Closes #240: add useHashRouter hook 2026-09-09 01:51:02 +01:00
Miguel PalhasandClaude Sonnet 5 af5ad1a227 feat(web): add useHashRouter hook for React shell (#240)
Ports main.ts's parseRoute/dispatch hash parsing into a React hook:
same #/ and #/attach/:id shape, current route plus a navigate function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 01:43:46 +01:00
Miguel Palhas 8304ef9a49 Closes #251: port preview mode to React 2026-09-09 01:39:37 +01:00
Miguel Palhas cdba6d07e3 Closes #250: port dialogs, context menu, help overlay, mobile drawer 2026-09-09 01:39:37 +01:00
Miguel Palhas ea8c42bfb2 feat(maestro): port preview mode to React (#251)
Extends layoutStore with previewSession/commitPreview, mirroring
multiview.ts's preview controller: a throwaway tab for a session no pane
holds, byte-for-byte restore on end, and every real layout-changing action
drops an active preview first. Never schedules a save while a preview is up.

Adds usePreview, wrapping the store actions with previewSchedule.ts's
120ms leading-edge throttle, plus a touch guard and an immediate `end()`
for real changes. MultiView/TabBar render the preview hue on the pane
titlebar, canvas ring/chip, and the throwaway tab (dashed, unclickable).
2026-09-09 01:39:05 +01:00
Miguel Palhas e0edbc09d2 feat(maestro): port kill dialog, help overlay, context menu, drawer to React
Closes #250: kill confirmation/force-kill (dialogs/KillDialog), the ?
shortcuts overlay (dialogs/HelpOverlay), the general-purpose right-click
menu (common/ContextMenu, closes on scroll/resize unlike the sidebar's
own), the mobile drawer with swipe support (layout/Drawer), and the
visual-viewport hook (hooks/useViewport). Each is a standalone, tested
port of its vanilla-DOM source; wiring into the shell is a later issue.
2026-09-09 01:33:56 +01:00
Miguel Palhas e9e98e0c49 Closes #249: port layout system to React 2026-09-09 01:23:04 +01:00
Miguel Palhas 02f1844650 Closes #247: port command bar (ctrl-k) to React 2026-09-09 01:23:01 +01:00
Miguel Palhas 1b7ec1f54c feat(maestro): port layout system (tabs, splits, multiview) to React
Closes #249: layoutStore.ts wraps the existing layout.ts tree model
in Zustand, persisting to localStorage on a 400ms debounce and adding
boot reconciliation to prune leaves for sessions no longer live.
MultiView/TabBar/PaneContainer/PaneTitle render tabs, nested splits
with draggable dividers, drag-and-drop tab reordering, and dispatch
each pane to TerminalPane or StructuredPane by session view.

Preview mode (#251) and sidebar drag-to-dock stay out of scope here.
2026-09-09 01:21:37 +01:00
Miguel Palhas ee61cea64f feat(maestro): port the command bar to React (#247)
CommandBar, ResultList, ActionPage and useCommandBar over the same
model docs/COMMAND-BAR.md specifies: sigils, empty-query session order,
the drill-in action page, and the keys.

The highlight now walks the rows in the order they render. The legacy
bar indexed the highlight by display order but activated by rank order,
so Enter could act on a different row than the one lit.
2026-09-09 01:14:28 +01:00
Miguel Palhas 7e21371e47 refactor(maestro): lift the matcher into a shared utils module
The match-and-rank algorithm is pure and both the legacy bar and the
React port rank with it (#247).
2026-09-09 01:14:28 +01:00
Miguel Palhas 52ae636dbb Closes #248: port spawn dialog and fast-forward to React 2026-09-09 01:05:53 +01:00
Miguel Palhas 9d93e6e60e Closes #246: port structured pane (ACP transcript) to React 2026-09-09 01:05:50 +01:00
Miguel Palhas babdc678e0 feat(maestro): port spawn dialog to React (#248)
Ports the vanilla spawn form/modal to src/components/spawn/:
SpawnModal, SpawnForm, DirAutocomplete, BranchPicker, and a
useFastForward hook for the one-keystroke spawn. Pure logic modules
(spawnForm.ts's buildSpawnRequest, fastForward.ts, spawnSimilar.ts,
projectList.ts, toolIcon.ts) are reused as-is rather than re-derived.

Exports supportsStructuredView from spawnForm.ts so the React form can
share the structured-view-eligibility check with the vanilla one.
2026-09-09 01:04:05 +01:00
Miguel PalhasandClaude Opus 5 9ba1078386 feat(maestro): port the structured pane to React
Closes #246. The transcript, composer and approval controls move from
structured.ts/structuredRender.ts into components under
web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row
kinds, both projections, the single-active approval nonce, and diff rendering
with its own line counts.

The pure layers stay shared — structuredDiff.ts computes the diff,
structuredRender.ts still sanitizes markdown — and model.ts holds the
client-side decisions (activity grouping, notice classification, offered
decisions, `/` completion) so they are testable without a DOM.

Like TerminalPane, the pane draws the body only; pane chrome reads the
connection through onStatusChange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:41:18 +01:00
Miguel PalhasandClaude Opus 4.6 f0a1774e4d fix(maestro): integration review fixes for wave 3
- Add #root div to index.html so React entry point can mount
- Replace unsafe type cast in Sidebar with typed CatalogStore selector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 00:29:05 +01:00
Miguel Palhas ac720eb266 Closes #245: port terminal pane to React 2026-09-09 00:26:16 +01:00
Miguel Palhas 0ba83c3d8f Closes #245: port terminal pane (xterm attach) to React
TerminalPane is a thin ref-based wrapper: xterm lifecycle lives in a
single mount effect keyed on sessionId, tearing the socket and terminal
down on cleanup. Preserves webglRenderer.ts's page-wide WebGL context
budget (acquire on mount, release on unmount, re-acquire on context
loss while still mounted) and reconnect.ts's exponential 500ms->10s
backoff. keys.ts's mobile modifier encoding moves into KeyBar.tsx.

Pane chrome (title, disconnect, sessions drawer) stays out of scope;
composition happens one level up.
2026-09-09 00:24:08 +01:00
Miguel Palhas e8e98baf4d feat(maestro): port the sidebar to React
Ports catalog.ts's tree renderer to components under
src/components/sidebar/, following docs/SIDEBAR.md: two-line cards
behind a 5px activity rail, adaptive meta line (duration, detail,
program, diff stat), collapsible folders, j/k walk, inline rename and a
right-click menu.

The nav walk reads a pure model (model.ts) rather than the DOM, so the
rule that an expanded folder header is skipped and a collapsed one is
not is testable without a render. Rail motion needs @keyframes, so it
stays in sidebar.css rather than the Tailwind config.

Closes #244
2026-09-09 00:21:13 +01:00
Miguel Palhas b286323c9c Closes #243: port theme system and design tokens to Tailwind config 2026-09-09 00:14:11 +01:00
Miguel Palhas 71ec7e073c Closes #241: port CatalogStore and ConnectionPool to Zustand stores 2026-09-09 00:14:08 +01:00
Miguel Palhas 46bae13212 feat(maestro): map design tokens to Tailwind config, port theme to React
Wires tailwind.config.ts to every custom-property token in styles.css
(spacing, OKLCH colours, radii, z-index, sizing, durations) so Tailwind
utilities resolve to the same variables the vanilla CSS uses. Adds
ThemeContext/useTheme as a React port of theme.ts, keeping the same
localStorage keys and data-theme/data-palette attributes so it can run
alongside the vanilla shell.
2026-09-09 00:12:59 +01:00
Miguel PalhasandClaude Sonnet 5 20d303278d feat(web): port CatalogStore and ConnectionPool to Zustand stores
Ports events.ts's CatalogStore and connections.ts's ConnectionPool to
Zustand stores (src/stores/catalogStore.ts, connectionStore.ts) built
via factory functions so tests can inject fake sockets/schedulers, same
as the originals. Reconnect/backoff schedule, the 6-max-parked LRU, and
the #140 view-reconciliation race handling all carry over unchanged.

The vanilla events.ts/connections.ts stay in place for the current app;
this is prep for the React port (#241).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 00:12:09 +01:00
Miguel Palhas 589e0144e2 Closes #239: scaffold React + Tailwind + Zustand 2026-09-09 00:03:04 +01:00
Miguel Palhas 0061378193 feat(maestro): scaffold React + Tailwind + Zustand
Adds react, react-dom, zustand, tailwindcss, @tailwindcss/vite as a new
skeleton alongside the existing vanilla SPA. New src/index.tsx entry
point (unused until wired into index.html in a later issue); main.ts
stays the active entry.
2026-09-09 00:01:33 +01:00
Miguel Palhas 1cf4825b1d fix(web): collapse agent commentary
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m31s
ci / rust (push) Successful in 6m18s
2026-09-08 17:21:08 +01:00
Miguel PalhasandClaude Opus 4.6 f799aaf564 fix(maestro): restore focus after surgical tool-row patch
ci / web (push) Successful in 41s
ci / flake (push) Successful in 4m36s
ci / rust (push) Successful in 8m42s
The patch path did a DOM replaceWith and returned early, skipping the
focus-restoration logic in rebuildTranscript. Now saves and restores
tool-head focus within the surgical swap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:37:58 +01:00
naps62-yolo 5af5ceb9a8 feat(maestro): trigger haiku naming for ACP sessions (#206)
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-08 15:35:27 +01:00
Miguel PalhasandClaude Opus 4.6 fbdcec6f69 chore(maestro): allow cargo test in local settings
ci / flake (push) Successful in 3m39s
ci / rust (push) Successful in 6m32s
ci / web (push) Failing after 27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:05:41 +01:00
Miguel PalhasandClaude Opus 4.6 6af4cda650 fix(maestro): gitroot tests tolerate /tmp inside a repo
The sandbox has /tmp/.git, so the ancestor walk finds a git root
above the tempdir. Weakened the assertion: the result must not point
inside the .git-free tempdir, but may point above it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:50:50 +01:00
Miguel Palhas ab4beea2d9 Merge branch 'trombone-2'
ci / web (push) Failing after 27s
ci / rust (push) Failing after 1m1s
ci / flake (push) Successful in 3m18s
2026-09-08 14:36:21 +01:00
Miguel Palhas 44ef5619bf Merge branch 'staccato-2'
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-08 14:36:20 +01:00
Miguel Palhas 6adb5dabf0 Merge branch 'harpsichord-2'
ci / web (push) Failing after 31s
ci / rust (push) Failing after 56s
ci / flake (push) Successful in 4m3s
2026-09-08 14:33:49 +01:00
Miguel Palhas e9c9e1c647 Merge remote-tracking branch 'origin/main' into staccato-2 2026-09-08 14:33:26 +01:00
Miguel PalhasandClaude Opus 4.6 4167b21fe9 feat(maestro): render queued messages as blurred chat bubbles
Queued prompts now appear inline in the transcript as faded user bubbles
instead of a pill badge above the composer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:33:22 +01:00
Miguel PalhasandClaude Opus 4.6 b9f8f02e11 fix(maestro): fix diff double-spacing and add +/- markers
Block spans + literal \n text nodes inside <pre> caused empty lines
between every diff line. Drop the text nodes, add unified-diff prefixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:32:51 +01:00
Miguel PalhasandClaude Opus 4.6 f36b7808a7 feat(maestro): show image previews in user message bubbles
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Uploaded images now render as thumbnails in the user bubble instead of
showing only the server file path as text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:32:42 +01:00
Miguel PalhasandClaude Opus 4.6 c2d427691d fix(web): stop tool-group flashing and config-menu closure on updates
Reuse existing tool-group DOM elements in rebuildTranscript instead of
tearing them down — preserves CSS transition state and focus. Defer
config-menu DOM rebuild while the menu is open so <select> dropdowns
survive background state updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:26:14 +01:00
Miguel Palhas 50b862b1fa Merge branch 'vivace'
ci / web (push) Failing after 29s
ci / rust (push) Failing after 50s
ci / flake (push) Successful in 3m30s
2026-09-08 13:47:19 +01:00
Miguel Palhas 4668cf8ece Merge remote-tracking branch 'origin/main' into recorder-4
ci / rust (push) Failing after 42s
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m39s
2026-09-08 13:46:03 +01:00
Miguel PalhasandClaude Opus 4.6 638155de03 feat(maestro): move activity indicator into scroll area
The "Running 1m ..." status now sits at the tail of the chat
history instead of pinned above the input, matching Claude Code's
placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:56 +01:00
Miguel Palhas 1c0fff5507 Merge remote-tracking branch 'origin/main' into vivace 2026-09-08 13:45:48 +01:00
Miguel PalhasandClaude Opus 4.6 10c9db6d87 feat(web): show last agent text outside collapsed group during active turn
Intermediate narration was hidden inside the collapsed tool group
while the agent was still working. The last AgentText/AgentThought
now stays visible as current status, absorbed only when the turn ends
or newer text replaces it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:45 +01:00
Miguel PalhasandClaude Opus 4.6 b339fecad3 feat(maestro): route maestro send through ACP for structured sessions
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Structured sessions were rejected by `send_input` with NotTerminal.
Now `run_send` checks the session view and routes to
`send_structured_prompt` for structured sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:43:01 +01:00
Miguel PalhasandClaude Opus 4.6 3c10a3a08a fix(maestro): skip Starting sessions in liveness watcher
ci / web (push) Failing after 21s
ci / flake (push) Successful in 3m30s
ci / rust (push) Successful in 6m8s
A structured session still booting its adapter has no ACP handle and
no tmux pane, so the liveness pass misclassified it as a vanished
terminal session and reaped it. Claude Code adapters take long enough
to boot that the 2s liveness tick fires before the handshake finishes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 07:25:48 +01:00
Miguel Palhas fd79c8116a Merge remote-tracking branch 'origin/main' into tuba
ci / web (push) Failing after 7s
ci / flake (push) Successful in 3m15s
ci / rust (push) Successful in 5m54s
2026-09-07 21:49:31 +01:00
Miguel PalhasandClaude Opus 4.6 cb8b93f178 test(maestro): update tests for async structured spawn
Tests now assert `Starting` after create and use `wait_for_ready`
before checking post-handshake state. Unsupported command test
expects `BadRequest` instead of a persisted Dead session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:28:22 +01:00
Miguel PalhasandClaude Opus 4.6 60e57fde31 feat(web): collapse resolved approval rows
ci / web (push) Failing after 25s
ci / flake (push) Successful in 3m36s
ci / rust (push) Successful in 6m12s
Resolved permission boxes vanish from the transcript instead of
lingering as "answered" cards. When a turn has 2+ tools the resolved
approval is absorbed into the tool group as a compact italic line;
otherwise it is hidden entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:20:31 +01:00
Miguel PalhasandClaude Opus 4.6 8d35f7f8c8 feat(maestro): async structured session spawn
Register a structured session immediately with `activity: Starting`
and run the ACP handshake in a background task. The HTTP caller gets
an instant response; the user can begin typing a prompt while the
adapter boots.

- Add `ActivityState::Starting` to the protocol
- Open the event log eagerly so the structured WS can connect during
  boot (empty transcript + idle state)
- `queue_acp_prompt` stashes a prompt while `Starting`; the background
  task drains it on successful handshake
- `apply_structured_activity` skips overwriting `Starting`
- `subscribe_acp_projection` allows connecting during `Starting`
- TUI/CLI map `Starting` to idle colour / "starting" label

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 21:08:08 +01:00
Miguel Palhas bc0fd6ee60 Merge branch 'etude'
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m32s
ci / rust (push) Successful in 6m3s
2026-09-07 20:56:24 +01:00
Miguel PalhasandClaude Opus 4.6 ed5a088299 feat(web): equal-width project cards, drop worktree counters
Project boxes now use a fixed two-column grid so every card is the
same width. The worktree count line is removed — the information
lives in the worktree picker that follows a project selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:48:43 +01:00
Miguel Palhas 9c77965c51 Merge remote-tracking branch 'origin/main' into arpeggio
ci / web (push) Failing after 29s
ci / rust (push) Successful in 6m48s
ci / flake (push) Successful in 4m8s
2026-09-07 20:33:38 +01:00
Miguel PalhasandClaude Opus 4.6 49df4c2e9e fix(web): surgical DOM patch for tool rows to stop flashing
Patch deltas on existing rows now swap just the affected element via
replaceWith() instead of tearing down and rebuilding the entire
transcript. Stops the visible flash and preserves text selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:33:34 +01:00
Miguel PalhasandClaude Opus 4.6 65d7186e1c feat(web): harness cards with tool logos lead the spawn dialog
ci / rust (push) Successful in 8m28s
ci / web (push) Successful in 25s
ci / flake (push) Successful in 3m27s
Move the harness/command choice above the project picker so the
operator decides what runs before where. Replace the flat command
tiles with larger centered cards that render each tool's icon from
toolIcon.ts at 24x24. The Custom escape hatch gets a three-dot
ellipsis icon and a dashed border to read as secondary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 20:29:35 +01:00
Miguel PalhasandClaude Opus 4.6 47654aa434 feat(web): gate sidebar hover preview on ctrl
ci / rust (push) Failing after 5s
ci / web (push) Successful in 26s
ci / flake (push) Successful in 3m0s
Plain hover no longer previews — ctrl+hover does.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 15:22:58 +01:00
Miguel PalhasandClaude Opus 4.6 6ca2ab1707 merge: resolve conflict with main's prompt queue
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m38s
ci / rust (push) Successful in 6m18s
Keep icon cancel button, always-"Send" label, and queue status span
from main. Drop unused `active` local after label simplification.
Update test expectation to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:35:47 +01:00
Miguel PalhasandClaude Opus 4.6 b0b0160ccb feat(web): align composer and activity to content width, icon cancel button
Move max-width constraint from .structured-rows up to .structured-main
so the activity banner and composer match the transcript width. Replace
text cancel button with a stop-sign icon inside a spinning ring. Drop
the accent border-top on composer focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 14:32:56 +01:00
naps62 c4cd8a691e Merge pull request 'fix(web): queue structured prompts' (#204) from quaver into main
ci / web (push) Successful in 30s
ci / flake (push) Successful in 3m46s
ci / rust (push) Successful in 6m33s
Reviewed-on: #204
2026-09-07 12:28:49 +01:00
Miguel Palhas 0381a571f2 Merge remote-tracking branch 'origin/main' into brillante
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m23s
ci / rust (push) Successful in 7m8s
2026-09-07 11:42:28 +01:00
Miguel PalhasandClaude Opus 4.6 8f3d47c49e feat(web): absorb intermediate agent text into tool groups
Intermediate agent text/thought rows between tool calls in the same
turn now render inside the collapsed tool group instead of after it,
preserving chronological order. The last agent text (no subsequent
tool) stays outside as the prominent final answer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:42:25 +01:00
Miguel Palhas ba0c54759b fix(tui): complete test presets
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 47s
ci / rust (pull_request) Successful in 7m3s
2026-09-07 11:39:51 +01:00
Miguel PalhasandClaude Opus 4.6 78912e7925 fix(tui): add missing auto_approve field in draft test
ci / web (push) Successful in 27s
ci / flake (push) Successful in 3m30s
ci / rust (push) Successful in 6m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:32:32 +01:00
Miguel PalhasandClaude Opus 4.6 fef9be6c01 feat(web): cap structured view width for readability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:30:52 +01:00
Miguel Palhas 5387b29049 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 30s
ci / flake (pull_request) Successful in 49s
ci / rust (pull_request) Failing after 52s
2026-09-07 11:25:33 +01:00
Miguel PalhasandClaude Opus 4.6 88afd0df3f feat(maestro): auto-approve ACP permissions via preset config
ci / web (push) Failing after 6s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 2m41s
Structured sessions ignored the --dangerously-skip-permissions flags in
preset commands because the daemon discards CLI flags when spawning the
ACP adapter binary. This adds an `auto_approve` bool to SpawnPreset and
CreateSessionRequest so the operator can declare it in config.toml:

  { id = "claude", command = "claude", auto_approve = true }

When set, the daemon's permission pump immediately responds AllowAlways
to every ACP permission request without surfacing it to clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:19:24 +01:00
Miguel Palhas df51aa7be9 Merge remote-tracking branch 'origin/main' into quaver
ci / web (pull_request) Successful in 27s
ci / flake (pull_request) Successful in 46s
ci / rust (pull_request) Successful in 5m16s
# Conflicts:
#	web/src/structured.ts
2026-09-07 11:06:13 +01:00
naps62-yolo ac8a1b319d feat(web): redesign spawn dialog advanced area (#205)
ci / web (push) Successful in 40s
ci / flake (push) Successful in 3m51s
ci / rust (push) Successful in 6m26s
2026-09-07 10:50:44 +01:00
Miguel PalhasandClaude Opus 4.6 17a57269f7 fix(maestro): satisfy field_reassign_with_default lint
ci / rust (push) Successful in 9m13s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 3m56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 10:47:07 +01:00
Miguel Palhas 43bfda5a60 fix(web): queue structured prompts
ci / web (pull_request) Successful in 47s
ci / flake (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 9m35s
2026-09-07 10:42:49 +01:00
Miguel Palhas 6ffbca3596 Merge remote-tracking branch 'origin/main' into recorder-2 2026-09-07 10:41:59 +01:00
Miguel PalhasandClaude Opus 4.6 2cde58b42f fix(web): compact tool-call groups in structured view
Drop the per-tool name list from group headers (redundant with the rows
below), remove individual borders inside groups, and tighten padding so
20-tool blocks scan as a dense list instead of a wall of cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 10:41:55 +01:00
Miguel Palhas 721c34aa11 Merge remote-tracking branch 'origin/main' into tritone
ci / web (push) Successful in 28s
ci / rust (push) Failing after 1m4s
ci / flake (push) Successful in 3m4s
2026-09-07 10:40:10 +01:00
Miguel PalhasandClaude Opus 5 a7d8b5e22f fix(maestro): keep the draft first when the composer wraps
ci / web (push) Successful in 28s
ci / flake (push) Successful in 3m24s
ci / rust (push) Successful in 6m8s
The attach button precedes the draft in the DOM, so under the ≤520px wrap
rule it took a line of its own above the textarea. An explicit order puts
the draft back on the first line and groups every control below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:27:46 +01:00
Miguel Palhas 2f695e1354 Merge remote-tracking branch 'origin/main' into balalaika 2026-09-07 10:25:40 +01:00
Miguel Palhas 1e30b9ade1 Merge remote-tracking branch 'origin/main' into timbre
ci / rust (push) Successful in 8m43s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 4m21s
2026-09-07 10:24:22 +01:00
Miguel PalhasandClaude Opus 5 b4d4b0712c fix(maestro): keep the composer's one-row layout
Reverts the stacked control strip from f78aa13. The floating card is still
gone — flush band, one hairline against the transcript that doubles as the
focus indicator — but the composer keeps its own `--surface` fill so it
still reads as distinct from the pane, and the draft, gear and Send stay on
one bottom-aligned row.

At phone width that row cannot hold a draft plus three controls, so it
wraps: the draft takes a full line and the controls drop below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:23:15 +01:00
Miguel Palhas 4df8fdb7dc feat(maestro): attach files in structured views 2026-09-07 10:20:08 +01:00
Miguel Palhas b949cffb9e feat(tui): refine structured transcript 2026-09-07 10:18:19 +01:00
Miguel PalhasandClaude Opus 5 25653abcf5 feat(maestro): default structured view on for ACP agents
ci / rust (push) Successful in 9m32s
ci / web (push) Successful in 31s
ci / flake (push) Successful in 4m6s
A Claude Code or Codex spawn now asks for the structured view unless the
operator says otherwise: the two spawn forms open with the box ticked and
`maestro spawn` takes `--terminal` to opt out. Once the operator works the
toggle, a command change no longer re-ticks it under them.

The support predicate moves to `maestro_protocol::display` so all three
clients gate on the same resolution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:18:07 +01:00
Miguel PalhasandClaude Opus 5 f78aa13b36 fix(maestro): flatten structured composer
The composer was a floating bordered card inset from the pane edge, and
its send button was bottom-aligned beside the textarea, so it sank as the
draft grew.

It is now a flush band at the foot of the pane: one hairline rule against
the transcript, which doubles as the focus indicator. The textarea spans
the full width and the controls sit in a fixed strip below it, so the send
button keeps its place however tall the draft grows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:16:56 +01:00
Miguel Palhas 0dc10ffe76 fix(web): repair nix frontend build
ci / web (push) Successful in 27s
ci / flake (push) Successful in 6m50s
ci / rust (push) Successful in 7m15s
Two things broke `nix build .#webFrontend`, and with it `nh home apply`
on any host that installs maestro.

`marked` was added to web/package.json without repinning pnpmDeps.hash,
so pnpm install failed offline with ERR_PNPM_NO_OFFLINE_TARBALL.

`tsc -b` also type-checks web/src/toolIcon.test.ts, which imports the
shared ../../fixtures/command-resolution.json. The derivation's src is
./web, so that path does not exist in the sandbox. The production build
now uses tsconfig.build.json, which excludes tests; `pnpm typecheck`
still covers them from a full checkout.
2026-09-07 09:54:51 +01:00
Miguel Palhas 0543f66469 fix(web): keep copy labels hover-only
ci / web (push) Successful in 25s
ci / flake (push) Failing after 40s
ci / rust (push) Successful in 5m12s
2026-09-07 08:13:23 +01:00
Miguel Palhas 4a2f7e98b4 feat(web): render structured tool payloads 2026-09-07 07:51:42 +01:00
Miguel Palhas 8c151a5fb2 Merge remote-tracking branch 'origin/main' into blitz/codex-structured-sessions
ci / web (push) Failing after 8s
ci / flake (push) Failing after 2m18s
ci / rust (push) Successful in 8m53s
# Conflicts:
#	crates/maestro-daemon/tests/worktree.rs
#	crates/maestro-e2e/tests/e2e.rs
#	web/src/attach.ts
#	web/src/connections.test.ts
#	web/src/connections.ts
2026-09-06 23:16:27 +01:00
Miguel Palhas 8d0886f9b7 feat(web): animate structured session interactions 2026-09-06 22:49:10 +01:00
Miguel Palhas 9f82400f0a fix(web): reclaim mobile composer edges 2026-09-06 22:22:24 +01:00
Miguel Palhas 059316cbac fix(web): normalize structured thought rows 2026-09-06 22:21:19 +01:00
Miguel Palhas 0b1c9e15cf fix(web): quiet structured transcript metadata 2026-09-06 21:51:17 +01:00
Miguel Palhas e30084ea24 docs(maestro): document Codex structured installation 2026-09-06 18:48:24 +01:00
Miguel Palhas bb898a75f3 Merge issue #203: offer structured Codex TUI spawns 2026-09-06 18:43:27 +01:00
Miguel Palhas c8e90af91c feat(maestro): support Codex structured TUI 2026-09-06 18:43:09 +01:00
Miguel Palhas d0df60ac87 Merge issue #202: offer structured Codex web spawns 2026-09-06 18:39:16 +01:00
Miguel Palhas 25244803a9 feat(maestro): support structured Codex spawns 2026-09-06 18:38:58 +01:00
Miguel Palhas 92b41a8742 docs(maestro): clarify adapter PATH lookup 2026-09-06 18:33:06 +01:00
Miguel Palhas 508262bbac Merge issue #201: cover Codex ACP lifecycle 2026-09-06 18:32:47 +01:00
Miguel Palhas 002f1b466b test(maestro): cover Codex ACP lifecycle 2026-09-06 18:32:19 +01:00
Miguel Palhas bc27bffbae Merge issue #200: select structured ACP adapter 2026-09-06 18:15:09 +01:00
Miguel Palhas bbf622ed9b feat(maestro): select ACP adapter by command 2026-09-06 18:11:04 +01:00
Miguel Palhas 0c46fb993a fix: recover PWA after backgrounding 2026-09-06 18:02:11 +01:00
Miguel Palhas be554d0ede feat: render structured responses as markdown 2026-09-06 17:31:15 +01:00
Miguel Palhas 4fda393378 polish: refine structured composer 2026-09-06 17:06:40 +01:00
Miguel Palhas a1f3690aaf feat: show structured turn activity 2026-09-06 16:50:46 +01:00
Miguel Palhas 4ce432c227 fix: move session picker to composer 2026-09-06 16:38:50 +01:00
Miguel Palhas ba7fd68004 feat: add structured session picker 2026-09-06 16:18:33 +01:00
Miguel Palhas c3d008d07f fix: autocomplete at composer caret 2026-09-06 15:52:04 +01:00
Miguel Palhas c355f67133 feat: autocomplete structured commands 2026-09-06 15:37:49 +01:00
Miguel Palhas b5c9970e28 fix: extend transcript hover surfaces 2026-09-06 15:26:31 +01:00
Miguel Palhas 22d740a36b feat: streamline structured transcript 2026-09-06 15:17:40 +01:00
Miguel Palhas ccdfec8257 fix(daemon): persist structured user prompts 2026-09-06 14:19:45 +01:00
Miguel Palhas 30f91176ae fix(daemon): coalesce anonymous ACP text chunks 2026-09-06 14:10:11 +01:00
Miguel Palhas a771d29210 fix(web): separate transcript scrolling from composer 2026-09-06 14:01:43 +01:00
Miguel Palhas 913421e244 fix(web): anchor structured composer on mobile 2026-09-05 18:15:38 +01:00
Miguel Palhas 2e7c5f7f58 fix: recover PWA after backgrounding
ci / web (push) Successful in 48s
ci / flake (push) Successful in 2m56s
ci / rust (push) Successful in 6m4s
2026-09-05 18:10:21 +01:00
Miguel Palhas dba3d8c249 Merge issue #199: align structured command resolution 2026-09-05 14:28:12 +01:00
Miguel Palhas 4c8217385a fix(maestro): scope assignment-skipping to wrappers that accept it
`command FOO=1 claude` and `nohup FOO=1 claude` are invalid shells (they
exec `FOO=1` as the program, exit 127), but the resolver skipped the
assignment unconditionally after any wrapper. Assignment prefixes are
only real at the start of a simple command or after a wrapper that itself
interprets them as env vars (env, sudo) or a keyword that restarts the
assignment grammar (time) — not after command, nohup, or a package
manager's subcommand, which just exec their next argument as the program.

Attach an accepts_assignment flag to each wrapper transition in both the
Rust and TS resolvers and gate the assignment-skip loop on the flag from
the most recently consumed wrapper. Extends the shared fixture with the
two reported cases plus every other wrapper transition and several nested
combinations (sudo+time valid, sudo+command / command+nohup / time+command
invalid, nohup+env valid) to lock the per-wrapper policy in for both
languages.
2026-09-05 14:25:57 +01:00
Miguel Palhas 758bacf3fc fix(maestro): harden command resolution 2026-09-05 14:05:52 +01:00
Miguel PalhasandClaude Sonnet 5 0af91296df fix(maestro): close command-resolution gaps from #199 review
Fixes six issues in the shared command resolver (Rust display.rs and
its TS mirror toolIcon.ts), all locked in by shared fixture cases:

- An unescaped newline now ends resolution like `;` does, instead of
  being folded in as whitespace; a backslash-newline is a line
  continuation, removed entirely rather than embedded or splitting
  the word.
- Assignment recognition (`FOO=bar`) now requires the `=` to come from
  plain, unquoted/unescaped source text. `"FOO=1"` and `FOO\=1` are
  executable words named `FOO=1`, not assignments — quote removal was
  losing that provenance.
- `command -v`/`-V` (lookup only, no execution), `env -S` (embeds a
  command string) and `npx -c`/`--call` (same) no longer resolve
  through to a program they don't actually run. Restricted bash
  `time`'s options to `-p`, the only flag the keyword itself parses
  (`-o` etc. belong to external GNU time) — this corrects a
  previously-wrong fixture case.
- `env`, `sudo` and `command`'s options may now bundle (`env -iv`,
  `sudo -nE`) or take an attached value (`sudo -uroot`), the way real
  getopt-based tools do, with missing-operand forms safely rejected.
- pnpm/yarn `dlx` and `bun x`/`bunx` now consume their own trailing
  options (`--package foo`/`--package=foo`, `--bun`) before the
  executable, matching each tool's documented CLI syntax.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:51:43 +01:00
Miguel Palhas 370edfa763 fix(maestro): resolve command wrappers with a shell-aware tokenizer (#199)
command_program/command_tool whitespace-split and only skipped whole
wrapper words, so quoted paths broke, sudo -u/env -i/nohup/time/npx
--yes/pnpm|yarn --silent dlx/bun x were rejected, and pnpm claude /
yarn claude (a package.json script, not `dlx claude`) resolved as a
Claude launch. Replace it with a tokenizer that honours quoting and
escaping without evaluating any expansion, and per-wrapper option
tables (env, sudo, command, nohup, time, npx, pnpm, yarn, bun) that
require dlx/x before looking through a package-manager wrapper.
Ambiguous shell grammar (pipelines, list operators, redirections,
substitution, unterminated quotes) is rejected outright rather than
guessed through; command_program still degrades to the naive first
word for display, but command_tool returns None.

Mirror the same resolver in web/src/toolIcon.ts and add
fixtures/command-resolution.json as a parity fixture both test
suites assert against.
2026-09-05 13:19:28 +01:00
Miguel Palhas e7086512e7 test(maestro): lock in shared command resolution for the TUI structured gate (#199)
The TUI's structured-view gate already delegates to command_tool, the
same resolver web/spawnForm.ts uses via splitCommand, so paths, env
assignments, wrappers, and configured presets already resolve like
daemon dispatch. Add coverage for those forms plus stale-selection
clearing on both preset switches and custom-command edits, so a
regression back to a naive basename match would be caught.
2026-09-05 12:59:03 +01:00
Miguel Palhas aca8d39886 Merge issue #198: keep TUI structured viewport stable 2026-09-05 12:46:31 +01:00
Miguel Palhas 6ae7c3eb97 fix(maestro): anchor structured TUI viewport to row identity
follow_selection=false stored scroll as a raw rendered-line offset into
lines rebuilt fresh every frame. A ProjectionUpdate::Patch growing a
streaming row above the viewport shifted every line below it, so the
raw offset kept pointing at a numerically-same but visually different
line and the viewport drifted even though the operator had scrolled
away deliberately.

Cache each frame's row-id-per-line layout and, before applying scroll
next frame, translate the old raw offset into (row id, intra-row line)
against that cache, then relocate it in the freshly rebuilt lines. Rows
above or intersecting the viewport growing, shrinking, or being
patched now shift the offset instead of the visible content. Falls
back to the nearest surviving neighbor row when the anchor row itself
is removed, and to the clamped raw offset when nothing nearby
survived. follow_selection and bottom-follow are unaffected — they
already recompute scroll from the live selection/tail each frame.
2026-09-05 12:39:21 +01:00
Miguel Palhas a73eb6284f Merge issue #197: remove structured lock inversion 2026-09-05 12:22:55 +01:00
Miguel Palhas 57efb615f6 fix(maestro): key activity watermark by stream 2026-09-05 12:19:16 +01:00
Miguel Palhas fe26dab6a8 fix(maestro): remove structured lock inversion 2026-09-05 11:55:25 +01:00
Miguel Palhas 8eecd9961e Merge issue #196: allow concurrent structured handshakes 2026-09-05 11:39:21 +01:00
Miguel Palhas ac2eb01bda fix(maestro): allow concurrent ACP handshakes 2026-09-05 11:29:44 +01:00
Miguel Palhas 1576380080 Merge issue #195: keep structured transcript pinned 2026-09-05 11:12:02 +01:00
Miguel Palhas 85feb433ae fix(web): follow composer and rail layout 2026-09-05 11:09:42 +01:00
Miguel Palhas a11933b7bf fix(web): keep structured transcript pinned 2026-09-05 11:00:15 +01:00
Miguel Palhas fa40eb7378 Merge issue #194: omit plan transcript rows 2026-09-05 10:49:28 +01:00
Miguel Palhas b680266990 docs(maestro): fix Plan row rendering claim in STRUCTURED-VIEW
Plan rows are omitted from the transcript in both clients and rendered
in a separate plan rail/status line, not as a transcript checklist.
2026-09-05 10:48:15 +01:00
Miguel Palhas ded1f050b2 fix(web): omit plan transcript rows 2026-09-05 10:41:00 +01:00
Miguel Palhas a58ebfed93 Merge issue #193: preserve expanded cards on reconnect 2026-09-05 10:32:48 +01:00
Miguel Palhas 31b429a840 fix(web): preserve expanded structured cards 2026-09-05 10:29:40 +01:00
Miguel Palhas 12bd68a2af Merge issue #192: reject structured PTY operations 2026-09-05 10:24:34 +01:00
Miguel Palhas f765d415d6 fix(maestro): reject structured PTY access 2026-09-05 10:20:41 +01:00
Miguel Palhas d3e3010245 Merge issue #143: offer structured view in spawn forms 2026-09-05 09:53:21 +01:00
Miguel Palhas 7a0dbbf7f2 feat(maestro): offer structured spawn view 2026-09-05 09:48:42 +01:00
Miguel Palhas 84fa4a3076 Merge issue #191: render accurate structured diffs 2026-09-05 09:36:32 +01:00
Miguel Palhas eafeac5c05 fix(diff): preserve collapsed EOF marker 2026-09-05 09:33:58 +01:00
Miguel Palhas f96f94db8f fix(maestro): align structured diff hunks 2026-09-05 09:27:37 +01:00
Miguel Palhas efb54750ed fix(maestro): render accurate diffs 2026-09-05 09:18:05 +01:00
Miguel Palhas 9e59121eec Merge issue #190: preserve web tool patch state 2026-09-05 09:00:51 +01:00
Miguel Palhas 4a5c3b9982 fix(web): restore tool expansion directly 2026-09-05 08:59:20 +01:00
Miguel Palhas 443800bd79 fix(maestro): preserve web tool patch state 2026-09-05 08:53:22 +01:00
Miguel Palhas d55d92d58a Merge issue #189: ignore stale TUI approval failures 2026-09-05 08:45:17 +01:00
Miguel Palhas 41770a54a7 fix(maestro): ignore stale approval failures 2026-09-05 08:43:13 +01:00
Miguel Palhas 6cf0b200f8 Merge issue #188: generation-safe web structured submission 2026-09-05 08:33:55 +01:00
Miguel Palhas 25344abe4f fix(web): close stale structured sockets 2026-09-05 08:29:48 +01:00
Miguel Palhas b52a9085e9 fix(web): require post-acceptance state 2026-09-05 08:22:26 +01:00
Miguel Palhas 8bd943a8ca fix(web): release accepted reconnect submit 2026-09-05 08:16:14 +01:00
Miguel Palhas b86335ddf1 fix(maestro): guard structured web submits 2026-09-05 08:09:51 +01:00
Miguel Palhas c348381c5f Merge issue #187: fix TUI structured spawn attachment race 2026-09-05 07:59:55 +01:00
Miguel Palhas dabdf9fcba fix(tui): use spawn view for attach 2026-09-05 07:57:03 +01:00
Miguel Palhas b76dc06a09 Merge structured-view/141-tui-structured 2026-09-05 07:44:56 +01:00
Miguel Palhas 23fbfdc5b8 fix(maestro): guard reconnect generations 2026-09-05 07:43:08 +01:00
Miguel Palhas ff34727e3c fix(maestro): correct structured TUI state 2026-09-05 07:36:34 +01:00
Miguel Palhas e9ede49256 feat(maestro): render structured TUI sessions 2026-09-05 07:19:10 +01:00
Miguel Palhas 8c97ef0616 Merge structured-view/140-web-structured 2026-09-05 06:53:26 +01:00
Miguel Palhas 9533672e09 fix(maestro): require real DOM focus before reconciliation re-focuses (#140)
ConnectionPool.reconcileViews re-focused a repaired connection whenever
focusedId named it, but a same-tab sidebar preview moves the keyboard
into the sidebar without ever calling setFocus (multiview.ts's
syncFocus skips it while previewing) — so focusedId keeps naming the
pane the operator was in before the preview, even though the DOM
doesn't agree. Replacing that pane's connection during exactly that
window called onMount({focus:true}) and yanked the keyboard back out
of the sidebar mid-navigation.

Capture existing.el.contains(document.activeElement) before dispose()/
el.remove() detach it (afterward the browser has already moved
activeElement to <body>, too late to tell), and require both that and
focusedId===id to re-focus. document.body and a null activeElement
both naturally fail .contains(), so "nothing focused" and "focus
elsewhere" both correctly skip.

Regression tests: 3 new (real-focus requirement, same-tab preview
race, descendant focus, body-as-unfocused) — 498 tests total.
2026-09-05 06:49:48 +01:00
Miguel Palhas b11784e8e3 fix(maestro): mobile drawer access, focus preservation, stale rAF focus (#140)
Three more fixes from acceptance review:

- structured.ts's header omitted the .sessions-btn attach.ts's terminal
  header carries. The sidebar is off-canvas on mobile, so a structured
  session had no way to reach it — add the same button and
  requestToggleDrawer wiring.

- ConnectionPool.reconcileViews always remounted a repaired connection
  with focus:false. If the mistyped pane was the one actually focused,
  disposing it dropped DOM focus while `focusedId` still named it. Pass
  `focus: this.focusedId === id` instead — `focusedId` only ever names a
  real focus (multiview.ts skips setFocus while previewing), so a
  preview or a merely-visible split neighbour still comes back
  unfocused.

- onMount's deferred requestAnimationFrame focus had no way to tell it
  had been superseded by an unmount (or a remount elsewhere) before it
  fired, so a parked pane could steal focus a frame later. A token
  bumped on every mount/unmount lets the callback recognize it's stale
  and no-op.

Regression tests: 7 new (structured.test.ts, connections.test.ts). 495
tests total.
2026-09-05 06:41:40 +01:00
Miguel Palhas 42ffc1a352 fix(maestro): correct connection view race and approval decisions (#140)
Three fixes from acceptance review:

- ConnectionPool.mount cached a connection's kind (terminal/structured)
  permanently from whatever `info` it saw first. A deep link or restored
  layout mounts before the catalog snapshot names a session's view, so
  `info` is undefined and a structured session got an xterm connection
  forever. The pool now tracks the view each cached connection was built
  for, subscribes to the catalog itself, and replaces a mistyped
  connection in place the moment the real view resolves — without
  downgrading a resolved connection just because a later caller couldn't
  resolve the session, and without treating a session merely absent from
  a snapshot (mid-removal) as "Terminal".

- The approval card offered all three decisions unconditionally. Per
  the daemon's decision mapping (session.rs's permission_outcome), a
  decision with no matching PermissionOptionKind in the request's own
  options resolves Cancelled silently — so "Allow for session" could
  look like it worked while the daemon withdrew it. Buttons are now
  gated on the options actually offered.

- Clicking a decision disabled the buttons and never re-enabled them if
  the POST failed. The decision path is async now: a rejection restores
  the buttons and surfaces the error only if the approval is still the
  pending one — a superseded/resolved approval's stale rejection is a
  no-op, not a wrongly revived control.

Regression tests: 12 new (connections.test.ts, structured.test.ts,
structuredRender.test.ts). 488 tests total.
2026-09-05 06:32:08 +01:00
Miguel Palhas a50f098c8a feat(maestro): render the structured view in the web client (#140)
Adds a transcript surface for a Structured-view session alongside the
existing xterm attach view: a WS /api/structured/:id connection
(structured.ts) mounted by ConnectionPool whenever a session's `view`
is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts,
structuredDiff.ts) covering all eight transcript row kinds.

Hierarchy follows the design reference (artifact 88ecd690): the
approval is the only lifted, card-like element; a user turn is a
tinted block with an accent rail; agent prose is plain text; tool
calls are compact bordered rows expanding to output or a coloured,
horizontally-scrolling diff computed from ACP's full old/new text.
The client renders `reduced_state` and rows exactly as received, with
no raw-event folding or turn tracking of its own.

Extracted attach.ts's pane-header title building into paneTitle.ts and
its connection interface into pane.ts (PaneConnection) so both
connection kinds share one contract and one header.
2026-09-05 06:10:40 +01:00
Miguel Palhas 32d4058c96 Merge structured-view/186-unknown-update-doc 2026-09-05 05:38:04 +01:00
Miguel Palhas 74a423567f docs(maestro): fix disabled-unstable-variant claim in STRUCTURED-VIEW.md
A SessionUpdate variant gated behind an unstable ACP feature this build
doesn't enable is compiled out of the enum, not decoded into a Notice.
It fails deserialization the same as a genuinely unknown future tag.
2026-09-05 05:35:19 +01:00
Miguel Palhas b225fbdd69 docs(maestro): narrow the Notice forward-compat claim in STRUCTURED-VIEW.md
Notice only covers SessionUpdate variants the agent-client-protocol
dependency already decodes but the projection doesn't special-case.
SessionUpdate is an internally-tagged enum with no unknown-variant
fallback, so a genuinely new wire variant fails deserialization before
the projection ever sees it, rather than becoming a Notice. A new
variant needs the dependency upgraded first.

Adds a projection test asserting that an unrecognised sessionUpdate
tag is a deserialization error.

Refs #186
2026-09-05 05:28:11 +01:00
Miguel Palhas 244e8d2927 Merge structured-view/185-lock-order into blitz/structured-view 2026-09-05 05:20:20 +01:00
Miguel Palhas 4a1a10c49a fix(maestro): avoid nested session locks 2026-09-05 05:15:58 +01:00
Miguel Palhas d607740fea Merge structured-view/184-approval-persist into blitz/structured-view 2026-09-05 05:05:15 +01:00
Miguel PalhasandClaude Sonnet 5 0b34d1608f fix(maestro): make FaultyLogSink honor the Write contract on partial writes
write() reported Ok(partial) and Err from the same call, which Write
forbids. Defer the error to the following call so write_all observes
a genuine short write, then a failure — matching what a real fd does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 05:02:01 +01:00
Miguel Palhas 075dea509a fix(maestro): make EventLog::append transactional under partial write/flush failure
A write_all that errors mid-line, or a flush that errors after a full
write, could leave last_seq and the log's on-disk line count out of
sync: a well-formed but uncounted line, or a partial one, would corrupt
every later cursor once the next append landed beside it. append now
captures the pre-append length and truncates back to it on either
failure, so the same EventLog can be appended to again immediately. If
the rollback itself can't restore that length, append returns a Fatal
variant so callers kill the session instead of promising consistency
they can no longer keep — record_permission_request and its pump now
route this through the session-death path used elsewhere.

Adds deterministic fault injection (a seekable/truncatable LogSink
double) to prove the same EventLog recovers with valid JSONL and
live/replay agreement, rather than swapping in a healthy handle. Fixes
the row-index rollback test to actually shift a later row, and
tightens the persistence-failure wording in STRUCTURED-VIEW.md.
2026-09-05 04:55:26 +01:00
Miguel Palhas c067f48a24 fix(maestro): roll back phantom approval row on log append failure
request_approval() appended the Approval row to the live projection
before the daemon tried to persist it. On append failure the pending
nonce was cancelled but the row stayed in the in-memory transcript, so
a WebSocket snapshot taken afterward showed a row durable replay could
never reproduce.

Add Projection::rollback_row to undo the append and call it alongside
the existing cancel_pending_approvals on failure. Extract the pump's
per-request handling into record_permission_request so the failure
path is directly testable without a live ACP connection.

Fault-injection regression tests use a read-only file handle
(EventLog::for_test, cfg(test)) to force append() to fail
deterministically, then assert the live snapshot and durable replay
agree (both empty) and a subsequent request still persists and
replays correctly.

Fixes #184.
2026-09-05 04:34:47 +01:00
Miguel Palhas 51f96cfb98 Merge structured-view/142-structured-activity into blitz/structured-view 2026-09-05 04:15:46 +01:00
Miguel Palhas 8f5d96a95b feat(maestro): derive structured activity 2026-09-05 04:08:43 +01:00
Miguel Palhas 3e98b3ab24 Merge structured-view/137-structured-docs into blitz/structured-view 2026-09-05 03:51:34 +01:00
Miguel Palhas 3529947497 Merge branch 'fix/codex-attach-scroll'
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 3m3s
ci / rust (push) Successful in 7m35s
2026-09-05 03:49:35 +01:00
Miguel Palhas 4851ad9101 fix(maestro): follow initial attach replay 2026-09-05 03:48:35 +01:00
Miguel Palhas 3cb4bebd1e fix(maestro): update second stale worktree test for branch suffixing
ci / web (push) Successful in 54s
ci / flake (push) Successful in 3m8s
ci / rust (push) Successful in 8m30s
second_spawn_on_same_branch_is_rejected asserted the same pre-#77
outright-rejection contract as the unit test fixed in 0e20baf, just at
the e2e/session level — also broken by 49ab7e9's branch-collision
suffixing and also already failing on main.

Renamed to second_spawn_on_same_branch_gets_suffixed and rewritten to
assert the actual behavior: the second spawn succeeds on a `-2`
suffixed branch and directory, and the first worktree is untouched.
2026-09-04 20:08:16 +01:00
Miguel Palhas 0e20baf7c2 fix(maestro): update stale worktree test for branch-collision suffixing
ci / web (push) Failing after 38s
ci / flake (push) Successful in 4m17s
ci / rust (push) Failing after 7m40s
add_fails_when_branch_already_exists asserted the pre-#77 behavior:
that a branch collision errors outright. Since 49ab7e9, add_new
sidesteps a branch collision with a numeric suffix instead, so the
test was failing CI on main regardless of any given change.

Replaces it with a test asserting the actual (and intended) suffixing
behavior.
2026-09-04 19:57:33 +01:00
Miguel Palhas af8acbfbf2 test(maestro): verify umbrella binary routes each subcommand to its crate
ci / web (push) Successful in 1m10s
ci / rust (push) Failing after 3m17s
ci / flake (push) Successful in 7m6s
Closes #10

The compile_error guard and per-feature build already had CI coverage
(the "feature-gate matrix" job). What was missing was proof that a
subcommand actually reaches its own crate's dispatch at runtime, not
just that clap parses it.

A malformed --config file makes cli/daemon/tui fail identically at
config load, before touching tmux, the network, or a terminal. That
turns "clap parsed it but the handler never ran" (a usage error, exit
2) into a distinguishable outcome from "it dispatched into the real
crate" (a `toml decode` runtime error via main's error printer, exit
1). The absence-side tests are feature-gated to only exist in a
single-feature build, so they run under CI's existing per-feature
matrix rather than the default all-features build.
2026-09-04 19:47:32 +01:00
Miguel Palhas 1ad7845f51 fix(maestro): refresh attach header when a session is renamed
Header name/command were baked into innerHTML at connection creation
and never revisited. ConnectionPool.mount() reused the cached
connection on later calls but dropped the fresh info, so a header
never picked up haiku autoname's rename.
2026-09-04 19:38:09 +01:00
304 changed files with 33470 additions and 13288 deletions
+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
Generated
+3
View File
@@ -1467,6 +1467,7 @@ dependencies = [
"maestro-cli",
"maestro-daemon",
"maestro-tui",
"tempfile",
"tokio",
"tracing-subscriber",
]
@@ -1477,6 +1478,7 @@ version = "0.1.0"
dependencies = [
"agent-client-protocol",
"libc",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
@@ -1644,6 +1646,7 @@ dependencies = [
"tokio",
"tokio-tungstenite 0.21.0",
"tracing",
"unicode-width",
"uuid",
"vt100",
]
+1
View File
@@ -44,6 +44,7 @@ tokio-util = { version = "0.7", features = ["compat"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
unicode-width = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }
ratatui = "0.28"
vt100 = "0.15"
+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")
+2 -1
View File
@@ -5,11 +5,12 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "ACP client role: one claude-code-acp child per structured session"
description = "ACP client role: one provider adapter child per structured session"
[dependencies]
agent-client-protocol.workspace = true
libc.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
+446 -54
View File
@@ -1,5 +1,5 @@
//! ACP client role for the daemon (#134): one `claude-code-acp` child per
//! structured session.
//! ACP client role for the daemon: one provider adapter child per structured
//! session.
//!
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the
//! adapter as a child process with stdio pipes, in the session's `cwd` with
@@ -9,10 +9,10 @@
//!
//! **The handshake declares almost nothing.** `initialize` reports
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the
//! daemon to act on its behalf — exactly what already happens in the
//! terminal view. `session/request_permission` is the one client method that
//! gets a real implementation, in #139; here it is a stub that declines.
//! adapter uses the provider's own file and command tools instead of asking
//! the daemon to act on its behalf — exactly what already happens in the
//! terminal view. `session/request_permission` is the one client method with
//! a real implementation.
//!
//! Every `session/update` notification the adapter sends lands on an
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
@@ -42,30 +42,80 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, Implementation,
InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId,
SessionNotification,
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, SetSessionModeRequest,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client};
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncBufReadExt;
use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema;
/// The adapter binary, resolved on `PATH`.
pub const ADAPTER_BIN: &str = "claude-code-acp";
/// 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>>,
},
}
/// How to install the adapter when it is missing. Named in the error so an
/// operator staring at a failed session knows what to run.
/// ACP adapter selected from the structured session's resolved command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
ClaudeCode,
Codex,
}
impl AcpAdapter {
pub const fn binary(self) -> &'static str {
match self {
Self::ClaudeCode => "claude-code-acp",
Self::Codex => "codex-acp",
}
}
pub const fn install_command(self) -> &'static str {
match self {
Self::ClaudeCode => "npm install -g --prefix ~/.local @zed-industries/claude-code-acp",
Self::Codex => "npm install -g --prefix ~/.local @agentclientprotocol/codex-acp",
}
}
}
impl std::fmt::Display for AcpAdapter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::ClaudeCode => "Claude Code",
Self::Codex => "Codex",
})
}
}
/// Compatibility aliases for Claude Code callers.
pub const ADAPTER_BIN: &str = "claude-code-acp";
pub const INSTALL_COMMAND: &str =
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
/// How long `initialize` + `session/new` may take before the spawn is
/// declared failed. The real adapter boots a node process and the Claude
/// Code SDK behind it; a slow machine takes seconds, never a minute.
/// declared failed. The real adapters boot a node process and their provider
/// client behind it; a slow machine takes seconds, never a minute.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
@@ -78,6 +128,75 @@ pub const TERM_GRACE: Duration = Duration::from_secs(2);
/// Bytes of the child's stderr kept for the failure message.
const STDERR_TAIL_BYTES: usize = 4096;
const LEGACY_MODEL_CONFIG_ID: &str = "__maestro_legacy_model";
const LEGACY_MODE_CONFIG_ID: &str = "__maestro_legacy_mode";
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/new", response = ExtendedNewSessionResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedNewSessionRequest {
cwd: PathBuf,
mcp_servers: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedNewSessionResponse {
session_id: SessionId,
#[serde(default)]
modes: Option<SessionModeState>,
#[serde(default)]
config_options: Option<Vec<SessionConfigOption>>,
#[serde(default)]
models: Option<LegacyModelState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/load", response = ExtendedLoadSessionResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedLoadSessionRequest {
session_id: SessionId,
cwd: PathBuf,
mcp_servers: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
struct ExtendedLoadSessionResponse {
#[serde(default)]
modes: Option<SessionModeState>,
#[serde(default)]
config_options: Option<Vec<SessionConfigOption>>,
#[serde(default)]
models: Option<LegacyModelState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyModelState {
current_model_id: String,
available_models: Vec<LegacyModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyModel {
model_id: String,
name: String,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)]
#[request(method = "session/set_model", response = LegacySetModelResponse)]
#[serde(rename_all = "camelCase")]
struct LegacySetModelRequest {
session_id: SessionId,
model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)]
struct LegacySetModelResponse {}
/// What [`spawn`] needs to start one adapter.
#[derive(Debug, Clone)]
@@ -94,6 +213,8 @@ pub struct SpawnConfig {
/// instead of opening a fresh one. The adapter must advertise
/// `loadSession`; the handshake fails otherwise.
pub load_session: Option<String>,
/// The provider-specific adapter to launch.
pub adapter: AcpAdapter,
}
impl SpawnConfig {
@@ -103,6 +224,7 @@ impl SpawnConfig {
env,
handshake_timeout: HANDSHAKE_TIMEOUT,
load_session: None,
adapter: AcpAdapter::ClaudeCode,
}
}
@@ -111,6 +233,11 @@ impl SpawnConfig {
self.load_session = Some(session_id.into());
self
}
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
self.adapter = adapter;
self
}
}
/// A blocked ACP permission request delivered to the daemon.
@@ -126,31 +253,39 @@ pub struct PermissionRequest {
pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command
/// so the failed session is actionable rather than silently dead.
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")]
AdapterMissing,
#[error("failed to start {ADAPTER_BIN}: {0}")]
Spawn(#[source] std::io::Error),
#[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
AdapterMissing { adapter: AcpAdapter },
#[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
Spawn {
adapter: AcpAdapter,
#[source]
source: std::io::Error,
},
/// The adapter answered the handshake with an error.
#[error("ACP handshake failed: {0}")]
Handshake(String),
/// The adapter went away before the handshake finished. Carries the
/// tail of its stderr, which is where a missing Claude login shows up.
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")]
ExitedEarly { status: String, stderr: String },
#[error("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
ExitedEarly {
adapter: AcpAdapter,
status: String,
stderr: String,
},
#[error("ACP handshake timed out after {0:?}")]
HandshakeTimeout(Duration),
}
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
/// session env's `PATH` when it sets one, the daemon's own otherwise.
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> {
pub fn resolve_adapter(adapter: AcpAdapter, env: &HashMap<String, String>) -> Option<PathBuf> {
let path: OsString = match env.get("PATH") {
Some(p) => OsString::from(p),
None => std::env::var_os("PATH")?,
};
std::env::split_paths(&path)
.filter(|dir| !dir.as_os_str().is_empty())
.map(|dir| dir.join(ADAPTER_BIN))
.map(|dir| dir.join(adapter.binary()))
.find(|candidate| is_executable(candidate))
}
@@ -168,9 +303,20 @@ enum Command {
turn: u64,
done: oneshot::Sender<Result<(), String>>,
},
SetConfig {
config_id: String,
value: SessionConfigOptionValue,
done: oneshot::Sender<Result<(), String>>,
},
Close,
}
struct ConnectionConfig {
cwd: PathBuf,
load_session: Option<String>,
adapter: AcpAdapter,
}
enum ChildCommand {
Signal(libc::c_int),
Reap(oneshot::Sender<bool>),
@@ -226,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>>,
}
@@ -265,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()
}
@@ -301,6 +447,23 @@ impl AcpSession {
Ok(done_rx)
}
/// Change one option advertised by the ACP agent.
pub fn set_config(
&self,
config_id: String,
value: SessionConfigOptionValue,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::SetConfig {
config_id,
value,
done: done_tx,
})
.map_err(|_| "ACP connection is closed".to_string())?;
Ok(done_rx)
}
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
@@ -341,7 +504,9 @@ impl Drop for AcpSession {
/// Fails without a running child: on any error the process is already
/// signalled, so a failed session never leaves a stray adapter behind.
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?;
let provider = config.adapter;
let adapter = resolve_adapter(provider, &config.env)
.ok_or(AcpError::AdapterMissing { adapter: provider })?;
let mut cmd = tokio::process::Command::new(&adapter);
cmd.current_dir(&config.cwd)
@@ -369,18 +534,27 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
});
}
}
let mut child = cmd.spawn().map_err(AcpError::Spawn)?;
let mut child = cmd.spawn().map_err(|source| AcpError::Spawn {
adapter: provider,
source,
})?;
let pid = child.id().expect("child pid is known before wait");
let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone(), provider));
let (exit_tx, exit_rx) = watch::channel(None);
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
tokio::spawn(supervise_child(
child,
pid,
child_commands_rx,
exit_tx,
provider,
));
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
@@ -389,8 +563,11 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
tokio::spawn(run_connection(
transport,
config.cwd.clone(),
config.load_session.clone(),
ConnectionConfig {
cwd: config.cwd.clone(),
load_session: config.load_session.clone(),
adapter: provider,
},
updates_tx,
permission_tx,
commands_rx,
@@ -423,6 +600,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
.to_string()
};
AcpError::ExitedEarly {
adapter: provider,
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
stderr: if stderr.is_empty() {
String::new()
@@ -457,12 +635,13 @@ async fn supervise_child(
pid: u32,
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
exit_tx: watch::Sender<Option<ExitStatus>>,
adapter: AcpAdapter,
) {
loop {
tokio::select! {
status = child.wait() => {
let status = status.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(status);
return;
}
@@ -476,7 +655,7 @@ async fn supervise_child(
Some(ChildCommand::Reap(done)) => match child.try_wait() {
Ok(Some(status)) => {
let _ = done.send(true);
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(Some(status));
return;
}
@@ -484,7 +663,7 @@ async fn supervise_child(
let _ = done.send(false);
}
Err(error) => {
tracing::warn!(pid, %error, "could not reap {ADAPTER_BIN}");
tracing::warn!(pid, %error, provider = %adapter, "could not reap ACP adapter");
let _ = done.send(false);
}
},
@@ -494,7 +673,7 @@ async fn supervise_child(
// a reused PID.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
let status = child.wait().await.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
let _ = exit_tx.send(status);
return;
}
@@ -513,11 +692,105 @@ fn client_capabilities() -> ClientCapabilities {
.terminal(false)
}
fn advertised_config_options(
mut options: Vec<SessionConfigOption>,
modes: Option<&SessionModeState>,
models: Option<&LegacyModelState>,
) -> Vec<SessionConfigOption> {
let has_model = options
.iter()
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model)));
let has_mode = options
.iter()
.any(|option| matches!(option.category, Some(SessionConfigOptionCategory::Mode)));
if let Some(models) = models.filter(|_| !has_model) {
let mut choices = models
.available_models
.iter()
.map(|model| {
let mut option =
SessionConfigSelectOption::new(model.model_id.clone(), &model.name);
if model.model_id == "default" {
option.name = "Opus (default)".into();
}
option.description.clone_from(&model.description);
option
})
.collect::<Vec<_>>();
if !models
.available_models
.iter()
.any(|model| model.model_id == "fable")
{
choices.push(SessionConfigSelectOption::new("fable", "Fable"));
}
options.push(
SessionConfigOption::select(
LEGACY_MODEL_CONFIG_ID,
"Model",
models.current_model_id.clone(),
choices,
)
.category(SessionConfigOptionCategory::Model),
);
}
if let Some(modes) = modes.filter(|_| !has_mode) {
let choices = modes
.available_modes
.iter()
.map(|mode| {
let mut option = SessionConfigSelectOption::new(mode.id.0.clone(), &mode.name);
option.description.clone_from(&mode.description);
option
})
.collect::<Vec<_>>();
options.push(
SessionConfigOption::select(
LEGACY_MODE_CONFIG_ID,
"Mode",
modes.current_mode_id.0.clone(),
choices,
)
.category(SessionConfigOptionCategory::Mode),
);
}
options
}
fn replace_config_value(
options: &Arc<Mutex<Vec<SessionConfigOption>>>,
config_id: &str,
value: &SessionConfigOptionValue,
) -> Vec<SessionConfigOption> {
let mut options = options.lock().expect("config options");
if let Some(option) = options
.iter_mut()
.find(|option| option.id.0.as_ref() == config_id)
{
match (&mut option.kind, value) {
(SessionConfigKind::Select(select), SessionConfigOptionValue::ValueId { value }) => {
select.current_value = value.clone();
}
(SessionConfigKind::Boolean(boolean), SessionConfigOptionValue::Boolean { value }) => {
boolean.current_value = *value;
}
_ => {}
}
}
options.clone()
}
fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> SessionNotification {
SessionNotification::new(
SessionId::new(session_id),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)),
)
}
async fn run_connection<T>(
transport: T,
cwd: PathBuf,
load_session: Option<String>,
updates_tx: mpsc::UnboundedSender<SessionNotification>,
config: ConnectionConfig,
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
@@ -534,6 +807,7 @@ async fn run_connection<T>(
// until a cancelled prompt settles, so this active turn is unambiguous.
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
let permission_turn_seen = permission_turn.clone();
let received_updates = updates_tx.clone();
let result = Client
.builder()
.name("maestro")
@@ -544,7 +818,7 @@ async fn run_connection<T>(
return Ok(());
}
// A dropped receiver only means nobody wants updates any more.
let _ = updates_tx.send(notification);
let _ = received_updates.send(AcpUpdate::Notification(Box::new(notification)));
Ok(())
},
agent_client_protocol::on_receive_notification!(),
@@ -589,11 +863,13 @@ async fn run_connection<T>(
return Ok(());
}
};
let session_id = match load_session {
let (session_id, modes, initial_options, models) = match config.load_session {
Some(existing) => {
if !initialized.agent_capabilities.load_session {
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}"
"{} ACP adapter ({}) does not advertise loadSession; cannot resume session {existing}",
config.adapter,
config.adapter.binary()
))));
return Ok(());
}
@@ -601,7 +877,11 @@ async fn run_connection<T>(
let (loaded_tx, loaded_rx) = oneshot::channel();
let replaying_done = replaying.clone();
if let Err(e) = cx
.send_request(LoadSessionRequest::new(existing.clone(), cwd))
.send_request(ExtendedLoadSessionRequest {
session_id: SessionId::new(existing.as_str()),
cwd: config.cwd,
mcp_servers: vec![],
})
.on_receiving_result(move |result| {
replaying_done.store(false, Ordering::SeqCst);
async move {
@@ -613,8 +893,8 @@ async fn run_connection<T>(
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
match loaded_rx.await {
Ok(Ok(_)) => {}
let loaded = match loaded_rx.await {
Ok(Ok(loaded)) => loaded,
Ok(Err(e)) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
@@ -625,21 +905,48 @@ async fn run_connection<T>(
)));
return Ok(());
}
}
existing
};
(existing, loaded.modes, loaded.config_options.unwrap_or_default(), loaded.models)
}
None => match cx
.send_request(NewSessionRequest::new(cwd))
.send_request(ExtendedNewSessionRequest {
cwd: config.cwd,
mcp_servers: vec![],
})
.block_task()
.await
{
Ok(created) => created.session_id.0.to_string(),
Ok(created) => (
created.session_id.0.to_string(),
created.modes,
created.config_options.unwrap_or_default(),
created.models,
),
Err(e) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
},
};
let config_options = Arc::new(Mutex::new(advertised_config_options(
initial_options,
modes.as_ref(),
models.as_ref(),
)));
if let Some(modes) = &modes {
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(AcpUpdate::Notification(Box::new(config_notification(
&session_id,
config_options.lock().expect("config options").clone(),
))));
}
let _ = ready_tx.send(Ok(session_id.clone()));
loop {
@@ -666,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(())
}
@@ -689,6 +1003,83 @@ async fn run_connection<T>(
}
}
}
Some(Command::SetConfig { config_id, value, done }) => {
let done = Arc::new(Mutex::new(Some(done)));
let callback_done = done.clone();
let callback_options = config_options.clone();
let callback_updates = updates_tx.clone();
let callback_session = session_id.clone();
let callback_config_id = config_id.clone();
let callback_value = value.clone();
let sent = if config_id == LEGACY_MODEL_CONFIG_ID {
let Some(model_id) = value.as_value_id() else {
let _ = done.lock().expect("config sender").take()
.map(|done| done.send(Err("model must be a string value".into())));
continue;
};
cx.send_request(LegacySetModelRequest {
session_id: SessionId::new(session_id.as_str()),
model_id: model_id.0.to_string(),
}).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(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()));
}
Ok(())
})
} else if config_id == LEGACY_MODE_CONFIG_ID {
let Some(mode_id) = value.as_value_id() else {
let _ = done.lock().expect("config sender").take()
.map(|done| done.send(Err("mode must be a string value".into())));
continue;
};
cx.send_request(SetSessionModeRequest::new(
SessionId::new(session_id.as_str()),
mode_id.0.to_string(),
)).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(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()));
}
Ok(())
})
} else {
cx.send_request(SetSessionConfigOptionRequest::new(
SessionId::new(session_id.as_str()),
config_id,
value,
))
.on_receiving_result(move |result| async move {
if let Ok(response) = &result {
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(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()));
}
Ok(())
})
};
if let Err(error) = sent {
if let Some(done) = done.lock().expect("config sender").take() {
let _ = done.send(Err(error.to_string()));
}
}
}
Some(Command::Close) | None => break,
},
_ = cx.incoming_closed() => break,
@@ -706,10 +1097,11 @@ async fn drain_stderr(
stderr: tokio::process::ChildStderr,
pid: u32,
tail: Arc<Mutex<VecDeque<u8>>>,
adapter: AcpAdapter,
) {
let mut lines = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
tracing::debug!(pid, provider = %adapter, "ACP adapter: {line}");
let mut tail = tail.lock().expect("stderr tail");
tail.extend(line.as_bytes());
tail.push_back(b'\n');
+53 -2
View File
@@ -19,6 +19,8 @@ Environment knobs, all optional:
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
`session/new`
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
`session/new`
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
so the SIGTERM path is exercised)
@@ -34,6 +36,7 @@ Environment knobs, all optional:
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
JSON-RPC error
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
callback until a later prompt
@@ -76,6 +79,41 @@ prompt_failures = 0
pending_prompt_id = None
pending_permission_id = None
delayed_permission = False
current_model = "opus"
current_mode = "default"
current_effort = "high"
def settings_response(include_session=False):
result = {}
if include_session:
result["sessionId"] = session_id
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
result.update({
"models": {
"currentModelId": current_model,
"availableModels": [
{"modelId": "opus", "name": "Opus"},
{"modelId": "sonnet", "name": "Sonnet"},
],
},
"modes": {
"currentModeId": current_mode,
"availableModes": [
{"id": "default", "name": "Default"},
{"id": "plan", "name": "Plan"},
],
},
"configOptions": [{
"id": "effort", "name": "Effort", "category": "thought_level",
"type": "select", "currentValue": current_effort,
"options": [
{"value": "low", "name": "Low"},
{"value": "high", "name": "High"},
],
}],
})
return result
def chunk(text):
@@ -133,8 +171,11 @@ for raw in sys.stdin:
"authMethods": [],
}})
elif method == "session/new":
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
while gate and not os.path.exists(gate):
time.sleep(0.01)
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
send(chunk("hello from fake"))
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
sys.exit(0)
@@ -147,8 +188,18 @@ for raw in sys.stdin:
continue
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
send(chunk(f"replayed {i + 1}"))
send({"jsonrpc": "2.0", "id": rid, "result": {}})
send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
send(chunk("hello after load"))
elif method == "session/set_model":
current_model = params.get("modelId")
send({"jsonrpc": "2.0", "id": rid, "result": {}})
elif method == "session/set_mode":
current_mode = params.get("modeId")
send({"jsonrpc": "2.0", "id": rid, "result": {}})
elif method == "session/set_config_option":
current_effort = params.get("value")
send({"jsonrpc": "2.0", "id": rid, "result": {
"configOptions": settings_response()["configOptions"]}})
elif method == "session/cancel":
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
send(chunk("final on cancel"))
+130 -10
View File
@@ -1,12 +1,12 @@
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
//! one ignored test against the real `claude-code-acp`.
//! ignored smoke tests against each maintained adapter.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use tempfile::TempDir;
fn fake_agent() -> PathBuf {
@@ -25,6 +25,7 @@ impl Fake {
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir }
}
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await
.expect_err("no adapter on PATH");
assert!(matches!(err, AcpError::AdapterMissing));
assert!(matches!(
err,
AcpError::AdapterMissing {
adapter: AcpAdapter::ClaudeCode
}
));
let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{msg}");
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
}
#[test]
fn codex_adapter_has_its_own_binary_and_install_guidance() {
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
assert!(AcpAdapter::Codex
.install_command()
.contains("@agentclientprotocol/codex-acp"));
}
#[tokio::test]
async fn spawn_selects_the_codex_adapter() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await
.expect("spawn Codex adapter");
assert!(fake.log().contains("recv session/new"));
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new();
@@ -166,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 {
@@ -180,11 +210,71 @@ async fn session_updates_reach_the_taken_receiver() {
.await;
}
#[tokio::test]
async fn advertised_settings_are_normalized_and_mutable() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
let mut updates = session.take_updates().expect("updates");
let initial = loop {
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("settings update")
.expect("channel open");
let maestro_acp::AcpUpdate::Notification(n) = update else {
continue;
};
if let SessionUpdate::ConfigOptionUpdate(options) = n.update {
break options.config_options;
}
};
assert_eq!(initial.len(), 3);
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "effort"));
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
let model = initial
.iter()
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
.expect("legacy model option");
let model = serde_json::to_value(model).unwrap();
assert_eq!(model["options"][0]["name"], "Opus");
assert!(model["options"]
.as_array()
.unwrap()
.iter()
.any(|option| option["value"] == "fable"));
let changed = session
.set_config(
"__maestro_legacy_model".into(),
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
)
.expect("queue model change");
changed
.await
.expect("response delivered")
.expect("model changed");
fake.wait_for_log("recv session/set_model").await;
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()))
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await
.expect("spawn");
let pid = session.pid();
@@ -225,7 +315,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails");
@@ -284,7 +374,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err {
AcpError::ExitedEarly { status, stderr } => {
AcpError::ExitedEarly { status, stderr, .. } => {
assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}");
}
@@ -306,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(),
@@ -320,7 +413,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
@@ -353,7 +449,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
async fn load_session_requires_the_load_capability() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config)
@@ -368,7 +467,10 @@ async fn load_session_requires_the_load_capability() {
async fn load_session_error_fails_the_spawn() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails");
@@ -392,3 +494,21 @@ async fn real_adapter_handshake() {
.await;
assert!(session.has_exited());
}
/// End to end against the maintained Codex adapter. It needs a locally
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
#[tokio::test]
#[ignore]
async fn real_codex_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session =
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
.await
.expect("real codex-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
+45 -1
View File
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
pub enum AttachError {
#[error("ws: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
#[error("daemon returned {status} ({code}): {message}")]
Rejected {
status: String,
code: String,
message: String,
},
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("termios: {0}")]
@@ -32,6 +38,21 @@ pub enum AttachError {
}
impl AttachError {
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
if let Some(body) = response.body().as_deref() {
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
return Self::Rejected {
status: response.status().to_string(),
code: error_body.error.code,
message: error_body.error.message,
};
}
}
}
Self::WebSocket(error)
}
/// True when the failure was an inability to reach the WS endpoint at the
/// transport level (connection refused), as opposed to an HTTP-level
/// rejection from a daemon that did answer.
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
/// byte that exits cleanly without killing the session.
#[allow(clippy::result_large_err)]
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
let (ws, _resp) = tokio_tungstenite::connect_async(url)
.await
.map_err(AttachError::from_websocket)?;
let (mut sink, mut stream) = ws.split();
let raw = RawTty::enable()?;
@@ -233,4 +256,25 @@ mod tests {
Err(AttachError::InvalidDetachKey(_))
));
}
#[test]
fn attach_surfaces_structured_handshake_errors() {
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
"not_terminal",
"session is not a terminal session",
))
.unwrap();
let response = tokio_tungstenite::tungstenite::http::Response::builder()
.status(409)
.body(Some(body))
.unwrap();
let error =
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
assert_eq!(
error.to_string(),
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
);
}
}
+11 -3
View File
@@ -41,8 +41,8 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
/// Send text to a running session. Terminal sessions receive raw
/// keystrokes; structured sessions receive an ACP prompt.
Send(SendArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
/// there.
#[arg(long)]
pub existing_branch: bool,
/// Run a Claude Code or Codex session in a plain terminal instead of the
/// structured (ACP) view it otherwise gets by default.
#[arg(long, conflicts_with = "structured")]
pub terminal: bool,
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
/// Codex commands have an adapter; the daemon refuses anything else.
#[arg(long)]
pub structured: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
pub attach: bool,
@@ -125,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,
}
+131 -23
View File
@@ -3,10 +3,10 @@
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, WorktreeSpec,
Session, SessionStatus, SessionView, WorktreeSpec,
};
use serde_json::Value;
@@ -51,16 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
};
let body = CreateSessionRequest {
view: Default::default(),
view: spawn_view(&args),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
metadata,
worktree,
auto_approve: false,
};
client.create_session(&body).await
}
/// The view a `maestro spawn` asks for: structured whenever the command
/// resolves to an agent with an ACP adapter, since that is the richer session
/// and the one both other clients now default to. `--terminal` opts out;
/// `--structured` forces the request through even for a command this side
/// does not recognise, leaving the refusal to the daemon.
fn spawn_view(args: &SpawnArgs) -> SessionView {
if args.terminal {
return SessionView::Terminal;
}
if args.structured || supports_structured_view(&args.command) {
SessionView::Structured
} else {
SessionView::Terminal
}
}
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where
/// the work went.
@@ -113,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
/// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text.
///
/// A bare ID skips the catalog fetch; anything else needs the list to match
/// against. Text comes from the arguments, or from stdin when there is none —
/// the shape a heredoc or a pipe wants.
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
/// there — ACP prompts are always complete messages).
///
/// Text comes from the arguments, or from stdin when there is none — the
/// shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)]
pub async fn run_send(
client: &DaemonClient,
@@ -138,19 +158,28 @@ pub async fn run_send(
return Err(CliError::NothingToSend);
}
let id = match uuid::Uuid::parse_str(args.session.trim()) {
Ok(id) => id,
Err(_) => {
let sessions = client.list_sessions().await?;
resolve_session(&sessions, &args.session)?
}
};
// We always need the session list: even a bare UUID needs the view field
// to decide which endpoint to hit.
let sessions = client.list_sessions().await?;
let id = resolve_session(&sessions, &args.session)?;
let view = sessions
.iter()
.find(|s| s.id == id)
.map(|s| s.view)
.unwrap_or(SessionView::Terminal);
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
match view {
SessionView::Structured => {
client.send_structured_prompt(id, &text).await?;
}
SessionView::Terminal => {
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
}
}
Ok(id)
}
@@ -179,8 +208,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
}
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
)
.ok();
for s in sorted {
@@ -190,11 +219,16 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let view = match s.view {
SessionView::Terminal => "terminal",
SessionView::Structured => "structured",
};
let activity = match s.activity {
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error",
ActivityState::Starting => "starting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
@@ -207,8 +241,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let blocked = blocked_label(s);
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
s.id, status, activity, dir, command
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
s.id, status, view, activity, dir, command
)
.ok();
}
@@ -230,11 +264,67 @@ mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{ActivityState, Session, SessionStatus};
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
use uuid::Uuid;
use super::DaemonInfo;
use clap::Parser;
use crate::cli::{Cli, Command};
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
argv.extend_from_slice(extra);
let cli = Cli::try_parse_from(argv).expect("parses");
let Command::Spawn(args) = cli.command else {
panic!("expected spawn")
};
args
}
/// The CLI follows the two interactive clients: a command with an ACP
/// adapter spawns structured unless `--terminal` says otherwise, and
/// `--structured` still forces the request for a command this side does
/// not recognise.
#[test]
fn spawn_defaults_to_structured_for_supported_agents() {
use super::spawn_view;
assert_eq!(
spawn_view(&spawn_args(&["claude"])),
SessionView::Structured
);
assert_eq!(
spawn_view(&spawn_args(&["npx codex exec"])),
SessionView::Structured
);
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
assert_eq!(
spawn_view(&spawn_args(&["claude", "--terminal"])),
SessionView::Terminal
);
assert_eq!(
spawn_view(&spawn_args(&["opencode", "--structured"])),
SessionView::Structured
);
}
#[test]
fn spawn_refuses_both_view_flags_at_once() {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"claude",
"--structured",
"--terminal",
])
.unwrap_err();
assert!(err.to_string().contains("--terminal"), "{err}");
}
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
view: Default::default(),
@@ -255,6 +345,8 @@ mod tests {
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
auto_approve: false,
queued_prompts: Vec::new(),
}
}
@@ -286,6 +378,22 @@ mod tests {
assert!(s.contains("idle"), "{s}");
}
#[test]
fn table_exposes_terminal_and_structured_views() {
let terminal = session("/tmp", "bash", 1);
let mut structured = session("/tmp", "claude", 2);
structured.view = SessionView::Structured;
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
let out = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = out.lines().collect();
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
assert!(lines[header + 1].contains("terminal"), "{out}");
assert!(lines[header + 2].contains("structured"), "{out}");
}
#[test]
fn live_cwd_overrides_spawn_dir_in_table() {
let mut s = session("/home/naps/maestro", "bash", 1);
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo};
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
use maestro_testing::{session, MockDaemon};
use uuid::Uuid;
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
}],
..Default::default()
});
@@ -97,6 +98,7 @@ async fn spawn_round_trips_through_the_daemon() {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -200,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
assert_eq!(sent, id);
}
/// A structured session routes through the ACP prompt endpoint, not the
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
/// complete messages).
#[tokio::test]
async fn send_to_structured_session_uses_acp_prompt() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(13);
let mut s = session(id, "/tmp", "claude");
s.view = SessionView::Structured;
mock.add_session(s);
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["fix".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
// Terminal input must stay empty — the message went through ACP.
assert!(mock.inputs().is_empty());
let prompts = mock.structured_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].0, id);
assert_eq!(prompts[0].1.prompt, "fix the tests");
}
#[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
+10
View File
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "opencode".into(),
label: "opencode".into(),
command: "opencode".into(),
auto_approve: false,
},
SpawnPreset {
id: "zsh".into(),
label: "Shell".into(),
command: "zsh".into(),
auto_approve: false,
},
]
}
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(),
command: String::new(),
auto_approve: false,
}
}
@@ -515,6 +519,7 @@ auto_install = false
id: "fish".into(),
label: "fish".into(),
command: "fish".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -551,6 +556,7 @@ auto_install = false
id: " ".into(),
label: "x".into(),
command: "x".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -561,6 +567,7 @@ auto_install = false
id: "x".into(),
label: "x".into(),
command: "".into(),
auto_approve: false,
}]),
..Default::default()
};
@@ -578,11 +585,13 @@ auto_install = false
id: "a".into(),
label: "a".into(),
command: "a".into(),
auto_approve: false,
},
SpawnPreset {
id: "a".into(),
label: "a2".into(),
command: "a2".into(),
auto_approve: false,
},
]),
..Default::default()
@@ -597,6 +606,7 @@ auto_install = false
id: "custom".into(),
label: "Custom".into(),
command: "whatever".into(),
auto_approve: false,
}]),
..Default::default()
};
+28
View File
@@ -66,6 +66,23 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
}
}
/// Derive a structured session's sidebar state from its ACP projection.
pub fn decide_structured(
turn_active: bool,
pending_approval: bool,
turn_failed: bool,
) -> ActivityState {
if turn_failed {
ActivityState::Error
} else if pending_approval {
ActivityState::AwaitingInput
} else if turn_active {
ActivityState::Active
} else {
ActivityState::Idle
}
}
/// How long a session's pane must be continuously focused (a client's pane,
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
/// operator has looked, so the rail stops asking for attention.
@@ -136,6 +153,17 @@ mod tests {
);
}
#[test]
fn structured_activity_follows_lifecycle_with_approval_precedence() {
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
assert_eq!(
decide_structured(true, true, false),
ActivityState::AwaitingInput
);
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
}
#[test]
fn report_is_in_force_until_exactly_its_expiry() {
let ttl = Duration::from_secs(300);
+13 -4
View File
@@ -80,13 +80,22 @@ mod tests {
}
#[test]
fn non_repo_dir_is_none() {
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
// the walk can't accidentally hit a real repo above the test's cwd.
fn non_repo_dir_does_not_find_root_inside_fixture() {
// The fixture has no `.git`, so `find_root` must not return a path
// inside it. On hosts where an ancestor of the temp dir *is* a repo
// (e.g. `/tmp` lives inside a checkout), the function legitimately
// returns that ancestor — the test only asserts the fixture itself
// was not mistaken for a repo.
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("plain/dir");
fs::create_dir_all(&sub).unwrap();
assert_eq!(find_root(&sub), None);
match find_root(&sub) {
None => {} // expected on most hosts
Some(root) => assert!(
!root.starts_with(tmp.path()),
"found .git inside the fixture at {root:?}, but none was created"
),
}
}
#[test]
+111 -21
View File
@@ -12,7 +12,8 @@
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post};
@@ -21,12 +22,13 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
StructuredApprovalDecision, StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::session::{QueueAcpPrompt, SessionManager};
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
use crate::sidecar::EventCursor;
#[derive(Debug, Clone)]
@@ -69,7 +71,12 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(
routes::patterns::STRUCTURED_ATTACHMENT,
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
)
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route(
routes::patterns::STRUCTURED_APPROVAL,
post(structured_approval),
@@ -173,12 +180,13 @@ fn dir_error_status(code: &str) -> StatusCode {
fn session_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"invalid_env" => StatusCode::BAD_REQUEST,
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
"not_terminal" => StatusCode::CONFLICT,
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
}
@@ -307,11 +315,10 @@ async fn rename_session(
}
}
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
/// The report holds above the daemon's inference for a TTL, then inference
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
/// forever. Unknown session is 404; an unknown state value is rejected by the
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal
/// session's state. Structured sessions acknowledge and ignore these reports
/// because their activity comes from ACP. Unknown session is 404; an unknown
/// state value is rejected by the JSON decoder (422). Answers `204 No Content`.
async fn report_activity(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
@@ -430,17 +437,58 @@ async fn structured_replay(
}
}
#[derive(Deserialize)]
struct AttachmentQuery {
name: String,
}
async fn structured_attachment(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<AttachmentQuery>,
bytes: Bytes,
) -> Response {
let name = query.name;
if name.is_empty()
|| name.len() > 180
|| name == "."
|| name == ".."
|| name
.chars()
.any(|c| c.is_control() || c == '/' || c == '\\')
{
return error_response(
StatusCode::BAD_REQUEST,
"invalid_filename",
"use a filename without directory separators, up to 180 bytes",
);
}
match s.manager.store_acp_attachment(id, &name, &bytes).await {
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_prompt(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) => 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,
@@ -472,6 +520,37 @@ async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
}
}
async fn structured_config(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<StructuredConfigRequest>,
) -> Response {
match s
.manager
.set_acp_config(id, body.config_id, body.value)
.await
{
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
Ok(SetAcpConfig::InvalidValue) => error_response(
StatusCode::BAD_REQUEST,
"invalid_config_value",
"configuration values must be strings or booleans",
),
Ok(SetAcpConfig::Unavailable) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Ok(SetAcpConfig::Failed(message)) => {
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
}
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_approval(
State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>,
@@ -535,12 +614,23 @@ async fn attach_ws(
State(s): State<LocalApiState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
));
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
Ok(subscription) => subscription,
Err(SessionError::NotFound(_)) => {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
));
}
Err(error) => {
let info = error.to_error_info();
return Err(error_response(
session_error_status(&info.code),
&info.code,
&info.message,
));
}
};
let manager = s.manager.clone();
Ok(ws.on_upgrade(move |socket| async move {
+368 -16
View File
@@ -2,9 +2,11 @@
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
use maestro_protocol::{
ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, TranscriptSnapshot,
ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind,
TranscriptSnapshot,
};
use serde_json::Value;
@@ -36,8 +38,12 @@ pub struct Projection {
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
state: ReducedState,
active_turn: Option<u64>,
active_turn_started_at: Option<DateTime<Utc>>,
active_turn_context_tokens: Option<u64>,
turn_failed: bool,
next_turn: u64,
turn_tool_calls: HashSet<String>,
anonymous_content_row: Option<(String, String)>,
}
impl Projection {
@@ -58,6 +64,7 @@ impl Projection {
SessionUpdate::ToolCall(tool) => self.tool_call(tool),
SessionUpdate::ToolCallUpdate(update) => self.tool_update(update),
SessionUpdate::Plan(plan) => {
self.anonymous_content_row = None;
let data = to_value(plan);
self.state.plan = Some(data.clone());
Some(self.replace("plan".into(), TranscriptRowKind::Plan, data))
@@ -75,16 +82,20 @@ impl Projection {
SessionUpdate::ConfigOptionUpdate(options) => {
let value = to_value(options);
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
self.state.config_options = Some(value);
None
}
// ACP deliberately adds notification variants over time. Known but
// non-transcript updates become a generic notice instead of being
// dropped or forcing clients to understand ACP's enum.
_ => Some(self.append(
format!("notice:{seq}"),
TranscriptRowKind::Notice,
to_value(&notification.update),
)),
_ => {
self.anonymous_content_row = None;
Some(self.append(
format!("notice:{seq}"),
TranscriptRowKind::Notice,
to_value(&notification.update),
))
}
};
ProjectionUpdate {
transcript,
@@ -109,13 +120,30 @@ impl Projection {
self.active_turn.is_some()
}
/// Sidebar activity derived from the live turn projection.
pub fn activity(&self) -> ActivityState {
crate::activity::decide_structured(
self.active_turn.is_some(),
self.state.pending_approval.is_some(),
self.turn_failed,
)
}
/// Start a prompt turn and return its monotonically increasing token.
pub fn begin_turn(&mut self) -> (u64, ProjectionUpdate) {
self.anonymous_content_row = None;
self.next_turn += 1;
let turn = self.next_turn;
self.active_turn = Some(turn);
let started_at = Utc::now();
let started_context_tokens = usage_tokens(self.state.usage.as_ref());
self.active_turn_started_at = Some(started_at);
self.active_turn_context_tokens = started_context_tokens;
self.turn_failed = false;
self.state.turn.responding = true;
self.state.turn.thinking = true;
self.state.turn.started_at = Some(started_at);
self.state.turn.started_context_tokens = started_context_tokens;
self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear();
(
@@ -141,12 +169,47 @@ impl Projection {
return None;
}
self.active_turn = None;
self.turn_failed = lifecycle == TurnLifecycle::Failure;
self.state.turn.responding = false;
self.state.turn.thinking = false;
let finished_at = Utc::now();
let started_at = self.active_turn_started_at.take();
let started_context_tokens = self.active_turn_context_tokens.take();
self.state.turn.started_at = None;
self.state.turn.started_context_tokens = None;
self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear();
let context_tokens = usage_tokens(self.state.usage.as_ref());
let context_delta = context_tokens
.zip(started_context_tokens)
.map(|(end, start)| i128::from(end) - i128::from(start));
let elapsed_ms = started_at.map_or(0, |started| {
finished_at
.signed_duration_since(started)
.num_milliseconds()
.max(0)
});
let outcome = match lifecycle {
TurnLifecycle::Finish => "completed",
TurnLifecycle::Cancel => "cancelled",
TurnLifecycle::Failure => "failed",
TurnLifecycle::Begin => unreachable!(),
};
let row = self.append(
format!("turn_meta:{}", uuid::Uuid::new_v4()),
TranscriptRowKind::Notice,
serde_json::json!({
"maestroTurnMeta": {
"elapsedMs": elapsed_ms,
"contextTokens": context_tokens,
"contextDelta": context_delta,
"outcome": outcome,
"model": self.state.model,
}
}),
);
Some(ProjectionUpdate {
transcript: None,
transcript: Some(row),
state: self.state(),
})
}
@@ -173,7 +236,8 @@ impl Projection {
}
}
pub fn restore_approval(&mut self, row: TranscriptRow) -> ProjectionUpdate {
pub fn restore_row(&mut self, row: TranscriptRow) -> ProjectionUpdate {
self.anonymous_content_row = None;
let transcript = if !self.row_index.contains_key(&row.id) {
self.row_index.insert(row.id.clone(), self.rows.len());
self.rows.push(row.clone());
@@ -210,6 +274,21 @@ impl Projection {
})
}
/// Undo an `append`ed row that a caller decides never happened — used
/// when persisting the row's own event fails (#184), so a snapshot taken
/// after the failure cannot show a row durable replay would never
/// reproduce. Only ever called for a row that was just appended and
/// never patched, so removing it by id fully reverses `append`.
pub fn rollback_row(&mut self, id: &str) {
let Some(index) = self.row_index.remove(id) else {
return;
};
self.rows.remove(index);
for (shifted_index, row) in self.rows.iter().enumerate().skip(index) {
self.row_index.insert(row.id.clone(), shifted_index);
}
}
fn content(
&mut self,
seq: u64,
@@ -217,12 +296,32 @@ impl Projection {
kind: TranscriptRowKind,
prefix: &str,
) -> Option<TranscriptDelta> {
let id = chunk
.message_id
.as_ref()
.map(|id| format!("{prefix}:{id}"))
.unwrap_or_else(|| format!("{prefix}:{seq}"));
let data = to_value(chunk);
let id = if let Some(message_id) = chunk.message_id.as_ref() {
self.anonymous_content_row = None;
format!("{prefix}:{message_id}")
} else {
let text = data.pointer("/content/text").and_then(Value::as_str);
let previous = self
.anonymous_content_row
.as_ref()
.filter(|(previous_prefix, _)| previous_prefix == prefix)
.map(|(_, id)| id.clone());
if text.is_some_and(|text| !text.is_empty()) {
if let Some(id) = previous {
let index = *self
.row_index
.get(&id)
.expect("anonymous content row remains addressable");
let row = &mut self.rows[index];
append_text(&mut row.data, &data);
return Some(TranscriptDelta::Patch { row: row.clone() });
}
}
let id = format!("{prefix}:{seq}");
self.anonymous_content_row = text.map(|_| (prefix.to_owned(), id.clone()));
id
};
if let Some(index) = self.row_index.get(&id).copied() {
let row = &mut self.rows[index];
append_text(&mut row.data, &data);
@@ -232,6 +331,7 @@ impl Projection {
}
fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> {
self.anonymous_content_row = None;
let id = format!("tool:{}", tool.tool_call_id);
let tool_id = tool.tool_call_id.to_string();
let mut canonical = tool.clone();
@@ -262,6 +362,7 @@ impl Projection {
&mut self,
update: &maestro_acp::schema::ToolCallUpdate,
) -> Option<TranscriptDelta> {
self.anonymous_content_row = None;
let tool_id = update.tool_call_id.to_string();
let Some(tool) = self.tools.get_mut(&tool_id) else {
self.pending_tool_updates
@@ -355,6 +456,10 @@ impl Projection {
}
}
fn usage_tokens(usage: Option<&Value>) -> Option<u64> {
usage?.get("used")?.as_u64()
}
fn to_value(value: &impl serde::Serialize) -> Value {
serde_json::to_value(value).expect("ACP schema values serialize")
}
@@ -398,7 +503,7 @@ fn model_from_options(value: &Value) -> Option<String> {
#[cfg(test)]
mod tests {
use maestro_acp::schema::SessionNotification;
use maestro_protocol::{TranscriptDelta, TranscriptRowKind};
use maestro_protocol::{TranscriptDelta, TranscriptRow, TranscriptRowKind};
use super::{Projection, TurnLifecycle};
@@ -410,6 +515,16 @@ mod tests {
.unwrap()
}
#[test]
fn a_session_update_tag_the_acp_dependency_does_not_know_fails_to_deserialize() {
// `SessionUpdate` has no unknown-variant fallback, so this never reaches `Projection::apply`.
let result: Result<SessionNotification, _> = serde_json::from_value(serde_json::json!({
"sessionId": "acp-1",
"update": {"sessionUpdate": "some_future_update_variant"},
}));
assert!(result.is_err());
}
#[test]
fn folds_content_tool_diff_plan_and_notice_rows() {
let mut projection = Projection::default();
@@ -458,6 +573,137 @@ mod tests {
);
}
#[test]
fn projects_the_codex_adapter_fixture_without_provider_specific_rows() {
let fixture = include_str!("../tests/fixtures/codex-acp-session.jsonl");
let events = fixture
.lines()
.map(serde_json::from_str::<SessionNotification>)
.collect::<Result<Vec<_>, _>>()
.expect("Codex ACP fixture follows the supported schema");
let mut projection = Projection::default();
for (seq, event) in events.iter().enumerate() {
projection.apply((seq + 1) as u64, event);
}
let rows = projection.snapshot(events.len() as u64).rows;
assert_eq!(
rows.iter().map(|row| row.kind).collect::<Vec<_>>(),
[
TranscriptRowKind::User,
TranscriptRowKind::AgentThought,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolDiff,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::Plan,
TranscriptRowKind::Notice,
TranscriptRowKind::Notice,
TranscriptRowKind::AgentText,
]
);
assert_eq!(rows[2].data["rawInput"]["command"], "rg TODO");
assert_eq!(rows[2].data["rawOutput"]["exit_code"], 0);
assert_eq!(rows[4].data["_meta"]["is_mcp_tool_call"], true);
assert_eq!(rows[5].data["rawInput"]["query"], "Agent Client Protocol");
assert_eq!(
rows[7].data["content"][0]["content"]["mimeType"],
"image/png"
);
let state = projection.state();
assert_eq!(state.model.as_deref(), Some("gpt-5.3-codex"));
assert_eq!(state.mode.as_deref(), Some("agent"));
assert_eq!(state.usage.as_ref().unwrap()["used"], 1200);
assert_eq!(state.context.as_ref().unwrap()["size"], 258400);
assert_eq!(
state.config_options.as_ref().unwrap()["configOptions"][2]["currentValue"],
"high"
);
}
#[test]
fn coalesces_anonymous_stream_chunks_without_joining_turns() {
let mut projection = Projection::default();
let (first_turn, _) = projection.begin_turn();
for (seq, text) in [(1, ""), (2, "p"), (3, "ong")] {
projection.apply(
seq,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text},
})),
);
}
projection.turn_lifecycle(TurnLifecycle::Finish, first_turn);
projection.begin_turn();
projection.apply(
4,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "next"},
})),
);
let rows = projection.snapshot(4).rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "pong");
assert!(rows[1].data.get("maestroTurnMeta").is_some());
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "next");
}
#[test]
fn anonymous_empty_chunk_delimits_turns_during_replay() {
let mut projection = Projection::default();
for (seq, text) in [(1, ""), (2, "visible"), (3, ""), (4, "p"), (5, "ong")] {
projection.apply(
seq,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text},
})),
);
}
let rows = projection.snapshot(5).rows;
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "visible");
assert_eq!(rows[1].data.pointer("/content/text").unwrap(), "pong");
}
#[test]
fn restored_user_prompt_delimits_anonymous_agent_rows() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "before"},
})),
);
projection.restore_row(TranscriptRow {
id: "user:prompt".into(),
kind: TranscriptRowKind::User,
data: serde_json::json!({"content": {"type": "text", "text": "next"}}),
});
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "after"},
})),
);
let rows = projection.snapshot(2).rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "before");
assert_eq!(rows[1].kind, TranscriptRowKind::User);
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "after");
}
#[test]
fn tool_update_patches_the_original_stable_row() {
let mut projection = Projection::default();
@@ -576,6 +822,10 @@ mod tests {
assert_eq!(state.context.as_ref().unwrap()["size"], 200);
assert_eq!(state.mode.as_deref(), Some("plan"));
assert_eq!(state.model.as_deref(), Some("opus"));
assert_eq!(
state.config_options.as_ref().unwrap()["configOptions"][0]["currentValue"],
"opus"
);
assert_eq!(
state.plan.as_ref().unwrap()["entries"],
serde_json::json!([])
@@ -647,6 +897,38 @@ mod tests {
}
}
#[test]
fn completed_turn_keeps_duration_and_context_delta_in_transcript() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"usage_update", "used":10_000, "size":200_000
})),
);
let (turn, begun) = projection.begin_turn();
assert!(begun.state.turn.started_at.is_some());
assert_eq!(begun.state.turn.started_context_tokens, Some(10_000));
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"usage_update", "used":12_500, "size":200_000
})),
);
let finished = projection
.turn_lifecycle(TurnLifecycle::Finish, turn)
.expect("active turn finishes");
let TranscriptDelta::Append { row } = finished.transcript.unwrap() else {
panic!("turn footer");
};
assert_eq!(row.kind, TranscriptRowKind::Notice);
assert_eq!(row.data["maestroTurnMeta"]["contextDelta"], 2_500);
assert_eq!(row.data["maestroTurnMeta"]["contextTokens"], 12_500);
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
assert!(finished.state.turn.started_at.is_none());
}
fn terminal_turn_drops_unfinished_tools(
terminal: TurnLifecycle,
unfinished_id: &str,
@@ -665,7 +947,9 @@ mod tests {
projection.turn_lifecycle(terminal, turn);
assert!(projection.state().turn.active_tool_calls.is_empty());
assert_eq!(projection.snapshot(1).rows.len(), 1);
let rows = projection.snapshot(1).rows;
assert_eq!(rows.len(), 2);
assert!(rows[1].data.get("maestroTurnMeta").is_some());
projection.begin_turn();
projection.apply(
@@ -712,6 +996,74 @@ mod tests {
assert_eq!(projection.snapshot(0).rows, vec![row]);
}
#[test]
fn rollback_row_removes_only_that_row_and_keeps_others_addressable() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate": "user_message_chunk",
"messageId": "m1",
"content": {"type": "text", "text": "hi"},
})),
);
let TranscriptDelta::Append { row } = projection
.request_approval("nonce".into(), serde_json::json!({"tool": "bash"}))
.transcript
.unwrap()
else {
panic!("approval row")
};
// A row after the one being rolled back, so removing the approval
// (index 1) shifts this one from index 2 down to index 1 — proving
// `row_index` gets reindexed, not just `rows`.
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate": "user_message_chunk",
"messageId": "m2",
"content": {"type": "text", "text": "hey"},
})),
);
assert_eq!(projection.snapshot(0).rows.len(), 3);
projection.rollback_row(&row.id);
assert_eq!(projection.snapshot(0).rows.len(), 2);
assert_eq!(projection.snapshot(0).rows[0].kind, TranscriptRowKind::User);
assert_eq!(projection.snapshot(0).rows[1].kind, TranscriptRowKind::User);
// The shifted row must still be independently patchable at its new
// index.
let update = projection.apply(
3,
&event(serde_json::json!({
"sessionUpdate": "user_message_chunk",
"messageId": "m2",
"content": {"type": "text", "text": " there"},
})),
);
let TranscriptDelta::Patch { row } = update.transcript.unwrap() else {
panic!("patched row")
};
assert_eq!(row.data.pointer("/content/text").unwrap(), "hey there");
assert_eq!(projection.snapshot(0).rows.len(), 2);
}
#[test]
fn rollback_row_is_a_no_op_for_an_unknown_id() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate": "user_message_chunk",
"content": {"type": "text", "text": "hi"},
})),
);
projection.rollback_row("approval:does-not-exist");
assert_eq!(projection.snapshot(0).rows.len(), 1);
}
#[test]
fn recovered_approval_is_historical_not_pending() {
let mut live = Projection::default();
@@ -723,7 +1075,7 @@ mod tests {
panic!("approval row")
};
let mut recovered = Projection::default();
let update = recovered.restore_approval(row.clone());
let update = recovered.restore_row(row.clone());
assert_eq!(
update.transcript,
Some(TranscriptDelta::Append { row: row.clone() })
File diff suppressed because it is too large Load Diff
+327 -8
View File
@@ -112,7 +112,7 @@ impl SidecarStore {
}
file.seek(SeekFrom::End(0))?;
Ok(EventLog {
file,
file: Box::new(file),
last_seq: lines,
})
}
@@ -263,9 +263,15 @@ pub struct LoggedEvent {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
pub enum PersistedEvent {
User {
row: TranscriptRow,
},
Approval {
row: TranscriptRow,
},
Turn {
row: TranscriptRow,
},
#[serde(skip)]
Notification(Box<SessionNotification>),
}
@@ -296,15 +302,82 @@ impl From<&PersistedEvent> for PersistedEvent {
}
}
/// What an [`EventLog`] appends onto: something seekable by length and
/// truncatable back to one, so a failed append can be rolled off the end
/// again. A real [`File`] in production; an in-memory fault double in tests
/// (#184) — the same [`EventLog::append`] rollback logic runs over both, so
/// the behavior under test is the production behavior, not a stand-in for
/// it.
pub(crate) trait LogSink: Write + Send {
fn len(&mut self) -> std::io::Result<u64>;
fn truncate(&mut self, len: u64) -> std::io::Result<()>;
}
impl LogSink for File {
fn len(&mut self) -> std::io::Result<u64> {
Ok(self.metadata()?.len())
}
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
self.set_len(len)
}
}
/// Outcome of a failed [`EventLog::append`].
#[derive(Debug, thiserror::Error)]
pub enum AppendError {
/// The write or flush failed, but the log was rolled back to exactly the
/// byte it was at before the call: `last_seq` and the file agree, and
/// this same [`EventLog`] is safe to keep appending to. The event itself
/// was never persisted or broadcast.
#[error("event append failed, rolled back cleanly: {0}")]
Failed(#[source] SidecarError),
/// The append failed *and* the rollback did not restore the log to its
/// prior length, so the file's line count and `last_seq` may now
/// disagree. The caller must not append to this [`EventLog`] again —
/// treat the session as dead rather than promise consistency it cannot
/// keep.
#[error("event append failed ({cause}) and rollback did not restore consistency ({rollback})")]
Fatal {
cause: SidecarError,
rollback: SidecarError,
},
}
impl From<SidecarError> for AppendError {
fn from(error: SidecarError) -> Self {
AppendError::Failed(error)
}
}
/// An open, append-only handle on one session's event log. Obtained from
/// [`SidecarStore::open_events`]; held for the session's lifetime.
#[derive(Debug)]
pub struct EventLog {
file: File,
file: Box<dyn LogSink>,
last_seq: u64,
}
impl std::fmt::Debug for EventLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EventLog")
.field("last_seq", &self.last_seq)
.finish_non_exhaustive()
}
}
impl EventLog {
/// Wrap an arbitrary sink as an [`EventLog`], for tests that need to
/// inject a deterministic append failure (#184) — a partial write, a
/// flush that fails after the bytes are already down, or a rollback that
/// cannot itself complete.
#[cfg(test)]
pub(crate) fn for_test(file: impl LogSink + 'static, last_seq: u64) -> Self {
Self {
file: Box::new(file),
last_seq,
}
}
/// The seq of the last line, `0` for an empty log.
pub fn last_seq(&self) -> u64 {
self.last_seq
@@ -314,16 +387,262 @@ impl EventLog {
/// handed to the kernel in a single write before this returns, so a
/// caller that broadcasts afterwards never announces an event the log
/// would not replay — short of the machine losing power.
pub fn append(&mut self, event: impl Into<PersistedEvent>) -> Result<u64, SidecarError> {
///
/// A `write_all` that errors partway can leave an unterminated prefix on
/// disk; a `flush` that errors after a *complete* `write_all` leaves
/// ambiguity about whether that full, well-formed line should count —
/// counting it would silently shift every later seq by one, since
/// `last_seq` was never bumped for it. Both cases are rolled back to the
/// exact pre-append length before returning, so `last_seq` and the file
/// never disagree and the same [`EventLog`] can be appended to again
/// immediately. If the rollback itself fails, the log's consistency can
/// no longer be promised and [`AppendError::Fatal`] is returned instead.
pub fn append(&mut self, event: impl Into<PersistedEvent>) -> Result<u64, AppendError> {
let event = event.into();
let mut line = match &event {
PersistedEvent::Notification(notification) => serde_json::to_vec(notification)?,
_ => serde_json::to_vec(&event)?,
PersistedEvent::Notification(notification) => serde_json::to_vec(notification)
.map_err(|e| AppendError::from(SidecarError::from(e)))?,
_ => {
serde_json::to_vec(&event).map_err(|e| AppendError::from(SidecarError::from(e)))?
}
};
line.push(b'\n');
self.file.write_all(&line)?;
self.file.flush()?;
let pre_len = self
.file
.len()
.map_err(|e| AppendError::from(SidecarError::from(e)))?;
if let Err(cause) = self.file.write_all(&line) {
return Err(self.rollback_after(pre_len, cause));
}
if let Err(cause) = self.file.flush() {
return Err(self.rollback_after(pre_len, cause));
}
self.last_seq += 1;
Ok(self.last_seq)
}
fn rollback_after(&mut self, pre_len: u64, cause: std::io::Error) -> AppendError {
match self.file.truncate(pre_len) {
Ok(()) => AppendError::Failed(SidecarError::Io(cause)),
Err(rollback) => AppendError::Fatal {
cause: SidecarError::Io(cause),
rollback: SidecarError::Io(rollback),
},
}
}
}
/// Bytes shared between a [`FaultyLogSink`] moved into an [`EventLog`] and
/// the test that planted it, so the test can inspect what actually ended up
/// on the "disk" after a fault without reaching into `EventLog`'s private
/// `file` field.
#[cfg(test)]
#[derive(Clone, Default)]
pub(crate) struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
#[cfg(test)]
impl SharedBuf {
pub(crate) fn snapshot(&self) -> Vec<u8> {
self.0.lock().unwrap().clone()
}
}
/// An in-memory [`LogSink`] double that injects one deterministic fault — a
/// short write, a flush that fails after a full write, or a rollback that
/// cannot itself complete — then behaves like a healthy sink from then on.
/// Lets a test prove the *same* [`EventLog`] recovers, rather than one with a
/// handle swapped in from a healthy disk, which production never does
/// (#184).
#[cfg(test)]
#[derive(Default)]
pub(crate) struct FaultyLogSink {
pub(crate) buf: SharedBuf,
/// Accept this many bytes on the next `write` call, then fail the
/// *following* call — a short write followed by an error, exactly what
/// `write_all` sees from a real fd that dies mid-line. `Write::write`
/// must not report an error on a call that made partial progress, so
/// the fault is delivered one call later, via `pending_write_error`.
pub(crate) fail_write_after: Option<usize>,
pub(crate) pending_write_error: bool,
pub(crate) fail_flush_once: bool,
pub(crate) fail_truncate: bool,
}
#[cfg(test)]
impl Write for FaultyLogSink {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
if self.pending_write_error {
self.pending_write_error = false;
return Err(std::io::Error::other("fault: write failed mid-line"));
}
if let Some(allowed) = self.fail_write_after.take() {
let allowed = allowed.min(data.len());
if allowed == 0 {
// No progress was made, so it's fine to report the error
// from this same call.
return Err(std::io::Error::other("fault: write failed mid-line"));
}
self.buf
.0
.lock()
.unwrap()
.extend_from_slice(&data[..allowed]);
self.pending_write_error = true;
return Ok(allowed);
}
self.buf.0.lock().unwrap().extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if self.fail_flush_once {
self.fail_flush_once = false;
return Err(std::io::Error::other(
"fault: flush failed after a full write",
));
}
Ok(())
}
}
#[cfg(test)]
impl LogSink for FaultyLogSink {
fn len(&mut self) -> std::io::Result<u64> {
Ok(self.buf.0.lock().unwrap().len() as u64)
}
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
if self.fail_truncate {
return Err(std::io::Error::other("fault: rollback truncate failed"));
}
self.buf.0.lock().unwrap().truncate(len as usize);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_event(id: &str) -> PersistedEvent {
PersistedEvent::Approval {
row: TranscriptRow {
id: id.to_owned(),
kind: maestro_protocol::TranscriptRowKind::Approval,
data: serde_json::json!({"tool": "bash"}),
},
}
}
/// Replays `bytes` as a real on-disk log via the production read path,
/// so "live and durable agree" is checked with the same code a
/// reconnecting client would hit, not a hand-rolled line splitter.
fn replay(bytes: &[u8]) -> Vec<LoggedEvent> {
let dir = tempfile::tempdir().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
std::fs::create_dir_all(store.dir()).unwrap();
std::fs::write(store.events_path_for(id), bytes).unwrap();
store.read_events(id, EventCursor::Since(0), 100).unwrap()
}
#[test]
fn append_rolls_back_a_partial_write_and_the_same_instance_recovers() {
let buf = SharedBuf::default();
let writer = FaultyLogSink {
buf: buf.clone(),
fail_write_after: Some(5),
..Default::default()
};
let mut log = EventLog::for_test(writer, 0);
let err = log.append(sample_event("r1")).unwrap_err();
assert!(
matches!(err, AppendError::Failed(_)),
"a repairable rollback must not be reported fatal"
);
assert_eq!(log.last_seq(), 0, "cursor must stay put on a failed append");
assert!(
buf.snapshot().is_empty(),
"the partial prefix a failed write_all leaves behind must be truncated away"
);
let seq = log
.append(sample_event("r1"))
.expect("the same instance must be able to append again after a clean rollback");
assert_eq!(seq, 1);
assert_eq!(log.last_seq(), 1);
let bytes = buf.snapshot();
assert_eq!(
bytes.iter().filter(|&&b| b == b'\n').count(),
1,
"exactly one well-formed line, not a partial prefix glued to the recovered append"
);
let replayed = replay(&bytes);
assert_eq!(replayed.len(), 1);
assert_eq!(replayed[0].seq, log.last_seq());
let PersistedEvent::Approval { row } = &replayed[0].event else {
panic!("approval event");
};
assert_eq!(row.id, "r1");
}
#[test]
fn append_rolls_back_when_flush_fails_after_a_full_write() {
let buf = SharedBuf::default();
let writer = FaultyLogSink {
buf: buf.clone(),
fail_flush_once: true,
..Default::default()
};
let mut log = EventLog::for_test(writer, 0);
let err = log.append(sample_event("r1")).unwrap_err();
assert!(matches!(err, AppendError::Failed(_)));
assert_eq!(log.last_seq(), 0);
assert!(
buf.snapshot().is_empty(),
"a full-but-unflushed line must not survive uncounted by last_seq — it would \
silently shift every later seq by one"
);
let seq = log
.append(sample_event("r1"))
.expect("the same instance must recover once flush stops failing");
assert_eq!(seq, 1);
let bytes = buf.snapshot();
let replayed = replay(&bytes);
assert_eq!(replayed.len(), 1);
assert_eq!(replayed[0].seq, log.last_seq());
}
#[test]
fn append_reports_fatal_when_rollback_cannot_restore_consistency() {
let buf = SharedBuf::default();
let writer = FaultyLogSink {
buf,
fail_write_after: Some(3),
fail_truncate: true,
..Default::default()
};
let mut log = EventLog::for_test(writer, 0);
let err = log.append(sample_event("r1")).unwrap_err();
assert!(
matches!(err, AppendError::Fatal { .. }),
"an unrepairable rollback must be surfaced as fatal, never silently treated as \
a clean recoverable failure"
);
assert_eq!(
log.last_seq(),
0,
"a fatal append must not advance the cursor either"
);
}
}
+495 -48
View File
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND};
use maestro_acp::{AcpAdapter, ADAPTER_BIN};
use maestro_daemon::projection::TurnLifecycle;
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
use maestro_daemon::worktree::WorktreeManager;
@@ -38,6 +38,7 @@ impl Fake {
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir }
}
@@ -83,26 +84,47 @@ impl Fake {
}
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
panic!("unexpected daemon event")
};
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
match &event.event {
maestro_daemon::sidecar::PersistedEvent::Notification(notification) => {
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
maestro_daemon::sidecar::PersistedEvent::User { row } => row
.data
.pointer("/content/text")
.and_then(serde_json::Value::as_str)
.expect("user row text"),
maestro_daemon::sidecar::PersistedEvent::Turn { row } => row
.data
.pointer("/maestroTurnMeta/outcome")
.and_then(serde_json::Value::as_str)
.unwrap_or("[turn]"),
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
}
}
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
structured_command(cwd, env, "claude")
}
fn structured_command(
cwd: &Path,
env: HashMap<String, String>,
command: &str,
) -> CreateSessionRequest {
CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.to_path_buf(),
command: "claude".into(),
command: command.into(),
env,
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
@@ -120,6 +142,25 @@ async fn wait_for_exit(pid: u32) {
assert!(!pid_alive(pid), "pid {pid} still alive");
}
/// Wait for a session's background ACP handshake to complete (activity
/// transitions out of `Starting`). Returns the refreshed session.
async fn wait_for_ready(manager: &SessionManager, id: uuid::Uuid) -> maestro_protocol::Session {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let session = manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == id)
.expect("session exists");
if session.activity != ActivityState::Starting {
return session;
}
assert!(Instant::now() < deadline, "handshake timed out");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new();
@@ -128,14 +169,23 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let session = fx
let created = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(
cwd.path(),
env,
"codex --dangerously-bypass-approvals-and-sandbox",
))
.await
.expect("create");
assert_eq!(session.view, SessionView::Structured);
assert_eq!(session.status, SessionStatus::Running);
assert_eq!(created.view, SessionView::Structured);
assert_eq!(created.status, SessionStatus::Running);
assert_eq!(created.activity, ActivityState::Starting);
assert!(created.acp_session_id.is_none());
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.activity, ActivityState::Idle);
let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}");
@@ -149,7 +199,7 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
"{log}"
);
assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
log.contains(&format!("env MAESTRO_SESSION_ID={}", created.id)),
"{log}"
);
@@ -204,16 +254,89 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
.exists());
}
#[tokio::test]
async fn structured_activity_tracks_turn_completion_and_failure() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "100".into());
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
let session = fx
.manager
.create(structured_command(cwd.path(), env, "codex"))
.await
.unwrap();
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "fail".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
assert_eq!(
fx.manager
.snapshot()
.await
.into_iter()
.find(|candidate| candidate.id == session.id)
.unwrap()
.activity,
ActivityState::Active
);
wait_for_async(Duration::from_secs(2), || async {
fx.manager
.snapshot()
.await
.into_iter()
.find(|candidate| candidate.id == session.id)
.is_some_and(|candidate| candidate.activity == ActivityState::Error)
})
.await;
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "succeed".into(), None)
.await
.unwrap(),
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
assert_eq!(
fx.manager
.snapshot()
.await
.into_iter()
.find(|candidate| candidate.id == session.id)
.unwrap()
.activity,
ActivityState::Active
);
wait_for_async(Duration::from_secs(2), || async {
fx.manager
.snapshot()
.await
.into_iter()
.find(|candidate| candidate.id == session.id)
.is_some_and(|candidate| candidate.activity == ActivityState::Idle)
})
.await;
fx.manager.kill(session.id, false).await.unwrap();
}
#[tokio::test]
async fn projection_snapshot_and_live_stream_share_one_fold() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
let created = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -277,12 +400,76 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
.await
.expect("finish lifecycle broadcast")
.expect("projection broadcast");
assert_eq!(finish.transcript, None);
let Some(TranscriptDelta::Append { row }) = finish.transcript else {
panic!("finish should append durable turn metadata");
};
assert_eq!(row.kind, TranscriptRowKind::Notice);
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
assert_eq!(finish.state.turn, Default::default());
let recovered = fx
.manager
.read_acp_projection_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("turn replay")
.expect("structured session");
assert!(recovered.iter().any(|event| {
matches!(
&event.transcript,
Some(TranscriptDelta::Append { row })
if row.data["maestroTurnMeta"]["outcome"] == "completed"
)
}));
fx.manager.kill(session.id, false).await.expect("kill");
}
#[tokio::test]
async fn queued_prompt_is_a_durable_user_transcript_row() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, session.id).await;
assert!(matches!(
fx.manager
.queue_acp_prompt(session.id, "show my prompt".into(), None)
.await
.expect("queue prompt"),
maestro_daemon::session::QueueAcpPrompt::Dispatched
));
let (snapshot, _, _) = fx
.manager
.subscribe_acp_projection(session.id, EventCursor::Since(0))
.await
.expect("projection subscription")
.expect("structured session");
let user = snapshot
.rows
.iter()
.find(|row| row.kind == TranscriptRowKind::User)
.expect("user prompt row");
assert_eq!(user.data["content"]["text"], "show my prompt");
let persisted = fx
.manager
.read_acp_events(session.id, EventCursor::Since(0), 100)
.expect("event replay");
assert!(persisted.iter().any(|event| matches!(
&event.event,
maestro_daemon::sidecar::PersistedEvent::User { row }
if row.data["content"]["text"] == "show my prompt"
)));
fx.manager.kill(session.id, false).await.unwrap();
}
#[tokio::test]
async fn stale_turn_completion_cannot_end_the_next_turn() {
let fx = DaemonFixture::new();
@@ -293,6 +480,7 @@ async fn stale_turn_completion_cannot_end_the_next_turn() {
.create(structured(cwd.path(), fake.env()))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
let maestro_daemon::session::BeginAcpTurn::Started(_, first) =
fx.manager.begin_acp_turn(session.id).await.unwrap()
@@ -336,18 +524,19 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
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!(
@@ -374,21 +563,22 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
.create(structured(cwd.path(), env))
.await
.unwrap();
wait_for_ready(&fx.manager, session.id).await;
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 {
@@ -433,11 +623,12 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let session = fx
let created = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("create");
let session = wait_for_ready(&fx.manager, created.id).await;
let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
@@ -481,10 +672,13 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
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;
@@ -493,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);
@@ -504,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");
@@ -539,7 +740,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
command: "codex".into(),
env: fake.env(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
@@ -547,10 +748,12 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
branch: branch.into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
assert_eq!(session.cwd, worktree);
wait_for_ready(&fx.manager, session.id).await;
push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await;
@@ -599,6 +802,7 @@ async fn failed_recovery_cleans_its_fifo() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
@@ -632,28 +836,56 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
empty.path().to_string_lossy().into_owned(),
)]);
let session = fx
let created = fx
.manager
.create(structured(cwd.path(), env))
.create(structured_command(cwd.path(), env, "codex"))
.await
.expect("a failed session, not an error");
.expect("create registers a Starting session");
assert_eq!(created.activity, ActivityState::Starting);
// The background handshake fails because the adapter binary is missing.
let session = wait_for_ready(&fx.manager, created.id).await;
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
assert!(detail.contains(ADAPTER_BIN), "{detail}");
assert!(detail.contains(INSTALL_COMMAND), "{detail}");
assert!(detail.contains(AcpAdapter::Codex.binary()), "{detail}");
assert!(
detail.contains(AcpAdapter::Codex.install_command()),
"{detail}"
);
assert_eq!(session.acp_session_id, None);
// It is a real session: listed, on disk, and dismissable like any other
// dead one.
assert!(fx.manager.has(session.id).await);
assert!(fx.manager.has(created.id).await);
assert!(fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.join(format!("{}.json", created.id))
.exists());
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
fx.manager.kill(created.id, false).await.expect("dismiss");
assert!(!fx.manager.has(created.id).await);
}
#[tokio::test]
async fn unsupported_structured_command_fails_without_launching_claude() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let err = fx
.manager
.create(structured_command(cwd.path(), fake.env(), "opencode"))
.await
.expect_err("unsupported command is rejected at request validation");
assert!(
matches!(err, maestro_daemon::SessionError::BadRequest(ref msg) if msg.contains("Claude Code or Codex")),
"{err:?}"
);
assert!(
fake.log().is_empty(),
"unsupported command launched an adapter"
);
}
#[tokio::test]
@@ -668,6 +900,7 @@ async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
assert!(pid_alive(pid));
@@ -710,6 +943,8 @@ async fn shutdown_reaps_every_adapter_child() {
.create(structured(cwd.path(), b.env()))
.await
.expect("create b");
wait_for_ready(&fx.manager, sa.id).await;
wait_for_ready(&fx.manager, sb.id).await;
let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb));
@@ -744,10 +979,13 @@ async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
.create(structured(cwd.path(), env))
.await
.expect("create");
fx.manager
let blocker_session = fx
.manager
.create(structured(cwd.path(), blocker.env()))
.await
.expect("create blocker");
wait_for_ready(&fx.manager, session.id).await;
wait_for_ready(&fx.manager, blocker_session.id).await;
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
@@ -799,6 +1037,7 @@ async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_exit(fake.pid()).await;
fx.manager.shutdown().await;
@@ -832,23 +1071,31 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
let create_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
// create() returns immediately; the handshake runs in the background
// and holds a lifecycle permit.
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
// Wait until the adapter has received session/new (handshake in progress).
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
// Shutdown must wait for the in-flight background handshake.
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!shutdown.is_finished(),
"shutdown returned before create finished"
"shutdown returned before handshake finished"
);
let session = create.await.expect("create task").expect("create");
// The handshake completes in the background (500ms delay).
wait_for_ready(&fx.manager, session.id).await;
let pid = fake.pid();
shutdown.await.expect("shutdown task");
@@ -868,6 +1115,194 @@ async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
assert_eq!(fake.log().matches("signal SIGTERM").count(), 1);
}
#[tokio::test]
async fn structured_handshakes_run_independently() {
let fx = DaemonFixture::new();
let blocked = Fake::new();
let ready = Fake::new();
let cwd = TempDir::new().unwrap();
let gate = cwd.path().join("release-first");
let mut blocked_env = blocked.env();
blocked_env.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate.to_string_lossy().into_owned(),
);
// Both create() calls return immediately with Starting; the first
// adapter's handshake is gated while the second runs freely.
let first = fx
.manager
.create(structured(cwd.path(), blocked_env))
.await
.expect("first create");
let second = fx
.manager
.create(structured(cwd.path(), ready.env()))
.await
.expect("second create");
// The second handshake completes independently of the first.
let second_ready = wait_for_ready(&fx.manager, second.id).await;
assert_eq!(second_ready.activity, ActivityState::Idle);
// The first handshake is still blocked.
let first_snap = fx
.manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == first.id)
.unwrap();
assert_eq!(first_snap.activity, ActivityState::Starting);
std::fs::write(&gate, b"release").unwrap();
let first_ready = wait_for_ready(&fx.manager, first.id).await;
assert_eq!(first_ready.activity, ActivityState::Idle);
fx.manager.kill(first.id, false).await.expect("kill first");
fx.manager
.kill(second.id, false)
.await
.expect("kill second");
}
#[tokio::test]
async fn shutdown_waits_for_every_admitted_structured_create() {
let fx = DaemonFixture::new();
let a = Fake::new();
let b = Fake::new();
let late = Fake::new();
let cwd = TempDir::new().unwrap();
let gate_a = cwd.path().join("release-a");
let gate_b = cwd.path().join("release-b");
let mut env_a = a.env();
let mut env_b = b.env();
env_a.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate_a.to_string_lossy().into_owned(),
);
env_b.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate_b.to_string_lossy().into_owned(),
);
// Both create() calls return immediately; the background handshake
// tasks hold lifecycle permits and are blocked on their gates.
let session_a = fx
.manager
.create(structured(cwd.path(), env_a))
.await
.expect("create a");
let session_b = fx
.manager
.create(structured(cwd.path(), env_b))
.await
.expect("create b");
wait_for_async(Duration::from_secs(5), || async {
a.log().contains("recv session/new") && b.log().contains("recv session/new")
})
.await;
let shutdown = {
let manager = fx.manager.clone();
tokio::spawn(async move { manager.shutdown().await })
};
wait_for_async(Duration::from_secs(5), || async {
matches!(
fx.manager.create(structured(cwd.path(), late.env())).await,
Err(maestro_daemon::SessionError::ShuttingDown)
)
})
.await;
assert!(!shutdown.is_finished());
std::fs::write(&gate_a, b"release").unwrap();
wait_for_ready(&fx.manager, session_a.id).await;
assert!(
!shutdown.is_finished(),
"shutdown ignored the second create"
);
std::fs::write(&gate_b, b"release").unwrap();
wait_for_ready(&fx.manager, session_b.id).await;
shutdown.await.expect("shutdown task");
wait_for_exit(a.pid()).await;
wait_for_exit(b.pid()).await;
assert_eq!(
fx.manager.status(session_a.id).await,
Some(SessionStatus::Dead)
);
assert_eq!(
fx.manager.status(session_b.id).await,
Some(SessionStatus::Dead)
);
}
#[tokio::test]
async fn killing_a_starting_session_rolls_back_and_settles_admission() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let gate = repo.path().join("release-create");
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
env.insert(
"FAKE_ACP_SESSION_NEW_GATE".into(),
gate.to_string_lossy().into_owned(),
);
let request = CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
env,
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "cancelled-create".into(),
create_branch: true,
}),
auto_approve: false,
};
// create() returns immediately with Starting; the handshake is gated.
let session = fx.manager.create(request).await.expect("create");
assert_eq!(session.activity, ActivityState::Starting);
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let pid = fake.pid();
// Kill the session while its handshake is still in progress.
// force=true because the worktree has an unpushed branch commit.
fx.manager.kill(session.id, true).await.expect("kill");
// Release the gate so the background task can finish and settle.
std::fs::write(&gate, b"release").unwrap();
// Shutdown must not hang — the lifecycle permit is settled.
tokio::time::timeout(Duration::from_secs(5), fx.manager.shutdown())
.await
.expect("shutdown hung on a settled permit");
wait_for_exit(pid).await;
assert!(fx.manager.snapshot().await.is_empty());
let id = session.id.to_string();
assert!(
std::fs::read_dir(fx.sidecar_dir())
.into_iter()
.flatten()
.all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(&id)),
"killed Starting session left a sidecar artifact"
);
}
#[tokio::test]
async fn shutdown_drains_final_notifications_to_the_log() {
let fx = DaemonFixture::new();
@@ -881,6 +1316,7 @@ async fn shutdown_drains_final_notifications_to_the_log() {
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
@@ -911,6 +1347,7 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_ready(&fx.manager, session.id).await;
fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux,
@@ -925,6 +1362,16 @@ async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
})
.await;
assert_eq!(
fx.manager
.snapshot()
.await
.into_iter()
.find(|candidate| candidate.id == session.id)
.unwrap()
.activity,
ActivityState::Error
);
assert!(fx.manager.has(session.id).await);
assert!(
!fx.sidecar_dir()
@@ -48,6 +48,7 @@ async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
+1
View File
@@ -81,6 +81,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
+38 -3
View File
@@ -11,14 +11,14 @@ use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
Fixture {
manager,
sidecar,
_tmux: tmux,
tmux,
_state_dir: state_dir,
}
}
@@ -56,6 +56,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -170,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
let live = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&live), NameSource::Generated);
}
/// A session that dies naturally (liveness watcher marks it dead) must not
/// overwrite a haiku rename that landed before the death. Regression test for
/// the stale-clone sidecar write in `mark_session_dead`.
#[tokio::test]
async fn session_death_preserves_the_haiku_name() {
let fx = fixture();
let id = spawn(&fx.manager, None).await;
fx.manager.claim_autoname(id).await.unwrap();
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
// Verify the sidecar has Haiku before the session dies.
let before = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&before), NameSource::Haiku);
// Kill the tmux session directly so the liveness watcher picks it up.
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
fx.manager
.start_liveness_watcher(std::time::Duration::from_millis(25));
common::wait_for_async(std::time::Duration::from_secs(3), || async {
fx.manager.status(id).await == Some(SessionStatus::Dead)
})
.await;
let after = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(
display::name_source(&after),
NameSource::Haiku,
"mark_session_dead must not overwrite a haiku rename in the sidecar"
);
assert_eq!(display::custom_name(&after), Some("haiku-name"));
}

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