1use std::collections::{HashMap, VecDeque};
45use std::path::{Path, PathBuf};
46use std::sync::{Mutex, MutexGuard, PoisonError};
47use std::time::{Duration, Instant};
48
49use chrono::{DateTime, Utc};
50use serde::{Deserialize, Serialize};
51use tokio::sync::watch;
52
53#[cfg(unix)]
54pub mod codex_app_server;
55pub mod codex_watcher;
56#[cfg(unix)]
57pub mod journal;
58#[cfg(unix)]
59pub(crate) mod journal_watcher;
60pub(crate) mod pid_liveness;
61pub(crate) mod pid_watcher;
62pub mod relocate;
63pub mod stream;
64pub mod watcher;
65
66const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
91
92const ENDED_SESSION_TTL: Duration = Duration::from_secs(10);
98
99const DEFAULT_WINDOW_TTL: Duration = Duration::from_secs(30);
105
106const MAX_SESSIONS: usize = 512;
112
113const MAX_REPLACED_PIDS: usize = 8;
117
118const MAX_WINDOWS: usize = 256;
120
121const SOCKET_REORDER_WINDOW: chrono::Duration = chrono::Duration::seconds(10);
129
130#[must_use]
136pub fn is_session_uuid(id: &str) -> bool {
137 let mut groups = id.split('-');
138 [8, 4, 4, 4, 12].into_iter().all(|len| {
139 groups
140 .next()
141 .is_some_and(|g| g.len() == len && g.bytes().all(|b| b.is_ascii_hexdigit()))
142 }) && groups.next().is_none()
143}
144
145const MAX_RECENT_SEQS: usize = 32;
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct EventStamp {
161 pub ts: DateTime<Utc>,
163 pub seq: String,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub(crate) enum Origin {
170 Socket,
173 Journal,
177}
178
179#[derive(Debug)]
195struct AwakeClock {
196 base: Instant,
198 #[cfg(test)]
200 skew_ms: std::sync::atomic::AtomicU64,
201}
202
203impl AwakeClock {
204 fn new() -> Self {
205 Self {
206 base: Instant::now(),
207 #[cfg(test)]
208 skew_ms: std::sync::atomic::AtomicU64::new(0),
209 }
210 }
211
212 fn now(&self) -> Duration {
214 let elapsed = self.base.elapsed();
215 #[cfg(test)]
216 let elapsed = elapsed
217 + Duration::from_millis(self.skew_ms.load(std::sync::atomic::Ordering::Relaxed));
218 elapsed
219 }
220
221 #[cfg(test)]
223 fn advance(&self, by: Duration) {
224 self.skew_ms.fetch_add(
225 u64::try_from(by.as_millis()).unwrap_or(u64::MAX),
226 std::sync::atomic::Ordering::Relaxed,
227 );
228 }
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum SessionState {
241 Starting,
243 Working,
246 Idle,
248 WaitingForInput,
250 WaitingForPermission,
252 Ended,
255}
256
257impl SessionState {
258 #[must_use]
285 pub fn for_event(event: &SessionEvent, current: Option<Self>) -> Self {
286 match event {
287 SessionEvent::SessionStart => Self::Starting,
288 SessionEvent::UserPromptSubmit
289 | SessionEvent::PreToolUse
290 | SessionEvent::PostToolUse => Self::Working,
291 SessionEvent::TranscriptGrew => match current {
317 Some(
318 held @ (Self::Starting
319 | Self::WaitingForInput
320 | Self::WaitingForPermission
321 | Self::Ended),
322 ) => held,
323 _ => Self::Working,
324 },
325 SessionEvent::Stop => Self::Idle,
326 SessionEvent::StreamState(state) => *state,
330 SessionEvent::Notification(NotificationKind::PermissionPrompt) => {
331 Self::WaitingForPermission
332 }
333 SessionEvent::Notification(
334 NotificationKind::IdlePrompt | NotificationKind::AgentNeedsInput,
335 ) => Self::WaitingForInput,
336 SessionEvent::Notification(NotificationKind::Other)
340 | SessionEvent::TranscriptDiscovered => current.unwrap_or(Self::Idle),
341 }
342 }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(rename_all = "snake_case")]
352pub enum NotificationKind {
353 PermissionPrompt,
356 IdlePrompt,
358 AgentNeedsInput,
360 Other,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum SessionEvent {
372 SessionStart,
374 UserPromptSubmit,
376 PreToolUse,
378 PostToolUse,
380 Stop,
382 Notification(NotificationKind),
384 TranscriptGrew,
387 TranscriptDiscovered,
390 StreamState(SessionState),
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(tag = "kind", rename_all = "snake_case")]
411pub enum Source {
412 Terminal,
414 VsCode {
417 window_key: String,
419 },
420}
421
422#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(rename_all = "snake_case")]
438pub enum Agent {
439 #[default]
441 Claude,
442 Pi,
445 Codex,
448}
449
450impl Agent {
451 #[must_use]
453 pub fn display_name(self) -> &'static str {
454 match self {
455 Self::Claude => "Claude",
456 Self::Pi => "pi",
457 Self::Codex => "Codex",
458 }
459 }
460
461 #[must_use]
463 pub fn is_claude(&self) -> bool {
464 *self == Self::Claude
465 }
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct ObserveRequest {
476 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub agent_id: Option<String>,
479 pub session_id: String,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub cwd: Option<PathBuf>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub transcript_path: Option<PathBuf>,
489 pub event: SessionEvent,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub repo: Option<String>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub model: Option<String>,
498 #[serde(default, skip_serializing_if = "Agent::is_claude")]
500 pub agent: Agent,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub pid: Option<u32>,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct WindowReport {
522 pub key: String,
524 #[serde(default)]
526 pub folders: Vec<PathBuf>,
527 #[serde(default)]
529 pub tabs: usize,
530 #[serde(default)]
532 pub terminals: usize,
533}
534
535impl WindowReport {
536 #[must_use]
539 fn has_embedding(&self) -> bool {
540 self.tabs > 0 || self.terminals > 0
541 }
542}
543
544#[derive(Debug, Clone, Serialize)]
551pub struct SessionEntry {
552 #[serde(skip)]
554 pub(crate) subagent_base: Option<SessionState>,
555 #[serde(skip)]
557 pub(crate) subagent_waits: HashMap<String, SessionState>,
558 pub session_id: String,
560 #[serde(skip_serializing_if = "Option::is_none")]
562 pub cwd: Option<PathBuf>,
563 #[serde(skip_serializing_if = "Option::is_none")]
565 pub transcript_path: Option<PathBuf>,
566 #[serde(skip_serializing_if = "Option::is_none")]
568 pub repo: Option<String>,
569 #[serde(skip_serializing_if = "Option::is_none")]
571 pub model: Option<String>,
572 pub agent: Agent,
575 pub state: SessionState,
577 pub source: Source,
579 pub last_event: SessionEvent,
581 pub started_at: DateTime<Utc>,
583 pub last_seen: DateTime<Utc>,
586 #[serde(skip)]
590 pub(crate) last_active: Duration,
591 #[serde(skip)]
594 pub(crate) pid: Option<u32>,
595 #[serde(skip)]
603 pub(crate) pid_start: Option<String>,
604 #[serde(skip)]
609 pub(crate) prompted: bool,
610 #[serde(skip)]
619 pub(crate) streamed: bool,
620 #[serde(skip)]
624 pub(crate) replaced_pids: VecDeque<u32>,
625 #[serde(skip)]
629 pub(crate) recent_seqs: VecDeque<String>,
630 #[serde(skip)]
633 pub(crate) latest_stamp_ts: Option<DateTime<Utc>>,
634}
635
636impl SessionEntry {
637 fn stamp_skip(&self, stamp: Option<&EventStamp>, origin: Origin) -> Option<&'static str> {
641 let stamp = stamp?;
642 if self.recent_seqs.contains(&stamp.seq) {
643 return Some("duplicate_ignored");
644 }
645 let behind = self.latest_stamp_ts.map(|latest| latest - stamp.ts);
646 match (origin, behind) {
647 (Origin::Journal, Some(behind)) if behind > chrono::Duration::zero() => {
648 Some("journal_stale_ignored")
649 }
650 (Origin::Socket, Some(behind))
651 if behind > chrono::Duration::zero() && behind <= SOCKET_REORDER_WINDOW =>
652 {
653 Some("socket_reordered_ignored")
654 }
655 _ => None,
656 }
657 }
658
659 fn record_stamp(&mut self, stamp: &EventStamp) {
661 if self.recent_seqs.len() >= MAX_RECENT_SEQS {
662 self.recent_seqs.pop_front();
663 }
664 self.recent_seqs.push_back(stamp.seq.clone());
665 self.latest_stamp_ts = Some(self.latest_stamp_ts.map_or(stamp.ts, |t| t.max(stamp.ts)));
669 }
670}
671
672fn seen_at(stamp: Option<&EventStamp>, origin: Origin, now: DateTime<Utc>) -> DateTime<Utc> {
676 match (origin, stamp) {
677 (Origin::Journal, Some(stamp)) => stamp.ts.min(now),
678 _ => now,
679 }
680}
681
682fn subagent_state(
684 entry: &mut SessionEntry,
685 event: SessionEvent,
686 agent_id: Option<&str>,
687) -> SessionState {
688 if let Some(id) = agent_id.filter(|id| !id.trim().is_empty()) {
689 match event {
690 SessionEvent::Notification(
691 NotificationKind::PermissionPrompt
692 | NotificationKind::IdlePrompt
693 | NotificationKind::AgentNeedsInput,
694 ) => {
695 entry.subagent_base.get_or_insert(entry.state);
696 entry
697 .subagent_waits
698 .insert(id.to_owned(), SessionState::for_event(&event, None));
699 }
700 SessionEvent::PostToolUse => {
701 entry.subagent_waits.remove(id);
702 }
703 _ => {}
704 }
705 if entry
706 .subagent_waits
707 .values()
708 .any(|s| *s == SessionState::WaitingForPermission)
709 {
710 return SessionState::WaitingForPermission;
711 }
712 if !entry.subagent_waits.is_empty() {
713 return SessionState::WaitingForInput;
714 }
715 return entry.subagent_base.take().unwrap_or(entry.state);
716 }
717 let next = SessionState::for_event(&event, Some(entry.state));
718 if !matches!(
721 event,
722 SessionEvent::TranscriptDiscovered
723 | SessionEvent::TranscriptGrew
724 | SessionEvent::Notification(NotificationKind::Other)
725 ) {
726 entry.subagent_waits.clear();
727 entry.subagent_base = None;
728 }
729 next
730}
731
732#[derive(Debug, Clone)]
737pub(crate) struct PidCandidate {
738 pub(crate) session_id: String,
739 pub(crate) pid: u32,
740 pub(crate) pid_start: Option<String>,
743 pub(crate) prompted: bool,
744 pub(crate) streamed: bool,
747 pub(crate) last_seen: DateTime<Utc>,
753}
754
755#[derive(Debug, Clone)]
757struct WindowEntry {
758 report: WindowReport,
760 last_active: Duration,
765 registered_at: DateTime<Utc>,
771}
772
773pub struct SessionsRegistry {
779 sessions: Mutex<HashMap<String, SessionEntry>>,
781 windows: Mutex<HashMap<String, WindowEntry>>,
784 session_ttl: Duration,
786 ended_ttl: Duration,
788 window_ttl: Duration,
790 clock: AwakeClock,
792 changes: watch::Sender<u64>,
806}
807
808impl SessionsRegistry {
809 #[must_use]
811 pub fn new() -> Self {
812 Self {
813 sessions: Mutex::new(HashMap::new()),
814 windows: Mutex::new(HashMap::new()),
815 session_ttl: DEFAULT_SESSION_TTL,
816 ended_ttl: ENDED_SESSION_TTL,
817 window_ttl: DEFAULT_WINDOW_TTL,
818 clock: AwakeClock::new(),
819 changes: watch::channel(0).0,
820 }
821 }
822
823 #[must_use]
827 pub(crate) fn scratch() -> Self {
828 Self {
829 session_ttl: Duration::MAX,
830 ended_ttl: Duration::MAX,
831 window_ttl: Duration::MAX,
832 ..Self::new()
833 }
834 }
835
836 pub(crate) fn live_session_ids(&self) -> std::collections::HashSet<String> {
840 self.lock_sessions()
841 .values()
842 .filter(|entry| entry.state != SessionState::Ended)
843 .map(|entry| entry.session_id.to_ascii_lowercase())
844 .collect()
845 }
846
847 #[must_use]
854 pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
855 self.changes.subscribe()
856 }
857
858 pub(crate) fn bump(&self) {
874 self.changes.send_modify(|v| *v = v.wrapping_add(1));
875 }
876
877 fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, SessionEntry>> {
880 self.sessions.lock().unwrap_or_else(PoisonError::into_inner)
881 }
882
883 fn lock_windows(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
885 self.windows.lock().unwrap_or_else(PoisonError::into_inner)
886 }
887
888 pub fn observe(&self, req: ObserveRequest) {
904 self.observe_stamped(req, None, Origin::Socket);
905 }
906
907 pub(crate) fn observe_stamped(
914 &self,
915 req: ObserveRequest,
916 stamp: Option<EventStamp>,
917 origin: Origin,
918 ) {
919 let agent_id = req
920 .agent_id
921 .as_deref()
922 .filter(|id| req.agent == Agent::Claude && !id.trim().is_empty());
923 let session_id = req.session_id.clone();
924 let event = req.event;
925 let agent = req.agent;
926 let pid = req.pid;
927 let now = Utc::now();
928 let seen = seen_at(stamp.as_ref(), origin, now);
929 let awake = self.clock.now();
930 let (changed, old_state, new_state, outcome, reaped) = {
931 let mut sessions = self.lock_sessions();
932 let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, awake);
933 let old_state = sessions.get(&session_id).map(|entry| entry.state);
934 let skip = sessions
935 .get(&session_id)
936 .and_then(|entry| entry.stamp_skip(stamp.as_ref(), origin));
937 let mut outcome = "created";
938 let mutated = match sessions.get_mut(&req.session_id) {
939 Some(_) if skip.is_some() => {
942 outcome = skip.unwrap_or("skipped");
943 false
944 }
945 Some(entry)
949 if entry.state == SessionState::Ended
950 && (req.event == SessionEvent::TranscriptDiscovered
951 || agent_id.is_some()) =>
952 {
953 outcome = "ended_passive_ignored";
954 false
955 }
956 Some(entry)
962 if req
963 .pid
964 .is_some_and(|pid| entry.replaced_pids.contains(&pid)) =>
965 {
966 outcome = "replaced_pid_ignored";
967 false
968 }
969 Some(entry) => {
970 track_pid(entry, req.pid);
971 entry.prompted |= req.event == SessionEvent::UserPromptSubmit;
972 entry.streamed |= is_claude_stream_report(&req);
973 let next = subagent_state(entry, req.event, agent_id);
974 let state_changed = next != entry.state;
975 entry.state = next;
976 entry.last_event = req.event;
977 entry.last_seen = if origin == Origin::Journal {
978 entry.last_seen.max(seen)
979 } else {
980 now
981 };
982 entry.last_active = awake;
983 let filled_cwd = fill(&mut entry.cwd, req.cwd);
986 let filled_transcript = fill(&mut entry.transcript_path, req.transcript_path);
987 let filled_repo = fill(&mut entry.repo, req.repo);
988 let filled_model = fill(&mut entry.model, req.model);
989 let metadata_changed =
990 filled_cwd || filled_transcript || filled_repo || filled_model;
991 outcome = if state_changed {
992 "state_changed"
993 } else if metadata_changed {
994 "metadata_enriched"
995 } else {
996 "heartbeat_only"
997 };
998 state_changed || metadata_changed
999 }
1000 None => {
1001 if sessions.len() >= MAX_SESSIONS {
1002 evict_oldest_session(&mut sessions);
1003 }
1004 let state = if agent_id.is_some() {
1005 SessionState::Idle
1006 } else {
1007 SessionState::for_event(&req.event, None)
1008 };
1009 let prompted = req.event == SessionEvent::UserPromptSubmit;
1010 let streamed = is_claude_stream_report(&req);
1011 let session_id = req.session_id.clone();
1012 let mut entry = SessionEntry {
1013 subagent_base: None,
1014 subagent_waits: HashMap::new(),
1015 session_id: req.session_id,
1016 cwd: req.cwd,
1017 transcript_path: req.transcript_path,
1018 repo: req.repo,
1019 model: req.model,
1020 agent: req.agent,
1021 state,
1022 source: Source::Terminal,
1023 last_event: req.event,
1024 started_at: seen,
1025 last_seen: seen,
1026 last_active: awake,
1027 pid: req.pid,
1028 pid_start: None,
1029 prompted,
1030 streamed,
1031 replaced_pids: VecDeque::new(),
1032 recent_seqs: VecDeque::new(),
1033 latest_stamp_ts: None,
1034 };
1035 entry.state = subagent_state(&mut entry, req.event, agent_id);
1036 sessions.insert(session_id, entry);
1037 true
1038 }
1039 };
1040 if skip.is_none() {
1041 if let (Some(stamp), Some(entry)) = (&stamp, sessions.get_mut(&session_id)) {
1042 entry.record_stamp(stamp);
1043 }
1044 }
1045 (
1046 mutated || reaped > 0,
1047 old_state,
1048 sessions.get(&session_id).map(|entry| entry.state),
1049 outcome,
1050 reaped,
1051 )
1052 };
1053 if changed {
1054 self.bump();
1055 }
1056 tracing::debug!(%session_id, ?agent, ?pid, ?agent_id, ?event, ?old_state, ?new_state, outcome,
1057 ?origin, reaped, bumped = changed, "session_observed");
1058 }
1059
1060 pub fn end(&self, session_id: &str, reason: Option<&str>, pid: Option<u32>) -> bool {
1074 self.end_stamped(session_id, reason, pid, None, Origin::Socket)
1075 }
1076
1077 pub(crate) fn end_stamped(
1081 &self,
1082 session_id: &str,
1083 _reason: Option<&str>,
1084 pid: Option<u32>,
1085 stamp: Option<EventStamp>,
1086 origin: Origin,
1087 ) -> bool {
1088 let now = Utc::now();
1089 let seen = seen_at(stamp.as_ref(), origin, now);
1090 let awake = self.clock.now();
1091 let (known, reaped, old_state, outcome) = {
1092 let mut sessions = self.lock_sessions();
1093 let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, awake);
1094 let old_state = sessions.get(session_id).map(|entry| entry.state);
1095 let skip = sessions
1096 .get(session_id)
1097 .and_then(|entry| entry.stamp_skip(stamp.as_ref(), origin));
1098 let mut outcome = "unknown";
1099 let known = match sessions.get_mut(session_id) {
1100 Some(_) if skip.is_some() => {
1101 outcome = skip.unwrap_or("skipped");
1102 (true, false)
1103 }
1104 Some(entry) if entry.state == SessionState::Ended => {
1107 outcome = "already_ended";
1108 (true, false)
1109 }
1110 Some(entry) if pid.is_some_and(|pid| entry.replaced_pids.contains(&pid)) => {
1113 outcome = "replaced_pid_ignored";
1114 (true, false)
1115 }
1116 Some(entry) => {
1117 outcome = "ended";
1118 entry.state = SessionState::Ended;
1119 entry.last_event = SessionEvent::Stop;
1120 entry.last_seen = if origin == Origin::Journal {
1121 entry.last_seen.max(seen)
1122 } else {
1123 now
1124 };
1125 entry.last_active = awake;
1126 (true, true)
1127 }
1128 None => (false, false),
1129 };
1130 if skip.is_none() {
1131 if let (Some(stamp), Some(entry)) = (&stamp, sessions.get_mut(session_id)) {
1132 entry.record_stamp(stamp);
1133 }
1134 }
1135 (known, reaped, old_state, outcome)
1136 };
1137 let (known, flipped) = known;
1138 let bumped = flipped || reaped > 0;
1141 if bumped {
1142 self.bump();
1143 }
1144 tracing::debug!(
1145 session_id,
1146 ?pid,
1147 ?old_state,
1148 outcome,
1149 ?origin,
1150 reaped,
1151 bumped,
1152 "session_end"
1153 );
1154 known
1155 }
1156
1157 pub(crate) fn pid_liveness_candidates(&self) -> Vec<PidCandidate> {
1162 self.lock_sessions()
1163 .values()
1164 .filter(|e| e.state != SessionState::Ended)
1165 .filter_map(|e| {
1166 Some(PidCandidate {
1167 session_id: e.session_id.clone(),
1168 pid: e.pid?,
1169 pid_start: e.pid_start.clone(),
1170 prompted: e.prompted,
1171 streamed: e.streamed,
1172 last_seen: e.last_seen,
1173 })
1174 })
1175 .collect()
1176 }
1177
1178 pub(crate) fn confirm_pid_liveness(
1187 &self,
1188 session_id: &str,
1189 now: DateTime<Utc>,
1190 pid_start: &str,
1191 ) {
1192 let awake = self.clock.now();
1193 let mut sessions = self.lock_sessions();
1194 if let Some(entry) = sessions.get_mut(session_id) {
1195 if entry.state != SessionState::Ended {
1196 entry.last_seen = now;
1197 entry.last_active = awake;
1198 if entry.pid_start.is_none() {
1199 entry.pid_start = Some(pid_start.to_string());
1200 }
1201 }
1202 }
1203 }
1204
1205 pub fn report_window(&self, report: WindowReport) {
1214 let window_key = report.key.clone();
1215 let folder_count = report.folders.len();
1216 let now = Utc::now();
1217 let awake = self.clock.now();
1218 let (changed, outcome, reaped) = {
1219 let mut windows = self.lock_windows();
1220 let reaped = reap_windows(&mut windows, self.window_ttl, awake);
1221 let outcome = if windows.contains_key(&report.key) {
1222 "refresh"
1223 } else {
1224 "registered"
1225 };
1226 let (mutated, registered_at) = if let Some(previous) = windows.get(&report.key) {
1227 (
1228 previous.report.folders != report.folders
1229 || previous.report.has_embedding() != report.has_embedding(),
1230 previous.registered_at,
1231 )
1232 } else {
1233 if windows.len() >= MAX_WINDOWS {
1234 evict_oldest_window(&mut windows);
1235 }
1236 (true, now)
1237 };
1238 windows.insert(
1239 report.key.clone(),
1240 WindowEntry {
1241 report,
1242 last_active: awake,
1243 registered_at,
1244 },
1245 );
1246 (
1247 mutated || reaped > 0,
1248 if outcome == "refresh" && mutated {
1249 "embedding_changed"
1250 } else {
1251 outcome
1252 },
1253 reaped,
1254 )
1255 };
1256 if changed {
1257 self.bump();
1258 }
1259 tracing::debug!(%window_key, folder_count, outcome, reaped, bumped = changed, "session_window_reported");
1260 }
1261
1262 pub fn unregister_window(&self, key: &str) -> bool {
1265 let removed = {
1266 let mut windows = self.lock_windows();
1267 windows.remove(key).is_some()
1268 };
1269 if removed {
1270 self.bump();
1271 }
1272 tracing::debug!(
1273 window_key = key,
1274 removed,
1275 bumped = removed,
1276 "session_window_unregistered"
1277 );
1278 removed
1279 }
1280
1281 pub fn credit_window_outage(&self, outage: Duration) {
1303 let awake = self.clock.now();
1304 for entry in self.lock_windows().values_mut() {
1305 entry.last_active = entry
1307 .last_active
1308 .checked_add(outage)
1309 .map_or(awake, |credited| credited.min(awake));
1310 }
1311 }
1312
1313 pub fn list(&self) -> Vec<SessionEntry> {
1330 let awake = self.clock.now();
1331 let mut sessions: Vec<SessionEntry> = {
1332 let mut guard = self.lock_sessions();
1333 reap_sessions(&mut guard, self.session_ttl, self.ended_ttl, awake);
1334 guard.values().cloned().collect()
1335 };
1336 let windows: Vec<WindowEntry> = {
1337 let mut guard = self.lock_windows();
1338 reap_windows(&mut guard, self.window_ttl, awake);
1339 guard
1340 .values()
1341 .filter(|e| e.report.has_embedding())
1342 .cloned()
1343 .collect()
1344 };
1345 for session in &mut sessions {
1346 session.source = resolve_source(session.cwd.as_deref(), &windows);
1347 }
1348 sessions.sort_by(|a, b| {
1349 a.repo
1350 .cmp(&b.repo)
1351 .then_with(|| a.session_id.cmp(&b.session_id))
1352 });
1353 sessions
1354 }
1355
1356 pub fn focus_folder(&self, session_id: &str) -> Option<PathBuf> {
1361 let cwd = {
1362 let sessions = self.lock_sessions();
1363 sessions.get(session_id).and_then(|e| e.cwd.clone())
1364 }?;
1365 let awake = self.clock.now();
1366 let mut windows = self.lock_windows();
1367 reap_windows(&mut windows, self.window_ttl, awake);
1368 pick_window(&cwd, windows.values().filter(|e| e.report.has_embedding()))
1369 .and_then(|w| w.folders.first().cloned())
1370 }
1371}
1372
1373impl Default for SessionsRegistry {
1374 fn default() -> Self {
1375 Self::new()
1376 }
1377}
1378
1379fn track_pid(entry: &mut SessionEntry, pid: Option<u32>) {
1400 let Some(pid) = pid else {
1401 return;
1402 };
1403 if entry.pid == Some(pid) {
1404 return;
1405 }
1406 if let Some(owner) = entry.pid.replace(pid) {
1407 if entry.replaced_pids.len() >= MAX_REPLACED_PIDS {
1408 entry.replaced_pids.pop_front();
1409 }
1410 entry.replaced_pids.push_back(owner);
1411 entry.streamed = false;
1415 }
1416 entry.pid_start = None;
1417}
1418
1419fn fill<T: PartialEq>(slot: &mut Option<T>, incoming: Option<T>) -> bool {
1425 match incoming {
1426 Some(value) if slot.as_ref() != Some(&value) => {
1427 *slot = Some(value);
1428 true
1429 }
1430 _ => false,
1431 }
1432}
1433
1434fn resolve_source(cwd: Option<&Path>, windows: &[WindowEntry]) -> Source {
1441 let Some(cwd) = cwd else {
1442 tracing::trace!(outcome = "missing_cwd", "session_attribution_miss");
1443 return Source::Terminal;
1444 };
1445 if let Some(window) = pick_window(cwd, windows) {
1446 Source::VsCode {
1447 window_key: window.key.clone(),
1448 }
1449 } else {
1450 tracing::debug!(cwd = %cwd.display(), window_count = windows.len(), "session_attribution_miss");
1451 for window in windows {
1452 tracing::trace!(window_key = %window.report.key, folders = ?window.report.folders, "session_attribution_candidate");
1453 }
1454 Source::Terminal
1455 }
1456}
1457
1458fn pick_window<'a>(
1474 cwd: &Path,
1475 windows: impl IntoIterator<Item = &'a WindowEntry>,
1476) -> Option<&'a WindowReport> {
1477 windows
1478 .into_iter()
1479 .filter_map(|e| Some((e, match_depth(cwd, &e.report.folders)?)))
1480 .min_by(|(a, a_depth), (b, b_depth)| {
1481 b_depth
1482 .cmp(a_depth)
1483 .then_with(|| b.registered_at.cmp(&a.registered_at))
1484 .then_with(|| a.report.key.cmp(&b.report.key))
1485 })
1486 .map(|(e, _)| &e.report)
1487}
1488
1489fn match_depth(cwd: &Path, folders: &[PathBuf]) -> Option<usize> {
1492 folders
1493 .iter()
1494 .filter(|f| cwd.starts_with(f))
1495 .map(|f| f.components().count())
1496 .max()
1497}
1498
1499fn is_claude_stream_report(req: &ObserveRequest) -> bool {
1504 req.agent == Agent::Claude && matches!(req.event, SessionEvent::StreamState(_))
1505}
1506
1507fn reap_sessions(
1520 sessions: &mut HashMap<String, SessionEntry>,
1521 session_ttl: Duration,
1522 ended_ttl: Duration,
1523 awake: Duration,
1524) -> usize {
1525 let before = sessions.len();
1526 sessions.retain(|_, e| {
1527 let max_age = if e.state == SessionState::Ended {
1528 ended_ttl
1529 } else {
1530 session_ttl
1531 };
1532 let keep = awake.saturating_sub(e.last_active) <= max_age;
1533 if !keep {
1534 tracing::trace!(session_id = %e.session_id, reason = if e.state == SessionState::Ended { "ended_ttl" } else { "session_ttl" }, "session_reaped");
1535 }
1536 keep
1537 });
1538 let count = before - sessions.len();
1539 if count > 0 {
1540 tracing::debug!(count, "sessions_reaped");
1541 }
1542 count
1543}
1544
1545fn reap_windows(
1548 windows: &mut HashMap<String, WindowEntry>,
1549 ttl: Duration,
1550 awake: Duration,
1551) -> usize {
1552 let before = windows.len();
1553 windows.retain(|key, e| {
1554 let keep = awake.saturating_sub(e.last_active) <= ttl;
1555 if !keep {
1556 tracing::trace!(window_key = %key, reason = "window_ttl", "session_window_reaped");
1557 }
1558 keep
1559 });
1560 let count = before - windows.len();
1561 if count > 0 {
1562 tracing::debug!(count, "session_windows_reaped");
1563 }
1564 count
1565}
1566
1567fn evict_oldest_session(sessions: &mut HashMap<String, SessionEntry>) {
1571 let oldest = sessions
1572 .values()
1573 .min_by(|a, b| {
1574 a.last_active
1575 .cmp(&b.last_active)
1576 .then_with(|| a.session_id.cmp(&b.session_id))
1577 })
1578 .map(|e| e.session_id.clone());
1579 if let Some(key) = oldest {
1580 sessions.remove(&key);
1581 tracing::debug!(session_id = %key, reason = "capacity", "session_evicted");
1582 }
1583}
1584
1585fn evict_oldest_window(windows: &mut HashMap<String, WindowEntry>) {
1588 let oldest = windows
1589 .iter()
1590 .min_by(|a, b| {
1591 a.1.last_active
1592 .cmp(&b.1.last_active)
1593 .then_with(|| a.0.cmp(b.0))
1594 })
1595 .map(|(k, _)| k.clone());
1596 if let Some(key) = oldest {
1597 windows.remove(&key);
1598 tracing::debug!(window_key = %key, reason = "capacity", "session_window_evicted");
1599 }
1600}
1601
1602#[cfg(test)]
1603#[allow(clippy::unwrap_used, clippy::expect_used)]
1604mod tests {
1605 use super::*;
1606
1607 #[test]
1608 fn registry_diagnostics_distinguish_transition_heartbeat_and_unknown_end() {
1609 let registry = SessionsRegistry::new();
1610 let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
1611 registry.observe(observe_request(
1612 "diagnostics",
1613 SessionEvent::UserPromptSubmit,
1614 Some("/project"),
1615 ));
1616 registry.observe(observe_request(
1617 "diagnostics",
1618 SessionEvent::UserPromptSubmit,
1619 Some("/project"),
1620 ));
1621 registry.end("missing", None, None);
1622 registry.end("diagnostics", None, None);
1623 });
1624 assert!(logs.contains("state_changed") || logs.contains("created"));
1625 assert!(logs.contains("heartbeat_only"));
1626 assert!(logs.contains("unknown"));
1627 assert!(logs.contains("outcome=\"ended\""), "{logs}");
1628 assert!(logs.contains("bumped=true"));
1629 assert!(logs.contains("bumped=false"));
1630 }
1631
1632 #[test]
1633 fn attribution_misses_are_logged_with_each_candidate_window() {
1634 let windows = vec![window_entry("w1", 20), window_entry("w2", 5)];
1635 let logs = crate::test_support::capture_at(tracing::Level::TRACE, || {
1636 assert_eq!(
1637 resolve_source(Some(Path::new("/elsewhere/x")), &windows),
1638 Source::Terminal
1639 );
1640 assert_eq!(resolve_source(None, &windows), Source::Terminal);
1641 });
1642 assert!(logs.contains("session_attribution_miss"), "{logs}");
1643 assert!(logs.contains("missing_cwd"), "{logs}");
1644 assert_eq!(
1645 logs.matches("session_attribution_candidate").count(),
1646 2,
1647 "{logs}"
1648 );
1649 assert!(
1650 logs.contains("window_key=w1") && logs.contains("window_key=w2"),
1651 "{logs}"
1652 );
1653 }
1654
1655 fn observe_request(session_id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
1656 ObserveRequest {
1657 agent_id: None,
1658 pid: None,
1659 agent: Agent::Claude,
1660 session_id: session_id.to_string(),
1661 cwd: cwd.map(PathBuf::from),
1662 transcript_path: None,
1663 event,
1664 repo: None,
1665 model: None,
1666 }
1667 }
1668
1669 #[test]
1670 fn list_is_empty_initially() {
1671 let reg = SessionsRegistry::new();
1672 assert!(reg.list().is_empty());
1673 }
1674
1675 #[test]
1676 fn observe_then_list_round_trips_and_infers_state() {
1677 let reg = SessionsRegistry::new();
1678 reg.observe(observe_request(
1679 "s1",
1680 SessionEvent::SessionStart,
1681 Some("/tmp/a"),
1682 ));
1683 let sessions = reg.list();
1684 assert_eq!(sessions.len(), 1);
1685 assert_eq!(sessions[0].session_id, "s1");
1686 assert_eq!(sessions[0].state, SessionState::Starting);
1687 assert_eq!(sessions[0].source, Source::Terminal);
1689 }
1690
1691 #[test]
1692 fn observe_is_idempotent_upsert_advancing_state() {
1693 let reg = SessionsRegistry::new();
1694 reg.observe(observe_request(
1695 "s1",
1696 SessionEvent::SessionStart,
1697 Some("/tmp/a"),
1698 ));
1699 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
1700 let sessions = reg.list();
1701 assert_eq!(sessions.len(), 1, "same session_id upserts, not duplicates");
1702 assert_eq!(sessions[0].state, SessionState::Working);
1703 assert_eq!(sessions[0].cwd.as_deref(), Some(Path::new("/tmp/a")));
1705 }
1706
1707 #[test]
1708 fn state_machine_covers_every_event() {
1709 use NotificationKind::*;
1710 use SessionEvent::*;
1711 let cases = [
1712 (SessionStart, SessionState::Starting),
1713 (UserPromptSubmit, SessionState::Working),
1714 (PreToolUse, SessionState::Working),
1715 (PostToolUse, SessionState::Working),
1716 (Stop, SessionState::Idle),
1717 (
1718 Notification(PermissionPrompt),
1719 SessionState::WaitingForPermission,
1720 ),
1721 (Notification(IdlePrompt), SessionState::WaitingForInput),
1722 (Notification(AgentNeedsInput), SessionState::WaitingForInput),
1723 (TranscriptGrew, SessionState::Working),
1724 (TranscriptDiscovered, SessionState::Idle),
1725 (
1727 StreamState(SessionState::WaitingForPermission),
1728 SessionState::WaitingForPermission,
1729 ),
1730 (StreamState(SessionState::Idle), SessionState::Idle),
1731 ];
1732 for (event, expected) in cases {
1733 assert_eq!(
1734 SessionState::for_event(&event, None),
1735 expected,
1736 "event {event:?}"
1737 );
1738 }
1739 assert_eq!(
1741 SessionState::for_event(&Notification(Other), Some(SessionState::Working)),
1742 SessionState::Working
1743 );
1744 assert_eq!(
1746 SessionState::for_event(&TranscriptDiscovered, Some(SessionState::Working)),
1747 SessionState::Working
1748 );
1749 assert_eq!(
1752 SessionState::for_event(
1753 &StreamState(SessionState::Idle),
1754 Some(SessionState::Working)
1755 ),
1756 SessionState::Idle
1757 );
1758 for held in [
1765 SessionState::Starting,
1766 SessionState::WaitingForInput,
1767 SessionState::WaitingForPermission,
1768 SessionState::Ended,
1769 ] {
1770 assert_eq!(
1771 SessionState::for_event(&TranscriptGrew, Some(held)),
1772 held,
1773 "growth must not overwrite {held:?}"
1774 );
1775 }
1776 for other in [SessionState::Working, SessionState::Idle] {
1779 assert_eq!(
1780 SessionState::for_event(&TranscriptGrew, Some(other)),
1781 SessionState::Working,
1782 "growth from {other:?}"
1783 );
1784 }
1785 }
1786
1787 #[test]
1788 fn end_marks_ended_and_reaps_quickly() {
1789 let reg = SessionsRegistry::new();
1790 reg.observe(observe_request(
1791 "s1",
1792 SessionEvent::PreToolUse,
1793 Some("/tmp/a"),
1794 ));
1795 assert!(reg.end("s1", Some("clear"), None));
1796 assert!(!reg.end("ghost", None, None));
1798 let sessions = reg.list();
1799 assert_eq!(sessions.len(), 1);
1800 assert_eq!(sessions[0].state, SessionState::Ended);
1801 reg.clock.advance(Duration::from_secs(30));
1803 assert!(reg.list().is_empty(), "ended entry reaps after ended TTL");
1804 }
1805
1806 #[test]
1809 fn pid_liveness_candidates_lists_only_non_ended_pid_bearing_sessions() {
1810 let reg = SessionsRegistry::new();
1811 reg.observe(observe_from(
1812 "with_pid",
1813 SessionEvent::UserPromptSubmit,
1814 100,
1815 ));
1816 reg.observe(observe_request(
1817 "no_pid",
1818 SessionEvent::UserPromptSubmit,
1819 None,
1820 ));
1821 reg.observe(observe_from("ended", SessionEvent::UserPromptSubmit, 200));
1822 reg.end("ended", None, Some(200));
1823
1824 let candidates = reg.pid_liveness_candidates();
1825 assert_eq!(candidates.len(), 1);
1826 assert_eq!(candidates[0].session_id, "with_pid");
1827 assert_eq!(candidates[0].pid, 100);
1828 assert!(candidates[0].prompted);
1829 assert_eq!(candidates[0].pid_start, None);
1830 }
1831
1832 #[test]
1833 fn only_a_claude_stream_report_marks_a_session_streamed() {
1834 let reg = SessionsRegistry::new();
1836 reg.observe(observe_from("hooked", SessionEvent::UserPromptSubmit, 100));
1837 let candidates = reg.pid_liveness_candidates();
1838 assert!(candidates[0].prompted);
1839 assert!(!candidates[0].streamed);
1840
1841 reg.observe(observe_from(
1844 "hooked",
1845 SessionEvent::StreamState(SessionState::Idle),
1846 100,
1847 ));
1848 reg.observe(observe_from("hooked", SessionEvent::Stop, 100));
1849 let candidates = reg.pid_liveness_candidates();
1850 assert!(candidates[0].prompted && candidates[0].streamed);
1851
1852 let reg = SessionsRegistry::new();
1853 reg.observe(observe_from(
1854 "wrapped",
1855 SessionEvent::StreamState(SessionState::Working),
1856 100,
1857 ));
1858 assert!(reg.pid_liveness_candidates()[0].streamed);
1859
1860 let reg = SessionsRegistry::new();
1863 reg.observe(observe_from(
1864 "resumed",
1865 SessionEvent::StreamState(SessionState::Idle),
1866 100,
1867 ));
1868 assert!(reg.pid_liveness_candidates()[0].streamed);
1869 reg.observe(observe_from("resumed", SessionEvent::SessionStart, 200));
1870 assert!(!reg.pid_liveness_candidates()[0].streamed);
1871
1872 for agent in [Agent::Codex, Agent::Pi] {
1875 let reg = SessionsRegistry::new();
1876 reg.observe(ObserveRequest {
1877 agent,
1878 ..observe_from("other", SessionEvent::StreamState(SessionState::Idle), 100)
1879 });
1880 assert!(!reg.pid_liveness_candidates()[0].streamed, "{agent:?}");
1881 }
1882 }
1883
1884 #[test]
1885 fn confirm_pid_liveness_refreshes_last_seen_and_captures_the_token_once() {
1886 let reg = SessionsRegistry::new();
1887 reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
1888 let old = Utc::now() - chrono::Duration::seconds(1000);
1889 let old_active = reg.lock_sessions()["s1"].last_active;
1890 {
1891 let mut guard = reg.lock_sessions();
1892 guard.get_mut("s1").unwrap().last_seen = old;
1893 }
1894 reg.clock.advance(Duration::from_secs(1000));
1895
1896 reg.confirm_pid_liveness("s1", Utc::now(), "tok-1");
1897 {
1898 let guard = reg.lock_sessions();
1899 let entry = &guard["s1"];
1900 assert!(entry.last_seen > old, "last_seen should be refreshed");
1901 assert!(
1902 entry.last_active > old_active + Duration::from_secs(999),
1903 "the awake stamp the TTL reads should be refreshed too"
1904 );
1905 assert_eq!(entry.pid_start.as_deref(), Some("tok-1"));
1906 }
1907
1908 reg.confirm_pid_liveness("s1", Utc::now(), "tok-2");
1911 assert_eq!(
1912 reg.lock_sessions()["s1"].pid_start.as_deref(),
1913 Some("tok-1")
1914 );
1915 }
1916
1917 #[test]
1918 fn confirm_pid_liveness_is_a_noop_for_an_unknown_or_ended_session() {
1919 let reg = SessionsRegistry::new();
1920 reg.confirm_pid_liveness("ghost", Utc::now(), "tok");
1922
1923 reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
1924 reg.end("s1", None, Some(100));
1925 let before = reg.lock_sessions()["s1"].last_seen;
1926 let before_active = reg.lock_sessions()["s1"].last_active;
1927 reg.clock.advance(Duration::from_secs(1));
1928 reg.confirm_pid_liveness("s1", Utc::now(), "tok");
1929 assert_eq!(
1930 reg.lock_sessions()["s1"].last_seen,
1931 before,
1932 "an ended session must not be revived by a liveness confirmation"
1933 );
1934 assert_eq!(reg.lock_sessions()["s1"].last_active, before_active);
1935 }
1936
1937 #[test]
1938 fn stale_working_session_reaps_but_recent_survives() {
1939 let reg = SessionsRegistry::new();
1940 reg.observe(observe_request("stale", SessionEvent::PreToolUse, None));
1941 reg.clock.advance(Duration::from_secs(1000));
1942 reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
1943 let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
1944 assert_eq!(ids, vec!["fresh".to_string()]);
1945 }
1946
1947 #[test]
1948 fn source_is_vscode_when_cwd_is_under_a_reporting_window() {
1949 let reg = SessionsRegistry::new();
1950 reg.observe(observe_request(
1951 "s1",
1952 SessionEvent::PreToolUse,
1953 Some("/home/me/proj/sub"),
1954 ));
1955 reg.report_window(WindowReport {
1957 key: "w1".to_string(),
1958 folders: vec![PathBuf::from("/home/me/proj")],
1959 tabs: 1,
1960 terminals: 0,
1961 });
1962 let sessions = reg.list();
1963 assert_eq!(
1964 sessions[0].source,
1965 Source::VsCode {
1966 window_key: "w1".to_string()
1967 }
1968 );
1969 }
1970
1971 #[test]
1972 fn source_is_terminal_when_window_has_no_embedding() {
1973 let reg = SessionsRegistry::new();
1974 reg.observe(observe_request(
1975 "s1",
1976 SessionEvent::PreToolUse,
1977 Some("/home/me/proj"),
1978 ));
1979 reg.report_window(WindowReport {
1981 key: "w1".to_string(),
1982 folders: vec![PathBuf::from("/home/me/proj")],
1983 tabs: 0,
1984 terminals: 0,
1985 });
1986 assert_eq!(reg.list()[0].source, Source::Terminal);
1987 }
1988
1989 #[test]
1990 fn window_report_is_upsert_and_unregister_removes() {
1991 let reg = SessionsRegistry::new();
1992 reg.report_window(WindowReport {
1993 key: "w1".to_string(),
1994 folders: vec![PathBuf::from("/p")],
1995 tabs: 1,
1996 terminals: 0,
1997 });
1998 reg.report_window(WindowReport {
2000 key: "w1".to_string(),
2001 folders: vec![PathBuf::from("/p")],
2002 tabs: 2,
2003 terminals: 1,
2004 });
2005 assert!(reg.unregister_window("w1"));
2006 assert!(!reg.unregister_window("w1"));
2007 }
2008
2009 #[test]
2010 fn stale_window_stops_tagging_source() {
2011 let reg = SessionsRegistry::new();
2012 reg.observe(observe_request(
2013 "s1",
2014 SessionEvent::PreToolUse,
2015 Some("/p/sub"),
2016 ));
2017 reg.report_window(WindowReport {
2018 key: "w1".to_string(),
2019 folders: vec![PathBuf::from("/p")],
2020 tabs: 1,
2021 terminals: 0,
2022 });
2023 reg.clock.advance(Duration::from_secs(120));
2025 assert_eq!(reg.list()[0].source, Source::Terminal);
2026 }
2027
2028 #[test]
2029 fn credit_window_outage_keeps_a_report_the_outage_alone_would_have_expired() {
2030 let reg = SessionsRegistry::new();
2031 let report = |key: &str| WindowReport {
2032 key: key.to_string(),
2033 folders: vec![PathBuf::from("/p")],
2034 tabs: 1,
2035 terminals: 0,
2036 };
2037 reg.report_window(report("kept"));
2038 reg.report_window(report("long-gone"));
2039 reg.clock.advance(Duration::from_secs(1000));
2041 {
2042 let awake = reg.clock.now();
2043 let mut guard = reg.lock_windows();
2044 guard.get_mut("kept").unwrap().last_active =
2046 awake.saturating_sub(Duration::from_secs(40));
2047 guard.get_mut("long-gone").unwrap().last_active =
2048 awake.saturating_sub(Duration::from_secs(300));
2049 }
2050 reg.credit_window_outage(Duration::from_secs(25));
2052 reg.list();
2053 let guard = reg.lock_windows();
2054 assert!(guard.contains_key("kept"), "the outage must not age it out");
2055 assert!(
2056 !guard.contains_key("long-gone"),
2057 "a report 300 s silent is dead however long the outage was"
2058 );
2059 }
2060
2061 #[test]
2062 fn credit_window_outage_never_moves_last_active_into_the_future() {
2063 let reg = SessionsRegistry::new();
2064 reg.report_window(WindowReport {
2065 key: "w".to_string(),
2066 folders: vec![PathBuf::from("/p")],
2067 tabs: 1,
2068 terminals: 0,
2069 });
2070 reg.credit_window_outage(Duration::from_secs(3600));
2071 assert!(reg.lock_windows()["w"].last_active <= reg.clock.now());
2072 reg.credit_window_outage(Duration::MAX);
2074 assert!(reg.lock_windows()["w"].last_active <= reg.clock.now());
2075 }
2076
2077 #[test]
2078 fn credit_window_outage_is_not_a_visible_change() {
2079 let reg = SessionsRegistry::new();
2080 reg.report_window(WindowReport {
2081 key: "w".to_string(),
2082 folders: vec![PathBuf::from("/p")],
2083 tabs: 1,
2084 terminals: 0,
2085 });
2086 let rx = reg.subscribe_changes();
2087 reg.credit_window_outage(Duration::from_secs(5));
2088 assert!(!rx.has_changed().unwrap());
2089 }
2090
2091 fn window_entry(key: &str, age_secs: i64) -> WindowEntry {
2093 let now = Utc::now();
2094 WindowEntry {
2095 report: window_report(key, "/p", true),
2096 last_active: Duration::ZERO,
2097 registered_at: now - chrono::Duration::seconds(age_secs),
2098 }
2099 }
2100
2101 fn vscode(key: &str) -> Source {
2102 Source::VsCode {
2103 window_key: key.to_string(),
2104 }
2105 }
2106
2107 #[test]
2108 fn resolve_source_prefers_newest_registration_over_key_order() {
2109 let windows = vec![window_entry("w1", 20), window_entry("w2", 5)];
2111 assert_eq!(
2112 resolve_source(Some(Path::new("/p/x")), &windows),
2113 vscode("w2")
2114 );
2115 assert_eq!(resolve_source(None, &windows), Source::Terminal);
2117 }
2118
2119 #[test]
2120 fn resolve_source_breaks_equal_registrations_by_lowest_key() {
2121 let now = Utc::now();
2122 let at_now = |key: &str| WindowEntry {
2123 report: window_report(key, "/p", true),
2124 last_active: Duration::ZERO,
2125 registered_at: now,
2126 };
2127 let windows = vec![at_now("w2"), at_now("w1")];
2128 assert_eq!(
2129 resolve_source(Some(Path::new("/p/x")), &windows),
2130 vscode("w1")
2131 );
2132 }
2133
2134 #[test]
2135 fn reload_overlap_attributes_to_the_new_window_even_if_its_key_sorts_higher() {
2136 let reg = SessionsRegistry::new();
2139 reg.observe(observe_request(
2140 "s1",
2141 SessionEvent::PreToolUse,
2142 Some("/p/sub"),
2143 ));
2144 reg.report_window(window_report("39-old", "/p", true));
2145 {
2146 let mut guard = reg.lock_windows();
2147 guard.get_mut("39-old").unwrap().registered_at =
2148 Utc::now() - chrono::Duration::seconds(60);
2149 }
2150 reg.report_window(window_report("52-new", "/p", true));
2151 assert_eq!(reg.list()[0].source, vscode("52-new"));
2152 reg.report_window(window_report("39-old", "/p", true));
2154 assert_eq!(reg.list()[0].source, vscode("52-new"));
2155 }
2156
2157 #[test]
2158 fn a_more_specific_folder_outranks_a_newer_parent_window() {
2159 let now = Utc::now();
2160 let entry = |key: &str, folder: &str, age_secs: i64| WindowEntry {
2161 report: window_report(key, folder, true),
2162 last_active: Duration::ZERO,
2163 registered_at: now - chrono::Duration::seconds(age_secs),
2164 };
2165 let windows = vec![
2168 entry("a-parent", "/repo", 1),
2169 entry("z-sub", "/repo/sub", 50),
2170 ];
2171 assert_eq!(
2172 resolve_source(Some(Path::new("/repo/sub/x")), &windows),
2173 vscode("z-sub")
2174 );
2175 assert_eq!(
2177 resolve_source(Some(Path::new("/repo/other")), &windows),
2178 vscode("a-parent")
2179 );
2180 }
2181
2182 #[test]
2183 fn refreshing_a_window_preserves_its_registration_time() {
2184 let reg = SessionsRegistry::new();
2185 reg.report_window(window_report("w1", "/p", true));
2186 let first = reg.lock_windows()["w1"].registered_at;
2187 let first_active = reg.lock_windows()["w1"].last_active;
2188 reg.clock.advance(Duration::from_secs(5));
2189 reg.report_window(window_report("w1", "/p", true));
2190 let guard = reg.lock_windows();
2191 assert_eq!(guard["w1"].registered_at, first);
2192 assert!(guard["w1"].last_active > first_active);
2193 }
2194
2195 #[test]
2196 fn reregistering_after_unregister_gets_a_fresh_registration_time() {
2197 let reg = SessionsRegistry::new();
2198 reg.report_window(window_report("w1", "/p", true));
2199 let first = reg.lock_windows()["w1"].registered_at;
2200 std::thread::sleep(std::time::Duration::from_millis(5));
2201 assert!(reg.unregister_window("w1"));
2202 reg.report_window(window_report("w1", "/p", true));
2203 assert!(reg.lock_windows()["w1"].registered_at > first);
2204 }
2205
2206 #[test]
2207 fn focus_folder_agrees_with_list_on_overlap() {
2208 let reg = SessionsRegistry::new();
2209 reg.observe(observe_request(
2210 "s1",
2211 SessionEvent::PreToolUse,
2212 Some("/p/sub"),
2213 ));
2214 let mut old = window_report("a-old", "/p", true);
2215 old.folders = vec![PathBuf::from("/p/old-root"), PathBuf::from("/p")];
2216 reg.report_window(old);
2217 {
2218 let mut guard = reg.lock_windows();
2219 guard.get_mut("a-old").unwrap().registered_at =
2220 Utc::now() - chrono::Duration::seconds(60);
2221 }
2222 reg.report_window(window_report("z-new", "/p", true));
2223 assert_eq!(reg.list()[0].source, vscode("z-new"));
2224 assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/p")));
2226 }
2227
2228 #[test]
2229 fn focus_folder_resolves_matching_window_folder() {
2230 let reg = SessionsRegistry::new();
2231 reg.observe(observe_request(
2232 "s1",
2233 SessionEvent::PreToolUse,
2234 Some("/home/me/proj/sub"),
2235 ));
2236 assert!(reg.focus_folder("s1").is_none(), "no window yet");
2237 reg.report_window(WindowReport {
2238 key: "w1".to_string(),
2239 folders: vec![PathBuf::from("/home/me/proj")],
2240 tabs: 1,
2241 terminals: 0,
2242 });
2243 assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/home/me/proj")));
2244 assert!(reg.focus_folder("ghost").is_none());
2246 }
2247
2248 #[test]
2249 fn evict_oldest_session_drops_the_longest_silent() {
2250 let now = Utc::now();
2251 let mut sessions = HashMap::new();
2252 for (id, age) in [("young", 0_u64), ("old", 100), ("older", 200)] {
2253 sessions.insert(
2254 id.to_string(),
2255 SessionEntry {
2256 subagent_base: None,
2257 subagent_waits: HashMap::new(),
2258 pid: None,
2259 pid_start: None,
2260 prompted: false,
2261 streamed: false,
2262 replaced_pids: VecDeque::new(),
2263 recent_seqs: VecDeque::new(),
2264 latest_stamp_ts: None,
2265 agent: Agent::Claude,
2266 session_id: id.to_string(),
2267 cwd: None,
2268 transcript_path: None,
2269 repo: None,
2270 model: None,
2271 state: SessionState::Working,
2272 source: Source::Terminal,
2273 last_event: SessionEvent::PreToolUse,
2274 started_at: now,
2275 last_seen: now,
2276 last_active: Duration::from_secs(1000 - age),
2277 },
2278 );
2279 }
2280 evict_oldest_session(&mut sessions);
2281 assert!(!sessions.contains_key("older"));
2282 assert!(sessions.contains_key("young"));
2283 assert!(sessions.contains_key("old"));
2284 let mut empty: HashMap<String, SessionEntry> = HashMap::new();
2286 evict_oldest_session(&mut empty);
2287 assert!(empty.is_empty());
2288 }
2289
2290 #[test]
2291 fn list_sorts_by_repo_then_session_id() {
2292 let reg = SessionsRegistry::new();
2293 for (id, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
2294 reg.observe(ObserveRequest {
2295 agent_id: None,
2296 pid: None,
2297 agent: Agent::Claude,
2298 session_id: id.to_string(),
2299 cwd: None,
2300 transcript_path: None,
2301 event: SessionEvent::PreToolUse,
2302 repo: Some(repo.to_string()),
2303 model: None,
2304 });
2305 }
2306 let ordered: Vec<(String, String)> = reg
2307 .list()
2308 .into_iter()
2309 .map(|s| (s.session_id, s.repo.unwrap()))
2310 .collect();
2311 assert_eq!(
2312 ordered,
2313 vec![
2314 ("m".to_string(), "repo-a".to_string()),
2315 ("z".to_string(), "repo-a".to_string()),
2316 ("a".to_string(), "repo-b".to_string()),
2317 ]
2318 );
2319 }
2320
2321 #[test]
2322 fn serialized_session_shapes_are_stable() {
2323 let reg = SessionsRegistry::new();
2326 reg.observe(ObserveRequest {
2327 agent_id: None,
2328 pid: None,
2329 agent: Agent::Claude,
2330 session_id: "s1".to_string(),
2331 cwd: Some(PathBuf::from("/p")),
2332 transcript_path: None,
2333 event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
2334 repo: Some("proj".to_string()),
2335 model: None,
2336 });
2337 let value = serde_json::to_value(®.list()[0]).unwrap();
2338 assert_eq!(value["state"], "waiting_for_permission");
2339 assert_eq!(value["source"]["kind"], "terminal");
2340 assert_eq!(value["repo"], "proj");
2341 assert!(value.get("model").is_none());
2343 assert!(value.get("transcript_path").is_none());
2344 }
2345
2346 #[test]
2347 fn stream_state_is_authoritative_and_round_trips() {
2348 let reg = SessionsRegistry::new();
2351 reg.observe(observe_request("s1", SessionEvent::PreToolUse, Some("/p")));
2352 assert_eq!(reg.list()[0].state, SessionState::Working);
2353 reg.observe(observe_request(
2354 "s1",
2355 SessionEvent::StreamState(SessionState::WaitingForPermission),
2356 None,
2357 ));
2358 assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2359 let value = serde_json::to_value(SessionEvent::StreamState(
2361 SessionState::WaitingForPermission,
2362 ))
2363 .unwrap();
2364 assert_eq!(
2365 value,
2366 serde_json::json!({ "stream_state": "waiting_for_permission" })
2367 );
2368 let event: SessionEvent = serde_json::from_value(value).unwrap();
2370 assert_eq!(
2371 event,
2372 SessionEvent::StreamState(SessionState::WaitingForPermission)
2373 );
2374 }
2375
2376 #[test]
2377 fn default_constructs_an_empty_registry() {
2378 let reg = SessionsRegistry::default();
2379 assert!(reg.list().is_empty());
2380 }
2381
2382 #[test]
2383 fn agent_is_omitted_for_claude_and_tagged_for_pi_on_the_wire() {
2384 let claude = serde_json::to_value(observe_request("s", SessionEvent::Stop, None)).unwrap();
2386 assert!(claude.get("agent").is_none(), "{claude}");
2387 let parsed: ObserveRequest =
2389 serde_json::from_value(serde_json::json!({ "session_id": "s", "event": "stop" }))
2390 .unwrap();
2391 assert_eq!(parsed.agent, Agent::Claude);
2392 let pi: ObserveRequest = serde_json::from_value(serde_json::json!({
2394 "session_id": "019a0000-0000-7000-8000-000000000000",
2395 "cwd": "/work/repo",
2396 "agent": "pi",
2397 "event": { "stream_state": "waiting_for_input" },
2398 }))
2399 .unwrap();
2400 assert_eq!(pi.agent, Agent::Pi);
2401 assert_eq!(
2402 pi.event,
2403 SessionEvent::StreamState(SessionState::WaitingForInput)
2404 );
2405 }
2406
2407 #[test]
2408 fn agents_have_display_names() {
2409 assert_eq!(Agent::Claude.display_name(), "Claude");
2410 assert_eq!(Agent::Pi.display_name(), "pi");
2411 assert_eq!(Agent::Codex.display_name(), "Codex");
2412 }
2413
2414 #[test]
2415 fn an_ended_session_is_not_refreshed_by_a_passive_sighting_or_a_second_end() {
2416 let reg = SessionsRegistry::new();
2417 reg.observe(observe_request("s", SessionEvent::Stop, None));
2418 assert!(reg.end("s", None, None));
2419 let ended_at = reg.list()[0].last_seen;
2420 let mut changes = reg.subscribe_changes();
2421 changes.mark_unchanged();
2422 std::thread::sleep(std::time::Duration::from_millis(5));
2423 reg.observe(observe_request(
2424 "s",
2425 SessionEvent::TranscriptDiscovered,
2426 None,
2427 ));
2428 assert!(reg.end("s", None, None), "still a known session");
2429 let listed = reg.list();
2430 assert_eq!(listed[0].state, SessionState::Ended);
2431 assert_eq!(
2432 listed[0].last_seen, ended_at,
2433 "the linger window did not restart"
2434 );
2435 assert!(
2436 !changes.has_changed().unwrap(),
2437 "no consumer-visible change"
2438 );
2439 reg.observe(observe_request("s", SessionEvent::UserPromptSubmit, None));
2441 assert_eq!(reg.list()[0].state, SessionState::Working);
2442 }
2443
2444 #[test]
2445 fn codex_is_a_tagged_agent_on_the_wire() {
2446 let codex: ObserveRequest = serde_json::from_value(serde_json::json!({
2447 "session_id": "019a0000-0000-7000-8000-000000000001",
2448 "agent": "codex",
2449 "event": { "notification": "permission_prompt" },
2450 }))
2451 .unwrap();
2452 assert_eq!(codex.agent, Agent::Codex);
2453 assert!(!codex.agent.is_claude());
2454 let json = serde_json::to_value(&codex).unwrap();
2455 assert_eq!(json["agent"], "codex");
2456 }
2457
2458 #[test]
2459 fn a_pi_session_is_listed_with_its_agent_and_reported_state() {
2460 let reg = SessionsRegistry::new();
2461 let mut req = observe_request(
2462 "pi-1",
2463 SessionEvent::StreamState(SessionState::Working),
2464 Some("/work/repo"),
2465 );
2466 req.agent = Agent::Pi;
2467 reg.observe(req);
2468 reg.observe(observe_request(
2470 "pi-1",
2471 SessionEvent::StreamState(SessionState::Idle),
2472 None,
2473 ));
2474 let listed = reg.list();
2475 assert_eq!(listed.len(), 1);
2476 assert_eq!(listed[0].agent, Agent::Pi);
2477 assert_eq!(listed[0].state, SessionState::Idle);
2478 let json = serde_json::to_value(&listed[0]).unwrap();
2479 assert_eq!(json["agent"], "pi");
2480 }
2481
2482 #[test]
2483 fn fill_only_overwrites_with_a_present_value() {
2484 let mut slot = Some("keep");
2487 fill(&mut slot, None);
2488 assert_eq!(slot, Some("keep"));
2489 fill(&mut slot, Some("new"));
2490 assert_eq!(slot, Some("new"));
2491 let mut empty: Option<&str> = None;
2493 fill(&mut empty, Some("filled"));
2494 assert_eq!(empty, Some("filled"));
2495 }
2496
2497 #[test]
2498 fn observe_at_session_cap_evicts_the_longest_silent() {
2499 let reg = SessionsRegistry::new();
2500 {
2503 let mut sessions = reg.lock_sessions();
2504 let base = Utc::now();
2505 for i in 0..MAX_SESSIONS {
2506 let id = format!("s{i:04}");
2507 sessions.insert(
2508 id.clone(),
2509 SessionEntry {
2510 subagent_base: None,
2511 subagent_waits: HashMap::new(),
2512 pid: None,
2513 pid_start: None,
2514 prompted: false,
2515 streamed: false,
2516 replaced_pids: VecDeque::new(),
2517 recent_seqs: VecDeque::new(),
2518 latest_stamp_ts: None,
2519 agent: Agent::Claude,
2520 session_id: id.clone(),
2521 cwd: None,
2522 transcript_path: None,
2523 repo: None,
2524 model: None,
2525 state: SessionState::Working,
2526 source: Source::Terminal,
2527 last_event: SessionEvent::PreToolUse,
2528 started_at: base,
2529 last_seen: base,
2530 last_active: Duration::from_millis(1_000_000 - i as u64),
2531 },
2532 );
2533 }
2534 }
2535 reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
2537 let sessions = reg.lock_sessions();
2538 assert_eq!(sessions.len(), MAX_SESSIONS);
2539 assert!(sessions.contains_key("fresh"));
2540 assert!(!sessions.contains_key(&format!("s{:04}", MAX_SESSIONS - 1)));
2541 assert!(sessions.contains_key("s0000"));
2542 }
2543
2544 #[test]
2545 fn report_window_at_cap_evicts_the_longest_silent() {
2546 let reg = SessionsRegistry::new();
2547 {
2548 let mut windows = reg.lock_windows();
2549 let base = Utc::now();
2550 for i in 0..MAX_WINDOWS {
2551 let key = format!("w{i:04}");
2552 windows.insert(
2553 key.clone(),
2554 WindowEntry {
2555 report: WindowReport {
2556 key: key.clone(),
2557 folders: vec![],
2558 tabs: 1,
2559 terminals: 0,
2560 },
2561 last_active: Duration::from_millis(1_000_000 - i as u64),
2562 registered_at: base,
2563 },
2564 );
2565 }
2566 }
2567 reg.report_window(WindowReport {
2568 key: "fresh".to_string(),
2569 folders: vec![],
2570 tabs: 1,
2571 terminals: 0,
2572 });
2573 let windows = reg.lock_windows();
2574 assert_eq!(windows.len(), MAX_WINDOWS);
2575 assert!(windows.contains_key("fresh"));
2576 assert!(!windows.contains_key(&format!("w{:04}", MAX_WINDOWS - 1)));
2577 assert!(windows.contains_key("w0000"));
2578 }
2579
2580 #[test]
2581 fn evict_oldest_window_breaks_ties_by_key() {
2582 let now = Utc::now();
2583 let mut windows = HashMap::new();
2584 let at = |key: &str, secs: u64| WindowEntry {
2585 report: WindowReport {
2586 key: key.to_string(),
2587 folders: vec![],
2588 tabs: 1,
2589 terminals: 0,
2590 },
2591 last_active: Duration::from_secs(100 - secs),
2592 registered_at: now,
2593 };
2594 windows.insert("young".to_string(), at("young", 0));
2595 windows.insert("old-b".to_string(), at("old-b", 10));
2596 windows.insert("old-a".to_string(), at("old-a", 10));
2597 evict_oldest_window(&mut windows);
2599 assert!(!windows.contains_key("old-a"));
2600 assert!(windows.contains_key("old-b"));
2601 assert!(windows.contains_key("young"));
2602 let mut empty: HashMap<String, WindowEntry> = HashMap::new();
2604 evict_oldest_window(&mut empty);
2605 assert!(empty.is_empty());
2606 }
2607
2608 fn window_report(key: &str, folder: &str, embedded: bool) -> WindowReport {
2612 WindowReport {
2613 key: key.to_string(),
2614 folders: vec![PathBuf::from(folder)],
2615 tabs: usize::from(embedded),
2616 terminals: 0,
2617 }
2618 }
2619
2620 #[test]
2621 fn subscribe_changes_starts_seen_and_a_new_session_bumps() {
2622 let reg = SessionsRegistry::new();
2623 let mut rx = reg.subscribe_changes();
2624 assert!(!rx.has_changed().unwrap());
2626 reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
2627 assert!(rx.has_changed().unwrap(), "a new session should bump");
2628 rx.borrow_and_update();
2630 assert!(!rx.has_changed().unwrap());
2631 }
2632
2633 #[test]
2634 fn observe_bumps_on_a_state_transition_but_not_on_a_repeat_sighting() {
2635 let reg = SessionsRegistry::new();
2636 reg.observe(observe_request(
2637 "s1",
2638 SessionEvent::PreToolUse,
2639 Some("/tmp/a"),
2640 ));
2641 let mut rx = reg.subscribe_changes();
2643
2644 reg.observe(observe_request(
2648 "s1",
2649 SessionEvent::PreToolUse,
2650 Some("/tmp/a"),
2651 ));
2652 assert!(
2653 !rx.has_changed().unwrap(),
2654 "a repeat sighting with no visible change must not bump"
2655 );
2656
2657 reg.observe(observe_request("s1", SessionEvent::Stop, None));
2659 assert!(rx.has_changed().unwrap(), "a state transition should bump");
2660 rx.borrow_and_update();
2661
2662 reg.observe(observe_request("s1", SessionEvent::Stop, Some("/tmp/b")));
2665 assert!(
2666 rx.has_changed().unwrap(),
2667 "a newly-filled `cwd` should bump"
2668 );
2669 }
2670
2671 #[test]
2672 fn transcript_growth_does_not_clobber_a_waiting_session() {
2673 let reg = SessionsRegistry::new();
2674 reg.observe(observe_request(
2675 "s1",
2676 SessionEvent::Notification(NotificationKind::PermissionPrompt),
2677 Some("/tmp/a"),
2678 ));
2679 let rx = reg.subscribe_changes();
2683
2684 reg.observe(observe_request(
2688 "s1",
2689 SessionEvent::TranscriptGrew,
2690 Some("/tmp/a"),
2691 ));
2692 assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2693 assert!(
2694 !rx.has_changed().unwrap(),
2695 "state did not change, so nothing a consumer renders did either (#1414)"
2696 );
2697
2698 reg.observe(observe_request("s1", SessionEvent::PostToolUse, None));
2701 assert_eq!(reg.list()[0].state, SessionState::Working);
2702 assert!(
2703 rx.has_changed().unwrap(),
2704 "the release is a real transition"
2705 );
2706 }
2707
2708 #[test]
2709 fn transcript_growth_does_not_revive_an_ended_session() {
2710 let reg = SessionsRegistry::new();
2711 reg.observe(observe_request(
2712 "s1",
2713 SessionEvent::PreToolUse,
2714 Some("/tmp/a"),
2715 ));
2716 assert!(reg.end("s1", Some("clear"), None));
2717 let rx = reg.subscribe_changes();
2719
2720 reg.observe(observe_request(
2725 "s1",
2726 SessionEvent::TranscriptGrew,
2727 Some("/tmp/a"),
2728 ));
2729 assert_eq!(reg.list()[0].state, SessionState::Ended);
2730 assert!(
2731 !rx.has_changed().unwrap(),
2732 "state did not change, so nothing a consumer renders did either (#1414)"
2733 );
2734 }
2735
2736 #[test]
2737 fn a_resumed_session_stays_starting_until_its_first_prompt() {
2738 let reg = SessionsRegistry::new();
2739 reg.observe(observe_request(
2740 "s1",
2741 SessionEvent::UserPromptSubmit,
2742 Some("/tmp/a"),
2743 ));
2744 assert!(reg.end("s1", Some("other"), None));
2747 reg.observe(observe_request(
2748 "s1",
2749 SessionEvent::SessionStart,
2750 Some("/tmp/a"),
2751 ));
2752 assert_eq!(reg.list()[0].state, SessionState::Starting);
2753
2754 reg.observe(observe_request(
2759 "s1",
2760 SessionEvent::TranscriptGrew,
2761 Some("/tmp/a"),
2762 ));
2763 assert_eq!(reg.list()[0].state, SessionState::Starting);
2764
2765 reg.observe(observe_request("s1", SessionEvent::UserPromptSubmit, None));
2767 assert_eq!(reg.list()[0].state, SessionState::Working);
2768 }
2769
2770 #[test]
2771 fn end_bumps_only_for_a_known_session() {
2772 let reg = SessionsRegistry::new();
2773 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
2774 let mut rx = reg.subscribe_changes();
2775
2776 assert!(
2777 !reg.end("ghost", None, None),
2778 "an unknown session is a no-op"
2779 );
2780 assert!(
2781 !rx.has_changed().unwrap(),
2782 "ending an unknown session must not bump"
2783 );
2784
2785 assert!(reg.end("s1", None, None));
2786 assert!(rx.has_changed().unwrap(), "a real end should bump");
2787 rx.borrow_and_update();
2788 }
2789
2790 fn observe_from(session_id: &str, event: SessionEvent, pid: u32) -> ObserveRequest {
2792 ObserveRequest {
2793 agent_id: None,
2794 pid: Some(pid),
2795 ..observe_request(session_id, event, None)
2796 }
2797 }
2798
2799 #[test]
2800 fn a_late_end_from_the_replaced_process_keeps_a_resumed_session_live() {
2801 let reg = SessionsRegistry::new();
2802 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
2805 reg.observe(observe_from("s1", SessionEvent::Stop, 100));
2806 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
2807 let rx = reg.subscribe_changes();
2808
2809 assert!(reg.end("s1", Some("other"), Some(100)));
2811 assert!(!rx.has_changed().unwrap(), "an ignored end must not bump");
2812 let sessions = reg.list();
2813 assert_eq!(sessions.len(), 1);
2814 assert_eq!(sessions[0].state, SessionState::Starting);
2815
2816 assert!(reg.end("s1", Some("exit"), Some(200)));
2818 assert_eq!(reg.list()[0].state, SessionState::Ended);
2819 }
2820
2821 #[test]
2822 fn a_straggling_hook_from_the_replaced_process_does_not_take_the_session_back() {
2823 let reg = SessionsRegistry::new();
2824 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
2825 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
2826 reg.observe(observe_from("s1", SessionEvent::PostToolUse, 100));
2830 assert_eq!(reg.list()[0].state, SessionState::Starting);
2831 reg.end("s1", None, Some(100));
2832 assert_ne!(reg.list()[0].state, SessionState::Ended);
2833 }
2834
2835 #[test]
2836 fn a_straggling_non_end_hook_from_the_replaced_process_does_not_change_state() {
2837 let reg = SessionsRegistry::new();
2838 reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
2839 assert_eq!(reg.list()[0].state, SessionState::Working);
2840 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
2842 assert_eq!(reg.list()[0].state, SessionState::Starting);
2843 let rx = reg.subscribe_changes();
2844
2845 reg.observe(observe_from("s1", SessionEvent::Stop, 100));
2849 assert_eq!(reg.list()[0].state, SessionState::Starting);
2850 assert!(
2851 !rx.has_changed().unwrap(),
2852 "an ignored sighting must not bump"
2853 );
2854 }
2855
2856 #[test]
2857 fn an_end_not_from_the_replaced_process_still_ends_the_session() {
2858 for end_pid in [None, Some(200), Some(999)] {
2862 let reg = SessionsRegistry::new();
2863 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
2864 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
2865 assert!(reg.end("s1", None, end_pid));
2866 assert_eq!(
2867 reg.list()[0].state,
2868 SessionState::Ended,
2869 "end with pid {end_pid:?}"
2870 );
2871 }
2872 let reg = SessionsRegistry::new();
2874 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
2875 assert!(reg.end("s1", None, Some(100)));
2876 assert_eq!(reg.list()[0].state, SessionState::Ended);
2877 }
2878
2879 #[test]
2880 fn every_process_of_a_reload_burst_is_remembered_as_replaced() {
2881 let reg = SessionsRegistry::new();
2882 for pid in [100, 200, 300] {
2883 reg.observe(observe_from("s1", SessionEvent::SessionStart, pid));
2884 }
2885 assert!(reg.end("s1", None, Some(100)));
2887 assert!(reg.end("s1", None, Some(200)));
2888 assert_eq!(reg.list()[0].state, SessionState::Starting);
2889 }
2890
2891 #[test]
2892 fn a_stream_report_from_a_new_process_takes_the_session_over() {
2893 let idle = || SessionEvent::StreamState(SessionState::Idle);
2896 let reg = SessionsRegistry::new();
2897 reg.observe(observe_from("s1", idle(), 100));
2898 reg.observe(observe_from("s1", idle(), 200));
2899 reg.end("s1", None, Some(100));
2900 assert_eq!(reg.list()[0].state, SessionState::Idle);
2901 }
2902
2903 #[test]
2904 fn replaced_pids_are_capped_oldest_first() {
2905 let reg = SessionsRegistry::new();
2906 let last = u32::try_from(MAX_REPLACED_PIDS).unwrap() + 1;
2907 for pid in 0..=last {
2908 reg.observe(observe_from("s1", SessionEvent::PostToolUse, pid));
2909 }
2910 let guard = reg.lock_sessions();
2911 let replaced = &guard["s1"].replaced_pids;
2912 assert_eq!(replaced.len(), MAX_REPLACED_PIDS);
2913 assert_eq!(replaced.front(), Some(&1), "pid 0 was forgotten first");
2914 assert_eq!(replaced.back(), Some(&(last - 1)));
2915 }
2916
2917 #[test]
2918 fn a_sessions_first_pid_sighting_is_not_a_replacement() {
2919 let reg = SessionsRegistry::new();
2920 reg.observe(observe_request(
2923 "s1",
2924 SessionEvent::TranscriptDiscovered,
2925 None,
2926 ));
2927 reg.observe(observe_from("s1", SessionEvent::PostToolUse, 100));
2928 let guard = reg.lock_sessions();
2929 assert_eq!(guard["s1"].pid, Some(100));
2930 assert!(guard["s1"].replaced_pids.is_empty());
2931 }
2932
2933 #[test]
2934 fn end_then_start_resume_ordering_is_unchanged() {
2935 let reg = SessionsRegistry::new();
2937 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
2938 assert!(reg.end("s1", None, Some(100)));
2939 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
2940 assert_eq!(reg.list()[0].state, SessionState::Starting);
2941 }
2942
2943 #[test]
2944 fn window_report_bumps_only_when_it_changes_the_source_join() {
2945 let reg = SessionsRegistry::new();
2946 reg.report_window(window_report("w1", "/p", true));
2947 let mut rx = reg.subscribe_changes();
2948
2949 reg.report_window(window_report("w1", "/p", true));
2951 assert!(
2952 !rx.has_changed().unwrap(),
2953 "an unchanged window refresh must not bump"
2954 );
2955
2956 reg.report_window(window_report("w1", "/p", false));
2958 assert!(
2959 rx.has_changed().unwrap(),
2960 "an embedding that vanished should bump"
2961 );
2962 rx.borrow_and_update();
2963
2964 reg.report_window(window_report("w1", "/q", false));
2966 assert!(rx.has_changed().unwrap(), "changed folders should bump");
2967 rx.borrow_and_update();
2968
2969 reg.report_window(window_report("w2", "/r", true));
2971 assert!(rx.has_changed().unwrap(), "a new window should bump");
2972 }
2973
2974 #[test]
2975 fn unregister_window_bumps_only_when_it_removes() {
2976 let reg = SessionsRegistry::new();
2977 reg.report_window(window_report("w1", "/p", true));
2978 let rx = reg.subscribe_changes();
2979
2980 assert!(!reg.unregister_window("ghost"));
2981 assert!(
2982 !rx.has_changed().unwrap(),
2983 "a no-op unregister must not bump"
2984 );
2985
2986 assert!(reg.unregister_window("w1"));
2987 assert!(
2988 rx.has_changed().unwrap(),
2989 "a removing unregister should bump"
2990 );
2991 }
2992
2993 #[tokio::test]
2994 async fn a_burst_of_bumps_coalesces_into_one_wakeup() {
2995 let reg = SessionsRegistry::new();
2996 let mut rx = reg.subscribe_changes();
2997 reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
2999 reg.observe(observe_request("s2", SessionEvent::SessionStart, None));
3000 reg.observe(observe_request("s3", SessionEvent::SessionStart, None));
3001 rx.changed().await.unwrap();
3003 assert!(
3005 !rx.has_changed().unwrap(),
3006 "a burst should collapse into a single wakeup"
3007 );
3008 }
3009
3010 #[test]
3011 fn list_does_not_bump_even_when_it_reaps() {
3012 let reg = SessionsRegistry::new();
3016 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3017 reg.clock.advance(Duration::from_secs(600));
3019 let rx = reg.subscribe_changes();
3020 assert!(reg.list().is_empty(), "the stale session should be reaped");
3021 assert!(!rx.has_changed().unwrap(), "`list` must never bump");
3022 }
3023
3024 fn just_inside_the_ttl() -> Duration {
3028 DEFAULT_SESSION_TTL
3029 .checked_sub(Duration::from_secs(1))
3030 .unwrap()
3031 }
3032
3033 fn jump_wall_clock_forward(reg: &SessionsRegistry, by: chrono::Duration) {
3036 for entry in reg.lock_sessions().values_mut() {
3037 entry.last_seen -= by;
3038 }
3039 }
3040
3041 #[test]
3042 fn a_wall_clock_jump_with_no_awake_time_reaps_nothing() {
3043 let reg = SessionsRegistry::new();
3046 reg.observe(observe_request("working", SessionEvent::PreToolUse, None));
3047 reg.observe(observe_request("idle", SessionEvent::Stop, None));
3048 reg.observe(observe_request("ended", SessionEvent::PreToolUse, None));
3049 reg.end("ended", None, None);
3050 reg.report_window(window_report("w1", "/p", true));
3051
3052 jump_wall_clock_forward(®, chrono::Duration::days(3));
3053
3054 let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
3055 assert_eq!(
3056 ids,
3057 vec![
3058 "ended".to_string(),
3059 "idle".to_string(),
3060 "working".to_string()
3061 ],
3062 "sleeping must not age a session, not even an ended one"
3063 );
3064 assert_eq!(reg.lock_windows().len(), 1, "nor a window report");
3065 assert!(reg.list()[0].last_seen < Utc::now() - chrono::Duration::days(2));
3067 }
3068
3069 #[test]
3070 fn awake_time_past_the_ttl_still_reaps() {
3071 let reg = SessionsRegistry::new();
3072 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3073 reg.report_window(window_report("w1", "/p", true));
3074 reg.clock.advance(just_inside_the_ttl());
3075 assert_eq!(reg.list().len(), 1, "inside the TTL it survives");
3076 assert_eq!(reg.lock_windows().len(), 0, "the window TTL is shorter");
3077 reg.clock.advance(Duration::from_secs(2));
3078 assert!(
3079 reg.list().is_empty(),
3080 "past the TTL of awake silence it reaps"
3081 );
3082 }
3083
3084 #[test]
3085 fn activity_after_a_sleep_restarts_the_awake_ttl() {
3086 let reg = SessionsRegistry::new();
3087 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3088 reg.clock.advance(just_inside_the_ttl());
3089 jump_wall_clock_forward(®, chrono::Duration::hours(8));
3090 reg.observe(observe_request("s1", SessionEvent::PostToolUse, None));
3092 reg.clock.advance(just_inside_the_ttl());
3093 assert_eq!(reg.list().len(), 1);
3094 }
3095
3096 fn at(ts: &str) -> DateTime<Utc> {
3099 ts.parse().unwrap()
3100 }
3101
3102 fn stamp(ts: &str, seq: &str) -> EventStamp {
3103 EventStamp {
3104 ts: at(ts),
3105 seq: seq.to_string(),
3106 }
3107 }
3108
3109 fn state_of(reg: &SessionsRegistry, id: &str) -> SessionState {
3110 reg.lock_sessions()[id].state
3111 }
3112
3113 #[test]
3114 fn the_second_copy_of_a_stamped_event_is_dropped_whatever_its_route() {
3115 let reg = SessionsRegistry::new();
3116 let work = stamp("2026-10-03T03:40:00Z", "1-a");
3117 let stop = stamp("2026-10-03T03:40:05Z", "1-b");
3118 reg.observe_stamped(
3119 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3120 Some(work.clone()),
3121 Origin::Socket,
3122 );
3123 reg.observe_stamped(
3124 observe_request("s", SessionEvent::Stop, None),
3125 Some(stop),
3126 Origin::Socket,
3127 );
3128 assert_eq!(state_of(®, "s"), SessionState::Idle);
3129 for origin in [Origin::Journal, Origin::Socket] {
3132 reg.observe_stamped(
3133 observe_request("s", SessionEvent::PreToolUse, None),
3134 Some(work.clone()),
3135 origin,
3136 );
3137 assert_eq!(state_of(®, "s"), SessionState::Idle, "{origin:?}");
3138 }
3139 }
3140
3141 #[test]
3142 fn a_journal_event_older_than_one_already_applied_is_stale() {
3143 let reg = SessionsRegistry::new();
3144 reg.observe_stamped(
3145 observe_request("s", SessionEvent::Stop, Some("/p")),
3146 Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3147 Origin::Socket,
3148 );
3149 reg.observe_stamped(
3151 observe_request("s", SessionEvent::PreToolUse, None),
3152 Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3153 Origin::Journal,
3154 );
3155 assert_eq!(state_of(®, "s"), SessionState::Idle);
3156 reg.observe_stamped(
3158 observe_request("s", SessionEvent::PreToolUse, None),
3159 Some(stamp("2026-10-03T03:41:00Z", "1-c")),
3160 Origin::Journal,
3161 );
3162 assert_eq!(state_of(®, "s"), SessionState::Working);
3163 }
3164
3165 #[test]
3166 fn a_socket_event_overtaken_by_a_newer_one_is_dropped_but_never_for_a_clock_step() {
3167 let reg = SessionsRegistry::new();
3171 reg.observe_stamped(
3172 observe_request("s", SessionEvent::Stop, Some("/p")),
3173 Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3174 Origin::Journal,
3175 );
3176 reg.observe_stamped(
3177 observe_request("s", SessionEvent::PreToolUse, None),
3178 Some(stamp("2026-10-03T03:40:03Z", "1-a")),
3179 Origin::Socket,
3180 );
3181 assert_eq!(state_of(®, "s"), SessionState::Idle);
3182 reg.observe_stamped(
3185 observe_request("s", SessionEvent::PreToolUse, None),
3186 Some(stamp("2026-10-03T03:30:00Z", "1-c")),
3187 Origin::Socket,
3188 );
3189 assert_eq!(state_of(®, "s"), SessionState::Working);
3190 }
3191
3192 #[test]
3193 fn unstamped_events_are_never_skipped() {
3194 let reg = SessionsRegistry::new();
3195 reg.observe(observe_request("s", SessionEvent::Stop, Some("/p")));
3196 reg.observe(observe_request("s", SessionEvent::PreToolUse, None));
3197 assert_eq!(state_of(®, "s"), SessionState::Working);
3198 reg.observe(observe_request("s", SessionEvent::Stop, None));
3199 assert_eq!(state_of(®, "s"), SessionState::Idle);
3200 }
3201
3202 #[test]
3203 fn a_journal_end_older_than_a_resume_does_not_end_the_resumed_session() {
3204 let reg = SessionsRegistry::new();
3205 reg.observe_stamped(
3206 observe_request("s", SessionEvent::SessionStart, Some("/p")),
3207 Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3208 Origin::Socket,
3209 );
3210 reg.observe_stamped(
3213 observe_request("s", SessionEvent::SessionStart, None),
3214 Some(stamp("2026-10-03T03:42:00Z", "1-c")),
3215 Origin::Socket,
3216 );
3217 let ended = reg.end_stamped(
3218 "s",
3219 Some("clear"),
3220 None,
3221 Some(stamp("2026-10-03T03:41:00Z", "1-b")),
3222 Origin::Journal,
3223 );
3224 assert!(ended, "the session is known");
3225 assert_eq!(state_of(®, "s"), SessionState::Starting);
3226 reg.end_stamped(
3228 "s",
3229 None,
3230 None,
3231 Some(stamp("2026-10-03T03:43:00Z", "1-d")),
3232 Origin::Journal,
3233 );
3234 assert_eq!(state_of(®, "s"), SessionState::Ended);
3235 let ended_at = reg.lock_sessions()["s"].last_seen;
3237 reg.end_stamped(
3238 "s",
3239 None,
3240 None,
3241 Some(stamp("2026-10-03T03:43:00Z", "1-d")),
3242 Origin::Socket,
3243 );
3244 assert_eq!(reg.lock_sessions()["s"].last_seen, ended_at);
3245 }
3246
3247 #[test]
3248 fn a_journal_event_keeps_its_own_timestamp_but_not_its_own_age() {
3249 let reg = SessionsRegistry::new();
3250 let long_ago = Utc::now() - chrono::Duration::hours(2);
3251 let st = EventStamp {
3252 ts: long_ago,
3253 seq: "1-a".to_string(),
3254 };
3255 reg.observe_stamped(
3256 observe_request("s", SessionEvent::UserPromptSubmit, Some("/p")),
3257 Some(st),
3258 Origin::Journal,
3259 );
3260 let listed = reg.list();
3261 assert_eq!(listed.len(), 1, "replay is not reaped on arrival");
3262 assert_eq!(listed[0].last_seen, long_ago, "history must not look fresh");
3263 assert_eq!(listed[0].started_at, long_ago);
3264 reg.clock.advance(just_inside_the_ttl());
3266 assert_eq!(reg.list().len(), 1);
3267 reg.clock.advance(Duration::from_secs(2));
3268 assert!(reg.list().is_empty());
3269 }
3270
3271 #[test]
3272 fn a_journal_timestamp_in_the_future_is_clamped_to_now() {
3273 let reg = SessionsRegistry::new();
3274 let future = Utc::now() + chrono::Duration::hours(1);
3275 reg.observe_stamped(
3276 observe_request("s", SessionEvent::Stop, Some("/p")),
3277 Some(EventStamp {
3278 ts: future,
3279 seq: "1-a".to_string(),
3280 }),
3281 Origin::Journal,
3282 );
3283 assert!(reg.list()[0].last_seen <= Utc::now());
3284 }
3285
3286 #[test]
3287 fn only_the_newest_seqs_are_remembered() {
3288 let reg = SessionsRegistry::new();
3289 for i in 0..=MAX_RECENT_SEQS {
3290 reg.observe_stamped(
3291 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3292 Some(stamp("2026-10-03T03:40:00Z", &format!("1-{i}"))),
3293 Origin::Socket,
3294 );
3295 }
3296 let guard = reg.lock_sessions();
3297 assert_eq!(guard["s"].recent_seqs.len(), MAX_RECENT_SEQS);
3298 assert!(!guard["s"].recent_seqs.contains(&"1-0".to_string()));
3299 assert!(guard["s"]
3300 .recent_seqs
3301 .contains(&format!("1-{MAX_RECENT_SEQS}")));
3302 }
3303
3304 #[test]
3305 fn a_duplicate_does_not_bump_and_does_not_refresh_liveness() {
3306 let reg = SessionsRegistry::new();
3307 let st = stamp("2026-10-03T03:40:00Z", "1-a");
3308 reg.observe_stamped(
3309 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3310 Some(st.clone()),
3311 Origin::Socket,
3312 );
3313 let active = reg.lock_sessions()["s"].last_active;
3314 let rx = reg.subscribe_changes();
3315 reg.clock.advance(Duration::from_secs(10));
3316 reg.observe_stamped(
3317 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3318 Some(st),
3319 Origin::Journal,
3320 );
3321 assert!(!rx.has_changed().unwrap());
3322 assert_eq!(reg.lock_sessions()["s"].last_active, active);
3323 }
3324}