Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975f3ef0c5 | ||
|
|
c2ac3f7eb6 | ||
|
|
c118020b29 | ||
|
|
7cb14c7aae | ||
|
|
80a9399905 | ||
|
|
9d9a2e723d | ||
|
|
bbafff5a98 | ||
|
|
17e1dc4049 | ||
|
|
436cb194c3 | ||
|
|
6471da2624 | ||
|
|
2a43c76176 | ||
|
|
f689c916bf | ||
|
|
88233cb96d | ||
|
|
54d84e3eaa | ||
|
|
efbccb4e11 | ||
|
|
41c498401b | ||
|
|
6ca347b657 | ||
|
|
1b4981f9ce | ||
|
|
1b289a0450 | ||
|
|
3c5bb51e88 | ||
|
|
f60587a6d6 | ||
|
|
43946c3c46 | ||
|
|
eb605a3d05 | ||
|
|
4c9409a785 | ||
|
|
c24a2133d4 | ||
|
|
75ccfe10d2 | ||
|
|
ea3e18d1f2 | ||
|
|
4f0e887a50 | ||
|
|
cfebfd441b | ||
|
|
81312aa0c5 | ||
|
|
52be48b0a6 | ||
|
|
71add0215a |
Generated
+11
@@ -1065,6 +1065,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"libc",
|
||||
"maestro-config",
|
||||
"maestro-hooks",
|
||||
"maestro-protocol",
|
||||
"maestro-testing",
|
||||
"nix",
|
||||
@@ -1102,6 +1103,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"libc",
|
||||
"maestro-config",
|
||||
"maestro-hooks",
|
||||
"maestro-names",
|
||||
"maestro-protocol",
|
||||
"reqwest",
|
||||
@@ -1137,6 +1139,15 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-hooks"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-installer"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -18,6 +18,7 @@ dirs = "5"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
libc = "0.2"
|
||||
maestro-config = { path = "crates/maestro-config" }
|
||||
maestro-hooks = { path = "crates/maestro-hooks" }
|
||||
maestro-names = { path = "crates/maestro-names" }
|
||||
maestro-protocol = { path = "crates/maestro-protocol" }
|
||||
maestro-testing = { path = "crates/maestro-testing" }
|
||||
|
||||
@@ -17,6 +17,7 @@ dirs.workspace = true
|
||||
futures-util.workspace = true
|
||||
libc.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-hooks.workspace = true
|
||||
maestro-protocol = { workspace = true, features = ["client"] }
|
||||
nix.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -43,6 +43,23 @@ pub enum Command {
|
||||
Kill(KillArgs),
|
||||
/// List retained worktrees, or reclaim one by ID (#86).
|
||||
Worktrees(WorktreesArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
Hooks(HooksArgs),
|
||||
}
|
||||
|
||||
/// `maestro hooks <install>`
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct HooksArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: HooksCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum HooksCommand {
|
||||
/// Write the Claude Code activity hook into ~/.claude/settings.json.
|
||||
/// Idempotent: an older maestro hook is updated in place, every other
|
||||
/// hook is left untouched.
|
||||
Install,
|
||||
}
|
||||
|
||||
/// `maestro worktrees <list|reclaim>`
|
||||
@@ -275,6 +292,23 @@ mod tests {
|
||||
assert!(err.to_string().contains("KEY=VALUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hooks_install() {
|
||||
let cli = Cli::try_parse_from(["maestro", "hooks", "install"]).unwrap();
|
||||
match cli.command {
|
||||
Command::Hooks(h) => {
|
||||
assert!(matches!(h.command, HooksCommand::Install));
|
||||
}
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
// No subcommand → help, like `worktrees`.
|
||||
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
|
||||
assert!(matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_list() {
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
||||
|
||||
@@ -24,6 +24,10 @@ pub enum CliError {
|
||||
#[error("{0}")]
|
||||
Attach(#[from] crate::attach::AttachError),
|
||||
#[error("{0}")]
|
||||
Hooks(#[from] maestro_hooks::HooksError),
|
||||
#[error("could not determine the home directory ($HOME unset?)")]
|
||||
NoHome,
|
||||
#[error("{0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
@@ -40,6 +44,15 @@ pub async fn dispatch(
|
||||
None => default_config_path()?,
|
||||
};
|
||||
|
||||
// `hooks install` is purely local: it must work with the daemon down, so
|
||||
// it never touches the client.
|
||||
if let Command::Hooks(args) = &command {
|
||||
match args.command {
|
||||
crate::cli::HooksCommand::Install => run_hooks_install()?,
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let daemon = client(&config_path, daemon_url_override)?;
|
||||
|
||||
match command {
|
||||
@@ -77,6 +90,7 @@ pub async fn dispatch(
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
||||
Command::Worktrees(args) => match args.command {
|
||||
WorktreesCommand::List(list_args) => {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
@@ -101,6 +115,25 @@ pub async fn dispatch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||
/// user's data dir and register it in their Claude Code settings. Idempotent
|
||||
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn run_hooks_install() -> Result<(), CliError> {
|
||||
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||
let changed = maestro_hooks::install_for_home(&home)?;
|
||||
if changed {
|
||||
println!(
|
||||
"installed claude-code activity hook: {} → {}",
|
||||
maestro_hooks::script_path(&home).display(),
|
||||
maestro_hooks::settings_path(&home).display()
|
||||
);
|
||||
} else {
|
||||
println!("claude-code activity hook already up to date");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
||||
const CTRL_Q: u8 = 0x11;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
||||
SessionStatus, WorktreeSpec,
|
||||
@@ -138,21 +139,6 @@ pub(crate) fn blocked_label(s: &Session) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
||||
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
||||
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||
let p = path.to_string_lossy();
|
||||
if !home.is_empty() {
|
||||
if p == home {
|
||||
return "~".into();
|
||||
}
|
||||
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
|
||||
return format!("~/{rest}");
|
||||
}
|
||||
}
|
||||
p.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||
|
||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||
use crate::sessions::abbreviate_dir;
|
||||
|
||||
/// `maestro worktrees list`
|
||||
pub async fn run_list(
|
||||
|
||||
@@ -64,6 +64,24 @@ pub struct MaestroConfigFile {
|
||||
pub tui: TuiSection,
|
||||
#[serde(default)]
|
||||
pub spawn: SpawnSection,
|
||||
#[serde(default)]
|
||||
pub hooks: HooksSection,
|
||||
}
|
||||
|
||||
/// `[hooks]` — agent-hook installation settings (#106).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct HooksSection {
|
||||
/// Whether the daemon installs the Claude Code activity hook into
|
||||
/// `~/.claude/settings.json` on first start when no maestro hook is
|
||||
/// there yet. Default `true`; set `auto_install = false` to opt out.
|
||||
#[serde(default)]
|
||||
pub auto_install: Option<bool>,
|
||||
}
|
||||
|
||||
impl HooksSection {
|
||||
pub fn auto_install(&self) -> bool {
|
||||
self.auto_install.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
@@ -375,6 +393,26 @@ mod tests {
|
||||
assert_eq!(cfg, MaestroConfigFile::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||
// Absent file / absent [hooks] ⇒ auto_install on.
|
||||
assert!(HooksSection::default().auto_install());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
assert!(MaestroConfigFile::load(&path).unwrap().hooks.auto_install());
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[hooks]
|
||||
auto_install = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!MaestroConfigFile::load(&path).unwrap().hooks.auto_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_presets_default_to_builtins_plus_custom() {
|
||||
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
|
||||
|
||||
@@ -18,6 +18,7 @@ dirs.workspace = true
|
||||
futures-util.workspace = true
|
||||
libc.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-hooks.workspace = true
|
||||
maestro-names.workspace = true
|
||||
maestro-protocol.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -47,6 +47,20 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
let _ = default_state_dir();
|
||||
std::fs::create_dir_all(config.sidecar_dir())?;
|
||||
std::fs::create_dir_all(config.fifo_dir())?;
|
||||
|
||||
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||
// session spawned right away should already carry the hook.
|
||||
if config.hooks_auto_install {
|
||||
match crate::hooks::auto_install(&config.home_dir) {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!("installed claude-code activity hook (#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
|
||||
// under their base repos, created on demand (#56).
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ pub struct DaemonConfig {
|
||||
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
||||
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
||||
pub project_scan_depth: u32,
|
||||
/// Whether the daemon installs the Claude Code activity hook on first
|
||||
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||
/// `true`.
|
||||
pub hooks_auto_install: bool,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
@@ -197,6 +201,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
spawn_presets,
|
||||
project_roots,
|
||||
project_scan_depth,
|
||||
hooks_auto_install: file.hooks.auto_install(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,6 +238,27 @@ pub fn system_hostname() -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert!(cfg.hooks_auto_install);
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[hooks]
|
||||
auto_install = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert!(!cfg.hooks_auto_install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_defaults_everything() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! First-start hook auto-install (#106).
|
||||
//!
|
||||
//! The daemon runs [`auto_install`] once at startup: when the user's
|
||||
//! `~/.claude/settings.json` carries no maestro hook yet (and
|
||||
//! `[hooks].auto_install` hasn't opted out — checked by the caller), the same
|
||||
//! installer `maestro hooks install` uses writes it. Best-effort: a failure is
|
||||
//! warned about and never blocks startup. The same global hook serves every
|
||||
//! Claude Code session on the machine, so nothing here targets repositories
|
||||
//! or worktrees.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Install the Claude Code activity hook for `home_dir` unless one is already
|
||||
/// present or the home dir is unresolvable (`""`). Returns what happened.
|
||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
||||
if home_dir.is_empty() {
|
||||
return AutoInstallOutcome::Skipped;
|
||||
}
|
||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
||||
if maestro_hooks::is_installed(&settings) {
|
||||
return AutoInstallOutcome::AlreadyPresent;
|
||||
}
|
||||
match maestro_hooks::install_for_home(Path::new(home_dir)) {
|
||||
Ok(_) => AutoInstallOutcome::Installed,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
|
||||
AutoInstallOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`auto_install`] did, so callers can log and tests can assert.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutoInstallOutcome {
|
||||
Installed,
|
||||
AlreadyPresent,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Everything happens under a temp `$HOME`; the developer's own
|
||||
/// `~/.claude/settings.json` is never read or written.
|
||||
#[test]
|
||||
fn installs_when_settings_carry_no_maestro_hook() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outcome = auto_install(dir.path().to_str().unwrap());
|
||||
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
assert!(maestro_hooks::is_installed(&settings));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_start_is_a_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
// A foreign edit between starts survives; presence alone skips.
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
let before = std::fs::read_to_string(&settings).unwrap();
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read_to_string(&settings).unwrap(),
|
||||
"already-installed settings must not be rewritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_home_is_skipped() {
|
||||
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod cli;
|
||||
pub mod config;
|
||||
pub mod dirlist;
|
||||
pub mod gitroot;
|
||||
pub mod hooks;
|
||||
pub mod local_api;
|
||||
pub mod panes;
|
||||
pub mod proc;
|
||||
|
||||
@@ -62,6 +62,11 @@ pub struct SessionRecord {
|
||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||
/// field is daemon-local bookkeeping only — it never rides the wire.
|
||||
pub reported_activity: Option<crate::activity::ReportedActivity>,
|
||||
/// Flipped once the natural-exit settle step (#82) has been applied or
|
||||
/// refused for this session. `Dead` is observable before settling ends
|
||||
/// (#115), so this is the observable that says its outcomes are safe to
|
||||
/// assert on. Daemon-local bookkeeping; never rides the wire.
|
||||
pub settled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -294,6 +299,17 @@ impl SessionManager {
|
||||
sessions.get(&id).map(|r| r.session.status)
|
||||
}
|
||||
|
||||
/// Whether the natural-exit settle step (#82) has finished for this
|
||||
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
||||
/// still pending, `None` if the session is unknown. `Dead` is observable
|
||||
/// before settling ends (#115), so anything asserting on a dead
|
||||
/// session's worktree waits on this rather than inferring settle
|
||||
/// completion from its side effects.
|
||||
pub async fn is_settled(&self, id: Uuid) -> Option<bool> {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
sessions.get(&id).map(|r| r.settled)
|
||||
}
|
||||
|
||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
||||
@@ -429,6 +445,7 @@ impl SessionManager {
|
||||
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -657,6 +674,7 @@ impl SessionManager {
|
||||
worktree_branch: None,
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
@@ -1069,6 +1087,14 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Settle has now been applied or refused either way (#115): from here on
|
||||
// its outcomes are safe to observe.
|
||||
{
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.settled = true;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = inner.sidecar.write(&session);
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||
|
||||
@@ -189,8 +189,23 @@ fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
|
||||
"never observed a foreground group whose leader had exited"
|
||||
);
|
||||
|
||||
assert!(
|
||||
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
|
||||
"a surviving pipeline member still holds the tty"
|
||||
);
|
||||
// The leader can be gone while the surviving stage is still setting up:
|
||||
// `true` exits as soon as both stages are forked, before the reader has
|
||||
// opened the tty and parked. Poll for the answer, but only accept it
|
||||
// while the state this test exists for still holds — otherwise a shell
|
||||
// that regained the terminal would satisfy it vacuously.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut holds = false;
|
||||
while std::time::Instant::now() < deadline {
|
||||
let groups = ProcGroups::snapshot();
|
||||
let still_leaderless = pgrp_and_tpgid(pane.pane_pid).is_some_and(|(pgrp, tpgid)| {
|
||||
tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none()
|
||||
});
|
||||
if still_leaderless && awaiting_input(pane.pane_pid, &pane.tty, &groups) {
|
||||
holds = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(holds, "a surviving pipeline member still holds the tty");
|
||||
}
|
||||
|
||||
@@ -397,6 +397,12 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
// Subscribe before the watcher starts so every pass's emission is
|
||||
// captured — otherwise the breakdown event can land between the
|
||||
// snapshot observation below and the drain, and the quiet window
|
||||
// would see it as an illegitimate re-emit.
|
||||
let mut events = fx.manager.events();
|
||||
|
||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
@@ -418,14 +424,38 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
})
|
||||
.await;
|
||||
|
||||
// A steady state must not re-emit every tick (#83 change-detection).
|
||||
let mut events = fx.manager.events();
|
||||
// Consume through the emission that carried the breakdown (#83
|
||||
// change-detection): the quiet window below must start after the last
|
||||
// legitimate change, not race it into the channel.
|
||||
loop {
|
||||
match tokio::time::timeout(Duration::from_secs(5), events.recv()).await {
|
||||
Ok(Ok(CatalogEvent::SessionUpdated { session }))
|
||||
if matches!(
|
||||
&session.blocked_work,
|
||||
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||
) =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
_ => panic!("never saw the SessionUpdated carrying the breakdown"),
|
||||
}
|
||||
}
|
||||
while events.try_recv().is_ok() {}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
assert!(
|
||||
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
|
||||
"an unchanged worktree must not re-emit SessionUpdated"
|
||||
);
|
||||
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
|
||||
// The activity pass may still legitimately announce e.g. Active→Idle
|
||||
// (its debounce is time-based, not worktree-based), and its payload
|
||||
// carries today's blocked_work unchanged.
|
||||
while let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
|
||||
assert!(
|
||||
matches!(
|
||||
&session.blocked_work,
|
||||
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||
),
|
||||
"an unchanged worktree must not re-emit a changed indicator"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||
@@ -769,6 +799,14 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
||||
"a dirty worktree must be retained on natural exit"
|
||||
);
|
||||
// ...in the store, under the exit reason.
|
||||
// (`Dead` is observable a moment before the settle step's store write lands.)
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.retained_store()
|
||||
.load_all()
|
||||
.map(|all| all.len() == 1)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.await;
|
||||
let all = fx.retained_store().load_all().expect("load_all");
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].path, wt_path);
|
||||
@@ -840,6 +878,17 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
})
|
||||
.await;
|
||||
|
||||
// (`Dead` is observable a moment before the settle step removes the tree.)
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
!wt_path.exists()
|
||||
&& fx
|
||||
.retained_store()
|
||||
.load_all()
|
||||
.map(|all| all.is_empty())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!wt_path.exists(),
|
||||
"a pristine worktree leaves nothing behind"
|
||||
@@ -897,6 +946,14 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
||||
})
|
||||
.await;
|
||||
|
||||
// The settle step has been applied or refused either way — here refused,
|
||||
// since there is no store to record the tree into. Only from this point
|
||||
// is "the tree survived" a settled fact rather than a race (#115).
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
manager.is_settled(s.id).await == Some(true)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"nothing may be destroyed when it cannot be recorded"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "maestro-hooks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Agent activity hooks (#106): shipped script + idempotent settings.json installer"
|
||||
|
||||
[dependencies]
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Claude Code activity hook (#106).
|
||||
//!
|
||||
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
|
||||
//! the daemon's first-start auto-install:
|
||||
//!
|
||||
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
||||
//! posts an [`ActivityState`](maestro-protocol) to
|
||||
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
|
||||
//! 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
|
||||
//! daemon failure escape (short curl timeout, status ignored).
|
||||
//! * [`install`] — idempotent registration of that script in
|
||||
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
|
||||
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
|
||||
//! in the file is preserved verbatim.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Marker substring every maestro-installed hook command carries. The
|
||||
/// installer rewrites or removes exactly these entries — nothing else in the
|
||||
/// user's settings file.
|
||||
pub const HOOK_MARKER: &str = "maestro-claude-hook";
|
||||
|
||||
/// File name of the installed script (carries [`HOOK_MARKER`]).
|
||||
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||
|
||||
/// Claude Code hook event → `ActivityState` spelling posted to the daemon.
|
||||
///
|
||||
/// `PreToolUse` is what makes a session `Active` *during* a long tool call —
|
||||
/// `PostToolUse` alone only fires once it ends (#114). `StopFailure` is
|
||||
/// 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
|
||||
/// `Error`.
|
||||
pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
// Tool starting: the agent is demonstrably producing work, even if the
|
||||
// tool itself runs for minutes.
|
||||
("PreToolUse", "Active"),
|
||||
// Tool finished successfully.
|
||||
("PostToolUse", "Active"),
|
||||
// Permission / idle notices: Claude is blocked on the user.
|
||||
("Notification", "AwaitingInput"),
|
||||
// Turn ended: waiting on the user again.
|
||||
("Stop", "AwaitingInput"),
|
||||
// Turn ended on an API error; the payload's `error` field is forwarded
|
||||
// as the report's `detail` (#114).
|
||||
("StopFailure", "Error"),
|
||||
];
|
||||
|
||||
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
||||
# Exits silently unless running inside a session maestro spawned (#103 env).
|
||||
# Event -> ActivityState mapping:
|
||||
# PreToolUse -> Active (tool started; agent is working)
|
||||
# PostToolUse -> Active (tool finished)
|
||||
# Notification -> AwaitingInput (permission / idle notice)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (turn ended on an API error)
|
||||
state="$1"
|
||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||
[ -n "$MAESTRO_URL" ] || exit 0
|
||||
payload=$(cat 2>/dev/null) || true
|
||||
body="{\"state\":\"$state\"}"
|
||||
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
||||
# spelling is reported as the detail — anything else (quotes, backslashes,
|
||||
# prose) would risk breaking the JSON body, and the spelling already names
|
||||
# the failure.
|
||||
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
||||
if [ -n "$detail" ]; then
|
||||
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
|
||||
fi
|
||||
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$body" \
|
||||
"$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity" \
|
||||
>/dev/null 2>&1 || true
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HooksError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Where the script is written for a given home directory:
|
||||
/// `<home>/.local/share/maestro/maestro-claude-hook.sh`.
|
||||
pub fn script_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// The user-level Claude Code settings file for a given home directory.
|
||||
pub fn settings_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".claude/settings.json")
|
||||
}
|
||||
|
||||
/// Whether `settings_path` already carries a maestro hook (#106).
|
||||
pub fn is_installed(settings_path: &Path) -> bool {
|
||||
settings_json(settings_path)
|
||||
.map(|root| find_maestro_commands(&root).next().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Write [`HOOK_SCRIPT`] to `path`, executable. Parent directories are
|
||||
/// created as needed — this is always under the caller's chosen data dir,
|
||||
/// never inside a repository.
|
||||
pub fn write_script(path: &Path) -> Result<(), HooksError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, HOOK_SCRIPT)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the full hook setup for `home`: write the script under
|
||||
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
|
||||
/// Idempotent; returns whether anything changed on disk.
|
||||
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
|
||||
let script = script_path(home);
|
||||
let settings = settings_path(home);
|
||||
let before = std::fs::read_to_string(&settings).unwrap_or_default();
|
||||
write_script(&script)?;
|
||||
install(&settings, &script)?;
|
||||
let after = std::fs::read_to_string(&settings).unwrap_or_default();
|
||||
Ok(before != after)
|
||||
}
|
||||
|
||||
/// Register the hook command for every event in [`HOOK_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
|
||||
/// user's real home or a temp one in tests, never a repository.
|
||||
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
|
||||
let mut root = settings_json(settings_path)?;
|
||||
|
||||
// Strip every maestro entry wherever it currently sits, so an older
|
||||
// install under different events is replaced rather than duplicated.
|
||||
strip_maestro_entries(&mut root);
|
||||
|
||||
let hooks = ensure_object(&mut root, "hooks");
|
||||
for (event, state) in HOOK_EVENTS {
|
||||
let groups = ensure_array(hooks, event);
|
||||
let mut group = serde_json::Map::new();
|
||||
group.insert(
|
||||
"hooks".into(),
|
||||
serde_json::json!([{
|
||||
"type": "command",
|
||||
"command": format!("{} {state}", script_path.display()),
|
||||
}]),
|
||||
);
|
||||
groups.push(serde_json::Value::Object(group));
|
||||
}
|
||||
|
||||
write_atomic(settings_path, &root)
|
||||
}
|
||||
|
||||
fn settings_json(path: &Path) -> Result<serde_json::Value, HooksError> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(s) => Ok(serde_json::from_str(&s)?),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(serde_json::Value::Object(Default::default()))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command string in the file belonging to a maestro hook.
|
||||
fn find_maestro_commands(root: &serde_json::Value) -> impl Iterator<Item = &str> {
|
||||
root.get("hooks")
|
||||
.and_then(|h| h.as_object())
|
||||
.into_iter()
|
||||
.flat_map(|events| events.values())
|
||||
.filter_map(|groups| groups.as_array())
|
||||
.flatten()
|
||||
.filter_map(|group| group.get("hooks"))
|
||||
.filter_map(|inner| inner.as_array())
|
||||
.flatten()
|
||||
.filter_map(|hook| hook.get("command"))
|
||||
.filter_map(|cmd| cmd.as_str())
|
||||
.filter(|cmd| cmd.contains(HOOK_MARKER))
|
||||
}
|
||||
|
||||
/// Remove all maestro inner-hook entries from every event group, dropping
|
||||
/// groups left empty. Non-maestro siblings survive untouched.
|
||||
fn strip_maestro_entries(root: &mut serde_json::Value) {
|
||||
let Some(events) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
|
||||
return;
|
||||
};
|
||||
for (_, groups) in events.iter_mut() {
|
||||
let Some(list) = groups.as_array_mut() else {
|
||||
continue;
|
||||
};
|
||||
for group in list.iter_mut() {
|
||||
let Some(inner) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
|
||||
continue;
|
||||
};
|
||||
inner.retain(|hook| {
|
||||
!hook
|
||||
.get("command")
|
||||
.and_then(|c| c.as_str())
|
||||
.is_some_and(|c| c.contains(HOOK_MARKER))
|
||||
});
|
||||
}
|
||||
list.retain(|group| {
|
||||
group
|
||||
.get("hooks")
|
||||
.and_then(|h| h.as_array())
|
||||
.is_some_and(|h| !h.is_empty())
|
||||
});
|
||||
}
|
||||
// An event left with no groups at all (ours emptied it, or the user's
|
||||
// file carried an empty list) reads better gone.
|
||||
events.retain(|_, groups| groups.as_array().is_some_and(|g| !g.is_empty()));
|
||||
}
|
||||
|
||||
fn ensure_object<'a>(
|
||||
root: &'a mut serde_json::Value,
|
||||
key: &str,
|
||||
) -> &'a mut serde_json::Map<String, serde_json::Value> {
|
||||
if !root.get(key).map(|v| v.is_object()).unwrap_or(false) {
|
||||
root[key] = serde_json::json!({});
|
||||
}
|
||||
root.get_mut(key).unwrap().as_object_mut().unwrap()
|
||||
}
|
||||
|
||||
fn ensure_array<'a>(
|
||||
parent: &'a mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
) -> &'a mut Vec<serde_json::Value> {
|
||||
let v = parent
|
||||
.entry(key.to_string())
|
||||
.or_insert_with(|| serde_json::json!([]));
|
||||
if !v.is_array() {
|
||||
*v = serde_json::json!([]);
|
||||
}
|
||||
v.as_array_mut().unwrap()
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, value: &serde_json::Value) -> Result<(), HooksError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.maestro-tmp");
|
||||
std::fs::write(&tmp, serde_json::to_string_pretty(value)? + "\n")?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
||||
//! and the settings-file installer. Everything happens under a temp `$HOME`;
|
||||
//! the developer's own `~/.claude/settings.json` is never touched.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
// `sh -c CMD NAME ARGS…`: NAME fills $0, so the real arguments start at $1.
|
||||
let mut argv = vec!["-c", script, HOOK_MARKER];
|
||||
argv.extend(args.iter().copied());
|
||||
cmd.args(&argv);
|
||||
// A clean env: only what the test passes, plus PATH so curl can spawn.
|
||||
cmd.env_clear();
|
||||
cmd.env("PATH", std::env::var("PATH").unwrap_or_default());
|
||||
for (k, v) in envs {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn /bin/sh");
|
||||
// Feed a Claude-Code-style payload on stdin; the script must drain it.
|
||||
child.stdin.take().unwrap().write_all(payload).ok();
|
||||
child.wait_with_output().expect("wait")
|
||||
}
|
||||
|
||||
const SESSION: &str = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
|
||||
/// Accept exactly one HTTP request on a throwaway listener, answer 204, and
|
||||
/// hand back the raw request text. Runs `run` with the base URL once the
|
||||
/// acceptor thread is in place.
|
||||
fn capture_request(run: impl FnOnce(String) -> std::process::Output) -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut sock, _) = listener.accept().unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = sock.read(&mut buf).unwrap();
|
||||
sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&buf[..n]).to_string()
|
||||
});
|
||||
let out = run(format!("http://{addr}"));
|
||||
assert!(out.status.success(), "{:?}", out.stderr);
|
||||
handle.join().unwrap()
|
||||
}
|
||||
|
||||
fn session_envs(url: &str) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("MAESTRO_URL", url.to_string()),
|
||||
("MAESTRO_SESSION_ID", SESSION.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
|
||||
envs.iter().map(|(k, v)| (*k, v.as_str())).collect()
|
||||
}
|
||||
|
||||
/// The hook must be invisible outside maestro: no env vars (#103 unset) ⇒
|
||||
/// exit 0, no output, stdin drained.
|
||||
#[test]
|
||||
fn script_exits_silently_without_maestro_env() {
|
||||
for (event, _) in HOOK_EVENTS {
|
||||
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
||||
assert!(out.stderr.is_empty(), "{event:?}: {:?}", out.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
/// With both env vars set the script POSTs the mapped state to the daemon's
|
||||
/// activity endpoint and still exits 0.
|
||||
#[test]
|
||||
fn script_posts_mapped_state_to_the_activity_endpoint() {
|
||||
// The installer bakes the mapped state into the command's argv; the
|
||||
// script relays whatever it is given.
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Active"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"x"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.starts_with("POST "), "{request}");
|
||||
assert!(
|
||||
request.contains(&format!("/api/sessions/{SESSION}/activity")),
|
||||
"{request}"
|
||||
);
|
||||
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||
}
|
||||
|
||||
/// Every event maps to the state the contract specifies.
|
||||
#[test]
|
||||
fn event_state_mapping_matches_the_contract() {
|
||||
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
|
||||
assert_eq!(map["PreToolUse"], "Active");
|
||||
assert_eq!(map["PostToolUse"], "Active");
|
||||
assert_eq!(map["Notification"], "AwaitingInput");
|
||||
assert_eq!(map["Stop"], "AwaitingInput");
|
||||
assert_eq!(map["StopFailure"], "Error");
|
||||
}
|
||||
|
||||
/// A `StopFailure` payload names the API error that ended the turn; the
|
||||
/// script reports its `error` spelling as the report's `detail` so a card
|
||||
/// can show why (#114).
|
||||
#[test]
|
||||
fn script_reports_the_error_spelling_as_detail() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Error"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"abc","hook_event_name":"StopFailure","error":"rate_limit","error_details":"429 Too Many Requests"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||
}
|
||||
|
||||
/// An `error` value that is not a plain enum spelling (or a payload without
|
||||
/// one at all) must never produce a broken body — no detail is posted.
|
||||
#[test]
|
||||
fn script_posts_no_detail_for_an_unexpected_error_value() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Error"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"hook_event_name":"StopFailure","error":"429 \"Too Many\" \\ requests"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||
assert!(!request.contains("detail"), "{request}");
|
||||
}
|
||||
|
||||
/// Events whose payload carries no error fields post a bare body — never an
|
||||
/// empty or stale detail.
|
||||
#[test]
|
||||
fn script_posts_no_detail_for_events_without_an_error() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["AwaitingInput"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"AwaitingInput""#), "{request}");
|
||||
assert!(!request.contains("detail"), "{request}");
|
||||
}
|
||||
|
||||
/// A dead or unreachable daemon must never fail — or slow down — a session.
|
||||
#[test]
|
||||
fn script_ignores_daemon_failure() {
|
||||
// Port 1 on localhost is closed in practice; curl fails fast.
|
||||
let out = sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Stop"],
|
||||
&[
|
||||
("MAESTRO_URL", "http://127.0.0.1:1"),
|
||||
("MAESTRO_SESSION_ID", "00000000-0000-0000-0000-000000000000"),
|
||||
],
|
||||
br#"{"session_id":"x"}"#,
|
||||
);
|
||||
assert!(out.status.success());
|
||||
}
|
||||
|
||||
// --- installer -------------------------------------------------------------
|
||||
|
||||
fn installed_commands(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()
|
||||
.flat_map(|events| events.values())
|
||||
.filter_map(|g| g.as_array())
|
||||
.flatten()
|
||||
.filter_map(|g| g.get("hooks"))
|
||||
.filter_map(|h| h.as_array())
|
||||
.flatten()
|
||||
.filter_map(|h| h.get("command"))
|
||||
.filter_map(|c| c.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fresh install registers exactly one command per event, all pointing at the
|
||||
/// script with the right state argument.
|
||||
#[test]
|
||||
fn fresh_install_registers_every_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
write_script(&script_path(home)).unwrap();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings_path(home));
|
||||
assert_eq!(cmds.len(), HOOK_EVENTS.len());
|
||||
let joined = cmds.join("\n");
|
||||
assert_eq!(joined.matches("Active").count(), 2);
|
||||
assert!(joined.contains("Error"));
|
||||
assert_eq!(joined.matches("AwaitingInput").count(), 2);
|
||||
for cmd in &cmds {
|
||||
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
||||
}
|
||||
}
|
||||
|
||||
/// Running install twice changes nothing further — no duplicates.
|
||||
#[test]
|
||||
fn install_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
let once = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
assert_eq!(once, twice);
|
||||
assert_eq!(
|
||||
installed_commands(&settings_path(home)).len(),
|
||||
HOOK_EVENTS.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// An older maestro entry under the same events is updated in place, not
|
||||
/// duplicated next to the new one.
|
||||
#[test]
|
||||
fn install_updates_an_older_maestro_hook_in_place() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
|
||||
let old_script = "/old/location/maestro-claude-hook.sh Active";
|
||||
std::fs::write(
|
||||
&settings,
|
||||
serde_json::json!({
|
||||
"model": "sonnet",
|
||||
"hooks": {
|
||||
"Stop": [{"hooks": [{"type": "command", "command": old_script}]}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let new_script = dir
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(&settings, &new_script).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.len(),
|
||||
HOOK_EVENTS.len(),
|
||||
"old entry replaced, not kept: {cmds:?}"
|
||||
);
|
||||
assert!(!cmds.iter().any(|c| c == old_script));
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
assert_eq!(root["model"], "sonnet", "unrelated fields survive");
|
||||
}
|
||||
|
||||
/// A stale maestro entry under an event we no longer use is cleaned up too;
|
||||
/// the user's own hooks are never touched.
|
||||
#[test]
|
||||
fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&settings,
|
||||
serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [{"matcher": "Bash", "hooks": [
|
||||
{"type": "command", "command": "my-own-linter.sh"},
|
||||
]}],
|
||||
"UserPromptSubmit": [{"hooks": [
|
||||
{"type": "command", "command": "/old/maestro-claude-hook.sh Active"},
|
||||
]}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let new_script = dir
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(&settings, &new_script).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
// Foreign hook untouched.
|
||||
let pre = root["hooks"]["PreToolUse"].as_array().unwrap();
|
||||
assert_eq!(pre[0]["hooks"][0]["command"], "my-own-linter.sh");
|
||||
// Stale maestro event gone.
|
||||
assert!(root["hooks"].get("UserPromptSubmit").is_none());
|
||||
// Our events present, nothing else maestro-related anywhere.
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||
HOOK_EVENTS.len(),
|
||||
"{cmds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `is_installed` flips only once a maestro entry exists.
|
||||
#[test]
|
||||
fn is_installed_tracks_reality() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!is_installed(&settings_path(home)));
|
||||
install_for_home(home).unwrap();
|
||||
assert!(is_installed(&settings_path(home)));
|
||||
}
|
||||
|
||||
/// The full home-level install leaves the script executable.
|
||||
#[test]
|
||||
fn install_for_home_writes_an_executable_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_for_home(home).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(script_path(home))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o111, 0o111);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Path-abbreviation helpers shared by every client that renders a session's
|
||||
//! directory (CLI, TUI, web (#117)). Ports of `web/src/types.ts` and
|
||||
//! `web/src/tree.ts`'s `abbreviateCwd`, `abbreviateFolderName`, `mergeLinear`,
|
||||
//! and `sessionTreePath` — keep them in sync.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
|
||||
/// untouched. Mirrors the web's `abbreviateCwd`.
|
||||
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
|
||||
let p = path.to_string_lossy();
|
||||
if !home.is_empty() {
|
||||
if p == home {
|
||||
return "~".into();
|
||||
}
|
||||
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
|
||||
return format!("~/{rest}");
|
||||
}
|
||||
}
|
||||
p.into_owned()
|
||||
}
|
||||
|
||||
/// Result of [`abbreviate_folder_name`]: the last path segment kept verbatim,
|
||||
/// and — when there was something to collapse — the shortened lead-in a
|
||||
/// caller can render dimmed (the web's `.folder-stem`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AbbreviatedFolderName {
|
||||
/// The collapsed earlier segments, joined by `/`, with no trailing
|
||||
/// slash. `None` when `name` had two or fewer segments — nothing to
|
||||
/// gain from collapsing, so `last` carries the name unabbreviated.
|
||||
pub stem: Option<String>,
|
||||
pub last: String,
|
||||
}
|
||||
|
||||
/// `~/git.naps.pt/naps62/maestro` → stem `~/g/n`, last `maestro`: keep the
|
||||
/// last segment verbatim, collapse every earlier one to its first character.
|
||||
/// Two-or-fewer segments are left alone. Mirrors the web's
|
||||
/// `abbreviateFolderName`, minus the HTML escaping/wrapping — a ratatui
|
||||
/// caller styles `stem` dim itself rather than parsing markup out of it.
|
||||
pub fn abbreviate_folder_name(name: &str) -> AbbreviatedFolderName {
|
||||
let parts: Vec<&str> = name.split('/').collect();
|
||||
if parts.len() <= 2 {
|
||||
return AbbreviatedFolderName {
|
||||
stem: None,
|
||||
last: name.to_string(),
|
||||
};
|
||||
}
|
||||
let last = parts[parts.len() - 1].to_string();
|
||||
let stem = parts[..parts.len() - 1]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if *p == "~" {
|
||||
"~".to_string()
|
||||
} else {
|
||||
p.chars().next().map(String::from).unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
AbbreviatedFolderName {
|
||||
stem: Some(stem),
|
||||
last,
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory a session lives in: its live `current_cwd` if the daemon
|
||||
/// has observed one, else the spawn `cwd`. Mirrors the web's `sessionCwd`.
|
||||
fn session_cwd(s: &Session) -> Option<PathBuf> {
|
||||
if let Some(cur) = &s.current_cwd {
|
||||
if !cur.as_os_str().is_empty() {
|
||||
return Some(cur.clone());
|
||||
}
|
||||
}
|
||||
if !s.cwd.as_os_str().is_empty() {
|
||||
return Some(s.cwd.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where a session sits in the directory tree. A worktree session belongs to
|
||||
/// its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
||||
/// grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
||||
/// session of the same name, and the repository stops being where you look
|
||||
/// for the work. Mirrors the web's `sessionTreePath`.
|
||||
pub fn session_tree_path(s: &Session) -> Option<PathBuf> {
|
||||
if let Some(wt) = &s.worktree {
|
||||
return Some(wt.base_repo.clone());
|
||||
}
|
||||
session_cwd(s)
|
||||
}
|
||||
|
||||
/// One node of a directory tree grouping sessions by path, generic over the
|
||||
/// leaf payload since building the tree itself (grouping key, sort order,
|
||||
/// filtering) is not this crate's job — only [`merge_linear`] is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TreeNode<T> {
|
||||
Folder(TreeFolder<T>),
|
||||
Leaf(T),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TreeFolder<T> {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub children: Vec<TreeNode<T>>,
|
||||
}
|
||||
|
||||
/// Fold a folder with a single folder-child into one node (`a` + `b` →
|
||||
/// `a/b`), repeating down the chain, then recurse into the surviving
|
||||
/// branches. The merged node keeps the deepest path so collapse/expand state
|
||||
/// stays stable as it grows. Mirrors the web's `mergeLinear`.
|
||||
pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
||||
let mut cur = folder;
|
||||
loop {
|
||||
let single_folder_child =
|
||||
cur.children.len() == 1 && matches!(cur.children[0], TreeNode::Folder(_));
|
||||
if !single_folder_child {
|
||||
break;
|
||||
}
|
||||
let child = match cur.children.pop().expect("checked len == 1 above") {
|
||||
TreeNode::Folder(f) => f,
|
||||
TreeNode::Leaf(_) => unreachable!("checked matches! above"),
|
||||
};
|
||||
cur = TreeFolder {
|
||||
name: format!("{}/{}", cur.name, child.name),
|
||||
path: child.path,
|
||||
children: child.children,
|
||||
};
|
||||
}
|
||||
cur.children = cur
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|c| match c {
|
||||
TreeNode::Folder(f) => TreeNode::Folder(merge_linear(f)),
|
||||
leaf => leaf,
|
||||
})
|
||||
.collect();
|
||||
cur
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{SessionStatus, SessionWorktree};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn session(cwd: &str) -> Session {
|
||||
Session {
|
||||
id: Uuid::from_u128(1),
|
||||
cwd: PathBuf::from(cwd),
|
||||
command: "bash".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_dir_collapses_home() {
|
||||
assert_eq!(
|
||||
abbreviate_dir(Path::new("/home/naps/maestro"), "/home/naps"),
|
||||
"~/maestro"
|
||||
);
|
||||
assert_eq!(abbreviate_dir(Path::new("/home/naps"), "/home/naps"), "~");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_dir_leaves_paths_outside_home_untouched() {
|
||||
assert_eq!(
|
||||
abbreviate_dir(Path::new("/srv/work/api"), "/home/naps"),
|
||||
"/srv/work/api"
|
||||
);
|
||||
assert_eq!(
|
||||
abbreviate_dir(Path::new("/srv/work/api"), ""),
|
||||
"/srv/work/api"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_folder_name_collapses_the_stem() {
|
||||
let got = abbreviate_folder_name("~/git.naps.pt/naps62/maestro");
|
||||
assert_eq!(
|
||||
got,
|
||||
AbbreviatedFolderName {
|
||||
stem: Some("~/g/n".into()),
|
||||
last: "maestro".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Two-or-fewer segments are left alone — nothing to gain from collapsing.
|
||||
#[test]
|
||||
fn abbreviate_folder_name_two_segment_no_op() {
|
||||
assert_eq!(
|
||||
abbreviate_folder_name("~/work"),
|
||||
AbbreviatedFolderName {
|
||||
stem: None,
|
||||
last: "~/work".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
abbreviate_folder_name("work"),
|
||||
AbbreviatedFolderName {
|
||||
stem: None,
|
||||
last: "work".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_tree_path_prefers_worktree_base_repo() {
|
||||
let mut s = session("/home/n/tea/agent-skills/worktrees/largo-clarinet");
|
||||
s.worktree = Some(SessionWorktree {
|
||||
path: PathBuf::from("/home/n/tea/agent-skills/worktrees/largo-clarinet"),
|
||||
base_repo: PathBuf::from("/home/n/tea/agent-skills"),
|
||||
});
|
||||
assert_eq!(
|
||||
session_tree_path(&s),
|
||||
Some(PathBuf::from("/home/n/tea/agent-skills"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_tree_path_falls_back_to_cwd_without_a_worktree() {
|
||||
let s = session("/home/n/tea/arr");
|
||||
assert_eq!(
|
||||
session_tree_path(&s),
|
||||
Some(PathBuf::from("/home/n/tea/arr"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_tree_path_prefers_live_current_cwd_over_spawn_cwd() {
|
||||
let mut s = session("/home/n/tea/arr");
|
||||
s.current_cwd = Some(PathBuf::from("/home/n/tea/arr/crates"));
|
||||
assert_eq!(
|
||||
session_tree_path(&s),
|
||||
Some(PathBuf::from("/home/n/tea/arr/crates"))
|
||||
);
|
||||
}
|
||||
|
||||
fn leaf(name: &str) -> TreeNode<String> {
|
||||
TreeNode::Leaf(name.into())
|
||||
}
|
||||
|
||||
fn folder(name: &str, path: &str, children: Vec<TreeNode<String>>) -> TreeFolder<String> {
|
||||
TreeFolder {
|
||||
name: name.into(),
|
||||
path: path.into(),
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/// The web's mergeLinear test: keeps the deepest path so collapse state
|
||||
/// survives growth.
|
||||
#[test]
|
||||
fn merge_linear_folds_a_chain_and_keeps_the_deepest_path() {
|
||||
let tree = folder("a", "a", vec![TreeNode::Folder(folder("b", "a/b", vec![]))]);
|
||||
let merged = merge_linear(tree);
|
||||
assert_eq!(merged.name, "a/b");
|
||||
assert_eq!(merged.path, "a/b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_linear_stops_where_a_directory_branches() {
|
||||
let tree = folder(
|
||||
"git",
|
||||
"git",
|
||||
vec![
|
||||
TreeNode::Folder(folder("one", "git/one", vec![])),
|
||||
TreeNode::Folder(folder("two", "git/two", vec![])),
|
||||
],
|
||||
);
|
||||
let merged = merge_linear(tree);
|
||||
assert_eq!(merged.name, "git");
|
||||
assert_eq!(merged.children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_linear_stops_at_a_leaf() {
|
||||
let tree = folder("a", "a", vec![leaf("session")]);
|
||||
let merged = merge_linear(tree);
|
||||
assert_eq!(merged.name, "a");
|
||||
assert_eq!(merged.children, vec![leaf("session")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_linear_recurses_into_surviving_branches() {
|
||||
let tree = folder(
|
||||
"root",
|
||||
"root",
|
||||
vec![
|
||||
TreeNode::Folder(folder(
|
||||
"a",
|
||||
"root/a",
|
||||
vec![TreeNode::Folder(folder("b", "root/a/b", vec![leaf("s")]))],
|
||||
)),
|
||||
leaf("other"),
|
||||
],
|
||||
);
|
||||
let merged = merge_linear(tree);
|
||||
assert_eq!(merged.children.len(), 2);
|
||||
let TreeNode::Folder(nested) = &merged.children[0] else {
|
||||
panic!("expected folder")
|
||||
};
|
||||
assert_eq!(nested.name, "a/b");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
pub mod client;
|
||||
pub mod display;
|
||||
pub mod routes;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
@@ -123,7 +124,12 @@ pub enum SessionStatus {
|
||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
||||
/// inspection — never by parsing the terminal byte stream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
///
|
||||
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
||||
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
||||
/// whole `Session`. Non-string values still error — only variant names are
|
||||
/// forgiven.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||
pub enum ActivityState {
|
||||
/// Producing output, or the foreground process is busy.
|
||||
Active,
|
||||
@@ -137,11 +143,34 @@ pub enum ActivityState {
|
||||
Error,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ActivityState {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(match s.as_str() {
|
||||
"Active" => Self::Active,
|
||||
"AwaitingInput" => Self::AwaitingInput,
|
||||
"Error" => Self::Error,
|
||||
// Known or not, any other spelling degrades to the neutral default
|
||||
_ => Self::Idle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP `POST /api/sessions/:id/activity` body (#105): an agent's report of
|
||||
/// its own state. The daemon holds the reported state above its own inference
|
||||
/// for a fixed TTL, then resumes inference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportActivityRequest {
|
||||
/// Parsed strictly, unlike [`ActivityState`]'s own lenient `Deserialize`
|
||||
/// (#110). Leniency exists so a client reading a *newer* daemon's snapshot
|
||||
/// degrades an unrecognised state to `Idle` instead of dropping the whole
|
||||
/// session. A report travelling the other way has no such excuse: an agent
|
||||
/// posting a state this daemon does not know is a bug in the agent, and
|
||||
/// silently recording it as `Idle` would hide that behind a 204.
|
||||
#[serde(deserialize_with = "strict_activity_state")]
|
||||
pub state: ActivityState,
|
||||
/// Short human string shown next to the state (e.g. an error message).
|
||||
/// The daemon caps its length server-side.
|
||||
@@ -149,6 +178,26 @@ pub struct ReportActivityRequest {
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
||||
/// `Idle`. Used only by [`ReportActivityRequest`]; see the note there.
|
||||
fn strict_activity_state<'de, D>(deserializer: D) -> Result<ActivityState, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error as _;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
match s.as_str() {
|
||||
"Active" => Ok(ActivityState::Active),
|
||||
"Idle" => Ok(ActivityState::Idle),
|
||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||
"Error" => Ok(ActivityState::Error),
|
||||
other => Err(D::Error::unknown_variant(
|
||||
other,
|
||||
&["Active", "Idle", "AwaitingInput", "Error"],
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional worktree request attached to a `CreateSessionRequest`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorktreeSpec {
|
||||
@@ -658,6 +707,35 @@ mod tests {
|
||||
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
||||
}
|
||||
|
||||
// #110 / SIDEBAR.md Precedence: a future daemon may send an
|
||||
// `ActivityState` variant this client does not know; it must degrade to
|
||||
// the neutral default instead of failing the whole `Session`.
|
||||
#[test]
|
||||
fn unknown_activity_state_variant_deserializes_to_idle() {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ActivityState>(serde_json::json!("Teleported")).unwrap(),
|
||||
ActivityState::Idle
|
||||
);
|
||||
|
||||
let mut future = serde_json::to_value(sample_session()).unwrap();
|
||||
future["activity"] = serde_json::json!("QuantumEntangled");
|
||||
let s: Session = serde_json::from_value(future).unwrap();
|
||||
assert_eq!(s.activity, ActivityState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_string_activity_payload_still_fails_deserialization() {
|
||||
let json = serde_json::json!({
|
||||
"id": Uuid::from_u128(1),
|
||||
"cwd": "/tmp",
|
||||
"command": "bash",
|
||||
"status": "Running",
|
||||
"created_at": "2026-05-24T12:00:00Z",
|
||||
"activity": 42,
|
||||
});
|
||||
assert!(serde_json::from_value::<Session>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_detail_defaults_to_none_on_legacy_payload() {
|
||||
// #105: a daemon predating the detail field omits it; it must
|
||||
|
||||
@@ -160,12 +160,26 @@ impl Sidebar {
|
||||
spans.push(Span::styled(" ", base));
|
||||
spans.push(Span::styled(format_state_duration(d), dot_style));
|
||||
}
|
||||
// The trailing fragments are measured up front so a long detail
|
||||
// truncates ahead of them instead of pushing them off the line
|
||||
// (docs/SIDEBAR.md).
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
spans.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||
}
|
||||
if dead {
|
||||
spans.push(Span::styled(" exited", muted));
|
||||
tail.push(Span::styled(" exited", muted));
|
||||
}
|
||||
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
||||
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||
let budget = width.saturating_sub(head_w + tail_w);
|
||||
spans.extend(fit_to_width(
|
||||
vec![Span::styled(format!(" {detail}"), muted)],
|
||||
budget,
|
||||
));
|
||||
}
|
||||
spans.extend(tail);
|
||||
Some(fit_to_width(spans, width))
|
||||
}
|
||||
}
|
||||
@@ -531,6 +545,93 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #112: the agent-reported detail sits right after the duration on the
|
||||
/// meta line, in the muted style (docs/SIDEBAR.md).
|
||||
#[test]
|
||||
fn errored_session_shows_its_reported_detail_on_the_meta_line() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = ActivityState::Error;
|
||||
s.activity_since = Utc::now() - Duration::seconds(90);
|
||||
s.activity_detail = Some("cargo build failed".into());
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = &all[idx + 1];
|
||||
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("1m"),
|
||||
"duration precedes the detail: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
meta.contains("cargo build failed"),
|
||||
"reported detail must render: {meta:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fresh Error report earns line 2 on its own (contract rule 4), with
|
||||
/// the detail even though no duration has accrued.
|
||||
#[test]
|
||||
fn fresh_errored_session_earns_a_meta_line_for_its_detail() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = ActivityState::Error;
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.activity_detail = Some("waiting for approval".into());
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
assert!(
|
||||
all[idx + 1].contains("waiting for approval"),
|
||||
"detail must render on a fresh report's own meta line: {:?}",
|
||||
all[idx + 1]
|
||||
);
|
||||
}
|
||||
|
||||
/// #112 via docs/SIDEBAR.md: the detail truncates with `…` ahead of the
|
||||
/// trailing fragments — a long detail never pushes blocked work or
|
||||
/// `exited` off the line, and never wraps.
|
||||
#[test]
|
||||
fn long_detail_yields_to_blocked_work_and_exited() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dead;
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.activity_detail =
|
||||
Some("an absurdly long error message that cannot possibly fit ".repeat(3));
|
||||
s.blocked_work = Some(InTheWay {
|
||||
unpushed_commits: 2,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = content(&all[idx + 1]);
|
||||
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("2 unpushed commits"),
|
||||
"blocked work stays on the line: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
meta.contains("exited"),
|
||||
"exited fragment stays on the line: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
content(&all[idx + 2]).trim().is_empty(),
|
||||
"meta line must not wrap: {:?}",
|
||||
all[idx + 2]
|
||||
);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
|
||||
/// `activity_since` and so contributes no duration — a card must not claim
|
||||
/// the session has been idle since 1970. The dot still renders when some
|
||||
|
||||
@@ -55,6 +55,9 @@ enum Command {
|
||||
/// Kill a session by ID.
|
||||
#[cfg(feature = "cli")]
|
||||
Kill(maestro_cli::cli::KillArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
#[cfg(feature = "cli")]
|
||||
Hooks(maestro_cli::cli::HooksArgs),
|
||||
/// Per-host daemon: tmux wrapper + session API.
|
||||
#[cfg(feature = "daemon")]
|
||||
Daemon {
|
||||
@@ -123,6 +126,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
)
|
||||
.await?
|
||||
}
|
||||
#[cfg(feature = "cli")]
|
||||
Command::Hooks(args) => {
|
||||
maestro_cli::dispatch(
|
||||
maestro_cli::cli::Command::Hooks(args),
|
||||
cli.config,
|
||||
cli.daemon_url,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
#[cfg(feature = "daemon")]
|
||||
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
|
||||
#[cfg(feature = "tui")]
|
||||
@@ -157,7 +169,11 @@ fn init_tracing(cli: &Cli) {
|
||||
fn is_cli_subcommand(command: &Command) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
Command::List(_) | Command::Spawn(_) | Command::Attach(_) | Command::Kill(_)
|
||||
Command::List(_)
|
||||
| Command::Spawn(_)
|
||||
| Command::Attach(_)
|
||||
| Command::Kill(_)
|
||||
| Command::Hooks(_)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+50
-26
@@ -5,8 +5,7 @@ sidebar (#107) and the TUI sidebar (#108) describe a session the same way.
|
||||
Read this before changing either. Both clients implement exactly what is
|
||||
written here; if it needs to change, change it here first.
|
||||
|
||||
A card has **line 1** always and **line 2** only when line 2 has something to
|
||||
say.
|
||||
A card is always two lines: **line 1** and **line 2**.
|
||||
|
||||
## Line 1 — name + actions
|
||||
|
||||
@@ -25,41 +24,46 @@ badge move off this row into line 2.
|
||||
|
||||
## Line 2 — meta line (adaptive)
|
||||
|
||||
Rendered only when at least one of these holds:
|
||||
Line 2 always renders — never an empty placeholder row either, since its
|
||||
unconditional fragments (see below) always have something to show. Its
|
||||
*fragments* are adaptive: some show only when their trigger holds. The line
|
||||
itself is not.
|
||||
|
||||
1. The session has been held in its current activity state longer than
|
||||
**30 seconds** (`activity_since` from #102). A freshly-active session
|
||||
stays one line.
|
||||
2. Its worktree holds blocked work: `blocked_work` is `Some` and not clear
|
||||
(`InTheWay::is_clear()`), rendered in `formatInTheWay`'s phrasing on web
|
||||
and as `⚠ <summary>` on the TUI.
|
||||
3. It has exited: `status == Dead`.
|
||||
4. An agent-reported state (#105) of `Error` is in force (see Precedence).
|
||||
|
||||
Otherwise line 2 is absent — never an empty placeholder row.
|
||||
|
||||
When present, line 2 reads left to right:
|
||||
Line 2 reads left to right:
|
||||
|
||||
```
|
||||
<dot> <duration> [<detail>] [<blocked work> | exited] [<command>]
|
||||
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
|
||||
```
|
||||
|
||||
Its minimum content is therefore the dot and the command.
|
||||
|
||||
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
||||
state's colour, and `<duration>` follows in the same colour. There is no state
|
||||
word and no second badge: the dot exists so the state is still legible when a
|
||||
row is dimmed or the terminal renders the duration in the default fg. The dot
|
||||
and duration are unconditional parts of line 2; the bracketed fragments appear
|
||||
only when their trigger holds. A payload that predates #102 (no usable
|
||||
`activity_since`) contributes no duration — the dot still renders.
|
||||
state's colour, and `<duration>` follows in the same colour when shown. There
|
||||
is no state word and no second badge: the dot exists so the state is still
|
||||
legible when a row is dimmed or the terminal renders the duration in the
|
||||
default fg. The dot is an unconditional part of line 2; the bracketed
|
||||
fragments appear only when their trigger holds.
|
||||
|
||||
`<duration>` renders only once the session has been held in its current
|
||||
activity state longer than **30 seconds** (`activity_since` from #102) — under
|
||||
that threshold the duration is absent and the dot renders alone. A payload
|
||||
that predates #102 (no usable `activity_since`) likewise contributes no
|
||||
duration — the dot still renders. This rule now governs only the duration
|
||||
fragment; it no longer decides whether line 2 exists.
|
||||
|
||||
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||
`Error`. It sits right after the duration, in the muted style, and shows only
|
||||
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.
|
||||
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
|
||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
|
||||
`<command>` is optional and muted: the session's command line, shown only when
|
||||
the rendered name came from `metadata.name` and differs from the command (the
|
||||
web has always surfaced this; the TUI may omit it).
|
||||
`<command>` is unconditional and muted: the session's command line, shown on
|
||||
every card in both clients — no omission in the TUI, no name-vs-command
|
||||
comparison gating it. A session named by its command line then shows the same
|
||||
string on both lines; that is accepted, the price of a row that never changes
|
||||
height.
|
||||
|
||||
## Colour per activity state
|
||||
|
||||
@@ -104,7 +108,27 @@ When the TTL expires, inference resumes and the session's reported detail —
|
||||
if any — clears with the report. Clients never blend the two: whichever source
|
||||
is in force supplies both the colour and the clock for `activity_since`.
|
||||
|
||||
Two rulings specific to a reported `Error` (#114):
|
||||
|
||||
- **The five-minute TTL applies to `Error` like to any other state.** A
|
||||
session that failed is still failed after five minutes, but a card pinned
|
||||
red forever lies once the session moves on; when the report lapses the card
|
||||
returns to the daemon's own inference — usually `Idle` for a session that
|
||||
has gone quiet.
|
||||
- **Only a new report clears an `Error` before its TTL.** The next
|
||||
`ActivityState` of any kind replaces it (with #106's hook that is typically
|
||||
`PreToolUse` → `Active` when the user prompts again). PTY output and
|
||||
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
|
||||
stacking reports.
|
||||
|
||||
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)
|
||||
until the daemon sends a variant it does know. Rust clients deserialize
|
||||
activity leniently for the same reason.
|
||||
|
||||
Leniency runs one way only. It covers a client *reading* a newer daemon, where
|
||||
dropping the whole session would be worse than dimming one card. A report
|
||||
arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
|
||||
state is a 4xx: an agent posting a state the daemon does not know is a bug in
|
||||
the agent, and answering 204 would hide it.
|
||||
|
||||
@@ -105,6 +105,38 @@ describe("blocked-work indicator (#83)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent-reported detail (#112)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("renders the reported detail right after the duration on line 2", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000020",
|
||||
status: "Dead",
|
||||
activity: "Error",
|
||||
activity_since: ago(90_000),
|
||||
activity_detail: "cargo build failed",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
|
||||
const order = [...line2!.children].map((c) => c.className);
|
||||
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
|
||||
expect(order.indexOf("activity-detail")).toBeLessThan(
|
||||
order.findIndex((c) => c.startsWith("card-flag")),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the detail when no report is in force", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000021",
|
||||
activity: "Active",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session cards (#107)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ function metaLine(s: Session, now: number): string {
|
||||
const fragments = [
|
||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
||||
s.activity_detail
|
||||
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
||||
: "",
|
||||
blockers
|
||||
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||
: "",
|
||||
|
||||
@@ -851,6 +851,17 @@ textarea {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Agent-reported detail (#112): sits after the duration, shrinks with an
|
||||
ellipsis so it never pushes the trailing fragments off the line. */
|
||||
.activity-detail {
|
||||
color: var(--faint);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
|
||||
signals severity and "exited" dims with the row. */
|
||||
.card-flag {
|
||||
|
||||
Reference in New Issue
Block a user