Skip to content

Commit 8d84dd1

Browse files
committed
fix(watcher): emit update when hook items added to existing AI chunk
The skip optimization (msg_count == prev_msg_count && !ongoing) prevented stop_hook_summary events from reaching the frontend in live sessions. Hook events fold into the existing AI chunk as HookEvent items without creating a new top-level message, so msg_count stays the same and the emit was skipped. Track prev_item_count alongside prev_msg_count so any item addition (including hooks) fires the update.
1 parent fb9b0e5 commit 8d84dd1

7 files changed

Lines changed: 225 additions & 7 deletions

File tree

src-tauri/src/commands/session.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use tauri::{AppHandle, State};
33
use crate::convert::*;
44
use crate::parser::chunk::build_chunks;
55
use crate::parser::ongoing::OngoingChecker;
6-
use crate::parser::session::{extract_session_meta, read_session_incremental, SessionMeta};
6+
use crate::parser::session::{extract_session_meta, read_session_with_debug_hooks, SessionMeta};
77
use crate::parser::subagent::{discover_and_link_all, inject_orphan_subagents};
88
use crate::parser::team::reconstruct_teams;
99
use crate::state::AppState;
@@ -16,7 +16,7 @@ pub async fn load_session(path: String, state: State<'_, AppState>) -> Result<Lo
1616
return Err("no session path provided".to_string());
1717
}
1818

19-
let (classified, _new_offset, _) = read_session_incremental(&path, 0)?;
19+
let (classified, _new_offset, _) = read_session_with_debug_hooks(&path)?;
2020
let mut chunks = build_chunks(&classified);
2121

2222
// Discover and link subagent execution traces.

