Skip to main content

claudectl_core/
session.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use serde::Deserialize;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9pub enum SessionStatus {
10    NeedsInput,   // Blocked — waiting for user to approve/confirm (permission prompt)
11    Processing,   // Actively generating or executing tools
12    WaitingInput, // Done responding, waiting for user's next prompt
13    Unknown,      // Process is alive, but transcript telemetry is unavailable
14    Idle,         // No recent activity, stale session
15    Finished,     // Process exited
16}
17
18impl fmt::Display for SessionStatus {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::NeedsInput => write!(f, "Needs Input"),
22            Self::Processing => write!(f, "Processing"),
23            Self::WaitingInput => write!(f, "Waiting"),
24            Self::Unknown => write!(f, "Unknown"),
25            Self::Idle => write!(f, "Idle"),
26            Self::Finished => write!(f, "Finished"),
27        }
28    }
29}
30
31impl SessionStatus {
32    pub fn sort_key(&self) -> u8 {
33        match self {
34            Self::NeedsInput => 0,
35            Self::Processing => 1,
36            Self::WaitingInput => 2,
37            Self::Unknown => 3,
38            Self::Idle => 4,
39            Self::Finished => 5,
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TelemetryStatus {
46    Pending,
47    Available,
48    MissingTranscript,
49    UnreadableTranscript,
50    UnsupportedTranscript,
51}
52
53impl TelemetryStatus {
54    pub fn is_available(self) -> bool {
55        matches!(self, Self::Available)
56    }
57
58    pub fn label(self) -> &'static str {
59        match self {
60            Self::Pending => "Pending",
61            Self::Available => "Available",
62            Self::MissingTranscript => "No transcript",
63            Self::UnreadableTranscript => "Unreadable transcript",
64            Self::UnsupportedTranscript => "Unsupported transcript",
65        }
66    }
67
68    pub fn short_label(self) -> &'static str {
69        match self {
70            Self::Pending => "Pending",
71            Self::Available => "Available",
72            Self::MissingTranscript => "No transcript",
73            Self::UnreadableTranscript => "Unreadable",
74            Self::UnsupportedTranscript => "Unsupported",
75        }
76    }
77}
78
79#[derive(Debug, Deserialize)]
80pub struct RawSession {
81    pub pid: u32,
82    #[serde(rename = "sessionId")]
83    pub session_id: String,
84    pub cwd: String,
85    #[serde(rename = "startedAt")]
86    pub started_at: u64,
87}
88
89#[derive(Debug, Clone)]
90pub struct ClaudeSession {
91    pub pid: u32,
92    #[allow(dead_code)]
93    pub session_id: String,
94    pub cwd: String,
95    pub project_name: String,
96    pub started_at: u64,
97    pub elapsed: Duration,
98    pub tty: String,
99    pub status: SessionStatus,
100    pub cpu_percent: f32,
101    pub cpu_history: Vec<f32>, // Last N CPU readings for smoothing
102    pub mem_mb: f64,
103    pub own_input_tokens: u64,
104    pub own_output_tokens: u64,
105    pub own_cache_read_tokens: u64,
106    pub own_cache_write_tokens: u64,
107    pub subagent_input_tokens: u64,
108    pub subagent_output_tokens: u64,
109    pub subagent_cache_read_tokens: u64,
110    pub subagent_cache_write_tokens: u64,
111    pub total_input_tokens: u64,
112    pub total_output_tokens: u64,
113    pub model: String,
114    pub command_args: String,
115    pub session_name: String,
116    pub jsonl_path: Option<PathBuf>,
117    pub jsonl_offset: u64,
118    pub last_message_ts: u64,
119    pub cache_read_tokens: u64,
120    pub cache_write_tokens: u64,
121    pub cost_usd: f64,
122    pub context_tokens: u64,
123    pub context_max: u64,
124    pub prev_cost_usd: f64,
125    pub burn_rate_per_hr: f64,
126    /// EWMA-smoothed burn rate ($/hr) used for budget forecasting. Damps the
127    /// tick-to-tick flicker of `burn_rate_per_hr`. See `forecast` (#370).
128    pub burn_rate_ewma: f64,
129    /// Recent instantaneous burn samples ($/hr), capped at `BURN_SAMPLE_CAP`,
130    /// for the p10/p90 ETA band. In-memory only (rebuilt at runtime).
131    pub burn_samples: std::collections::VecDeque<f64>,
132    pub subagent_count: usize,
133    pub active_subagent_count: usize,
134    pub active_subagent_jsonl_paths: Vec<PathBuf>,
135    pub subagent_rollups: HashMap<PathBuf, SubagentRollup>,
136    pub activity_history: Vec<u8>, // Ring buffer of status levels (0-7) for sparkline, one per tick
137    pub files_modified: HashMap<String, u32>, // file path -> edit count
138    pub tool_usage: HashMap<String, ToolStats>, // tool name -> call count & tokens
139    pub worktree_id: Option<String>, // Resolved git toplevel + git-dir, for conflict detection
140    pub telemetry_status: TelemetryStatus,
141    pub usage_metrics_available: bool,
142    pub cost_estimate_unverified: bool,
143    pub model_profile_source: String,
144    /// Persisted across ticks so status inference works when no new JSONL arrives.
145    pub last_msg_type: String,
146    pub last_stop_reason: String,
147    pub is_waiting_for_task: bool,
148    /// Pending tool call details for rule-based auto-actions.
149    pub pending_tool_name: Option<String>,
150    pub pending_tool_input: Option<String>, // Extracted command string (for Bash)
151    pub pending_file_path: Option<String>,  // File path for pending Edit/Write/NotebookEdit
152    pub has_file_conflict: bool,            // Pending file edit conflicts with another session
153    pub last_tool_error: bool,
154    pub last_error_message: Option<String>,
155    pub recent_errors: Vec<ErrorEntry>, // Last 5 errors (ring buffer)
156    // ── Cognitive health tracking ────────────────────────────────────
157    /// Cumulative tokens at each Edit/Write event (for efficiency trending).
158    pub total_tokens_at_edit_count: u64,
159    /// Number of Edit/Write events (for averaging tokens-per-edit).
160    pub edit_event_count: u32,
161    /// Baseline tokens-per-edit, frozen after first 5 edits.
162    pub baseline_tokens_per_edit: Option<f64>,
163    /// Error count ring buffer: one entry per window (~10s each).
164    pub error_counts_per_window: Vec<u32>, // max 10 entries
165    /// Accumulator for current error window.
166    pub current_window_errors: u32,
167    /// Ticks since last window flush.
168    pub window_tick_counter: u32,
169    /// Baseline error rate (errors per window), frozen after 3 windows.
170    pub baseline_error_rate: Option<f64>,
171    /// File reads since last edit: path -> read count. Reset when file is edited.
172    pub file_reads_since_edit: HashMap<String, u32>,
173    /// All-time error count.
174    pub total_error_count: u32,
175    /// Cached composite decay score (0-100), recomputed each tick.
176    pub decay_score: u32,
177    /// If set, this session is from a remote worker (not local).
178    /// Terminal actions (approve, kill, etc.) are disabled for remote sessions.
179    pub worker_origin: Option<String>,
180}
181
182/// A captured tool error with context.
183#[derive(Debug, Clone)]
184pub struct ErrorEntry {
185    pub tool_name: String,
186    pub message: String,
187}
188
189/// Per-tool usage statistics.
190#[derive(Debug, Clone, Default)]
191pub struct ToolStats {
192    pub calls: u32,
193}
194
195#[derive(Debug, Clone, Default)]
196pub struct SubagentRollup {
197    pub jsonl_offset: u64,
198    pub input_tokens: u64,
199    pub output_tokens: u64,
200    pub cache_read_tokens: u64,
201    pub cache_write_tokens: u64,
202    pub cost_usd: f64,
203    pub model: String,
204    pub cost_estimate_unverified: bool,
205    pub usage_metrics_available: bool,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum SubagentState {
210    Active,
211    Completed,
212}
213
214#[derive(Debug, Clone)]
215pub struct SubagentBreakdown {
216    pub label: String,
217    pub state: SubagentState,
218    pub count: usize,
219    pub input_tokens: u64,
220    pub output_tokens: u64,
221    pub cache_read_tokens: u64,
222    pub cache_write_tokens: u64,
223    pub cost_usd: f64,
224    pub usage_metrics_available: bool,
225    pub cost_estimate_unverified: bool,
226}
227
228impl SubagentBreakdown {
229    pub fn total_input_tokens(&self) -> u64 {
230        self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
231    }
232
233    pub fn state_label(&self) -> String {
234        match self.state {
235            SubagentState::Active => "Active".to_string(),
236            SubagentState::Completed if self.count > 1 => format!("Completed ({})", self.count),
237            SubagentState::Completed => "Completed".to_string(),
238        }
239    }
240
241    pub fn display_label(&self) -> String {
242        if self.state == SubagentState::Completed && self.label == "completed" && self.count > 1 {
243            format!("completed ({})", self.count)
244        } else {
245            self.label.clone()
246        }
247    }
248
249    pub fn format_tokens(&self) -> String {
250        if !self.usage_metrics_available {
251            return "n/a".to_string();
252        }
253        let total = self.total_input_tokens() + self.output_tokens;
254        if total == 0 {
255            return "-".to_string();
256        }
257        format_count(self.total_input_tokens()) + "/" + &format_count(self.output_tokens)
258    }
259
260    pub fn format_cost(&self) -> String {
261        if !self.usage_metrics_available {
262            return "n/a".to_string();
263        }
264        if self.cost_usd < 0.01 {
265            return "-".to_string();
266        }
267        if self.cost_usd < 1.0 {
268            format!(
269                "${:.2}{}",
270                self.cost_usd,
271                if self.cost_estimate_unverified {
272                    "?"
273                } else {
274                    ""
275                }
276            )
277        } else {
278            format!(
279                "${:.1}{}",
280                self.cost_usd,
281                if self.cost_estimate_unverified {
282                    "?"
283                } else {
284                    ""
285                }
286            )
287        }
288    }
289}
290
291impl ClaudeSession {
292    pub fn from_raw(raw: RawSession) -> Self {
293        let project_name = raw.cwd.rsplit('/').next().unwrap_or("unknown").to_string();
294
295        let now_ms = std::time::SystemTime::now()
296            .duration_since(std::time::UNIX_EPOCH)
297            .unwrap_or_default()
298            .as_millis() as u64;
299        let elapsed_ms = now_ms.saturating_sub(raw.started_at);
300        let elapsed = Duration::from_millis(elapsed_ms);
301
302        Self {
303            pid: raw.pid,
304            session_id: raw.session_id,
305            cwd: raw.cwd,
306            project_name,
307            started_at: raw.started_at,
308            elapsed,
309            tty: String::new(),
310            status: SessionStatus::Idle,
311            cpu_percent: 0.0,
312            cpu_history: Vec::new(),
313            mem_mb: 0.0,
314            own_input_tokens: 0,
315            own_output_tokens: 0,
316            own_cache_read_tokens: 0,
317            own_cache_write_tokens: 0,
318            subagent_input_tokens: 0,
319            subagent_output_tokens: 0,
320            subagent_cache_read_tokens: 0,
321            subagent_cache_write_tokens: 0,
322            total_input_tokens: 0,
323            total_output_tokens: 0,
324            model: String::new(),
325            command_args: String::new(),
326            session_name: String::new(),
327            jsonl_path: None,
328            jsonl_offset: 0,
329            last_message_ts: 0,
330            cache_read_tokens: 0,
331            cache_write_tokens: 0,
332            cost_usd: 0.0,
333            context_tokens: 0,
334            context_max: 0,
335            prev_cost_usd: 0.0,
336            burn_rate_per_hr: 0.0,
337            burn_rate_ewma: 0.0,
338            burn_samples: std::collections::VecDeque::new(),
339            subagent_count: 0,
340            active_subagent_count: 0,
341            active_subagent_jsonl_paths: Vec::new(),
342            subagent_rollups: HashMap::new(),
343            activity_history: Vec::new(),
344            files_modified: HashMap::new(),
345            tool_usage: HashMap::new(),
346            worktree_id: None,
347            telemetry_status: TelemetryStatus::Pending,
348            usage_metrics_available: false,
349            cost_estimate_unverified: false,
350            model_profile_source: "built-in".into(),
351            last_msg_type: String::new(),
352            last_stop_reason: String::new(),
353            is_waiting_for_task: false,
354            pending_tool_name: None,
355            pending_tool_input: None,
356            pending_file_path: None,
357            has_file_conflict: false,
358            last_tool_error: false,
359            last_error_message: None,
360            recent_errors: Vec::new(),
361            total_tokens_at_edit_count: 0,
362            edit_event_count: 0,
363            baseline_tokens_per_edit: None,
364            error_counts_per_window: Vec::new(),
365            current_window_errors: 0,
366            window_tick_counter: 0,
367            baseline_error_rate: None,
368            file_reads_since_edit: HashMap::new(),
369            total_error_count: 0,
370            decay_score: 0,
371            worker_origin: None,
372        }
373    }
374
375    /// Record current status into the activity sparkline ring buffer.
376    /// Max 15 entries (one per tick, at 2s default = 30s of history).
377    pub fn record_activity(&mut self) {
378        let level = match self.status {
379            SessionStatus::Processing => 7,
380            SessionStatus::NeedsInput => 4,
381            SessionStatus::WaitingInput => 2,
382            SessionStatus::Unknown => 2,
383            SessionStatus::Idle => 1,
384            SessionStatus::Finished => 0,
385        };
386        self.activity_history.push(level);
387        if self.activity_history.len() > 15 {
388            self.activity_history.remove(0);
389        }
390
391        // Flush error window every 5 ticks (~10s at default 2s interval)
392        self.window_tick_counter += 1;
393        if self.window_tick_counter >= 5 {
394            self.error_counts_per_window
395                .push(self.current_window_errors);
396            if self.error_counts_per_window.len() > 10 {
397                self.error_counts_per_window.remove(0);
398            }
399            // Freeze baseline error rate after 3 windows
400            if self.baseline_error_rate.is_none() && self.error_counts_per_window.len() >= 3 {
401                let sum: u32 = self.error_counts_per_window.iter().sum();
402                self.baseline_error_rate =
403                    Some(sum as f64 / self.error_counts_per_window.len() as f64);
404            }
405            self.current_window_errors = 0;
406            self.window_tick_counter = 0;
407        }
408    }
409
410    /// Fold the current instantaneous `burn_rate_per_hr` into the EWMA and the
411    /// rolling sample window. Call once per refresh after burn is recomputed.
412    /// `interval_secs` is the refresh cadence (shapes the EWMA half-life).
413    pub fn record_burn_sample(&mut self, interval_secs: f64) {
414        let sample = self.burn_rate_per_hr;
415        let alpha = crate::forecast::alpha_for_half_life(
416            crate::forecast::BURN_HALF_LIFE_SECS,
417            interval_secs,
418        );
419        let prev = if self.burn_samples.is_empty() {
420            None
421        } else {
422            Some(self.burn_rate_ewma)
423        };
424        self.burn_rate_ewma = crate::forecast::ewma(prev, sample, alpha);
425        self.burn_samples.push_back(sample);
426        while self.burn_samples.len() > crate::forecast::BURN_SAMPLE_CAP {
427            self.burn_samples.pop_front();
428        }
429    }
430
431    /// Forecast time-to-budget for this session given a per-session cap, as a
432    /// p10/p90 interval. `None` when the session isn't burning or has no cap.
433    pub fn budget_eta(&self, budget_usd: f64) -> crate::forecast::EtaBand {
434        let headroom = budget_usd - self.cost_usd;
435        let samples: Vec<f64> = self.burn_samples.iter().copied().collect();
436        crate::forecast::budget_eta_band(headroom, self.burn_rate_ewma, &samples)
437    }
438
439    /// Render the sparkline as unicode block characters.
440    pub fn format_sparkline(&self) -> String {
441        const BLOCKS: &[char] = &[
442            ' ', '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}',
443            '\u{2587}', '\u{2588}',
444        ];
445        if self.activity_history.is_empty() {
446            return String::from("-");
447        }
448        self.activity_history
449            .iter()
450            .map(|&level| BLOCKS[level.min(8) as usize])
451            .collect()
452    }
453
454    pub fn display_name(&self) -> &str {
455        if !self.session_name.is_empty() {
456            &self.session_name
457        } else {
458            &self.project_name
459        }
460    }
461
462    /// Whether this session is from a remote worker (not local).
463    pub fn is_remote(&self) -> bool {
464        self.worker_origin.is_some()
465    }
466
467    /// Build a ClaudeSession from remote JSON (as received via heartbeat/HTTP).
468    #[allow(dead_code)]
469    pub fn from_remote_json(worker_id: &str, json: &serde_json::Value) -> Option<Self> {
470        let pid = json.get("pid")?.as_u64()? as u32;
471        let project = json.get("project")?.as_str()?;
472        let status_str = json
473            .get("status")
474            .and_then(|v| v.as_str())
475            .unwrap_or("Unknown");
476        let status = match status_str {
477            "Needs Input" => SessionStatus::NeedsInput,
478            "Processing" => SessionStatus::Processing,
479            "Waiting" => SessionStatus::WaitingInput,
480            "Idle" => SessionStatus::Idle,
481            "Finished" => SessionStatus::Finished,
482            _ => SessionStatus::Unknown,
483        };
484
485        let elapsed_secs = json
486            .get("elapsed_secs")
487            .and_then(|v| v.as_u64())
488            .unwrap_or(0);
489        let now_ms = std::time::SystemTime::now()
490            .duration_since(std::time::UNIX_EPOCH)
491            .unwrap_or_default()
492            .as_millis() as u64;
493
494        let mut session = Self::from_raw(RawSession {
495            pid,
496            session_id: format!("remote-{worker_id}-{pid}"),
497            cwd: project.to_string(),
498            started_at: now_ms.saturating_sub(elapsed_secs * 1000),
499        });
500        session.status = status;
501        session.worker_origin = Some(worker_id.to_string());
502        session.project_name = format!("[{worker_id}] {project}");
503
504        // Populate metrics from JSON when available
505        if let Some(cost) = json.get("cost_usd").and_then(|v| v.as_f64()) {
506            session.cost_usd = cost;
507            session.usage_metrics_available = true;
508        }
509        if let Some(burn) = json.get("burn_rate_per_hr").and_then(|v| v.as_f64()) {
510            session.burn_rate_per_hr = burn;
511        }
512        if let Some(ctx) = json.get("context_pct").and_then(|v| v.as_f64()) {
513            // Reverse-engineer context_tokens/context_max from percentage
514            session.context_max = 200_000; // reasonable default
515            session.context_tokens = ((ctx / 100.0) * session.context_max as f64) as u64;
516        }
517        if let Some(t_in) = json.get("tokens_in").and_then(|v| v.as_u64()) {
518            session.total_input_tokens = t_in;
519            session.usage_metrics_available = true;
520        }
521        if let Some(t_out) = json.get("tokens_out").and_then(|v| v.as_u64()) {
522            session.total_output_tokens = t_out;
523        }
524        if let Some(model) = json.get("model").and_then(|v| v.as_str()) {
525            session.model = model.to_string();
526        }
527        if let Some(subs) = json.get("subagents").and_then(|v| v.as_u64()) {
528            session.subagent_count = subs as usize;
529        }
530        if let Some(decay) = json.get("decay_score").and_then(|v| v.as_u64()) {
531            session.decay_score = decay as u32;
532        }
533
534        Some(session)
535    }
536
537    pub fn format_subagent_summary(&self) -> String {
538        if self.subagent_count == 0 {
539            return "0".to_string();
540        }
541        if self.active_subagent_count == 0 || self.active_subagent_count == self.subagent_count {
542            return self.subagent_count.to_string();
543        }
544        format!(
545            "{} total ({} active)",
546            self.subagent_count, self.active_subagent_count
547        )
548    }
549
550    pub fn subagent_breakdown(&self) -> Vec<SubagentBreakdown> {
551        if self.subagent_rollups.is_empty() {
552            return Vec::new();
553        }
554
555        let active_paths: HashSet<&PathBuf> = self.active_subagent_jsonl_paths.iter().collect();
556        let mut active_rows = Vec::new();
557        let mut completed_rows = Vec::new();
558
559        for (path, rollup) in &self.subagent_rollups {
560            let row = SubagentBreakdown {
561                label: subagent_label(path),
562                state: if active_paths.contains(path) {
563                    SubagentState::Active
564                } else {
565                    SubagentState::Completed
566                },
567                count: 1,
568                input_tokens: rollup.input_tokens,
569                output_tokens: rollup.output_tokens,
570                cache_read_tokens: rollup.cache_read_tokens,
571                cache_write_tokens: rollup.cache_write_tokens,
572                cost_usd: rollup.cost_usd,
573                usage_metrics_available: rollup.usage_metrics_available,
574                cost_estimate_unverified: rollup.cost_estimate_unverified,
575            };
576
577            if row.state == SubagentState::Active {
578                active_rows.push(row);
579            } else {
580                completed_rows.push(row);
581            }
582        }
583
584        active_rows.sort_by(|a, b| a.label.cmp(&b.label));
585
586        let mut rows = Vec::new();
587        if !completed_rows.is_empty() {
588            let mut aggregate = SubagentBreakdown {
589                label: "completed".to_string(),
590                state: SubagentState::Completed,
591                count: completed_rows.len(),
592                input_tokens: 0,
593                output_tokens: 0,
594                cache_read_tokens: 0,
595                cache_write_tokens: 0,
596                cost_usd: 0.0,
597                usage_metrics_available: false,
598                cost_estimate_unverified: false,
599            };
600
601            for row in completed_rows {
602                aggregate.input_tokens += row.input_tokens;
603                aggregate.output_tokens += row.output_tokens;
604                aggregate.cache_read_tokens += row.cache_read_tokens;
605                aggregate.cache_write_tokens += row.cache_write_tokens;
606                aggregate.cost_usd += row.cost_usd;
607                aggregate.usage_metrics_available |= row.usage_metrics_available;
608                aggregate.cost_estimate_unverified |= row.cost_estimate_unverified;
609            }
610
611            rows.push(aggregate);
612        }
613
614        rows.extend(active_rows);
615        rows
616    }
617
618    pub fn format_elapsed(&self) -> String {
619        let secs = self.elapsed.as_secs();
620        let h = secs / 3600;
621        let m = (secs % 3600) / 60;
622        let s = secs % 60;
623        if h > 0 {
624            format!("{h:02}:{m:02}:{s:02}")
625        } else {
626            format!("{m:02}:{s:02}")
627        }
628    }
629
630    pub fn format_tokens(&self) -> String {
631        if !self.usage_metrics_available {
632            return "n/a".to_string();
633        }
634        let total = self.total_input_tokens + self.total_output_tokens;
635        if total == 0 {
636            return String::from("-");
637        }
638        format_count(self.total_input_tokens) + "/" + &format_count(self.total_output_tokens)
639    }
640
641    pub fn format_mem(&self) -> String {
642        if self.mem_mb < 1.0 {
643            return String::from("-");
644        }
645        format!("{:.0}M", self.mem_mb)
646    }
647
648    pub fn format_cost(&self) -> String {
649        if !self.usage_metrics_available {
650            return "n/a".to_string();
651        }
652        if self.cost_usd < 0.01 {
653            return String::from("-");
654        }
655        if self.cost_usd < 1.0 {
656            format!(
657                "${:.2}{}",
658                self.cost_usd,
659                if self.cost_estimate_unverified {
660                    "?"
661                } else {
662                    ""
663                }
664            )
665        } else {
666            format!(
667                "${:.1}{}",
668                self.cost_usd,
669                if self.cost_estimate_unverified {
670                    "?"
671                } else {
672                    ""
673                }
674            )
675        }
676    }
677
678    pub fn context_percent(&self) -> f64 {
679        if !self.usage_metrics_available {
680            return 0.0;
681        }
682        if self.context_max == 0 || self.context_tokens == 0 {
683            return 0.0;
684        }
685        (self.context_tokens as f64 / self.context_max as f64) * 100.0
686    }
687
688    /// Format context as "450k/1M 45%" or a visual bar
689    pub fn format_context(&self) -> String {
690        if !self.usage_metrics_available {
691            return "n/a".to_string();
692        }
693        if self.context_tokens == 0 {
694            return String::from("-");
695        }
696        let pct = self.context_percent();
697        format!("{}%", pct as u32)
698    }
699
700    /// Visual bar for context usage: ████░░ 62%
701    pub fn format_context_bar(&self, width: usize) -> String {
702        if !self.usage_metrics_available {
703            return "n/a".to_string();
704        }
705        let pct = self.context_percent();
706        if pct == 0.0 {
707            return String::from("-");
708        }
709        let filled = ((pct / 100.0) * width as f64).round() as usize;
710        let empty = width.saturating_sub(filled);
711        format!(
712            "{}{} {}%",
713            "█".repeat(filled),
714            "░".repeat(empty),
715            pct as u32
716        )
717    }
718
719    /// Produce a JSON-serializable value for --json export.
720    pub fn to_json_value(&self) -> serde_json::Value {
721        let cost_usd = if self.usage_metrics_available {
722            serde_json::json!((self.cost_usd * 100.0).round() / 100.0)
723        } else {
724            serde_json::Value::Null
725        };
726        let burn_rate = if self.usage_metrics_available {
727            serde_json::json!((self.burn_rate_per_hr * 100.0).round() / 100.0)
728        } else {
729            serde_json::Value::Null
730        };
731        let context_pct = if self.usage_metrics_available {
732            serde_json::json!((self.context_percent() * 100.0).round() / 100.0)
733        } else {
734            serde_json::Value::Null
735        };
736        let tokens_in = if self.usage_metrics_available {
737            serde_json::json!(self.total_input_tokens)
738        } else {
739            serde_json::Value::Null
740        };
741        let tokens_out = if self.usage_metrics_available {
742            serde_json::json!(self.total_output_tokens)
743        } else {
744            serde_json::Value::Null
745        };
746
747        serde_json::json!({
748            "pid": self.pid,
749            "project": self.display_name(),
750            "status": self.status.to_string(),
751            "telemetry": {
752                "state": self.telemetry_status.label(),
753                "usage_metrics_available": self.usage_metrics_available,
754            },
755            "estimate": {
756                "verified": !self.cost_estimate_unverified,
757                "profile_source": self.model_profile_source,
758            },
759            "context_pct": context_pct,
760            "cost_usd": cost_usd,
761            "burn_rate_per_hr": burn_rate,
762            "elapsed_secs": self.elapsed.as_secs(),
763            "cpu": self.cpu_percent,
764            "mem_mb": (self.mem_mb * 100.0).round() / 100.0,
765            "tokens_in": tokens_in,
766            "tokens_out": tokens_out,
767            "subagents": self.subagent_count,
768            "active_subagents": self.active_subagent_count,
769            "subagent_breakdown": self.subagent_breakdown().into_iter().map(|row| {
770                serde_json::json!({
771                    "label": row.display_label(),
772                    "state": row.state_label(),
773                    "count": row.count,
774                    "tokens_in": if row.usage_metrics_available {
775                        serde_json::json!(row.total_input_tokens())
776                    } else {
777                        serde_json::Value::Null
778                    },
779                    "tokens_out": if row.usage_metrics_available {
780                        serde_json::json!(row.output_tokens)
781                    } else {
782                        serde_json::Value::Null
783                    },
784                    "cost_usd": if row.usage_metrics_available {
785                        serde_json::json!((row.cost_usd * 100.0).round() / 100.0)
786                    } else {
787                        serde_json::Value::Null
788                    },
789                })
790            }).collect::<Vec<_>>(),
791            "decay_score": if self.usage_metrics_available { serde_json::json!(self.decay_score) } else { serde_json::Value::Null },
792            "last_error": self.last_error_message,
793            "recent_errors": self.recent_errors.iter().map(|e| {
794                serde_json::json!({
795                    "tool": e.tool_name,
796                    "message": e.message,
797                })
798            }).collect::<Vec<_>>(),
799            "files_modified": self.files_modified,
800            "tool_usage": self.tool_usage.iter().map(|(k, v)| {
801                (k.clone(), serde_json::json!({"calls": v.calls}))
802            }).collect::<serde_json::Map<String, serde_json::Value>>(),
803            "worker_origin": self.worker_origin,
804        })
805    }
806
807    pub fn format_burn_rate(&self) -> String {
808        if !self.usage_metrics_available {
809            return "n/a".to_string();
810        }
811        if self.burn_rate_per_hr < 0.01 {
812            return String::from("-");
813        }
814        if self.burn_rate_per_hr < 1.0 {
815            format!(
816                "${:.2}/h{}",
817                self.burn_rate_per_hr,
818                if self.cost_estimate_unverified {
819                    "?"
820                } else {
821                    ""
822                }
823            )
824        } else {
825            format!(
826                "${:.1}/h{}",
827                self.burn_rate_per_hr,
828                if self.cost_estimate_unverified {
829                    "?"
830                } else {
831                    ""
832                }
833            )
834        }
835    }
836
837    pub fn telemetry_label(&self) -> &'static str {
838        self.telemetry_status.label()
839    }
840
841    pub fn has_usage_metrics(&self) -> bool {
842        self.usage_metrics_available
843    }
844}
845
846/// Truncate a string to at most `max_bytes` bytes, landing on a valid
847/// UTF-8 character boundary. Returns the original string if already short enough.
848pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
849    if s.len() <= max_bytes {
850        return s;
851    }
852    let mut end = max_bytes;
853    while end > 0 && !s.is_char_boundary(end) {
854        end -= 1;
855    }
856    &s[..end]
857}
858
859fn format_count(n: u64) -> String {
860    if n >= 1_000_000 {
861        format!("{:.1}M", n as f64 / 1_000_000.0)
862    } else if n >= 1_000 {
863        format!("{:.1}k", n as f64 / 1_000.0)
864    } else {
865        n.to_string()
866    }
867}
868
869fn subagent_label(path: &Path) -> String {
870    let components: Vec<String> = path
871        .components()
872        .map(|component| component.as_os_str().to_string_lossy().to_string())
873        .collect();
874
875    if let Some(tasks_idx) = components.iter().position(|component| component == "tasks") {
876        let relative = &components[tasks_idx + 1..];
877        if !relative.is_empty() {
878            let mut label = relative.join("/");
879            if let Some(stripped) = label.strip_suffix(".jsonl") {
880                label = stripped.to_string();
881            }
882            return label;
883        }
884    }
885
886    path.file_stem()
887        .and_then(|stem| stem.to_str())
888        .unwrap_or("subagent")
889        .to_string()
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895
896    fn make_session() -> ClaudeSession {
897        ClaudeSession::from_raw(RawSession {
898            pid: 1,
899            session_id: "session-1".into(),
900            cwd: "/tmp/project".into(),
901            started_at: 0,
902        })
903    }
904
905    #[test]
906    fn record_burn_sample_smooths_and_caps_window() {
907        let mut s = make_session();
908        // A steady burn settles the EWMA near the true rate and fills the window.
909        for _ in 0..crate::forecast::BURN_SAMPLE_CAP + 10 {
910            s.burn_rate_per_hr = 6.0;
911            s.record_burn_sample(2.0);
912        }
913        assert_eq!(s.burn_samples.len(), crate::forecast::BURN_SAMPLE_CAP);
914        assert!(
915            (s.burn_rate_ewma - 6.0).abs() < 0.5,
916            "ewma: {}",
917            s.burn_rate_ewma
918        );
919    }
920
921    #[test]
922    fn budget_eta_forecasts_time_to_cap() {
923        let mut s = make_session();
924        for _ in 0..20 {
925            s.burn_rate_per_hr = 5.0;
926            s.record_burn_sample(2.0);
927        }
928        s.cost_usd = 5.0; // $5 spent of a $15 cap ⇒ $10 headroom at ~$5/hr ≈ 2h
929        let band = s.budget_eta(15.0);
930        let mid = band.mid_hours.expect("burning, so an ETA exists");
931        assert!((mid - 2.0).abs() < 0.4, "eta hours: {mid}");
932    }
933
934    #[test]
935    fn subagent_breakdown_groups_completed_and_lists_active_rows() {
936        let mut session = make_session();
937        let completed = PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/agent-1.jsonl");
938        let active =
939            PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/nested/agent-2.jsonl");
940
941        session.active_subagent_jsonl_paths = vec![active.clone()];
942        session.subagent_rollups.insert(
943            completed,
944            SubagentRollup {
945                input_tokens: 10_000,
946                output_tokens: 2_000,
947                cost_usd: 0.25,
948                usage_metrics_available: true,
949                ..SubagentRollup::default()
950            },
951        );
952        session.subagent_rollups.insert(
953            active,
954            SubagentRollup {
955                input_tokens: 40_000,
956                output_tokens: 8_000,
957                cost_usd: 1.5,
958                usage_metrics_available: true,
959                ..SubagentRollup::default()
960            },
961        );
962
963        let rows = session.subagent_breakdown();
964        assert_eq!(rows.len(), 2);
965        assert_eq!(rows[0].display_label(), "completed");
966        assert_eq!(rows[0].state, SubagentState::Completed);
967        assert_eq!(rows[0].count, 1);
968        assert_eq!(rows[0].format_tokens(), "10.0k/2.0k");
969        assert_eq!(rows[1].display_label(), "nested/agent-2");
970        assert_eq!(rows[1].state, SubagentState::Active);
971        assert_eq!(rows[1].format_cost(), "$1.5");
972    }
973
974    #[test]
975    fn subagent_breakdown_collapses_multiple_completed_rows() {
976        let mut session = make_session();
977
978        for name in ["agent-1.jsonl", "agent-2.jsonl"] {
979            let path = PathBuf::from(format!("/tmp/claude-1/-tmp-project/session-1/tasks/{name}"));
980            session.subagent_rollups.insert(
981                path,
982                SubagentRollup {
983                    input_tokens: 10_000,
984                    output_tokens: 1_000,
985                    cost_usd: 0.2,
986                    usage_metrics_available: true,
987                    ..SubagentRollup::default()
988                },
989            );
990        }
991
992        let rows = session.subagent_breakdown();
993        assert_eq!(rows.len(), 1);
994        assert_eq!(rows[0].display_label(), "completed (2)");
995        assert_eq!(rows[0].count, 2);
996        assert_eq!(rows[0].format_tokens(), "20.0k/2.0k");
997    }
998
999    // ── Cognitive health tracking tests ──────────────────────────────
1000
1001    #[test]
1002    fn error_window_flush() {
1003        let mut s = make_session();
1004        s.current_window_errors = 3;
1005        // Call record_activity 5 times to trigger one window flush
1006        for _ in 0..5 {
1007            s.record_activity();
1008        }
1009        assert_eq!(s.error_counts_per_window.len(), 1);
1010        assert_eq!(s.error_counts_per_window[0], 3);
1011        assert_eq!(s.current_window_errors, 0);
1012        assert_eq!(s.window_tick_counter, 0);
1013    }
1014
1015    #[test]
1016    fn baseline_error_rate_freezes() {
1017        let mut s = make_session();
1018        // Simulate 3 windows of errors
1019        for errors in [2, 3, 4] {
1020            s.current_window_errors = errors;
1021            for _ in 0..5 {
1022                s.record_activity();
1023            }
1024        }
1025        assert_eq!(s.error_counts_per_window.len(), 3);
1026        let baseline = s.baseline_error_rate.expect("baseline should be set");
1027        // baseline = (2+3+4)/3 = 3.0
1028        assert!((baseline - 3.0).abs() < 0.01);
1029
1030        // Add another window — baseline should NOT change
1031        s.current_window_errors = 10;
1032        for _ in 0..5 {
1033            s.record_activity();
1034        }
1035        assert_eq!(s.baseline_error_rate.unwrap(), baseline);
1036    }
1037
1038    // ── Remote session tests ────────────────────────────────────────
1039
1040    #[test]
1041    fn local_session_is_not_remote() {
1042        let s = make_session();
1043        assert!(!s.is_remote());
1044        assert!(s.worker_origin.is_none());
1045    }
1046
1047    #[test]
1048    fn from_remote_json_parses_basic_fields() {
1049        let json = serde_json::json!({
1050            "pid": 42,
1051            "project": "backend",
1052            "status": "Processing",
1053            "cost_usd": 1.23,
1054            "elapsed_secs": 600,
1055            "tokens_in": 50000,
1056            "tokens_out": 10000,
1057        });
1058        let session = ClaudeSession::from_remote_json("macbook-02", &json).unwrap();
1059        assert!(session.is_remote());
1060        assert_eq!(session.worker_origin.as_deref(), Some("macbook-02"));
1061        assert_eq!(session.pid, 42);
1062        assert_eq!(session.project_name, "[macbook-02] backend");
1063        assert_eq!(session.status, SessionStatus::Processing);
1064        assert!((session.cost_usd - 1.23).abs() < 0.01);
1065        assert_eq!(session.total_input_tokens, 50000);
1066        assert_eq!(session.total_output_tokens, 10000);
1067        assert!(session.usage_metrics_available);
1068    }
1069
1070    #[test]
1071    fn from_remote_json_handles_all_statuses() {
1072        for (label, expected) in [
1073            ("Needs Input", SessionStatus::NeedsInput),
1074            ("Processing", SessionStatus::Processing),
1075            ("Waiting", SessionStatus::WaitingInput),
1076            ("Idle", SessionStatus::Idle),
1077            ("Finished", SessionStatus::Finished),
1078            ("SomethingElse", SessionStatus::Unknown),
1079        ] {
1080            let json = serde_json::json!({"pid": 1, "project": "p", "status": label});
1081            let session = ClaudeSession::from_remote_json("w", &json).unwrap();
1082            assert_eq!(session.status, expected, "status mismatch for {label}");
1083        }
1084    }
1085
1086    #[test]
1087    fn from_remote_json_returns_none_on_missing_fields() {
1088        // Missing pid
1089        let json = serde_json::json!({"project": "x", "status": "Idle"});
1090        assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1091
1092        // Missing project
1093        let json = serde_json::json!({"pid": 1, "status": "Idle"});
1094        assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1095    }
1096
1097    #[test]
1098    fn remote_session_display_name_shows_worker_prefix() {
1099        let json = serde_json::json!({"pid": 1, "project": "api-server", "status": "Idle"});
1100        let session = ClaudeSession::from_remote_json("laptop-01", &json).unwrap();
1101        assert_eq!(session.display_name(), "[laptop-01] api-server");
1102    }
1103
1104    #[test]
1105    fn remote_session_json_includes_worker_origin() {
1106        let json = serde_json::json!({"pid": 1, "project": "test", "status": "Idle"});
1107        let session = ClaudeSession::from_remote_json("remote-w", &json).unwrap();
1108        let output = session.to_json_value();
1109        assert_eq!(
1110            output.get("worker_origin").and_then(|v| v.as_str()),
1111            Some("remote-w")
1112        );
1113    }
1114}