Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ impl HookAgent {
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookRouteMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonHookEvent {
pub agent: String,
Expand All @@ -96,6 +110,8 @@ pub struct DaemonHookEvent {
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<HookRouteMetadata>,
}

impl DaemonHookEvent {
Expand All @@ -112,9 +128,16 @@ impl DaemonHookEvent {
rel_paths,
command,
cwd,
route: None,
}
}

#[must_use]
pub fn with_route(mut self, route: Option<HookRouteMetadata>) -> Self {
self.route = route;
self
}

pub fn cursor_after_file_edit(rel_paths: Vec<String>) -> Self {
Self::new(HookAgent::Cursor, "afterFileEdit", rel_paths, None, None)
}
Expand Down
14 changes: 9 additions & 5 deletions src/hooks/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ use super::steering::{
};
use super::tool_hints::{decide_hint, HintAgent, ToolHint, ToolHintInput};
use super::{
deduped_project_hint, event_session_id, format_tool_hint, nearest_project_like_root,
read_hook_event, record_hint_analytics, record_hook_invoked, rel_under_root, text_field,
deduped_project_hint, event_session_id, format_tool_hint, hook_route_metadata_from_event,
nearest_project_like_root, read_hook_event, record_hint_analytics, record_hook_invoked,
rel_under_root, text_field,
};

/// Largest tail the `beforeSubmitPrompt` hot path will read in one call. Larger
Expand Down Expand Up @@ -510,7 +511,8 @@ async fn notify_cursor_after_file_edit(event_json: &str) {
}
crate::daemon::notify_hook_event(
&root,
crate::daemon::DaemonHookEvent::cursor_after_file_edit(rels),
crate::daemon::DaemonHookEvent::cursor_after_file_edit(rels)
.with_route(hook_route_metadata_from_event(event_json, &root)),
)
.await;
}
Expand All @@ -533,7 +535,8 @@ async fn notify_cursor_after_shell_event(event_json: &str) {
let cwd = cursor_event_cwd(&parsed).unwrap_or_else(|| root.clone());
crate::daemon::notify_hook_event(
&root,
crate::daemon::DaemonHookEvent::cursor_after_shell_execution(command.to_string(), cwd),
crate::daemon::DaemonHookEvent::cursor_after_shell_execution(command.to_string(), cwd)
.with_route(hook_route_metadata_from_event(event_json, &root)),
)
.await;
}
Expand All @@ -548,7 +551,8 @@ async fn notify_cursor_workspace_open(event_json: &str) {
}
crate::daemon::notify_hook_event(
&root,
crate::daemon::DaemonHookEvent::cursor_workspace_open(root.clone()),
crate::daemon::DaemonHookEvent::cursor_workspace_open(root.clone())
.with_route(hook_route_metadata_from_event(event_json, &root)),
)
.await;
}
Expand Down
7 changes: 4 additions & 3 deletions src/hooks/kiro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use serde_json::Value;
use super::claude::is_code_research_prompt;
use super::tool_hints::{decide_hint, HintAgent, ToolHintInput};
use super::{
event_cwd, event_cwd_from_parsed, event_session_id, read_hook_event, record_hook_invoked,
rel_under_root, research_block_reason,
event_cwd, event_cwd_from_parsed, event_session_id, hook_route_metadata_from_event,
read_hook_event, record_hook_invoked, rel_under_root, research_block_reason,
};

/// Largest transcript tail the Kiro `userPromptSubmit` hook will read per call.
Expand Down Expand Up @@ -188,7 +188,8 @@ async fn notify_kiro_post_tool_use(event_json: &str) {
let rel_paths = kiro_post_tool_use_rel_paths(event_json, &project_root);
crate::daemon::notify_hook_event(
&project_root,
crate::daemon::DaemonHookEvent::kiro_post_tool_use(rel_paths, event_cwd(event_json)),
crate::daemon::DaemonHookEvent::kiro_post_tool_use(rel_paths, event_cwd(event_json))
.with_route(hook_route_metadata_from_event(event_json, &project_root)),
)
.await;
}
Expand Down
85 changes: 85 additions & 0 deletions src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,54 @@ fn record_hint_emitted(
record_hint_analytics(root, "hint_emitted", agent, session_id, hint);
}

