Skip to main content

atm_core/
session.rs

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