From 30789b64b84add40be2aa1b8df1244dfa8cbf004 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 10 Sep 2026 02:40:17 +0800 Subject: [PATCH 1/2] Suppress passive Claude hook traces --- bt-daemon/src/translate/claude.rs | 48 ++++- bt-daemon/tests/claude_translator.rs | 67 ++++++ bt-daemon/tests/pipeline.rs | 7 +- .../trace-claude-code/hooks/hooks.json | 191 ------------------ src/plugins/claude/validate.sh | 11 +- 5 files changed, 115 insertions(+), 209 deletions(-) diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index a7b669e..a1c78b6 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -168,6 +168,8 @@ struct ClaudeTranslator { pending_emission: Option, claude_version: Option, claude_version_logged: bool, + session_source: Option, + session_model: Option, git: Arc, current_cwd: Option, last_turn_cwd: Option, @@ -198,6 +200,8 @@ impl ClaudeTranslator { pending_emission: None, claude_version: None, claude_version_logged: false, + session_source: None, + session_model: None, git, current_cwd: None, last_turn_cwd: None, @@ -244,10 +248,14 @@ impl ClaudeTranslator { if let Some(version) = &self.claude_version { metadata.insert("claude_code_version".into(), json!(version)); } - if let Some(source) = string_field(&event.payload, "source") { + if let Some(source) = + string_field(&event.payload, "source").or_else(|| self.session_source.clone()) + { metadata.insert("session_source".into(), json!(source)); } - if let Some(model) = string_field(&event.payload, "model") { + if let Some(model) = + string_field(&event.payload, "model").or_else(|| self.session_model.clone()) + { metadata.insert("model".into(), json!(model)); } ops.push(SpanOp::Insert(SpanRow { @@ -278,6 +286,31 @@ impl ClaudeTranslator { cursor.buffered.extend(rows); } + fn observe_session_details(&mut self, event: &Envelope) { + if let Some(source) = string_field(&event.payload, "source") { + self.session_source = Some(source); + } + if let Some(model) = string_field(&event.payload, "model") { + self.session_model = Some(model); + } + } + + /// Passive Claude hooks must not create a trace. In particular, Claude + /// emits idle notifications after a completed turn, and it may start or + /// resume a session long before the user submits a prompt. + fn starts_trace(event: &Envelope) -> bool { + matches!( + event.event.as_str(), + "UserPromptSubmit" + | "PreToolUse" + | "PostToolUse" + | "PostToolUseFailure" + | "PermissionDenied" + | "SubagentStart" + | "SubagentStop" + ) + } + fn open_turn(&mut self, event: &Envelope, ops: &mut Vec) { if let Some(old) = self.turn.take() { self.close_pending_tools( @@ -786,8 +819,11 @@ impl AgentTranslator for ClaudeTranslator { self.current_cwd = Some(cwd); } self.tail_main(event); - self.ensure_root(event, ctx, &mut ops); - if !self.claude_version_logged { + self.observe_session_details(event); + if Self::starts_trace(event) { + self.ensure_root(event, ctx, &mut ops); + } + if self.root_open && !self.claude_version_logged { if let Some(version) = &self.claude_version { self.claude_version_logged = true; ops.push(SpanOp::Merge(SpanRow { @@ -798,13 +834,11 @@ impl AgentTranslator for ClaudeTranslator { })); } } - self.git.enrich_rows(self.current_cwd.as_deref(), &mut ops); - let mut event_op_start = ops.len(); + let event_op_start = 0; match event.event.as_str() { "SessionStart" => {} "UserPromptSubmit" => { self.flush_previous_turn_rows(&mut ops); - event_op_start = ops.len(); self.open_turn(event, &mut ops); } "UserPromptExpansion" => self.record_skill(event, &mut ops), diff --git a/bt-daemon/tests/claude_translator.rs b/bt-daemon/tests/claude_translator.rs index c81f64d..9a2265d 100644 --- a/bt-daemon/tests/claude_translator.rs +++ b/bt-daemon/tests/claude_translator.rs @@ -288,6 +288,73 @@ fn claude_additional_metadata_reaches_roots_without_overriding_session_fields() assert_eq!(root.metadata.as_ref().unwrap()["source"], "claude-code"); } +#[test] +fn claude_passive_hooks_do_not_create_blank_session_traces() { + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "idle-session"); + let ctx = SessionCtx { + session_id: "idle-session".into(), + config: None, + }; + let event = |name: &str, ts_ms: i64, payload: Value| Envelope { + source: "claude-code".into(), + source_version: None, + plugin_version: None, + session_id: "idle-session".into(), + event: name.into(), + ts_ms, + managed_run_id: None, + payload, + route: None, + config: None, + capture: None, + }; + + let mut ops = Vec::new(); + for event in [ + event( + "SessionStart", + 1, + json!({"cwd":"/workspace/demo", "source":"resume", "model":"claude-test"}), + ), + event( + "Notification", + 2, + json!({"cwd":"/workspace/demo", "notification_type":"idle_prompt"}), + ), + event( + "TeammateIdle", + 3, + json!({"cwd":"/workspace/demo", "teammate_name":"researcher"}), + ), + event("SessionEnd", 4, json!({"cwd":"/workspace/demo"})), + ] { + ops.extend(translator.handle(&event, &ctx).unwrap()); + } + assert!(ops.is_empty(), "passive Claude hooks must not emit a trace"); + + let ops = translator + .handle( + &event( + "UserPromptSubmit", + 5, + json!({"cwd":"/workspace/demo", "prompt":"trace this"}), + ), + &ctx, + ) + .unwrap(); + let root = ops + .into_iter() + .find_map(|op| match op { + SpanOp::Insert(row) if row.name == "Claude Code: demo" => Some(row), + _ => None, + }) + .expect("a user prompt starts a trace"); + let metadata = root.metadata.as_ref().unwrap(); + assert_eq!(metadata["session_source"], "resume"); + assert_eq!(metadata["model"], "claude-test"); +} + #[test] fn claude_subagent_fixture_builds_nested_subagent_llms() { let rows = reduce(replay("subagent-compact")); diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index 333f2c4..2bc9f8e 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -1167,12 +1167,13 @@ async fn identical_native_ids_from_different_sources_are_isolated() { "source": "startup", "permission_mode": "auto" }); - let mut claude = envelope("shared-native-id", "SessionStart", 2); + let mut claude = envelope("shared-native-id", "UserPromptSubmit", 2); claude.source = "claude".into(); claude.payload = serde_json::json!({ "session_id": "shared-native-id", - "hook_event_name": "SessionStart", - "cwd": "/workspace/claude" + "hook_event_name": "UserPromptSubmit", + "cwd": "/workspace/claude", + "prompt": "trace this" }); forward_envelope(&codex, &socket, &host, false) diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json b/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json index d9dd5f8..805b713 100644 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json +++ b/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json @@ -34,18 +34,6 @@ ] } ], - "PermissionRequest": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], "PermissionDenied": [ { "matcher": "*", @@ -82,28 +70,6 @@ ] } ], - "PostToolBatch": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "PreCompact": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], "PostCompact": [ { "hooks": [ @@ -173,17 +139,6 @@ ] } ], - "Setup": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], "UserPromptExpansion": [ { "hooks": [ @@ -194,152 +149,6 @@ } ] } - ], - "Notification": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "MessageDisplay": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "TaskCreated": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "TaskCompleted": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "TeammateIdle": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "InstructionsLoaded": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "ConfigChange": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "CwdChanged": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "FileChanged": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "WorktreeCreate": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "WorktreeRemove": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "Elicitation": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } - ], - "ElicitationResult": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", - "async": false - } - ] - } ] } } diff --git a/src/plugins/claude/validate.sh b/src/plugins/claude/validate.sh index 33b2d0a..e6bea1c 100755 --- a/src/plugins/claude/validate.sh +++ b/src/plugins/claude/validate.sh @@ -67,14 +67,9 @@ with open(sys.argv[1]) as f: hooks = json.load(f)["hooks"] expected_events = { - "ConfigChange", "CwdChanged", "Elicitation", "ElicitationResult", - "FileChanged", "InstructionsLoaded", "MessageDisplay", "Notification", - "PermissionDenied", "PermissionRequest", "PostCompact", "PostToolBatch", - "PostToolUse", "PostToolUseFailure", "PreCompact", "PreToolUse", - "SessionEnd", "SessionStart", "Setup", "Stop", "StopFailure", - "SubagentStart", "SubagentStop", "TaskCompleted", "TaskCreated", - "TeammateIdle", "UserPromptExpansion", "UserPromptSubmit", - "WorktreeCreate", "WorktreeRemove", + "PermissionDenied", "PostCompact", "PostToolUse", "PostToolUseFailure", + "PreToolUse", "SessionEnd", "SessionStart", "Stop", "StopFailure", + "SubagentStart", "SubagentStop", "UserPromptExpansion", "UserPromptSubmit", } assert set(hooks) == expected_events for definitions in hooks.values(): From 465c1b14adf025e70ed456ca89128f34f7accf59 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 10 Sep 2026 02:47:35 +0800 Subject: [PATCH 2/2] Preserve Claude late-row git metadata --- bt-daemon/src/translate/claude.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index a1c78b6..464f4d0 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -834,11 +834,16 @@ impl AgentTranslator for ClaudeTranslator { })); } } - let event_op_start = 0; + // Root creation moved below the passive-hook gate. Enrich it before + // handling the event so a later prompt can retain the prior turn's + // cwd while flushing its deferred transcript rows. + self.git.enrich_rows(self.current_cwd.as_deref(), &mut ops); + let mut event_op_start = ops.len(); match event.event.as_str() { "SessionStart" => {} "UserPromptSubmit" => { self.flush_previous_turn_rows(&mut ops); + event_op_start = ops.len(); self.open_turn(event, &mut ops); } "UserPromptExpansion" => self.record_skill(event, &mut ops),