From b05210369e49914b168474c0e9355260b9844507 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 00:56:40 +0000 Subject: [PATCH 1/2] fix(parser): rescue stop_hook_summary and Stop hook feedback entries as HookMsg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified via --debug output and live JSONL inspection: The actual hook entry format written to JSONL session files is: {type:"system", subtype:"stop_hook_summary", hookCount:N, hookInfos:[{command, durationMs}], preventedContinuation, ...} This is written on EVERY Stop hook invocation (success or failure). Previous code only rescued type:"progress" data.type:"hook_progress" entries, which never appear in JSONL files — so hooks were never displayed. When a Stop hook exits non-zero, Claude also injects a user meta entry: {type:"user", isMeta:true, message:{content:"Stop hook feedback:\n[cmd]: output"}} Both are now rescued before the NOISE_ENTRY_TYPES filter: - system/stop_hook_summary (hookCount > 0) → HookMsg with hook_event:"Stop" and hook_name extracted from hookInfos[0].command - system/hook_progress → HookMsg (verbose/stream-json mode) - user/isMeta "Stop hook feedback:" → HookMsg with parsed command and output New Entry fields: hookCount, hookInfos, preventedContinuation. Three new tests: stop_hook_summary rescue, zero-hook drop, feedback entry rescue. https://claude.ai/code/session_01MK63kGRSmtkGSXCDaqh8p3 --- src-tauri/src/parser/classify.rs | 164 +++++++++++++++++++++++++++++-- src-tauri/src/parser/entry.rs | 7 ++ 2 files changed, 161 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/parser/classify.rs b/src-tauri/src/parser/classify.rs index 72d4567..7ad3df4 100644 --- a/src-tauri/src/parser/classify.rs +++ b/src-tauri/src/parser/classify.rs @@ -178,16 +178,48 @@ pub fn classify(e: Entry) -> Option { } } - // Rescue hook_progress entries written as type:"system", subtype:"hook_progress" - // in verbose/stream-json mode (Claude Code v2.x). These must be rescued before the - // NOISE_ENTRY_TYPES filter discards all "system" entries. - if e.entry_type == "system" && (e.subtype == "hook_progress" || !e.hook_event.is_empty()) { - return Some(ClassifiedMsg::Hook(HookMsg { - timestamp: ts, - hook_event: e.hook_event.clone(), - hook_name: e.hook_name.clone(), - command: String::new(), - })); + // Rescue hook-related system entries before the NOISE_ENTRY_TYPES filter drops them. + if e.entry_type == "system" { + match e.subtype.as_str() { + // stop_hook_summary: written every time Stop hooks run (success or failure). + // hookInfos contains [{command, durationMs}, ...] for each hook that ran. + "stop_hook_summary" if e.hook_count > 0 => { + let hook_name = e + .hook_infos + .as_ref() + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|info| info.get("command")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + return Some(ClassifiedMsg::Hook(HookMsg { + timestamp: ts, + hook_event: "Stop".to_string(), + hook_name, + command: String::new(), + })); + } + // hook_progress: written in verbose/stream-json mode for mid-session hooks. + "hook_progress" => { + return Some(ClassifiedMsg::Hook(HookMsg { + timestamp: ts, + hook_event: e.hook_event.clone(), + hook_name: e.hook_name.clone(), + command: String::new(), + })); + } + // hookEvent present on any system entry (forward-compat for future hook types). + _ if !e.hook_event.is_empty() => { + return Some(ClassifiedMsg::Hook(HookMsg { + timestamp: ts, + hook_event: e.hook_event.clone(), + hook_name: e.hook_name.clone(), + command: String::new(), + })); + } + _ => {} + } } // Hard noise: structural metadata types. @@ -215,6 +247,22 @@ pub fn classify(e: Entry) -> Option { return None; } + // "Stop hook feedback:" entries: isMeta user messages injected by Claude Code when + // a Stop hook exits non-zero. Format: "Stop hook feedback:\n[command]: output\n" + // Classify as HookMsg so they appear with the other hook items, not as AI meta noise. + if e.entry_type == "user" && e.is_meta { + let trimmed = content_str.trim(); + if trimmed.starts_with("Stop hook feedback:") { + let (hook_name, command) = parse_hook_feedback(trimmed); + return Some(ClassifiedMsg::Hook(HookMsg { + timestamp: ts, + hook_event: "Stop".to_string(), + hook_name, + command, + })); + } + } + // Teammate messages. if e.entry_type == "user" { let trimmed = content_str.trim(); @@ -368,6 +416,26 @@ fn extract_teammate_content(s: &str) -> String { .unwrap_or_else(|| s.to_string()) } +/// Parse a "Stop hook feedback:\n[command]: output\n" string into (hook_name, command). +fn parse_hook_feedback(s: &str) -> (String, String) { + // Skip the first line ("Stop hook feedback:"), then parse "[command]: output" lines. + let body = s + .split_once('\n') + .map(|x| x.1) + .unwrap_or("") + .trim() + .to_string(); + // Format: "[~/.claude/script.sh]: error message" + if let Some(rest) = body.strip_prefix('[') { + if let Some(bracket_end) = rest.find("]: ") { + let hook_name = rest[..bracket_end].to_string(); + let command = rest[bracket_end + 3..].trim().to_string(); + return (hook_name, command); + } + } + (String::new(), body) +} + fn is_user_noise(raw: &Option, content_str: &str) -> bool { let trimmed = content_str.trim(); @@ -903,6 +971,82 @@ mod tests { ); } + #[test] + fn classify_rescues_stop_hook_summary_as_hook() { + // stop_hook_summary is written every time Stop hooks run (even on success). + // It must be rescued and shown as a HookMsg so hooks always appear in the transcript. + let e = Entry { + entry_type: "system".to_string(), + uuid: "uuid-stop-hook-summary".to_string(), + timestamp: "2025-01-15T10:30:00Z".to_string(), + subtype: "stop_hook_summary".to_string(), + hook_count: 1, + hook_infos: Some(json!([{ + "command": "~/.claude/stop-hook-git-check.sh", + "durationMs": 59 + }])), + ..Default::default() + }; + match classify(e) { + Some(ClassifiedMsg::Hook(h)) => { + assert_eq!(h.hook_event, "Stop"); + assert_eq!(h.hook_name, "~/.claude/stop-hook-git-check.sh"); + } + other => panic!("Expected Hook for stop_hook_summary entry, got {:?}", other), + } + } + + #[test] + fn classify_drops_stop_hook_summary_with_zero_hooks() { + // stop_hook_summary with hookCount=0 means no hooks ran; drop silently. + let e = Entry { + entry_type: "system".to_string(), + uuid: "uuid-stop-hook-empty".to_string(), + timestamp: "2025-01-15T10:30:00Z".to_string(), + subtype: "stop_hook_summary".to_string(), + hook_count: 0, + ..Default::default() + }; + assert!( + classify(e).is_none(), + "stop_hook_summary with hookCount=0 must be dropped" + ); + } + + #[test] + fn classify_rescues_stop_hook_feedback_user_entry_as_hook() { + // "Stop hook feedback:" user entries (isMeta=true) are injected by Claude Code when + // a Stop hook exits non-zero. Classify as HookMsg instead of fallthrough meta AI. + let e = Entry { + entry_type: "user".to_string(), + uuid: "uuid-hook-feedback".to_string(), + timestamp: "2025-01-15T10:30:00Z".to_string(), + is_meta: true, + message: super::super::entry::EntryMessage { + role: "user".to_string(), + content: Some(json!( + "Stop hook feedback:\n[~/.claude/stop-hook-git-check.sh]: There are untracked files.\n" + )), + ..Default::default() + }, + ..Default::default() + }; + match classify(e) { + Some(ClassifiedMsg::Hook(h)) => { + assert_eq!(h.hook_event, "Stop"); + assert_eq!(h.hook_name, "~/.claude/stop-hook-git-check.sh"); + assert!( + h.command.contains("untracked"), + "command should contain hook output" + ); + } + other => panic!( + "Expected Hook for stop hook feedback entry, got {:?}", + other + ), + } + } + // --- Unknown / structural entry type tests (compat: v2.1.79-v2.1.83) --- #[test] diff --git a/src-tauri/src/parser/entry.rs b/src-tauri/src/parser/entry.rs index d035286..f2a9662 100644 --- a/src-tauri/src/parser/entry.rs +++ b/src-tauri/src/parser/entry.rs @@ -46,6 +46,13 @@ pub struct Entry { pub hook_event: String, #[serde(default, rename = "hookName")] pub hook_name: String, + // Top-level fields present in system/stop_hook_summary entries. + #[serde(default, rename = "hookCount")] + pub hook_count: u32, + #[serde(default, rename = "hookInfos")] + pub hook_infos: Option, + #[serde(default, rename = "preventedContinuation")] + pub prevented_continuation: bool, } #[derive(Debug, Deserialize, Default)] From 1fa771bac2155d7b4061d23a6e1afcb40bd457e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 01:01:37 +0000 Subject: [PATCH 2/2] fix(parser): rescue attachment hook entries for PreToolUse/PostToolUse and all other hook events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified from cli.js bundle (v2.1.86): All non-Stop hook events (PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Notification, SessionStart, etc.) write their results to the JSONL session file as type:"attachment" entries: {type:"attachment", uuid, timestamp, attachment:{type:"hook_success"|"hook_non_blocking_error"| "hook_blocking_error"|"hook_cancelled", hookEvent, hookName, toolUseID, ...}} These were previously dropped because Entry had no attachment field and the empty-role fallback silently discarded them. Fix: add attachment: Option to Entry, rescue attachment entries where attachment.hookEvent is non-empty as HookMsg. Blocking errors also surface their error message in the command field. This covers the full hook surface area: - system/stop_hook_summary → Stop hooks (already fixed) - type:attachment + hookEvent → ALL other hook events (this commit) - system/hook_progress → verbose/stream-json mode hooks Three new tests: hook_success attachment, hook_blocking_error with message, non-hook attachment dropped. https://claude.ai/code/session_01MK63kGRSmtkGSXCDaqh8p3 --- src-tauri/src/parser/classify.rs | 105 +++++++++++++++++++++++++++++++ src-tauri/src/parser/entry.rs | 5 ++ 2 files changed, 110 insertions(+) diff --git a/src-tauri/src/parser/classify.rs b/src-tauri/src/parser/classify.rs index 7ad3df4..fc6ced0 100644 --- a/src-tauri/src/parser/classify.rs +++ b/src-tauri/src/parser/classify.rs @@ -222,6 +222,38 @@ pub fn classify(e: Entry) -> Option { } } + // Rescue hook attachment entries for all non-Stop hook events (PreToolUse, PostToolUse, + // UserPromptSubmit, Notification, SessionStart, etc.). + // Claude Code writes these as: {type:"attachment", attachment:{type:"hook_success"| + // "hook_non_blocking_error"|"hook_blocking_error"|"hook_cancelled"|..., hookEvent, hookName}} + if e.entry_type == "attachment" { + if let Some(ref att) = e.attachment { + let hook_event = att.get("hookEvent").and_then(|v| v.as_str()).unwrap_or(""); + if !hook_event.is_empty() { + let hook_name = att + .get("hookName") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + // For blocking errors, extract the error message as the command context. + let command = att + .get("blockingError") + .and_then(|v| v.get("blockingError")) + .and_then(|v| v.as_str()) + .or_else(|| att.get("stderr").and_then(|v| v.as_str())) + .unwrap_or("") + .trim() + .to_string(); + return Some(ClassifiedMsg::Hook(HookMsg { + timestamp: ts, + hook_event: hook_event.to_string(), + hook_name, + command, + })); + } + } + } + // Hard noise: structural metadata types. if NOISE_ENTRY_TYPES.contains(&e.entry_type.as_str()) { return None; @@ -1047,6 +1079,79 @@ mod tests { } } + #[test] + fn classify_rescues_attachment_hook_success() { + // PreToolUse/PostToolUse/UserPromptSubmit/etc. hooks are written as attachment entries. + let e = Entry { + entry_type: "attachment".to_string(), + uuid: "uuid-att-hook".to_string(), + timestamp: "2025-01-15T10:30:00Z".to_string(), + attachment: Some(json!({ + "type": "hook_success", + "hookEvent": "PreToolUse", + "hookName": "my-pre-hook", + "toolUseID": "tool-123", + "content": "Success" + })), + ..Default::default() + }; + match classify(e) { + Some(ClassifiedMsg::Hook(h)) => { + assert_eq!(h.hook_event, "PreToolUse"); + assert_eq!(h.hook_name, "my-pre-hook"); + } + other => panic!( + "Expected Hook for attachment/hook_success entry, got {:?}", + other + ), + } + } + + #[test] + fn classify_rescues_attachment_hook_blocking_error_with_message() { + // hook_blocking_error extracts the error message into command field. + let e = Entry { + entry_type: "attachment".to_string(), + uuid: "uuid-att-block".to_string(), + timestamp: "2025-01-15T10:30:00Z".to_string(), + attachment: Some(json!({ + "type": "hook_blocking_error", + "hookEvent": "PostToolUse", + "hookName": "post-lint", + "blockingError": {"blockingError": "Lint failed: unused variable"} + })), + ..Default::default() + }; + match classify(e) { + Some(ClassifiedMsg::Hook(h)) => { + assert_eq!(h.hook_event, "PostToolUse"); + assert_eq!(h.hook_name, "post-lint"); + assert!(h.command.contains("Lint failed")); + } + other => panic!( + "Expected Hook for attachment/hook_blocking_error, got {:?}", + other + ), + } + } + + #[test] + fn classify_drops_attachment_without_hook_event() { + // Non-hook attachments (file attachments, etc.) must not be shown as hooks. + let e = Entry { + entry_type: "attachment".to_string(), + uuid: "uuid-att-file".to_string(), + timestamp: "2025-01-15T10:30:00Z".to_string(), + attachment: Some(json!({ + "type": "file", + "filename": "README.md", + "content": "# readme" + })), + ..Default::default() + }; + assert!(classify(e).is_none(), "Non-hook attachment must be dropped"); + } + // --- Unknown / structural entry type tests (compat: v2.1.79-v2.1.83) --- #[test] diff --git a/src-tauri/src/parser/entry.rs b/src-tauri/src/parser/entry.rs index f2a9662..5816191 100644 --- a/src-tauri/src/parser/entry.rs +++ b/src-tauri/src/parser/entry.rs @@ -53,6 +53,11 @@ pub struct Entry { pub hook_infos: Option, #[serde(default, rename = "preventedContinuation")] pub prevented_continuation: bool, + // Present in type:"attachment" entries. Hook results for PreToolUse, PostToolUse, etc. + // are written as attachment entries: {type:"attachment", attachment:{type:"hook_success"| + // "hook_non_blocking_error"|"hook_blocking_error"|"hook_cancelled", hookEvent, hookName, ...}} + #[serde(default)] + pub attachment: Option, } #[derive(Debug, Deserialize, Default)]