The hook installer only ever wrote ~/.claude/settings.json, so a codex session reported no activity and never posted its first prompt — leaving it on the daemon's generated handle with no haiku name. Adds a Target enum (claude-code, codex) over the hook file's location and event set. One script and one marker serve both; codex swaps Notification for PermissionRequest and has no StopFailure, so it never reports Error. Both agents spell the prompt event UserPromptSubmit and put the text in the payload's `prompt`, so autoname needs no change. Codex will not run a hook it has not been told to trust; `maestro hooks install` says so, and the approval stays the operator's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
436 lines
17 KiB
Rust
436 lines
17 KiB
Rust
//! Agent 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 agent 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.
|
|
|
|
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.
|
|
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.
|
|
#[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 —
|
|
/// `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 CLAUDE_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"),
|
|
];
|
|
|
|
/// 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).
|
|
# 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)
|
|
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,
|
|
# 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)
|
|
}
|
|
|
|
/// 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`].
|
|
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.
|
|
///
|
|
/// 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 {
|
|
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))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Every maestro entry in the file as `(event, command)`, sorted — so a
|
|
/// duplicate, a stale event or a wrong script path all show up as a
|
|
/// difference against [`expected_entries`].
|
|
fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
|
|
let mut found: Vec<(String, String)> = root
|
|
.get("hooks")
|
|
.and_then(|h| h.as_object())
|
|
.into_iter()
|
|
.flatten()
|
|
.flat_map(|(event, groups)| {
|
|
groups
|
|
.as_array()
|
|
.into_iter()
|
|
.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))
|
|
.map(move |cmd| (event.clone(), cmd.to_string()))
|
|
})
|
|
.collect();
|
|
found.sort();
|
|
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()
|
|
.map(|(event, state)| {
|
|
(
|
|
event.to_string(),
|
|
format!("{} {state}", script_path.display()),
|
|
)
|
|
})
|
|
.collect();
|
|
want.sort();
|
|
want
|
|
}
|
|
|
|
/// 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 one target under `home`: write the script
|
|
/// under `.local/share/maestro/` and register it in the target's hook file.
|
|
/// Idempotent; returns whether anything changed on disk.
|
|
pub fn install_target(target: Target, home: &Path) -> Result<bool, HooksError> {
|
|
let script = script_path(home);
|
|
let settings = target.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);
|
|
write_script(&script)?;
|
|
install(target, &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.
|
|
///
|
|
/// 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> {
|
|
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 target.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;
|