`SessionManager::create` used to call `tmux new-session` (which exec's the
user's command immediately), run an async `PtyBridge::start_with_replay`
(mkfifo + open + spawn reader), then issue a *separate* `tmux pipe-pane`.
Any output the child wrote between exec and that third tmux subprocess
landing went into the pane but never into the FIFO. Hit in NAP-252
dogfood: `bash -c 'echo X; exec bash'` sessions showed only the trailing
prompt, never the X.
Two changes:
1. New `Tmux::new_session_piped(id, cwd, cmd, sink)` chains
`; pipe-pane -t maestro-<id> <sink>` into the same tmux command argv
as `new-session`. Within a tmux command chain, `pipe-pane` is
processed immediately after `new-session` returns — before the child
has finished `execve` — so the FIFO is wired up before the first
write.
2. `SessionManager::create` now builds the FIFO + reader task **before**
spawning the session, then calls `new_session_piped` instead of the
two-step `new_session` + `pipe_pane`.
`Tmux::pipe_pane` and the standalone `Tmux::new_session` stay around —
`recover()` (NAP-228) still needs a standalone pipe-pane re-attach, and
some integration tests want a bare session without a pipe.
Tests:
- Unit: argv assertion that the chained `pipe-pane` follows `new-session`
when a sink is supplied.
- Integration: `create_captures_initial_output_in_replay_buffer` spawns
`sh -c 'echo NAP_253_FIRST_LINE_MARKER; exec sleep 30'` and polls
`subscribe_pty_with_replay` until the marker shows up in the replay
buffer (or fails after 5s). Fails before the fix, passes after.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture sidecar_dir/fifo_dir/tmux/worktree_root before `drop(fx.manager)`
so the post-drop builder calls don't need to reborrow the partially-moved
fixture. Pure compile fix — same scenario as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Tmux::pipe_pane` used `tmux pipe-pane -o`, which tmux documents as "only
open a new pipe if no previous pipe exists." When `maestrod` restarts and
`SessionManager::recover` re-issues `pipe-pane` against the adopted tmux
session, the previous daemon process's pipe to a now-orphan FIFO is
still active — so `-o` makes the call a no-op. The revived manager's
PtyBridge reads from a fresh FIFO that tmux never feeds, and attach
clients see input land in the pane but no output flow back.
Drop the `-o`. tmux closes any existing pipe before opening the new one,
which is what `pipe_pane` callers always wanted: `create` runs on a
fresh session (nothing to clobber), `recover` actively needs the
clobber.
Test: `recover_re_pipes_pane_so_output_reaches_subscribers` creates a
bash session under one manager, drops it, recovers under a second
manager, sends keys, and asserts the echo bytes reach a fresh
subscriber within 5s. Fails before the fix (stale pipe), passes after.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI e2e tests failed with "tmux exited with code 1: server exited
unexpectedly" because the startup `start-server \; set-option -g
default-terminal` call left an empty server that tmux's default
`exit-empty on` killed before the first `new-session` could attach.
Move the `set-option -g default-terminal tmux-256color` into the same
tmux command as `new-session` (chained with `;`) so a single tmux
invocation creates the server, sets the option, and spawns the pane.
The pane reads `default-terminal` at creation time, so chaining before
`new-session` still applies to the very first session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`maestrod` running from a stripped systemd env leaves the spawned shell
without `TERM`/`COLORTERM`, and the pane process inherits tmux's
`default-terminal` (which defaults to `screen`, 8 colors). Browser
attaches via xterm.js render ANSI sequences as plain text.
Fix:
- `Tmux::new_session` now passes `-e TERM=xterm-256color -e
COLORTERM=truecolor` to `tmux new-session`.
- New `Tmux::set_option_global` chains `start-server \; set-option -g`
so it works before any session exists; the daemon calls it on startup
to set `default-terminal=tmux-256color`.
Operators wanting 24-bit color should add
`set-option -ga terminal-overrides ",xterm-256color:RGB"` to their
own tmux config — left out of the daemon since that's operator territory.
Tests:
- Unit: `new_session_args` returns the expected argv including the
`-e TERM=…` / `COLORTERM=…` pair.
- Integration: spawn a session under an isolated tmux server, set
`default-terminal`, run `printenv` + `tput colors`, assert the pane
sees `TERM=tmux-256color`, `COLORTERM=truecolor`, and 256 colors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each PtyBridge keeps a per-session ring buffer (Mutex<VecDeque<u8>>,
default 64 KB, override via `[daemon] pty_replay_bytes`). The reader
task appends each chunk while holding the buffer lock and broadcasts in
the same critical section, so `PtyBridge::subscribe_with_replay` —
which snapshots the buffer and subscribes to the broadcast under that
same lock — produces a snapshot that's a true prefix of subsequent live
bytes. No duplicates, no gaps at the seam.
`SessionManager` exposes the new `subscribe_pty_with_replay`, and
`attach::serve_local` sends the snapshot as a single binary frame
before pumping the live receiver. End result: attaching to an idle
bash session now shows the prompt immediately; reattaching after
running a build sees the last screenful instead of a blank terminal.
Adds `[daemon] pty_replay_bytes` to the unified config (None ⇒
default; Some(0) ⇒ disabled). E2E test exercises the full path: client
A drives output through, disconnects, client B sees the prior output
on connect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New module `local_events::LocalEventBus` (broadcast<RegistryEvent>) with
two publishers:
* `spawn_session_publisher` — translates SessionManager's own session
events into the matching `RegistryEvent` variants (with daemon_id
filled in for the SessionRemoved case).
* `CatalogMirror::apply` — re-publishes peer events after applying them
to the local mirror, so subscribers see the post-apply state.
New `WS /api/events` route on the daemon: subscribes to the bus, sends
an initial `Snapshot` built from manager.snapshot() ∪ catalog state,
then forwards live deltas. `Lagged` follows the registry's convention
(client resyncs via REST).
Revert the REST-seed hack in web/src/events.ts — the WS endpoint now
exists, so `CatalogStore::start()` can go back to just `connect()`.
E2E: subscribe to a daemon's events WS, expect Snapshot then a
SessionAdded after POSTing /api/sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* New PairAttachVerifier (crates/maestro-daemon/src/jwt.rs): checks
signature, aud == self, exp > now, and sub ∈ allow_inbound. Default
deny — empty allow_inbound rejects every cross-daemon attach.
* /sessions/:id/stream tries the legacy verifier first, falls back to
the pair verifier on mismatch. Compat with existing direct-attach.
* New BrokerHandle (crates/maestro-daemon/src/broker.rs): shared between
the registry-client task and HTTP handlers; calls ResolveAttach over
the control link and correlates the reply via a pending map.
* RegistryClient now owns a BrokerHandle, drains its outbound mpsc
alongside session events / heartbeats, and routes
ResolveAttachResult back into the pending map. First heartbeat fires
immediately so the registry has the daemon's perimeter info before
the first ResolveAttach.
* /api/attach/🆔 self-owned → local PTY; peer-owned (per catalog
mirror) → ResolveAttach + T2 dial + bidirectional frame proxy.
Returns structured errors for missing local perimeter, peer on a
different perimeter, etc. T1/T3 stay stubbed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New module crates/maestro-daemon/src/catalog_mirror.rs: in-memory mirror of
the global catalog (peer daemons + their sessions), updated from the
`DaemonInbound::CatalogEvent` frames the registry now fans out.
`local_api` consumes it: `GET /api/sessions` returns the union of the
daemon's own sessions and the mirrored peers; `GET /api/daemons` returns
self + connected peers. The web UI sees the same catalog from any home
daemon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New module crates/maestro-daemon/src/local_api.rs serving:
* GET /api/sessions — own sessions
* GET /api/daemons — self only (v1; peer mirror is NAP-246)
* GET /api/web/info — daemon identity (browser header data)
* POST /api/sessions — create on self; 501 for cross-daemon (NAP-247)
* DELETE /api/sessions/:id — kill on self; 404 for unknown
* WS /api/attach/:id — delegate to existing PTY subscribe when self-owned,
404 otherwise (cross-daemon broker is NAP-247)
The new routes share bind_addr with the legacy /sessions/:id/stream WS
endpoint, which keeps working unchanged. The shared serve loop is exposed
as attach::serve_local so the broker WS can reuse the PTY pump.
Trust model documented at the top of the module: no JWT, no cookie on
the new routes — same trust as tmux being one-user-wide. Operators who
bind a perimeter address opt into one-segment-wide trust.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a session terminates (the user runs `exit`, tmux is killed, or
`maestro kill` is called) the daemon's per-session broadcast channel
closes and `serve_attach` breaks out of its loop. The socket was then
dropped without a Close frame, so the client's `stream.next()` would
hang and the terminal stayed stuck in raw mode.
- daemon: send `Message::Close(None)` before returning from
`serve_attach` whenever the session itself ended (broadcast closed,
or session was killed between auth and subscribe).
- client: track remote-close, restore the TTY first, then print
`[maestro] session ended` so the message lands in cooked mode.
- tests: cover the kill path and the liveness-watcher reap path on the
daemon side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The daemon never noticed when a session's command exited and tmux tore
the session down — the in-memory `SessionManager` map plus the registry
catalog kept showing it as `Running`, and a follow-up `maestro attach`
would hang on an empty broadcast.
Adds a per-host background task that polls `tmux list-sessions` every
2s, diffs against the in-memory set, and reaps anything we still track
but tmux has lost: drops the record, deletes the sidecar + FIFO,
cleans up its worktree if requested, and emits `SessionEvent::Removed`
so the registry purges its catalog.
The task holds a `Weak<Inner>` so it exits cleanly once the last
`SessionManager` clone is dropped. Skips the tmux subprocess when the
in-memory map is empty.
Adds the client-facing surface on the registry (NAP-215). Endpoints:
- GET /api/daemons — list connected daemons (with daemon_url)
- GET /api/sessions — flat session catalog
- POST /api/sessions — forwards CreateSession to chosen daemon, 504 on
10s daemon timeout
- DELETE /api/sessions/:id — forwards KillSession
- GET /api/sessions/:id/connect-info — returns {daemon_url, jwt} with a
5min HS256-signed attach JWT
- WS /api/events — snapshot + delta broadcast of catalog and presence
- GET / placeholder served until NAP-218 web bundle lands
Cookie auth middleware mints a long-lived httpOnly JWT cookie on first
request and reissues silently on missing/tampered cookies. Per spec the
perimeter is the operator's network — the cookie is a second factor.
Protocol additions: daemon_url on Hello, DaemonInfo, ConnectInfo,
RegistryEvent. Daemon config + RegistryClient now carry daemon_url.
Registry state now exposes an event broadcast channel (subscribe gives
a snapshot + tokio::sync::broadcast::Receiver) and helper lookups
daemon_for_session / daemon_url.
Tests: 11 reqwest-driven HTTP integration tests cover every endpoint,
the cookie flow, 504 timeout path, worktree forwarding, and events WS
propagation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the headless daemon stack for NAP-214.
- `config`: daemon TOML config (daemon_id, registry_url, bind_addr,
token, state_dir, worktree_root) with sensible XDG defaults.
- `jwt`: HS256 verifier; the daemon validates per-attach JWTs locally
with no callback to the registry. Also exposes `issue()` for tests.
- `pty::PtyBridge`: per-session FIFO + tokio broadcast pump. The FIFO is
opened r+w so it never sees zero writers — stays alive across
`pipe-pane` re-issues. Helper `pipe_pane_command()` builds the
`cat >> <fifo>` shell line.
- `session::SessionManager`: authoritative live-session store. Creates
worktrees (when requested), spawns tmux, mkfifo + pipe-pane, writes
sidecar; tears it all down on kill. Emits diff events on a
broadcast for the registry client. `recover()` adopts surviving
`maestro-*` tmux sessions on startup and re-pipes them.
- `registry_client::RegistryClient`: persistent outbound WS to the
registry with exponential backoff. Sends Hello → snapshot, drives a
select loop that pumps inbound CreateSession/KillSession RPCs into
the manager (replying with results), emits SessionAdded/Removed/
Updated diffs from the manager, and heartbeats every 10s.
- `attach`: axum router mounting `WS /sessions/:id/stream?token=<jwt>`.
Binary frames carry PTY bytes either way (subscribe to broadcast →
send to client; receive from client → tmux send-keys -l). Text
frames carry `ClientControl::{Resize,Ping,Pong}`.
- `bin/maestrod`: ties everything together, loads config from
`MAESTROD_CONFIG` env or `~/.config/maestrod/config.toml`,
recovers, spawns the registry client, binds the WS server.
Also adds `JwtClaims` to `maestro-protocol` so the daemon and the
future registry JWT issuer (NAP-215) agree on the wire shape.
Integration tests cover session manager round-trip + recovery + PTY
broadcast; attach endpoint JWT (bad/wrong-session/wrong-daemon),
PTY round-trip through real tmux, multi-attach broadcast, ping/pong;
registry client registration + snapshot + diff forwarding +
CreateSession RPC round-trip.
Closes NAP-214.
Local primitives for `maestrod`, no network code yet:
- `tmux`: subprocess wrapper around new-session, kill-session,
list-sessions (filtered to maestro-*), send-keys -l, pipe-pane -o,
resize-window, kill-server.
- `sidecar`: atomic JSON metadata store keyed by session UUID, with
load_all for daemon restart recovery.
- `worktree`: `git worktree add -b` with collision-proof generated paths
and `git worktree remove --force` for cleanup_on_kill.
Integration tests use real tmux (isolated via unique socket + temp
TMUX_TMPDIR) and real git (tmpdir repo).
Closes NAP-212.