diff options
| author | Ward Peeters <ward@coding-tech.com> | 2026-07-04 00:29:42 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-04 01:29:42 +0300 |
| commit | d0e333451bd794a419e96f28db925772950ea156 (patch) | |
| tree | 1a6e5d3bd6ac40f8ef454058f069d30202327002 /src | |
| parent | d5521e3d250498df4af552fff83fce3d5ef33810 (diff) | |
feat: add mastracode integration (#788)
* feat: add mastracode integration
refs #337
Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 35 | ||||
| -rw-r--r-- | src/api/schema/integrations.rs | 1 | ||||
| -rw-r--r-- | src/cli/integration.rs | 9 | ||||
| -rw-r--r-- | src/config/sound.rs | 1 | ||||
| -rw-r--r-- | src/detect/mod.rs | 19 | ||||
| -rw-r--r-- | src/integration/actions.rs | 49 | ||||
| -rw-r--r-- | src/integration/assets/mastracode/herdr-agent-state.sh | 96 | ||||
| -rw-r--r-- | src/integration/config_edit.rs | 53 | ||||
| -rw-r--r-- | src/integration/env.rs | 4 | ||||
| -rw-r--r-- | src/integration/mod.rs | 18 | ||||
| -rw-r--r-- | src/integration/registry.rs | 9 | ||||
| -rw-r--r-- | src/integration/targets.rs | 112 | ||||
| -rw-r--r-- | src/integration/tests.rs | 205 | ||||
| -rw-r--r-- | src/integration/types.rs | 14 | ||||
| -rw-r--r-- | src/terminal/state.rs | 110 |
15 files changed, 701 insertions, 34 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 5c1001cb..facb9cf1 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -140,6 +140,13 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:kimi", "kimi", AgentSessionRefKind::Id) => { vec!["kimi".into(), "--session".into(), session_ref.value.clone()] } + ("herdr:mastracode", "mastracode", AgentSessionRefKind::Id) => { + vec![ + "mastracode".into(), + "--thread".into(), + session_ref.value.clone(), + ] + } ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { vec!["pi".into(), "--session".into(), session_ref.value.clone()] } @@ -206,6 +213,7 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:droid", "droid") | ("herdr:kimi", "kimi") | ("herdr:omp", "omp") + | ("herdr:mastracode", "mastracode") | ("herdr:pi", "pi") | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") @@ -316,6 +324,16 @@ mod tests { ); assert_eq!( plan( + "herdr:mastracode", + "mastracode", + &AgentSessionRef::id("mastracode-session").unwrap() + ) + .unwrap() + .argv, + vec!["mastracode", "--thread", "mastracode-session"] + ); + assert_eq!( + plan( "herdr:pi", "pi", &AgentSessionRef::path(&pi_session).unwrap() @@ -490,6 +508,16 @@ mod tests { assert_eq!(session_ref.kind, AgentSessionRefKind::Id); assert_eq!(session_ref.value, "kimi-id"); + let session_ref = session_ref_from_report( + "herdr:mastracode", + "mastracode", + Some("mastracode-id".into()), + None, + ) + .unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "mastracode-id"); + let session_ref = session_ref_from_report("herdr:kilo", "kilo", Some("kilo-id".into()), None).unwrap(); assert_eq!(session_ref.kind, AgentSessionRefKind::Id); @@ -592,6 +620,13 @@ mod tests { ) .is_none()); assert!(session_ref_from_snapshot( + "herdr:mastracode", + "mastracode", + AgentSessionRefKind::Id, + "mastracode-session" + ) + .is_some()); + assert!(session_ref_from_snapshot( "herdr:hermes", "hermes", AgentSessionRefKind::Id, diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index a6d808fc..f20b9095 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -26,6 +26,7 @@ pub enum IntegrationTarget { Hermes, Qodercli, Cursor, + Mastracode, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 8dc053f1..c407278e 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -103,13 +103,13 @@ fn parse_integration_target( ) -> std::io::Result<Option<IntegrationTarget>> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor|mastracode>" ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor|mastracode>" ); return Ok(None); } @@ -128,10 +128,11 @@ fn parse_integration_target( "hermes" => IntegrationTarget::Hermes, "qodercli" => IntegrationTarget::Qodercli, "cursor" => IntegrationTarget::Cursor, + "mastracode" => IntegrationTarget::Mastracode, _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode" ); return Ok(None); } @@ -155,6 +156,7 @@ fn print_integration_help() { eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); eprintln!(" herdr integration install cursor"); + eprintln!(" herdr integration install mastracode"); eprintln!(" herdr integration uninstall pi"); eprintln!(" herdr integration uninstall omp"); eprintln!(" herdr integration uninstall claude"); @@ -168,5 +170,6 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); eprintln!(" herdr integration uninstall cursor"); + eprintln!(" herdr integration uninstall mastracode"); eprintln!(" herdr integration status [--outdated-only]"); } diff --git a/src/config/sound.rs b/src/config/sound.rs index 8624881a..8221b753 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -127,6 +127,7 @@ impl AgentSoundOverrides { Some(Agent::Antigravity) => self.agy, Some(Agent::Cline) => self.cline, Some(Agent::Omp) => AgentSoundSetting::Default, + Some(Agent::Mastracode) => AgentSoundSetting::Default, Some(Agent::OpenCode) => self.open_code, Some(Agent::GithubCopilot) => self.github_copilot, Some(Agent::Kimi) => self.kimi, diff --git a/src/detect/mod.rs b/src/detect/mod.rs index cd350331..3520f010 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -50,6 +50,7 @@ pub enum Agent { Antigravity, Cline, Omp, + Mastracode, OpenCode, GithubCopilot, Kimi, @@ -96,6 +97,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Antigravity => "agy", Agent::Cline => "cline", Agent::Omp => "omp", + Agent::Mastracode => "mastracode", Agent::OpenCode => "opencode", Agent::GithubCopilot => "copilot", Agent::Kimi => "kimi", @@ -121,6 +123,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> { "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity), "cline" => Some(Agent::Cline), "omp" => Some(Agent::Omp), + "mastracode" | "mastra-code" | "mastra code" => Some(Agent::Mastracode), "opencode" | "open-code" => Some(Agent::OpenCode), "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), "kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi), @@ -150,6 +153,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> { "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity), "cline" => Some(Agent::Cline), "omp" => Some(Agent::Omp), + "mastracode" | "mastra-code" | "mastra code" => Some(Agent::Mastracode), "opencode" | "open-code" => Some(Agent::OpenCode), "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), "kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi), @@ -242,6 +246,7 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> (source, agent_label), ("herdr:pi", "pi") | ("herdr:omp", "omp") + | ("herdr:mastracode", "mastracode") | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") | ("herdr:kilo", "kilo") @@ -621,6 +626,8 @@ mod tests { assert_eq!(identify_agent("antigravity-cli"), Some(Agent::Antigravity)); assert_eq!(identify_agent("cline"), Some(Agent::Cline)); assert_eq!(identify_agent("omp"), Some(Agent::Omp)); + assert_eq!(identify_agent("mastracode"), Some(Agent::Mastracode)); + assert_eq!(identify_agent("mastra-code"), Some(Agent::Mastracode)); assert_eq!(identify_agent("opencode"), Some(Agent::OpenCode)); assert_eq!(identify_agent("opencode.exe"), Some(Agent::OpenCode)); assert_eq!(identify_agent("kimi"), Some(Agent::Kimi)); @@ -646,6 +653,8 @@ mod tests { assert_eq!(parse_agent_label("agy"), Some(Agent::Antigravity)); assert_eq!(parse_agent_label("antigravity"), Some(Agent::Antigravity)); assert_eq!(parse_agent_label("omp"), Some(Agent::Omp)); + assert_eq!(parse_agent_label("mastracode"), Some(Agent::Mastracode)); + assert_eq!(parse_agent_label("mastra code"), Some(Agent::Mastracode)); assert_eq!(parse_agent_label("opencode.exe"), Some(Agent::OpenCode)); assert_eq!(parse_agent_label("copilot"), Some(Agent::GithubCopilot)); assert_eq!(parse_agent_label("kimi-code"), Some(Agent::Kimi)); @@ -668,6 +677,7 @@ mod tests { assert_eq!(agent_label(Agent::Devin), "devin"); assert_eq!(agent_label(Agent::Antigravity), "agy"); assert_eq!(agent_label(Agent::Omp), "omp"); + assert_eq!(agent_label(Agent::Mastracode), "mastracode"); assert_eq!(agent_label(Agent::Kiro), "kiro"); assert_eq!(agent_label(Agent::Grok), "grok"); assert_eq!(agent_label(Agent::Hermes), "hermes"); @@ -675,6 +685,15 @@ mod tests { } #[test] + fn mastracode_is_hook_authority_without_screen_manifest() { + assert!(full_lifecycle_hook_authority( + "herdr:mastracode", + "mastracode" + )); + assert!(!Agent::SCREEN_MANIFEST_AGENTS.contains(&Agent::Mastracode)); + } + + #[test] fn identify_unknown_processes() { assert_eq!(identify_agent("bash"), None); assert_eq!(identify_agent("zsh"), None); diff --git a/src/integration/actions.rs b/src/integration/actions.rs index ea732891..807ceeb6 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -3,10 +3,11 @@ use std::io; use super::registry::{integration_target_label, integration_target_supported}; use super::targets::{ install_claude, install_codex, install_copilot, install_cursor, install_devin, install_droid, - install_hermes, install_kilo, install_kimi, install_omp, install_opencode, install_pi, - install_qodercli, uninstall_claude, uninstall_codex, uninstall_copilot, uninstall_cursor, - uninstall_devin, uninstall_droid, uninstall_hermes, uninstall_kilo, uninstall_kimi, - uninstall_omp, uninstall_opencode, uninstall_pi, uninstall_qodercli, + install_hermes, install_kilo, install_kimi, install_mastracode, install_omp, install_opencode, + install_pi, install_qodercli, uninstall_claude, uninstall_codex, uninstall_copilot, + uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_hermes, uninstall_kilo, + uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, uninstall_pi, + uninstall_qodercli, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -190,6 +191,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re format!("updated cursor hooks at {}", installed.hooks_path.display()), ] } + crate::api::schema::IntegrationTarget::Mastracode => { + let installed = install_mastracode()?; + vec![ + format!( + "installed mastracode integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured mastracode hooks at {}", + installed.hooks_path.display() + ), + ] + } }; if let Some(warning) = version_warning { @@ -517,6 +531,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Mastracode => { + let result = uninstall_mastracode()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed mastracode hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no mastracode hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr mastracode hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr mastracode hook entries found in {}", + result.hooks_path.display() + )); + } + messages + } }; crate::logging::integration_action("uninstall", integration_target_label(target), "ok"); diff --git a/src/integration/assets/mastracode/herdr-agent-state.sh b/src/integration/assets/mastracode/herdr-agent-state.sh new file mode 100644 index 00000000..b8e0a26a --- /dev/null +++ b/src/integration/assets/mastracode/herdr-agent-state.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=mastracode +# HERDR_INTEGRATION_VERSION=1 + +set -eu + +action="${1:-}" +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-mastracode-hook.XXXXXX")" || exit 0 +trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM +cat >"$hook_input_file" 2>/dev/null || true + +case "$action" in + working|idle|blocked|release) ;; + *) exit 0 ;; +esac + +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +import json +import os +import random +import socket +import time + +source = "herdr:mastracode" +action = os.environ.get("HERDR_ACTION", "") +pane_id = os.environ.get("HERDR_PANE_ID") +socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") + +if not pane_id or not socket_path: + raise SystemExit(0) + +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + hook_input = json.loads(content) + except Exception: + hook_input = {} + +request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() +session_id = hook_input.get("session_id") +if isinstance(session_id, str) and session_id: + agent_session_id = session_id +else: + agent_session_id = None +if action == "release": + request = { + "id": request_id, + "method": "pane.release_agent", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "mastracode", + "seq": report_seq, + }, + } +else: + request = { + "id": request_id, + "method": "pane.report_agent", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "mastracode", + "state": action, + "seq": report_seq, + }, + } + if agent_session_id: + request["params"]["agent_session_id"] = agent_session_id + +try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(0.5) + client.connect(socket_path) + client.sendall((json.dumps(request) + "\n").encode()) + try: + client.recv(4096) + except Exception: + pass + client.close() +except Exception: + pass +PY diff --git a/src/integration/config_edit.rs b/src/integration/config_edit.rs index a1653395..63c18845 100644 --- a/src/integration/config_edit.rs +++ b/src/integration/config_edit.rs @@ -110,6 +110,34 @@ pub(crate) fn ensure_command_hook( // { "type": "command", "matcher": "...", "bash": "...", ... } // Keep the helpers separate so install/uninstall preserves unrelated hooks in // each agent's native format instead of normalizing user configuration. +pub(crate) fn ensure_flat_command_hook( + hooks: &mut Map<String, Value>, + event: &str, + command: String, + timeout_ms: u64, +) -> io::Result<()> { + let entries = hooks + .entry(event.to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?; + + if entries.iter().any(|entry| { + entry.get("type").and_then(Value::as_str) == Some("command") + && entry.get("command").and_then(Value::as_str) == Some(command.as_str()) + }) { + return Ok(()); + } + + entries.push(json!({ + "type": "command", + "command": command, + "timeout": timeout_ms, + "description": "Report MastraCode agent state to Herdr", + })); + Ok(()) +} + pub(crate) fn ensure_direct_command_hook( hooks: &mut Map<String, Value>, event: &str, @@ -214,6 +242,31 @@ pub(crate) fn remove_command_hook( Ok(removed) } +pub(crate) fn remove_flat_command_hook( + hooks: &mut Map<String, Value>, + event: &str, + command: &str, +) -> io::Result<bool> { + let Some(entries_value) = hooks.get_mut(event) else { + return Ok(false); + }; + + let entries = entries_value + .as_array_mut() + .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?; + + let before = entries.len(); + entries.retain(|entry| { + !(entry.get("type").and_then(Value::as_str) == Some("command") + && entry.get("command").and_then(Value::as_str) == Some(command)) + }); + let removed = entries.len() != before; + if entries.is_empty() { + hooks.remove(event); + } + Ok(removed) +} + pub(crate) fn remove_direct_command_hook( hooks: &mut Map<String, Value>, event: &str, diff --git a/src/integration/env.rs b/src/integration/env.rs index 575c4c9a..ef946729 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -124,6 +124,10 @@ pub(crate) fn cursor_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"]) } +pub(crate) fn mastracode_dir() -> io::Result<PathBuf> { + Ok(home_dir()?.join(".mastracode")) +} + pub(crate) fn home_dir() -> io::Result<PathBuf> { if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) { return Ok(PathBuf::from(home)); diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 9f3f2bc4..d356175c 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -179,6 +179,24 @@ const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ const CURSOR_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh"); const CURSOR_INTEGRATION_VERSION: u32 = 1; +const MASTRACODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +const MASTRACODE_HOOK_ASSET: &str = include_str!("assets/mastracode/herdr-agent-state.sh"); +const MASTRACODE_INTEGRATION_VERSION: u32 = 1; +const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000; +const MASTRACODE_HOOK_EVENTS: [(&str, &str); 12] = [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("AgentStart", "working"), + ("PreToolUse", "working"), + ("PermissionRequest", "blocked"), + ("PermissionResult", "working"), + ("SubagentStart", "working"), + ("SubagentEnd", "working"), + ("Interrupt", "idle"), + ("AgentEnd", "idle"), + ("Stop", "idle"), + ("SessionEnd", "release"), +]; const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/integration/registry.rs b/src/integration/registry.rs index e68dce0c..84e42a25 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -21,6 +21,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", crate::api::schema::IntegrationTarget::Cursor => "cursor", + crate::api::schema::IntegrationTarget::Mastracode => "mastracode", } } @@ -47,6 +48,7 @@ pub(crate) fn integration_target_command_names( crate::api::schema::IntegrationTarget::Hermes => &["hermes"], crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), + crate::api::schema::IntegrationTarget::Mastracode => &["mastracode"], } } @@ -251,7 +253,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 13] { +); 14] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -321,6 +323,11 @@ fn integration_specs() -> [( cursor_dir().map(|dir| dir.join(super::CURSOR_HOOK_INSTALL_NAME)), super::CURSOR_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::Mastracode, + mastracode_dir().map(|dir| dir.join("hooks").join(super::MASTRACODE_HOOK_INSTALL_NAME)), + super::MASTRACODE_INTEGRATION_VERSION, + ), ] } diff --git a/src/integration/targets.rs b/src/integration/targets.rs index a47f0dae..2b4c5615 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -7,15 +7,15 @@ use serde_json::{json, Value}; use super::command::{hook_command, shell_single_quote}; use super::config_edit::{ build_codex_config_with_hooks, build_kimi_config_with_hooks, ensure_command_hook, - ensure_direct_command_hook, ensure_hermes_plugin_enabled, ensure_hooks_object, - ensure_simple_command_hook, hooks_object_if_present, remove_direct_hook_commands, - remove_hermes_plugin_enabled, remove_hook_commands, remove_kimi_config_block, - remove_simple_command_hook, + ensure_direct_command_hook, ensure_flat_command_hook, ensure_hermes_plugin_enabled, + ensure_hooks_object, ensure_simple_command_hook, hooks_object_if_present, + remove_direct_hook_commands, remove_flat_command_hook, remove_hermes_plugin_enabled, + remove_hook_commands, remove_kimi_config_block, remove_simple_command_hook, }; use super::env::{ claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, hermes_dir, - hermes_plugin_dir, kilo_dir, kimi_dir, omp_extension_dir, opencode_dir, pi_extension_dir, - qodercli_dir, + hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, opencode_dir, + pi_extension_dir, qodercli_dir, }; use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, @@ -25,9 +25,9 @@ use super::types::{ CopilotInstallPaths, CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, KiloUninstallResult, - KimiInstallPaths, KimiUninstallResult, OmpInstallPaths, OmpUninstallResult, - OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, - QodercliUninstallResult, + KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths, MastracodeUninstallResult, + OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths, OpenCodeUninstallResult, + PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, }; use super::{ CLAUDE_HOOK_ASSET, CLAUDE_HOOK_INSTALL_NAME, CODEX_HOOK_ASSET, CODEX_HOOK_INSTALL_NAME, @@ -38,9 +38,10 @@ use super::{ DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, - KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, - OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, - PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, + KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, + MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, OMP_EXTENSION_ASSET, + OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, + PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, }; @@ -1106,3 +1107,90 @@ pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> { updated_hooks, }) } + +pub(crate) fn install_mastracode() -> io::Result<MastracodeInstallPaths> { + let mastracode_home = mastracode_dir()?; + let hook_dir = mastracode_home.join("hooks"); + fs::create_dir_all(&hook_dir)?; + + let hook_path = hook_dir.join(MASTRACODE_HOOK_INSTALL_NAME); + fs::write(&hook_path, MASTRACODE_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let hooks_path = mastracode_home.join("hooks.json"); + let mut hooks_file = if hooks_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })? + } else { + json!({}) + }; + + let hooks = hooks_file.as_object_mut().ok_or_else(|| { + io::Error::other(format!( + "mastracode hooks file at {} must be a JSON object", + hooks_path.display() + )) + })?; + + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + for (event, action) in MASTRACODE_HOOK_EVENTS { + ensure_flat_command_hook( + hooks, + event, + format!("bash {quoted_hook_path} {action}"), + MASTRACODE_HOOK_TIMEOUT_MS, + )?; + } + + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + + Ok(MastracodeInstallPaths { + hook_path, + hooks_path, + }) +} + +pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> { + let mastracode_home = mastracode_dir()?; + let hook_path = mastracode_home + .join("hooks") + .join(MASTRACODE_HOOK_INSTALL_NAME); + let hooks_path = mastracode_home.join("hooks.json"); + let mut updated_hooks = false; + + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + let hooks = hooks_file.as_object_mut().ok_or_else(|| { + io::Error::other(format!( + "mastracode hooks file at {} must be a JSON object", + hooks_path.display() + )) + })?; + + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + for (event, action) in MASTRACODE_HOOK_EVENTS { + updated_hooks |= remove_flat_command_hook( + hooks, + event, + &format!("bash {quoted_hook_path} {action}"), + )?; + } + + if updated_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(MastracodeUninstallResult { + hook_path, + hooks_path, + removed_hook_file, + updated_hooks, + }) +} diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 2a8a7be3..b09e3c23 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -175,6 +175,7 @@ fn windows_supports_only_cli_hook_integrations() { assert!(!integration_target_supported(IntegrationTarget::Hermes)); assert!(!integration_target_supported(IntegrationTarget::Cursor)); assert!(!integration_target_supported(IntegrationTarget::Devin)); + assert!(!integration_target_supported(IntegrationTarget::Mastracode)); assert!(integration_target_supported(IntegrationTarget::Claude)); assert!(integration_target_supported(IntegrationTarget::Codex)); @@ -203,6 +204,7 @@ fn windows_does_not_offer_unsupported_integrations_even_when_commands_exist() { fs::write(bin.join("hermes.exe"), "").unwrap(); fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("devin.cmd"), "@echo off\r\n").unwrap(); + fs::write(bin.join("mastracode.cmd"), "@echo off\r\n").unwrap(); assert!(!integration_target_available(IntegrationTarget::Pi)); assert!(!integration_target_available(IntegrationTarget::Omp)); @@ -211,6 +213,7 @@ fn windows_does_not_offer_unsupported_integrations_even_when_commands_exist() { assert!(!integration_target_available(IntegrationTarget::Hermes)); assert!(!integration_target_available(IntegrationTarget::Cursor)); assert!(!integration_target_available(IntegrationTarget::Devin)); + assert!(!integration_target_available(IntegrationTarget::Mastracode)); if let Some(path) = original_path { std::env::set_var("PATH", path); @@ -2587,6 +2590,13 @@ fn bundled_integration_assets_report_session_refs() { assert!(CURSOR_HOOK_ASSET.contains("sessionStart")); assert!(!CURSOR_HOOK_ASSET.contains("\"state\":")); assert!(!CURSOR_HOOK_ASSET.contains("pane.release_agent")); + assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=mastracode")); + assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=1")); + assert!(MASTRACODE_HOOK_ASSET.contains("session_id")); + assert!(!MASTRACODE_HOOK_ASSET.contains("run_id")); + assert!(MASTRACODE_HOOK_ASSET.contains("agent_session_id")); + assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent")); + assert!(MASTRACODE_HOOK_ASSET.contains("pane.release_agent")); } #[test] @@ -3102,3 +3112,198 @@ fn install_cursor_errors_when_config_dir_missing() { std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); let _ = fs::remove_dir_all(base); } + +#[test] +fn install_mastracode_writes_hook_and_updates_hooks_json() { + let _lock = integration_env_lock(); + let base = unique_base(); + let original_home = std::env::var_os("HOME"); + let mastracode_dir = base.join(".mastracode"); + fs::create_dir_all(&mastracode_dir).unwrap(); + fs::write( + mastracode_dir.join("hooks.json"), + r#"{"PostToolUse":[{"type":"command","command":"echo keep-me"}]}"#, + ) + .unwrap(); + std::env::set_var("HOME", &base); + + let installed = install_mastracode().unwrap(); + + assert_eq!( + installed.hook_path, + mastracode_dir + .join("hooks") + .join(MASTRACODE_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.hooks_path, mastracode_dir.join("hooks.json")); + assert_eq!( + fs::read_to_string(&installed.hook_path).unwrap(), + MASTRACODE_HOOK_ASSET + ); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(mastracode_dir.join("hooks.json")).unwrap()) + .unwrap(); + let hooks = hooks_file.as_object().unwrap(); + for (event, action) in MASTRACODE_HOOK_EVENTS { + let entries = hooks.get(event).and_then(Value::as_array).unwrap(); + assert_eq!(entries.len(), 1, "{event} should have one Herdr hook"); + let command = entries[0].get("command").and_then(Value::as_str).unwrap(); + assert!(command.starts_with("bash ")); + assert!(command.contains(MASTRACODE_HOOK_INSTALL_NAME)); + assert!(command.ends_with(action)); + assert_eq!( + entries[0].get("type").and_then(Value::as_str), + Some("command") + ); + assert_eq!( + entries[0].get("timeout").and_then(Value::as_u64), + Some(MASTRACODE_HOOK_TIMEOUT_MS) + ); + } + assert_eq!( + hooks["PostToolUse"][0] + .get("command") + .and_then(Value::as_str), + Some("echo keep-me") + ); + + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_mastracode_is_idempotent_for_hook_entries() { + let _lock = integration_env_lock(); + let base = unique_base(); + let original_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &base); + + install_mastracode().unwrap(); + install_mastracode().unwrap(); + + let hooks_file: Value = serde_json::from_str( + &fs::read_to_string(base.join(".mastracode").join("hooks.json")).unwrap(), + ) + .unwrap(); + let hooks = hooks_file.as_object().unwrap(); + for (event, _) in MASTRACODE_HOOK_EVENTS { + assert_eq!(hooks.get(event).and_then(Value::as_array).unwrap().len(), 1); + } + + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn uninstall_mastracode_removes_herdr_hooks_and_preserves_others() { + let _lock = integration_env_lock(); + let base = unique_base(); + let original_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &base); + + install_mastracode().unwrap(); + let hooks_path = base.join(".mastracode").join("hooks.json"); + let mut hooks_file: Value = + serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); + hooks_file["UserPromptSubmit"] + .as_array_mut() + .unwrap() + .push(json!({ "type": "command", "command": "echo user-defined" })); + fs::write( + &hooks_path, + serde_json::to_string_pretty(&hooks_file).unwrap(), + ) + .unwrap(); + + let result = uninstall_mastracode().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_hooks); + assert!(!base + .join(".mastracode") + .join("hooks") + .join(MASTRACODE_HOOK_INSTALL_NAME) + .is_file()); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); + let hooks = hooks_file.as_object().unwrap(); + for (event, _) in MASTRACODE_HOOK_EVENTS { + if event == "UserPromptSubmit" { + continue; + } + assert!(!hooks.contains_key(event), "{event} should be removed"); + } + let user_prompt_submit = hooks + .get("UserPromptSubmit") + .and_then(Value::as_array) + .unwrap(); + assert_eq!(user_prompt_submit.len(), 1); + assert_eq!( + user_prompt_submit[0].get("command").and_then(Value::as_str), + Some("echo user-defined") + ); + + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_mastracode_errors_when_event_value_not_array() { + let _lock = integration_env_lock(); + let base = unique_base(); + let original_home = std::env::var_os("HOME"); + let mastracode_dir = base.join(".mastracode"); + fs::create_dir_all(&mastracode_dir).unwrap(); + fs::write(mastracode_dir.join("hooks.json"), r#"{"SessionStart":{}}"#).unwrap(); + std::env::set_var("HOME", &base); + + let err = install_mastracode().unwrap_err().to_string(); + assert!( + err.contains("hook entries for SessionStart must be an array"), + "unexpected error: {err}" + ); + + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn uninstall_mastracode_errors_when_event_value_not_array() { + let _lock = integration_env_lock(); + let base = unique_base(); + let original_home = std::env::var_os("HOME"); + let mastracode_dir = base.join(".mastracode"); + fs::create_dir_all(&mastracode_dir).unwrap(); + fs::write(mastracode_dir.join("hooks.json"), r#"{"SessionStart":{}}"#).unwrap(); + std::env::set_var("HOME", &base); + + let err = uninstall_mastracode().unwrap_err().to_string(); + assert!( + err.contains("hook entries for SessionStart must be an array"), + "unexpected error: {err}" + ); + + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} diff --git a/src/integration/types.rs b/src/integration/types.rs index c6ab7a0b..44ec76f1 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -82,6 +82,20 @@ pub(crate) struct CursorUninstallResult { } #[derive(Debug)] +pub(crate) struct MastracodeInstallPaths { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct MastracodeUninstallResult { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, + pub removed_hook_file: bool, + pub updated_hooks: bool, +} + +#[derive(Debug)] pub(crate) struct QodercliUninstallResult { pub hook_path: PathBuf, pub settings_path: PathBuf, diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 96580b7d..eebf0fb0 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -416,8 +416,16 @@ impl TerminalState { return None; } let session_ref = session_ref.map(|session_ref| { - self.conflicting_same_owner_session_ref(&source, &agent_label, &session_ref, None) - .unwrap_or(session_ref) + if self.lifecycle_hook_report_replaces_persisted_session( + &source, + &agent_label, + &session_ref, + ) { + session_ref + } else { + self.conflicting_same_owner_session_ref(&source, &agent_label, &session_ref, None) + .unwrap_or(session_ref) + } }); if self.live_full_lifecycle_hook_authority_conflicts_with_session( &source, @@ -855,6 +863,26 @@ impl TerminalState { ) } + fn lifecycle_hook_report_replaces_persisted_session( + &self, + source: &str, + agent_label: &str, + session_ref: &crate::agent_resume::AgentSessionRef, + ) -> bool { + self.hook_authority.is_none() + && (source, agent_label) == ("herdr:mastracode", "mastracode") + && self + .persisted_agent_session + .as_ref() + .is_some_and(|session| { + session.source == source + && session.agent == agent_label + && session.session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id + && session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id + && session.session_ref.value != session_ref.value + }) + } + fn session_start_source_allows_session_replacement( source: &str, agent_label: &str, @@ -2307,40 +2335,94 @@ mod tests { // Regression for #614: a same-pane restart must reacquire lifecycle authority. #[test] fn omp_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { + assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( + "herdr:omp", + "omp", + ); + } + + #[test] + fn mastracode_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { + assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( + "herdr:mastracode", + "mastracode", + ); + } + + #[test] + fn mastracode_lifecycle_report_replaces_restored_thread_ref() { let mut terminal = test_terminal(); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:mastracode".into(), + agent: "mastracode".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("mastracode-old").unwrap(), + }); + + let mutation = terminal + .set_hook_authority_with_session_ref( + "herdr:mastracode".into(), + "mastracode".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("mastracode-new"), + Some(20), + ) + .expect("fresh MastraCode thread should replace restored thread id"); + + assert!(mutation.session_ref_changed); + assert_eq!( + terminal.current_session_identity_for_persistence(), + Some(( + "herdr:mastracode".into(), + "mastracode".into(), + crate::agent_resume::AgentSessionRefKind::Id, + "mastracode-new".into() + )) + ); + } + + fn assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( + source: &str, + agent_label: &str, + ) { + let mut terminal = test_terminal(); + let old_session = format!("{agent_label}-old"); + let new_session = format!("{agent_label}-new"); + terminal.set_hook_authority_with_session_ref( - "herdr:omp".into(), - "omp".into(), + source.into(), + agent_label.into(), AgentState::Working, None, None, - crate::agent_resume::AgentSessionRef::id("omp-old"), + crate::agent_resume::AgentSessionRef::id(&old_session), Some(20), ); - terminal.release_agent("herdr:omp", "omp", Some(21)); + terminal.release_agent(source, agent_label, Some(21)); // A late report from the released run keeps its old session ref and stays - // suppressed, so a just-exited omp cannot resurrect the pane. + // suppressed, so a just-exited agent cannot resurrect the pane. let stale = terminal.set_hook_authority_with_session_ref( - "herdr:omp".into(), - "omp".into(), + source.into(), + agent_label.into(), AgentState::Working, None, None, - crate::agent_resume::AgentSessionRef::id("omp-old"), + crate::agent_resume::AgentSessionRef::id(&old_session), Some(22), ); assert!(stale.is_none()); assert!(terminal.hook_authority.is_none()); - // A fresh omp run carries a new session ref and reacquires authority. + // A fresh run carries a new session ref and reacquires authority. let fresh = terminal.set_hook_authority_with_session_ref( - "herdr:omp".into(), - "omp".into(), + source.into(), + agent_label.into(), AgentState::Working, None, None, - crate::agent_resume::AgentSessionRef::id("omp-new"), + crate::agent_resume::AgentSessionRef::id(&new_session), Some(23), ); assert!(fresh.is_some()); |