fn hook_route_metadata_from_event(
event_json: &str,
project_root: &Path,
) -> Option<crate::daemon::HookRouteMetadata> {
let parsed = serde_json::from_str::<Value>(event_json).ok()?;
Some(hook_route_metadata_from_parsed(&parsed, project_root))
}

fn hook_route_metadata_from_parsed(
parsed: &Value,
project_root: &Path,
) -> crate::daemon::HookRouteMetadata {
let cwd = event_cwd_from_parsed(parsed);
let route_root = cwd.as_deref().unwrap_or(project_root);
let worktree = crate::worktree::git_worktree_root(route_root)
.unwrap_or_else(|| project_root.to_path_buf());
let branch = crate::branch::current_branch(&worktree);
crate::daemon::HookRouteMetadata {
session_id: hook_route_session_id(parsed),
thread_id: text_field(
parsed,
&[
"thread_id",
"threadId",
"conversation_thread_id",
"conversationThreadId",
],
),
cwd,
worktree: Some(worktree),
branch,
}
}

fn hook_route_session_id(parsed: &Value) -> Option<String> {
text_field(
parsed,
&[
"session_id",
"sessionId",
"conversation_id",
"conversationId",
"chat_id",
"chatId",
],
)
}

fn deduped_project_hint(
root: Option<PathBuf>,
agent: HintAgent,
Expand Down Expand Up @@ -434,3 +482,40 @@ pub(crate) fn read_stdin_to_string() -> std::io::Result<String> {
std::io::stdin().read_to_string(&mut input)?;
Ok(input)
}

#[cfg(test)]
mod tests {
use super::hook_route_metadata_from_event;

#[test]
fn hook_route_metadata_preserves_camel_case_session_ids() {
let event = serde_json::json!({
"sessionId": "session-camel",
"conversationId": "conversation-camel",
"cwd": "/tmp/project"
})
.to_string();

let Some(route) =
hook_route_metadata_from_event(&event, std::path::Path::new("/tmp/project"))
else {
panic!("route metadata should parse");
};

assert_eq!(route.session_id.as_deref(), Some("session-camel"));

let event = serde_json::json!({
"conversationId": "conversation-camel",
"cwd": "/tmp/project"
})
.to_string();

let Some(route) =
hook_route_metadata_from_event(&event, std::path::Path::new("/tmp/project"))
else {
panic!("route metadata should parse");
};

assert_eq!(route.session_id.as_deref(), Some("conversation-camel"));
}
}
8 changes: 5 additions & 3 deletions src/hooks/post_tool_use.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::path::Path;

use serde_json::Value;

use super::{codex, event_cwd_from_parsed, rel_under_root};
use super::{codex, event_cwd_from_parsed, hook_route_metadata_from_parsed, rel_under_root};

/// Claude Code tools whose `PostToolUse` events the hook consumes. The
/// installer's `PostToolUse` matcher is derived from this list so the matcher
Expand Down Expand Up @@ -86,7 +86,8 @@ pub(crate) async fn notify_post_tool_use(spec: &PostToolUseSpec, event_json: &st
}
crate::daemon::notify_hook_event(
&root,
crate::daemon::DaemonHookEvent::post_tool_use_edit(spec.agent, rels, cwd),
crate::daemon::DaemonHookEvent::post_tool_use_edit(spec.agent, rels, cwd)
.with_route(Some(hook_route_metadata_from_parsed(&parsed, &root))),
)
.await;
} else if (spec.is_shell_tool)(tool_name) {
Expand All @@ -100,7 +101,8 @@ pub(crate) async fn notify_post_tool_use(spec: &PostToolUseSpec, event_json: &st
spec.agent,
command.to_string(),
cwd,
),
)
.with_route(Some(hook_route_metadata_from_parsed(&parsed, &root))),
)
.await;
}
Expand Down
42 changes: 42 additions & 0 deletions src/mcp/hook_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ impl HookEventKind {
_ => None,
}
}

