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