Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa846000e7 |
@@ -96,32 +96,20 @@ pub async fn dispatch(
|
||||
}
|
||||
|
||||
/// `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
|
||||
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
|
||||
/// agent failing is reported and does not stop the others.
|
||||
/// 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 script = maestro_hooks::script_path(&home);
|
||||
for (target, result) in maestro_hooks::install_for_home(&home) {
|
||||
let agent = target.label();
|
||||
let settings = target.settings_path(&home);
|
||||
match result {
|
||||
Ok(true) => {
|
||||
println!(
|
||||
"installed {agent} activity hook: {} → {}",
|
||||
script.display(),
|
||||
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}"),
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -252,6 +252,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// session spawned right away should already carry the hook.
|
||||
if config.hooks_auto_install {
|
||||
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
|
||||
let agent = target.label();
|
||||
match outcome {
|
||||
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"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
match crate::hooks::auto_install(&config.home_dir) {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!("installed claude-code activity hook (#106)");
|
||||
}
|
||||
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
|
||||
|
||||
@@ -1,53 +1,37 @@
|
||||
//! First-start hook auto-install (#106).
|
||||
//!
|
||||
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
|
||||
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
|
||||
//! file already carries exactly what this build ships (and `[hooks]
|
||||
//! .auto_install` hasn't opted out — checked by the caller), the same
|
||||
//! installer `maestro hooks install` uses writes it. That covers upgrades as
|
||||
//! well as first starts: an install from a maestro that had fewer events —
|
||||
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
|
||||
//! settings — is rewritten rather than left half-wired. Best-effort: a
|
||||
//! failure is warned about and never blocks startup, and one target failing
|
||||
//! does not stop the others. The same global hook serves every session on the
|
||||
//! machine, so nothing here targets repositories or worktrees.
|
||||
//! The daemon runs [`auto_install`] once at startup: unless the user's
|
||||
//! `~/.claude/settings.json` already carries exactly what this build ships
|
||||
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
|
||||
//! same installer `maestro hooks install` uses writes it. That covers
|
||||
//! upgrades as well as first starts: an install from a maestro that had fewer
|
||||
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
|
||||
//! everyone's settings — is rewritten rather than left half-wired. 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;
|
||||
|
||||
use maestro_hooks::Target;
|
||||
|
||||
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
|
||||
/// any whose install is already current. Returns what happened per target —
|
||||
/// an older install being brought up to date is
|
||||
/// [`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 {
|
||||
/// Install the Claude Code activity hook for `home_dir` unless the current
|
||||
/// one is already there or the home dir is unresolvable (`""`). Returns what
|
||||
/// happened; an older install being brought up to date is
|
||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
|
||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
||||
if home_dir.is_empty() {
|
||||
return AutoInstallOutcome::Skipped;
|
||||
}
|
||||
let home = Path::new(home_dir);
|
||||
let settings = target.settings_path(home);
|
||||
let script = maestro_hooks::script_path(home);
|
||||
if maestro_hooks::is_current(target, &settings, &script) {
|
||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
||||
let script = maestro_hooks::script_path(Path::new(home_dir));
|
||||
if maestro_hooks::is_current(&settings, &script) {
|
||||
return AutoInstallOutcome::AlreadyPresent;
|
||||
}
|
||||
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(_) => AutoInstallOutcome::Installed,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target = target.label(),
|
||||
error = %e,
|
||||
"hooks auto-install failed (non-fatal)"
|
||||
);
|
||||
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
|
||||
AutoInstallOutcome::Failed
|
||||
}
|
||||
}
|
||||
@@ -68,50 +52,15 @@ pub enum AutoInstallOutcome {
|
||||
mod tests {
|
||||
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
|
||||
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
|
||||
/// written.
|
||||
/// `~/.claude/settings.json` is never read or written.
|
||||
#[test]
|
||||
fn installs_when_settings_carry_no_maestro_hook() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outcomes = auto_install(dir.path().to_str().unwrap());
|
||||
for target in Target::ALL {
|
||||
assert_eq!(
|
||||
outcome(&outcomes, target),
|
||||
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"
|
||||
);
|
||||
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]
|
||||
@@ -120,22 +69,14 @@ mod tests {
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
// A current install is left exactly as it is.
|
||||
let before: Vec<String> = Target::ALL
|
||||
.iter()
|
||||
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
|
||||
.collect();
|
||||
let outcomes = auto_install(home);
|
||||
for (i, target) in Target::ALL.iter().enumerate() {
|
||||
assert_eq!(
|
||||
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"
|
||||
);
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// An install from an older maestro — present, but missing an event this
|
||||
@@ -146,7 +87,7 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().to_str().unwrap();
|
||||
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 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(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
|
||||
let outcomes = auto_install(home);
|
||||
assert_eq!(
|
||||
outcome(&outcomes, Target::ClaudeCode),
|
||||
AutoInstallOutcome::Upgraded
|
||||
);
|
||||
assert!(maestro_hooks::is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings,
|
||||
&script
|
||||
));
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
|
||||
assert!(maestro_hooks::is_current(&settings, &script));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_home_is_skipped() {
|
||||
let outcomes = auto_install("");
|
||||
for target in Target::ALL {
|
||||
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
|
||||
}
|
||||
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +307,11 @@ async fn report_activity(
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<ReportActivityRequest>,
|
||||
) -> Response {
|
||||
match s.manager.report_activity(id, body.state, body.detail).await {
|
||||
match s
|
||||
.manager
|
||||
.report_activity(id, body.state, body.detail, body.session_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
|
||||
@@ -478,6 +478,7 @@ impl SessionManager {
|
||||
// fresh tree has nothing in the way anyway.
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
};
|
||||
|
||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||
@@ -683,6 +684,7 @@ impl SessionManager {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
},
|
||||
};
|
||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||
@@ -881,11 +883,18 @@ impl SessionManager {
|
||||
/// A report that changes the effective state restarts `activity_since`
|
||||
/// (#104's clock); re-reporting an unchanged state only extends the TTL
|
||||
/// and stays silent on the event stream at steady state.
|
||||
///
|
||||
/// `agent_session_id` (#161) is the agent's own conversation id, parsed
|
||||
/// by the caller from the hook payload's `session_id` field. It is
|
||||
/// written once — the first report that carries one wins — and the
|
||||
/// sidecar is only rewritten on that transition, never on every report,
|
||||
/// since an agent's session id never changes mid-session.
|
||||
pub async fn report_activity(
|
||||
&self,
|
||||
id: Uuid,
|
||||
state: ActivityState,
|
||||
detail: Option<String>,
|
||||
agent_session_id: Option<String>,
|
||||
) -> Result<(), SessionError> {
|
||||
let updated = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
@@ -901,7 +910,16 @@ impl SessionManager {
|
||||
state,
|
||||
self.inner.report_ttl,
|
||||
));
|
||||
changed.then(|| rec.session.clone())
|
||||
let newly_identified =
|
||||
rec.session.agent_session_id.is_none() && agent_session_id.is_some();
|
||||
if newly_identified {
|
||||
rec.session.agent_session_id = agent_session_id;
|
||||
self.inner
|
||||
.sidecar
|
||||
.write(&rec.session)
|
||||
.map_err(SessionError::Sidecar)?;
|
||||
}
|
||||
(changed || newly_identified).then(|| rec.session.clone())
|
||||
};
|
||||
if let Some(session) = updated {
|
||||
let _ = self
|
||||
|
||||
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
||||
|
||||
struct Fixture {
|
||||
manager: SessionManager,
|
||||
sidecar: SidecarStore,
|
||||
_tmux: TmuxFixture,
|
||||
_state_dir: TempDir,
|
||||
}
|
||||
@@ -25,15 +26,17 @@ struct Fixture {
|
||||
async fn fixture(ttl: Duration) -> Fixture {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||
let manager = SessionManager::new(
|
||||
Arc::new(tmux.tmux.clone()),
|
||||
SidecarStore::new(state_dir.path().join("sessions")),
|
||||
sidecar.clone(),
|
||||
WorktreeManager::new(None),
|
||||
state_dir.path().join("fifos"),
|
||||
)
|
||||
.with_activity_report_ttl(ttl);
|
||||
Fixture {
|
||||
manager,
|
||||
sidecar,
|
||||
_tmux: tmux,
|
||||
_state_dir: state_dir,
|
||||
}
|
||||
@@ -67,7 +70,7 @@ async fn report_on_unknown_session_is_not_found() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let err = fx
|
||||
.manager
|
||||
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
|
||||
.report_activity(Uuid::new_v4(), ActivityState::Idle, None, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
||||
@@ -83,6 +86,7 @@ async fn report_sets_error_state_and_detail() {
|
||||
id,
|
||||
ActivityState::Error,
|
||||
Some("cargo build failed: link error".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -101,7 +105,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Error, Some("first failure".into()))
|
||||
.report_activity(id, ActivityState::Error, Some("first failure".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
fx.manager
|
||||
@@ -109,6 +113,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
|
||||
id,
|
||||
ActivityState::AwaitingInput,
|
||||
Some("needs approval".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -125,7 +130,7 @@ async fn detail_is_capped_server_side() {
|
||||
|
||||
let long = "x".repeat(10_000);
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Error, Some(long))
|
||||
.report_activity(id, ActivityState::Error, Some(long), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -144,7 +149,12 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
|
||||
.report_activity(
|
||||
id,
|
||||
ActivityState::Error,
|
||||
Some("agent died mid-run".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -162,3 +172,55 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_is_recorded_on_the_session() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||
}
|
||||
|
||||
/// #161: a session id does not change mid-session, so once set it is never
|
||||
/// overwritten by a later report — even one that names a different id.
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_is_written_once_and_never_replaced() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Active, None, Some("conv-second".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_survives_recovery_from_the_sidecar() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = fx
|
||||
.sidecar
|
||||
.read(id)
|
||||
.expect("sidecar read")
|
||||
.expect("sidecar present");
|
||||
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||
}
|
||||
|
||||
@@ -526,6 +526,33 @@ async fn post_activity_reports_error_with_detail() {
|
||||
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
||||
}
|
||||
|
||||
/// #161: a reported `session_id` lands on the session as `agent_session_id`.
|
||||
#[tokio::test]
|
||||
async fn post_activity_records_the_reported_session_id() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let id = spawn_via_manager(&h).await;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||
.json(&serde_json::json!({
|
||||
"state": "Active",
|
||||
"session_id": "conv-abc123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_activity_to_unknown_session_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
|
||||
@@ -32,6 +32,7 @@ fn sample(id: Uuid) -> Session {
|
||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: Some("conv-abc123".into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-144
@@ -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
|
||||
//! the daemon's first-start auto-install:
|
||||
@@ -6,40 +6,28 @@
|
||||
//! * [`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 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
|
||||
//! daemon failure escape (short curl timeout, status ignored).
|
||||
//! * [`install`] — idempotent registration of that script in one [`Target`]'s
|
||||
//! hook file. Only entries maestro itself wrote (identified by
|
||||
//! [`HOOK_MARKER`]) are touched; every other hook and every other field 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.
|
||||
//! * [`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.
|
||||
///
|
||||
/// 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";
|
||||
|
||||
/// File name of the installed script (carries [`HOOK_MARKER`]). One script
|
||||
/// for every [`Target`] — see the note on [`HOOK_MARKER`] for the name.
|
||||
/// File name of the installed script (carries [`HOOK_MARKER`]).
|
||||
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||
|
||||
/// Hook events installed only with the `haiku-autoname` feature (#131), for
|
||||
/// every [`Target`] — Claude Code and Codex both spell the event
|
||||
/// `UserPromptSubmit` and both put the submitted text in the payload's
|
||||
/// `prompt`. The script forwards the whole payload to
|
||||
/// `/api/sessions/:id/prompt`, which names a still-provisionally-named
|
||||
/// session after its first one.
|
||||
/// Hook events installed only with the `haiku-autoname` feature (#131).
|
||||
/// `UserPromptSubmit` carries the prompt the user just submitted; the script
|
||||
/// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
|
||||
/// still-provisionally-named session after its first one.
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
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
|
||||
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||
/// `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 itself runs for minutes.
|
||||
("PreToolUse", "Active"),
|
||||
@@ -70,86 +58,17 @@ pub const CLAUDE_EVENTS: [(&str, &str); 5] = [
|
||||
("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.
|
||||
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||
# Installed by maestro (#106): report agent activity to its daemon.
|
||||
# One script for every agent maestro installs hooks into; the state to post
|
||||
# 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).
|
||||
# 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 (claude code: permission / idle)
|
||||
# PermissionRequest -> AwaitingInput (codex: waiting on an approval)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (claude code: turn ended on an error)
|
||||
# Prompt -> the payload is forwarded to the prompt route (#131)
|
||||
# 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)
|
||||
# Prompt -> the payload is forwarded to the prompt route (#131)
|
||||
state="$1"
|
||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||
[ -n "$MAESTRO_URL" ] || exit 0
|
||||
@@ -165,15 +84,23 @@ if [ "$state" = "Prompt" ]; then
|
||||
>/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
body="{\"state\":\"$state\"}"
|
||||
fields="\"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\"}"
|
||||
fields="$fields,\"detail\":\"$detail\""
|
||||
fi
|
||||
# Every Claude Code hook payload carries its own conversation id (#161).
|
||||
# Constrained to a safe charset for the same reason as "detail" above: it is
|
||||
# spliced into the JSON body unescaped.
|
||||
session_id=$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
|
||||
if [ -n "$session_id" ]; then
|
||||
fields="$fields,\"session_id\":\"$session_id\""
|
||||
fi
|
||||
body="{$fields}"
|
||||
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$body" \
|
||||
@@ -196,29 +123,34 @@ pub fn script_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// Whether the file at `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`].
|
||||
/// 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). 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 {
|
||||
settings_json(settings_path)
|
||||
.map(|root| find_maestro_commands(&root).next().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether what is on disk is what this build ships for `target`: the script
|
||||
/// at `script_path` byte-for-byte, and exactly one settings entry per one of
|
||||
/// the target's [`Target::events`], pointing at that script.
|
||||
/// Whether what is on disk is what this build ships: the script at
|
||||
/// `script_path` byte-for-byte, and exactly one settings entry per event in
|
||||
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
|
||||
///
|
||||
/// This is the upgrade question, asked per [`Target`]. A maestro that gains
|
||||
/// an event — #131 added `UserPromptSubmit` long after #106 had installed
|
||||
/// itself — leaves every existing user with a present but incomplete
|
||||
/// install, which [`is_installed`] cannot tell apart from a current one.
|
||||
pub fn is_current(target: Target, settings_path: &Path, script_path: &Path) -> bool {
|
||||
/// This is the upgrade question. A maestro that gains an event — #131 added
|
||||
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
|
||||
/// existing user with a present but incomplete install, which
|
||||
/// [`is_installed`] cannot tell apart from a current one.
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -249,13 +181,14 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
|
||||
found
|
||||
}
|
||||
|
||||
/// What [`install`] writes for `target` and `script_path`, in the same shape.
|
||||
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> {
|
||||
let mut want: Vec<(String, String)> = target
|
||||
.events()
|
||||
/// What [`install`] writes for `script_path`, in the same shape.
|
||||
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
|
||||
let mut want: Vec<(String, String)> = HOOK_EVENTS
|
||||
.iter()
|
||||
.chain(PROMPT_EVENTS.iter())
|
||||
.map(|(event, state)| {
|
||||
(
|
||||
event.to_string(),
|
||||
(*event).to_string(),
|
||||
format!("{} {state}", script_path.display()),
|
||||
)
|
||||
})
|
||||
@@ -282,40 +215,28 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the full hook setup for one target under `home`: write the script
|
||||
/// under `.local/share/maestro/` and register it in the target's hook file.
|
||||
/// 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_target(target: Target, home: &Path) -> Result<bool, HooksError> {
|
||||
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
|
||||
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
|
||||
// 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)?;
|
||||
install(target, &settings, &script)?;
|
||||
install(&settings, &script)?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Install for every [`Target::ALL`], reporting each one separately. One
|
||||
/// target failing — an unwritable file, unparseable JSON — never stops the
|
||||
/// others: an agent whose hook could not be written is one agent without
|
||||
/// activity reporting, not a failed install.
|
||||
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.
|
||||
/// 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(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)?;
|
||||
|
||||
// Strip every maestro entry wherever it currently sits, so an older
|
||||
@@ -323,7 +244,7 @@ pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Resu
|
||||
strip_maestro_entries(&mut root);
|
||||
|
||||
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 mut group = serde_json::Map::new();
|
||||
group.insert(
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
//! 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` and `~/.codex/hooks.json`
|
||||
//! are never touched.
|
||||
//! the developer's own `~/.claude/settings.json` is never touched.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// How many commands one install writes for a target: its activity events,
|
||||
/// plus the prompt event when `haiku-autoname` is on (#131).
|
||||
fn installed_events(target: Target) -> usize {
|
||||
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)
|
||||
}
|
||||
/// How many commands one install writes: every activity event, plus the
|
||||
/// prompt event when `haiku-autoname` is on (#131).
|
||||
const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
|
||||
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||
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.
|
||||
#[test]
|
||||
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"}"#);
|
||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||
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.
|
||||
#[test]
|
||||
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["PostToolUse"], "Active");
|
||||
assert_eq!(map["Notification"], "AwaitingInput");
|
||||
@@ -137,6 +128,41 @@ fn script_reports_the_error_spelling_as_detail() {
|
||||
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||
}
|
||||
|
||||
/// #161: the payload's own `session_id` — the agent's conversation id — is
|
||||
/// parsed out and forwarded in the activity body, so the daemon can persist
|
||||
/// it on the session.
|
||||
#[test]
|
||||
fn script_forwards_the_payloads_session_id_in_the_activity_body() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Active"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"conv-abc123","hook_event_name":"PreToolUse"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||
assert!(
|
||||
request.contains(r#""session_id":"conv-abc123""#),
|
||||
"{request}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A payload without a `session_id` (or one with an unexpected shape) must
|
||||
/// never produce a broken body — no `session_id` field is posted.
|
||||
#[test]
|
||||
fn script_posts_no_session_id_when_the_payload_has_none() {
|
||||
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("session_id"), "{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]
|
||||
@@ -212,10 +238,10 @@ fn fresh_install_registers_every_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
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));
|
||||
assert_eq!(cmds.len(), installed_events(Target::ClaudeCode));
|
||||
assert_eq!(cmds.len(), INSTALLED_EVENTS);
|
||||
let joined = cmds.join("\n");
|
||||
assert_eq!(joined.matches("Active").count(), 2);
|
||||
assert!(joined.contains("Error"));
|
||||
@@ -230,15 +256,15 @@ fn fresh_install_registers_every_event() {
|
||||
fn install_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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();
|
||||
|
||||
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();
|
||||
assert_eq!(once, twice);
|
||||
assert_eq!(
|
||||
installed_commands(&settings_path(home)).len(),
|
||||
installed_events(Target::ClaudeCode)
|
||||
INSTALLED_EVENTS
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,12 +292,12 @@ fn install_updates_an_older_maestro_hook_in_place() {
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||
install(&settings, &new_script).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.len(),
|
||||
installed_events(Target::ClaudeCode),
|
||||
INSTALLED_EVENTS,
|
||||
"old entry replaced, not kept: {cmds:?}"
|
||||
);
|
||||
assert!(!cmds.iter().any(|c| c == old_script));
|
||||
@@ -307,7 +333,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||
install(&settings, &new_script).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
@@ -320,7 +346,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||
installed_events(Target::ClaudeCode),
|
||||
INSTALLED_EVENTS,
|
||||
"{cmds:?}"
|
||||
);
|
||||
}
|
||||
@@ -330,15 +356,9 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
fn is_installed_tracks_reality() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
for target in Target::ALL {
|
||||
assert!(!is_installed(&target.settings_path(home)));
|
||||
}
|
||||
for (_, result) in install_for_home(home) {
|
||||
result.unwrap();
|
||||
}
|
||||
for target in Target::ALL {
|
||||
assert!(is_installed(&target.settings_path(home)));
|
||||
}
|
||||
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.
|
||||
@@ -346,9 +366,7 @@ fn is_installed_tracks_reality() {
|
||||
fn install_for_home_writes_an_executable_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
for (_, result) in install_for_home(home) {
|
||||
result.unwrap();
|
||||
}
|
||||
install_for_home(home).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -367,7 +385,7 @@ fn install_for_home_writes_an_executable_script() {
|
||||
fn install_registers_the_prompt_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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 =
|
||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
||||
@@ -384,7 +402,7 @@ fn install_registers_the_prompt_event() {
|
||||
fn install_registers_no_prompt_event_without_the_feature() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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 =
|
||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
||||
@@ -453,12 +471,8 @@ fn the_script_posts_a_prompt_payload_unchanged() {
|
||||
fn is_current_rejects_an_install_missing_an_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_target(Target::ClaudeCode, home).unwrap();
|
||||
assert!(is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
install_for_home(home).unwrap();
|
||||
assert!(is_current(&settings_path(home), &script_path(home)));
|
||||
|
||||
// Drop one event, as an older install would have left the file.
|
||||
let mut root: serde_json::Value =
|
||||
@@ -471,11 +485,7 @@ fn is_current_rejects_an_install_missing_an_event() {
|
||||
.unwrap();
|
||||
|
||||
assert!(is_installed(&settings_path(home)));
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
assert!(!is_current(&settings_path(home), &script_path(home)));
|
||||
}
|
||||
|
||||
/// A settings file that is complete but points at a script from an older
|
||||
@@ -484,13 +494,9 @@ fn is_current_rejects_an_install_missing_an_event() {
|
||||
fn is_current_rejects_a_stale_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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();
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
assert!(!is_current(&settings_path(home), &script_path(home)));
|
||||
}
|
||||
|
||||
/// Nothing installed at all is not current.
|
||||
@@ -498,93 +504,18 @@ fn is_current_rejects_a_stale_script() {
|
||||
fn is_current_rejects_a_missing_install() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!is_current(
|
||||
Target::ClaudeCode,
|
||||
&settings_path(home),
|
||||
&script_path(home)
|
||||
));
|
||||
assert!(!is_current(&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.
|
||||
#[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 home = dir.path();
|
||||
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
||||
assert!(!install_target(Target::ClaudeCode, home).unwrap());
|
||||
assert!(install_for_home(home).unwrap());
|
||||
assert!(!install_for_home(home).unwrap());
|
||||
|
||||
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
assert!(install_target(Target::ClaudeCode, 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()
|
||||
assert!(install_for_home(home).unwrap());
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,13 @@ pub struct Session {
|
||||
/// sorts a never-focused session after every session that has one.
|
||||
#[serde(default)]
|
||||
pub last_focused_at: Option<DateTime<Utc>>,
|
||||
/// The conversation id the agent running in this session reported for
|
||||
/// itself (#161), parsed from a hook's `session_id` field. Written once,
|
||||
/// from the first activity report that carries one, and left alone after
|
||||
/// — an agent's own session id does not change mid-session. `None` for
|
||||
/// sessions that never run an agent (plain shells) or predate the field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||
@@ -184,6 +191,11 @@ pub struct ReportActivityRequest {
|
||||
/// The daemon caps its length server-side.
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
/// The agent's own conversation id (#161), forwarded from the hook
|
||||
/// payload's `session_id` field. `None` for hook events that don't carry
|
||||
/// one, or hooks predating this field.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
||||
@@ -862,6 +874,7 @@ mod tests {
|
||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1031,6 +1044,7 @@ mod tests {
|
||||
let full = ReportActivityRequest {
|
||||
state: ActivityState::AwaitingInput,
|
||||
detail: Some("waiting on permission".into()),
|
||||
session_id: Some("conv-abc123".into()),
|
||||
};
|
||||
assert_eq!(full, round_trip(&full));
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1898,6 +1898,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
@@ -2079,6 +2080,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
@@ -2106,6 +2108,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3214,6 +3217,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: focused,
|
||||
agent_session_id: None,
|
||||
};
|
||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
@@ -3492,6 +3496,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::KillHighlighted);
|
||||
@@ -3527,6 +3532,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -809,6 +809,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -500,6 +500,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,6 +1267,7 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
};
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
|
||||
@@ -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. |
|
||||
| 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 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. |
|
||||
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
|
||||
@@ -250,10 +250,6 @@ Two rulings specific to a reported `Error` (#114):
|
||||
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.
|
||||
- **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
|
||||
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
|
||||
|
||||
@@ -34,14 +34,12 @@ describe("resolveFastForward (#67)", () => {
|
||||
const res = resolveFastForward(projects, presets, "amber-falcon");
|
||||
expect(res.project?.path).toBe("/repos/fresh");
|
||||
expect(res.command).toBe("aider");
|
||||
expect(res.handle).toBe("amber-falcon");
|
||||
expect(res.name).toBe("amber-falcon");
|
||||
|
||||
const req = buildFastForwardRequest(res)!;
|
||||
expect(req.cwd).toBe("/repos/fresh");
|
||||
expect(req.command).toBe("aider");
|
||||
// No name (#131): the daemon names the session after its first prompt,
|
||||
// and a name here would collide with the branch handle.
|
||||
expect(req.metadata).toBeUndefined();
|
||||
expect(req.metadata).toEqual({ name: "amber-falcon" });
|
||||
expect(req.worktree).toEqual({
|
||||
base_repo: "/repos/fresh",
|
||||
branch: "amber-falcon",
|
||||
@@ -90,9 +88,9 @@ describe("resolveFastForward (#67)", () => {
|
||||
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);
|
||||
expect(res.handle).toBeUndefined();
|
||||
expect(res.name).toBeUndefined();
|
||||
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", () => {
|
||||
// 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
|
||||
|
||||
+13
-19
@@ -10,21 +10,19 @@ import { sortProjects } from "./projectList.js";
|
||||
export interface FastForwardResolution {
|
||||
project?: Project;
|
||||
command?: string;
|
||||
/** A generated handle for the worktree branch (#131). Not a session name:
|
||||
* the fast path names no session. */
|
||||
handle?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* picker shows) — so `N` lands where the operator currently is, matching
|
||||
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||
* falls back to the first non-custom preset, and the branch handle is
|
||||
* freshly generated when the daemon supplied one. */
|
||||
* falls back to the first non-custom preset, and the name is freshly
|
||||
* generated when the daemon supplied one. */
|
||||
export function resolveFastForward(
|
||||
projects: Project[],
|
||||
presets: SpawnPreset[],
|
||||
handle?: string,
|
||||
name?: string,
|
||||
focusedCwd?: string,
|
||||
): FastForwardResolution {
|
||||
const project =
|
||||
@@ -32,45 +30,41 @@ export function resolveFastForward(
|
||||
const lastCommand = project?.last_command?.trim();
|
||||
const command =
|
||||
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
|
||||
* generated handle — 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. */
|
||||
* generated session name — or `null` when any default is missing. */
|
||||
export function buildFastForwardRequest(
|
||||
res: FastForwardResolution,
|
||||
): CreateSessionRequest | null {
|
||||
const { project, command, handle } = res;
|
||||
if (!project || !command?.trim() || !handle) return null;
|
||||
const { project, command, name } = res;
|
||||
if (!project || !command?.trim() || !name) return null;
|
||||
return {
|
||||
cwd: project.path,
|
||||
command,
|
||||
metadata: { name },
|
||||
worktree: {
|
||||
base_repo: project.base_repo ?? project.path,
|
||||
branch: handle,
|
||||
branch: name,
|
||||
create_branch: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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
|
||||
* (#131) — the handle seeds the branch, not the session name. */
|
||||
* form; the rest stays with its own defaults. */
|
||||
export function fastForwardSeed(res: FastForwardResolution) {
|
||||
return {
|
||||
cwd: res.project?.path ?? "",
|
||||
command: res.command ?? "",
|
||||
name: "",
|
||||
name: res.name ?? "",
|
||||
// Left undefined rather than false when nothing resolved: the 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
|
||||
// (#67 review).
|
||||
worktreeEnabled: res.project ? true : undefined,
|
||||
worktreeBranch:
|
||||
res.project && res.handle ? res.handle : undefined,
|
||||
res.project && res.name ? res.name : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -132,9 +132,9 @@ function mount(p: KillDialogSpec): HTMLElement {
|
||||
};
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
open = { el, onKeydown };
|
||||
// The acting button takes the focus in both prompts: the operator already
|
||||
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
|
||||
confirm.focus();
|
||||
// Cancel takes the focus in both prompts: a kill is never one keystroke
|
||||
// away from whatever asked for the dialog.
|
||||
cancel.focus();
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -356,14 +356,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||
}
|
||||
|
||||
async function requestFastForward(): Promise<void> {
|
||||
const [discovered, handle] = await Promise.all([
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const presets = await spawnPresets();
|
||||
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
|
||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
@@ -390,7 +390,7 @@ async function requestFastForward(): Promise<void> {
|
||||
// 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.
|
||||
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const [discovered, handle] = await Promise.all([
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
@@ -399,7 +399,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const presets = await spawnPresets();
|
||||
const known = discovered.find((p) => p.path === 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);
|
||||
if (req) {
|
||||
const inRepo =
|
||||
|
||||
+3
-17
@@ -215,20 +215,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
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
|
||||
* the DOM (cheap path for click-to-focus). */
|
||||
function syncFocus(): void {
|
||||
@@ -247,7 +233,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (!preview) pool.setFocus(focusedLeafSession());
|
||||
sidebar.setHighlights({
|
||||
open: realOpenSessions(),
|
||||
focused: realFocusedSession(),
|
||||
focused: focusedLeafSession(),
|
||||
});
|
||||
// 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.
|
||||
@@ -622,7 +608,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
{ focus: !preview && !quietRender },
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -831,7 +817,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
openSessions: realOpenSessions,
|
||||
beginSessionDrag,
|
||||
render,
|
||||
focusedSessionId: realFocusedSession,
|
||||
focusedSessionId: focusedLeafSession,
|
||||
previewSession,
|
||||
previewedSessionId: () => preview?.sessionId ?? null,
|
||||
};
|
||||
|
||||
+1
-33
@@ -47,20 +47,12 @@ function fakePool(): Spy {
|
||||
|
||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||
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 {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights(h) {
|
||||
rowHighlights.open = h.open;
|
||||
rowHighlights.focused = h.focused;
|
||||
},
|
||||
setHighlights() {},
|
||||
setPreview(id: string | null) {
|
||||
previewRow.id = id;
|
||||
},
|
||||
@@ -103,8 +95,6 @@ describe("sidebar preview", () => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
previewRow.id = null;
|
||||
rowHighlights.open = new Set();
|
||||
rowHighlights.focused = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -144,28 +134,6 @@ describe("sidebar preview", () => {
|
||||
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", () => {
|
||||
const layout = twoTabs();
|
||||
const before = serializeLayout(layout);
|
||||
|
||||
Reference in New Issue
Block a user