From 347f945590a6f9624b3ea62060e09a68ac2cb3a5 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 9 Oct 2026 15:40:33 -0400 Subject: [PATCH 1/6] feat(cursor): trace subagent summaries Capture subagent start events so summary spans include task input and duration, and parent them to the spawning tool call when available. --- bt-daemon/src/translate/cursor/mod.rs | 4 ++- bt-daemon/tests/cursor_translator.rs | 40 ++++++++++++++------- src/plugins/cursor/content/hooks/hooks.json | 7 ++++ src/plugins/cursor/validate.sh | 2 +- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/bt-daemon/src/translate/cursor/mod.rs b/bt-daemon/src/translate/cursor/mod.rs index feb3260..45bdfea 100644 --- a/bt-daemon/src/translate/cursor/mod.rs +++ b/bt-daemon/src/translate/cursor/mod.rs @@ -56,6 +56,8 @@ const CURSOR_METADATA_FIELDS: &[&str] = &[ "is_interrupt", "tool_approval", "subagent_type", + "subagent_id", + "spawning_tool_call_id", "recursive_activity_verified", "observation_only", "completion_observed", @@ -1143,7 +1145,7 @@ impl CursorTranslator { let spawn = hook.tool_call_id.as_deref(); let Some(parent) = spawn .and_then(|s| self.tools.get(s).or_else(|| self.completed.get(s))) - .map(|t| t.turn.clone()) + .map(|t| t.row.span_id.clone()) .or_else(|| self.turn.as_ref().map(|turn| turn.id.clone())) else { return; diff --git a/bt-daemon/tests/cursor_translator.rs b/bt-daemon/tests/cursor_translator.rs index 6f950a1..b80ff6c 100644 --- a/bt-daemon/tests/cursor_translator.rs +++ b/bt-daemon/tests/cursor_translator.rs @@ -1069,7 +1069,7 @@ fn synthetic_usage_without_verified_prompt_generation_stays_unavailable() { } #[test] -fn synthetic_subagents_parent_to_spawning_turn_and_unmatched_stop_stays_incomplete() { +fn synthetic_subagents_parent_to_spawning_tool_and_unmatched_stop_stays_incomplete() { let mut h = Harness::new("test-session"); h.handle(&event( "beforeSubmitPrompt", @@ -1081,13 +1081,20 @@ fn synthetic_subagents_parent_to_spawning_turn_and_unmatched_stop_stays_incomple 110, json!({"tool_name":"Task","tool_use_id":"spawn","tool_input":{"task":"explore"}}), )); - for (ts, id) in [(120, "child-a"), (130, "child-b")] { - h.handle(&event("subagentStart", ts, json!({"subagent_id":id,"tool_call_id":"spawn","subagent_type":"explore","subagent_model":"auto","task":"explore"}))); - } + h.handle(&event( + "subagentStart", + 120, + json!({"subagent_id":"child-a","tool_call_id":"spawn","subagent_type":"explore","subagent_model":"auto","task":"inspect the code"}), + )); + h.handle(&event( + "subagentStart", + 130, + json!({"subagent_id":"child-b","tool_call_id":"spawn","subagent_type":"explore","subagent_model":"auto","task":"explore"}), + )); h.handle(&event( "subagentStop", 140, - json!({"subagent_id":"child-a","subagent_type":"explore","status":"completed","summary":"available summary"}), + json!({"subagent_id":"child-a","subagent_type":"explore","status":"completed","summary":"found the issue"}), )); h.handle(&event( "sessionEnd", @@ -1102,21 +1109,28 @@ fn synthetic_subagents_parent_to_spawning_turn_and_unmatched_stop_stays_incomple .collect(); assert_eq!(children.len(), 2); let rows = h.rows(); + let spawn_tool_id = h + .inserted(SpanType::Tool) + .into_iter() + .find(|row| row.name == "Task") + .unwrap() + .span_id + .clone(); for child in children { - assert_eq!(child.parent_span_ids, vec![h.turns()[0].span_id.clone()]); - assert!(child - .metadata - .as_ref() - .unwrap() - .get("spawning_tool_call_id") - .is_none()); + assert_eq!(child.parent_span_ids, vec![spawn_tool_id.clone()]); + assert_eq!( + child.metadata.as_ref().unwrap()["spawning_tool_call_id"], + "spawn" + ); assert_eq!( child.metadata.as_ref().unwrap()["recursive_activity_verified"], false ); let row = &rows[&child.span_id]; if child.start_ms == Some(120) { - assert_eq!(row["output"], "available summary"); + assert_eq!(row["end_ms"], 140); + assert_eq!(child.input, Some(json!("inspect the code"))); + assert_eq!(row["output"], "found the issue"); assert_eq!(row["metadata"]["status"], "completed"); } else { assert!(row.get("output").is_none()); diff --git a/src/plugins/cursor/content/hooks/hooks.json b/src/plugins/cursor/content/hooks/hooks.json index a09de42..56c3374 100644 --- a/src/plugins/cursor/content/hooks/hooks.json +++ b/src/plugins/cursor/content/hooks/hooks.json @@ -36,6 +36,13 @@ "failClosed": false } ], + "subagentStart": [ + { + "command": "bt trace hook --source cursor --event subagentStart --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "timeout": 10, + "failClosed": false + } + ], "subagentStop": [ { "command": "bt trace hook --source cursor --event subagentStop --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", diff --git a/src/plugins/cursor/validate.sh b/src/plugins/cursor/validate.sh index 7fbc67a..0013f71 100755 --- a/src/plugins/cursor/validate.sh +++ b/src/plugins/cursor/validate.sh @@ -46,7 +46,7 @@ config = json.loads((root / manifest['hooks']).read_text()) assert config['version'] == 1 expected = { 'sessionStart', 'sessionEnd', 'beforeSubmitPrompt', - 'postToolUse', 'postToolUseFailure', 'subagentStop', + 'postToolUse', 'postToolUseFailure', 'subagentStart', 'subagentStop', 'afterShellExecution', 'afterMCPExecution', 'afterFileEdit', 'preCompact', 'stop', 'afterAgentResponse', } From f00e7a2cffa3c943c420f6748ad94524c0be7637 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 9 Oct 2026 16:24:05 -0400 Subject: [PATCH 2/6] fix(cursor): honor subagent hook permissions Return valid allow responses from permission hooks, capture Task preToolUse, and retain turn lineage when subagent spans parent to the Task tool. --- bt-daemon/src/hook.rs | 18 ++++++++++--- bt-daemon/src/managed_run/cursor.rs | 29 ++++++++++++--------- bt-daemon/src/setup/cursor/mod.rs | 3 +++ bt-daemon/src/translate/cursor/mod.rs | 11 +++++--- bt-daemon/tests/cursor_translator.rs | 1 + src/plugins/cursor/content/hooks/hooks.json | 8 ++++++ src/plugins/cursor/content/hooks/trace.sh | 4 ++- src/plugins/cursor/test/test_capture.sh | 18 +++++++++++-- src/plugins/cursor/validate.sh | 9 ++++--- 9 files changed, 75 insertions(+), 26 deletions(-) diff --git a/bt-daemon/src/hook.rs b/bt-daemon/src/hook.rs index 2240249..b15a515 100644 --- a/bt-daemon/src/hook.rs +++ b/bt-daemon/src/hook.rs @@ -144,10 +144,10 @@ fn cursor_plugin_hook_is_redundant( } fn cursor_hook_response_for_event(event: Option<&str>) -> &'static str { - if event == Some("beforeSubmitPrompt") { - r#"{"continue":true}"# - } else { - "{}" + match event { + Some("beforeSubmitPrompt") => r#"{"continue":true}"#, + Some("preToolUse" | "subagentStart") => r#"{"permission":"allow"}"#, + _ => "{}", } } @@ -436,6 +436,16 @@ mod tests { )); } + #[test] + fn cursor_permission_hooks_allow_actions_after_capture() { + for event in ["preToolUse", "subagentStart"] { + assert_eq!( + cursor_hook_response_for_event(Some(event)), + r#"{"permission":"allow"}"# + ); + } + } + #[test] fn hook_plugin_version_reaches_envelope_and_every_initialize_attempt() { let args = HookCli::try_parse_from([ diff --git a/bt-daemon/src/managed_run/cursor.rs b/bt-daemon/src/managed_run/cursor.rs index 64f7be0..bbc6db2 100644 --- a/bt-daemon/src/managed_run/cursor.rs +++ b/bt-daemon/src/managed_run/cursor.rs @@ -108,6 +108,7 @@ fn write_cursor_managed_plugin_for_platform( }))?, )?; let script_path = hook_dir.join(if windows { "trace.ps1" } else { "trace.sh" }); + let source_manifest: serde_json::Value = serde_json::from_str(CURSOR_HOOKS_MANIFEST)?; let hooks = cursor_hook_events()? .iter() .map(|event| { @@ -119,14 +120,15 @@ fn write_cursor_managed_plugin_for_platform( } else { format!("\"${{CURSOR_PLUGIN_ROOT}}/hooks/trace.sh\" {event}") }; - ( - event.clone(), - serde_json::json!([{ - "command": command, - "timeout": 10, - "failClosed": false - }]), - ) + let mut registration = serde_json::json!({ + "command": command, + "timeout": 10, + "failClosed": false + }); + if let Some(matcher) = source_manifest["hooks"][event][0]["matcher"].as_str() { + registration["matcher"] = serde_json::json!(matcher); + } + (event.clone(), serde_json::json!([registration])) }) .collect::>(); std::fs::write( @@ -156,7 +158,7 @@ fn write_cursor_managed_plugin_for_platform( command.push_str("e_unix_shell_arg(arg)); } let script = format!( - "#!/bin/sh\n# Keep Cursor's prompt response valid even when tracing fails.\nif ! {command} >/dev/null 2>/dev/null; then\n :\nfi\ncase \"${{1-}}\" in\n beforeSubmitPrompt) printf '%s\\n' '{{\"continue\":true}}' ;;\n *) printf '%s\\n' '{{}}' ;;\nesac\nexit 0\n" + "#!/bin/sh\n# Keep Cursor's hook response valid even when tracing fails.\nif ! {command} >/dev/null 2>/dev/null; then\n :\nfi\ncase \"${{1-}}\" in\n beforeSubmitPrompt) printf '%s\\n' '{{\"continue\":true}}' ;;\n preToolUse|subagentStart) printf '%s\\n' '{{\"permission\":\"allow\"}}' ;;\n *) printf '%s\\n' '{{}}' ;;\nesac\nexit 0\n" ); std::fs::write(&script_path, script)?; #[cfg(unix)] @@ -202,7 +204,7 @@ fn cursor_managed_powershell_script(hook_command: &RunHookCommand) -> anyhow::Re .collect::>() .join(", "); Ok(format!( - "$bt = {}\n$hookArgs = @({})\ntry {{ & $bt @hookArgs *> $null }} catch {{}}\nif ($args.Count -gt 0 -and $args[0] -eq 'beforeSubmitPrompt') {{\n [Console]::Out.WriteLine('{{\"continue\":true}}')\n}} else {{\n [Console]::Out.WriteLine('{{}}')\n}}\nexit 0\n", + "$bt = {}\n$hookArgs = @({})\ntry {{ & $bt @hookArgs *> $null }} catch {{}}\nif ($args.Count -gt 0 -and $args[0] -eq 'beforeSubmitPrompt') {{\n [Console]::Out.WriteLine('{{\"continue\":true}}')\n}} elseif ($args.Count -gt 0 -and ($args[0] -eq 'preToolUse' -or $args[0] -eq 'subagentStart')) {{\n [Console]::Out.WriteLine('{{\"permission\":\"allow\"}}')\n}} else {{\n [Console]::Out.WriteLine('{{}}')\n}}\nexit 0\n", powershell_literal(executable), rendered_args )) @@ -236,6 +238,7 @@ mod tests { ) .unwrap(); assert_eq!(plugin_manifest["hooks"], "hooks/hooks.json"); + assert_eq!(manifest["hooks"]["preToolUse"][0]["matcher"], "Task"); for event in cursor_hook_events().unwrap() { let entry = &manifest["hooks"][event][0]; assert_eq!(entry["failClosed"], false); @@ -253,14 +256,14 @@ mod tests { assert!(script.contains("'--managed-run-hook'")); assert!(script.contains("'--session-id-field'")); assert!(script.contains("'conversation_id'")); - assert!(!script.contains("permission\":\"allow")); + assert!(script.contains("permission\":\"allow\"")); assert!(script.contains("{\"continue\":true}")); assert!(!script.contains("--dangerously-bypass")); } else { let script = std::fs::read_to_string(plugin.join("hooks/trace.sh")).unwrap(); assert!(script.contains("'--source' 'cursor' '--managed-run-hook'")); assert!(script.contains("'--session-id-field' 'conversation_id'")); - assert!(!script.contains("\"permission\":\"allow\"")); + assert!(script.contains("\"permission\":\"allow\"")); assert!(script.contains("\"continue\":true")); assert!(!script.contains("--dangerously-bypass")); } @@ -319,7 +322,7 @@ mod tests { assert!(script.contains("--session-id-field")); assert!(script.contains("[Console]::Out.WriteLine('{}')")); assert!(script.contains("{\"continue\":true}")); - assert!(!script.contains("permission\":\"allow")); + assert!(script.contains("permission\":\"allow\"")); assert!(!script.contains("/bin/sh")); } } diff --git a/bt-daemon/src/setup/cursor/mod.rs b/bt-daemon/src/setup/cursor/mod.rs index ae68ea7..74c966c 100644 --- a/bt-daemon/src/setup/cursor/mod.rs +++ b/bt-daemon/src/setup/cursor/mod.rs @@ -33,6 +33,8 @@ $hookArgs = @('trace', 'hook', '--source', 'cursor', '--session-id-field', 'conv try { & $bt @hookArgs *> $null } catch {} if ($args.Count -gt 0 -and $args[0] -eq 'beforeSubmitPrompt') { [Console]::Out.WriteLine('{"continue":true}') +} elseif ($args.Count -gt 0 -and ($args[0] -eq 'preToolUse' -or $args[0] -eq 'subagentStart')) { + [Console]::Out.WriteLine('{"permission":"allow"}') } else { [Console]::Out.WriteLine('{}') } @@ -427,6 +429,7 @@ mod tests { assert!(script.contains("'--source', 'cursor'")); assert!(script.contains("'--event-field', 'hook_event_name'")); assert!(script.contains("'{\"continue\":true}'") || script.contains("\"continue\":true")); + assert!(script.contains("permission\":\"allow\"")); assert!(script.contains("[Console]::Out.WriteLine('{}')")); } diff --git a/bt-daemon/src/translate/cursor/mod.rs b/bt-daemon/src/translate/cursor/mod.rs index 45bdfea..b6275ea 100644 --- a/bt-daemon/src/translate/cursor/mod.rs +++ b/bt-daemon/src/translate/cursor/mod.rs @@ -1143,10 +1143,14 @@ impl CursorTranslator { } self.ensure_turn(e.ts_ms, None, None, "hook_order", ops); let spawn = hook.tool_call_id.as_deref(); - let Some(parent) = spawn + let Some((parent, turn)) = spawn .and_then(|s| self.tools.get(s).or_else(|| self.completed.get(s))) - .map(|t| t.row.span_id.clone()) - .or_else(|| self.turn.as_ref().map(|turn| turn.id.clone())) + .map(|t| (t.row.span_id.clone(), t.turn.clone())) + .or_else(|| { + self.turn + .as_ref() + .map(|turn| (turn.id.clone(), turn.id.clone())) + }) else { return; }; @@ -1156,6 +1160,7 @@ impl CursorTranslator { "Cursor subagent", SpanType::Task, ); + row.turn_span_id = Some(turn); row.start_ms = Some(e.ts_ms); row.input = Some(json!(hook.task)); row.metadata = Some( diff --git a/bt-daemon/tests/cursor_translator.rs b/bt-daemon/tests/cursor_translator.rs index b80ff6c..755f8ff 100644 --- a/bt-daemon/tests/cursor_translator.rs +++ b/bt-daemon/tests/cursor_translator.rs @@ -1118,6 +1118,7 @@ fn synthetic_subagents_parent_to_spawning_tool_and_unmatched_stop_stays_incomple .clone(); for child in children { assert_eq!(child.parent_span_ids, vec![spawn_tool_id.clone()]); + assert_eq!(child.turn_span_id, Some(h.turns()[0].span_id.clone())); assert_eq!( child.metadata.as_ref().unwrap()["spawning_tool_call_id"], "spawn" diff --git a/src/plugins/cursor/content/hooks/hooks.json b/src/plugins/cursor/content/hooks/hooks.json index 56c3374..6c9d80f 100644 --- a/src/plugins/cursor/content/hooks/hooks.json +++ b/src/plugins/cursor/content/hooks/hooks.json @@ -22,6 +22,14 @@ "failClosed": false } ], + "preToolUse": [ + { + "command": "bt trace hook --source cursor --event preToolUse --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "timeout": 10, + "failClosed": false, + "matcher": "Task" + } + ], "postToolUse": [ { "command": "bt trace hook --source cursor --event postToolUse --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", diff --git a/src/plugins/cursor/content/hooks/trace.sh b/src/plugins/cursor/content/hooks/trace.sh index 32aac9b..c361a66 100755 --- a/src/plugins/cursor/content/hooks/trace.sh +++ b/src/plugins/cursor/content/hooks/trace.sh @@ -14,10 +14,12 @@ if command -v "$bt_bin" >/dev/null 2>&1; then fi fi -# beforeSubmitPrompt is a continuation hook, not a permission decision. +# beforeSubmitPrompt is a continuation hook; the others below are permission hooks. case "${1-}" in beforeSubmitPrompt) printf '%s\n' '{"continue":true}' ;; + preToolUse|subagentStart) + printf '%s\n' '{"permission":"allow"}' ;; *) printf '%s\n' '{}' ;; esac diff --git a/src/plugins/cursor/test/test_capture.sh b/src/plugins/cursor/test/test_capture.sh index bc8b1d1..88c6e1a 100755 --- a/src/plugins/cursor/test/test_capture.sh +++ b/src/plugins/cursor/test/test_capture.sh @@ -22,6 +22,7 @@ printf '%s\\n' "$*" >> "$CAPTURE_DIR/args" /bin/cat > "$CAPTURE_DIR/payload" case " $* " in *" --event beforeSubmitPrompt "*) printf '%s\\n' '{"continue":true}' ;; + *" --event preToolUse "*|*" --event subagentStart "*) printf '%s\\n' '{"permission":"allow"}' ;; *) printf '%s\\n' '{}' ;; esac exit "${BT_EXIT_CODE:-0}" @@ -39,8 +40,11 @@ exit "${BT_EXIT_CODE:-0}" input=payload, env=environment, capture_output=True, executable='/bin/sh') assert result.returncode == int(exit_code), (event, result.returncode) - expected_output = (b'{"continue":true}\n' if event == 'beforeSubmitPrompt' - else b'{}\n') + expected_output = { + 'beforeSubmitPrompt': b'{"continue":true}\n', + 'preToolUse': b'{"permission":"allow"}\n', + 'subagentStart': b'{"permission":"allow"}\n', + }.get(event, b'{}\n') assert result.stdout == expected_output, (event, result.stdout) assert (root / 'payload').read_bytes() == payload args = (root / 'args').read_text().splitlines()[-1] @@ -51,6 +55,16 @@ exit "${BT_EXIT_CODE:-0}" '--capture-timeout-ms 8000'), args assert registration['failClosed'] is False assert registration['timeout'] * 1000 > 8000 + for event, expected in ( + ('beforeSubmitPrompt', b'{"continue":true}\n'), + ('preToolUse', b'{"permission":"allow"}\n'), + ('subagentStart', b'{"permission":"allow"}\n'), + ): + result = subprocess.run([str(source / 'hooks/trace.sh'), event], + input=payload, env=environment, + capture_output=True) + assert result.returncode == 0, (event, result.returncode) + assert result.stdout == expected, (event, result.stdout) fake_bt.unlink() for event, registrations in config['hooks'].items(): result = subprocess.run(registrations[0]['command'], shell=True, diff --git a/src/plugins/cursor/validate.sh b/src/plugins/cursor/validate.sh index 0013f71..88d3990 100755 --- a/src/plugins/cursor/validate.sh +++ b/src/plugins/cursor/validate.sh @@ -45,21 +45,24 @@ assert 'MIT License' in (root / 'LICENSE').read_text() config = json.loads((root / manifest['hooks']).read_text()) assert config['version'] == 1 expected = { - 'sessionStart', 'sessionEnd', 'beforeSubmitPrompt', + 'sessionStart', 'sessionEnd', 'beforeSubmitPrompt', 'preToolUse', 'postToolUse', 'postToolUseFailure', 'subagentStart', 'subagentStop', 'afterShellExecution', 'afterMCPExecution', 'afterFileEdit', 'preCompact', 'stop', 'afterAgentResponse', } assert set(config['hooks']) == expected for event, registrations in config['hooks'].items(): - assert registrations == [{ + expected_registration = { 'command': ('bt trace hook --source cursor ' f'--event {event} --session-id-field conversation_id ' '--event-field hook_event_name --transcript-path-field transcript_path ' '--flush-on-turn-end --capture-timeout-ms 8000'), 'timeout': 10, 'failClosed': False, - }] + } + if event == 'preToolUse': + expected_registration['matcher'] = 'Task' + assert registrations == [expected_registration] PY "$SRC_DIR/test/test_capture.sh" "$TARGET_DIR" From 46a0f678a66e184dd03a701d93a1d6926d078641 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 13 Oct 2026 15:49:08 -0400 Subject: [PATCH 3/6] fix(cursor): address subagent tracing review feedback --- bt-daemon/src/setup/cursor/hooks.rs | 54 +++++++++++------ bt-daemon/src/translate/cursor/hooks.rs | 12 +++- bt-daemon/src/translate/cursor/mod.rs | 77 +++++++++++++++++++------ 3 files changed, 104 insertions(+), 39 deletions(-) diff --git a/bt-daemon/src/setup/cursor/hooks.rs b/bt-daemon/src/setup/cursor/hooks.rs index 5dabbdb..cf65629 100644 --- a/bt-daemon/src/setup/cursor/hooks.rs +++ b/bt-daemon/src/setup/cursor/hooks.rs @@ -71,7 +71,7 @@ fn capture_specs_from_manifest( let command = entries.as_array().and_then(|entries| entries.first()) .and_then(|entry| entry.get("command")).and_then(Value::as_str) .ok_or_else(|| anyhow::anyhow!("Cursor hook {event:?} is missing its command"))?; - let response = if event == "beforeSubmitPrompt" { r#"{"continue":true}"# } else { "{}" }; + let response = cursor_hook_response(event); let command = if windows { powershell_encoded_command(&format!( "$env:{USER_HOOK_ENV} = '1'; try {{ & {command} *> $null }} catch {{}}; [Console]::Out.WriteLine('{response}') # {CAPTURE_MARKER}" @@ -83,6 +83,24 @@ fn capture_specs_from_manifest( }).collect() } +fn cursor_hook_response(event: &str) -> &'static str { + match event { + "beforeSubmitPrompt" => r#"{"continue":true}"#, + "preToolUse" | "subagentStart" => r#"{"permission":"allow"}"#, + _ => "{}", + } +} + +fn capture_hook_entry(event: &str, command: &str) -> Value { + let manifest: Value = serde_json::from_str(super::HOOKS_MANIFEST) + .expect("embedded Cursor hooks manifest must be valid"); + let mut entry = hook_entry(command); + if let Some(matcher) = manifest["hooks"][event][0]["matcher"].as_str() { + entry["matcher"] = Value::String(matcher.to_owned()); + } + entry +} + /// Suppress a plugin registration only when this event has a user capture hook. pub(crate) fn user_capture_hook_is_installed_at(config_dir: &Path, event: &str) -> bool { let Ok(config) = load_object(&config_dir.join("hooks.json")) else { @@ -98,15 +116,12 @@ pub(crate) fn user_capture_hook_is_installed_at(config_dir: &Path, event: &str) } fn capture_entry_is_registered(config: &Map, event: &str, command: &str) -> bool { + let expected = capture_hook_entry(event, command); config .get("hooks") .and_then(|hooks| hooks.get(event)) .and_then(Value::as_array) - .is_some_and(|entries| { - entries - .iter() - .any(|entry| entry.get("command").and_then(Value::as_str) == Some(command)) - }) + .is_some_and(|entries| entries.iter().any(|entry| entry == &expected)) } fn powershell_encoded_command(script: &str) -> String { @@ -229,7 +244,12 @@ fn apply_specs( for (event, command) in specs { let Some(entries) = hooks.get_mut(event) else { if enable { - hooks.insert(event.clone(), Value::Array(vec![hook_entry(command)])); + let entry = if persistent { + capture_hook_entry(event, command) + } else { + hook_entry(command) + }; + hooks.insert(event.clone(), Value::Array(vec![entry])); } continue; }; @@ -243,7 +263,11 @@ fn apply_specs( entry.get("command").and_then(Value::as_str) != Some(command.as_str()) }); if enable { - entries.push(hook_entry(command)); + entries.push(if persistent { + capture_hook_entry(event, command) + } else { + hook_entry(command) + }); } } } @@ -407,7 +431,7 @@ mod tests { .iter() .find(|(event, _)| event == "stop") .unwrap(); - expected_stop.push(hook_entry(command)); + expected_stop.push(capture_hook_entry("stop", command)); } assert_eq!(config["hooks"]["stop"], Value::Array(expected_stop)); @@ -445,11 +469,7 @@ mod tests { child.stdin.take().unwrap().write_all(payload).unwrap(); let result = child.wait_with_output().unwrap(); assert!(result.status.success()); - let response = if event == "beforeSubmitPrompt" { - "{\"continue\":true}\n" - } else { - "{}\n" - }; + let response = format!("{}\n", cursor_hook_response(&event)); assert_eq!(result.stdout, response.as_bytes()); assert!(result.stderr.is_empty()); assert_eq!(std::fs::read(temp.path().join("payload")).unwrap(), payload); @@ -488,11 +508,7 @@ mod tests { assert!(script.contains(&format!("--event {event}"))); assert!(script.contains(USER_HOOK_ENV)); assert!(script.contains(CAPTURE_MARKER)); - assert!(script.contains(if event == "beforeSubmitPrompt" { - r#"{"continue":true}"# - } else { - "{}" - })); + assert!(script.contains(cursor_hook_response(&event))); } } } diff --git a/bt-daemon/src/translate/cursor/hooks.rs b/bt-daemon/src/translate/cursor/hooks.rs index 5378666..7ef6678 100644 --- a/bt-daemon/src/translate/cursor/hooks.rs +++ b/bt-daemon/src/translate/cursor/hooks.rs @@ -298,10 +298,16 @@ pub(super) struct SubagentStartHook { #[derive(Deserialize)] pub(super) struct SubagentStopHook { - pub subagent_id: String, - pub subagent_type: String, - pub status: String, + /// Cursor's documented stop payload omits this identifier. Some versions + /// may include it, so use it when present and correlate without it only + /// when there is exactly one open child. + pub subagent_id: Option, + pub subagent_type: Option, + pub task: Option, + pub description: Option, + pub status: Option, pub summary: Option, + pub duration_ms: Option, } /// Hooks that carry a richer result for a tool already reported by the diff --git a/bt-daemon/src/translate/cursor/mod.rs b/bt-daemon/src/translate/cursor/mod.rs index b6275ea..fb72545 100644 --- a/bt-daemon/src/translate/cursor/mod.rs +++ b/bt-daemon/src/translate/cursor/mod.rs @@ -1173,22 +1173,60 @@ impl CursorTranslator { ops.push(SpanOp::Insert(row)); } fn subagent_stop(&mut self, e: &Envelope, hook: &SubagentStopHook, ops: &mut Vec) { - let id = hook.subagent_id.as_str(); - let error = - matches!(hook.status.as_str(), "error" | "aborted").then(|| hook.status.clone()); - if let Some(mut row) = self.subagents.remove(id) { - row.end_ms = Some(e.ts_ms.max(row.start_ms.unwrap_or(e.ts_ms))); - row.output = hook.summary.as_ref().map(|summary| json!(summary)); - row.metadata.get_or_insert_with(|| json!({}))["status"] = json!(hook.status); - row.error = error; - ops.push(SpanOp::Merge(row)); - return; + let status = hook.status.as_deref().unwrap_or("unknown"); + let error = matches!(status, "error" | "aborted").then(|| status.to_owned()); + let correlated_id = hook + .subagent_id + .as_deref() + .filter(|id| self.subagents.contains_key(*id)) + .map(str::to_owned) + .or_else(|| { + hook.subagent_id + .is_none() + .then(|| { + (self.subagents.len() == 1) + .then(|| self.subagents.keys().next().cloned()) + .flatten() + }) + .flatten() + }); + if let Some(id) = correlated_id { + if let Some(mut row) = self.subagents.remove(&id) { + row.end_ms = Some(e.ts_ms.max(row.start_ms.unwrap_or(e.ts_ms))); + row.output = hook.summary.as_ref().map(|summary| json!(summary)); + let metadata = row.metadata.get_or_insert_with(|| json!({})); + metadata["status"] = json!(status); + if hook.subagent_id.is_none() { + metadata["stop_correlated_without_id"] = json!(true); + } + if let Some(duration) = hook.duration_ms { + metadata["duration_ms"] = json!(duration); + } + row.error = error; + ops.push(SpanOp::Merge(row)); + return; + } } - // The shipped plugin registers only the stop hook. Keep the terminal - // observation as a useful, zero-duration span and mark the - // unavailable start time explicitly. + // The documented stop payload omits an ID. If no unique open start is + // available, retain the terminal result as an observation rather than + // guessing which child it belongs to. self.ensure_turn(e.ts_ms, None, None, "hook_order", ops); - let Some(parent) = self.turn.as_ref().map(|turn| turn.id.clone()) else { + let Some((id, parent)) = self.turn.as_ref().map(|turn| { + let id = hook.subagent_id.clone().unwrap_or_else(|| { + ids::span_id( + &self.namespace, + &format!( + "subagent-stop-observation:{}:{}:{}:{}:{}", + e.ts_ms, + status, + hook.subagent_type.as_deref().unwrap_or(""), + hook.task.as_deref().unwrap_or(""), + hook.summary.as_deref().unwrap_or("") + ), + ) + }); + (id, turn.id.clone()) + }) else { return; }; let mut row = self.row( @@ -1197,16 +1235,21 @@ impl CursorTranslator { "Cursor subagent", SpanType::Task, ); - row.start_ms = Some(e.ts_ms); + let duration = hook.duration_ms.and_then(|ms| i64::try_from(ms).ok()); + row.start_ms = Some(duration.map_or(e.ts_ms, |duration| e.ts_ms.saturating_sub(duration))); row.end_ms = Some(e.ts_ms); + row.input = hook.task.as_ref().map(|task| json!(task)); row.output = hook.summary.as_ref().map(|summary| json!(summary)); row.metadata = Some(json!({ - "subagent_id": id, + "subagent_id": hook.subagent_id, "subagent_type": hook.subagent_type, + "description": hook.description, + "duration_ms": hook.duration_ms, "recursive_activity_verified": false, "start_time_estimated": true, "result_completeness": "terminal_observation_only", - "status": hook.status, + "stop_correlation": if hook.subagent_id.is_some() { "id_unmatched" } else { "ambiguous_or_missing_start" }, + "status": status, })); row.error = error; ops.push(SpanOp::Insert(row)); From 019948deeafce4f38d7bbecc5b7619d71be9584a Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 13 Oct 2026 15:58:31 -0400 Subject: [PATCH 4/6] fix(cursor): tighten id-free subagent stop matching --- bt-daemon/src/translate/cursor/mod.rs | 36 +++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/bt-daemon/src/translate/cursor/mod.rs b/bt-daemon/src/translate/cursor/mod.rs index fb72545..d6f7e86 100644 --- a/bt-daemon/src/translate/cursor/mod.rs +++ b/bt-daemon/src/translate/cursor/mod.rs @@ -58,6 +58,7 @@ const CURSOR_METADATA_FIELDS: &[&str] = &[ "subagent_type", "subagent_id", "spawning_tool_call_id", + "native_duration_ms", "recursive_activity_verified", "observation_only", "completion_observed", @@ -1181,17 +1182,32 @@ impl CursorTranslator { .filter(|id| self.subagents.contains_key(*id)) .map(str::to_owned) .or_else(|| { - hook.subagent_id - .is_none() - .then(|| { - (self.subagents.len() == 1) - .then(|| self.subagents.keys().next().cloned()) - .flatten() - }) - .flatten() + if hook.subagent_id.is_some() + || (hook.task.is_none() && hook.subagent_type.is_none()) + { + return None; + } + let mut matching_ids = self.subagents.iter().filter_map(|(id, row)| { + let task_matches = hook.task.as_ref().is_none_or(|task| { + row.input.as_ref().and_then(Value::as_str) == Some(task.as_str()) + }); + let type_matches = hook.subagent_type.as_ref().is_none_or(|kind| { + row.metadata + .as_ref() + .and_then(|metadata| metadata.get("subagent_type")) + .and_then(Value::as_str) + == Some(kind.as_str()) + }); + (task_matches && type_matches).then(|| id.clone()) + }); + let candidate = matching_ids.next()?; + matching_ids.next().is_none().then_some(candidate) }); if let Some(id) = correlated_id { if let Some(mut row) = self.subagents.remove(&id) { + if let Some(duration) = hook.duration_ms.and_then(|ms| i64::try_from(ms).ok()) { + row.start_ms = Some(e.ts_ms.saturating_sub(duration)); + } row.end_ms = Some(e.ts_ms.max(row.start_ms.unwrap_or(e.ts_ms))); row.output = hook.summary.as_ref().map(|summary| json!(summary)); let metadata = row.metadata.get_or_insert_with(|| json!({})); @@ -1200,7 +1216,7 @@ impl CursorTranslator { metadata["stop_correlated_without_id"] = json!(true); } if let Some(duration) = hook.duration_ms { - metadata["duration_ms"] = json!(duration); + metadata["native_duration_ms"] = json!(duration); } row.error = error; ops.push(SpanOp::Merge(row)); @@ -1244,7 +1260,7 @@ impl CursorTranslator { "subagent_id": hook.subagent_id, "subagent_type": hook.subagent_type, "description": hook.description, - "duration_ms": hook.duration_ms, + "native_duration_ms": hook.duration_ms, "recursive_activity_verified": false, "start_time_estimated": true, "result_completeness": "terminal_observation_only", From bc1a167162a1193596059d2c605040ee17d846dc Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 14 Oct 2026 17:06:29 -0400 Subject: [PATCH 5/6] fix(cursor): restore hooks after main rebase --- bt-daemon/src/hook.rs | 5 +++-- bt-daemon/src/setup/cursor/hooks.rs | 17 +++++++++-------- bt-daemon/src/setup/cursor/mod.rs | 2 +- bt-daemon/src/trace_runtime.rs | 22 +++++++++++++++++----- 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/bt-daemon/src/hook.rs b/bt-daemon/src/hook.rs index b15a515..50e5768 100644 --- a/bt-daemon/src/hook.rs +++ b/bt-daemon/src/hook.rs @@ -143,7 +143,7 @@ fn cursor_plugin_hook_is_redundant( } } -fn cursor_hook_response_for_event(event: Option<&str>) -> &'static str { +pub(crate) fn cursor_hook_response_for_event(event: Option<&str>) -> &'static str { match event { Some("beforeSubmitPrompt") => r#"{"continue":true}"#, Some("preToolUse" | "subagentStart") => r#"{"permission":"allow"}"#, @@ -417,7 +417,8 @@ mod tests { false )); for (event, command) in specs { - config["hooks"][event] = json!([{"command":command,"timeout":10,"failClosed":false}]); + config["hooks"][event] = + json!([crate::setup::cursor::capture_hook_entry(event, command).unwrap()]); } std::fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap(); assert!(cursor_plugin_hook_is_redundant( diff --git a/bt-daemon/src/setup/cursor/hooks.rs b/bt-daemon/src/setup/cursor/hooks.rs index cf65629..a8d3773 100644 --- a/bt-daemon/src/setup/cursor/hooks.rs +++ b/bt-daemon/src/setup/cursor/hooks.rs @@ -91,14 +91,13 @@ fn cursor_hook_response(event: &str) -> &'static str { } } -fn capture_hook_entry(event: &str, command: &str) -> Value { - let manifest: Value = serde_json::from_str(super::HOOKS_MANIFEST) - .expect("embedded Cursor hooks manifest must be valid"); +pub(crate) fn capture_hook_entry(event: &str, command: &str) -> anyhow::Result { + let manifest: Value = serde_json::from_str(super::HOOKS_MANIFEST)?; let mut entry = hook_entry(command); if let Some(matcher) = manifest["hooks"][event][0]["matcher"].as_str() { entry["matcher"] = Value::String(matcher.to_owned()); } - entry + Ok(entry) } /// Suppress a plugin registration only when this event has a user capture hook. @@ -116,7 +115,9 @@ pub(crate) fn user_capture_hook_is_installed_at(config_dir: &Path, event: &str) } fn capture_entry_is_registered(config: &Map, event: &str, command: &str) -> bool { - let expected = capture_hook_entry(event, command); + let Ok(expected) = capture_hook_entry(event, command) else { + return false; + }; config .get("hooks") .and_then(|hooks| hooks.get(event)) @@ -245,7 +246,7 @@ fn apply_specs( let Some(entries) = hooks.get_mut(event) else { if enable { let entry = if persistent { - capture_hook_entry(event, command) + capture_hook_entry(event, command)? } else { hook_entry(command) }; @@ -264,7 +265,7 @@ fn apply_specs( }); if enable { entries.push(if persistent { - capture_hook_entry(event, command) + capture_hook_entry(event, command)? } else { hook_entry(command) }); @@ -431,7 +432,7 @@ mod tests { .iter() .find(|(event, _)| event == "stop") .unwrap(); - expected_stop.push(capture_hook_entry("stop", command)); + expected_stop.push(capture_hook_entry("stop", command).unwrap()); } assert_eq!(config["hooks"]["stop"], Value::Array(expected_stop)); diff --git a/bt-daemon/src/setup/cursor/mod.rs b/bt-daemon/src/setup/cursor/mod.rs index 74c966c..97bfd80 100644 --- a/bt-daemon/src/setup/cursor/mod.rs +++ b/bt-daemon/src/setup/cursor/mod.rs @@ -9,7 +9,7 @@ pub(crate) use hooks::{ }; #[cfg(test)] -pub(crate) use hooks::persistent_specs; +pub(crate) use hooks::{capture_hook_entry, persistent_specs}; use crate::agents::Cursor; use crate::paths; diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index 9519071..2e1ad6f 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -653,11 +653,23 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul run_serve(serve_args, serve_options(&host)).await } TraceCommand::Hook(hook_args) => { - if crate::hook::suppress_hook(&hook_args) { - return Ok(()); + // Cursor permission hooks must emit a response on this production + // entry point too. Keep it outside capture and route resolution so + // tracing failures cannot leave Cursor waiting on an empty reply. + let response = (hook_args.source == "cursor") + .then(|| crate::hook::cursor_hook_response_for_event(hook_args.event.as_deref())); + let result = async { + if crate::hook::suppress_hook(&hook_args) { + return Ok(()); + } + let settings = crate::settings::AgentSettings::load_for_hook(&hook_args.source)?; + run_hook_command(hook_args, &host, settings).await + } + .await; + if let Some(response) = response { + println!("{response}"); } - let settings = crate::settings::AgentSettings::load_for_hook(&hook_args.source)?; - run_hook_command(hook_args, &host, settings).await + result } TraceCommand::Status(status_args) => print_output( TraceCommandOutput::status(run_status(status_args).await?), @@ -1101,7 +1113,7 @@ mod tests { .unwrap(); std::fs::write( temp.path().join("hooks.json"), - serde_json::json!({"hooks": {"stop": [{"command": command}]}}).to_string(), + serde_json::json!({"hooks": {"stop": [{"command": command, "timeout": 10, "failClosed": false}]}}).to_string(), ) .unwrap(); let settings = temp.path().join("settings.json"); From fbb5064170293d362f51b187cb8aabaf3f1a6a90 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 14 Oct 2026 17:12:17 -0400 Subject: [PATCH 6/6] test(cursor): align malformed hooks with optional ids --- bt-daemon/src/dispatch.rs | 2 +- bt-daemon/tests/cursor_translator.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/bt-daemon/src/dispatch.rs b/bt-daemon/src/dispatch.rs index 6b44e59..61fc8a5 100644 --- a/bt-daemon/src/dispatch.rs +++ b/bt-daemon/src/dispatch.rs @@ -363,7 +363,7 @@ async fn hydrate_claude_transcript_references(data_dir: &std::path::Path, env: & } } -const GROK_TERMINAL_SNAPSHOT_ATTEMPTS: usize = 4; +const GROK_TERMINAL_SNAPSHOT_ATTEMPTS: usize = 8; const GROK_TERMINAL_SNAPSHOT_QUIET_PERIOD: std::time::Duration = std::time::Duration::from_millis(10); diff --git a/bt-daemon/tests/cursor_translator.rs b/bt-daemon/tests/cursor_translator.rs index 755f8ff..ec65e38 100644 --- a/bt-daemon/tests/cursor_translator.rs +++ b/bt-daemon/tests/cursor_translator.rs @@ -1897,10 +1897,7 @@ fn malformed_hooks_fail_the_event_instead_of_dropping_fields() { ("beforeSubmitPrompt", json!({})), ("preToolUse", json!({"tool_name":"Shell", "tool_input":{}})), ("sessionEnd", json!({"reason":"completed"})), - ( - "subagentStop", - json!({"subagent_type":"explore", "status":"completed"}), - ), + ("subagentStop", json!({"duration_ms":-1})), ] { let mut translator = Registry::default_agents().create("cursor", "test-session"); let error = translator