diff --git a/bt-daemon/src/hook.rs b/bt-daemon/src/hook.rs index 6161663..5685f31 100644 --- a/bt-daemon/src/hook.rs +++ b/bt-daemon/src/hook.rs @@ -110,10 +110,10 @@ pub async fn run_hook(args: HookArgs, route: SessionRoute, host: HostInfo) -> an } 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"}"#, + _ => "{}", } } @@ -331,6 +331,16 @@ mod tests { args: HookArgs, } + #[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 709f1f4..53899ca 100644 --- a/bt-daemon/src/setup/cursor/mod.rs +++ b/bt-daemon/src/setup/cursor/mod.rs @@ -27,6 +27,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('{}') } @@ -414,6 +416,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 f243370..717adde 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", @@ -1129,16 +1131,20 @@ impl CursorTranslator { } self.ensure_turn(e.ts_ms, None, None, "hook_order", ops); let spawn = hook.tool_call_id.as_deref(); - let parent = spawn + let (parent, turn) = spawn .and_then(|s| self.tools.get(s).or_else(|| self.completed.get(s))) - .map(|t| t.turn.clone()) - .unwrap_or_else(|| self.turn.as_ref().unwrap().id.clone()); + .map(|t| (t.row.span_id.clone(), t.turn.clone())) + .unwrap_or_else(|| { + let turn = self.turn.as_ref().unwrap().id.clone(); + (turn.clone(), turn) + }); let mut row = self.row( ids::span_id(&self.namespace, &format!("subagent:{id}")), parent, "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 fc8afab..77cf601 100644 --- a/bt-daemon/tests/cursor_translator.rs +++ b/bt-daemon/tests/cursor_translator.rs @@ -1063,7 +1063,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", @@ -1075,13 +1075,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", @@ -1096,21 +1103,29 @@ 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.turn_span_id, Some(h.turns()[0].span_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..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", @@ -36,6 +44,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/content/hooks/trace.sh b/src/plugins/cursor/content/hooks/trace.sh index 036aa39..c5ad469 100755 --- a/src/plugins/cursor/content/hooks/trace.sh +++ b/src/plugins/cursor/content/hooks/trace.sh @@ -13,10 +13,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 62a0700..abfe350 100755 --- a/src/plugins/cursor/validate.sh +++ b/src/plugins/cursor/validate.sh @@ -43,21 +43,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', - 'postToolUse', 'postToolUseFailure', 'subagentStop', + '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"