Skip to content
Open
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
18 changes: 14 additions & 4 deletions bt-daemon/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}"#,
_ => "{}",
}
}

Expand Down Expand Up @@ -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([
Expand Down
29 changes: 16 additions & 13 deletions bt-daemon/src/managed_run/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand All @@ -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::<serde_json::Map<_, _>>();
std::fs::write(
Expand Down Expand Up @@ -156,7 +158,7 @@ fn write_cursor_managed_plugin_for_platform(
command.push_str(&quote_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)]
Expand Down Expand Up @@ -202,7 +204,7 @@ fn cursor_managed_powershell_script(hook_command: &RunHookCommand) -> anyhow::Re
.collect::<Vec<_>>()
.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
))
Expand Down Expand Up @@ -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);
Expand All @@ -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"));
}
Expand Down Expand Up @@ -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"));
}
}
3 changes: 3 additions & 0 deletions bt-daemon/src/setup/cursor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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('{}')
}
Expand Down Expand Up @@ -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('{}')"));
}

Expand Down
12 changes: 9 additions & 3 deletions bt-daemon/src/translate/cursor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
41 changes: 28 additions & 13 deletions bt-daemon/tests/cursor_translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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());
Expand Down
15 changes: 15 additions & 0 deletions src/plugins/cursor/content/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Comment thread
AbhiPrasad marked this conversation as resolved.
Comment thread
AbhiPrasad marked this conversation as resolved.
"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",
Expand Down
4 changes: 3 additions & 1 deletion src/plugins/cursor/content/hooks/trace.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/plugins/cursor/test/test_capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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]
Expand All @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions src/plugins/cursor/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading