The Claude Code hook already receives a session_id in every payload, but the activity route dropped it and the daemon had no field to store it on. Parse it out of the hook body, add Session.agent_session_id, and write it through to the sidecar once — the first report that carries one wins, and later reports never overwrite it since an agent's own id never changes mid-session. This is the input to a follow-up POST /api/sessions/:id/resume.
357 lines
14 KiB
Rust
357 lines
14 KiB
Rust
//! Claude Code activity hook (#106).
|
|
//!
|
|
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
|
|
//! the daemon's first-start auto-install:
|
|
//!
|
|
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
|
//! posts an [`ActivityState`](maestro-protocol) to
|
|
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
|
|
//! global hook runs in every Claude Code session on the machine, so it exits
|
|
//! 0 and silently when either #103 env var is unset, and it never lets a
|
|
//! daemon failure escape (short curl timeout, status ignored).
|
|
//! * [`install`] — idempotent registration of that script in
|
|
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
|
|
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
|
|
//! in the file is preserved verbatim.
|
|
|
|
use std::path::Path;
|
|
|
|
/// Marker substring every maestro-installed hook command carries. The
|
|
/// installer rewrites or removes exactly these entries — nothing else in the
|
|
/// user's settings file.
|
|
pub const HOOK_MARKER: &str = "maestro-claude-hook";
|
|
|
|
/// File name of the installed script (carries [`HOOK_MARKER`]).
|
|
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
|
|
|
/// 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 —
|
|
/// `PostToolUse` alone only fires once it ends (#114). `StopFailure` is
|
|
/// Claude Code's own name for "the turn ended on an API error"; there is no
|
|
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
|
/// `Error`.
|
|
pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
|
// Tool starting: the agent is demonstrably producing work, even if the
|
|
// tool itself runs for minutes.
|
|
("PreToolUse", "Active"),
|
|
// Tool finished successfully.
|
|
("PostToolUse", "Active"),
|
|
// Permission / idle notices: Claude is blocked on the user.
|
|
("Notification", "AwaitingInput"),
|
|
// Turn ended: waiting on the user again.
|
|
("Stop", "AwaitingInput"),
|
|
// Turn ended on an API error; the payload's `error` field is forwarded
|
|
// as the report's `detail` (#114).
|
|
("StopFailure", "Error"),
|
|
];
|
|
|
|
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
|
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
|
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
|
# Exits silently unless running inside a session maestro spawned (#103 env).
|
|
# Event -> ActivityState mapping:
|
|
# PreToolUse -> Active (tool started; agent is working)
|
|
# PostToolUse -> Active (tool finished)
|
|
# Notification -> AwaitingInput (permission / idle notice)
|
|
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
|
# StopFailure -> Error (turn ended on an API error)
|
|
# 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
|
|
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
|
|
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" \
|
|
"$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity" \
|
|
>/dev/null 2>&1 || true
|
|
exit 0
|
|
"#;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum HooksError {
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("json: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
}
|
|
|
|
/// Where the script is written for a given home directory:
|
|
/// `<home>/.local/share/maestro/maestro-claude-hook.sh`.
|
|
pub fn script_path(home: &Path) -> std::path::PathBuf {
|
|
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
|
}
|
|
|
|
/// The user-level Claude Code settings file for a given home directory.
|
|
pub fn settings_path(home: &Path) -> std::path::PathBuf {
|
|
home.join(".claude/settings.json")
|
|
}
|
|
|
|
/// Whether `settings_path` already carries a maestro hook (#106). 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: 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. 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(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 `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(),
|
|
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 `home`: write the script under
|
|
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
|
|
/// Idempotent; returns whether anything changed on disk.
|
|
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
|
|
let script = script_path(home);
|
|
let settings = settings_path(home);
|
|
// 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(&settings, &script);
|
|
write_script(&script)?;
|
|
install(&settings, &script)?;
|
|
Ok(changed)
|
|
}
|
|
|
|
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
|
|
/// settings file at `settings_path`. Idempotent: existing maestro entries are
|
|
/// updated in place wherever they sit, stale ones removed, duplicates folded;
|
|
/// every non-maestro entry and every unrelated top-level field survives.
|
|
///
|
|
/// Never inspects or writes anywhere but `settings_path` — callers pass the
|
|
/// user's real home or a temp one in tests, never a repository.
|
|
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
|
|
let mut root = settings_json(settings_path)?;
|
|
|
|
// Strip every maestro entry wherever it currently sits, so an older
|
|
// install under different events is replaced rather than duplicated.
|
|
strip_maestro_entries(&mut root);
|
|
|
|
let hooks = ensure_object(&mut root, "hooks");
|
|
for (event, state) in HOOK_EVENTS.iter().chain(PROMPT_EVENTS.iter()).copied() {
|
|
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;
|