Compare commits

..
Author SHA1 Message Date
Miguel Palhas 9728b10ffd feat(maestro): add preset resume commands 2026-09-04 08:33:08 +01:00
Miguel Palhas 1b2b392710 merge: dormant session recovery (#162)
Closes #162
2026-09-04 08:25:44 +01:00
Miguel PalhasandClaude Opus 5 72644dd564 feat(maestro): register dormant sessions on recovery
A host reboot kills the tmux server, so `SessionManager::recover` iterated an
empty list and every session vanished from the API while its sidecar sat
untouched on disk. A second recovery pass now reads the sidecars tmux had
nothing to adopt and registers each as `SessionStatus::Dormant` — a record of
what was running, with no pane and no PTY bridge behind it.

`SessionStatus` deserializes leniently now, like `ActivityState`: an unknown
spelling reads as `Dead` rather than failing the whole `Session`.

Refs #162

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:22:52 +01:00
67 changed files with 864 additions and 5590 deletions
Generated
+56 -746
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -10,7 +10,6 @@ repository = "https://git.naps.pt/naps62/maestro"
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
axum = { version = "0.7", features = ["ws"] } axum = { version = "0.7", features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
@@ -18,7 +17,6 @@ crossterm = { version = "0.28", features = ["event-stream"] }
dirs = "5" dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2" libc = "0.2"
maestro-acp = { path = "crates/maestro-acp" }
maestro-config = { path = "crates/maestro-config" } maestro-config = { path = "crates/maestro-config" }
# `default-features = false` so each consumer opts into `haiku-autoname` # `default-features = false` so each consumer opts into `haiku-autoname`
# through its own feature of the same name (#131); a workspace entry that # through its own feature of the same name (#131); a workspace entry that
@@ -40,7 +38,6 @@ tempfile = "3"
thiserror = "1" thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
tokio-tungstenite = "0.21" tokio-tungstenite = "0.21"
tokio-util = { version = "0.7", features = ["compat"] }
toml = "0.8" toml = "0.8"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "maestro-acp"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "ACP client role: one claude-code-acp child per structured session"
[dependencies]
agent-client-protocol.workspace = true
libc.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
-594
View File
@@ -1,594 +0,0 @@
//! ACP client role for the daemon (#134): one `claude-code-acp` 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
//! the session's env, runs the `initialize` + `session/new` handshake, and
//! hands back an [`AcpSession`] that owns the connection for the life of the
//! session.
//!
//! **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.
//!
//! Every `session/update` notification the adapter sends lands on an
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
//! is dropped while nobody reads: #135 takes the receiver and persists each
//! notification to the per-session event log before broadcasting it.
//!
//! A respawn after a daemon restart sets [`SpawnConfig::load_session`] and
//! the handshake runs `session/load` with the stored id instead of
//! `session/new`. The history the agent streams back while that request is
//! in flight is *not* delivered: the daemon's event log already holds it,
//! and replaying it would double every line.
//!
//! The child is a child of the daemon, full stop. It shares the daemon's
//! process group, is marked to receive `SIGTERM` when the daemon dies
//! (`PR_SET_PDEATHSIG` on Linux), and [`AcpSession::terminate`] is what both
//! `DELETE /api/sessions/:id` and daemon shutdown call: `session/cancel`, a
//! short wait, then `SIGTERM`, then `SIGKILL` as the last resort.
#![deny(missing_debug_implementations)]
use std::collections::{HashMap, VecDeque};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, FileSystemCapabilities, Implementation,
InitializeRequest, LoadSessionRequest, NewSessionRequest, RequestPermissionOutcome,
RequestPermissionRequest, RequestPermissionResponse, SessionId, SessionNotification,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client};
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";
/// 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.
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.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
/// sending `SIGTERM`. Cancel is a courtesy that lets the adapter finish the
/// turn it is on; the signal is what actually ends it.
pub const CANCEL_GRACE: Duration = Duration::from_millis(500);
/// How long [`AcpSession::terminate`] waits after `SIGTERM` before `SIGKILL`.
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;
/// What [`spawn`] needs to start one adapter.
#[derive(Debug, Clone)]
pub struct SpawnConfig {
/// Working directory of the child and `cwd` of `session/new`.
pub cwd: PathBuf,
/// Environment laid over the daemon's own for the child. A `PATH` in
/// here is also what the adapter is resolved against, since that is the
/// `PATH` the child would see.
pub env: HashMap<String, String>,
/// Upper bound on the handshake; [`HANDSHAKE_TIMEOUT`] by default.
pub handshake_timeout: Duration,
/// An existing ACP session id to resume with `session/load` (#135)
/// instead of opening a fresh one. The adapter must advertise
/// `loadSession`; the handshake fails otherwise.
pub load_session: Option<String>,
}
impl SpawnConfig {
pub fn new(cwd: impl Into<PathBuf>, env: HashMap<String, String>) -> Self {
Self {
cwd: cwd.into(),
env,
handshake_timeout: HANDSHAKE_TIMEOUT,
load_session: None,
}
}
/// Resume `session_id` with `session/load` instead of `session/new`.
pub fn load_session(mut self, session_id: impl Into<String>) -> Self {
self.load_session = Some(session_id.into());
self
}
}
#[derive(Debug, thiserror::Error)]
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),
/// 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("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> {
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))
.find(|candidate| is_executable(candidate))
}
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
enum Command {
Cancel,
Close,
}
enum ChildCommand {
Signal(libc::c_int),
Reap(oneshot::Sender<bool>),
}
/// Why the handshake did not produce a session id.
enum ReadyError {
/// The adapter answered with an error.
Handshake(String),
/// The transport closed under the request: the child is gone.
Closed,
}
fn ready_error(e: agent_client_protocol::Error) -> ReadyError {
if agent_client_protocol::is_incoming_transport_closed(&e) {
ReadyError::Closed
} else {
ReadyError::Handshake(e.to_string())
}
}
/// A live ACP connection to one adapter child. Dropping it sends `SIGTERM`
/// if the child is still running; [`AcpSession::terminate`] is the polite
/// version.
#[derive(Debug)]
pub struct AcpSession {
session_id: String,
pid: u32,
commands: mpsc::UnboundedSender<Command>,
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
exit: watch::Receiver<Option<ExitStatus>>,
}
impl AcpSession {
/// The id `session/new` returned. What a respawn passes to
/// `session/load` (#135).
pub fn session_id(&self) -> &str {
&self.session_id
}
/// The adapter's pid.
pub fn pid(&self) -> u32 {
self.pid
}
/// Whether the child has been reaped.
pub fn has_exited(&self) -> bool {
self.exit.borrow().is_some()
}
/// Reap the child if it has already exited.
pub async fn reap_if_exited(&self) -> bool {
if self.has_exited() {
return true;
}
let (sent, received) = oneshot::channel();
if self.child_commands.send(ChildCommand::Reap(sent)).is_err() {
return self.has_exited();
}
received.await.unwrap_or_else(|_| self.has_exited())
}
/// Wait for the child to exit.
pub async fn wait(&self) -> Option<ExitStatus> {
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>> {
self.updates.lock().expect("updates mutex").take()
}
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
/// child is already gone, which is the outcome cancel was after.
pub fn cancel(&self) {
let _ = self.commands.send(Command::Cancel);
}
/// 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) {
self.cancel();
if tokio::time::timeout(cancel_grace, self.wait())
.await
.is_ok()
{
return;
}
self.signal(libc::SIGTERM);
if tokio::time::timeout(term_grace, self.wait()).await.is_ok() {
return;
}
tracing::warn!(
pid = self.pid,
"{ADAPTER_BIN} ignored SIGTERM; sending SIGKILL"
);
self.signal(libc::SIGKILL);
let _ = self.commands.send(Command::Close);
self.wait().await;
}
fn signal(&self, sig: libc::c_int) {
let _ = self.child_commands.send(ChildCommand::Signal(sig));
}
}
impl Drop for AcpSession {
fn drop(&mut self) {
self.signal(libc::SIGTERM);
let _ = self.commands.send(Command::Close);
}
}
/// Start the adapter for one session and run the handshake.
///
/// 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 mut cmd = tokio::process::Command::new(&adapter);
cmd.current_dir(&config.cwd)
.envs(&config.env)
// Claude Code refuses to start when it thinks it is nested inside
// another Claude Code session; a daemon launched from one would
// otherwise never get a structured session up.
.env_remove("CLAUDECODE")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// If the daemon is dropped without a graceful shutdown the runtime
// still tears the task holding this `Child` down, and that kills it.
.kill_on_drop(true);
#[cfg(target_os = "linux")]
{
// Same process group as the daemon (no setpgid), and SIGTERM when the
// daemon dies however it dies: an agent that outlives its daemon is
// exactly the orphan #134 rules out.
// SAFETY: prctl is async-signal-safe and touches no shared state.
unsafe {
cmd.pre_exec(|| {
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
Ok(())
});
}
}
let mut child = cmd.spawn().map_err(AcpError::Spawn)?;
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()));
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));
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
let (ready_tx, ready_rx) = oneshot::channel();
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
tokio::spawn(run_connection(
transport,
config.cwd.clone(),
config.load_session.clone(),
updates_tx,
commands_rx,
ready_tx,
));
let outcome = tokio::time::timeout(config.handshake_timeout, ready_rx).await;
let failure = match outcome {
Ok(Ok(Ok(session_id))) => {
return Ok(AcpSession {
session_id,
pid,
commands: commands_tx,
child_commands: child_commands_tx,
updates: Mutex::new(Some(updates_rx)),
exit: exit_rx,
});
}
Ok(Ok(Err(ReadyError::Handshake(message)))) => AcpError::Handshake(message),
Ok(Ok(Err(ReadyError::Closed))) | Ok(Err(_)) => {
// The connection ended before the handshake did: the child went
// away. Reap it so the status is real, then report.
let status = wait_exit(&exit_rx).await;
let stderr = {
let mut tail = stderr_tail.lock().expect("stderr tail");
tail.make_contiguous();
String::from_utf8_lossy(tail.as_slices().0)
.trim()
.to_string()
};
AcpError::ExitedEarly {
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
stderr: if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
},
}
}
Err(_) => AcpError::HandshakeTimeout(config.handshake_timeout),
};
// No half-started adapter survives a failed spawn.
let _ = commands_tx.send(Command::Close);
let _ = child_commands_tx.send(ChildCommand::Signal(libc::SIGTERM));
if tokio::time::timeout(TERM_GRACE, wait_exit(&exit_rx))
.await
.is_err()
{
let _ = child_commands_tx.send(ChildCommand::Signal(libc::SIGKILL));
wait_exit(&exit_rx).await;
}
Err(failure)
}
async fn wait_exit(exit: &watch::Receiver<Option<ExitStatus>>) -> Option<ExitStatus> {
let mut exit = exit.clone();
let status = exit.wait_for(|s| s.is_some()).await.ok()?;
*status
}
async fn supervise_child(
mut child: tokio::process::Child,
pid: u32,
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
exit_tx: watch::Sender<Option<ExitStatus>>,
) {
loop {
tokio::select! {
status = child.wait() => {
let status = status.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(status);
return;
}
command = commands.recv() => match command {
Some(ChildCommand::Signal(sig)) => {
// This task alone owns `child`. It cannot reap the child
// between receiving this command and sending the signal,
// so its PID cannot be reused in that window.
unsafe { libc::kill(pid as libc::pid_t, sig) };
}
Some(ChildCommand::Reap(done)) => match child.try_wait() {
Ok(Some(status)) => {
let _ = done.send(true);
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(Some(status));
return;
}
Ok(None) => {
let _ = done.send(false);
}
Err(error) => {
tracing::warn!(pid, %error, "could not reap {ADAPTER_BIN}");
let _ = done.send(false);
}
},
None => {
// The session vanished without a command receiver. This
// task still owns the unreaped child, so this cannot hit
// 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");
let _ = exit_tx.send(status);
return;
}
},
}
}
}
/// The capabilities the daemon declares: none of the filesystem or terminal
/// surface, so the adapter falls back to Claude Code's own tools.
fn client_capabilities() -> ClientCapabilities {
ClientCapabilities::new()
.fs(FileSystemCapabilities::new()
.read_text_file(false)
.write_text_file(false))
.terminal(false)
}
async fn run_connection<T>(
transport: T,
cwd: PathBuf,
load_session: Option<String>,
updates_tx: mpsc::UnboundedSender<SessionNotification>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
) where
T: agent_client_protocol::ConnectTo<Client> + 'static,
{
// Set while `session/load` is in flight. Notification handlers run in
// the dispatch loop, in arrival order, so everything the agent streams
// before its load response sees the flag up and is dropped: that is the
// history the event log already holds (#135).
let replaying = Arc::new(AtomicBool::new(false));
let replaying_seen = replaying.clone();
let result = Client
.builder()
.name("maestro")
.on_receive_notification(
async move |notification: SessionNotification, _cx| {
if replaying_seen.load(Ordering::SeqCst) {
tracing::trace!("dropping replayed notification during session/load");
return Ok(());
}
// A dropped receiver only means nobody wants updates any more.
let _ = updates_tx.send(notification);
Ok(())
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
async move |request: RequestPermissionRequest, responder, _cx| {
// Stub until #139: decline rather than grant something the
// operator never saw.
tracing::warn!(
session = %request.session_id,
"session/request_permission declined: not implemented yet (#139)"
);
responder.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Cancelled,
))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
let init = InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities())
.client_info(Implementation::new("maestro", env!("CARGO_PKG_VERSION")));
let initialized = match cx.send_request(init).block_task().await {
Ok(r) => r,
Err(e) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
};
let session_id = match 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}"
))));
return Ok(());
}
replaying.store(true, Ordering::SeqCst);
let (loaded_tx, loaded_rx) = oneshot::channel();
let replaying_done = replaying.clone();
if let Err(e) = cx
.send_request(LoadSessionRequest::new(existing.clone(), cwd))
.on_receiving_result(move |result| {
replaying_done.store(false, Ordering::SeqCst);
async move {
let _ = loaded_tx.send(result);
Ok(())
}
})
{
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
match loaded_rx.await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
Err(_) => {
let _ = ready_tx.send(Err(ReadyError::Handshake(
"session/load response was not delivered".into(),
)));
return Ok(());
}
}
existing
}
None => match cx
.send_request(NewSessionRequest::new(cwd))
.block_task()
.await
{
Ok(created) => created.session_id.0.to_string(),
Err(e) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
},
};
let _ = ready_tx.send(Ok(session_id.clone()));
loop {
tokio::select! {
cmd = commands_rx.recv() => match cmd {
Some(Command::Cancel) => {
let _ = cx.send_notification(CancelNotification::new(
SessionId::new(session_id.as_str()),
));
}
Some(Command::Close) | None => break,
},
_ = cx.incoming_closed() => break,
}
}
Ok(())
})
.await;
if let Err(e) = result {
tracing::debug!(error = %e, "ACP connection ended with an error");
}
}
async fn drain_stderr(
stderr: tokio::process::ChildStderr,
pid: u32,
tail: Arc<Mutex<VecDeque<u8>>>,
) {
let mut lines = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
let mut tail = tail.lock().expect("stderr tail");
tail.extend(line.as_bytes());
tail.push_back(b'\n');
while tail.len() > STDERR_TAIL_BYTES {
tail.pop_front();
}
tail.make_contiguous();
}
}
-146
View File
@@ -1,146 +0,0 @@
#!/usr/bin/env python3
"""A deterministic stand-in for `claude-code-acp` (#134).
Speaks just enough JSON-RPC over stdio for the daemon's handshake:
answers `initialize` and `session/new`, logs every method it receives, and
exits on SIGTERM. Tests point `PATH` at a directory holding this file under
the adapter's name, so the real adapter (which needs Claude auth and is
slow) never runs in the deterministic suite.
Environment knobs, all optional:
FAKE_ACP_LOG append one line per event to this file:
start pid=<pid> cwd=<cwd>
env <NAME>=<value> (for FAKE_ACP_ECHO_ENV)
caps <json of clientCapabilities>
recv <method>
signal SIGTERM
FAKE_ACP_ECHO_ENV name of one env var to log at start
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_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)
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
notifications ("replayed <i>") before answering,
the way a real agent streams history back
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
counting from 1) and logs `emit tick <n>`, so a test can make the agent
speak on demand (#135).
"""
import json
import os
import signal
import sys
import time
LOG = os.environ.get("FAKE_ACP_LOG")
def log(line):
if not LOG:
return
with open(LOG, "a") as f:
f.write(line + "\n")
f.flush()
def send(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def on_sigterm(_signum, _frame):
log("signal SIGTERM")
sys.exit(0)
signal.signal(signal.SIGTERM, on_sigterm)
session_id = f"fake-{os.getpid()}"
ticks = 0
def chunk(text):
return {"jsonrpc": "2.0", "method": "session/update", "params": {
"sessionId": session_id,
"update": {"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text}},
}}
def on_sigusr1(_signum, _frame):
global ticks
ticks += 1
send(chunk(f"tick {ticks}"))
log(f"emit tick {ticks}")
signal.signal(signal.SIGUSR1, on_sigusr1)
log(f"start pid={os.getpid()} cwd={os.getcwd()}")
echo = os.environ.get("FAKE_ACP_ECHO_ENV")
if echo:
log(f"env {echo}={os.environ.get(echo, '')}")
for raw in sys.stdin:
raw = raw.strip()
if not raw:
continue
msg = json.loads(raw)
method = msg.get("method")
if method is None:
# A response to something we sent; nothing to do.
continue
log(f"recv {method}")
rid = msg.get("id")
params = msg.get("params") or {}
if method == "initialize":
log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True))
if os.environ.get("FAKE_ACP_INIT_FAIL") == "1":
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake init failure"}})
continue
send({"jsonrpc": "2.0", "id": rid, "result": {
"protocolVersion": params.get("protocolVersion", 1),
"agentCapabilities": {
"loadSession": os.environ.get("FAKE_ACP_NO_LOAD") != "1"},
"agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"},
"authMethods": [],
}})
elif method == "session/new":
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(chunk("hello from fake"))
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
sys.exit(0)
elif method == "session/load":
session_id = params.get("sessionId")
log(f"recv session/load sessionId={session_id}")
if os.environ.get("FAKE_ACP_LOAD_FAIL") == "1":
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake load failure"}})
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(chunk("hello after load"))
elif method == "session/cancel":
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
send(chunk("final on cancel"))
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
sys.exit(0)
elif rid is not None:
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32601, "message": f"method not found: {method}"}})
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
while True:
signal.pause()
-394
View File
@@ -1,394 +0,0 @@
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
//! one ignored test against the real `claude-code-acp`.
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 tempfile::TempDir;
fn fake_agent() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fake_acp_agent.py")
}
/// A temp `bin/` holding the fake agent under the adapter's name, plus a
/// log file the agent appends to. `PATH` for the spawn is just that dir.
struct Fake {
dir: TempDir,
}
impl Fake {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
Self { dir }
}
fn log_path(&self) -> PathBuf {
self.dir.path().join("agent.log")
}
fn log(&self) -> String {
std::fs::read_to_string(self.log_path()).unwrap_or_default()
}
/// The fake's `bin/` first, so it shadows any real adapter, then the
/// process `PATH` so the script's `python3` still resolves.
fn env(&self) -> HashMap<String, String> {
let path = format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
HashMap::from([
("PATH".to_string(), path),
(
"FAKE_ACP_LOG".to_string(),
self.log_path().to_string_lossy().into_owned(),
),
])
}
fn config(&self, cwd: &Path) -> SpawnConfig {
let mut config = SpawnConfig::new(cwd, self.env());
config.handshake_timeout = Duration::from_secs(10);
config
}
async fn wait_for_log(&self, needle: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if self.log().contains(needle) {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("log never contained {needle:?}:\n{}", self.log());
}
fn pid(&self) -> u32 {
self.log()
.lines()
.find_map(|line| line.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|pid| pid.parse().ok())
.expect("pid in log")
}
async fn wait_for_exit(&self) {
let pid = self.pid();
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} still alive");
}
}
fn pid_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
&& std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|s| !s.contains(") Z "))
.unwrap_or(false)
}
#[tokio::test]
async fn spawn_runs_adapter_in_cwd_with_env_and_restrictive_caps() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
config
.env
.insert("MAESTRO_SESSION_ID".into(), "sess-123".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
assert!(session.session_id().starts_with("fake-"));
assert!(pid_alive(session.pid()));
let log = fake.log();
let cwd_canon = cwd.path().canonicalize().unwrap();
assert!(
log.contains(&format!("cwd={}", cwd_canon.display())),
"child cwd missing from log:\n{log}"
);
assert!(log.contains("env MAESTRO_SESSION_ID=sess-123"), "{log}");
assert!(log.contains("recv initialize"), "{log}");
assert!(log.contains("recv session/new"), "{log}");
let caps = log
.lines()
.find_map(|l| l.strip_prefix("caps "))
.expect("caps line");
let caps: serde_json::Value = serde_json::from_str(caps).unwrap();
assert_eq!(caps["fs"]["readTextFile"], false);
assert_eq!(caps["fs"]["writeTextFile"], false);
assert_eq!(caps["terminal"], false);
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn missing_adapter_names_the_install_command() {
let empty = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let env = HashMap::from([(
"PATH".to_string(),
empty.path().to_string_lossy().into_owned(),
)]);
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await
.expect_err("no adapter on PATH");
assert!(matches!(err, AcpError::AdapterMissing));
let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{msg}");
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
}
#[tokio::test]
async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()))
.await
.expect("spawn");
let mut updates = session.take_updates().expect("first take");
assert!(session.take_updates().is_none(), "handed out once");
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("update within 5s")
.expect("channel open");
assert_eq!(first.session_id.0.as_ref(), session.session_id());
match first.update {
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
ContentBlock::Text(t) => assert_eq!(t.text, "hello from fake"),
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
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()))
.await
.expect("spawn");
let pid = session.pid();
session
.terminate(Duration::from_millis(200), Duration::from_secs(2))
.await;
assert!(session.has_exited());
fake.wait_for_log("signal SIGTERM").await;
let log = fake.log();
let cancel = log.find("recv session/cancel").expect("cancel logged");
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
assert!(!pid_alive(pid), "child {pid} still alive");
}
#[tokio::test]
async fn terminate_is_satisfied_by_an_exit_on_cancel() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
session
.terminate(Duration::from_secs(2), Duration::from_secs(2))
.await;
assert!(session.has_exited());
let log = fake.log();
assert!(log.contains("recv session/cancel"), "{log}");
assert!(!log.contains("signal SIGTERM"), "cancel was enough:\n{log}");
}
#[tokio::test]
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());
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("fake init failure"), "{err}");
// Closing the connection gives the fake EOF on stdin; the SIGTERM that
// follows may find it already gone. Either way it must not survive.
let pid: u32 = fake
.log()
.lines()
.find_map(|l| l.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|p| p.parse().ok())
.expect("pid in log");
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} outlived the failed spawn");
}
#[tokio::test]
async fn early_exit_is_observed_after_a_successful_handshake() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
let status = tokio::time::timeout(Duration::from_secs(5), session.wait())
.await
.expect("exit within 5s")
.expect("status");
assert!(status.success(), "{status}");
assert!(session.has_exited());
}
#[tokio::test]
async fn child_exit_before_handshake_reports_exited_early() {
// A "adapter" that dies before answering anything.
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let script = bin.join(ADAPTER_BIN);
std::fs::write(&script, "#!/bin/sh\necho 'not logged in' >&2\nexit 3\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let cwd = TempDir::new().unwrap();
let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]);
let mut config = SpawnConfig::new(cwd.path(), env);
config.handshake_timeout = Duration::from_secs(10);
let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err {
AcpError::ExitedEarly { status, stderr } => {
assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}");
}
other => panic!("unexpected {other:?}"),
}
}
#[tokio::test]
async fn drop_terminates_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_IGNORE_EOF".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
drop(session);
fake.wait_for_log("signal SIGTERM").await;
fake.wait_for_exit().await;
}
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
#[tokio::test]
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");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
assert_eq!(session.session_id(), "fake-old");
let log = fake.log();
assert!(
log.contains("recv session/load sessionId=fake-old"),
"{log}"
);
assert!(!log.contains("recv session/new"), "{log}");
// The three replayed chunks never reach the receiver: the event log
// already holds them. The first notification after the response does.
let mut updates = session.take_updates().expect("updates");
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("update within 5s")
.expect("channel open");
assert_eq!(chunk_text(&first), "hello after load");
// Nothing queued behind it either.
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(updates.try_recv().is_err(), "replay leaked through");
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
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");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config)
.await
.expect_err("no loadSession");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("loadSession"), "{err}");
assert!(!fake.log().contains("recv session/load"), "{}", fake.log());
}
#[tokio::test]
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");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("fake load failure"), "{err}");
}
/// End to end against the installed `claude-code-acp`. Needs Claude auth,
/// takes seconds, so it is opt-in: `cargo test -p maestro-acp -- --ignored`.
#[tokio::test]
#[ignore]
async fn real_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()))
.await
.expect("real claude-code-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());
}
+11 -23
View File
@@ -96,32 +96,20 @@ pub async fn dispatch(
} }
/// `maestro hooks install` (#106): write the shipped hook script under the /// `maestro hooks install` (#106): write the shipped hook script under the
/// user's data dir and register it in every agent's hook file. Idempotent and /// user's data dir and register it in their Claude Code settings. Idempotent
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One /// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
/// agent failing is reported and does not stop the others.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
fn run_hooks_install() -> Result<(), CliError> { fn run_hooks_install() -> Result<(), CliError> {
let home = dirs::home_dir().ok_or(CliError::NoHome)?; let home = dirs::home_dir().ok_or(CliError::NoHome)?;
let script = maestro_hooks::script_path(&home); let changed = maestro_hooks::install_for_home(&home)?;
for (target, result) in maestro_hooks::install_for_home(&home) { if changed {
let agent = target.label(); println!(
let settings = target.settings_path(&home); "installed claude-code activity hook: {} → {}",
match result { maestro_hooks::script_path(&home).display(),
Ok(true) => { maestro_hooks::settings_path(&home).display()
println!( );
"installed {agent} activity hook: {} → {}", } else {
script.display(), println!("claude-code activity hook already up to date");
settings.display()
);
if target == maestro_hooks::Target::Codex {
// Codex will not run a hook it has not been told to
// trust, and that approval is the operator's to give.
println!(" codex will ask you to trust this hook the next time it starts");
}
}
Ok(false) => println!("{agent} activity hook already up to date"),
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
}
} }
Ok(()) Ok(())
} }
+1 -3
View File
@@ -51,7 +51,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
}; };
let body = CreateSessionRequest { let body = CreateSessionRequest {
view: Default::default(),
cwd: args.cwd.clone(), cwd: args.cwd.clone(),
command: args.command.clone(), command: args.command.clone(),
env, env,
@@ -189,6 +188,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let status = match s.status { let status = match s.status {
SessionStatus::Running => "running", SessionStatus::Running => "running",
SessionStatus::Dead => "dead", SessionStatus::Dead => "dead",
SessionStatus::Dormant => "dormant",
}; };
let activity = match s.activity { let activity = match s.activity {
ActivityState::Active => "active", ActivityState::Active => "active",
@@ -237,7 +237,6 @@ mod tests {
fn session(cwd: &str, command: &str, id: u128) -> Session { fn session(cwd: &str, command: &str, id: u128) -> Session {
Session { Session {
view: Default::default(),
id: Uuid::from_u128(id), id: Uuid::from_u128(id),
cwd: PathBuf::from(cwd), cwd: PathBuf::from(cwd),
command: command.into(), command: command.into(),
@@ -254,7 +253,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
} }
} }
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: None,
}], }],
..Default::default() ..Default::default()
}); });
@@ -91,7 +92,6 @@ async fn spawn_round_trips_through_the_daemon() {
let created = mock let created = mock
.client() .client()
.create_session(&maestro_protocol::CreateSessionRequest { .create_session(&maestro_protocol::CreateSessionRequest {
view: Default::default(),
cwd: "/home/mock".into(), cwd: "/home/mock".into(),
command: "bash".into(), command: "bash".into(),
env: Default::default(), env: Default::default(),
+43
View File
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: Some("claude --resume {agent_session}".into()),
}, },
SpawnPreset { SpawnPreset {
id: "opencode".into(), id: "opencode".into(),
label: "opencode".into(), label: "opencode".into(),
command: "opencode".into(), command: "opencode".into(),
resume_command: None,
}, },
SpawnPreset { SpawnPreset {
id: "zsh".into(), id: "zsh".into(),
label: "Shell".into(), label: "Shell".into(),
command: "zsh".into(), command: "zsh".into(),
resume_command: None,
}, },
] ]
} }
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(), id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(), label: "Custom…".into(),
command: String::new(), command: String::new(),
resume_command: None,
} }
} }
@@ -507,6 +511,18 @@ auto_install = false
assert!(custom.command.is_empty()); assert!(custom.command.is_empty());
} }
#[test]
fn builtin_claude_preset_resumes_by_agent_session() {
let claude = builtin_presets()
.into_iter()
.find(|preset| preset.id == "claude")
.expect("Claude preset");
assert_eq!(
claude.resume_command.as_deref(),
Some("claude --resume {agent_session}")
);
}
#[test] #[test]
fn spawn_presets_override_replaces_builtins() { fn spawn_presets_override_replaces_builtins() {
// Present list replaces the built-ins entirely; custom still appended. // Present list replaces the built-ins entirely; custom still appended.
@@ -515,6 +531,7 @@ auto_install = false
id: "fish".into(), id: "fish".into(),
label: "fish".into(), label: "fish".into(),
command: "fish".into(), command: "fish".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -551,6 +568,7 @@ auto_install = false
id: " ".into(), id: " ".into(),
label: "x".into(), label: "x".into(),
command: "x".into(), command: "x".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -561,6 +579,7 @@ auto_install = false
id: "x".into(), id: "x".into(),
label: "x".into(), label: "x".into(),
command: "".into(), command: "".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -578,11 +597,13 @@ auto_install = false
id: "a".into(), id: "a".into(),
label: "a".into(), label: "a".into(),
command: "a".into(), command: "a".into(),
resume_command: None,
}, },
SpawnPreset { SpawnPreset {
id: "a".into(), id: "a".into(),
label: "a2".into(), label: "a2".into(),
command: "a2".into(), command: "a2".into(),
resume_command: None,
}, },
]), ]),
..Default::default() ..Default::default()
@@ -597,6 +618,7 @@ auto_install = false
id: "custom".into(), id: "custom".into(),
label: "Custom".into(), label: "Custom".into(),
command: "whatever".into(), command: "whatever".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -632,6 +654,27 @@ presets = [
assert_eq!(ids, ["claude", "zsh", "custom"]); assert_eq!(ids, ["claude", "zsh", "custom"]);
} }
#[test]
fn spawn_preset_parses_resume_command_from_issue_example() {
let cfg = toml::from_str::<MaestroConfigFile>(
r#"
[spawn]
presets = [
{ id = "claude", command = "claude --dangerously-skip-permissions", resume_command = "claude --dangerously-skip-permissions --resume {agent_session}" }
]
"#,
)
.expect("issue #165 example parses");
let preset = &cfg.spawn.presets.expect("configured presets")[0];
assert_eq!(preset.id, "claude");
assert_eq!(preset.command, "claude --dangerously-skip-permissions");
assert_eq!(
preset.resume_command.as_deref(),
Some("claude --dangerously-skip-permissions --resume {agent_session}")
);
}
#[test] #[test]
fn spawn_project_scan_fields_default_to_none() { fn spawn_project_scan_fields_default_to_none() {
// Absent [spawn].project_* → None; the daemon applies its own defaults. // Absent [spawn].project_* → None; the daemon applies its own defaults.
-2
View File
@@ -28,7 +28,6 @@ dirs.workspace = true
futures-util.workspace = true futures-util.workspace = true
include_dir = { workspace = true, optional = true } include_dir = { workspace = true, optional = true }
libc.workspace = true libc.workspace = true
maestro-acp.workspace = true
mime_guess = { workspace = true, optional = true } mime_guess = { workspace = true, optional = true }
maestro-config.workspace = true maestro-config.workspace = true
maestro-hooks = { workspace = true, default-features = false } maestro-hooks = { workspace = true, default-features = false }
@@ -43,7 +42,6 @@ tracing-subscriber.workspace = true
uuid.workspace = true uuid.workspace = true
[dev-dependencies] [dev-dependencies]
libc.workspace = true
reqwest.workspace = true reqwest.workspace = true
tokio-tungstenite.workspace = true tokio-tungstenite.workspace = true
urlencoding = "2" urlencoding = "2"
+14 -33
View File
@@ -51,23 +51,17 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
// First-start hook auto-install (#106). Best-effort, before serving: a // First-start hook auto-install (#106). Best-effort, before serving: a
// session spawned right away should already carry the hook. // session spawned right away should already carry the hook.
if config.hooks_auto_install { if config.hooks_auto_install {
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) { match crate::hooks::auto_install(&config.home_dir) {
let agent = target.label(); crate::hooks::AutoInstallOutcome::Installed => {
match outcome { tracing::info!("installed claude-code activity hook (#106)");
crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!(agent, "installed activity hook (#106)");
}
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!(agent, "updated activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!(
agent,
"hooks auto-install failed; run `maestro hooks install` manually"
);
}
_ => {}
} }
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!("updated claude-code activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
}
_ => {}
} }
} }
// No worktree dir to pre-create: without an override the worktrees live // No worktree dir to pre-create: without an override the worktrees live
@@ -106,7 +100,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
projects.start_background_refresh(); projects.start_background_refresh();
let router = local_api::router(local_api::state_for( let router = local_api::router(local_api::state_for(
manager.clone(), manager,
config.hostname.clone(), config.hostname.clone(),
config.home_dir.clone(), config.home_dir.clone(),
config.spawn_presets.clone(), config.spawn_presets.clone(),
@@ -118,22 +112,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let bound = listener.local_addr()?; let bound = listener.local_addr()?;
tracing::info!(addr = %bound, "API listening"); tracing::info!(addr = %bound, "API listening");
// Ctrl-C at a terminal or SIGTERM from systemd: either one is a let shutdown = async {
// shutdown, and both must reach the adapter children below (#134). The let _ = tokio::signal::ctrl_c().await;
// unit's `KillMode=process` signals only the daemon, so nothing else tracing::info!("ctrl-c received — shutting down");
// would end them.
let shutdown_manager = manager.clone();
let shutdown = async move {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received — shutting down"),
_ = sigterm.recv() => tracing::info!("SIGTERM received — shutting down"),
}
// Axum waits for upgraded WebSockets to drain. Structured children
// must be settled before that wait, while the signal path still owns
// the shutdown order (#179).
shutdown_manager.shutdown().await;
}; };
axum::serve(listener, router) axum::serve(listener, router)
+37 -107
View File
@@ -1,53 +1,37 @@
//! First-start hook auto-install (#106). //! First-start hook auto-install (#106).
//! //!
//! The daemon runs [`auto_install`] once at startup: for every agent maestro //! The daemon runs [`auto_install`] once at startup: unless the user's
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook //! `~/.claude/settings.json` already carries exactly what this build ships
//! file already carries exactly what this build ships (and `[hooks] //! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
//! .auto_install` hasn't opted out — checked by the caller), the same //! same installer `maestro hooks install` uses writes it. That covers
//! installer `maestro hooks install` uses writes it. That covers upgrades as //! upgrades as well as first starts: an install from a maestro that had fewer
//! well as first starts: an install from a maestro that had fewer events — //! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's //! everyone's settings — is rewritten rather than left half-wired. Best-effort:
//! settings — is rewritten rather than left half-wired. Best-effort: a //! a failure is warned about and never blocks startup. The same global hook
//! failure is warned about and never blocks startup, and one target failing //! serves every Claude Code session on the machine, so nothing here targets
//! does not stop the others. The same global hook serves every session on the //! repositories or worktrees.
//! machine, so nothing here targets repositories or worktrees.
use std::path::Path; use std::path::Path;
use maestro_hooks::Target; /// Install the Claude Code activity hook for `home_dir` unless the current
/// one is already there or the home dir is unresolvable (`""`). Returns what
/// Install the activity hook for every [`Target`] under `home_dir`, skipping /// happened; an older install being brought up to date is
/// any whose install is already current. Returns what happened per target — /// [`AutoInstallOutcome::Upgraded`], so the log can say so.
/// an older install being brought up to date is pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
Target::ALL
.iter()
.map(|&target| (target, auto_install_target(target, home_dir)))
.collect()
}
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
if home_dir.is_empty() { if home_dir.is_empty() {
return AutoInstallOutcome::Skipped; return AutoInstallOutcome::Skipped;
} }
let home = Path::new(home_dir); let settings = maestro_hooks::settings_path(Path::new(home_dir));
let settings = target.settings_path(home); let script = maestro_hooks::script_path(Path::new(home_dir));
let script = maestro_hooks::script_path(home); if maestro_hooks::is_current(&settings, &script) {
if maestro_hooks::is_current(target, &settings, &script) {
return AutoInstallOutcome::AlreadyPresent; return AutoInstallOutcome::AlreadyPresent;
} }
let stale = maestro_hooks::is_installed(&settings); let stale = maestro_hooks::is_installed(&settings);
match maestro_hooks::install_target(target, home) { match maestro_hooks::install_for_home(Path::new(home_dir)) {
Ok(_) if stale => AutoInstallOutcome::Upgraded, Ok(_) if stale => AutoInstallOutcome::Upgraded,
Ok(_) => AutoInstallOutcome::Installed, Ok(_) => AutoInstallOutcome::Installed,
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
target = target.label(),
error = %e,
"hooks auto-install failed (non-fatal)"
);
AutoInstallOutcome::Failed AutoInstallOutcome::Failed
} }
} }
@@ -68,50 +52,15 @@ pub enum AutoInstallOutcome {
mod tests { mod tests {
use super::*; use super::*;
/// The outcome recorded for one target.
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
outcomes
.iter()
.find(|(t, _)| *t == target)
.map(|(_, o)| *o)
.expect("every target is reported")
}
/// Everything happens under a temp `$HOME`; the developer's own /// Everything happens under a temp `$HOME`; the developer's own
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or /// `~/.claude/settings.json` is never read or written.
/// written.
#[test] #[test]
fn installs_when_settings_carry_no_maestro_hook() { fn installs_when_settings_carry_no_maestro_hook() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let outcomes = auto_install(dir.path().to_str().unwrap()); let outcome = auto_install(dir.path().to_str().unwrap());
for target in Target::ALL { assert_eq!(outcome, AutoInstallOutcome::Installed);
assert_eq!( let settings = dir.path().join(".claude/settings.json");
outcome(&outcomes, target), assert!(maestro_hooks::is_installed(&settings));
AutoInstallOutcome::Installed,
"{}",
target.label()
);
assert!(maestro_hooks::is_installed(
&target.settings_path(dir.path())
));
}
}
/// Every agent maestro knows about is hooked, not just Claude Code: a
/// codex session reports activity and its first prompt the same way.
#[test]
fn codex_gets_its_own_hook_file() {
let dir = tempfile::tempdir().unwrap();
auto_install(dir.path().to_str().unwrap());
let hooks = dir.path().join(".codex/hooks.json");
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
let events = root["hooks"].as_object().unwrap();
assert!(events.contains_key("PermissionRequest"));
assert!(
!events.contains_key("Notification"),
"codex has no Notification"
);
} }
#[test] #[test]
@@ -120,22 +69,14 @@ mod tests {
let home = dir.path().to_str().unwrap(); let home = dir.path().to_str().unwrap();
auto_install(home); auto_install(home);
// A current install is left exactly as it is. // A current install is left exactly as it is.
let before: Vec<String> = Target::ALL let settings = dir.path().join(".claude/settings.json");
.iter() let before = std::fs::read_to_string(&settings).unwrap();
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap()) assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
.collect(); assert_eq!(
let outcomes = auto_install(home); before,
for (i, target) in Target::ALL.iter().enumerate() { std::fs::read_to_string(&settings).unwrap(),
assert_eq!( "already-installed settings must not be rewritten"
outcome(&outcomes, *target), );
AutoInstallOutcome::AlreadyPresent
);
assert_eq!(
before[i],
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
"already-installed settings must not be rewritten"
);
}
} }
/// An install from an older maestro — present, but missing an event this /// An install from an older maestro — present, but missing an event this
@@ -146,7 +87,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap(); let home = dir.path().to_str().unwrap();
auto_install(home); auto_install(home);
let settings = Target::ClaudeCode.settings_path(dir.path()); let settings = dir.path().join(".claude/settings.json");
let script = maestro_hooks::script_path(dir.path()); let script = maestro_hooks::script_path(dir.path());
let mut root: serde_json::Value = let mut root: serde_json::Value =
@@ -155,23 +96,12 @@ mod tests {
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap(); std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
let outcomes = auto_install(home); assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
assert_eq!( assert!(maestro_hooks::is_current(&settings, &script));
outcome(&outcomes, Target::ClaudeCode),
AutoInstallOutcome::Upgraded
);
assert!(maestro_hooks::is_current(
Target::ClaudeCode,
&settings,
&script
));
} }
#[test] #[test]
fn unresolvable_home_is_skipped() { fn unresolvable_home_is_skipped() {
let outcomes = auto_install(""); assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
for target in Target::ALL {
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
}
} }
} }
-1
View File
@@ -21,7 +21,6 @@ pub mod hooks;
pub mod local_api; pub mod local_api;
pub mod panes; pub mod panes;
pub mod proc; pub mod proc;
pub mod projection;
pub mod projects; pub mod projects;
pub mod pty; pub mod pty;
pub mod release; pub mod release;
-1
View File
@@ -170,7 +170,6 @@ fn session_error_status(code: &str) -> StatusCode {
// The session outlived its pane: nothing to type into, and nothing // The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it. // the daemon can do about it.
"not_running" => StatusCode::CONFLICT, "not_running" => StatusCode::CONFLICT,
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY, _ => StatusCode::BAD_GATEWAY,
} }
} }
-608
View File
@@ -1,608 +0,0 @@
//! Fold persisted ACP notifications into client-neutral transcript and state.
use std::collections::{HashMap, HashSet};
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
use maestro_protocol::{
ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, TranscriptSnapshot,
};
use serde_json::Value;
/// A transcript delta paired with the state after the same ACP notification.
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectionUpdate {
pub transcript: Option<TranscriptDelta>,
pub state: ReducedState,
}
/// A prompt lifecycle transition supplied by the prompt transport (#138).
///
/// ACP notifications do not delimit turns, so replaying them must not infer
/// an in-flight prompt from an agent chunk.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnLifecycle {
Begin,
Finish,
Cancel,
Failure,
}
/// The one-pass ACP fold used for both persisted replay and live events.
#[derive(Debug, Default)]
pub struct Projection {
rows: Vec<TranscriptRow>,
row_index: HashMap<String, usize>,
tools: HashMap<String, ToolCall>,
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
state: ReducedState,
turn_active: bool,
turn_tool_calls: HashSet<String>,
}
impl Projection {
/// Fold one durable event and return its client-visible changes.
pub fn apply(&mut self, seq: u64, notification: &SessionNotification) -> ProjectionUpdate {
let transcript = match &notification.update {
SessionUpdate::UserMessageChunk(chunk) => {
self.content(seq, chunk, TranscriptRowKind::User, "user")
}
SessionUpdate::AgentMessageChunk(chunk) => {
self.agent_text();
self.content(seq, chunk, TranscriptRowKind::AgentText, "agent")
}
SessionUpdate::AgentThoughtChunk(chunk) => {
self.agent_thought();
self.content(seq, chunk, TranscriptRowKind::AgentThought, "thought")
}
SessionUpdate::ToolCall(tool) => self.tool_call(tool),
SessionUpdate::ToolCallUpdate(update) => self.tool_update(update),
SessionUpdate::Plan(plan) => {
let data = to_value(plan);
self.state.plan = Some(data.clone());
Some(self.replace("plan".into(), TranscriptRowKind::Plan, data))
}
SessionUpdate::UsageUpdate(usage) => {
let value = to_value(usage);
self.state.usage = Some(value.clone());
self.state.context = Some(value);
None
}
SessionUpdate::CurrentModeUpdate(mode) => {
self.state.mode = Some(mode.current_mode_id.to_string());
None
}
SessionUpdate::ConfigOptionUpdate(options) => {
let value = to_value(options);
self.state.model = model_from_options(&value).or_else(|| self.state.model.clone());
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),
)),
};
ProjectionUpdate {
transcript,
state: self.state.clone(),
}
}
/// The current transcript at `cursor`.
pub fn snapshot(&self, cursor: u64) -> TranscriptSnapshot {
TranscriptSnapshot {
cursor,
rows: self.rows.clone(),
}
}
/// The state folded over every event seen so far.
pub fn state(&self) -> ReducedState {
self.state.clone()
}
/// Apply a transport-owned prompt lifecycle transition.
pub fn turn_lifecycle(&mut self, lifecycle: TurnLifecycle) -> ProjectionUpdate {
match lifecycle {
TurnLifecycle::Begin => {
self.turn_active = true;
self.state.turn.responding = true;
self.state.turn.thinking = true;
self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear();
}
TurnLifecycle::Finish | TurnLifecycle::Cancel | TurnLifecycle::Failure => {
self.turn_active = false;
self.state.turn.responding = false;
self.state.turn.thinking = false;
self.turn_tool_calls.clear();
self.state.turn.active_tool_calls.clear();
}
}
ProjectionUpdate {
transcript: None,
state: self.state(),
}
}
fn content(
&mut self,
seq: u64,
chunk: &ContentChunk,
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);
if let Some(index) = self.row_index.get(&id).copied() {
let row = &mut self.rows[index];
append_text(&mut row.data, &data);
return Some(TranscriptDelta::Patch { row: row.clone() });
}
Some(self.append(id, kind, data))
}
fn tool_call(&mut self, tool: &ToolCall) -> Option<TranscriptDelta> {
let id = format!("tool:{}", tool.tool_call_id);
let tool_id = tool.tool_call_id.to_string();
let mut canonical = tool.clone();
if let Some(updates) = self.pending_tool_updates.get(&tool_id) {
for fields in updates {
canonical.update(fields.clone());
}
}
let kind = if tool_has_diff(tool) {
TranscriptRowKind::ToolDiff
} else {
TranscriptRowKind::ToolCall
};
self.tools.insert(tool_id, canonical.clone());
self.track_tool_call(&canonical);
Some(self.replace(
id,
if tool_has_diff(&canonical) {
TranscriptRowKind::ToolDiff
} else {
kind
},
to_value(&canonical),
))
}
fn tool_update(
&mut self,
update: &maestro_acp::schema::ToolCallUpdate,
) -> Option<TranscriptDelta> {
let tool_id = update.tool_call_id.to_string();
let Some(tool) = self.tools.get_mut(&tool_id) else {
self.pending_tool_updates
.entry(tool_id)
.or_default()
.push(update.fields.clone());
return None;
};
tool.update(update.fields.clone());
self.pending_tool_updates
.entry(tool_id.clone())
.or_default()
.push(update.fields.clone());
let row_id = format!("tool:{tool_id}");
let index = *self
.row_index
.get(&row_id)
.expect("a tracked tool call always has a transcript row");
let row = {
let row = &mut self.rows[index];
row.kind = if tool_has_diff(tool) {
TranscriptRowKind::ToolDiff
} else {
TranscriptRowKind::ToolCall
};
row.data = to_value(tool);
row.clone()
};
self.sync_active_tool(&tool_id);
Some(TranscriptDelta::Patch { row })
}
fn append(&mut self, id: String, kind: TranscriptRowKind, data: Value) -> TranscriptDelta {
let row = TranscriptRow { id, kind, data };
self.row_index.insert(row.id.clone(), self.rows.len());
self.rows.push(row.clone());
TranscriptDelta::Append { row }
}
fn replace(&mut self, id: String, kind: TranscriptRowKind, data: Value) -> TranscriptDelta {
let Some(index) = self.row_index.get(&id).copied() else {
return self.append(id, kind, data);
};
let row = &mut self.rows[index];
row.kind = kind;
row.data = data;
TranscriptDelta::Patch { row: row.clone() }
}
fn agent_text(&mut self) {
if self.turn_active {
self.state.turn.responding = true;
self.state.turn.thinking = false;
}
}
fn agent_thought(&mut self) {
if self.turn_active {
self.state.turn.responding = true;
self.state.turn.thinking = true;
}
}
fn track_tool_call(&mut self, tool: &ToolCall) {
if self.turn_active {
self.turn_tool_calls.insert(tool.tool_call_id.to_string());
self.sync_active_tool(&tool.tool_call_id.to_string());
}
}
fn sync_active_tool(&mut self, tool_id: &str) {
if !self.turn_active || !self.turn_tool_calls.contains(tool_id) {
return;
}
let active = self.tools.get(tool_id).is_some_and(|tool| {
matches!(
tool.status,
maestro_acp::schema::ToolCallStatus::Pending
| maestro_acp::schema::ToolCallStatus::InProgress
)
});
let active_tool_calls = &mut self.state.turn.active_tool_calls;
if active {
if !active_tool_calls.iter().any(|id| id == tool_id) {
active_tool_calls.push(tool_id.to_owned());
active_tool_calls.sort();
}
} else {
active_tool_calls.retain(|id| id != tool_id);
}
}
}
fn to_value(value: &impl serde::Serialize) -> Value {
serde_json::to_value(value).expect("ACP schema values serialize")
}
fn tool_has_diff(tool: &ToolCall) -> bool {
tool.content
.iter()
.any(|content| matches!(content, maestro_acp::schema::ToolCallContent::Diff(_)))
}
fn append_text(existing: &mut Value, next: &Value) {
let Some(existing_text) = existing.pointer_mut("/content/text") else {
*existing = next.clone();
return;
};
let Some(next_text) = next.pointer("/content/text").and_then(Value::as_str) else {
*existing = next.clone();
return;
};
if let Some(text) = existing_text.as_str() {
*existing_text = Value::String(format!("{text}{next_text}"));
} else {
*existing = next.clone();
}
}
fn model_from_options(value: &Value) -> Option<String> {
value
.get("configOptions")?
.as_array()?
.iter()
.find(|option| {
option.get("id").and_then(Value::as_str) == Some("model")
|| option.get("category").and_then(Value::as_str) == Some("model")
})?
.get("currentValue")?
.as_str()
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use maestro_acp::schema::SessionNotification;
use maestro_protocol::{TranscriptDelta, TranscriptRowKind};
use super::{Projection, TurnLifecycle};
fn event(value: serde_json::Value) -> SessionNotification {
serde_json::from_value(serde_json::json!({
"sessionId": "acp-1",
"update": value,
}))
.unwrap()
}
#[test]
fn folds_content_tool_diff_plan_and_notice_rows() {
let mut projection = Projection::default();
let events = [
event(
serde_json::json!({"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}}),
),
event(
serde_json::json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}),
),
event(
serde_json::json!({"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"think"}}),
),
event(
serde_json::json!({"sessionUpdate":"tool_call","toolCallId":"read-1","title":"Read","kind":"read"}),
),
event(
serde_json::json!({"sessionUpdate":"tool_call","toolCallId":"edit-1","title":"Edit","content":[{"type":"diff","path":"x.rs","oldText":"a","newText":"b"}]}),
),
event(serde_json::json!({"sessionUpdate":"plan","entries":[]})),
event(
serde_json::json!({"sessionUpdate":"available_commands_update","availableCommands":[]}),
),
];
let kinds: Vec<_> = events
.iter()
.enumerate()
.filter_map(|(i, event)| projection.apply((i + 1) as u64, event).transcript)
.map(|delta| match delta {
TranscriptDelta::Append { row } => row.kind,
other => panic!("expected append, got {other:?}"),
})
.collect();
assert_eq!(
kinds,
[
TranscriptRowKind::User,
TranscriptRowKind::AgentText,
TranscriptRowKind::AgentThought,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolDiff,
TranscriptRowKind::Plan,
TranscriptRowKind::Notice,
]
);
}
#[test]
fn tool_update_patches_the_original_stable_row() {
let mut projection = Projection::default();
let first = projection.apply(
4,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"bash-7", "title":"Run"
})),
);
let updated = projection.apply(
5,
&event(serde_json::json!({
"sessionUpdate":"tool_call_update", "toolCallId":"bash-7", "status":"completed"
})),
);
let id = match first.transcript.unwrap() {
TranscriptDelta::Append { row } => row.id,
other => panic!("expected append, got {other:?}"),
};
match updated.transcript.unwrap() {
TranscriptDelta::Patch { row } => {
assert_eq!(row.id, id);
assert_eq!(row.data["status"], "completed");
}
other => panic!("expected patch, got {other:?}"),
}
assert_eq!(projection.snapshot(5).rows.len(), 1);
}
#[test]
fn duplicate_tool_calls_patch_one_stable_row() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"bash-7", "title":"Run"
})),
);
let duplicate = projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"bash-7", "title":"Run again"
})),
);
match duplicate.transcript.unwrap() {
TranscriptDelta::Patch { row } => {
assert_eq!(row.id, "tool:bash-7");
assert_eq!(row.data["title"], "Run again");
}
other => panic!("expected patch, got {other:?}"),
}
assert_eq!(projection.snapshot(2).rows.len(), 1);
}
#[test]
fn early_tool_update_is_reconciled_when_the_call_arrives() {
let mut projection = Projection::default();
let early = projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"tool_call_update", "toolCallId":"bash-7", "status":"completed"
})),
);
assert_eq!(early.transcript, None);
let call = projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"bash-7", "title":"Run", "status":"pending"
})),
);
match call.transcript.unwrap() {
TranscriptDelta::Append { row } => {
assert_eq!(row.id, "tool:bash-7");
assert_eq!(row.data["status"], "completed");
}
other => panic!("expected append, got {other:?}"),
}
assert!(projection.state().turn.active_tool_calls.is_empty());
}
#[test]
fn unknown_tool_stays_generic_and_keeps_its_raw_name() {
let mut projection = Projection::default();
let update = projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":"custom-1", "title":"Custom",
"name":"vendor.custom", "kind":"future_kind"
})),
);
match update.transcript.unwrap() {
TranscriptDelta::Append { row } => {
assert_eq!(row.kind, TranscriptRowKind::ToolCall);
assert_eq!(row.data["name"], "vendor.custom");
}
other => panic!("expected generic tool row, got {other:?}"),
}
}
#[test]
fn folds_usage_mode_model_and_plan_into_whole_state() {
let mut projection = Projection::default();
for (seq, update) in [
serde_json::json!({"sessionUpdate":"usage_update","used":12,"size":200}),
serde_json::json!({"sessionUpdate":"current_mode_update","currentModeId":"plan"}),
serde_json::json!({"sessionUpdate":"config_option_update","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"opus","options":[]}]}),
serde_json::json!({"sessionUpdate":"plan","entries":[]}),
]
.into_iter()
.enumerate()
{
projection.apply((seq + 1) as u64, &event(update));
}
let state = projection.state();
assert_eq!(state.usage.as_ref().unwrap()["used"], 12);
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.plan.as_ref().unwrap()["entries"],
serde_json::json!([])
);
}
#[test]
fn plans_replace_one_stable_row_and_state() {
let mut projection = Projection::default();
let first = projection.apply(
1,
&event(serde_json::json!({"sessionUpdate":"plan","entries":[{"content":"first","priority":"high","status":"pending"}]})),
);
assert!(matches!(
first.transcript,
Some(TranscriptDelta::Append { .. })
));
let replacement = projection.apply(
2,
&event(serde_json::json!({"sessionUpdate":"plan","entries":[{"content":"second","priority":"high","status":"completed"}]})),
);
match replacement.transcript.unwrap() {
TranscriptDelta::Patch { row } => {
assert_eq!(row.id, "plan");
assert_eq!(row.data["entries"][0]["content"], "second");
}
other => panic!("expected patch, got {other:?}"),
}
assert_eq!(projection.snapshot(2).rows.len(), 1);
assert_eq!(
projection.state().plan.unwrap()["entries"][0]["content"],
"second"
);
}
#[test]
fn turn_lifecycle_gates_chunks_and_normalizes_terminal_states() {
let mut projection = Projection::default();
projection.apply(
1,
&event(serde_json::json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"replay"}})),
);
assert_eq!(projection.state().turn, Default::default());
projection.turn_lifecycle(TurnLifecycle::Begin);
assert!(projection.state().turn.responding);
assert!(projection.state().turn.thinking);
projection.apply(
2,
&event(serde_json::json!({"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"think"}})),
);
assert!(projection.state().turn.thinking);
projection.apply(
3,
&event(serde_json::json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"answer"}})),
);
assert!(projection.state().turn.responding);
assert!(!projection.state().turn.thinking);
for terminal in [
TurnLifecycle::Finish,
TurnLifecycle::Cancel,
TurnLifecycle::Failure,
] {
projection.turn_lifecycle(TurnLifecycle::Begin);
projection.turn_lifecycle(terminal);
assert!(!projection.state().turn.responding);
assert!(!projection.state().turn.thinking);
}
}
fn terminal_turn_drops_unfinished_tools(
terminal: TurnLifecycle,
unfinished_id: &str,
next_id: &str,
) {
let mut projection = Projection::default();
projection.turn_lifecycle(TurnLifecycle::Begin);
projection.apply(
1,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":unfinished_id,
"title":"Unfinished", "status":"in_progress"
})),
);
assert_eq!(projection.state().turn.active_tool_calls, [unfinished_id]);
projection.turn_lifecycle(terminal);
assert!(projection.state().turn.active_tool_calls.is_empty());
assert_eq!(projection.snapshot(1).rows.len(), 1);
projection.turn_lifecycle(TurnLifecycle::Begin);
projection.apply(
2,
&event(serde_json::json!({
"sessionUpdate":"tool_call", "toolCallId":next_id,
"title":"Next", "status":"pending"
})),
);
assert_eq!(projection.state().turn.active_tool_calls, [next_id]);
}
#[test]
fn cancelled_turn_does_not_reactivate_unfinished_tools() {
terminal_turn_drops_unfinished_tools(TurnLifecycle::Cancel, "cancelled-1", "next-1");
}
#[test]
fn failed_turn_does_not_reactivate_unfinished_tools() {
terminal_turn_drops_unfinished_tools(TurnLifecycle::Failure, "failed-1", "next-1");
}
}
File diff suppressed because it is too large Load Diff
+6 -192
View File
@@ -5,22 +5,9 @@
//! original `cwd`, command, env, metadata, etc. Files are deleted on //! original `cwd`, command, env, metadata, etc. Files are deleted on
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still //! `KillSession`. Sessions whose sidecar is missing at recovery time are still
//! advertised but with empty metadata (handled by the caller, not here). //! advertised but with empty metadata (handled by the caller, not here).
//!
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
//! sidecar: every `session/update` notification its adapter sent, one JSON
//! object per line, appended before it is broadcast. The log is the source of
//! truth for the transcript; `seq` is the line's 1-based position, so a
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
//! that does not parse — including the partial line a hard kill leaves at
//! the end — keeps its seq and is skipped on read. `delete` removes the log
//! with the sidecar; `load_all` never reads one as a sidecar.
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use maestro_acp::schema::SessionNotification;
use maestro_protocol::Session; use maestro_protocol::Session;
use uuid::Uuid; use uuid::Uuid;
@@ -51,120 +38,6 @@ impl SidecarStore {
self.dir.join(format!("{id}.json")) self.dir.join(format!("{id}.json"))
} }
/// Where the per-session event log lives (#135).
pub fn events_path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.events.jsonl"))
}
/// Durable evidence that graceful daemon shutdown ended this structured
/// session and the next daemon may resume it.
pub fn recovery_path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.recover"))
}
/// Mark `id` as eligible for one structured-session recovery attempt.
pub fn mark_for_recovery(&self, id: Uuid) -> Result<(), SidecarError> {
std::fs::create_dir_all(&self.dir)?;
std::fs::write(self.recovery_path_for(id), [])?;
Ok(())
}
/// Consume the graceful-shutdown recovery marker for `id`.
pub fn take_recovery_marker(&self, id: Uuid) -> Result<bool, SidecarError> {
match std::fs::remove_file(self.recovery_path_for(id)) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
/// Open the event log for `id` for appending, creating it (and the
/// directory) when missing. One handle per live session: the caller
/// keeps it for the session's lifetime rather than reopening per
/// notification.
///
/// A log whose last line has no newline — what a hard kill mid-write
/// leaves behind — is terminated first, so the next append starts a
/// fresh line instead of gluing onto the fragment. The fragment keeps
/// the seq of its line and reads as malformed.
pub fn open_events(&self, id: Uuid) -> Result<EventLog, SidecarError> {
std::fs::create_dir_all(&self.dir)?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(self.events_path_for(id))?;
let mut lines = 0u64;
let mut last = None;
let mut reader = BufReader::new(&mut file);
let mut buf = [0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
lines += buf[..n].iter().filter(|&&b| b == b'\n').count() as u64;
last = Some(buf[n - 1]);
}
if last.is_some_and(|b| b != b'\n') {
file.write_all(b"\n")?;
lines += 1;
}
file.seek(SeekFrom::End(0))?;
Ok(EventLog {
file,
last_seq: lines,
})
}
/// Read events from the log for `id`, at most `limit`, in ascending seq
/// order. A missing log is an empty log. Lines that fail to parse are
/// skipped but still consume a seq, so cursors stay stable.
pub fn read_events(
&self,
id: Uuid,
cursor: EventCursor,
limit: usize,
) -> Result<Vec<LoggedEvent>, SidecarError> {
let file = match File::open(self.events_path_for(id)) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
if limit == 0 {
return Ok(Vec::new());
}
let mut out: VecDeque<LoggedEvent> = VecDeque::new();
let mut seq = 0u64;
for line in BufReader::new(file).split(b'\n') {
let line = line?;
seq += 1;
let keep = match cursor {
EventCursor::Since(since) => seq > since,
EventCursor::Before(Some(before)) => seq < before,
EventCursor::Before(None) => true,
};
if !keep {
if matches!(cursor, EventCursor::Before(Some(_))) {
break;
}
continue;
}
let Ok(notification) = serde_json::from_slice::<SessionNotification>(&line) else {
continue;
};
out.push_back(LoggedEvent { seq, notification });
match cursor {
EventCursor::Since(_) if out.len() >= limit => break,
EventCursor::Before(_) if out.len() > limit => {
out.pop_front();
}
_ => {}
}
}
Ok(out.into())
}
/// Atomically write `session` to `<dir>/<id>.json`. Creates the directory /// Atomically write `session` to `<dir>/<id>.json`. Creates the directory
/// if it does not exist. /// if it does not exist.
pub fn write(&self, session: &Session) -> Result<(), SidecarError> { pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
@@ -186,12 +59,13 @@ impl SidecarStore {
} }
} }
/// Delete the sidecar, event log, and recovery marker for `id`. No-op for /// Delete the sidecar for `id`. No-op if it does not exist.
/// whichever does not exist.
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> { pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
remove_if_present(&self.events_path_for(id))?; match std::fs::remove_file(self.path_for(id)) {
remove_if_present(&self.recovery_path_for(id))?; Ok(()) => Ok(()),
remove_if_present(&self.path_for(id)) Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
} }
/// Read every sidecar JSON in the directory. Files that fail to parse are /// Read every sidecar JSON in the directory. Files that fail to parse are
@@ -207,8 +81,6 @@ impl SidecarStore {
for entry in entries { for entry in entries {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
// `.events.jsonl` logs (#135) sit in the same directory and are
// never sidecars.
if path.extension().and_then(|s| s.to_str()) != Some("json") { if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue; continue;
} }
@@ -223,61 +95,3 @@ impl SidecarStore {
Ok(out) Ok(out)
} }
} }
fn remove_if_present(path: &Path) -> Result<(), SidecarError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Which slice of a session's event log to read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventCursor {
/// Forward: events with `seq` strictly greater than the cursor, the
/// first `limit` of them. `Since(0)` is the whole log from the start.
/// What a client catching up after a reconnect asks for.
Since(u64),
/// Backward: events with `seq` strictly less than the cursor, the last
/// `limit` of them, still returned in ascending order. `None` means the
/// tail of the log, for a first paint. The next page passes the smallest
/// seq it received.
Before(Option<u64>),
}
/// One line of a session's event log.
#[derive(Debug, Clone, PartialEq)]
pub struct LoggedEvent {
/// 1-based line position in the log; the cursor for [`EventCursor`].
pub seq: u64,
pub notification: SessionNotification,
}
/// 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,
last_seq: u64,
}
impl EventLog {
/// The seq of the last line, `0` for an empty log.
pub fn last_seq(&self) -> u64 {
self.last_seq
}
/// Append one notification as a line and return its seq. The line is
/// 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, notification: &SessionNotification) -> Result<u64, SidecarError> {
let mut line = serde_json::to_vec(notification)?;
line.push(b'\n');
self.file.write_all(&line)?;
self.file.flush()?;
self.last_seq += 1;
Ok(self.last_seq)
}
}
+12 -40
View File
@@ -111,33 +111,16 @@ impl WorktreeManager {
// Clear registrations whose directory is gone first, so a // Clear registrations whose directory is gone first, so a
// previously-leaked entry doesn't push this branch to a suffix. // previously-leaked entry doesn't push this branch to a suffix.
prune(base_repo)?; prune(base_repo)?;
let (branch, path) = if create_branch { // Anything occupying the target — a tree we left behind, one the
// Worktrees are commonly abandoned without deleting their branch, // operator made, an unrelated directory — is sidestepped with a
// so a random handle can collide with one of its own past runs. // numeric suffix on the directory; the branch stays whatever was
// Unlike the `add_existing` arm below, the suffix goes on the // requested (#77). The daemon never adopts what it finds.
// branch name itself — the path is derived from it anyway. let mut path = self.target_path(base_repo, branch);
let mut candidate = branch.to_string(); let mut n = 1;
let mut path = self.target_path(base_repo, &candidate); while path.symlink_metadata().is_ok() {
let mut n = 1; n += 1;
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) { path = self.target_path_suffixed(base_repo, branch, n);
n += 1; }
candidate = format!("{branch}-{n}");
path = self.target_path(base_repo, &candidate);
}
(candidate, path)
} else {
// Existing branch: the name must stay exact, so only the
// directory — anything occupying it, ours or the operator's — is
// sidestepped with a numeric suffix (#77). The daemon never
// adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
(branch.to_string(), path)
};
// The parent (`<repo>/worktrees` or the override root) may not exist // The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not. // yet; the target itself must not.
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -146,10 +129,10 @@ impl WorktreeManager {
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]); cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch { if create_branch {
cmd.arg("-b").arg(&branch).arg(&path); cmd.arg("-b").arg(branch).arg(&path);
} else { } else {
// Existing branch: `worktree add <path> <branch>` with no `-b`. // Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(&branch); cmd.arg(&path).arg(branch);
} }
let out = cmd.output()?; let out = cmd.output()?;
check(out)?; check(out)?;
@@ -283,17 +266,6 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
Some(candidate.to_path_buf()) Some(candidate.to_path_buf())
} }
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["show-ref", "--verify", "--quiet"])
.arg(format!("refs/heads/{branch}"))
.status()
.is_ok_and(|s| s.success())
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory /// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists. /// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> { fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
-804
View File
@@ -1,804 +0,0 @@
//! Structured sessions (#134): a `claude-code-acp` child per session instead
//! of a tmux pane. Runs against the fake adapter in
//! `crates/maestro-acp/tests/fake_acp_agent.py`; nothing here needs Claude.
mod common;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
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_daemon::projection::TurnLifecycle;
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{
ActivityState, CreateSessionRequest, SessionStatus, SessionView, TranscriptDelta,
TranscriptRowKind, WorktreeSpec,
};
use tempfile::TempDir;
fn fake_agent() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py")
}
/// The fake adapter installed as `claude-code-acp` in a temp `bin/`, with
/// the log file it appends to. `env()` is what a spawn request carries.
struct Fake {
dir: TempDir,
}
impl Fake {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
Self { dir }
}
fn log_path(&self) -> PathBuf {
self.dir.path().join("agent.log")
}
fn log(&self) -> String {
std::fs::read_to_string(self.log_path()).unwrap_or_default()
}
/// Fake `bin/` first so it shadows any real adapter, then the process
/// `PATH` so the script's `python3` still resolves.
fn env(&self) -> HashMap<String, String> {
let path = format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
HashMap::from([
("PATH".to_string(), path),
(
"FAKE_ACP_LOG".to_string(),
self.log_path().to_string_lossy().into_owned(),
),
])
}
/// The pid the fake logged at start, once it has.
fn pid(&self) -> u32 {
self.log()
.lines()
.filter_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
.next_back()
.expect("pid in log")
}
}
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
match &event.notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.to_path_buf(),
command: "claude".into(),
env,
metadata: HashMap::new(),
worktree: None,
}
}
fn pid_alive(pid: u32) -> bool {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|s| !s.contains(") Z "))
.unwrap_or(false)
}
async fn wait_for_exit(pid: u32) {
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "pid {pid} still alive");
}
#[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
assert_eq!(session.view, SessionView::Structured);
assert_eq!(session.status, SessionStatus::Running);
let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}");
// The child ran in the session cwd with the session's env — including
// the identity the daemon injects, so the agent knows which session it
// is (#103).
let log = fake.log();
let cwd_canon = cwd.path().canonicalize().unwrap();
assert!(
log.contains(&format!("cwd={}", cwd_canon.display())),
"{log}"
);
assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
"{log}"
);
// No tmux pane was created for it: the agent is the daemon's child.
let panes = fx.tmux.tmux.list_sessions().expect("list");
assert!(
!panes.contains(&session.id),
"structured session got a tmux pane"
);
// The sidecar carries the id a respawn will pass to session/load (#135).
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", session.id))).unwrap(),
)
.unwrap();
assert_eq!(sidecar.acp_session_id.as_deref(), Some(acp_id.as_str()));
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| !events.is_empty())
})
.await;
// Live delivery happens only after the notification is durable.
let (replay, mut updates) = fx
.manager
.subscribe_acp_events_with_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("replay read")
.expect("event subscription");
assert_eq!(replay.len(), 1);
assert!(updates.try_recv().is_err(), "replay duplicated onto live");
unsafe {
libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1);
}
let live = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("an update within 5s")
.expect("broadcast open");
assert_eq!(event_text(&live), "tick 1");
let stored = fx
.manager
.read_acp_events(session.id, EventCursor::Since(live.seq - 1), 1)
.expect("read event logged before broadcast");
assert_eq!(stored, vec![live]);
fx.manager.kill(session.id, false).await.expect("kill");
assert!(!fx
.sidecar_dir()
.join(format!("{}.events.jsonl", session.id))
.exists());
}
#[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
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 1)
})
.await;
// The cursor does not truncate either projection: a reconnect needs the
// source row for future patches and whole reduced state from the session.
let (snapshot, state, mut live) = fx
.manager
.subscribe_acp_projection(session.id, EventCursor::Since(1))
.await
.expect("projection subscription")
.expect("structured session");
assert_eq!(snapshot.cursor, 1);
assert_eq!(snapshot.rows.len(), 1);
assert_eq!(snapshot.rows[0].kind, TranscriptRowKind::AgentText);
assert_eq!(state.turn, Default::default());
assert!(fx
.manager
.apply_acp_turn_lifecycle(session.id, TurnLifecycle::Begin)
.await
.expect("begin lifecycle"));
let begin = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("begin lifecycle broadcast")
.expect("projection broadcast");
assert_eq!(begin.transcript, None);
assert!(begin.state.turn.responding);
assert!(begin.state.turn.thinking);
unsafe {
libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1);
}
let agent_update = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("live projection")
.expect("projection broadcast");
match agent_update.transcript {
Some(TranscriptDelta::Append { row }) => {
assert_eq!(row.kind, TranscriptRowKind::AgentText);
assert_eq!(row.data["content"]["text"], "tick 1");
}
other => panic!("expected one appended transcript row, got {other:?}"),
}
assert!(agent_update.state.turn.responding);
assert!(!agent_update.state.turn.thinking);
assert!(fx
.manager
.apply_acp_turn_lifecycle(session.id, TurnLifecycle::Finish)
.await
.expect("finish lifecycle"));
let finish = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("finish lifecycle broadcast")
.expect("projection broadcast");
assert_eq!(finish.transcript, None);
assert_eq!(finish.state.turn, Default::default());
fx.manager.kill(session.id, false).await.expect("kill");
}
#[tokio::test]
async fn recovery_loads_the_acp_session_without_duplicating_history() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 1)
})
.await;
unsafe {
libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1);
}
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 2)
})
.await;
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
assert!(
fx.sidecar_dir()
.join(format!("{}.recover", session.id))
.exists(),
"graceful shutdown must record recovery provenance"
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(
revived.status(session.id).await,
Some(SessionStatus::Running)
);
let log = fake.log();
assert!(
log.contains(&format!("recv session/load sessionId={acp_id}")),
"{log}"
);
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)
})
.await;
let (replay, mut live) = revived
.subscribe_acp_events_with_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("replay read")
.expect("recovered event subscription");
assert_eq!(replay.len(), 3);
assert!(live.try_recv().is_err(), "replay duplicated onto live");
let new_pid = fake.pid();
assert_ne!(new_pid, old_pid);
unsafe {
libc::kill(new_pid as libc::pid_t, libc::SIGUSR1);
}
let event = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("live event after recovery")
.expect("broadcast open");
assert_eq!(event.seq, 4);
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.iter().map(event_text).collect::<Vec<_>>(),
["hello from fake", "tick 1", "hello after load", "tick 1"]
);
revived.kill(session.id, false).await.expect("kill");
}
#[tokio::test]
async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
let branch = "feature/restart";
let worktree = repo
.path()
.join("worktrees")
.join("feature")
.join("restart");
let session = fx
.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
env: fake.env(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: branch.into(),
create_branch: true,
}),
})
.await
.expect("create");
assert_eq!(session.cwd, worktree);
push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await;
assert!(
worktree.is_dir(),
"shutdown must not settle a pristine worktree"
);
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", session.id))).unwrap(),
)
.unwrap();
assert_eq!(
sidecar.worktree.as_ref().map(|wt| &wt.path),
Some(&worktree)
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(
revived.status(session.id).await,
Some(SessionStatus::Running)
);
assert!(fake.log().contains("recv session/load"), "{}", fake.log());
assert!(worktree.is_dir(), "recovery must retain the worktree");
revived.kill(session.id, false).await.expect("dismiss");
assert!(
!worktree.exists(),
"operator dismissal still cleans a pristine tree"
);
}
#[tokio::test]
async fn failed_recovery_cleans_its_fifo() {
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 old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
let events_path = fx
.sidecar_dir()
.join(format!("{}.events.jsonl", session.id));
std::fs::remove_file(&events_path).unwrap();
std::fs::create_dir(&events_path).unwrap();
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert!(revived.recover().await.is_err());
assert!(!fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
let failed_pid = fake.pid();
assert_ne!(failed_pid, old_pid);
wait_for_exit(failed_pid).await;
}
#[tokio::test]
async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
let fx = DaemonFixture::new();
let empty = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let env = HashMap::from([(
"PATH".to_string(),
empty.path().to_string_lossy().into_owned(),
)]);
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("a failed session, not an error");
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_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
.sidecar_dir()
.join(format!("{}.json", session.id))
.exists());
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
}
#[tokio::test]
async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut events = fx.manager.events();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let pid = fake.pid();
assert!(pid_alive(pid));
fx.manager.kill(session.id, false).await.expect("kill");
wait_for_exit(pid).await;
let log = fake.log();
let cancel = log.find("recv session/cancel").expect("cancel logged");
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
assert!(!fx.manager.has(session.id).await);
assert!(!fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.exists());
let mut removed = false;
while let Ok(ev) = events.try_recv() {
if matches!(ev, maestro_protocol::CatalogEvent::SessionRemoved { id } if id == session.id) {
removed = true;
}
}
assert!(removed, "SessionRemoved never broadcast");
}
#[tokio::test]
async fn shutdown_reaps_every_adapter_child() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let a = Fake::new();
let b = Fake::new();
let sa = fx
.manager
.create(structured(cwd.path(), a.env()))
.await
.expect("create a");
let sb = fx
.manager
.create(structured(cwd.path(), b.env()))
.await
.expect("create b");
let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb));
fx.manager.shutdown().await;
wait_for_exit(pa).await;
wait_for_exit(pb).await;
assert!(a.log().contains("signal SIGTERM"), "{}", a.log());
assert!(b.log().contains("signal SIGTERM"), "{}", b.log());
// Still listed, now Dead: the sidecar is truthful for whoever reads it
// after the restart (#135).
assert_eq!(fx.manager.status(sa.id).await, Some(SessionStatus::Dead));
assert_eq!(fx.manager.status(sb.id).await, Some(SessionStatus::Dead));
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", sa.id))).unwrap(),
)
.unwrap();
assert_eq!(sidecar.status, SessionStatus::Dead);
}
#[tokio::test]
async fn dismissal_during_shutdown_does_not_restore_recovery_artifacts() {
let fx = DaemonFixture::new();
let dismissed = Fake::new();
let blocker = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = dismissed.env();
env.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
fx.manager
.create(structured(cwd.path(), blocker.env()))
.await
.expect("create blocker");
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
wait_for_async(Duration::from_secs(5), || async {
dismissed.log().contains("recv session/cancel")
})
.await;
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(
!fx.sidecar_dir()
.join(format!("{}.json", session.id))
.exists(),
"dismissal must finish before shutdown settles its stale snapshot"
);
shutdown.await.expect("shutdown task");
assert!(!fx.manager.has(session.id).await);
assert!(
!fx.sidecar_dir()
.join(format!("{}.recover", session.id))
.exists(),
"dismissal must leave no recovery marker"
);
assert!(
!fx.sidecar_dir()
.join(format!("{}.json", session.id))
.exists(),
"dismissal must leave no sidecar"
);
assert!(
!fx.sidecar_dir()
.join(format!("{}.events.jsonl", session.id))
.exists(),
"dismissal must leave no event log"
);
}
#[tokio::test]
async fn shutdown_does_not_recover_an_adapter_that_already_exited() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_exit(fake.pid()).await;
fx.manager.shutdown().await;
assert!(
!fx.sidecar_dir()
.join(format!("{}.recover", session.id))
.exists(),
"a naturally exited adapter is not recoverable"
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(revived.status(session.id).await, Some(SessionStatus::Dead));
assert!(
!fake.log().contains("recv session/load"),
"a naturally dead adapter must not be resumed: {}",
fake.log()
);
}
#[tokio::test]
async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
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 });
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
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"
);
let session = create.await.expect("create task").expect("create");
let pid = fake.pid();
shutdown.await.expect("shutdown task");
wait_for_exit(pid).await;
assert!(fake.log().contains("signal SIGTERM"), "{}", fake.log());
assert_eq!(
fx.manager.status(session.id).await,
Some(SessionStatus::Dead)
);
assert!(matches!(
fx.manager.create(structured(cwd.path(), fake.env())).await,
Err(maestro_daemon::SessionError::ShuttingDown)
));
// A repeated signal must not attempt to terminate the child again.
fx.manager.shutdown().await;
assert_eq!(fake.log().matches("signal SIGTERM").count(), 1);
}
#[tokio::test]
async fn shutdown_drains_final_notifications_to_the_log() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_EMIT_ON_CANCEL".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 1)
})
.await;
fx.manager.shutdown().await;
let stored = fx
.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.expect("event log after shutdown");
assert_eq!(
stored.iter().map(event_text).collect::<Vec<_>>(),
["hello from fake", "final on cancel"]
);
}
#[tokio::test]
async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
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");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux,
// is what notices for a structured session.
let pid = fake.pid();
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(fx.manager.has(session.id).await);
assert!(
!fx.sidecar_dir()
.join(format!("{}.recover", session.id))
.exists(),
"a naturally exited adapter is never marked for recovery"
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(revived.status(session.id).await, Some(SessionStatus::Dead));
assert!(
!fake.log().contains("recv session/load"),
"a naturally dead adapter must not be resumed: {}",
fake.log()
);
revived.kill(session.id, false).await.expect("dismiss");
assert!(!revived.has(session.id).await);
}
@@ -42,7 +42,6 @@ async fn fixture(ttl: Duration) -> Fixture {
async fn spawn_sleeping(manager: &SessionManager) -> Uuid { async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
manager manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: Default::default(), env: Default::default(),
-1
View File
@@ -75,7 +75,6 @@ async fn create_bash_session(h: &Harness) -> Uuid {
let s = h let s = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd_path, cwd: cwd_path,
command: "bash --noprofile --norc".into(), command: "bash --noprofile --norc".into(),
env: HashMap::new(), env: HashMap::new(),
-1
View File
@@ -50,7 +50,6 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
}; };
manager manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: Default::default(), env: Default::default(),
@@ -19,7 +19,6 @@ use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest { fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
-10
View File
@@ -174,7 +174,6 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
assert!(empty.is_empty()); assert!(empty.is_empty());
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -391,7 +390,6 @@ async fn patch_session_renames_it() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -434,7 +432,6 @@ async fn post_focus_stamps_last_focused_at() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -468,7 +465,6 @@ async fn delete_owned_session_kills_it() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -490,7 +486,6 @@ async fn delete_owned_session_kills_it() {
async fn spawn_via_manager(h: &Harness) -> Uuid { async fn spawn_via_manager(h: &Harness) -> Uuid {
h.manager h.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -600,7 +595,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -653,7 +647,6 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -687,7 +680,6 @@ async fn attach_ws_streams_pty_bytes() {
let session = h let session = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(), command: "bash --noprofile --norc".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -756,7 +748,6 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
let created = h let created = h
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -801,7 +792,6 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
h.manager h.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
-1
View File
@@ -51,7 +51,6 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
}; };
manager manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
command: "sleep 60".into(), command: "sleep 60".into(),
env: Default::default(), env: Default::default(),
@@ -64,7 +64,6 @@ impl Fx {
fn request(&self, command: &str) -> CreateSessionRequest { fn request(&self, command: &str) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: self.tmp.path().to_path_buf(), cwd: self.tmp.path().to_path_buf(),
command: command.into(), command: command.into(),
env: HashMap::new(), env: HashMap::new(),
+134 -26
View File
@@ -22,7 +22,6 @@ async fn create_then_kill_round_trip() {
let session = fx let session = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -64,7 +63,6 @@ async fn create_appends_to_spawn_history() {
let session = fx let session = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -94,7 +92,6 @@ async fn create_without_history_logs_nothing() {
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
fx.manager fx.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -117,7 +114,6 @@ async fn recover_adopts_existing_tmux_sessions() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -144,6 +140,140 @@ async fn recover_adopts_existing_tmux_sessions() {
assert_eq!(snap[0].command, "sleep 30"); assert_eq!(snap[0].command, "sleep 30");
} }
/// #162: a host reboot kills the tmux server, so recovery finds nothing to
/// adopt while every sidecar is still on disk. Those sessions come back as
/// `Dormant` — a record of what was running — rather than vanishing from the
/// API and leaving the operator with nothing.
#[tokio::test]
async fn recover_registers_orphaned_sidecars_as_dormant() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let mut metadata = HashMap::new();
metadata.insert(
maestro_protocol::NAME_KEY.to_string(),
serde_json::Value::String("after-reboot".into()),
);
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata,
worktree: None,
})
.await
.expect("create");
// The reboot: the tmux server is gone, the sidecar is untouched.
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx);
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 0, "no live tmux session left to adopt");
let snap = revived.snapshot().await;
assert_eq!(snap.len(), 1, "the sidecar's session is still listed");
assert_eq!(snap[0].id, s.id);
assert_eq!(snap[0].status, SessionStatus::Dormant);
assert_eq!(snap[0].command, "sleep 30");
assert_eq!(snap[0].cwd, s.cwd);
assert_eq!(snap[0].created_at, s.created_at);
assert_eq!(
snap[0]
.metadata
.get(maestro_protocol::NAME_KEY)
.and_then(|v| v.as_str()),
Some("after-reboot"),
);
}
/// #162: a session tmux still holds is adopted `Running` by the first pass and
/// must not be re-registered as dormant by the second — the sidecar is on disk
/// for both.
#[tokio::test]
async fn recover_does_not_mark_adopted_sessions_dormant() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let live = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create live");
let orphan = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 31".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create orphan");
// Only the second session loses its pane.
fx.tmux.tmux.kill_session(orphan.id).expect("kill session");
let revived = revive(&fx);
assert_eq!(revived.recover().await.expect("recover"), 1);
let mut snap = revived.snapshot().await;
snap.sort_by_key(|s| s.command.clone());
assert_eq!(snap.len(), 2);
assert_eq!(snap[0].id, live.id);
assert_eq!(snap[0].status, SessionStatus::Running);
assert_eq!(snap[1].id, orphan.id);
assert_eq!(snap[1].status, SessionStatus::Dormant);
}
/// #162: dismissing a dormant session is the one thing that can be done to it
/// — no tmux call to fail, and the sidecar goes with it so the next recovery
/// does not resurrect it.
#[tokio::test]
async fn dormant_session_can_be_dismissed() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx);
revived.recover().await.expect("recover");
revived.kill(s.id, false).await.expect("dismiss");
assert!(!revived.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
}
/// A second `SessionManager` over the same dirs and the same tmux server —
/// what a daemon restart looks like from inside a test.
fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager {
maestro_daemon::SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
maestro_daemon::worktree::WorktreeManager::new(None),
fx.fifo_dir(),
)
}
/// Regression for NAP-253: the very first bytes a freshly-spawned command /// Regression for NAP-253: the very first bytes a freshly-spawned command
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a /// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
/// separate tmux invocation that ran after `new-session` + an async FIFO /// separate tmux invocation that ran after `new-session` + an async FIFO
@@ -157,7 +287,6 @@ async fn create_captures_initial_output_in_replay_buffer() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
// The shell prints the marker before doing anything else and // The shell prints the marker before doing anything else and
// then sleeps so the session stays alive. Before NAP-253 this // then sleeps so the session stays alive. Before NAP-253 this
@@ -205,7 +334,6 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
// Print a line, then switch to the alternate screen the way a // Print a line, then switch to the alternate screen the way a
// full-screen program does on startup. Only the second marker is // full-screen program does on startup. Only the second marker is
@@ -264,7 +392,6 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
// Alternate screen, then button-event tracking (?1002) with SGR // Alternate screen, then button-event tracking (?1002) with SGR
// coordinates (?1006) — what tmux and agent TUIs actually request. // coordinates (?1006) — what tmux and agent TUIs actually request.
@@ -318,7 +445,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(), command: "bash --noprofile --norc".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -381,7 +507,6 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(), command: "bash --noprofile --norc".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -432,7 +557,6 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -498,7 +622,6 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
fx.manager fx.manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -591,7 +714,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -629,7 +751,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -693,7 +814,6 @@ async fn killing_refuses_until_the_branch_is_pushed() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -743,7 +863,6 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -789,7 +908,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -830,7 +948,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -872,7 +989,6 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -934,7 +1050,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -995,7 +1110,6 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1030,7 +1144,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let first = fx let first = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1048,7 +1161,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let second = fx let second = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: inner_worktree.clone(), cwd: inner_worktree.clone(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1087,7 +1199,6 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1126,7 +1237,6 @@ async fn a_freshly_created_session_has_never_been_focused() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1152,7 +1262,6 @@ async fn set_focused_true_stamps_last_focused_at() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1201,7 +1310,6 @@ async fn attaching_stamps_last_focused_at() {
let s = fx let s = fx
.manager .manager
.create(CreateSessionRequest { .create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
+1 -235
View File
@@ -7,7 +7,7 @@ use std::path::PathBuf;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use maestro_daemon::sidecar::SidecarStore; use maestro_daemon::sidecar::SidecarStore;
use maestro_protocol::{Session, SessionStatus, SessionView}; use maestro_protocol::{Session, SessionStatus};
use serde_json::Value; use serde_json::Value;
use tempfile::TempDir; use tempfile::TempDir;
use uuid::Uuid; use uuid::Uuid;
@@ -32,8 +32,6 @@ fn sample(id: Uuid) -> Session {
current_cwd: Some(PathBuf::from("/tmp/work/crates")), current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
view: SessionView::Terminal,
} }
} }
@@ -68,30 +66,15 @@ fn delete_removes_file_and_is_idempotent() {
let s = sample(id); let s = sample(id);
store.write(&s).unwrap(); store.write(&s).unwrap();
store.mark_for_recovery(id).unwrap();
assert!(store.path_for(id).exists()); assert!(store.path_for(id).exists());
assert!(store.recovery_path_for(id).exists());
store.delete(id).unwrap(); store.delete(id).unwrap();
assert!(!store.path_for(id).exists()); assert!(!store.path_for(id).exists());
assert!(!store.recovery_path_for(id).exists());
// second delete is a no-op // second delete is a no-op
store.delete(id).unwrap(); store.delete(id).unwrap();
} }
#[test]
fn recovery_marker_is_consumed_once() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
assert!(!store.take_recovery_marker(id).unwrap());
store.mark_for_recovery(id).unwrap();
assert!(store.take_recovery_marker(id).unwrap());
assert!(!store.take_recovery_marker(id).unwrap());
}
#[test] #[test]
fn load_all_recovers_every_sidecar_and_skips_garbage() { fn load_all_recovers_every_sidecar_and_skips_garbage() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -120,37 +103,6 @@ fn load_all_returns_empty_when_directory_missing() {
assert!(store.load_all().unwrap().is_empty()); assert!(store.load_all().unwrap().is_empty());
} }
#[test]
fn sidecar_missing_view_key_reads_as_terminal() {
// A pre-#133 sidecar on disk has no `view` field at all.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut value = serde_json::to_value(sample(id)).unwrap();
value.as_object_mut().unwrap().remove("view");
std::fs::write(store.path_for(id), serde_json::to_vec(&value).unwrap()).unwrap();
let loaded = store.read(id).expect("read").expect("present");
assert_eq!(loaded.view, SessionView::Terminal);
}
#[test]
fn sidecar_structured_view_round_trips() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut s = sample(id);
s.view = SessionView::Structured;
store.write(&s).expect("write");
let loaded = store.read(id).expect("read");
assert_eq!(
loaded.as_ref().map(|s| s.view),
Some(SessionView::Structured)
);
}
#[test] #[test]
fn write_creates_parent_directory() { fn write_creates_parent_directory() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -161,189 +113,3 @@ fn write_creates_parent_directory() {
assert!(nested.exists()); assert!(nested.exists());
assert!(store.path_for(s.id).exists()); assert!(store.path_for(s.id).exists());
} }
// ---- per-session event log (#135) ----
use maestro_acp::schema::{ContentBlock, SessionNotification, SessionUpdate, TextContent};
use maestro_daemon::sidecar::EventCursor;
fn note(text: &str) -> SessionNotification {
SessionNotification::new(
"acp-1",
SessionUpdate::AgentMessageChunk(maestro_acp::schema::ContentChunk::new(
ContentBlock::Text(TextContent::new(text)),
)),
)
}
fn text_of(n: &SessionNotification) -> &str {
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => &t.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
#[test]
fn append_assigns_line_position_as_seq_and_reads_back() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).expect("open");
assert_eq!(log.last_seq(), 0);
assert_eq!(log.append(&note("a")).unwrap(), 1);
assert_eq!(log.append(&note("b")).unwrap(), 2);
assert_eq!(log.append(&note("c")).unwrap(), 3);
assert_eq!(log.last_seq(), 3);
let path = store.events_path_for(id);
assert!(path.ends_with(format!("{id}.events.jsonl")), "{path:?}");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3, "one object per line:\n{raw}");
for line in raw.lines() {
let v: Value = serde_json::from_str(line).expect("each line is one JSON object");
assert_eq!(v["sessionId"], "acp-1");
}
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
assert_eq!(
all.iter()
.map(|e| text_of(&e.notification))
.collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
}
#[test]
fn since_reads_forward_and_before_reads_backward_with_limit() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
for i in 1..=6 {
log.append(&note(&format!("n{i}"))).unwrap();
}
// `since` is exclusive: what a client that already holds seq 2 needs.
let fwd = store.read_events(id, EventCursor::Since(2), 3).unwrap();
assert_eq!(fwd.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4, 5]);
// Past the end: nothing, not an error.
assert!(store
.read_events(id, EventCursor::Since(6), 3)
.unwrap()
.is_empty());
// `before` is exclusive and pages backward: the `limit` events closest
// to the cursor, still in ascending seq order.
let back = store
.read_events(id, EventCursor::Before(Some(5)), 2)
.unwrap();
assert_eq!(back.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4]);
// No cursor: the tail of the log, for a first paint.
let tail = store.read_events(id, EventCursor::Before(None), 2).unwrap();
assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![5, 6]);
// Paging past the start runs out cleanly.
let start = store
.read_events(id, EventCursor::Before(Some(2)), 10)
.unwrap();
assert_eq!(start.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
assert!(store
.read_events(id, EventCursor::Before(Some(1)), 10)
.unwrap()
.is_empty());
}
#[test]
fn missing_log_reads_as_empty() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
assert!(store
.read_events(id, EventCursor::Since(0), 10)
.unwrap()
.is_empty());
assert!(store
.read_events(id, EventCursor::Before(None), 10)
.unwrap()
.is_empty());
}
#[test]
fn malformed_lines_are_skipped_but_keep_their_seq() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
log.append(&note("first")).unwrap();
drop(log);
// Garbage in the middle and a line that is JSON but not a notification.
let path = store.events_path_for(id);
let mut raw = std::fs::read_to_string(&path).unwrap();
raw.push_str("{not json\n");
raw.push_str("{\"unrelated\": true}\n");
std::fs::write(&path, raw).unwrap();
let mut log = store.open_events(id).unwrap();
assert_eq!(log.last_seq(), 3);
assert_eq!(log.append(&note("fourth")).unwrap(), 4);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
assert_eq!(text_of(&all[1].notification), "fourth");
}
#[test]
fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
// A hard kill mid-write leaves a partial line with no newline.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
log.append(&note("whole")).unwrap();
drop(log);
let path = store.events_path_for(id);
let mut raw = std::fs::read(&path).unwrap();
raw.extend_from_slice(br#"{"sessionId":"acp-1","update":{"sessionUpd"#);
std::fs::write(&path, &raw).unwrap();
// Reads: the partial line is skipped, nothing else is lost.
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
// Reopening terminates the partial line so the next append does not
// glue onto it; the partial keeps a seq of its own.
let mut log = store.open_events(id).unwrap();
assert_eq!(log.last_seq(), 2);
assert_eq!(log.append(&note("after")).unwrap(), 3);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
assert_eq!(text_of(&all[1].notification), "after");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3, "{raw}");
}
#[test]
fn delete_removes_the_event_log_and_load_all_ignores_it() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
store.write(&sample(id)).unwrap();
let mut log = store.open_events(id).unwrap();
log.append(&note("x")).unwrap();
drop(log);
assert!(store.events_path_for(id).exists());
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1, "the .jsonl must not be read as a sidecar");
store.delete(id).unwrap();
assert!(!store.path_for(id).exists());
assert!(!store.events_path_for(id).exists());
// Idempotent with the log already gone.
store.delete(id).unwrap();
}
+4 -6
View File
@@ -405,10 +405,8 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
); );
} }
/// 49ab7e9: a new-branch add onto a name that already exists suffixes the
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md.
#[test] #[test]
fn add_new_suffixes_when_branch_already_exists() { fn add_fails_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap(); let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf(); let repo = repo_dir.path().to_path_buf();
init_repo(&repo); init_repo(&repo);
@@ -425,9 +423,9 @@ fn add_new_suffixes_when_branch_already_exists() {
let wt_root = TempDir::new().unwrap(); let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/dup").unwrap(); let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
assert!(path.ends_with("feat/dup-2"), "{}", path.display()); let msg = format!("{err}");
assert!(path.is_dir()); assert!(msg.contains("git exited"), "{msg}");
} }
/// The project picker lists linked worktrees as pickable rows, so `base_repo` /// The project picker lists linked worktrees as pickable rows, so `base_repo`
-1
View File
@@ -13,7 +13,6 @@ path = "src/lib.rs"
[dependencies] [dependencies]
chrono.workspace = true chrono.workspace = true
futures-util.workspace = true futures-util.workspace = true
libc.workspace = true
maestro-protocol = { workspace = true, features = ["client"] } maestro-protocol = { workspace = true, features = ["client"] }
maestro-testing.workspace = true maestro-testing.workspace = true
serde.workspace = true serde.workspace = true
-8
View File
@@ -87,14 +87,6 @@ impl Daemon {
} }
} }
/// Send the daemon the same SIGTERM systemd uses, without waiting for its
/// graceful HTTP shutdown to finish.
pub fn send_sigterm(&self) {
let child = self.child.as_ref().expect("daemon is running");
let result = unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGTERM) };
assert_eq!(result, 0, "send SIGTERM to daemon");
}
/// Re-spawn the daemon on the same port + state. The previous tmux /// Re-spawn the daemon on the same port + state. The previous tmux
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt /// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
/// its sessions. /// its sessions.
+4 -180
View File
@@ -15,7 +15,7 @@ use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use maestro_e2e::util::wait_for_async; use maestro_e2e::util::wait_for_async;
use maestro_e2e::{Daemon, TestClient}; use maestro_e2e::{Daemon, TestClient};
use maestro_protocol::{CreateSessionRequest, SessionStatus, SessionView, WorktreeSpec}; use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use tempfile::TempDir; use tempfile::TempDir;
use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -27,7 +27,6 @@ async fn bring_up() -> (Daemon, TestClient) {
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest { fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
command: command.into(), command: command.into(),
env: HashMap::new(), env: HashMap::new(),
@@ -36,61 +35,6 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
} }
} }
struct FakeAcp {
_dir: TempDir,
env: HashMap<String, String>,
}
fn fake_acp(log: &std::path::Path) -> FakeAcp {
let dir = tempfile::tempdir().expect("fake adapter dir");
let adapter = dir.path().join("claude-code-acp");
std::fs::copy(
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../maestro-acp/tests/fake_acp_agent.py"
),
&adapter,
)
.expect("copy fake adapter");
let mut permissions = std::fs::metadata(&adapter)
.expect("adapter metadata")
.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&adapter, permissions).expect("make fake adapter executable");
let env = HashMap::from([
(
"PATH".into(),
format!(
"{}:{}",
dir.path().display(),
std::env::var("PATH").unwrap_or_default()
),
),
("FAKE_ACP_LOG".into(), log.to_string_lossy().into_owned()),
]);
FakeAcp { _dir: dir, env }
}
fn fake_acp_pid(log: &std::path::Path) -> Option<u32> {
std::fs::read_to_string(log).ok()?.lines().find_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
}
fn pid_alive(pid: u32) -> bool {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|stat| !stat.contains(") Z "))
.unwrap_or(false)
}
/// A worktree spec built through its wire shape. The daemon and clients ship /// A worktree spec built through its wire shape. The daemon and clients ship
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON /// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
/// object carries only the fields the lifecycle actually honours. /// object carries only the fields the lifecycle actually honours.
@@ -105,7 +49,6 @@ fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool)
/// Spawn request carrying a worktree spec against `base`. /// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest { fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: base.to_path_buf(), cwd: base.to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -200,7 +143,6 @@ async fn kill_removes_clean_worktree() {
init_repo(base.path()); init_repo(base.path());
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(), cwd: base.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -254,7 +196,6 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
.join("unsaved"); .join("unsaved");
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(), cwd: base.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -313,7 +254,6 @@ async fn spawn_on_existing_branch_checks_it_out() {
run_git(&["branch", "feature/existing"], base.path()); run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(), cwd: base.path().to_path_buf(),
command: "sleep 30".into(), command: "sleep 30".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -383,11 +323,8 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
client.kill_session(s.id).await.expect("kill"); client.kill_session(s.id).await.expect("kill");
} }
/// #66: a second spawn onto an *existing* branch that already has a worktree /// #66: a second spawn onto a branch that already has a worktree is rejected
/// is rejected with a structured worktree error; the first worktree stays /// with a structured worktree error; the first worktree stays intact.
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9
/// suffixes the new branch name instead, see
/// `colliding_branch_name_gets_suffixed_too`.)
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() { async fn second_spawn_on_same_branch_is_rejected() {
let (_daemon, client) = bring_up().await; let (_daemon, client) = bring_up().await;
@@ -400,7 +337,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
.await .await
.expect("first spawn"); .expect("first spawn");
let dup = client let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", false)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await; .await;
match dup { match dup {
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => { Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
@@ -560,57 +497,6 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
); );
} }
/// A random handle can reuse the name of a branch left behind by an earlier,
/// already-removed worktree — `git worktree add -b` refuses outright in that
/// case, even though the directory is free. The daemon suffixes the branch
/// itself (not just the directory) and both land on `-2`.
#[tokio::test(flavor = "multi_thread")]
async fn colliding_branch_name_gets_suffixed_too() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// No worktree directory in the way, but the branch already exists.
run_git(&["branch", "handle"], base.path());
let s = client
.spawn_session(&worktree_req(base.path(), "handle", true))
.await
.expect("spawn onto a taken branch name");
let suffixed = base.path().join("worktrees").join("handle-2");
assert_eq!(
s.cwd, suffixed,
"the directory must follow the suffixed branch name"
);
assert!(suffixed.is_dir());
let registrations = git_worktree_list(base.path());
assert!(
registrations.iter().any(|l| l.contains("handle-2")),
"the -2 branch's worktree must be registered: {registrations:?}"
);
let branches = String::from_utf8(
std::process::Command::new("git")
.arg("-C")
.arg(base.path())
.args(["branch", "--list"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
branches.contains("handle-2"),
"the branch itself must be named handle-2: {branches}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
/// #77/#88: the target path holds a plain directory the daemon never made and /// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds /// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone. /// beside it and leaves every byte of it alone.
@@ -817,7 +703,6 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let _dirty = client let _dirty = client
.spawn_session(&CreateSessionRequest { .spawn_session(&CreateSessionRequest {
view: Default::default(),
cwd: dirty_repo.path().to_path_buf(), cwd: dirty_repo.path().to_path_buf(),
command: "sh -c 'echo unsaved > scratch.txt'".into(), command: "sh -c 'echo unsaved > scratch.txt'".into(),
env: HashMap::new(), env: HashMap::new(),
@@ -866,7 +751,6 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest { fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
CreateSessionRequest { CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
command: command.into(), command: command.into(),
env: HashMap::new(), env: HashMap::new(),
@@ -1050,66 +934,6 @@ async fn daemon_restart_recovers_session() {
client.kill_session(s.id).await.expect("kill"); client.kill_session(s.id).await.expect("kill");
} }
#[tokio::test(flavor = "multi_thread")]
async fn sigterm_stops_structured_child_before_events_ws_drains() {
let (mut daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let log = cwd.path().join("fake-acp.log");
let fake = fake_acp(&log);
let session = client
.spawn_session(&CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.path().to_path_buf(),
command: "claude".into(),
env: fake.env.clone(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("spawn structured session");
let ws_url = daemon.client().events_url().expect("events URL");
let (_events_ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.expect("events WS handshake");
wait_for_async("fake ACP starts", Duration::from_secs(5), || {
let log = log.clone();
async move { fake_acp_pid(&log).is_some() }
})
.await;
let pid = fake_acp_pid(&log).expect("fake ACP pid");
daemon.send_sigterm();
wait_for_async(
"fake ACP exits after SIGTERM",
Duration::from_secs(5),
|| async { !pid_alive(pid) },
)
.await;
let sidecar_path = daemon
.state_dir()
.join("sessions")
.join(format!("{}.json", session.id));
wait_for_async(
"dead structured session sidecar",
Duration::from_secs(5),
|| {
let sidecar_path = sidecar_path.clone();
async move {
std::fs::read(&sidecar_path)
.ok()
.and_then(|json| {
serde_json::from_slice::<maestro_protocol::Session>(&json).ok()
})
.is_some_and(|saved| saved.status == SessionStatus::Dead)
}
},
)
.await;
daemon.stop();
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn concurrent_spawns_each_get_unique_ids() { async fn concurrent_spawns_each_get_unique_ids() {
let (_daemon, client) = bring_up().await; let (_daemon, client) = bring_up().await;
+55 -142
View File
@@ -1,4 +1,4 @@
//! Agent activity hook (#106). //! Claude Code activity hook (#106).
//! //!
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and //! Two halves, both shared by the `maestro hooks install` CLI subcommand and
//! the daemon's first-start auto-install: //! the daemon's first-start auto-install:
@@ -6,40 +6,28 @@
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It //! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
//! posts an [`ActivityState`](maestro-protocol) to //! posts an [`ActivityState`](maestro-protocol) to
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same //! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
//! global hook runs in every agent session on the machine, so it exits //! global hook runs in every Claude Code session on the machine, so it exits
//! 0 and silently when either #103 env var is unset, and it never lets a //! 0 and silently when either #103 env var is unset, and it never lets a
//! daemon failure escape (short curl timeout, status ignored). //! daemon failure escape (short curl timeout, status ignored).
//! * [`install`] — idempotent registration of that script in one [`Target`]'s //! * [`install`] — idempotent registration of that script in
//! hook file. Only entries maestro itself wrote (identified by //! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
//! [`HOOK_MARKER`]) are touched; every other hook and every other field in //! by [`HOOK_MARKER`]) are touched; every other hook and every other field
//! the file is preserved verbatim. //! in the file is preserved verbatim.
//!
//! One script serves every [`Target`]: Claude Code and Codex read the same
//! `{"hooks": {Event: [{"hooks": [{"type": "command", "command": …}]}]}}`
//! shape and hand the hook the same JSON on stdin. Only the file's location
//! and the event set differ.
use std::path::Path; use std::path::Path;
/// Marker substring every maestro-installed hook command carries. The /// Marker substring every maestro-installed hook command carries. The
/// installer rewrites or removes exactly these entries — nothing else in the /// installer rewrites or removes exactly these entries — nothing else in the
/// user's settings file. /// user's settings file.
///
/// It still says `claude` although the script now serves every [`Target`]:
/// the marker is how an install from an older maestro is recognised, and
/// renaming it would orphan every entry already on disk.
pub const HOOK_MARKER: &str = "maestro-claude-hook"; pub const HOOK_MARKER: &str = "maestro-claude-hook";
/// File name of the installed script (carries [`HOOK_MARKER`]). One script /// File name of the installed script (carries [`HOOK_MARKER`]).
/// for every [`Target`] — see the note on [`HOOK_MARKER`] for the name.
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh"; pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
/// Hook events installed only with the `haiku-autoname` feature (#131), for /// Hook events installed only with the `haiku-autoname` feature (#131).
/// every [`Target`] — Claude Code and Codex both spell the event /// `UserPromptSubmit` carries the prompt the user just submitted; the script
/// `UserPromptSubmit` and both put the submitted text in the payload's /// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
/// `prompt`. The script forwards the whole payload to /// still-provisionally-named session after its first one.
/// `/api/sessions/:id/prompt`, which names a still-provisionally-named
/// session after its first one.
#[cfg(feature = "haiku-autoname")] #[cfg(feature = "haiku-autoname")]
pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")]; pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")];
@@ -55,7 +43,7 @@ pub const PROMPT_EVENTS: [(&str, &str); 0] = [];
/// Claude Code's own name for "the turn ended on an API error"; there is no /// Claude Code's own name for "the turn ended on an API error"; there is no
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report /// failure signal in `Stop` or `SessionEnd` payloads, so those never report
/// `Error`. /// `Error`.
pub const CLAUDE_EVENTS: [(&str, &str); 5] = [ pub const HOOK_EVENTS: [(&str, &str); 5] = [
// Tool starting: the agent is demonstrably producing work, even if the // Tool starting: the agent is demonstrably producing work, even if the
// tool itself runs for minutes. // tool itself runs for minutes.
("PreToolUse", "Active"), ("PreToolUse", "Active"),
@@ -70,86 +58,17 @@ pub const CLAUDE_EVENTS: [(&str, &str); 5] = [
("StopFailure", "Error"), ("StopFailure", "Error"),
]; ];
/// Codex hook event → `ActivityState` spelling posted to the daemon.
///
/// Codex's event set is close to Claude Code's but not identical: a pending
/// approval arrives as `PermissionRequest` rather than `Notification`, and
/// there is no `StopFailure`, so a codex session never reports `Error`.
pub const CODEX_EVENTS: [(&str, &str); 4] = [
("PreToolUse", "Active"),
("PostToolUse", "Active"),
// Codex is blocked on the operator approving a tool call.
("PermissionRequest", "AwaitingInput"),
("Stop", "AwaitingInput"),
];
/// An agent whose hook file maestro writes into.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// `~/.claude/settings.json`.
ClaudeCode,
/// `~/.codex/hooks.json`. Codex asks the operator to trust a hook it has
/// not seen before, so an install here does nothing until they approve it
/// once inside codex — maestro never writes that approval itself.
Codex,
}
impl Target {
/// Every target an install covers. Both files are written whether or not
/// the agent is on the machine: an unused hook file costs nothing, and
/// the alternative is a session spawned after the daemon started missing
/// the hook because its agent arrived later.
pub const ALL: [Target; 2] = [Target::ClaudeCode, Target::Codex];
/// How the target is named in logs and CLI output.
pub fn label(self) -> &'static str {
match self {
Target::ClaudeCode => "claude-code",
Target::Codex => "codex",
}
}
/// The file this target's hook entries are registered in, under `home`.
pub fn settings_path(self, home: &Path) -> std::path::PathBuf {
match self {
Target::ClaudeCode => home.join(".claude/settings.json"),
Target::Codex => home.join(".codex/hooks.json"),
}
}
/// The activity events this target emits.
pub fn activity_events(self) -> &'static [(&'static str, &'static str)] {
match self {
Target::ClaudeCode => &CLAUDE_EVENTS,
Target::Codex => &CODEX_EVENTS,
}
}
/// Every event installed for this target: its activity events plus, with
/// `haiku-autoname` on, the prompt event both agents spell the same way.
pub fn events(self) -> impl Iterator<Item = (&'static str, &'static str)> {
self.activity_events()
.iter()
.chain(PROMPT_EVENTS.iter())
.copied()
}
}
/// The shipped hook script. POSIX sh so any `/bin/sh` works. /// The shipped hook script. POSIX sh so any `/bin/sh` works.
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
# Installed by maestro (#106): report agent activity to its daemon. # Installed by maestro (#106): report Claude Code activity to its daemon.
# One script for every agent maestro installs hooks into; the state to post # Exits silently unless running inside a session maestro spawned (#103 env).
# is chosen by the caller, so the two event sets differ only in what is
# registered where. Exits silently unless running inside a session maestro
# spawned (#103 env).
# Event -> ActivityState mapping: # Event -> ActivityState mapping:
# PreToolUse -> Active (tool started; agent is working) # PreToolUse -> Active (tool started; agent is working)
# PostToolUse -> Active (tool finished) # PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (claude code: permission / idle) # Notification -> AwaitingInput (permission / idle notice)
# PermissionRequest -> AwaitingInput (codex: waiting on an approval) # Stop -> AwaitingInput (turn ended; waiting on the user)
# Stop -> AwaitingInput (turn ended; waiting on the user) # StopFailure -> Error (turn ended on an API error)
# StopFailure -> Error (claude code: turn ended on an error) # Prompt -> the payload is forwarded to the prompt route (#131)
# Prompt -> the payload is forwarded to the prompt route (#131)
state="$1" state="$1"
[ -n "$MAESTRO_SESSION_ID" ] || exit 0 [ -n "$MAESTRO_SESSION_ID" ] || exit 0
[ -n "$MAESTRO_URL" ] || exit 0 [ -n "$MAESTRO_URL" ] || exit 0
@@ -196,29 +115,34 @@ pub fn script_path(home: &Path) -> std::path::PathBuf {
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME) home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
} }
/// Whether the file at `settings_path` already carries a maestro hook /// The user-level Claude Code settings file for a given home directory.
/// (#106). Presence only: an install from an older release answers `true` pub fn settings_path(home: &Path) -> std::path::PathBuf {
/// even when it is missing events this build ships — see [`is_current`]. home.join(".claude/settings.json")
}
/// Whether `settings_path` already carries a maestro hook (#106). Presence
/// only: an install from an older release answers `true` even when it is
/// missing events this build ships — see [`is_current`].
pub fn is_installed(settings_path: &Path) -> bool { pub fn is_installed(settings_path: &Path) -> bool {
settings_json(settings_path) settings_json(settings_path)
.map(|root| find_maestro_commands(&root).next().is_some()) .map(|root| find_maestro_commands(&root).next().is_some())
.unwrap_or(false) .unwrap_or(false)
} }
/// Whether what is on disk is what this build ships for `target`: the script /// Whether what is on disk is what this build ships: the script at
/// at `script_path` byte-for-byte, and exactly one settings entry per one of /// `script_path` byte-for-byte, and exactly one settings entry per event in
/// the target's [`Target::events`], pointing at that script. /// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
/// ///
/// This is the upgrade question, asked per [`Target`]. A maestro that gains /// This is the upgrade question. A maestro that gains an event — #131 added
/// an event — #131 added `UserPromptSubmit` long after #106 had installed /// `UserPromptSubmit` long after #106 had installed itself — leaves every
/// itself — leaves every existing user with a present but incomplete /// existing user with a present but incomplete install, which
/// install, which [`is_installed`] cannot tell apart from a current one. /// [`is_installed`] cannot tell apart from a current one.
pub fn is_current(target: Target, settings_path: &Path, script_path: &Path) -> bool { pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) { if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
return false; return false;
} }
settings_json(settings_path) settings_json(settings_path)
.map(|root| maestro_entries(&root) == expected_entries(target, script_path)) .map(|root| maestro_entries(&root) == expected_entries(script_path))
.unwrap_or(false) .unwrap_or(false)
} }
@@ -249,13 +173,14 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
found found
} }
/// What [`install`] writes for `target` and `script_path`, in the same shape. /// What [`install`] writes for `script_path`, in the same shape.
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> { fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
let mut want: Vec<(String, String)> = target let mut want: Vec<(String, String)> = HOOK_EVENTS
.events() .iter()
.chain(PROMPT_EVENTS.iter())
.map(|(event, state)| { .map(|(event, state)| {
( (
event.to_string(), (*event).to_string(),
format!("{} {state}", script_path.display()), format!("{} {state}", script_path.display()),
) )
}) })
@@ -282,40 +207,28 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
Ok(()) Ok(())
} }
/// Install the full hook setup for one target under `home`: write the script /// Install the full hook setup for `home`: write the script under
/// under `.local/share/maestro/` and register it in the target's hook file. /// `.local/share/maestro/` and register it in `.claude/settings.json`.
/// Idempotent; returns whether anything changed on disk. /// Idempotent; returns whether anything changed on disk.
pub fn install_target(target: Target, home: &Path) -> Result<bool, HooksError> { pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
let script = script_path(home); let script = script_path(home);
let settings = target.settings_path(home); let settings = settings_path(home);
// A script left over from an older release counts as a change even when // A script left over from an older release counts as a change even when
// the settings entries pointing at it are already right. // the settings entries pointing at it are already right.
let changed = !is_current(target, &settings, &script); let changed = !is_current(&settings, &script);
write_script(&script)?; write_script(&script)?;
install(target, &settings, &script)?; install(&settings, &script)?;
Ok(changed) Ok(changed)
} }
/// Install for every [`Target::ALL`], reporting each one separately. One /// Register the hook command for every event in [`HOOK_EVENTS`] in the
/// target failing — an unwritable file, unparseable JSON — never stops the /// settings file at `settings_path`. Idempotent: existing maestro entries are
/// others: an agent whose hook could not be written is one agent without /// updated in place wherever they sit, stale ones removed, duplicates folded;
/// activity reporting, not a failed install. /// every non-maestro entry and every unrelated top-level field survives.
pub fn install_for_home(home: &Path) -> Vec<(Target, Result<bool, HooksError>)> {
Target::ALL
.iter()
.map(|&target| (target, install_target(target, home)))
.collect()
}
/// Register the hook command for every one of `target`'s [`Target::events`]
/// in the settings file at `settings_path`. Idempotent: existing maestro
/// entries are updated in place wherever they sit, stale ones removed,
/// duplicates folded; every non-maestro entry and every unrelated top-level
/// field survives.
/// ///
/// Never inspects or writes anywhere but `settings_path` — callers pass the /// Never inspects or writes anywhere but `settings_path` — callers pass the
/// user's real home or a temp one in tests, never a repository. /// user's real home or a temp one in tests, never a repository.
pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Result<(), HooksError> { pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
let mut root = settings_json(settings_path)?; let mut root = settings_json(settings_path)?;
// Strip every maestro entry wherever it currently sits, so an older // Strip every maestro entry wherever it currently sits, so an older
@@ -323,7 +236,7 @@ pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Resu
strip_maestro_entries(&mut root); strip_maestro_entries(&mut root);
let hooks = ensure_object(&mut root, "hooks"); let hooks = ensure_object(&mut root, "hooks");
for (event, state) in target.events() { for (event, state) in HOOK_EVENTS.iter().chain(PROMPT_EVENTS.iter()).copied() {
let groups = ensure_array(hooks, event); let groups = ensure_array(hooks, event);
let mut group = serde_json::Map::new(); let mut group = serde_json::Map::new();
group.insert( group.insert(
+32 -136
View File
@@ -1,24 +1,15 @@
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess) //! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
//! and the settings-file installer. Everything happens under a temp `$HOME`; //! and the settings-file installer. Everything happens under a temp `$HOME`;
//! the developer's own `~/.claude/settings.json` and `~/.codex/hooks.json` //! the developer's own `~/.claude/settings.json` is never touched.
//! are never touched.
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use super::*; use super::*;
/// How many commands one install writes for a target: its activity events, /// How many commands one install writes: every activity event, plus the
/// plus the prompt event when `haiku-autoname` is on (#131). /// prompt event when `haiku-autoname` is on (#131).
fn installed_events(target: Target) -> usize { const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
target.events().count()
}
/// The Claude Code hook file under a temp home. Most installer tests only
/// need one target, and this is the one they were written against.
fn settings_path(home: &Path) -> std::path::PathBuf {
Target::ClaudeCode.settings_path(home)
}
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output { fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
let mut cmd = Command::new("/bin/sh"); let mut cmd = Command::new("/bin/sh");
@@ -79,7 +70,7 @@ fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a
/// exit 0, no output, stdin drained. /// exit 0, no output, stdin drained.
#[test] #[test]
fn script_exits_silently_without_maestro_env() { fn script_exits_silently_without_maestro_env() {
for (event, _) in CLAUDE_EVENTS { for (event, _) in HOOK_EVENTS {
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#); let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
assert!(out.status.success(), "{event:?}: {:?}", out.stderr); assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout); assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
@@ -112,7 +103,7 @@ fn script_posts_mapped_state_to_the_activity_endpoint() {
/// Every event maps to the state the contract specifies. /// Every event maps to the state the contract specifies.
#[test] #[test]
fn event_state_mapping_matches_the_contract() { fn event_state_mapping_matches_the_contract() {
let map: std::collections::HashMap<&str, &str> = CLAUDE_EVENTS.into_iter().collect(); let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
assert_eq!(map["PreToolUse"], "Active"); assert_eq!(map["PreToolUse"], "Active");
assert_eq!(map["PostToolUse"], "Active"); assert_eq!(map["PostToolUse"], "Active");
assert_eq!(map["Notification"], "AwaitingInput"); assert_eq!(map["Notification"], "AwaitingInput");
@@ -212,10 +203,10 @@ fn fresh_install_registers_every_event() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
write_script(&script_path(home)).unwrap(); write_script(&script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap(); install(&settings_path(home), &script_path(home)).unwrap();
let cmds = installed_commands(&settings_path(home)); let cmds = installed_commands(&settings_path(home));
assert_eq!(cmds.len(), installed_events(Target::ClaudeCode)); assert_eq!(cmds.len(), INSTALLED_EVENTS);
let joined = cmds.join("\n"); let joined = cmds.join("\n");
assert_eq!(joined.matches("Active").count(), 2); assert_eq!(joined.matches("Active").count(), 2);
assert!(joined.contains("Error")); assert!(joined.contains("Error"));
@@ -230,15 +221,15 @@ fn fresh_install_registers_every_event() {
fn install_is_idempotent() { fn install_is_idempotent() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap(); install(&settings_path(home), &script_path(home)).unwrap();
let once = std::fs::read_to_string(settings_path(home)).unwrap(); let once = std::fs::read_to_string(settings_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap(); install(&settings_path(home), &script_path(home)).unwrap();
let twice = std::fs::read_to_string(settings_path(home)).unwrap(); let twice = std::fs::read_to_string(settings_path(home)).unwrap();
assert_eq!(once, twice); assert_eq!(once, twice);
assert_eq!( assert_eq!(
installed_commands(&settings_path(home)).len(), installed_commands(&settings_path(home)).len(),
installed_events(Target::ClaudeCode) INSTALLED_EVENTS
); );
} }
@@ -266,12 +257,12 @@ fn install_updates_an_older_maestro_hook_in_place() {
.path() .path()
.join(".local/share/maestro") .join(".local/share/maestro")
.join(SCRIPT_FILE_NAME); .join(SCRIPT_FILE_NAME);
install(Target::ClaudeCode, &settings, &new_script).unwrap(); install(&settings, &new_script).unwrap();
let cmds = installed_commands(&settings); let cmds = installed_commands(&settings);
assert_eq!( assert_eq!(
cmds.len(), cmds.len(),
installed_events(Target::ClaudeCode), INSTALLED_EVENTS,
"old entry replaced, not kept: {cmds:?}" "old entry replaced, not kept: {cmds:?}"
); );
assert!(!cmds.iter().any(|c| c == old_script)); assert!(!cmds.iter().any(|c| c == old_script));
@@ -307,7 +298,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
.path() .path()
.join(".local/share/maestro") .join(".local/share/maestro")
.join(SCRIPT_FILE_NAME); .join(SCRIPT_FILE_NAME);
install(Target::ClaudeCode, &settings, &new_script).unwrap(); install(&settings, &new_script).unwrap();
let root: serde_json::Value = let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
@@ -320,7 +311,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
let cmds = installed_commands(&settings); let cmds = installed_commands(&settings);
assert_eq!( assert_eq!(
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(), cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
installed_events(Target::ClaudeCode), INSTALLED_EVENTS,
"{cmds:?}" "{cmds:?}"
); );
} }
@@ -330,15 +321,9 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
fn is_installed_tracks_reality() { fn is_installed_tracks_reality() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
for target in Target::ALL { assert!(!is_installed(&settings_path(home)));
assert!(!is_installed(&target.settings_path(home))); install_for_home(home).unwrap();
} assert!(is_installed(&settings_path(home)));
for (_, result) in install_for_home(home) {
result.unwrap();
}
for target in Target::ALL {
assert!(is_installed(&target.settings_path(home)));
}
} }
/// The full home-level install leaves the script executable. /// The full home-level install leaves the script executable.
@@ -346,9 +331,7 @@ fn is_installed_tracks_reality() {
fn install_for_home_writes_an_executable_script() { fn install_for_home_writes_an_executable_script() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
for (_, result) in install_for_home(home) { install_for_home(home).unwrap();
result.unwrap();
}
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -367,7 +350,7 @@ fn install_for_home_writes_an_executable_script() {
fn install_registers_the_prompt_event() { fn install_registers_the_prompt_event() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap(); install(&settings_path(home), &script_path(home)).unwrap();
let root: serde_json::Value = let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap(); serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
@@ -384,7 +367,7 @@ fn install_registers_the_prompt_event() {
fn install_registers_no_prompt_event_without_the_feature() { fn install_registers_no_prompt_event_without_the_feature() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap(); install(&settings_path(home), &script_path(home)).unwrap();
let root: serde_json::Value = let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap(); serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
@@ -453,12 +436,8 @@ fn the_script_posts_a_prompt_payload_unchanged() {
fn is_current_rejects_an_install_missing_an_event() { fn is_current_rejects_an_install_missing_an_event() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
install_target(Target::ClaudeCode, home).unwrap(); install_for_home(home).unwrap();
assert!(is_current( assert!(is_current(&settings_path(home), &script_path(home)));
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
// Drop one event, as an older install would have left the file. // Drop one event, as an older install would have left the file.
let mut root: serde_json::Value = let mut root: serde_json::Value =
@@ -471,11 +450,7 @@ fn is_current_rejects_an_install_missing_an_event() {
.unwrap(); .unwrap();
assert!(is_installed(&settings_path(home))); assert!(is_installed(&settings_path(home)));
assert!(!is_current( assert!(!is_current(&settings_path(home), &script_path(home)));
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
} }
/// A settings file that is complete but points at a script from an older /// A settings file that is complete but points at a script from an older
@@ -484,13 +459,9 @@ fn is_current_rejects_an_install_missing_an_event() {
fn is_current_rejects_a_stale_script() { fn is_current_rejects_a_stale_script() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
install_target(Target::ClaudeCode, home).unwrap(); install_for_home(home).unwrap();
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap(); std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(!is_current( assert!(!is_current(&settings_path(home), &script_path(home)));
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
} }
/// Nothing installed at all is not current. /// Nothing installed at all is not current.
@@ -498,93 +469,18 @@ fn is_current_rejects_a_stale_script() {
fn is_current_rejects_a_missing_install() { fn is_current_rejects_a_missing_install() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
assert!(!is_current( assert!(!is_current(&settings_path(home), &script_path(home)));
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
} }
/// `install_target` reports a stale script as a change, even when the /// `install_for_home` reports a stale script as a change, even when the
/// settings entries pointing at it already read correctly. /// settings entries pointing at it already read correctly.
#[test] #[test]
fn install_target_reports_a_stale_script_as_a_change() { fn install_for_home_reports_a_stale_script_as_a_change() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path(); let home = dir.path();
assert!(install_target(Target::ClaudeCode, home).unwrap()); assert!(install_for_home(home).unwrap());
assert!(!install_target(Target::ClaudeCode, home).unwrap()); assert!(!install_for_home(home).unwrap());
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap(); std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(install_target(Target::ClaudeCode, home).unwrap()); assert!(install_for_home(home).unwrap());
}
/// Codex is hooked in its own file, with its own event set: the two agents
/// share the script but not the events, so neither file carries the other's
/// spellings (#131 follow-up — a codex session was never named because
/// nothing installed its `UserPromptSubmit` hook).
#[test]
fn each_target_gets_its_own_file_and_events() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
for (_, result) in install_for_home(home) {
result.unwrap();
}
let claude = events_in(&Target::ClaudeCode.settings_path(home));
let codex = events_in(&Target::Codex.settings_path(home));
assert_eq!(
Target::Codex.settings_path(home),
home.join(".codex/hooks.json")
);
// Claude Code's failure signal has no codex equivalent, and codex's
// approval notice is spelled differently.
assert!(claude.contains(&"StopFailure".to_string()));
assert!(claude.contains(&"Notification".to_string()));
assert!(!codex.contains(&"StopFailure".to_string()));
assert!(!codex.contains(&"Notification".to_string()));
assert!(codex.contains(&"PermissionRequest".to_string()));
// Both report work and both report a finished turn.
for event in ["PreToolUse", "PostToolUse", "Stop"] {
assert!(claude.contains(&event.to_string()), "claude {event}");
assert!(codex.contains(&event.to_string()), "codex {event}");
}
assert_eq!(
installed_commands(&Target::Codex.settings_path(home)).len(),
installed_events(Target::Codex)
);
}
/// #131: a codex session names itself the same way a Claude Code one does —
/// same event spelling, same script, same `Prompt` argument.
#[cfg(feature = "haiku-autoname")]
#[test]
fn codex_reports_its_first_prompt_too() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_target(Target::Codex, home).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(Target::Codex.settings_path(home)).unwrap())
.unwrap();
let cmd = root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.to_string();
assert!(cmd.ends_with(" Prompt"), "{cmd}");
assert!(cmd.contains(SCRIPT_FILE_NAME));
}
/// Every event name a settings file registers a maestro hook under.
fn events_in(settings: &Path) -> Vec<String> {
serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(settings).unwrap())
.unwrap()
.get("hooks")
.and_then(|h| h.as_object())
.into_iter()
.flatten()
.map(|(event, _)| event.clone())
.collect()
} }
-2
View File
@@ -268,7 +268,6 @@ mod tests {
fn session(cwd: &str) -> Session { fn session(cwd: &str) -> Session {
Session { Session {
view: Default::default(),
id: Uuid::from_u128(1), id: Uuid::from_u128(1),
cwd: PathBuf::from(cwd), cwd: PathBuf::from(cwd),
command: "bash".into(), command: "bash".into(),
@@ -285,7 +284,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
} }
} }
+59 -185
View File
@@ -116,33 +116,6 @@ pub struct Session {
/// sorts a never-focused session after every session that has one. /// sorts a never-focused session after every session that has one.
#[serde(default)] #[serde(default)]
pub last_focused_at: Option<DateTime<Utc>>, pub last_focused_at: Option<DateTime<Utc>>,
/// Whether this session's process is a plain terminal (PTY bytes through
/// tmux) or a structured agent driven over ACP rows (#133). Fixed at
/// spawn time and never changes for the life of the session. Defaults to
/// `Terminal` on the wire so every existing client and payload from a
/// daemon that predates the field keeps working untouched.
#[serde(default)]
pub view: SessionView,
/// The ACP session id the `claude-code-acp` adapter handed back from
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
/// one, and only once its handshake succeeded; a later respawn passes it
/// to `session/load` so the conversation continues (#135). Distinct from
/// the hook-reported Claude Code conversation id (#161): that one is what
/// `claude --resume` takes, this one is what the ACP adapter takes, and
/// the two are minted by different processes with different lifetimes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>,
}
/// A session's process shape (#133): a plain terminal, or a structured agent
/// driven over ACP rows. Chosen at spawn and immutable for the session's
/// life. `Terminal` is the default — every sidecar and `CreateSessionRequest`
/// written before this field existed meant a terminal session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SessionView {
#[default]
Terminal,
Structured,
} }
/// Default for [`Session::last_activity_at`] when an older payload omits it. /// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -150,10 +123,38 @@ fn epoch() -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp") DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] /// Lifecycle of a session as the daemon knows it.
///
/// Deserializes leniently, like [`ActivityState`] (#162): an unknown spelling
/// from a newer daemon reads as [`SessionStatus::Dead`] instead of failing the
/// whole `Session`. `Dead` is the safe degrade — an unrecognised session is
/// one this client must not treat as having a live pane to type into.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum SessionStatus { pub enum SessionStatus {
Running, Running,
Dead, Dead,
/// Known from its sidecar, with no tmux session behind it (#162). The
/// daemon registers these at recovery time for every sidecar tmux had
/// nothing to adopt — after a host reboot, that is every session there
/// was. There is no pane and no PTY bridge: a dormant session is a record
/// of what was running, listed so the operator still has it, and the only
/// thing that can be done to it is dismissing it.
Dormant,
}
impl<'de> Deserialize<'de> for SessionStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"Running" => Self::Running,
"Dormant" => Self::Dormant,
// Known or not, any other spelling degrades to the inert variant.
_ => Self::Dead,
})
}
} }
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The /// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
@@ -379,11 +380,6 @@ pub struct CreateSessionRequest {
pub metadata: HashMap<String, Value>, pub metadata: HashMap<String, Value>,
#[serde(default)] #[serde(default)]
pub worktree: Option<WorktreeSpec>, pub worktree: Option<WorktreeSpec>,
/// The process shape to spawn (#133). Defaulted so every existing client
/// and `curl` against the API keeps requesting a `Terminal` session
/// untouched.
#[serde(default)]
pub view: SessionView,
} }
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory. /// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
@@ -521,8 +517,11 @@ impl ApiError {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPreset { pub struct SpawnPreset {
pub id: String, pub id: String,
#[serde(default)]
pub label: String, pub label: String,
pub command: String, pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_command: Option<String>,
} }
/// Build provenance of the running daemon: the commit it was compiled /// Build provenance of the running daemon: the commit it was compiled
@@ -755,87 +754,6 @@ pub enum CatalogEvent {
}, },
} }
// ---------------------------------------------------------------------------
// Structured-session projections
// ---------------------------------------------------------------------------
/// One client-neutral item in a structured session transcript.
///
/// `data` preserves the ACP payload without committing clients to an ACP
/// dependency. Rendering choices, including markdown and tool-card treatment,
/// stay with the client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TranscriptRow {
/// Stable within one session, so later deltas can address this row.
pub id: String,
pub kind: TranscriptRowKind,
pub data: Value,
}
/// The semantic shape of a [`TranscriptRow`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptRowKind {
User,
AgentText,
AgentThought,
ToolCall,
ToolDiff,
Approval,
Plan,
Notice,
}
/// A complete transcript at a durable event-log position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TranscriptSnapshot {
pub cursor: u64,
#[serde(default)]
pub rows: Vec<TranscriptRow>,
}
/// A change to a transcript row.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TranscriptDelta {
Append { row: TranscriptRow },
Patch { row: TranscriptRow },
Remove { id: String },
}
/// Per-turn indicators derived from the ACP stream.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnState {
#[serde(default)]
pub responding: bool,
#[serde(default)]
pub thinking: bool,
#[serde(default)]
pub active_tool_calls: Vec<String>,
}
/// A whole-session view derived from ACP notifications.
///
/// Values whose shape ACP owns are carried as JSON so this wire protocol stays
/// forward-compatible when ACP adds fields or variants.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ReducedState {
#[serde(default)]
pub turn: TurnState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_approval: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Client ↔ Daemon control messages (text frames on the attach WS) // Client ↔ Daemon control messages (text frames on the attach WS)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -975,8 +893,6 @@ mod tests {
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
view: SessionView::Terminal,
acp_session_id: None,
} }
} }
@@ -1174,6 +1090,33 @@ mod tests {
serde_json::to_value(SessionStatus::Dead).unwrap(), serde_json::to_value(SessionStatus::Dead).unwrap(),
serde_json::json!("Dead") serde_json::json!("Dead")
); );
assert_eq!(
serde_json::to_value(SessionStatus::Dormant).unwrap(),
serde_json::json!("Dormant")
);
}
// #162: a session with no tmux behind it still rides the wire whole.
#[test]
fn dormant_session_round_trips() {
let mut s = sample_session();
s.status = SessionStatus::Dormant;
assert_eq!(s, round_trip(&s));
}
// #162: a `SessionStatus` this client does not know must degrade to the
// inert variant, not fail the whole `Session` the way a derived
// `Deserialize` would.
#[test]
fn unknown_session_status_degrades_to_dead() {
assert_eq!(
serde_json::from_value::<SessionStatus>(serde_json::json!("Hibernating")).unwrap(),
SessionStatus::Dead
);
let mut value = serde_json::to_value(sample_session()).unwrap();
value["status"] = serde_json::json!("Hibernating");
let s: Session = serde_json::from_value(value).expect("session survives unknown status");
assert_eq!(s.status, SessionStatus::Dead);
} }
#[test] #[test]
@@ -1188,7 +1131,6 @@ mod tests {
branch: "feat/foo".into(), branch: "feat/foo".into(),
create_branch: true, create_branch: true,
}), }),
view: SessionView::Terminal,
}; };
assert_eq!(req, round_trip(&req)); assert_eq!(req, round_trip(&req));
} }
@@ -1203,74 +1145,6 @@ mod tests {
assert!(req.worktree.is_none()); assert!(req.worktree.is_none());
assert!(req.env.is_empty()); assert!(req.env.is_empty());
assert!(req.metadata.is_empty()); assert!(req.metadata.is_empty());
assert_eq!(req.view, SessionView::Terminal);
}
#[test]
fn session_view_defaults_to_terminal_on_legacy_payload() {
// A pre-#133 daemon or client omits `view`; it must deserialize to
// `Terminal` rather than failing, and `Structured` round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.view, SessionView::Terminal);
let mut structured = sample_session();
structured.view = SessionView::Structured;
assert_eq!(structured, round_trip(&structured));
}
#[test]
fn structured_projection_types_round_trip_and_default() {
let row = TranscriptRow {
id: "tool:read-1".into(),
kind: TranscriptRowKind::ToolCall,
data: serde_json::json!({"name": "vendor.read", "title": "Read"}),
};
let snapshot = TranscriptSnapshot {
cursor: 7,
rows: vec![row.clone()],
};
assert_eq!(snapshot, round_trip(&snapshot));
assert_eq!(
TranscriptDelta::Patch { row: row.clone() },
round_trip(&TranscriptDelta::Patch { row })
);
let state: ReducedState = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(state, ReducedState::default());
assert_eq!(
serde_json::to_value(TranscriptRowKind::AgentThought).unwrap(),
serde_json::json!("agent_thought")
);
}
#[test]
fn acp_session_id_defaults_to_none_and_round_trips() {
// A payload from before #134 carries no `acp_session_id`; it must
// read as `None`, stay off the wire while unset, and round-trip
// once the adapter has handed one back.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.acp_session_id, None);
let serialized = serde_json::to_value(&s).unwrap();
assert!(serialized.get("acp_session_id").is_none());
let mut structured = sample_session();
structured.view = SessionView::Structured;
structured.acp_session_id = Some("sess_01HZX".into());
assert_eq!(structured, round_trip(&structured));
} }
/// A client that predates the new/existing discriminator sends no /// A client that predates the new/existing discriminator sends no
-2
View File
@@ -31,7 +31,6 @@ use uuid::Uuid;
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`]. /// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
Session { Session {
view: Default::default(),
id, id,
cwd: cwd.into(), cwd: cwd.into(),
command: command.into(), command: command.into(),
@@ -48,7 +47,6 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
} }
} }
-13
View File
@@ -1882,7 +1882,6 @@ mod tests {
sessions: ids sessions: ids
.iter() .iter()
.map(|id| Session { .map(|id| Session {
view: Default::default(),
id: *id, id: *id,
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -1899,7 +1898,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
}) })
.collect(), .collect(),
}); });
@@ -2065,7 +2063,6 @@ mod tests {
let mut app = make_app(); let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session { sessions: vec![Session {
view: Default::default(),
id: sid, id: sid,
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -2082,7 +2079,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
}], }],
}); });
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
@@ -2094,7 +2090,6 @@ mod tests {
use chrono::Utc; use chrono::Utc;
use maestro_protocol::{Session, SessionStatus}; use maestro_protocol::{Session, SessionStatus};
Session { Session {
view: Default::default(),
id: sid, id: sid,
command: "bash".into(), command: "bash".into(),
cwd: cwd.into(), cwd: cwd.into(),
@@ -2111,7 +2106,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
} }
} }
@@ -2783,7 +2777,6 @@ mod tests {
); );
app.dispatch(Action::OpenSpawnModal); app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest { let req = CreateSessionRequest {
view: Default::default(),
cwd: std::path::PathBuf::from("/srv/work"), cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(), command: "bash".into(),
env: Default::default(), env: Default::default(),
@@ -3202,7 +3195,6 @@ mod tests {
use chrono::Utc; use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus}; use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session { let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
view: Default::default(),
id: Uuid::from_u128(n), id: Uuid::from_u128(n),
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -3222,7 +3214,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: focused, last_focused_at: focused,
acp_session_id: None,
}; };
let older = Utc::now() - chrono::Duration::seconds(60); let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3484,7 +3475,6 @@ mod tests {
let sid = Uuid::from_u128(7); let sid = Uuid::from_u128(7);
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session { sessions: vec![Session {
view: Default::default(),
id: sid, id: sid,
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -3502,7 +3492,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
}], }],
}); });
app.dispatch(Action::KillHighlighted); app.dispatch(Action::KillHighlighted);
@@ -3522,7 +3511,6 @@ mod tests {
}; };
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session { sessions: vec![Session {
view: Default::default(),
id: sid, id: sid,
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/"), cwd: std::path::PathBuf::from("/"),
@@ -3539,7 +3527,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
}], }],
}); });
} }
-2
View File
@@ -207,7 +207,6 @@ mod tests {
fn session(id: u128, command: &str) -> Session { fn session(id: u128, command: &str) -> Session {
Session { Session {
view: Default::default(),
id: Uuid::from_u128(id), id: Uuid::from_u128(id),
command: command.into(), command: command.into(),
cwd: std::path::PathBuf::from("/tmp"), cwd: std::path::PathBuf::from("/tmp"),
@@ -224,7 +223,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
} }
} }
+21 -4
View File
@@ -194,7 +194,9 @@ pub enum BarOutcome {
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> { pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
let mut ordered: Vec<&Session> = sessions.iter().collect(); let mut ordered: Vec<&Session> = sessions.iter().collect();
ordered.sort_by(|a, b| { ordered.sort_by(|a, b| {
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead); // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant
// (#162) — sorts after the sessions you could actually attach to.
let dead = |s: &Session| u8::from(s.status != SessionStatus::Running);
dead(a) dead(a)
.cmp(&dead(b)) .cmp(&dead(b))
.then_with(|| { .then_with(|| {
@@ -319,7 +321,7 @@ impl CommandBar {
.map(|s| BarEntry { .map(|s| BarEntry {
candidate: MatchCandidate::Session(session_candidate(s, home_dir)), candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
target: BarTarget::Session(s.id), target: BarTarget::Session(s.id),
dead: s.status == SessionStatus::Dead, dead: s.status != SessionStatus::Running,
chords: Vec::new(), chords: Vec::new(),
}) })
.collect(); .collect();
@@ -793,7 +795,6 @@ mod tests {
fn session(n: u128, command: &str) -> Session { fn session(n: u128, command: &str) -> Session {
Session { Session {
view: Default::default(),
id: Uuid::from_u128(n), id: Uuid::from_u128(n),
cwd: PathBuf::from("/home/me/dev/api"), cwd: PathBuf::from("/home/me/dev/api"),
command: command.to_string(), command: command.to_string(),
@@ -810,7 +811,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
} }
} }
@@ -891,6 +891,23 @@ mod tests {
assert!(bar.rows()[1].dead); assert!(bar.rows()[1].dead);
} }
/// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by
/// which way the pane went away — a dormant session sorts with the dead.
#[test]
fn dormant_sessions_sort_after_live_ones_however_recent() {
let mut dormant = named(1, "dormant");
dormant.status = SessionStatus::Dormant;
dormant.last_focused_at = Some(at(99));
let live = named(2, "live");
let bar = CommandBar::new(&[dormant, live], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
);
assert!(bar.rows()[1].dead);
}
#[test] #[test]
fn typing_ranks_through_the_matcher_and_returns_ranges() { fn typing_ranks_through_the_matcher_and_returns_ranges() {
let mut bar = CommandBar::new( let mut bar = CommandBar::new(
+4 -1
View File
@@ -694,7 +694,6 @@ impl DraftPane {
} }
let worktree = self.build_worktree()?; let worktree = self.build_worktree()?;
Ok(CreateSessionRequest { Ok(CreateSessionRequest {
view: Default::default(),
cwd, cwd,
command: command.to_string(), command: command.to_string(),
env: Default::default(), env: Default::default(),
@@ -1706,6 +1705,7 @@ mod tests {
id: "aider".into(), id: "aider".into(),
label: "Aider".into(), label: "Aider".into(),
command: "aider".into(), command: "aider".into(),
resume_command: None,
}]); }]);
assert_eq!( assert_eq!(
draft.command(), draft.command(),
@@ -1728,11 +1728,13 @@ mod tests {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: None,
}, },
SpawnPreset { SpawnPreset {
id: "aider".into(), id: "aider".into(),
label: "Aider".into(), label: "Aider".into(),
command: "aider".into(), command: "aider".into(),
resume_command: None,
}, },
]); ]);
assert_eq!(draft.command(), "aider"); assert_eq!(draft.command(), "aider");
@@ -1752,6 +1754,7 @@ mod tests {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: None,
}]); }]);
assert!(draft.is_custom()); assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l"); assert_eq!(draft.command(), "bash -l");
+1 -1
View File
@@ -108,7 +108,6 @@ impl FastForwardDefaults {
return None; return None;
} }
Some(CreateSessionRequest { Some(CreateSessionRequest {
view: Default::default(),
cwd: project.path.clone(), cwd: project.path.clone(),
command: command.to_string(), command: command.to_string(),
env: Default::default(), env: Default::default(),
@@ -184,6 +183,7 @@ mod tests {
id: id.into(), id: id.into(),
label: id.into(), label: id.into(),
command: command.into(), command: command.into(),
resume_command: None,
} }
} }
+39 -4
View File
@@ -268,6 +268,8 @@ impl Sidebar {
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> { fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) { let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗", (SessionStatus::Dead, _) => "✗",
// #162: known from its sidecar, nothing running behind it.
(SessionStatus::Dormant, _) => "◌",
(SessionStatus::Running, true) => "●", (SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○", (SessionStatus::Running, false) => "○",
}; };
@@ -322,6 +324,7 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics. // on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear()); let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead; let dead = s.status == SessionStatus::Dead;
let dormant = s.status == SessionStatus::Dormant;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let fill = self.card_fill(s, focused); let fill = self.card_fill(s, focused);
let base = card_base(fill, focused); let base = card_base(fill, focused);
@@ -361,6 +364,9 @@ impl Sidebar {
if dead { if dead {
tail.push(Span::styled(" exited", muted)); tail.push(Span::styled(" exited", muted));
} }
if dormant {
tail.push(Span::styled(" dormant", muted));
}
// docs/SIDEBAR.md: the card names the program, never its arguments. // docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted); let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
@@ -484,7 +490,6 @@ mod tests {
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata.insert("name".into(), serde_json::Value::String(name.into())); metadata.insert("name".into(), serde_json::Value::String(name.into()));
Session { Session {
view: Default::default(),
id: Uuid::from_u128(id), id: Uuid::from_u128(id),
command: "bash".into(), command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"), cwd: std::path::PathBuf::from("/tmp"),
@@ -501,7 +506,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
} }
} }
@@ -1070,6 +1074,39 @@ mod tests {
); );
} }
/// #162, docs/SIDEBAR.md: a dormant session is known but not running —
/// its own glyph on line 1 and the muted `dormant` fragment on line 2, so
/// it never reads as something that exited.
#[test]
fn dormant_session_reads_as_known_but_not_running() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dormant;
s.activity_since = Utc::now() - Duration::seconds(5);
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
assert!(
all[idx].contains('\u{25cc}'),
"expected the dormant glyph in: {:?}",
all[idx]
);
assert!(
all[idx + 1].contains("dormant"),
"expected dormant fragment in: {:?}",
all[idx + 1]
);
assert!(
!all[idx + 1].contains("exited"),
"a dormant session never reads as exited: {:?}",
all[idx + 1]
);
}
/// docs/SIDEBAR.md: line 2 names the program, never its arguments. /// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test] #[test]
fn meta_line_drops_the_commands_arguments() { fn meta_line_drops_the_commands_arguments() {
@@ -1252,7 +1289,6 @@ mod tests {
#[test] #[test]
fn falls_back_to_command_when_name_metadata_is_absent() { fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session { let s = Session {
view: Default::default(),
id: Uuid::from_u128(1), id: Uuid::from_u128(1),
command: "vim".into(), command: "vim".into(),
cwd: std::path::PathBuf::from("/tmp"), cwd: std::path::PathBuf::from("/tmp"),
@@ -1269,7 +1305,6 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None,
}; };
let sb = Sidebar { let sb = Sidebar {
sessions: vec![s], sessions: vec![s],
+3 -3
View File
@@ -123,9 +123,9 @@ client opening `/api/attach/:id`, and a client reporting focus explicitly at
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open. `POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
**A session that has never been focused sorts after every session that has**, **A session that has never been focused sorts after every session that has**,
and among those, most recently created first. Dead sessions sort after live and among those, most recently created first. Sessions with no live pane —
ones regardless of recency: a session you cannot attach to is not where you `Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
just were. session you cannot attach to is not where you just were.
Under `>` an empty query lists every command in registry order. Under `~` it Under `>` an empty query lists every command in registry order. Under `~` it
lists recent dirs, most recent first, then the project list. lists recent dirs, most recent first, then the project list.
-25
View File
@@ -188,31 +188,6 @@ An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`. `crates/maestro-tui/src/action.rs`.
## Structured sessions: `claude-code-acp`
A session spawned with `view: Structured` (#134) runs Claude Code as a child
of the daemon over the Agent Client Protocol instead of in a tmux pane. The
daemon resolves the adapter binary, `claude-code-acp`, on its `PATH` at spawn
time. Install it once per host:
```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
```
That puts `claude-code-acp` in `~/.local/bin`, which must be on the daemon's
`PATH` (a systemd user unit does not read your shell profile; set
`Environment=PATH=…` in the unit or install somewhere already on it). The
adapter needs a logged-in Claude Code (`claude /login`).
When the adapter is missing the spawn still creates the session, marked dead
with the install command above as its activity detail, so the sidebar says
what to run. The adapter is a child of the daemon: it ends with a `DELETE
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
itself. A daemon restart ends an in-flight structured turn, but preserves its
worktree, session record, and event-log transcript. The replacement daemon
starts a new adapter, resumes it with `session/load`, and replays the
transcript. Terminal sessions keep surviving restarts through tmux.
## The browser UI ## The browser UI
The daemon serves the SPA off its own listener. Open The daemon serves the SPA off its own listener. Open
-1
View File
@@ -47,7 +47,6 @@ Web client (`--preview` and friends in `web/src/styles.css`):
| The canvas | A `preview` chip, top right, and a dashed inset ring. | | The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. | | A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. | | The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. | | A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge — see the tier ladder in Rows say it with fill, not with an edge — see the tier ladder in
+20
View File
@@ -68,6 +68,26 @@ resolves, so a blocked client is never a trapped one. It clears itself the
moment a probe succeeds — there is no "retry" button, because the client is moment a probe succeeds — there is no "retry" button, because the client is
already retrying. already retrying.
## What survives, and what comes back dormant
A daemon restart leaves tmux alone, so recovery re-adopts every
`maestro-<uuid>` session tmux still holds and the operator sees no change
beyond the reconnect above.
A **host reboot** is different: it kills the tmux server, so there is nothing
to adopt while every session's sidecar is still on disk. Recovery makes a
second pass over those sidecars and registers each as a `Dormant` session
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
session has no pane and no PTY bridge: it cannot be attached to, typed into or
resized, and neither watcher pass touches it. It is a record of what was
running, and the only thing left to do with one is dismiss it, which removes
the sidecar so the next recovery does not bring it back.
`SessionStatus` is a wire type, so it deserializes leniently on both clients:
a spelling this build does not know reads as `Dead` rather than failing the
whole session. Clients render a dormant session as known-but-not-running —
see `docs/SIDEBAR.md` for the card and `docs/COMMAND-BAR.md` for how it ranks.
## Timing ## Timing
- **Grace window** — 1200 ms between the socket dropping and the overlay - **Grace window** — 1200 ms between the socket dropping and the overlay
+13 -12
View File
@@ -56,7 +56,7 @@ itself is not.
Line 2 reads left to right: Line 2 reads left to right:
``` ```
[<duration>] [<detail>] <command> [<diff stat> | exited] [<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
``` ```
Its minimum content is therefore just the command. Its minimum content is therefore just the command.
@@ -104,8 +104,14 @@ fragment; it no longer decides whether line 2 exists.
while an agent-reported state is in force (see Precedence): when the report while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it. The detail lapses at its TTL or is replaced, the detail goes with it. The detail
truncates with `…` ahead of the trailing fragments — a long detail never truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
client ever lets it wrap. neither client ever lets it wrap.
`dormant` (#162) is the counterpart of `exited` for a session the daemon knows
only from its sidecar: recovery found no tmux session behind it, so it has no
pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
observed ending — so the two fragments never both show. The TUI marks the same
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing `<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
@@ -209,7 +215,7 @@ the cursor's fill while the pane holds the keyboard, and the web's ring on a
highlighted **folder** row — a folder previews nothing, so it has no fill to highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to. step up to.
The duration text inherits its state's colour; the exited and command The duration text inherits its state's colour; the exited, dormant and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they `↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
take the `active` and `error` roles of the same surface. take the `active` and `error` roles of the same surface.
@@ -250,10 +256,6 @@ Two rulings specific to a reported `Error` (#114):
process changes do not clear it — only the agent itself may withdraw what process changes do not clear it — only the agent itself may withdraw what
the agent itself reported. Re-reporting `Error` extends the TTL rather than the agent itself reported. Re-reporting `Error` extends the TTL rather than
stacking reports. stacking reports.
- **Only Claude Code reports `Error`.** Codex has no counterpart to
`StopFailure` in its hook event set, so a codex turn that ends on an API
error reports `AwaitingInput` and its card carries no error mark. A card
without the mark means "nothing reported a failure", not "nothing failed".
If a client receives an `ActivityState` variant it does not know, it must not If a client receives an `ActivityState` variant it does not know, it must not
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual) fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
@@ -307,10 +309,9 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list. than jumping to the end of the list.
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h` The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
by default — and the same chord back): an attached terminal owns every plain same chord back): an attached terminal owns every plain keystroke. The TUI uses
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI `ctrl-w h`.
uses `ctrl-w h`.
Everything else is the same key on both clients: Everything else is the same key on both clients:
+4 -12
View File
@@ -35,18 +35,10 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
back to a short hash of the branch. back to a short hash of the branch.
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
a free one. a free one. The suffix names the **directory only**. The branch is whatever was
requested; a second worktree of the same branch is not what happens here, because
For a *new* branch (`create_branch: true`), the suffix goes on the branch name git refuses to check the same branch out twice and the branch picker already
itself, and the directory follows since it is derived from the branch — a marks branches that are checked out elsewhere.
random handle can otherwise reuse the name of a branch some earlier, already
removed worktree left behind, and `git worktree add -b` refuses outright even
though the directory is free.
For an *existing* branch (`create_branch: false`) the name must stay exact, so
only the directory is suffixed — a second worktree of the same branch is not
what happens here, because git refuses to check the same branch out twice and
the branch picker already marks branches that are checked out elsewhere.
### The `worktree_root` override ### The `worktree_root` override
+20 -7
View File
@@ -4,6 +4,8 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js"; import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.js";
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -12,6 +14,15 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js"; import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js"; import { WebglPolicy } from "./webglRenderer.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are /** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */ * rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy(); const webglPolicy = new WebglPolicy();
@@ -210,6 +221,15 @@ export function createAttachConnection(
} }
} }
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a // Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes: // line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback. // • normal buffer → term.scrollLines walks the scrollback.
@@ -450,12 +470,6 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => { const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows }); sendControl({ type: "Resize", cols, rows });
}); });
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null; const visual = window.visualViewport ?? null;
@@ -511,7 +525,6 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize); visual?.removeEventListener("resize", onResize);
dataDisp.dispose(); dataDisp.dispose();
resizeDisp.dispose(); resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl(); releaseWebgl();
try { try {
ws.close(); ws.close();
+13
View File
@@ -414,6 +414,19 @@ describe("session cards (#107)", () => {
expect(line2?.textContent).toContain("exited"); expect(line2?.textContent).toContain("exited");
}); });
// #162: a session recovered with no tmux behind it is known but not running,
// and never reads as something that exited.
it("shows dormant on line 2 for dormant sessions", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000118",
status: "Dormant",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("dormant");
expect(line2?.textContent).not.toContain("exited");
});
it("carries the state word only in tooltips and aria labels, never as text", () => { it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({ const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019", id: "aaaaaaaa-0000-0000-0000-000000000019",
+8
View File
@@ -53,6 +53,10 @@ export function requestOpenCommandBar(): void {
* connection pool. */ * connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane"; export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session /** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */ * ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
@@ -109,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string {
function metaLine(s: Session, now: number): string { function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead"; const dead = s.status === "Dead";
// #162: known from its sidecar with nothing running behind it — a distinct
// fragment, since it never exited.
const dormant = s.status === "Dormant";
const blockers = sessionBlockers(s); const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle"); const slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now); const held = activityHeldMs(s.activity_since, now);
@@ -123,6 +130,7 @@ function metaLine(s: Session, now: number): string {
commandFragment(s.command), commandFragment(s.command),
blockers ? blockedFragment(blockers) : "", blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "", dead ? `<span class="card-flag">exited</span>` : "",
dormant ? `<span class="card-flag">dormant</span>` : "",
]; ];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`; return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
} }
+14
View File
@@ -99,6 +99,20 @@ describe("empty-query ordering", () => {
]); ]);
}); });
// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by which
// way the pane went away.
it("sorts dormant sessions after live ones, like dead ones", () => {
const dormantRecent = session("dormant", {
status: "Dormant",
last_focused_at: "2026-06-01T00:00:00Z",
});
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
expect(orderSessionsForBar([dormantRecent, liveStale]).map((s) => s.id)).toEqual([
"live",
"dormant",
]);
});
it("treats a null last_focused_at as never focused", () => { it("treats a null last_focused_at as never focused", () => {
const nulled = session("nulled", { last_focused_at: null }); const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" }); const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
+5 -3
View File
@@ -142,8 +142,10 @@ function createdAt(s: Session): number {
* matcher's last tie-break preserves. */ * matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] { export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => { return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0; // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
const deadB = b.status === "Dead" ? 1 : 0; // — sorts after the sessions you could actually attach to.
const deadA = a.status === "Running" ? 0 : 1;
const deadB = b.status === "Running" ? 0 : 1;
if (deadA !== deadB) return deadA - deadB; if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a); const focusA = focusedAt(a);
@@ -350,7 +352,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
row.dataset.kind = candidate.kind; row.dataset.kind = candidate.kind;
if (candidate.kind === "session") { if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id; row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead"); if (candidate.session.status !== "Running") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) { } else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id; row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") { } else if (candidate.kind === "command") {
+14 -12
View File
@@ -133,28 +133,31 @@ describe("CommandSet", () => {
expect(run).toHaveBeenCalledTimes(2); expect(run).toHaveBeenCalledTimes(2);
}); });
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => { it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
const plain = vi.fn(); const plain = vi.fn();
const chorded = vi.fn(); const chorded = vi.fn();
const set = new CommandSet( const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER), defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
{ leader: DEFAULT_LEADER },
); );
const input = el("<input>"); const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false); expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled(); expect(plain).not.toHaveBeenCalled();
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
// left for the pane's shell/app instead.
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
pressOn(set, terminal, "a", { ctrlKey: true });
expect(pressOn(set, terminal, "h")).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1); expect(chorded).toHaveBeenCalledTimes(1);
}); });
it("leaves a plain Escape for the pane instead of binding it", () => { it("keeps pane commands live inside the terminal's textarea", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() })); const run = vi.fn();
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>'); const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
expect(pressOn(set, textarea, "Escape")).toBe(false); const e = key("Escape");
Object.defineProperty(e, "target", { value: textarea });
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
// xterm is told to swallow by the return value, not by the browser default.
expect(e.defaultPrevented).toBe(false);
}); });
}); });
@@ -170,7 +173,6 @@ describe("the leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s])); const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]); expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]); expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
// Commands that do not hang off the leader are untouched. // Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]); expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
}); });
+8 -2
View File
@@ -93,8 +93,7 @@ const SPECS: readonly SpecSource[] = [
id: "focus.toggle", id: "focus.toggle",
label: "Move the keyboard between the session list and the pane", label: "Move the keyboard between the session list and the pane",
group: "Session list", group: "Session list",
keys: [`${DEFAULT_LEADER} h`], keys: ["ctrl-shift-h"],
leaderKeys: (leader) => [`${leader} h`],
scope: "global", scope: "global",
}, },
{ {
@@ -146,6 +145,13 @@ const SPECS: readonly SpecSource[] = [
keys: ["tab"], keys: ["tab"],
scope: "list", scope: "list",
}, },
{
id: "pane.focusList",
label: "Hand the keyboard back to the session list, from the pane",
group: "Session list",
keys: ["esc"],
scope: "pane",
},
{ {
id: "help.toggle", id: "help.toggle",
label: "Show or hide this help", label: "Show or hide this help",
+4 -17
View File
@@ -34,14 +34,12 @@ describe("resolveFastForward (#67)", () => {
const res = resolveFastForward(projects, presets, "amber-falcon"); const res = resolveFastForward(projects, presets, "amber-falcon");
expect(res.project?.path).toBe("/repos/fresh"); expect(res.project?.path).toBe("/repos/fresh");
expect(res.command).toBe("aider"); expect(res.command).toBe("aider");
expect(res.handle).toBe("amber-falcon"); expect(res.name).toBe("amber-falcon");
const req = buildFastForwardRequest(res)!; const req = buildFastForwardRequest(res)!;
expect(req.cwd).toBe("/repos/fresh"); expect(req.cwd).toBe("/repos/fresh");
expect(req.command).toBe("aider"); expect(req.command).toBe("aider");
// No name (#131): the daemon names the session after its first prompt, expect(req.metadata).toEqual({ name: "amber-falcon" });
// and a name here would collide with the branch handle.
expect(req.metadata).toBeUndefined();
expect(req.worktree).toEqual({ expect(req.worktree).toEqual({
base_repo: "/repos/fresh", base_repo: "/repos/fresh",
branch: "amber-falcon", branch: "amber-falcon",
@@ -90,9 +88,9 @@ describe("resolveFastForward (#67)", () => {
expect(buildFastForwardRequest(res)).toBeNull(); expect(buildFastForwardRequest(res)).toBeNull();
}); });
it("downgrades to a draft when the generated handle is missing", () => { it("downgrades to a draft when the generated name is missing", () => {
const res = resolveFastForward([project({ last_command: "claude" })], presets); const res = resolveFastForward([project({ last_command: "claude" })], presets);
expect(res.handle).toBeUndefined(); expect(res.name).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull(); expect(buildFastForwardRequest(res)).toBeNull();
}); });
@@ -110,17 +108,6 @@ describe("resolveFastForward (#67)", () => {
}); });
}); });
it("seeds the handle as the branch and leaves the name field empty (#131)", () => {
const res = resolveFastForward(
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
presets,
"amber-falcon",
);
const seed = fastForwardSeed(res);
expect(seed.name).toBe("");
expect(seed.worktreeBranch).toBe("amber-falcon");
});
it("leaves the worktree default alone when not even a project resolved", () => { it("leaves the worktree default alone when not even a project resolved", () => {
// Not `false`: the form defaults the toggle on and a false would stick, // Not `false`: the form defaults the toggle on and a false would stick,
// so the fallback draft would disagree with a normally-opened draft and // so the fallback draft would disagree with a normally-opened draft and
+13 -19
View File
@@ -10,21 +10,19 @@ import { sortProjects } from "./projectList.js";
export interface FastForwardResolution { export interface FastForwardResolution {
project?: Project; project?: Project;
command?: string; command?: string;
/** A generated handle for the worktree branch (#131). Not a session name: name?: string;
* the fast path names no session. */
handle?: string;
} }
/** The defaults: the currently focused session's project when it's still a /** The defaults: the currently focused session's project when it's still a
* known project, else the most recent one (fresh-first, same ordering the * known project, else the most recent one (fresh-first, same ordering the
* picker shows) — so `N` lands where the operator currently is, matching * picker shows) — so `N` lands where the operator currently is, matching
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command * what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
* falls back to the first non-custom preset, and the branch handle is * falls back to the first non-custom preset, and the name is freshly
* freshly generated when the daemon supplied one. */ * generated when the daemon supplied one. */
export function resolveFastForward( export function resolveFastForward(
projects: Project[], projects: Project[],
presets: SpawnPreset[], presets: SpawnPreset[],
handle?: string, name?: string,
focusedCwd?: string, focusedCwd?: string,
): FastForwardResolution { ): FastForwardResolution {
const project = const project =
@@ -32,45 +30,41 @@ export function resolveFastForward(
const lastCommand = project?.last_command?.trim(); const lastCommand = project?.last_command?.trim();
const command = const command =
lastCommand || presets.find((p) => p.command !== "")?.command || undefined; lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
return { project, command, handle: handle || undefined }; return { project, command, name: name || undefined };
} }
/** The immediate spawn request — worktree on, new branch named after the /** The immediate spawn request — worktree on, new branch named after the
* generated handle — or `null` when any default is missing. * generated session name — or `null` when any default is missing. */
*
* The request carries no name (#131): the daemon assigns a provisional one
* and replaces it once the session sees its first prompt. A name here would
* also collide with the branch handle's own session. */
export function buildFastForwardRequest( export function buildFastForwardRequest(
res: FastForwardResolution, res: FastForwardResolution,
): CreateSessionRequest | null { ): CreateSessionRequest | null {
const { project, command, handle } = res; const { project, command, name } = res;
if (!project || !command?.trim() || !handle) return null; if (!project || !command?.trim() || !name) return null;
return { return {
cwd: project.path, cwd: project.path,
command, command,
metadata: { name },
worktree: { worktree: {
base_repo: project.base_repo ?? project.path, base_repo: project.base_repo ?? project.path,
branch: handle, branch: name,
create_branch: true, create_branch: true,
}, },
}; };
} }
/** Seed for the fallback spawn form: whatever did resolve goes into the /** Seed for the fallback spawn form: whatever did resolve goes into the
* form; the rest stays with its own defaults. The name field stays empty * form; the rest stays with its own defaults. */
* (#131) — the handle seeds the branch, not the session name. */
export function fastForwardSeed(res: FastForwardResolution) { export function fastForwardSeed(res: FastForwardResolution) {
return { return {
cwd: res.project?.path ?? "", cwd: res.project?.path ?? "",
command: res.command ?? "", command: res.command ?? "",
name: "", name: res.name ?? "",
// Left undefined rather than false when nothing resolved: the form // Left undefined rather than false when nothing resolved: the form
// defaults the toggle on, and `false` would stick, so the fallback form // defaults the toggle on, and `false` would stick, so the fallback form
// would disagree with both a normally-opened form and the TUI's fast path // would disagree with both a normally-opened form and the TUI's fast path
// (#67 review). // (#67 review).
worktreeEnabled: res.project ? true : undefined, worktreeEnabled: res.project ? true : undefined,
worktreeBranch: worktreeBranch:
res.project && res.handle ? res.handle : undefined, res.project && res.name ? res.name : undefined,
}; };
} }
-33
View File
@@ -1,33 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
closeForceKillDialog,
openForceKillDialog,
openKillConfirmDialog,
} from "./killDialog.js";
describe("kill dialogs", () => {
beforeEach(() => {
closeForceKillDialog();
document.body.innerHTML = "";
});
it("focuses the kill button, so Enter confirms", () => {
const el = openKillConfirmDialog({
name: "alpha",
blockers: null,
onKill: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
});
it("focuses the force button too", () => {
const el = openForceKillDialog({
name: "alpha",
blockers: null,
reason: "worktree is dirty.",
onForce: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
});
});
+3 -3
View File
@@ -132,9 +132,9 @@ function mount(p: KillDialogSpec): HTMLElement {
}; };
window.addEventListener("keydown", onKeydown, true); window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown }; open = { el, onKeydown };
// The acting button takes the focus in both prompts: the operator already // Cancel takes the focus in both prompts: a kill is never one keystroke
// asked for the kill, so Enter confirms it. Escape and the scrim cancel. // away from whatever asked for the dialog.
confirm.focus(); cancel.focus();
return el; return el;
} }
-18
View File
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { import {
emptyLayout, emptyLayout,
equalizeRatios,
leaves, leaves,
locateSession, locateSession,
openSessions, openSessions,
@@ -45,23 +44,6 @@ describe("splitLeaf", () => {
}); });
}); });
describe("equalizeRatios", () => {
it("resets nested splits to equal width, not just the newest one", () => {
const tab = tabWithOneLeaf("s1");
const first = leaves(tab.root)[0].id;
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
const second = leaves(tab.root)[1].id;
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
equalizeRatios(tab.root);
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
expect(root.ratio).toBeCloseTo(1 / 3);
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
expect(inner.ratio).toBeCloseTo(1 / 2);
});
});
describe("removeLeaf", () => { describe("removeLeaf", () => {
it("collapses the parent split into the surviving sibling", () => { it("collapses the parent split into the surviving sibling", () => {
const tab = tabWithOneLeaf("s1"); const tab = tabWithOneLeaf("s1");
-11
View File
@@ -118,17 +118,6 @@ export function removeLeaf(tab: Tab, leafId: string): void {
tab.root = tab.root ? rec(tab.root) : null; tab.root = tab.root ? rec(tab.root) : null;
} }
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
export function addTab(layout: Layout): Tab { export function addTab(layout: Layout): Tab {
const tab: Tab = { const tab: Tab = {
id: freshId("t"), id: freshId("t"),
+15 -11
View File
@@ -35,6 +35,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js"; import { createPreviewSchedule } from "./previewSchedule.js";
import { import {
FOCUS_PANE_EVENT, FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT, OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT, OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT, SESSION_SELECT_EVENT,
@@ -355,14 +356,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
} }
async function requestFastForward(): Promise<void> { async function requestFastForward(): Promise<void> {
const [discovered, handle] = await Promise.all([ const [discovered, name] = await Promise.all([
fetchProjects().catch(() => [] as Project[]), fetchProjects().catch(() => [] as Project[]),
fetchNames(1) fetchNames(1)
.then((batch) => batch.names[0]) .then((batch) => batch.names[0])
.catch(() => undefined), .catch(() => undefined),
]); ]);
const presets = await spawnPresets(); const presets = await spawnPresets();
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd()); const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
const req = buildFastForwardRequest(resolved); const req = buildFastForwardRequest(resolved);
if (req) { if (req) {
try { try {
@@ -389,7 +390,7 @@ async function requestFastForward(): Promise<void> {
// one outside any repo spawns without a worktree, since there is no repo to cut // one outside any repo spawns without a worktree, since there is no repo to cut
// a branch from. Anything still unresolved opens the pre-filled form. // a branch from. Anything still unresolved opens the pre-filled form.
async function requestSpawnInDirectory(path: string): Promise<void> { async function requestSpawnInDirectory(path: string): Promise<void> {
const [discovered, handle] = await Promise.all([ const [discovered, name] = await Promise.all([
fetchProjects().catch(() => [] as Project[]), fetchProjects().catch(() => [] as Project[]),
fetchNames(1) fetchNames(1)
.then((batch) => batch.names[0]) .then((batch) => batch.names[0])
@@ -398,7 +399,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
const presets = await spawnPresets(); const presets = await spawnPresets();
const known = discovered.find((p) => p.path === path); const known = discovered.find((p) => p.path === path);
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path }; const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
const resolved = resolveFastForward([project], presets, handle, path); const resolved = resolveFastForward([project], presets, name, path);
const req = buildFastForwardRequest(resolved); const req = buildFastForwardRequest(resolved);
if (req) { if (req) {
const inRepo = const inRepo =
@@ -439,11 +440,10 @@ const spawnKeys = new CommandSet(
}), }),
); );
// Leader-h moves the keyboard between the session list and the pane, the web // Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the // web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H // terminal owns every plain keystroke, so nothing unmodified could ever get
// is already claimed by the browser (history), so nothing unmodified — or // the focus back out of it.
// modified with a common browser chord — could get the focus back out of it.
// //
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off // on `/api/info`): it holds the key as a partial chord and takes select-all off
@@ -528,8 +528,8 @@ window.addEventListener(
); );
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle, // Tab in the session list hands the keyboard to the pane; Escape in the pane
// above) hands it back the other way. // hands it back — the pair the TUI gets from `tab` and `esc`.
document.addEventListener(FOCUS_PANE_EVENT, () => { document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops // Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs. // being provisional rather than snapping back the moment the list blurs.
@@ -537,6 +537,10 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
if (previewed) focusSession(previewed); if (previewed) focusSession(previewed);
pool.focusTerminal(); pool.focusTerminal();
}); });
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the /** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely * hash router and the sidebar's click-driven path (#133), which can't rely
+3 -21
View File
@@ -10,7 +10,6 @@ import {
activeTab, activeTab,
addTab, addTab,
closeTab, closeTab,
equalizeRatios,
findLeaf, findLeaf,
freshId, freshId,
type Dir, type Dir,
@@ -216,20 +215,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return fl?.sessionId ?? null; return fl?.sessionId ?? null;
} }
/** The session the operator actually picked — what `focusedLeafSession()`
* would return with the preview undone. A preview moves the active tab and
* its focus, but it is a view: the accent stays on the row the operator
* chose until they choose another (docs/PREVIEW.md). */
function realFocusedSession(): string | null {
const p = preview;
if (!p) return focusedLeafSession();
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
const leafId =
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
const fl = leafId ? findLeaf(tab.root, leafId) : null;
return fl?.sessionId ?? null;
}
/** Update focus styling + pool focus + sidebar highlights without rebuilding /** Update focus styling + pool focus + sidebar highlights without rebuilding
* the DOM (cheap path for click-to-focus). */ * the DOM (cheap path for click-to-focus). */
function syncFocus(): void { function syncFocus(): void {
@@ -248,7 +233,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (!preview) pool.setFocus(focusedLeafSession()); if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({ sidebar.setHighlights({
open: realOpenSessions(), open: realOpenSessions(),
focused: realFocusedSession(), focused: focusedLeafSession(),
}); });
// The row the preview came from wears the same colour, so the sidebar and // The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights. // the pane read as one thing rather than two unrelated highlights.
@@ -458,7 +443,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
dropPreview(); dropPreview();
const tab = activeTab(layout); const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render(); render();
} }
@@ -544,7 +528,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
target.sessionId = sessionId; target.sessionId = sessionId;
} else { } else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
} }
render(); render();
opts.onOpenSessionsChange?.(); opts.onOpenSessionsChange?.();
@@ -625,7 +608,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
{ focus: !preview && !quietRender }, { focus: !preview && !quietRender },
); );
if (!preview) pool.setFocus(focused.sessionId ?? null); if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() }); sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
sidebar.setPreview(preview?.sessionId ?? null); sidebar.setPreview(preview?.sessionId ?? null);
} }
@@ -818,7 +801,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) removeLeaf(tab, self); if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id); else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after); tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render(); render();
} }
@@ -835,7 +817,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
openSessions: realOpenSessions, openSessions: realOpenSessions,
beginSessionDrag, beginSessionDrag,
render, render,
focusedSessionId: realFocusedSession, focusedSessionId: focusedLeafSession,
previewSession, previewSession,
previewedSessionId: () => preview?.sessionId ?? null, previewedSessionId: () => preview?.sessionId ?? null,
}; };
+1 -33
View File
@@ -47,20 +47,12 @@ function fakePool(): Spy {
/** Last value the controller pushed to `SidebarHandle.setPreview`. */ /** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null }; const previewRow: { id: string | null } = { id: null };
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
const rowHighlights: { open: Set<string>; focused: string | null } = {
open: new Set(),
focused: null,
};
function fakeSidebar(): SidebarHandle { function fakeSidebar(): SidebarHandle {
return { return {
el: document.createElement("aside"), el: document.createElement("aside"),
setActive() {}, setActive() {},
setHighlights(h) { setHighlights() {},
rowHighlights.open = h.open;
rowHighlights.focused = h.focused;
},
setPreview(id: string | null) { setPreview(id: string | null) {
previewRow.id = id; previewRow.id = id;
}, },
@@ -103,8 +95,6 @@ describe("sidebar preview", () => {
stubFetch(); stubFetch();
document.body.replaceChildren(); document.body.replaceChildren();
previewRow.id = null; previewRow.id = null;
rowHighlights.open = new Set();
rowHighlights.focused = null;
}); });
afterEach(() => { afterEach(() => {
@@ -144,28 +134,6 @@ describe("sidebar preview", () => {
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0); expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
}); });
it("leaves the accent on the session the pane actually holds", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(rowHighlights.focused).toBe("s2");
// Walking past other rows never moves the accent off what the operator
// picked: only a real open does (docs/PREVIEW.md).
mv.previewSession("s1");
expect(rowHighlights.focused).toBe("s2");
expect(mv.focusedSessionId()).toBe("s2");
mv.previewSession("s9");
expect(rowHighlights.focused).toBe("s2");
mv.previewSession(null);
expect(rowHighlights.focused).toBe("s2");
mv.openSession("s1");
expect(rowHighlights.focused).toBe("s1");
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => { it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs(); const layout = twoTabs();
const before = serializeLayout(layout); const before = serializeLayout(layout);
+4 -5
View File
@@ -6,7 +6,10 @@ import { escapeHtml } from "./html.js";
export type Uuid = string; export type Uuid = string;
export type IsoTimestamp = string; export type IsoTimestamp = string;
export type SessionStatus = "Running" | "Dead"; /** `Dormant` (#162) is a session the daemon knows only from its sidecar —
* recovered after the tmux server died under it, so there is no pane behind it
* and nothing to attach to. Listed, never live. */
export type SessionStatus = "Running" | "Dead" | "Dormant";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340). /** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. `Error` is only set * Absent on payloads from daemons that predate the field. `Error` is only set
@@ -56,10 +59,6 @@ export interface Session {
* how the command bar's empty query sorts a never-focused session after every * how the command bar's empty query sorts a never-focused session after every
* session that has one (`docs/COMMAND-BAR.md`). */ * session that has one (`docs/COMMAND-BAR.md`). */
last_focused_at?: IsoTimestamp | null; last_focused_at?: IsoTimestamp | null;
/** The ACP session id the `claude-code-acp` adapter returned from
* `session/new` (#134). Only a `Structured` session whose handshake
* succeeded has one; a respawn passes it to `session/load` (#135). */
acp_session_id?: string | null;
} }
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */ /** A session's daemon-created worktree (`SessionWorktree` on the wire). */