1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
use std::time::{Duration, SystemTime};
use crate::models::{AttentionState, Provider, Session};
const JUST_FINISHED_WINDOW: Duration = Duration::from_secs(3 * 60);
const IDLE_LONG_THRESHOLD: Duration = Duration::from_secs(30 * 60);
/// A session whose latest event is older than this is considered abandoned —
/// the user has likely moved on and the row should sink to the bottom even if
/// sessions JSON still says `status=busy` (which lags badly for stale sessions).
const STALE_THRESHOLD: Duration = Duration::from_secs(24 * 60 * 60);
pub fn classify(session: &Session, now: SystemTime) -> AttentionState {
if session.provider == Provider::Codex {
return classify_codex(session, now);
}
if session.last_stop_had_errors {
return AttentionState::Error;
}
// Two-signal Blocked detection:
// 1. Sessions JSON `status=waiting` — Claude Code's own canonical
// signal. Cheapest path, accurate when set, but routinely missed:
// observed cases where the native permission UI was visibly up for
// minutes while status stayed `busy`. We can't fix that from
// outside Claude Code, so we layer a second signal underneath.
// 2. `pane_blocked` — set in the refresh pass when the pane content
// shows the `1. Yes`/`2. No` permission UI anchor. Deterministic
// ground truth from the pixels Claude actually drew; survives the
// hook's 3-s timeout (after which the hook file is gone and only
// the pane tells us the user is still being asked).
if session.status == "waiting" || session.pane_blocked {
return AttentionState::Blocked;
}
// Real activity age, used for the Stale check below. last_stop_at is the
// strongest signal (turn ended); fall back to last_event_at otherwise.
let event_age = session
.last_stop_at
.or(session.last_event_at)
.and_then(|t| now.duration_since(t).ok());
// Stale takes precedence over status=busy because sessions JSON lags 30+
// min and routinely reports busy on Claude processes that haven't seen
// activity in days. Without this override, the ux pane (idle 5d, status
// still busy) would classify as Working.
if let Some(age) = event_age
&& age >= STALE_THRESHOLD
{
return AttentionState::Stale;
}
if session.status == "busy" {
return AttentionState::Working;
}
if session.user_prompt_count == 0 && session.headline.is_none() {
return AttentionState::Fresh;
}
if let Some(stop) = session.last_stop_at
&& let Ok(age) = now.duration_since(stop)
{
if age <= JUST_FINISHED_WINDOW {
return AttentionState::JustFinished;
}
if age >= IDLE_LONG_THRESHOLD {
return AttentionState::IdleLong;
}
return AttentionState::IdleShort;
}
// No stop yet observed; fall back to last_event_at. Newer Claude Code
// (2.1.13x+) doesn't emit `stop_hook_summary`, so this is the common
// path. Treat sessions JSON `status=idle` itself as the implicit
// turn-end signal: any recent event on an idle session means the turn
// just ended (otherwise the earlier `status=busy` branch would have
// caught it as Working).
if let Some(last) = session.last_event_at
&& let Ok(age) = now.duration_since(last)
{
if age <= JUST_FINISHED_WINDOW {
return AttentionState::JustFinished;
}
if age >= IDLE_LONG_THRESHOLD {
return AttentionState::IdleLong;
}
return AttentionState::IdleShort;
}
AttentionState::Unknown
}
fn classify_codex(session: &Session, now: SystemTime) -> AttentionState {
if session.pane_blocked {
return AttentionState::Blocked;
}
let event_age = session
.last_event_at
.and_then(|t| now.duration_since(t).ok());
if let Some(age) = event_age
&& age >= STALE_THRESHOLD
{
return AttentionState::Stale;
}
if session.status == "busy" {
return AttentionState::Working;
}
if session.user_prompt_count == 0 && session.headline.is_none() {
return AttentionState::Fresh;
}
if let Some(age) = event_age {
if age <= JUST_FINISHED_WINDOW {
return AttentionState::JustFinished;
}
if age >= IDLE_LONG_THRESHOLD {
return AttentionState::IdleLong;
}
return AttentionState::IdleShort;
}
AttentionState::Unknown
}
pub fn idle_age(session: &Session, now: SystemTime) -> Option<Duration> {
let anchor = session.last_stop_at.or(session.last_event_at)?;
now.duration_since(anchor).ok()
}