src-tauri/src/http_api.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use crate::convert::*;
1818
use crate::parser::chunk::build_chunks;
1919
use crate::parser::debuglog::*;
2020
use crate::parser::ongoing::OngoingChecker;
21-
use crate::parser::session::{extract_session_meta, read_session_incremental};
21+
use crate::parser::session::{extract_session_meta, read_session_with_debug_hooks};
2222
use crate::parser::subagent::{discover_and_link_all, inject_orphan_subagents};
2323
use crate::parser::team::reconstruct_teams;
2424
use crate::state::AppState;
@@ -218,7 +218,7 @@ fn load_session_by_path(
218218
since: Option<DateTime<Utc>>,
219219
before: Option<DateTime<Utc>>,
220220
) -> Response {
221-
let (classified, _new_offset, _) = match read_session_incremental(&path, 0) {
221+
let (classified, _new_offset, _) = match read_session_with_debug_hooks(&path) {
222222
Ok(v) => v,
223223
Err(e) => return err_response(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e),
224224
};

src-tauri/src/parser/debuglog.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub struct DebugEntry {
3535
}
3636

3737
lazy_static! {
38+
static ref HOOK_MSG_RE: Regex =
39+
Regex::new(r"^Hook ([^ (]+) \(([^)]+)\) (success|error):$").unwrap();
3840
static ref DEBUG_LINE_RE: Regex = Regex::new(
3941
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\s+\[(DEBUG|WARN|ERROR)\]\s+(.*)$"
4042
)
@@ -206,6 +208,54 @@ pub fn filter_by_text(entries: &[DebugEntry], query: &str) -> Vec<DebugEntry> {
206208
.collect()
207209
}
208210

211+
/// Extract hook execution events from a session's debug log as ClassifiedMsg::Hook.
212+
///
213+
/// Claude Code writes one `[DEBUG] Hook {name} ({event}) success:` line per hook
214+
/// execution in `~/.claude/debug/{session_id}.txt` (only when run with `--debug`).
215+
/// This function reads those lines and returns HookMsg entries that can be merged
216+
/// into the session's classified message list to surface non-Stop hooks (PreToolUse,
217+
/// PostToolUse, UserPromptSubmit, SessionStart, etc.) that are not recorded in JSONL.
218+
///
219+
/// Stop hooks are excluded because they are already captured via `stop_hook_summary`
220+
/// system entries in the JSONL file.
221+
pub fn extract_hook_msgs(session_path: &str) -> Vec<super::classify::ClassifiedMsg> {
222+
let debug_path = debug_log_path(session_path);
223+
if debug_path.is_empty() {
224+
return Vec::new();
225+
}
226+
let (entries, _) = match read_debug_log(&debug_path) {
227+
Ok(v) => v,
228+
Err(_) => return Vec::new(),
229+
};
230+
entries
231+
.iter()
232+
.filter_map(|e| {
233+
let caps = HOOK_MSG_RE.captures(&e.message)?;
234+
let hook_name_full = caps.get(1)?.as_str(); // e.g. "PreToolUse:Agent"
235+
let hook_event = caps.get(2)?.as_str(); // e.g. "PreToolUse"
236+
// Stop hooks are already captured via stop_hook_summary in the JSONL.
237+
if hook_event == "Stop" {
238+
return None;
239+
}
240+
// Extract the tool/matcher name after the colon (e.g. "Agent" from "PreToolUse:Agent").
241+
let hook_name = hook_name_full
242+
.find(':')
243+
.map(|i| &hook_name_full[i + 1..])
244+
.unwrap_or(hook_name_full)
245+
.to_string();
246+
let command = e.extra.clone();
247+
Some(super::classify::ClassifiedMsg::Hook(
248+
super::classify::HookMsg {
249+
timestamp: e.timestamp,
250+
hook_event: hook_event.to_string(),
251+
hook_name,
252+
command,
253+
},
254+
))
255+
})
256+
.collect()
257+
}
258+
209259
/// Collapse consecutive duplicate entries.
210260
pub fn collapse_duplicates(entries: Vec<DebugEntry>) -> Vec<DebugEntry> {
211261
if entries.is_empty() {
@@ -225,3 +275,114 @@ pub fn collapse_duplicates(entries: Vec<DebugEntry>) -> Vec<DebugEntry> {
225275
result.push(current);
226276
result
227277
}
278+
279+
#[cfg(test)]
280+
mod tests {
281+
use super::*;
282+
use crate::parser::classify::ClassifiedMsg;
283+
use std::io::Write;
284+
use tempfile::NamedTempFile;
285+
286+
fn write_debug_file(content: &str) -> NamedTempFile {
287+
let mut f = NamedTempFile::new().unwrap();
288+
f.write_all(content.as_bytes()).unwrap();
289+
f
290+
}
291+
292+
#[test]
293+
fn extract_hook_msgs_returns_empty_for_missing_debug_log() {
294+
// session_path with no corresponding debug file → empty
295+
let result = extract_hook_msgs("/nonexistent/path/fake-uuid.jsonl");
296+
assert!(result.is_empty());
297+
}
298+
299+
#[test]
300+
fn extract_hook_msgs_parses_pretooluse_and_posttooluse() {
301+
// Write a fake debug log to a temp file with known session UUID pattern.
302+
// We test the inner parsing by calling read_debug_log directly + applying
303+
// the same regex, since extract_hook_msgs relies on debug_log_path() which
304+
// looks up ~/.claude/debug/{session_id}.txt.
305+
let content = "\
306+
2026-03-03T01:01:41.147Z [DEBUG] Hook PreToolUse:Agent (PreToolUse) success:\n\
307+
[entire] PreToolUse[Task] hook invoked\n\
308+
2026-03-03T01:01:48.664Z [DEBUG] Hook PostToolUse:Agent (PostToolUse) success:\n\
309+
[entire] PostToolUse[Task] hook invoked\n\
310+
2026-03-03T01:02:38.628Z [DEBUG] Hook Stop (Stop) success:\n\
311+
stop output\n\
312+
2026-03-03T01:03:00.000Z [DEBUG] Hook UserPromptSubmit (UserPromptSubmit) success:\n\
313+
prompt captured\n\
314+
";
315+
let f = write_debug_file(content);
316+
let (entries, _) = read_debug_log(f.path().to_str().unwrap()).unwrap();
317+
318+
let hooks: Vec<ClassifiedMsg> = entries
319+
.iter()
320+
.filter_map(|e| {
321+
let caps = HOOK_MSG_RE.captures(&e.message)?;
322+
let hook_name_full = caps.get(1)?.as_str();
323+
let hook_event = caps.get(2)?.as_str();
324+
if hook_event == "Stop" {
325+
return None;
326+
}
327+
let hook_name = hook_name_full
328+
.find(':')
329+
.map(|i| &hook_name_full[i + 1..])
330+
.unwrap_or(hook_name_full)
331+
.to_string();
332+
Some(ClassifiedMsg::Hook(crate::parser::classify::HookMsg {
333+
timestamp: e.timestamp,
334+
hook_event: hook_event.to_string(),
335+
hook_name,
336+
command: e.extra.clone(),
337+
}))
338+
})
339+
.collect();
340+
341+
assert_eq!(hooks.len(), 3); // PreToolUse, PostToolUse, UserPromptSubmit (Stop excluded)
342+
343+
let events: Vec<&str> = hooks
344+
.iter()
345+
.map(|m| match m {
346+
ClassifiedMsg::Hook(h) => h.hook_event.as_str(),
347+
_ => "",
348+
})
349+
.collect();
350+
assert!(events.contains(&"PreToolUse"));
351+
assert!(events.contains(&"PostToolUse"));
352+
assert!(events.contains(&"UserPromptSubmit"));
353+
assert!(!events.contains(&"Stop"), "Stop should be excluded");
354+
}
355+
356+
#[test]
357+
fn extract_hook_msgs_extracts_tool_name_from_colon_format() {
358+
let content =
359+
"2026-03-03T01:01:41.147Z [DEBUG] Hook PreToolUse:Read (PreToolUse) success:\n";
360+
let f = write_debug_file(content);
361+
let (entries, _) = read_debug_log(f.path().to_str().unwrap()).unwrap();
362+
363+
let caps = HOOK_MSG_RE.captures(&entries[0].message).unwrap();
364+
let hook_name_full = caps.get(1).unwrap().as_str();
365+
let hook_name = hook_name_full
366+
.find(':')
367+
.map(|i| &hook_name_full[i + 1..])
368+
.unwrap_or(hook_name_full);
369+
assert_eq!(hook_name, "Read");
370+
}
371+
372+
#[test]
373+
fn extract_hook_msgs_handles_hook_without_colon() {
374+
// e.g. "Hook UserPromptSubmit (UserPromptSubmit) success:"
375+
let content =
376+
"2026-03-03T01:01:41.147Z [DEBUG] Hook UserPromptSubmit (UserPromptSubmit) success:\n";
377+
let f = write_debug_file(content);
378+
let (entries, _) = read_debug_log(f.path().to_str().unwrap()).unwrap();
379+
380+
let caps = HOOK_MSG_RE.captures(&entries[0].message).unwrap();
381+
let hook_name_full = caps.get(1).unwrap().as_str();
382+
let hook_name = hook_name_full
383+
.find(':')
384+
.map(|i| &hook_name_full[i + 1..])
385+
.unwrap_or(hook_name_full);
386+
assert_eq!(hook_name, "UserPromptSubmit");
387+
}
388+
}

src-tauri/src/parser/session.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::path::PathBuf;
88

99
use super::chunk::{build_chunks, Chunk};
1010
use super::classify::{classify, ClassifiedMsg};
11+
use super::debuglog::extract_hook_msgs;
1112
use super::entry::parse_entry;
1213

1314
/// SessionInfo holds metadata about a discovered session file for the picker.
@@ -56,6 +57,29 @@ pub fn read_session(path: &str) -> Result<Vec<Chunk>, String> {
5657
Ok(build_chunks(&msgs))
5758
}
5859

60+
/// Full session load: JSONL classified messages merged with hook events from the
61+
/// debug log (if one exists at `~/.claude/debug/{session_id}.txt`).
62+
///
63+
/// Non-Stop hooks (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart) are
64+
/// only written to the debug log (not the JSONL) in Claude Code v2.1.84+. This
65+
/// function surfaces them by reading the debug log and merging by timestamp.
66+
pub fn read_session_with_debug_hooks(path: &str) -> Result<(Vec<ClassifiedMsg>, u64, u64), String> {
67+
let (mut msgs, offset, bytes) = read_session_incremental(path, 0)?;
68+
let debug_hooks = extract_hook_msgs(path);
69+
if !debug_hooks.is_empty() {
70+
msgs.extend(debug_hooks);
71+
msgs.sort_by_key(|m| match m {
72+
ClassifiedMsg::User(u) => u.timestamp,
73+
ClassifiedMsg::AI(a) => a.timestamp,
74+
ClassifiedMsg::System(s) => s.timestamp,
75+
ClassifiedMsg::Teammate(t) => t.timestamp,
76+
ClassifiedMsg::Compact(c) => c.timestamp,
77+
ClassifiedMsg::Hook(h) => h.timestamp,
78+
});
79+
}
80+
Ok((msgs, offset, bytes))
81+
}
82+
5983
/// Read new lines from a session file starting at the given byte offset.
6084
/// Returns (new classified messages, updated offset, bytes read).
6185
pub fn read_session_incremental(

src-tauri/src/watcher.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::convert::*;
88
use crate::parser::chunk::build_chunks;
99
use crate::parser::classify::ClassifiedMsg;
1010
use crate::parser::ongoing::OngoingChecker;
11-
use crate::parser::session::{read_session_incremental, IncrementalTokenScanner};
11+
use crate::parser::session::{read_session_with_debug_hooks, IncrementalTokenScanner};
1212
use crate::parser::subagent::{discover_and_link_all, inject_orphan_subagents};
1313
use crate::parser::team::reconstruct_teams;
1414

@@ -133,6 +133,7 @@ pub fn start_session_watcher(path: String, app: AppHandle) -> WatcherHandle {
133133
tauri::async_runtime::spawn(async move {
134134
let mut token_scanner = IncrementalTokenScanner::new();
135135
let mut prev_msg_count: usize = 0;
136+
let mut prev_item_count: usize = 0;
136137
let mut prev_ongoing = false;
137138
let mut prev_file_size: u64 = 0;
138139

@@ -152,6 +153,7 @@ pub fn start_session_watcher(path: String, app: AppHandle) -> WatcherHandle {
152153
.unwrap_or(0);
153154
if file_size < prev_file_size {
154155
prev_msg_count = 0;
156+
prev_item_count = 0;
155157
token_scanner = IncrementalTokenScanner::new();
156158
}
157159
prev_file_size = file_size;
@@ -161,7 +163,7 @@ pub fn start_session_watcher(path: String, app: AppHandle) -> WatcherHandle {
161163
// avoids holding all classified messages in memory for the
162164
// entire session lifetime — which caused multi-GB growth
163165
// for long sessions with large tool inputs/outputs.
164-
let all_classified = match read_session_incremental(&path_for_rebuild, 0) {
166+
let all_classified = match read_session_with_debug_hooks(&path_for_rebuild) {
165167
Ok((msgs, _, _)) => msgs,
166168
Err(_) => continue,
167169
};
@@ -182,14 +184,23 @@ pub fn start_session_watcher(path: String, app: AppHandle) -> WatcherHandle {
182184
let messages = chunks_to_messages(&chunks, &all_procs, &color_map);
183185

184186
// Skip emit if nothing meaningful changed.
187+
// Track both message count and total item count so that hook
188+
// events (which are added inside existing AI chunks without
189+
// creating a new top-level message) still trigger an emit.
185190
let msg_count = messages.len();
186-
if msg_count == prev_msg_count && !ongoing && !prev_ongoing {
191+
let item_count: usize = messages.iter().map(|m| m.items.len()).sum();
192+
if msg_count == prev_msg_count
193+
&& item_count == prev_item_count
194+
&& !ongoing
195+
&& !prev_ongoing
196+
{
187197
// Token totals may still have changed — update scanner
188198
// but skip the expensive emit + serialize.
189199
token_scanner.scan_new_bytes(&path_for_rebuild);
190200
continue;
191201
}
192202
prev_msg_count = msg_count;
203+
prev_item_count = item_count;
193204
prev_ongoing = ongoing;
194205

195206
// Extract last permission_mode from UserMsg entries.

src/components/MessageDetail.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ export function MessageDetail({
176176
const time = formatExactTime(msg.timestamp);
177177
const hasItems = msg.items.length > 0;
178178
const hasPanels = panelStack.length > 0;
179+
const hasToolCalls = msg.items.some(
180+
(i) => i.item_type === "ToolCall" || i.item_type === "Subagent",
181+
);
182+
const hasHookEvents = msg.items.some((i) => i.item_type === "HookEvent");
183+
const showDebugHint = hasToolCalls && !hasHookEvents;
179184

180185
// Stack manipulation
181186
const openSubagentFromMain = useCallback((item: DisplayItem) => {
@@ -408,6 +413,11 @@ export function MessageDetail({
408413
})}
409414
</div>
410415
)}
416+
{showDebugHint && (
417+
<div className="message-detail__debug-hint">
418+
Run <code>claude --debug</code> to see PreToolUse / PostToolUse hooks
419+
</div>
420+
)}
411421
{ongoing && (
412422
<div className="message-detail__ongoing">
413423
<OngoingDots />

src/styles/global.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,18 @@ body {
13041304
padding: 16px 0;
13051305
}
13061306

1307+
.message-detail__debug-hint {
1308+
font-size: 11px;
1309+
color: var(--text-muted);
1310+
padding: 8px 16px;
1311+
opacity: 0.6;
1312+
}
1313+
1314+
.message-detail__debug-hint code {
1315+
font-family: var(--font-mono);
1316+
font-size: 11px;
1317+
}
1318+
13071319
.message-detail__text {
13081320
font-size: 13px;
13091321
line-height: 1.6;

0 commit comments

Comments
 (0)