diff --git a/Cargo.toml b/Cargo.toml index a15a776..426dc2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,10 @@ 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" } +# `default-features = false` so each consumer opts into `haiku-autoname` +# through its own feature of the same name (#131); a workspace entry that +# leaves defaults on would ignore the members' `default-features = false`. +maestro-hooks = { path = "crates/maestro-hooks", default-features = false } maestro-names = { path = "crates/maestro-names" } maestro-protocol = { path = "crates/maestro-protocol" } maestro-testing = { path = "crates/maestro-testing" } diff --git a/crates/maestro-cli/Cargo.toml b/crates/maestro-cli/Cargo.toml index 81720da..362e0e7 100644 --- a/crates/maestro-cli/Cargo.toml +++ b/crates/maestro-cli/Cargo.toml @@ -10,6 +10,11 @@ description = "`maestro` CLI: list, spawn, attach, kill sessions across daemons" [lib] path = "src/lib.rs" +[features] +default = ["haiku-autoname"] +# Install the first-prompt hook alongside the activity ones (#131). +haiku-autoname = ["maestro-hooks/haiku-autoname"] + [dependencies] chrono.workspace = true clap.workspace = true @@ -17,7 +22,7 @@ dirs.workspace = true futures-util.workspace = true libc.workspace = true maestro-config.workspace = true -maestro-hooks.workspace = true +maestro-hooks = { workspace = true, default-features = false } maestro-protocol = { workspace = true, features = ["client"] } nix.workspace = true serde_json.workspace = true diff --git a/crates/maestro-daemon/Cargo.toml b/crates/maestro-daemon/Cargo.toml index 7785c96..a75e059 100644 --- a/crates/maestro-daemon/Cargo.toml +++ b/crates/maestro-daemon/Cargo.toml @@ -10,6 +10,13 @@ description = "Per-host tmux wrapper + session orchestrator" [lib] path = "src/lib.rs" +[features] +default = ["haiku-autoname"] +# Name a session after its first prompt (#131): the `/api/sessions/:id/prompt` +# route, the `claude -p` naming run behind it, and the agent hook that feeds +# it. Off leaves sessions on the handle the daemon generates at spawn. +haiku-autoname = ["maestro-hooks/haiku-autoname"] + [dependencies] axum.workspace = true chrono.workspace = true @@ -18,7 +25,7 @@ dirs.workspace = true futures-util.workspace = true libc.workspace = true maestro-config.workspace = true -maestro-hooks.workspace = true +maestro-hooks = { workspace = true, default-features = false } maestro-names.workspace = true maestro-protocol.workspace = true serde.workspace = true diff --git a/crates/maestro-hooks/Cargo.toml b/crates/maestro-hooks/Cargo.toml index 1c6b004..b884750 100644 --- a/crates/maestro-hooks/Cargo.toml +++ b/crates/maestro-hooks/Cargo.toml @@ -7,6 +7,13 @@ repository.workspace = true publish.workspace = true description = "Agent activity hooks (#106): shipped script + idempotent settings.json installer" +[features] +default = ["haiku-autoname"] +# Also report the first prompt of a turn, so the daemon can name the session +# after it (#131). Off, no `UserPromptSubmit` entry is installed and the +# script never posts a prompt anywhere. +haiku-autoname = [] + [dependencies] serde_json.workspace = true thiserror.workspace = true diff --git a/crates/maestro-hooks/src/lib.rs b/crates/maestro-hooks/src/lib.rs index fcd4359..d801119 100644 --- a/crates/maestro-hooks/src/lib.rs +++ b/crates/maestro-hooks/src/lib.rs @@ -24,6 +24,18 @@ 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"; +/// 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")]; + +/// Same, with the feature off: nothing is installed, so the script never +/// posts a prompt and no hook points at a route the daemon does not serve. +#[cfg(not(feature = "haiku-autoname"))] +pub const PROMPT_EVENTS: [(&str, &str); 0] = []; + /// Claude Code hook event → `ActivityState` spelling posted to the daemon. /// /// `PreToolUse` is what makes a session `Active` *during* a long tool call — @@ -56,10 +68,22 @@ pub const HOOK_SCRIPT: &str = r#"#!/bin/sh # 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 payload=$(cat 2>/dev/null) || true +# "Prompt" is not an activity state (#131): the payload already carries the +# submitted prompt, so it is forwarded verbatim to the prompt route. Building +# the JSON here instead would mean quoting arbitrary user text in shell. +if [ "$state" = "Prompt" ]; then + printf '%s' "$payload" | curl -fsS --connect-timeout 1 --max-time 2 \ + -H 'Content-Type: application/json' \ + --data-binary @- \ + "$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/prompt" \ + >/dev/null 2>&1 || true + exit 0 +fi body="{\"state\":\"$state\"}" # StopFailure payloads carry the outcome in "error". Only a plain enum # spelling is reported as the detail — anything else (quotes, backslashes, @@ -149,7 +173,7 @@ pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksErro strip_maestro_entries(&mut root); let hooks = ensure_object(&mut root, "hooks"); - for (event, state) in HOOK_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( diff --git a/crates/maestro-hooks/src/tests.rs b/crates/maestro-hooks/src/tests.rs index 6f73169..d597fbb 100644 --- a/crates/maestro-hooks/src/tests.rs +++ b/crates/maestro-hooks/src/tests.rs @@ -7,6 +7,10 @@ use std::process::{Command, Stdio}; use super::*; +/// 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"); // `sh -c CMD NAME ARGS…`: NAME fills $0, so the real arguments start at $1. @@ -202,7 +206,7 @@ fn fresh_install_registers_every_event() { install(&settings_path(home), &script_path(home)).unwrap(); let cmds = installed_commands(&settings_path(home)); - assert_eq!(cmds.len(), HOOK_EVENTS.len()); + assert_eq!(cmds.len(), INSTALLED_EVENTS); let joined = cmds.join("\n"); assert_eq!(joined.matches("Active").count(), 2); assert!(joined.contains("Error")); @@ -225,7 +229,7 @@ fn install_is_idempotent() { assert_eq!(once, twice); assert_eq!( installed_commands(&settings_path(home)).len(), - HOOK_EVENTS.len() + INSTALLED_EVENTS ); } @@ -258,7 +262,7 @@ fn install_updates_an_older_maestro_hook_in_place() { let cmds = installed_commands(&settings); assert_eq!( cmds.len(), - HOOK_EVENTS.len(), + INSTALLED_EVENTS, "old entry replaced, not kept: {cmds:?}" ); assert!(!cmds.iter().any(|c| c == old_script)); @@ -281,7 +285,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() { "PreToolUse": [{"matcher": "Bash", "hooks": [ {"type": "command", "command": "my-own-linter.sh"}, ]}], - "UserPromptSubmit": [{"hooks": [ + "SessionEnd": [{"hooks": [ {"type": "command", "command": "/old/maestro-claude-hook.sh Active"}, ]}], }, @@ -302,12 +306,12 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() { 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()); + assert!(root["hooks"].get("SessionEnd").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(), + INSTALLED_EVENTS, "{cmds:?}" ); } @@ -338,3 +342,89 @@ fn install_for_home_writes_an_executable_script() { assert_eq!(mode & 0o111, 0o111); } } + +/// #131: the prompt event is installed alongside the activity ones, pointing +/// at the same script with the `Prompt` argument. +#[cfg(feature = "haiku-autoname")] +#[test] +fn install_registers_the_prompt_event() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + 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(); + let group = &root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]; + let cmd = group.as_str().unwrap(); + assert!(cmd.ends_with(" Prompt"), "{cmd}"); + assert!(cmd.contains(SCRIPT_FILE_NAME)); +} + +/// With the feature off nothing points at a route the daemon does not serve +/// (#131). +#[cfg(not(feature = "haiku-autoname"))] +#[test] +fn install_registers_no_prompt_event_without_the_feature() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + 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(); + assert!(root["hooks"].get("UserPromptSubmit").is_none()); +} + +/// The script forwards a prompt payload verbatim rather than rebuilding the +/// JSON in shell, so quotes and newlines in a prompt survive (#131). +#[test] +fn the_script_posts_a_prompt_payload_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let script = script_path(dir.path()); + write_script(&script).unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + // A `curl` that records its stdin and argv instead of making a request. + let record = dir.path().join("curl.log"); + std::fs::write( + bin.join("curl"), + format!( + "#!/bin/sh\ncat > {0}.body\nprintf '%s\\n' \"$@\" > {0}.argv\n", + record.display() + ), + ) + .unwrap(); + std::fs::set_permissions( + bin.join("curl"), + std::os::unix::fs::PermissionsExt::from_mode(0o755), + ) + .unwrap(); + + let payload = r#"{"prompt":"fix \"the\" parser\nthen run tests"}"#; + let mut child = Command::new("sh") + .arg(&script) + .arg("Prompt") + .env( + "PATH", + format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()), + ) + .env("MAESTRO_SESSION_ID", "abc") + .env("MAESTRO_URL", "http://127.0.0.1:7070") + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(payload.as_bytes()) + .unwrap(); + assert!(child.wait().unwrap().success()); + + let body = std::fs::read_to_string(format!("{}.body", record.display())).unwrap(); + assert_eq!(body, payload); + let argv = std::fs::read_to_string(format!("{}.argv", record.display())).unwrap(); + assert!( + argv.contains("http://127.0.0.1:7070/api/sessions/abc/prompt"), + "{argv}" + ); +} diff --git a/crates/maestro/Cargo.toml b/crates/maestro/Cargo.toml index e7d2e5c..10cc2ef 100644 --- a/crates/maestro/Cargo.toml +++ b/crates/maestro/Cargo.toml @@ -12,15 +12,18 @@ name = "maestro" path = "src/main.rs" [features] -default = ["cli", "daemon", "tui"] +default = ["cli", "daemon", "tui", "haiku-autoname"] cli = ["dep:maestro-cli"] daemon = ["dep:maestro-daemon"] tui = ["dep:maestro-tui"] +# Name a session after its first prompt (#131). `?` so a build that leaves +# the daemon or the CLI out does not pull it back in. +haiku-autoname = ["maestro-cli?/haiku-autoname", "maestro-daemon?/haiku-autoname"] [dependencies] clap.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } tracing-subscriber.workspace = true -maestro-cli = { path = "../maestro-cli", optional = true } -maestro-daemon = { path = "../maestro-daemon", optional = true } +maestro-cli = { path = "../maestro-cli", optional = true, default-features = false } +maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false } maestro-tui = { path = "../maestro-tui", optional = true }