Skip to main content

atm_core/
session.rs

1//! Session domain entities and value objects.
2
3use crate::hook::is_interactive_tool;
4use crate::{AgentType, ContextUsage, HookEventType, Model, Money, TokenCount};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12// ============================================================================
13// Type-Safe Identifiers
14// ============================================================================
15
16/// Unique identifier for a Claude Code session.
17///
18/// Wraps a UUID string (e.g., "8e11bfb5-7dc2-432b-9206-928fa5c35731").
19/// Obtained from Claude Code's status line JSON `session_id` field.
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct SessionId(String);
23
24/// Prefix used for pending session IDs (sessions discovered before their real ID is known).
25pub const PENDING_SESSION_PREFIX: &str = "pending-";
26
27impl SessionId {
28    /// Creates a new SessionId from a string.
29    ///
30    /// Note: This does not validate UUID format. Claude Code provides
31    /// the session_id, so we trust its format.
32    pub fn new(id: impl Into<String>) -> Self {
33        Self(id.into())
34    }
35
36    /// Creates a pending session ID from a process ID.
37    ///
38    /// Used when a Claude process is discovered but no transcript exists yet
39    /// (e.g., session just started, no conversation has occurred).
40    /// The pending session will be upgraded to the real session ID when
41    /// it arrives via hook event or status line.
42    pub fn pending_from_pid(pid: u32) -> Self {
43        Self(format!("{PENDING_SESSION_PREFIX}{pid}"))
44    }
45
46    /// Checks if this is a pending session ID (not yet associated with real session).
47    pub fn is_pending(&self) -> bool {
48        self.0.starts_with(PENDING_SESSION_PREFIX)
49    }
50
51    /// Extracts the PID from a pending session ID.
52    ///
53    /// Returns `None` if this is not a pending session ID or the PID cannot be parsed.
54    pub fn pending_pid(&self) -> Option<u32> {
55        if !self.is_pending() {
56            return None;
57        }
58        self.0
59            .strip_prefix(PENDING_SESSION_PREFIX)
60            .and_then(|s| s.parse().ok())
61    }
62
63    /// Returns the underlying string reference.
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67
68    /// Returns a shortened display form (first 8 characters).
69    ///
70    /// Useful for compact TUI display.
71    pub fn short(&self) -> &str {
72        self.0.get(..8).unwrap_or(&self.0)
73    }
74}
75
76impl fmt::Display for SessionId {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "{}", self.0)
79    }
80}
81
82impl From<String> for SessionId {
83    fn from(s: String) -> Self {
84        Self(s)
85    }
86}
87
88impl From<&str> for SessionId {
89    fn from(s: &str) -> Self {
90        Self(s.to_string())
91    }
92}
93
94impl AsRef<str> for SessionId {
95    fn as_ref(&self) -> &str {
96        &self.0
97    }
98}
99
100/// Unique identifier for a tool invocation.
101///
102/// Format: "toolu_..." (e.g., "toolu_01ABC123XYZ")
103/// Provided by Claude Code in hook events.
104#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(transparent)]
106pub struct ToolUseId(String);
107
108impl ToolUseId {
109    pub fn new(id: impl Into<String>) -> Self {
110        Self(id.into())
111    }
112
113    pub fn as_str(&self) -> &str {
114        &self.0
115    }
116}
117
118impl fmt::Display for ToolUseId {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(f, "{}", self.0)
121    }
122}
123
124impl From<String> for ToolUseId {
125    fn from(s: String) -> Self {
126        Self(s)
127    }
128}
129
130/// Path to a session's transcript JSONL file.
131///
132/// Example: "/home/user/.claude/projects/.../session.jsonl"
133#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
134#[serde(transparent)]
135pub struct TranscriptPath(PathBuf);
136
137impl TranscriptPath {
138    pub fn new(path: impl Into<PathBuf>) -> Self {
139        Self(path.into())
140    }
141
142    pub fn as_path(&self) -> &Path {
143        &self.0
144    }
145
146    /// Returns the filename portion of the path.
147    pub fn filename(&self) -> Option<&str> {
148        self.0.file_name().and_then(|n| n.to_str())
149    }
150}
151
152impl fmt::Display for TranscriptPath {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        write!(f, "{}", self.0.display())
155    }
156}
157
158impl AsRef<Path> for TranscriptPath {
159    fn as_ref(&self) -> &Path {
160        &self.0
161    }
162}
163
164// ============================================================================
165// Session Status
166// ============================================================================
167
168/// Current operational status of a session.
169///
170/// Derived from session activity patterns and hook events.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(tag = "status", rename_all = "snake_case")]
173pub enum SessionStatus {
174    /// Session is actively processing (received update within 5 seconds)
175    Active,
176
177    /// Agent is thinking/generating (between tool calls)
178    Thinking,
179
180    /// Agent is executing a tool
181    RunningTool {
182        /// Name of the tool being executed
183        tool_name: String,
184        /// When tool execution started
185        #[serde(skip_serializing_if = "Option::is_none")]
186        started_at: Option<DateTime<Utc>>,
187    },
188
189    /// Agent is waiting for user permission to execute a tool
190    WaitingForPermission {
191        /// Tool awaiting permission
192        tool_name: String,
193    },
194
195    /// Session is idle (no activity for extended period)
196    Idle,
197
198    /// Session is stale (no activity for >8 hours, pending cleanup)
199    Stale,
200}
201
202impl SessionStatus {
203    /// Returns true if the session is in an active state.
204    pub fn is_active(&self) -> bool {
205        matches!(
206            self,
207            Self::Active | Self::Thinking | Self::RunningTool { .. }
208        )
209    }
210
211    /// Returns true if the session is waiting for user input.
212    pub fn needs_attention(&self) -> bool {
213        matches!(self, Self::WaitingForPermission { .. })
214    }
215
216    /// Returns true if the session may be cleaned up.
217    pub fn is_removable(&self) -> bool {
218        matches!(self, Self::Stale)
219    }
220
221    /// Returns a short status label for display.
222    pub fn label(&self) -> &str {
223        match self {
224            Self::Active => "active",
225            Self::Thinking => "thinking",
226            Self::RunningTool { .. } => "running",
227            Self::WaitingForPermission { .. } => "waiting",
228            Self::Idle => "idle",
229            Self::Stale => "stale",
230        }
231    }
232
233    /// Returns the tool name if applicable.
234    pub fn tool_name(&self) -> Option<&str> {
235        match self {
236            Self::RunningTool { tool_name, .. } => Some(tool_name.as_str()),
237            Self::WaitingForPermission { tool_name } => Some(tool_name.as_str()),
238            _ => None,
239        }
240    }
241}
242
243impl Default for SessionStatus {
244    fn default() -> Self {
245        Self::Active
246    }
247}
248
249impl fmt::Display for SessionStatus {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        match self {
252            Self::Active => write!(f, "Active"),
253            Self::Thinking => write!(f, "Thinking..."),
254            Self::RunningTool { tool_name, .. } => write!(f, "Running: {tool_name}"),
255            Self::WaitingForPermission { tool_name } => {
256                write!(f, "Permission: {tool_name}")
257            }
258            Self::Idle => write!(f, "Idle"),
259            Self::Stale => write!(f, "Stale"),
260        }
261    }
262}
263
264// ============================================================================
265// Display State (UI Layer)
266// ============================================================================
267
268/// Visual display state for a session in the TUI.
269///
270/// Represents the simplified user-facing state of a session, derived from
271/// the underlying `SessionStatus` and activity timing. This provides clear
272/// visual feedback about whether the user needs to take action.
273///
274/// ## State Definitions
275///
276/// - **Working**: Claude is actively processing (recent activity within 5s)
277/// - **Compacting**: Working after significant context reduction
278/// - **NeedsInput**: User's turn - either permission needed or awaiting next message
279/// - **Stale**: No activity for extended period (>8 hours)
280///
281/// ## Key Insight
282///
283/// If Claude isn't actively generating or running a tool with recent activity,
284/// the session needs user input. Tool execution without updates likely means
285/// Claude is waiting for user confirmation ("Do you want to proceed?").
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
287#[serde(rename_all = "snake_case")]
288pub enum DisplayState {
289    /// Actively processing with recent activity
290    #[default]
291    Working,
292
293    /// Working after context reduction (compaction detected)
294    Compacting,
295
296    /// Waiting for user input or permission (blocked, urgent)
297    NeedsInput,
298
299    /// Idle - no recent activity but not stale (relaxed, non-urgent)
300    Idle,
301
302    /// No activity for extended period (>8 hours)
303    Stale,
304}
305
306impl DisplayState {
307    /// Activity threshold in seconds - below this is considered "recent".
308    const ACTIVITY_THRESHOLD_SECS: i64 = 5;
309
310    /// Stale threshold in seconds (8 hours) - matches SessionDomain::is_stale().
311    const STALE_THRESHOLD_SECS: i64 = 8 * 3600;
312
313    /// Compaction detection: previous context must have been at least this high.
314    const COMPACTION_HIGH_THRESHOLD: f64 = 70.0;
315
316    /// Compaction detection: context must drop by at least this much.
317    const COMPACTION_DROP_THRESHOLD: f64 = 20.0;
318
319    /// Returns the display label for this state.
320    pub fn label(&self) -> &'static str {
321        match self {
322            Self::Working => "working",
323            Self::Compacting => "compacting",
324            Self::NeedsInput => "needs input",
325            Self::Idle => "idle",
326            Self::Stale => "stale",
327        }
328    }
329
330    /// Returns the ASCII icon for this state.
331    ///
332    /// Uses ASCII characters for terminal compatibility:
333    /// - `>` Working (active indicator)
334    /// - `~` Compacting (context shrinking)
335    /// - `!` NeedsInput (attention needed)
336    /// - `-` Idle (relaxed, waiting)
337    /// - `z` Stale (sleeping)
338    pub fn icon(&self) -> &'static str {
339        match self {
340            Self::Working => ">",
341            Self::Compacting => "~",
342            Self::NeedsInput => "!",
343            Self::Idle => "-",
344            Self::Stale => "z",
345        }
346    }
347
348    /// Returns a description of this state.
349    pub fn description(&self) -> &'static str {
350        match self {
351            Self::Working => "Session is actively processing",
352            Self::Compacting => "Working after context reduction",
353            Self::NeedsInput => "Waiting for user input or permission",
354            Self::Idle => "Session idle, awaiting user prompt",
355            Self::Stale => "No activity for extended period",
356        }
357    }
358
359    /// Returns true if this state should blink in the UI.
360    ///
361    /// Only truly urgent/blocked states blink:
362    /// - NeedsInput: Blocked, waiting for permission (urgent)
363    ///
364    /// States that do NOT blink:
365    /// - Idle: Relaxed "waiting for user" state
366    /// - Stale: Old session, low priority, doesn't need attention
367    pub fn should_blink(&self) -> bool {
368        matches!(self, Self::NeedsInput)
369    }
370
371    /// Determines display state from session data.
372    ///
373    /// # Algorithm
374    ///
375    /// 1. Stale check: >8 hours idle → Stale
376    /// 2. Permission check: waiting for permission → NeedsInput (urgent, blocked)
377    /// 3. Explicit Thinking/RunningTool status → Working (trust these regardless of activity)
378    /// 4. Generic Active status with recent activity (<5s) → Working/Compacting
379    /// 5. Generic Active status without recent activity → Idle (relaxed, non-urgent)
380    /// 6. Otherwise → Idle
381    ///
382    /// # Arguments
383    ///
384    /// * `time_since_activity_secs` - Seconds since last activity
385    /// * `status` - Current session status
386    /// * `context_percentage` - Current context usage percentage
387    /// * `previous_context_percentage` - Previous context percentage for compaction detection
388    pub fn from_session(
389        time_since_activity_secs: i64,
390        status: &SessionStatus,
391        context_percentage: f64,
392        previous_context_percentage: Option<f64>,
393    ) -> Self {
394        // Priority 1: Stale (no activity for 8+ hours OR explicit Stale status)
395        if time_since_activity_secs > Self::STALE_THRESHOLD_SECS {
396            return Self::Stale;
397        }
398
399        // Priority 2: Explicit Idle/Stale status from domain
400        // (handles cases where status is set directly, e.g., during testing)
401        match status {
402            SessionStatus::Stale => return Self::Stale,
403            SessionStatus::Idle => return Self::Idle,
404            _ => {}
405        }
406
407        // Priority 3: Waiting for permission = needs input
408        if status.needs_attention() {
409            return Self::NeedsInput;
410        }
411
412        // Priority 4: Explicit Thinking/RunningTool status = Working
413        // Trust these statuses regardless of activity timeout - they indicate
414        // Claude is actively processing (either generating response or running tool)
415        match status {
416            SessionStatus::Thinking | SessionStatus::RunningTool { .. } => {
417                // Check for compaction even during thinking
418                if let Some(prev_pct) = previous_context_percentage {
419                    let dropped = prev_pct - context_percentage;
420                    if prev_pct >= Self::COMPACTION_HIGH_THRESHOLD
421                        && dropped >= Self::COMPACTION_DROP_THRESHOLD
422                    {
423                        return Self::Compacting;
424                    }
425                }
426                return Self::Working;
427            }
428            _ => {}
429        }
430
431        // Check if session has recent activity
432        let has_recent_activity = time_since_activity_secs < Self::ACTIVITY_THRESHOLD_SECS;
433
434        // Generic Active status without recent activity = idle, waiting for user
435        // (relaxed state, not urgent - user can take their time)
436        if !has_recent_activity {
437            return Self::Idle;
438        }
439
440        // Recent activity + active status = Working (or Compacting)
441        if status.is_active() {
442            // Check for compaction: context was high and dropped significantly
443            if let Some(prev_pct) = previous_context_percentage {
444                let dropped = prev_pct - context_percentage;
445                if prev_pct >= Self::COMPACTION_HIGH_THRESHOLD
446                    && dropped >= Self::COMPACTION_DROP_THRESHOLD
447                {
448                    return Self::Compacting;
449                }
450            }
451            return Self::Working;
452        }
453
454        // Default: idle (waiting for user)
455        Self::Idle
456    }
457}
458
459impl fmt::Display for DisplayState {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        write!(f, "{}", self.label())
462    }
463}
464
465// ============================================================================
466// Value Objects
467// ============================================================================
468
469/// Duration tracking for a session.
470///
471/// Based on Claude Code status line `cost.total_duration_ms`.
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
473pub struct SessionDuration {
474    /// Total duration in milliseconds
475    total_ms: u64,
476    /// API call duration in milliseconds (time spent waiting for Claude)
477    api_ms: u64,
478}
479
480impl SessionDuration {
481    /// Creates a new SessionDuration.
482    pub fn new(total_ms: u64, api_ms: u64) -> Self {
483        Self { total_ms, api_ms }
484    }
485
486    /// Creates from total duration only.
487    pub fn from_total_ms(total_ms: u64) -> Self {
488        Self { total_ms, api_ms: 0 }
489    }
490
491    /// Returns total duration in milliseconds.
492    pub fn total_ms(&self) -> u64 {
493        self.total_ms
494    }
495
496    /// Returns API duration in milliseconds.
497    pub fn api_ms(&self) -> u64 {
498        self.api_ms
499    }
500
501    /// Returns total duration as seconds (float).
502    pub fn total_seconds(&self) -> f64 {
503        self.total_ms as f64 / 1000.0
504    }
505
506    /// Returns the overhead time (total - API).
507    pub fn overhead_ms(&self) -> u64 {
508        self.total_ms.saturating_sub(self.api_ms)
509    }
510
511    /// Formats duration for display.
512    ///
513    /// Returns format like "35s", "2m 15s", "1h 30m"
514    pub fn format(&self) -> String {
515        let secs = self.total_ms / 1000;
516        if secs < 60 {
517            format!("{secs}s")
518        } else if secs < 3600 {
519            let mins = secs / 60;
520            let remaining_secs = secs % 60;
521            if remaining_secs == 0 {
522                format!("{mins}m")
523            } else {
524                format!("{mins}m {remaining_secs}s")
525            }
526        } else {
527            let hours = secs / 3600;
528            let remaining_mins = (secs % 3600) / 60;
529            if remaining_mins == 0 {
530                format!("{hours}h")
531            } else {
532                format!("{hours}h {remaining_mins}m")
533            }
534        }
535    }
536
537    /// Formats duration compactly.
538    pub fn format_compact(&self) -> String {
539        let secs = self.total_ms / 1000;
540        if secs < 60 {
541            format!("{secs}s")
542        } else if secs < 3600 {
543            let mins = secs / 60;
544            format!("{mins}m")
545        } else {
546            let hours = secs / 3600;
547            format!("{hours}h")
548        }
549    }
550}
551
552impl fmt::Display for SessionDuration {
553    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
554        write!(f, "{}", self.format())
555    }
556}
557
558/// Tracks lines added and removed in a session.
559///
560/// Based on Claude Code status line `cost.total_lines_added/removed`.
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
562pub struct LinesChanged {
563    /// Lines added
564    pub added: u64,
565    /// Lines removed
566    pub removed: u64,
567}
568
569impl LinesChanged {
570    /// Creates new LinesChanged.
571    pub fn new(added: u64, removed: u64) -> Self {
572        Self { added, removed }
573    }
574
575    /// Returns net change (added - removed).
576    pub fn net(&self) -> i64 {
577        self.added as i64 - self.removed as i64
578    }
579
580    /// Returns total churn (added + removed).
581    pub fn churn(&self) -> u64 {
582        self.added.saturating_add(self.removed)
583    }
584
585    /// Returns true if no changes have been made.
586    pub fn is_empty(&self) -> bool {
587        self.added == 0 && self.removed == 0
588    }
589
590    /// Formats for display (e.g., "+150 -30").
591    pub fn format(&self) -> String {
592        format!("+{} -{}", self.added, self.removed)
593    }
594
595    /// Formats net change with sign.
596    pub fn format_net(&self) -> String {
597        let net = self.net();
598        if net >= 0 {
599            format!("+{net}")
600        } else {
601            format!("{net}")
602        }
603    }
604}
605
606impl fmt::Display for LinesChanged {
607    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
608        write!(f, "{}", self.format())
609    }
610}
611
612// ============================================================================
613// Domain Entity
614// ============================================================================
615
616/// Core domain model for a Claude Code session.
617///
618/// Contains pure business logic and state. Does NOT include
619/// infrastructure concerns (PIDs, sockets, file paths).
620///
621/// Consistent with CONCURRENCY_MODEL.md RegistryActor ownership.
622#[derive(Debug, Clone, Serialize, Deserialize)]
623pub struct SessionDomain {
624    /// Unique session identifier
625    pub id: SessionId,
626
627    /// Type of agent (main, subagent, etc.)
628    pub agent_type: AgentType,
629
630    /// Claude model being used
631    pub model: Model,
632
633    /// Current session status
634    pub status: SessionStatus,
635
636    /// Context window usage
637    pub context: ContextUsage,
638
639    /// Accumulated cost
640    pub cost: Money,
641
642    /// Session duration tracking
643    pub duration: SessionDuration,
644
645    /// Lines of code changed
646    pub lines_changed: LinesChanged,
647
648    /// When the session started
649    pub started_at: DateTime<Utc>,
650
651    /// Last activity timestamp
652    pub last_activity: DateTime<Utc>,
653
654    /// Working directory (project root)
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub working_directory: Option<String>,
657
658    /// Claude Code version
659    #[serde(skip_serializing_if = "Option::is_none")]
660    pub claude_code_version: Option<String>,
661
662    /// Tmux pane ID (e.g., "%5") if session is running in tmux
663    #[serde(skip_serializing_if = "Option::is_none")]
664    pub tmux_pane: Option<String>,
665}
666
667impl SessionDomain {
668    /// Creates a new SessionDomain with required fields.
669    pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
670        let now = Utc::now();
671        Self {
672            id,
673            agent_type,
674            model,
675            status: SessionStatus::Active,
676            context: ContextUsage::new(model.context_window_size()),
677            cost: Money::zero(),
678            duration: SessionDuration::default(),
679            lines_changed: LinesChanged::default(),
680            started_at: now,
681            last_activity: now,
682            working_directory: None,
683            claude_code_version: None,
684            tmux_pane: None,
685        }
686    }
687
688    /// Creates a SessionDomain from Claude Code status line data.
689    #[allow(clippy::too_many_arguments)]
690    pub fn from_status_line(
691        session_id: &str,
692        model_id: &str,
693        cost_usd: f64,
694        total_duration_ms: u64,
695        api_duration_ms: u64,
696        lines_added: u64,
697        lines_removed: u64,
698        total_input_tokens: u64,
699        total_output_tokens: u64,
700        context_window_size: u32,
701        current_input_tokens: u64,
702        current_output_tokens: u64,
703        cache_creation_tokens: u64,
704        cache_read_tokens: u64,
705        cwd: Option<&str>,
706        version: Option<&str>,
707    ) -> Self {
708        let model = Model::from_id(model_id);
709
710        let mut session = Self::new(
711            SessionId::new(session_id),
712            AgentType::GeneralPurpose, // Default, may be updated by hook events
713            model,
714        );
715
716        session.cost = Money::from_usd(cost_usd);
717        session.duration = SessionDuration::new(total_duration_ms, api_duration_ms);
718        session.lines_changed = LinesChanged::new(lines_added, lines_removed);
719        session.context = ContextUsage {
720            total_input_tokens: TokenCount::new(total_input_tokens),
721            total_output_tokens: TokenCount::new(total_output_tokens),
722            context_window_size,
723            current_input_tokens: TokenCount::new(current_input_tokens),
724            current_output_tokens: TokenCount::new(current_output_tokens),
725            cache_creation_tokens: TokenCount::new(cache_creation_tokens),
726            cache_read_tokens: TokenCount::new(cache_read_tokens),
727        };
728        session.working_directory = cwd.map(|s| s.to_string());
729        session.claude_code_version = version.map(|s| s.to_string());
730        session.last_activity = Utc::now();
731
732        session
733    }
734
735    /// Updates the session with new status line data.
736    ///
737    /// When `current_usage` is null in Claude's status line, all current_* values
738    /// will be 0, which correctly resets context percentage to 0%.
739    #[allow(clippy::too_many_arguments)]
740    pub fn update_from_status_line(
741        &mut self,
742        cost_usd: f64,
743        total_duration_ms: u64,
744        api_duration_ms: u64,
745        lines_added: u64,
746        lines_removed: u64,
747        total_input_tokens: u64,
748        total_output_tokens: u64,
749        current_input_tokens: u64,
750        current_output_tokens: u64,
751        cache_creation_tokens: u64,
752        cache_read_tokens: u64,
753    ) {
754        self.cost = Money::from_usd(cost_usd);
755        self.duration = SessionDuration::new(total_duration_ms, api_duration_ms);
756        self.lines_changed = LinesChanged::new(lines_added, lines_removed);
757        self.context.total_input_tokens = TokenCount::new(total_input_tokens);
758        self.context.total_output_tokens = TokenCount::new(total_output_tokens);
759        self.context.current_input_tokens = TokenCount::new(current_input_tokens);
760        self.context.current_output_tokens = TokenCount::new(current_output_tokens);
761        self.context.cache_creation_tokens = TokenCount::new(cache_creation_tokens);
762        self.context.cache_read_tokens = TokenCount::new(cache_read_tokens);
763        self.last_activity = Utc::now();
764
765        // Update status based on activity
766        if !matches!(self.status, SessionStatus::WaitingForPermission { .. }) {
767            self.status = SessionStatus::Active;
768        }
769    }
770
771    /// Updates status based on a hook event.
772    pub fn apply_hook_event(&mut self, event_type: HookEventType, tool_name: Option<&str>) {
773        self.last_activity = Utc::now();
774
775        match event_type {
776            HookEventType::PreToolUse => {
777                if let Some(name) = tool_name {
778                    // Check if this is an interactive tool that waits for user input
779                    if is_interactive_tool(name) {
780                        self.status = SessionStatus::WaitingForPermission {
781                            tool_name: name.to_string(),
782                        };
783                    } else {
784                        self.status = SessionStatus::RunningTool {
785                            tool_name: name.to_string(),
786                            started_at: Some(Utc::now()),
787                        };
788                    }
789                }
790            }
791            HookEventType::PostToolUse => {
792                self.status = SessionStatus::Thinking;
793            }
794            _ => {}
795        }
796    }
797
798    /// Marks the session as waiting for permission.
799    pub fn set_waiting_for_permission(&mut self, tool_name: &str) {
800        self.status = SessionStatus::WaitingForPermission {
801            tool_name: tool_name.to_string(),
802        };
803        self.last_activity = Utc::now();
804    }
805
806    /// Returns the session age (time since started).
807    pub fn age(&self) -> chrono::Duration {
808        Utc::now().signed_duration_since(self.started_at)
809    }
810
811    /// Returns time since last activity.
812    pub fn time_since_activity(&self) -> chrono::Duration {
813        Utc::now().signed_duration_since(self.last_activity)
814    }
815
816    /// Returns true if the session should be considered stale.
817    ///
818    /// A session is stale if no activity for 8 hours.
819    pub fn is_stale(&self) -> bool {
820        self.time_since_activity() > chrono::Duration::hours(8)
821    }
822
823    /// Returns true if context usage needs attention.
824    pub fn needs_context_attention(&self) -> bool {
825        self.context.is_warning() || self.context.is_critical()
826    }
827}
828
829impl Default for SessionDomain {
830    fn default() -> Self {
831        Self::new(
832            SessionId::new("unknown"),
833            AgentType::default(),
834            Model::default(),
835        )
836    }
837}
838
839// ============================================================================
840// Infrastructure Entity
841// ============================================================================
842
843/// Record of a tool invocation.
844#[derive(Debug, Clone)]
845pub struct ToolUsageRecord {
846    /// Name of the tool (e.g., "Bash", "Read", "Write")
847    pub tool_name: String,
848    /// Unique ID for this tool invocation
849    pub tool_use_id: Option<ToolUseId>,
850    /// When the tool was invoked
851    pub timestamp: DateTime<Utc>,
852}
853
854/// Infrastructure-level data for a session.
855///
856/// Contains OS/system concerns that don't belong in the domain model.
857/// Owned by RegistryActor alongside SessionDomain.
858#[derive(Debug, Clone)]
859pub struct SessionInfrastructure {
860    /// Process ID of the Claude Code process (if known)
861    pub pid: Option<u32>,
862
863    /// Process start time in clock ticks (from /proc/{pid}/stat field 22).
864    /// Used to detect PID reuse - if the start time changes, it's a different process.
865    pub process_start_time: Option<u64>,
866
867    /// Path to the Unix socket for this session (if applicable)
868    pub socket_path: Option<PathBuf>,
869
870    /// Path to the transcript JSONL file
871    pub transcript_path: Option<TranscriptPath>,
872
873    /// Recent tool usage history (bounded FIFO queue)
874    pub recent_tools: VecDeque<ToolUsageRecord>,
875
876    /// Number of status updates received
877    pub update_count: u64,
878
879    /// Number of hook events received
880    pub hook_event_count: u64,
881
882    /// Last error encountered (for debugging)
883    pub last_error: Option<String>,
884}
885
886impl SessionInfrastructure {
887    /// Maximum number of tool records to keep.
888    const MAX_TOOL_HISTORY: usize = 50;
889
890    /// Creates new SessionInfrastructure.
891    pub fn new() -> Self {
892        Self {
893            pid: None,
894            process_start_time: None,
895            socket_path: None,
896            transcript_path: None,
897            recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
898            update_count: 0,
899            hook_event_count: 0,
900            last_error: None,
901        }
902    }
903
904    /// Sets the process ID and captures the process start time for PID reuse detection.
905    ///
906    /// The start time is read from `/proc/{pid}/stat` field 22 (starttime in clock ticks).
907    /// If the PID is already set with the same value, this is a no-op.
908    ///
909    /// # Validation
910    ///
911    /// The PID is only stored if:
912    /// - It's non-zero (PID 0 is invalid)
913    /// - We can successfully read its start time from `/proc/{pid}/stat`
914    ///
915    /// This prevents storing invalid PIDs that would cause incorrect liveness checks.
916    pub fn set_pid(&mut self, pid: u32) {
917        // PID 0 is invalid
918        if pid == 0 {
919            return;
920        }
921
922        // Only update if PID changed or wasn't set
923        if self.pid == Some(pid) {
924            return;
925        }
926
927        // Only store PID if we can read and validate its start time
928        // This ensures the PID is valid and gives us PID reuse protection
929        if let Some(start_time) = read_process_start_time(pid) {
930            self.pid = Some(pid);
931            self.process_start_time = Some(start_time);
932        } else {
933            debug!(
934                pid = pid,
935                "PID validation failed - process may have exited or is inaccessible"
936            );
937        }
938    }
939
940    /// Checks if the tracked process is still alive.
941    ///
942    /// Returns `true` if:
943    /// - No PID is tracked (can't determine liveness)
944    /// - The process exists and has the same start time
945    ///
946    /// Returns `false` if:
947    /// - The process no longer exists
948    /// - The PID has been reused by a different process (start time mismatch)
949    pub fn is_process_alive(&self) -> bool {
950        let Some(pid) = self.pid else {
951            // No PID tracked - assume alive (can't determine)
952            debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
953            return true;
954        };
955
956        let Some(expected_start_time) = self.process_start_time else {
957            // No start time recorded - just check if process exists via procfs
958            let exists = procfs::process::Process::new(pid as i32).is_ok();
959            debug!(pid, exists, "is_process_alive: no start_time, checking procfs only");
960            return exists;
961        };
962
963        // Check if process exists and has same start time
964        match read_process_start_time(pid) {
965            Some(current_start_time) => {
966                let alive = current_start_time == expected_start_time;
967                if !alive {
968                    debug!(
969                        pid,
970                        expected_start_time,
971                        current_start_time,
972                        "is_process_alive: start time MISMATCH - PID reused?"
973                    );
974                }
975                alive
976            }
977            None => {
978                debug!(pid, expected_start_time, "is_process_alive: process NOT FOUND in /proc");
979                false
980            }
981        }
982    }
983
984    /// Records a tool usage.
985    pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
986        let record = ToolUsageRecord {
987            tool_name: tool_name.to_string(),
988            tool_use_id,
989            timestamp: Utc::now(),
990        };
991
992        self.recent_tools.push_back(record);
993
994        // Maintain bounded size using safe VecDeque operations
995        while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
996            self.recent_tools.pop_front();
997        }
998
999        self.hook_event_count += 1;
1000    }
1001
1002    /// Increments the update count.
1003    pub fn record_update(&mut self) {
1004        self.update_count += 1;
1005    }
1006
1007    /// Records an error.
1008    pub fn record_error(&mut self, error: &str) {
1009        self.last_error = Some(error.to_string());
1010    }
1011
1012    /// Returns the most recent tool used.
1013    pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
1014        self.recent_tools.back()
1015    }
1016
1017    /// Returns recent tools (most recent first).
1018    pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
1019        self.recent_tools.iter().rev()
1020    }
1021}
1022
1023/// Reads the process start time using the procfs crate.
1024///
1025/// The start time (in clock ticks since boot) is stable for the lifetime
1026/// of a process and unique enough to detect PID reuse.
1027///
1028/// Returns `None` if the process doesn't exist or can't be read.
1029fn read_process_start_time(pid: u32) -> Option<u64> {
1030    let process = procfs::process::Process::new(pid as i32).ok()?;
1031    let stat = process.stat().ok()?;
1032    Some(stat.starttime)
1033}
1034
1035impl Default for SessionInfrastructure {
1036    fn default() -> Self {
1037        Self::new()
1038    }
1039}
1040
1041// ============================================================================
1042// Application Layer DTO
1043// ============================================================================
1044
1045/// Read-only view of a session for TUI display.
1046///
1047/// Immutable snapshot created from SessionDomain.
1048/// Implements Clone for easy distribution to multiple UI components.
1049#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1050pub struct SessionView {
1051    /// Session identifier
1052    pub id: SessionId,
1053
1054    /// Short ID for display (first 8 chars)
1055    pub id_short: String,
1056
1057    /// Agent type label
1058    pub agent_type: String,
1059
1060    /// Model display name
1061    pub model: String,
1062
1063    /// Status label
1064    pub status: String,
1065
1066    /// Status detail (tool name if applicable)
1067    pub status_detail: Option<String>,
1068
1069    /// Context usage percentage
1070    pub context_percentage: f64,
1071
1072    /// Context usage formatted string
1073    pub context_display: String,
1074
1075    /// Whether context is in warning state
1076    pub context_warning: bool,
1077
1078    /// Whether context is in critical state
1079    pub context_critical: bool,
1080
1081    /// Cost formatted string
1082    pub cost_display: String,
1083
1084    /// Cost in USD (for sorting)
1085    pub cost_usd: f64,
1086
1087    /// Duration formatted string
1088    pub duration_display: String,
1089
1090    /// Duration in seconds (for sorting)
1091    pub duration_seconds: f64,
1092
1093    /// Lines changed formatted string
1094    pub lines_display: String,
1095
1096    /// Working directory (shortened for display)
1097    pub working_directory: Option<String>,
1098
1099    /// Whether session is stale
1100    pub is_stale: bool,
1101
1102    /// Whether session needs attention (permission wait, high context)
1103    pub needs_attention: bool,
1104
1105    /// Time since last activity (formatted)
1106    pub last_activity_display: String,
1107
1108    /// Session age (formatted)
1109    pub age_display: String,
1110
1111    /// Session start time (ISO 8601)
1112    pub started_at: String,
1113
1114    /// Last activity time (ISO 8601)
1115    pub last_activity: String,
1116
1117    /// Tmux pane ID (e.g., "%5") if session is running in tmux
1118    pub tmux_pane: Option<String>,
1119
1120    /// Display state for UI visualization (working/needs_input/stale/compacting)
1121    pub display_state: DisplayState,
1122}
1123
1124impl SessionView {
1125    /// Creates a SessionView from a SessionDomain.
1126    pub fn from_domain(session: &SessionDomain) -> Self {
1127        let now = Utc::now();
1128        let since_activity = now.signed_duration_since(session.last_activity);
1129        let age = now.signed_duration_since(session.started_at);
1130
1131        Self {
1132            id: session.id.clone(),
1133            id_short: session.id.short().to_string(),
1134            agent_type: session.agent_type.short_name().to_string(),
1135            model: session.model.display_name().to_string(),
1136            status: session.status.label().to_string(),
1137            status_detail: session.status.tool_name().map(|s| s.to_string()),
1138            context_percentage: session.context.usage_percentage(),
1139            context_display: session.context.format(),
1140            context_warning: session.context.is_warning(),
1141            context_critical: session.context.is_critical(),
1142            cost_display: session.cost.format(),
1143            cost_usd: session.cost.as_usd(),
1144            duration_display: session.duration.format(),
1145            duration_seconds: session.duration.total_seconds(),
1146            lines_display: session.lines_changed.format(),
1147            working_directory: session.working_directory.clone().map(|p| {
1148                // Shorten path for display
1149                if p.len() > 30 {
1150                    format!("...{}", &p[p.len().saturating_sub(27)..])
1151                } else {
1152                    p
1153                }
1154            }),
1155            is_stale: session.is_stale(),
1156            needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1157            last_activity_display: format_duration(since_activity),
1158            age_display: format_duration(age),
1159            started_at: session.started_at.to_rfc3339(),
1160            last_activity: session.last_activity.to_rfc3339(),
1161            tmux_pane: session.tmux_pane.clone(),
1162            display_state: DisplayState::from_session(
1163                since_activity.num_seconds(),
1164                &session.status,
1165                session.context.usage_percentage(),
1166                None, // TODO: Track previous context % for compaction detection
1167            ),
1168        }
1169    }
1170}
1171
1172impl From<&SessionDomain> for SessionView {
1173    fn from(session: &SessionDomain) -> Self {
1174        Self::from_domain(session)
1175    }
1176}
1177
1178/// Formats a duration for human-readable display.
1179fn format_duration(duration: chrono::Duration) -> String {
1180    let secs = duration.num_seconds();
1181    if secs < 0 {
1182        return "now".to_string();
1183    }
1184    if secs < 60 {
1185        format!("{secs}s ago")
1186    } else if secs < 3600 {
1187        let mins = secs / 60;
1188        format!("{mins}m ago")
1189    } else if secs < 86400 {
1190        let hours = secs / 3600;
1191        format!("{hours}h ago")
1192    } else {
1193        let days = secs / 86400;
1194        format!("{days}d ago")
1195    }
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200    use super::*;
1201
1202    /// Creates a test session with default values.
1203    fn create_test_session(id: &str) -> SessionDomain {
1204        SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1205    }
1206
1207    #[test]
1208    fn test_session_id_short() {
1209        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1210        assert_eq!(id.short(), "8e11bfb5");
1211    }
1212
1213    #[test]
1214    fn test_session_id_short_short_id() {
1215        let id = SessionId::new("abc");
1216        assert_eq!(id.short(), "abc");
1217    }
1218
1219    #[test]
1220    fn test_session_status_display() {
1221        let status = SessionStatus::RunningTool {
1222            tool_name: "Bash".to_string(),
1223            started_at: None,
1224        };
1225        assert_eq!(format!("{status}"), "Running: Bash");
1226    }
1227
1228    #[test]
1229    fn test_session_domain_creation() {
1230        let session = SessionDomain::new(
1231            SessionId::new("test-123"),
1232            AgentType::GeneralPurpose,
1233            Model::Opus45,
1234        );
1235        assert_eq!(session.id.as_str(), "test-123");
1236        assert_eq!(session.model, Model::Opus45);
1237        assert!(session.cost.is_zero());
1238    }
1239
1240    #[test]
1241    fn test_session_view_from_domain() {
1242        let session = SessionDomain::new(
1243            SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1244            AgentType::Explore,
1245            Model::Sonnet4,
1246        );
1247        let view = SessionView::from_domain(&session);
1248
1249        assert_eq!(view.id_short, "8e11bfb5");
1250        assert_eq!(view.agent_type, "explore");
1251        assert_eq!(view.model, "Sonnet 4");
1252    }
1253
1254    #[test]
1255    fn test_lines_changed() {
1256        let lines = LinesChanged::new(150, 30);
1257        assert_eq!(lines.net(), 120);
1258        assert_eq!(lines.churn(), 180);
1259        assert_eq!(lines.format(), "+150 -30");
1260        assert_eq!(lines.format_net(), "+120");
1261    }
1262
1263    #[test]
1264    fn test_session_duration_formatting() {
1265        assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1266        assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1267        assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1268    }
1269
1270    #[test]
1271    fn test_session_id_pending_from_pid() {
1272        let id = SessionId::pending_from_pid(12345);
1273        assert_eq!(id.as_str(), "pending-12345");
1274        assert!(id.is_pending());
1275        assert_eq!(id.pending_pid(), Some(12345));
1276    }
1277
1278    #[test]
1279    fn test_session_id_is_pending_true() {
1280        let id = SessionId::new("pending-99999");
1281        assert!(id.is_pending());
1282    }
1283
1284    #[test]
1285    fn test_session_id_is_pending_false() {
1286        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1287        assert!(!id.is_pending());
1288    }
1289
1290    #[test]
1291    fn test_session_id_pending_pid_returns_none_for_regular_id() {
1292        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1293        assert_eq!(id.pending_pid(), None);
1294    }
1295
1296    #[test]
1297    fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1298        let id = SessionId::new("pending-not-a-number");
1299        assert_eq!(id.pending_pid(), None);
1300    }
1301
1302    #[test]
1303    fn test_apply_hook_event_interactive_tool() {
1304        let mut session = create_test_session("test-interactive");
1305
1306        // PreToolUse with interactive tool → WaitingForPermission
1307        session.apply_hook_event(HookEventType::PreToolUse, Some("AskUserQuestion"));
1308
1309        assert!(matches!(
1310            session.status,
1311            SessionStatus::WaitingForPermission { ref tool_name } if tool_name == "AskUserQuestion"
1312        ));
1313
1314        // PostToolUse → back to Thinking
1315        session.apply_hook_event(HookEventType::PostToolUse, None);
1316        assert!(matches!(session.status, SessionStatus::Thinking));
1317    }
1318
1319    #[test]
1320    fn test_apply_hook_event_enter_plan_mode() {
1321        let mut session = create_test_session("test-plan");
1322
1323        // EnterPlanMode is also interactive
1324        session.apply_hook_event(HookEventType::PreToolUse, Some("EnterPlanMode"));
1325
1326        assert!(matches!(
1327            session.status,
1328            SessionStatus::WaitingForPermission { ref tool_name } if tool_name == "EnterPlanMode"
1329        ));
1330    }
1331
1332    #[test]
1333    fn test_apply_hook_event_standard_tool() {
1334        let mut session = create_test_session("test-standard");
1335
1336        // PreToolUse with standard tool → RunningTool
1337        session.apply_hook_event(HookEventType::PreToolUse, Some("Bash"));
1338
1339        assert!(matches!(
1340            session.status,
1341            SessionStatus::RunningTool { ref tool_name, .. } if tool_name == "Bash"
1342        ));
1343
1344        // PostToolUse → back to Thinking
1345        session.apply_hook_event(HookEventType::PostToolUse, Some("Bash"));
1346        assert!(matches!(session.status, SessionStatus::Thinking));
1347    }
1348
1349    #[test]
1350    fn test_apply_hook_event_none_tool_name() {
1351        let mut session = create_test_session("test-none");
1352        let original_status = session.status.clone();
1353
1354        // PreToolUse with None tool name should not change status
1355        session.apply_hook_event(HookEventType::PreToolUse, None);
1356
1357        assert_eq!(
1358            session.status, original_status,
1359            "PreToolUse with None tool_name should not change status"
1360        );
1361    }
1362
1363    #[test]
1364    fn test_apply_hook_event_empty_tool_name() {
1365        let mut session = create_test_session("test-empty");
1366
1367        // Empty string tool name - should be treated as standard tool
1368        // (is_interactive_tool returns false for empty strings)
1369        session.apply_hook_event(HookEventType::PreToolUse, Some(""));
1370
1371        assert!(matches!(
1372            session.status,
1373            SessionStatus::RunningTool { ref tool_name, .. } if tool_name.is_empty()
1374        ));
1375    }
1376}