pub(crate) fn as_key(self) -> &'static str {
match self {
Self::FileEdit => "file_edit",
Self::Shell => "shell",
Self::WorkspaceOpen => "workspace_open",
Self::IncrementalSync => "incremental_sync",
}
}
}

pub(crate) struct HookEvent {
Expand All @@ -36,6 +45,7 @@ pub(crate) struct HookEvent {
pub(crate) rel_paths: Vec<String>,
pub(crate) command: Option<String>,
pub(crate) cwd: Option<PathBuf>,
pub(crate) route: Option<crate::daemon::HookRouteMetadata>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand All @@ -56,6 +66,7 @@ pub(crate) fn parse_hook_event(params: Option<&Value>) -> Option<HookEvent> {
rel_paths: safe_hook_rel_paths(&event.rel_paths),
command: event.command.filter(|command| !command.is_empty()),
cwd: event.cwd,
route: event.route,
})
}

Expand Down Expand Up @@ -214,6 +225,37 @@ mod tests {
assert_eq!(workspace.kind, HookEventKind::WorkspaceOpen);
}

#[test]
fn preserves_route_metadata_from_hook_notification() {
let params = json!({
"agent": "codex",
"event": "postToolUseShell",
"command": "cargo test",
"cwd": "/tmp/project",
"route": {
"session_id": "session-123",
"thread_id": "thread-456",
"cwd": "/tmp/project",
"worktree": "/tmp/project-worktree",
"branch": "feature/hook-route"
}
});

let event = parse_or_panic(&params);

let Some(route) = event.route.as_ref() else {
panic!("route metadata should parse");
};
assert_eq!(route.session_id.as_deref(), Some("session-123"));
assert_eq!(route.thread_id.as_deref(), Some("thread-456"));
assert_eq!(route.cwd.as_deref(), Some(Path::new("/tmp/project")));
assert_eq!(
route.worktree.as_deref(),
Some(Path::new("/tmp/project-worktree"))
);
assert_eq!(route.branch.as_deref(), Some("feature/hook-route"));
}

#[test]
fn ignores_unknown_hook_event_names() {
let params = json!({
Expand Down
29 changes: 28 additions & 1 deletion src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ use crate::global_db::GlobalDb;
use crate::mcp::response_handles::{
cleanup_expired_response_handles, response_handle_stats_json, RESPONSE_RETRIEVE_TOOL,
};
use crate::mcp::tool_analytics::{mcp_tool_analytics_event, McpToolAnalyticsEvent};
use crate::mcp::tool_analytics::{
hook_route_analytics_event, mcp_tool_analytics_event, McpToolAnalyticsEvent,
};
use crate::path_tree::format_compact_annotated_path_list;
use crate::tracedecay::TraceDecay;

Expand Down Expand Up @@ -1607,6 +1609,7 @@ impl McpServer {
let root = cg.project_root().to_path_buf();
self.update_hook_workspace_route(&event, connection).await;
let current_branch = crate::branch::current_branch(&root);
self.record_hook_route_analytics(&root, &event, current_branch.as_deref());
let plan = hook_events::plan_hook_event(&event, &root, current_branch.as_deref());
self.run_hook_event_plan(cg, &root, plan).await;
}
Expand Down Expand Up @@ -2307,6 +2310,30 @@ impl McpServer {
});
}

fn record_hook_route_analytics(
&self,
project_root: &std::path::Path,
event: &hook_events::HookEvent,
current_branch: Option<&str>,
) {
let Some(event) = hook_route_analytics_event(
project_root,
event,
current_branch,
crate::tracedecay::current_timestamp(),
) else {
return;
};
let Some(gdb) = self.global_db.clone() else {
return;
};
self.spawn_observed_ledger_write(async move {
if let Err(e) = gdb.append_analytics_event(&event).await {
eprintln!("[tracedecay] hook route analytics insert failed: {e}");
}
});
}

fn spawn_observed_ledger_write<F>(&self, future: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
Expand Down
Loading
Loading