Skip to main content

edda_bridge_cursor/
dispatch.rs

1use edda_bridge_claude::{render, state};
2
3use crate::parse::CursorEnvelope;
4
5#[derive(Debug, Default, Clone)]
6pub struct HookResult {
7    pub stdout: Option<String>,
8    pub stderr: Option<String>,
9}
10
11impl HookResult {
12    fn output(stdout: String) -> Self {
13        Self {
14            stdout: Some(stdout),
15            stderr: None,
16        }
17    }
18}
19
20fn empty_json() -> HookResult {
21    HookResult::output("{}".to_string())
22}
23
24fn continue_json() -> HookResult {
25    HookResult::output(r#"{"continue":true}"#.to_string())
26}
27
28fn allow_json() -> HookResult {
29    HookResult::output(r#"{"permission":"allow"}"#.to_string())
30}
31
32fn pre_tool_warning(warning: &str) -> anyhow::Result<HookResult> {
33    let output = serde_json::json!({
34        "permission": "allow",
35        "agent_message": warning,
36    });
37    Ok(HookResult::output(serde_json::to_string(&output)?))
38}
39
40fn additional_context(context: &str) -> anyhow::Result<HookResult> {
41    let output = serde_json::json!({"additional_context": context});
42    Ok(HookResult::output(serde_json::to_string(&output)?))
43}
44
45pub fn hook_entrypoint_from_stdin(stdin: &str) -> anyhow::Result<HookResult> {
46    if stdin.trim().is_empty() {
47        return Ok(HookResult::default());
48    }
49    let envelope = match crate::parse::parse_hook_stdin(stdin) {
50        Ok(envelope) => envelope,
51        Err(_) => return Ok(empty_json()),
52    };
53    let cwd = envelope.cwd();
54    let project_id = edda_store::project_id(&cwd);
55    let _ = edda_store::ensure_dirs(&project_id);
56    let _ = crate::parse::append_to_session_ledger(&envelope);
57    if !envelope.session_id().is_empty() {
58        edda_bridge_claude::peers::touch_heartbeat(&project_id, envelope.session_id());
59    }
60
61    let result = match envelope.event_name() {
62        "sessionStart" => dispatch_session_start(&project_id, &envelope),
63        "beforeSubmitPrompt" => Ok(continue_json()),
64        "preToolUse" => dispatch_pre_tool_use(&project_id, &envelope),
65        "postToolUse" => dispatch_post_tool_use(&project_id, &envelope),
66        "preCompact" => {
67            state::set_compact_pending(&project_id);
68            Ok(empty_json())
69        }
70        "sessionEnd" | "stop" => dispatch_session_end(&project_id, &envelope),
71        "subagentStart" => Ok(dispatch_subagent_start(&project_id, &envelope)),
72        "subagentStop" => Ok(dispatch_subagent_stop(&project_id, &envelope)),
73        _ => Ok(empty_json()),
74    };
75    Ok(result.unwrap_or_else(|_| empty_json()))
76}
77
78fn dispatch_subagent_start(project_id: &str, envelope: &CursorEnvelope) -> HookResult {
79    if !envelope.subagent_id.is_empty() {
80        let label = if envelope.subagent_type.is_empty() {
81            "subagent"
82        } else {
83            &envelope.subagent_type
84        };
85        edda_bridge_claude::peers::write_heartbeat_minimal(
86            project_id,
87            &envelope.subagent_id,
88            label,
89            &envelope.cwd().to_string_lossy(),
90        );
91    }
92    allow_json()
93}
94
95fn dispatch_subagent_stop(project_id: &str, envelope: &CursorEnvelope) -> HookResult {
96    if !envelope.subagent_id.is_empty() {
97        edda_bridge_claude::peers::remove_heartbeat(project_id, &envelope.subagent_id);
98    }
99    empty_json()
100}
101
102fn dispatch_session_end(project_id: &str, envelope: &CursorEnvelope) -> anyhow::Result<HookResult> {
103    let session_id = envelope.session_id();
104    if !session_id.is_empty() {
105        let cwd = envelope.cwd();
106        let _ = edda_bridge_claude::digest::digest_session_manual(
107            project_id,
108            session_id,
109            &cwd.to_string_lossy(),
110            true,
111        );
112    }
113    let peers_active = !session_id.is_empty()
114        && !edda_bridge_claude::peers::discover_active_peers(project_id, session_id).is_empty();
115    cleanup_session_state(project_id, session_id, peers_active);
116    Ok(empty_json())
117}
118
119fn cleanup_session_state(project_id: &str, session_id: &str, peers_active: bool) {
120    let state_dir = edda_store::project_dir(project_id).join("state");
121    for name in [
122        "inject_hash",
123        "nudge_ts",
124        "nudge_count",
125        "decide_count",
126        "signal_count",
127        "peer_count",
128        "coord_offset",
129    ] {
130        let _ = std::fs::remove_file(state_dir.join(format!("{name}.{session_id}")));
131    }
132    let _ = std::fs::remove_file(state_dir.join(format!("phase.{session_id}.json")));
133    let _ = std::fs::remove_file(state_dir.join("compact_pending"));
134    edda_bridge_claude::peers::remove_heartbeat(project_id, session_id);
135    if peers_active {
136        edda_bridge_claude::peers::write_unclaim(project_id, session_id);
137    }
138}
139
140fn dispatch_post_tool_use(
141    project_id: &str,
142    envelope: &CursorEnvelope,
143) -> anyhow::Result<HookResult> {
144    let session_id = envelope.session_id();
145    let tool_name = if envelope.tool_name == "Shell" {
146        "Bash"
147    } else {
148        &envelope.tool_name
149    };
150    let raw = serde_json::json!({
151        "tool_name": tool_name,
152        "tool_input": envelope.tool_input,
153    });
154    let signal = match edda_bridge_claude::nudge::detect_signal(&raw) {
155        Some(signal) => signal,
156        None => return Ok(empty_json()),
157    };
158
159    state::increment_counter(project_id, session_id, "signal_count");
160    if signal == edda_bridge_claude::nudge::NudgeSignal::SelfRecord {
161        state::increment_counter(project_id, session_id, "decide_count");
162        return Ok(empty_json());
163    }
164    if !state::should_nudge(project_id, session_id) {
165        return Ok(empty_json());
166    }
167
168    let decide_count = state::read_counter(project_id, session_id, "decide_count");
169    let nudge = edda_bridge_claude::nudge::format_nudge(&signal, decide_count);
170    if nudge.is_empty() {
171        return Ok(empty_json());
172    }
173    state::mark_nudge_sent(project_id, session_id);
174    state::increment_counter(project_id, session_id, "nudge_count");
175    additional_context(&nudge)
176}
177
178fn dispatch_pre_tool_use(
179    project_id: &str,
180    envelope: &CursorEnvelope,
181) -> anyhow::Result<HookResult> {
182    if std::env::var("EDDA_POSTMORTEM").unwrap_or_else(|_| "1".to_string()) == "0" {
183        return Ok(empty_json());
184    }
185
186    let mut rules_store = edda_postmortem::RulesStore::load_project(project_id);
187    if rules_store.active_rules().is_empty() {
188        return Ok(empty_json());
189    }
190
191    let files_touched = ["file_path", "path", "filePath"]
192        .iter()
193        .find_map(|key| {
194            envelope
195                .tool_input
196                .get(key)
197                .and_then(|value| value.as_str())
198        })
199        .map(|path| vec![path.to_string()])
200        .unwrap_or_default();
201    let command = envelope
202        .tool_input
203        .get("command")
204        .and_then(|value| value.as_str())
205        .map(str::to_string);
206    let context = edda_postmortem::hooks::HookContext {
207        hook_event: "PreToolUse".to_string(),
208        tool_name: envelope.tool_name.clone(),
209        files_touched,
210        cwd: envelope.cwd().to_string_lossy().into_owned(),
211        command,
212    };
213
214    let evaluation = edda_postmortem::hooks::evaluate_rules(&rules_store, &context);
215    if !evaluation.matched_rule_ids.is_empty() {
216        edda_postmortem::hooks::record_matched_hits(&mut rules_store, &evaluation.matched_rule_ids);
217        let _ = rules_store.save_project(project_id);
218    }
219    match edda_postmortem::hooks::format_warnings(&evaluation) {
220        Some(warning) => pre_tool_warning(&warning),
221        None => Ok(empty_json()),
222    }
223}
224
225fn dispatch_session_start(
226    project_id: &str,
227    envelope: &CursorEnvelope,
228) -> anyhow::Result<HookResult> {
229    let cwd = envelope.cwd();
230    let cwd_text = cwd.to_string_lossy();
231    let session_id = envelope.session_id();
232
233    if !session_id.is_empty() {
234        let label = std::env::var("EDDA_SESSION_LABEL").unwrap_or_default();
235        edda_bridge_claude::peers::write_heartbeat_minimal(
236            project_id, session_id, &label, &cwd_text,
237        );
238    }
239
240    let mut body_parts = Vec::new();
241    let doctrine_budget = env_usize("EDDA_DOCTRINE_BUDGET_CHARS", 4000);
242    if let Some(doctrine) = edda_pack::read_doctrine_pack(&cwd, doctrine_budget) {
243        body_parts.push(doctrine);
244    }
245
246    let workspace_budget = env_usize("EDDA_WORKSPACE_BUDGET_CHARS", 2500);
247    if let Some(workspace) = render::workspace(&cwd_text, workspace_budget) {
248        body_parts.push(workspace);
249    }
250    if let Some(pack) = render::pack(project_id) {
251        body_parts.push(pack);
252    }
253    if let Some(plan) = render::plan(Some(project_id)) {
254        body_parts.push(plan);
255    }
256
257    let body = body_parts.join("\n\n");
258    let mut tail = format!("\n\n{}", render::writeback());
259    if let Some(coordination) =
260        edda_bridge_claude::peers::render_coordination_protocol(project_id, session_id, &cwd_text)
261    {
262        tail.push_str("\n\n");
263        tail.push_str(&coordination);
264    }
265
266    let body_budget = render::context_budget(&cwd_text).saturating_sub(tail.len());
267    let content = format!("{}{tail}", render::apply_budget(&body, body_budget));
268    let wrapped = render::wrap_boundary(&content);
269    let sidefile = context_sidefile(project_id, &wrapped)?;
270    let output = serde_json::json!({
271        "env": {
272            "EDDA_HOT_PACK_PATH": sidefile.to_string_lossy(),
273        },
274        "additional_context": wrapped,
275    });
276    Ok(HookResult::output(serde_json::to_string(&output)?))
277}
278
279fn env_usize(name: &str, default: usize) -> usize {
280    std::env::var(name)
281        .ok()
282        .and_then(|value| value.parse().ok())
283        .unwrap_or(default)
284}
285
286fn context_sidefile(project_id: &str, context: &str) -> anyhow::Result<std::path::PathBuf> {
287    let packs_dir = edda_store::project_dir(project_id).join("packs");
288    let hot_path = packs_dir.join("hot.md");
289    if hot_path.is_file() {
290        return Ok(hot_path);
291    }
292
293    std::fs::create_dir_all(&packs_dir)?;
294    let generated_path = packs_dir.join("cursor-context.md");
295    std::fs::write(&generated_path, context)?;
296    Ok(generated_path)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn empty_stdin_returns_no_output() {
305        let result = hook_entrypoint_from_stdin("").unwrap();
306
307        assert!(result.stdout.is_none());
308        assert!(result.stderr.is_none());
309    }
310
311    #[test]
312    fn malformed_stdin_fails_open_with_empty_json() {
313        let result = hook_entrypoint_from_stdin("not json").unwrap();
314
315        assert_eq!(result.stdout.as_deref(), Some("{}"));
316        assert!(result.stderr.is_none());
317    }
318
319    #[test]
320    fn session_start_pushes_context_and_readable_sidefile() {
321        let tmp = tempfile::tempdir().unwrap();
322        let stdin = serde_json::json!({
323            "hook_event_name": "sessionStart",
324            "session_id": "cursor-session-start-1",
325            "workspace_roots": [tmp.path()],
326            "composer_mode": "agent"
327        })
328        .to_string();
329
330        let result = hook_entrypoint_from_stdin(&stdin).unwrap();
331        let output: serde_json::Value =
332            serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
333
334        let context = output["additional_context"].as_str().unwrap();
335        assert!(context.contains("Write-Back Protocol"));
336        assert!(context.contains("edda decide"));
337        let sidefile = output["env"]["EDDA_HOT_PACK_PATH"].as_str().unwrap();
338        assert!(std::path::Path::new(sidefile).is_file());
339    }
340
341    #[test]
342    fn before_submit_prompt_explicitly_continues() {
343        let stdin = serde_json::json!({
344            "hook_event_name": "beforeSubmitPrompt",
345            "conversation_id": "cursor-prompt-1",
346            "cwd": "/work/project"
347        })
348        .to_string();
349
350        let result = hook_entrypoint_from_stdin(&stdin).unwrap();
351        let output: serde_json::Value =
352            serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
353
354        assert_eq!(output["continue"], true);
355    }
356
357    #[test]
358    fn pre_tool_warning_uses_cursor_permission_schema() {
359        let result = pre_tool_warning("Review the governing decision").unwrap();
360        let output: serde_json::Value =
361            serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
362
363        assert_eq!(output["permission"], "allow");
364        assert_eq!(output["agent_message"], "Review the governing decision");
365    }
366
367    #[test]
368    fn post_tool_use_maps_cursor_shell_and_pushes_nudge() {
369        let tmp = tempfile::tempdir().unwrap();
370        let stdin = serde_json::json!({
371            "hook_event_name": "postToolUse",
372            "session_id": "cursor-post-tool-1",
373            "workspace_roots": [tmp.path()],
374            "tool_name": "Shell",
375            "tool_input": {"command": "git commit -m checkpoint"}
376        })
377        .to_string();
378
379        let result = hook_entrypoint_from_stdin(&stdin).unwrap();
380        let output: serde_json::Value =
381            serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
382
383        let context = output["additional_context"].as_str().unwrap();
384        assert!(context.contains("edda decide"));
385    }
386
387    #[test]
388    fn pre_compact_marks_recovery_state_without_claiming_rebuild() {
389        let tmp = tempfile::tempdir().unwrap();
390        let project_id = edda_store::project_id(tmp.path());
391        let _ = edda_bridge_claude::state::take_compact_pending(&project_id);
392        let stdin = serde_json::json!({
393            "hook_event_name": "preCompact",
394            "session_id": "cursor-compact-1",
395            "workspace_roots": [tmp.path()]
396        })
397        .to_string();
398
399        let result = hook_entrypoint_from_stdin(&stdin).unwrap();
400
401        assert_eq!(result.stdout.as_deref(), Some("{}"));
402        assert!(edda_bridge_claude::state::take_compact_pending(&project_id));
403    }
404
405    #[test]
406    fn session_end_cleans_session_state() {
407        let tmp = tempfile::tempdir().unwrap();
408        let project_id = edda_store::project_id(tmp.path());
409        let session_id = "cursor-session-end-1";
410        let start = serde_json::json!({
411            "hook_event_name": "sessionStart",
412            "session_id": session_id,
413            "workspace_roots": [tmp.path()]
414        })
415        .to_string();
416        hook_entrypoint_from_stdin(&start).unwrap();
417        let heartbeat = edda_store::project_dir(&project_id)
418            .join("state")
419            .join(format!("session.{session_id}.json"));
420        assert!(heartbeat.is_file());
421
422        let end = serde_json::json!({
423            "hook_event_name": "sessionEnd",
424            "session_id": session_id,
425            "workspace_roots": [tmp.path()]
426        })
427        .to_string();
428        let result = hook_entrypoint_from_stdin(&end).unwrap();
429
430        assert_eq!(result.stdout.as_deref(), Some("{}"));
431        assert!(!heartbeat.exists());
432    }
433
434    #[test]
435    fn hook_events_are_recorded_in_cursor_session_ledger() {
436        let tmp = tempfile::tempdir().unwrap();
437        let project_id = edda_store::project_id(tmp.path());
438        let session_id = "cursor-ledger-1";
439        let stdin = serde_json::json!({
440            "hook_event_name": "beforeSubmitPrompt",
441            "session_id": session_id,
442            "workspace_roots": [tmp.path()],
443            "model": "composer"
444        })
445        .to_string();
446
447        hook_entrypoint_from_stdin(&stdin).unwrap();
448
449        let ledger = edda_store::project_dir(&project_id)
450            .join("ledger")
451            .join(format!("{session_id}.jsonl"));
452        let recorded = std::fs::read_to_string(ledger).unwrap();
453        assert!(recorded.contains(r#""bridge":"cursor""#));
454        assert!(recorded.contains(r#""hook_event_name":"beforeSubmitPrompt""#));
455    }
456
457    #[test]
458    fn subagent_lifecycle_tracks_and_clears_peer_heartbeat() {
459        let tmp = tempfile::tempdir().unwrap();
460        let project_id = edda_store::project_id(tmp.path());
461        let subagent_id = "cursor-subagent-1";
462        let start = serde_json::json!({
463            "hook_event_name": "subagentStart",
464            "conversation_id": "cursor-parent-1",
465            "workspace_roots": [tmp.path()],
466            "subagent_id": subagent_id,
467            "subagent_type": "explore"
468        })
469        .to_string();
470
471        let result = hook_entrypoint_from_stdin(&start).unwrap();
472        let output: serde_json::Value =
473            serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
474        assert_eq!(output["permission"], "allow");
475        let heartbeat = edda_store::project_dir(&project_id)
476            .join("state")
477            .join(format!("session.{subagent_id}.json"));
478        assert!(heartbeat.is_file());
479
480        let stop = serde_json::json!({
481            "hook_event_name": "subagentStop",
482            "conversation_id": "cursor-parent-1",
483            "workspace_roots": [tmp.path()],
484            "subagent_id": subagent_id
485        })
486        .to_string();
487        hook_entrypoint_from_stdin(&stop).unwrap();
488        assert!(!heartbeat.exists());
489    }
490}