Skip to content

Commit fb9b0e5

Browse files
delexwclaude
andauthored
fix(parser): display all hook types in GUI (Stop, PreToolUse, PostToolUse, etc.) (#23)
* fix(parser): rescue stop_hook_summary and Stop hook feedback entries as HookMsg 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 * fix(parser): rescue attachment hook entries for PreToolUse/PostToolUse and all other hook events 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<Value> 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1617ff7 commit fb9b0e5

2 files changed

Lines changed: 271 additions & 10 deletions

File tree

src-tauri/src/parser/classify.rs

Lines changed: 259 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,80 @@ pub fn classify(e: Entry) -> Option<ClassifiedMsg> {
178178
}
179179
}
180180

181-
// Rescue hook_progress entries written as type:"system", subtype:"hook_progress"
182-
// in verbose/stream-json mode (Claude Code v2.x). These must be rescued before the
183-
// NOISE_ENTRY_TYPES filter discards all "system" entries.
184-
if e.entry_type == "system" && (e.subtype == "hook_progress" || !e.hook_event.is_empty()) {
185-
return Some(ClassifiedMsg::Hook(HookMsg {
186-
timestamp: ts,
187-
hook_event: e.hook_event.clone(),
188-
hook_name: e.hook_name.clone(),
189-
command: String::new(),
190-
}));
181+
// Rescue hook-related system entries before the NOISE_ENTRY_TYPES filter drops them.
182+
if e.entry_type == "system" {
183+
match e.subtype.as_str() {
184+
// stop_hook_summary: written every time Stop hooks run (success or failure).
185+
// hookInfos contains [{command, durationMs}, ...] for each hook that ran.
186+
"stop_hook_summary" if e.hook_count > 0 => {
187+
let hook_name = e
188+
.hook_infos
189+
.as_ref()
190+
.and_then(|v| v.as_array())
191+
.and_then(|arr| arr.first())
192+
.and_then(|info| info.get("command"))
193+
.and_then(|v| v.as_str())
194+
.unwrap_or("")
195+
.to_string();
196+
return Some(ClassifiedMsg::Hook(HookMsg {
197+
timestamp: ts,
198+
hook_event: "Stop".to_string(),
199+
hook_name,
200+
command: String::new(),
201+
}));
202+
}
203+
// hook_progress: written in verbose/stream-json mode for mid-session hooks.
204+
"hook_progress" => {
205+
return Some(ClassifiedMsg::Hook(HookMsg {
206+
timestamp: ts,
207+
hook_event: e.hook_event.clone(),
208+
hook_name: e.hook_name.clone(),
209+
command: String::new(),
210+
}));
211+
}
212+
// hookEvent present on any system entry (forward-compat for future hook types).
213+
_ if !e.hook_event.is_empty() => {
214+
return Some(ClassifiedMsg::Hook(HookMsg {
215+
timestamp: ts,
216+
hook_event: e.hook_event.clone(),
217+
hook_name: e.hook_name.clone(),
218+
command: String::new(),
219+
}));
220+
}
221+
_ => {}
222+
}
223+
}
224+
225+
// Rescue hook attachment entries for all non-Stop hook events (PreToolUse, PostToolUse,
226+
// UserPromptSubmit, Notification, SessionStart, etc.).
227+
// Claude Code writes these as: {type:"attachment", attachment:{type:"hook_success"|
228+
// "hook_non_blocking_error"|"hook_blocking_error"|"hook_cancelled"|..., hookEvent, hookName}}
229+
if e.entry_type == "attachment" {
230+
if let Some(ref att) = e.attachment {
231+
let hook_event = att.get("hookEvent").and_then(|v| v.as_str()).unwrap_or("");
232+
if !hook_event.is_empty() {
233+
let hook_name = att
234+
.get("hookName")
235+
.and_then(|v| v.as_str())
236+
.unwrap_or("")
237+
.to_string();
238+
// For blocking errors, extract the error message as the command context.
239+
let command = att
240+
.get("blockingError")
241+
.and_then(|v| v.get("blockingError"))
242+
.and_then(|v| v.as_str())
243+
.or_else(|| att.get("stderr").and_then(|v| v.as_str()))
244+
.unwrap_or("")
245+
.trim()
246+
.to_string();
247+
return Some(ClassifiedMsg::Hook(HookMsg {
248+
timestamp: ts,
249+
hook_event: hook_event.to_string(),
250+
hook_name,
251+
command,
252+
}));
253+
}
254+
}
191255
}
192256

193257
// Hard noise: structural metadata types.
@@ -215,6 +279,22 @@ pub fn classify(e: Entry) -> Option<ClassifiedMsg> {
215279
return None;
216280
}
217281

282+
// "Stop hook feedback:" entries: isMeta user messages injected by Claude Code when
283+
// a Stop hook exits non-zero. Format: "Stop hook feedback:\n[command]: output\n"
284+
// Classify as HookMsg so they appear with the other hook items, not as AI meta noise.
285+
if e.entry_type == "user" && e.is_meta {
286+
let trimmed = content_str.trim();
287+
if trimmed.starts_with("Stop hook feedback:") {
288+
let (hook_name, command) = parse_hook_feedback(trimmed);
289+
return Some(ClassifiedMsg::Hook(HookMsg {
290+
timestamp: ts,
291+
hook_event: "Stop".to_string(),
292+
hook_name,
293+
command,
294+
}));
295+
}
296+
}
297+
218298
// Teammate messages.
219299
if e.entry_type == "user" {
220300
let trimmed = content_str.trim();
@@ -368,6 +448,26 @@ fn extract_teammate_content(s: &str) -> String {
368448
.unwrap_or_else(|| s.to_string())
369449
}
370450

451+
/// Parse a "Stop hook feedback:\n[command]: output\n" string into (hook_name, command).
452+
fn parse_hook_feedback(s: &str) -> (String, String) {
453+
// Skip the first line ("Stop hook feedback:"), then parse "[command]: output" lines.
454+
let body = s
455+
.split_once('\n')
456+
.map(|x| x.1)
457+
.unwrap_or("")
458+
.trim()
459+
.to_string();
460+
// Format: "[~/.claude/script.sh]: error message"
461+
if let Some(rest) = body.strip_prefix('[') {
462+
if let Some(bracket_end) = rest.find("]: ") {
463+
let hook_name = rest[..bracket_end].to_string();
464+
let command = rest[bracket_end + 3..].trim().to_string();
465+
return (hook_name, command);
466+
}
467+
}
468+
(String::new(), body)
469+
}
470+
371471
fn is_user_noise(raw: &Option<Value>, content_str: &str) -> bool {
372472
let trimmed = content_str.trim();
373473

@@ -903,6 +1003,155 @@ mod tests {
9031003
);
9041004
}
9051005

1006+
#[test]
1007+
fn classify_rescues_stop_hook_summary_as_hook() {
1008+
// stop_hook_summary is written every time Stop hooks run (even on success).
1009+
// It must be rescued and shown as a HookMsg so hooks always appear in the transcript.
1010+
let e = Entry {
1011+
entry_type: "system".to_string(),
1012+
uuid: "uuid-stop-hook-summary".to_string(),
1013+
timestamp: "2025-01-15T10:30:00Z".to_string(),
1014+
subtype: "stop_hook_summary".to_string(),
1015+
hook_count: 1,
1016+
hook_infos: Some(json!([{
1017+
"command": "~/.claude/stop-hook-git-check.sh",
1018+
"durationMs": 59
1019+
}])),
1020+
..Default::default()
1021+
};
1022+
match classify(e) {
1023+
Some(ClassifiedMsg::Hook(h)) => {
1024+
assert_eq!(h.hook_event, "Stop");
1025+
assert_eq!(h.hook_name, "~/.claude/stop-hook-git-check.sh");
1026+
}
1027+
other => panic!("Expected Hook for stop_hook_summary entry, got {:?}", other),
1028+
}
1029+
}
1030+
1031+
#[test]
1032+
fn classify_drops_stop_hook_summary_with_zero_hooks() {
1033+
// stop_hook_summary with hookCount=0 means no hooks ran; drop silently.
1034+
let e = Entry {
1035+
entry_type: "system".to_string(),
1036+
uuid: "uuid-stop-hook-empty".to_string(),
1037+
timestamp: "2025-01-15T10:30:00Z".to_string(),
1038+
subtype: "stop_hook_summary".to_string(),
1039+
hook_count: 0,
1040+
..Default::default()
1041+
};
1042+
assert!(
1043+
classify(e).is_none(),
1044+
"stop_hook_summary with hookCount=0 must be dropped"
1045+
);
1046+
}
1047+
1048+
#[test]
1049+
fn classify_rescues_stop_hook_feedback_user_entry_as_hook() {
1050+
// "Stop hook feedback:" user entries (isMeta=true) are injected by Claude Code when
1051+
// a Stop hook exits non-zero. Classify as HookMsg instead of fallthrough meta AI.
1052+
let e = Entry {
1053+
entry_type: "user".to_string(),
1054+
uuid: "uuid-hook-feedback".to_string(),
1055+
timestamp: "2025-01-15T10:30:00Z".to_string(),
1056+
is_meta: true,
1057+
message: super::super::entry::EntryMessage {
1058+
role: "user".to_string(),
1059+
content: Some(json!(
1060+
"Stop hook feedback:\n[~/.claude/stop-hook-git-check.sh]: There are untracked files.\n"
1061+
)),
1062+
..Default::default()
1063+
},
1064+
..Default::default()
1065+
};
1066+
match classify(e) {
1067+
Some(ClassifiedMsg::Hook(h)) => {
1068+
assert_eq!(h.hook_event, "Stop");
1069+
assert_eq!(h.hook_name, "~/.claude/stop-hook-git-check.sh");
1070+
assert!(
1071+
h.command.contains("untracked"),
1072+
"command should contain hook output"
1073+
);
1074+
}
1075+
other => panic!(
1076+
"Expected Hook for stop hook feedback entry, got {:?}",
1077+
other
1078+
),
1079+
}
1080+
}
1081+
1082+
#[test]
1083+
fn classify_rescues_attachment_hook_success() {
1084+
// PreToolUse/PostToolUse/UserPromptSubmit/etc. hooks are written as attachment entries.
1085+
let e = Entry {
1086+
entry_type: "attachment".to_string(),
1087+
uuid: "uuid-att-hook".to_string(),
1088+
timestamp: "2025-01-15T10:30:00Z".to_string(),
1089+
attachment: Some(json!({
1090+
"type": "hook_success",
1091+
"hookEvent": "PreToolUse",
1092+
"hookName": "my-pre-hook",
1093+
"toolUseID": "tool-123",
1094+
"content": "Success"
1095+
})),
1096+
..Default::default()
1097+
};
1098+
match classify(e) {
1099+
Some(ClassifiedMsg::Hook(h)) => {
1100+
assert_eq!(h.hook_event, "PreToolUse");
1101+
assert_eq!(h.hook_name, "my-pre-hook");
1102+
}
1103+
other => panic!(
1104+
"Expected Hook for attachment/hook_success entry, got {:?}",
1105+
other
1106+
),
1107+
}
1108+
}
1109+
1110+
#[test]
1111+
fn classify_rescues_attachment_hook_blocking_error_with_message() {
1112+
// hook_blocking_error extracts the error message into command field.
1113+
let e = Entry {
1114+
entry_type: "attachment".to_string(),
1115+
uuid: "uuid-att-block".to_string(),
1116+
timestamp: "2025-01-15T10:30:00Z".to_string(),
1117+
attachment: Some(json!({
1118+
"type": "hook_blocking_error",
1119+
"hookEvent": "PostToolUse",
1120+
"hookName": "post-lint",
1121+
"blockingError": {"blockingError": "Lint failed: unused variable"}
1122+
})),
1123+
..Default::default()
1124+
};
1125+
match classify(e) {
1126+
Some(ClassifiedMsg::Hook(h)) => {
1127+
assert_eq!(h.hook_event, "PostToolUse");
1128+
assert_eq!(h.hook_name, "post-lint");
1129+
assert!(h.command.contains("Lint failed"));
1130+
}
1131+
other => panic!(
1132+
"Expected Hook for attachment/hook_blocking_error, got {:?}",
1133+
other
1134+
),
1135+
}
1136+
}
1137+
1138+
#[test]
1139+
fn classify_drops_attachment_without_hook_event() {
1140+
// Non-hook attachments (file attachments, etc.) must not be shown as hooks.
1141+
let e = Entry {
1142+
entry_type: "attachment".to_string(),
1143+
uuid: "uuid-att-file".to_string(),
1144+
timestamp: "2025-01-15T10:30:00Z".to_string(),
1145+
attachment: Some(json!({
1146+
"type": "file",
1147+
"filename": "README.md",
1148+
"content": "# readme"
1149+
})),
1150+
..Default::default()
1151+
};
1152+
assert!(classify(e).is_none(), "Non-hook attachment must be dropped");
1153+
}
1154+
9061155
// --- Unknown / structural entry type tests (compat: v2.1.79-v2.1.83) ---
9071156

9081157
#[test]

src-tauri/src/parser/entry.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ pub struct Entry {
4646
pub hook_event: String,
4747
#[serde(default, rename = "hookName")]
4848
pub hook_name: String,
49+
// Top-level fields present in system/stop_hook_summary entries.
50+
#[serde(default, rename = "hookCount")]
51+
pub hook_count: u32,
52+
#[serde(default, rename = "hookInfos")]
53+
pub hook_infos: Option<Value>,
54+
#[serde(default, rename = "preventedContinuation")]
55+
pub prevented_continuation: bool,
56+
// Present in type:"attachment" entries. Hook results for PreToolUse, PostToolUse, etc.
57+
// are written as attachment entries: {type:"attachment", attachment:{type:"hook_success"|
58+
// "hook_non_blocking_error"|"hook_blocking_error"|"hook_cancelled", hookEvent, hookName, ...}}
59+
#[serde(default)]
60+
pub attachment: Option<Value>,
4961
}
5062

5163
#[derive(Debug, Deserialize, Default)]

0 commit comments

Comments
 (0)