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