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