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    pub fn from_remote_json(worker_id: &str, json: &serde_json::Value) -> Option<Self> {
469        let pid = json.get("pid")?.as_u64()? as u32;
470        let project = json.get("project")?.as_str()?;
471        let status_str = json
472            .get("status")
473            .and_then(|v| v.as_str())
474            .unwrap_or("Unknown");
475        let status = match status_str {
476            "Needs Input" => SessionStatus::NeedsInput,
477            "Processing" => SessionStatus::Processing,
478            "Waiting" => SessionStatus::WaitingInput,
479            "Idle" => SessionStatus::Idle,
480            "Finished" => SessionStatus::Finished,
481            _ => SessionStatus::Unknown,
482        };
483
484        let elapsed_secs = json
485            .get("elapsed_secs")
486            .and_then(|v| v.as_u64())
487            .unwrap_or(0);
488        let now_ms = std::time::SystemTime::now()
489            .duration_since(std::time::UNIX_EPOCH)
490            .unwrap_or_default()
491            .as_millis() as u64;
492
493        let mut session = Self::from_raw(RawSession {
494            pid,
495            session_id: format!("remote-{worker_id}-{pid}"),
496            cwd: project.to_string(),
497            started_at: now_ms.saturating_sub(elapsed_secs * 1000),
498        });
499        session.status = status;
500        session.worker_origin = Some(worker_id.to_string());
501        session.project_name = format!("[{worker_id}] {project}");
502
503        // Populate metrics from JSON when available
504        if let Some(cost) = json.get("cost_usd").and_then(|v| v.as_f64()) {
505            session.cost_usd = cost;
506            session.usage_metrics_available = true;
507        }
508        if let Some(burn) = json.get("burn_rate_per_hr").and_then(|v| v.as_f64()) {
509            session.burn_rate_per_hr = burn;
510        }
511        if let Some(ctx) = json.get("context_pct").and_then(|v| v.as_f64()) {
512            // Reverse-engineer context_tokens/context_max from percentage
513            session.context_max = 200_000; // reasonable default
514            session.context_tokens = ((ctx / 100.0) * session.context_max as f64) as u64;
515        }
516        if let Some(t_in) = json.get("tokens_in").and_then(|v| v.as_u64()) {
517            session.total_input_tokens = t_in;
518            session.usage_metrics_available = true;
519        }
520        if let Some(t_out) = json.get("tokens_out").and_then(|v| v.as_u64()) {
521            session.total_output_tokens = t_out;
522        }
523        if let Some(model) = json.get("model").and_then(|v| v.as_str()) {
524            session.model = model.to_string();
525        }
526        if let Some(subs) = json.get("subagents").and_then(|v| v.as_u64()) {
527            session.subagent_count = subs as usize;
528        }
529        if let Some(decay) = json.get("decay_score").and_then(|v| v.as_u64()) {
530            session.decay_score = decay as u32;
531        }
532
533        Some(session)
534    }
535
536    pub fn format_subagent_summary(&self) -> String {
537        if self.subagent_count == 0 {
538            return "0".to_string();
539        }
540        if self.active_subagent_count == 0 || self.active_subagent_count == self.subagent_count {
541            return self.subagent_count.to_string();
542        }
543        format!(
544            "{} total ({} active)",
545            self.subagent_count, self.active_subagent_count
546        )
547    }
548
549    pub fn subagent_breakdown(&self) -> Vec<SubagentBreakdown> {
550        if self.subagent_rollups.is_empty() {
551            return Vec::new();
552        }
553
554        let active_paths: HashSet<&PathBuf> = self.active_subagent_jsonl_paths.iter().collect();
555        let mut active_rows = Vec::new();
556        let mut completed_rows = Vec::new();
557
558        for (path, rollup) in &self.subagent_rollups {
559            let row = SubagentBreakdown {
560                label: subagent_label(path),
561                state: if active_paths.contains(path) {
562                    SubagentState::Active
563                } else {
564                    SubagentState::Completed
565                },
566                count: 1,
567                input_tokens: rollup.input_tokens,
568                output_tokens: rollup.output_tokens,
569                cache_read_tokens: rollup.cache_read_tokens,
570                cache_write_tokens: rollup.cache_write_tokens,
571                cost_usd: rollup.cost_usd,
572                usage_metrics_available: rollup.usage_metrics_available,
573                cost_estimate_unverified: rollup.cost_estimate_unverified,
574            };
575
576            if row.state == SubagentState::Active {
577                active_rows.push(row);
578            } else {
579                completed_rows.push(row);
580            }
581        }
582
583        active_rows.sort_by(|a, b| a.label.cmp(&b.label));
584
585        let mut rows = Vec::new();
586        if !completed_rows.is_empty() {
587            let mut aggregate = SubagentBreakdown {
588                label: "completed".to_string(),
589                state: SubagentState::Completed,
590                count: completed_rows.len(),
591                input_tokens: 0,
592                output_tokens: 0,
593                cache_read_tokens: 0,
594                cache_write_tokens: 0,
595                cost_usd: 0.0,
596                usage_metrics_available: false,
597                cost_estimate_unverified: false,
598            };
599
600            for row in completed_rows {
601                aggregate.input_tokens += row.input_tokens;
602                aggregate.output_tokens += row.output_tokens;
603                aggregate.cache_read_tokens += row.cache_read_tokens;
604                aggregate.cache_write_tokens += row.cache_write_tokens;
605                aggregate.cost_usd += row.cost_usd;
606                aggregate.usage_metrics_available |= row.usage_metrics_available;
607                aggregate.cost_estimate_unverified |= row.cost_estimate_unverified;
608            }
609
610            rows.push(aggregate);
611        }
612
613        rows.extend(active_rows);
614        rows
615    }
616
617    pub fn format_elapsed(&self) -> String {
618        let secs = self.elapsed.as_secs();
619        let h = secs / 3600;
620        let m = (secs % 3600) / 60;
621        let s = secs % 60;
622        if h > 0 {
623            format!("{h:02}:{m:02}:{s:02}")
624        } else {
625            format!("{m:02}:{s:02}")
626        }
627    }
628
629    pub fn format_tokens(&self) -> String {
630        if !self.usage_metrics_available {
631            return "n/a".to_string();
632        }
633        let total = self.total_input_tokens + self.total_output_tokens;
634        if total == 0 {
635            return String::from("-");
636        }
637        format_count(self.total_input_tokens) + "/" + &format_count(self.total_output_tokens)
638    }
639
640    pub fn format_mem(&self) -> String {
641        if self.mem_mb < 1.0 {
642            return String::from("-");
643        }
644        format!("{:.0}M", self.mem_mb)
645    }
646
647    pub fn format_cost(&self) -> String {
648        if !self.usage_metrics_available {
649            return "n/a".to_string();
650        }
651        if self.cost_usd < 0.01 {
652            return String::from("-");
653        }
654        if self.cost_usd < 1.0 {
655            format!(
656                "${:.2}{}",
657                self.cost_usd,
658                if self.cost_estimate_unverified {
659                    "?"
660                } else {
661                    ""
662                }
663            )
664        } else {
665            format!(
666                "${:.1}{}",
667                self.cost_usd,
668                if self.cost_estimate_unverified {
669                    "?"
670                } else {
671                    ""
672                }
673            )
674        }
675    }
676
677    pub fn context_percent(&self) -> f64 {
678        if !self.usage_metrics_available {
679            return 0.0;
680        }
681        if self.context_max == 0 || self.context_tokens == 0 {
682            return 0.0;
683        }
684        (self.context_tokens as f64 / self.context_max as f64) * 100.0
685    }
686
687    /// Format context as "450k/1M 45%" or a visual bar
688    pub fn format_context(&self) -> String {
689        if !self.usage_metrics_available {
690            return "n/a".to_string();
691        }
692        if self.context_tokens == 0 {
693            return String::from("-");
694        }
695        let pct = self.context_percent();
696        format!("{}%", pct as u32)
697    }
698
699    /// Visual bar for context usage: ████░░ 62%
700    pub fn format_context_bar(&self, width: usize) -> String {
701        if !self.usage_metrics_available {
702            return "n/a".to_string();
703        }
704        let pct = self.context_percent();
705        if pct == 0.0 {
706            return String::from("-");
707        }
708        let filled = ((pct / 100.0) * width as f64).round() as usize;
709        let empty = width.saturating_sub(filled);
710        format!(
711            "{}{} {}%",
712            "█".repeat(filled),
713            "░".repeat(empty),
714            pct as u32
715        )
716    }
717
718    /// Produce a JSON-serializable value for --json export.
719    pub fn to_json_value(&self) -> serde_json::Value {
720        let cost_usd = if self.usage_metrics_available {
721            serde_json::json!((self.cost_usd * 100.0).round() / 100.0)
722        } else {
723            serde_json::Value::Null
724        };
725        let burn_rate = if self.usage_metrics_available {
726            serde_json::json!((self.burn_rate_per_hr * 100.0).round() / 100.0)
727        } else {
728            serde_json::Value::Null
729        };
730        let context_pct = if self.usage_metrics_available {
731            serde_json::json!((self.context_percent() * 100.0).round() / 100.0)
732        } else {
733            serde_json::Value::Null
734        };
735        let tokens_in = if self.usage_metrics_available {
736            serde_json::json!(self.total_input_tokens)
737        } else {
738            serde_json::Value::Null
739        };
740        let tokens_out = if self.usage_metrics_available {
741            serde_json::json!(self.total_output_tokens)
742        } else {
743            serde_json::Value::Null
744        };
745
746        serde_json::json!({
747            "pid": self.pid,
748            "project": self.display_name(),
749            "status": self.status.to_string(),
750            "telemetry": {
751                "state": self.telemetry_status.label(),
752                "usage_metrics_available": self.usage_metrics_available,
753            },
754            "estimate": {
755                "verified": !self.cost_estimate_unverified,
756                "profile_source": self.model_profile_source,
757            },
758            "context_pct": context_pct,
759            "cost_usd": cost_usd,
760            "burn_rate_per_hr": burn_rate,
761            "elapsed_secs": self.elapsed.as_secs(),
762            "cpu": self.cpu_percent,
763            "mem_mb": (self.mem_mb * 100.0).round() / 100.0,
764            "tokens_in": tokens_in,
765            "tokens_out": tokens_out,
766            "subagents": self.subagent_count,
767            "active_subagents": self.active_subagent_count,
768            "subagent_breakdown": self.subagent_breakdown().into_iter().map(|row| {
769                serde_json::json!({
770                    "label": row.display_label(),
771                    "state": row.state_label(),
772                    "count": row.count,
773                    "tokens_in": if row.usage_metrics_available {
774                        serde_json::json!(row.total_input_tokens())
775                    } else {
776                        serde_json::Value::Null
777                    },
778                    "tokens_out": if row.usage_metrics_available {
779                        serde_json::json!(row.output_tokens)
780                    } else {
781                        serde_json::Value::Null
782                    },
783                    "cost_usd": if row.usage_metrics_available {
784                        serde_json::json!((row.cost_usd * 100.0).round() / 100.0)
785                    } else {
786                        serde_json::Value::Null
787                    },
788                })
789            }).collect::<Vec<_>>(),
790            "decay_score": if self.usage_metrics_available { serde_json::json!(self.decay_score) } else { serde_json::Value::Null },
791            "last_error": self.last_error_message,
792            "recent_errors": self.recent_errors.iter().map(|e| {
793                serde_json::json!({
794                    "tool": e.tool_name,
795                    "message": e.message,
796                })
797            }).collect::<Vec<_>>(),
798            "files_modified": self.files_modified,
799            "tool_usage": self.tool_usage.iter().map(|(k, v)| {
800                (k.clone(), serde_json::json!({"calls": v.calls}))
801            }).collect::<serde_json::Map<String, serde_json::Value>>(),
802            "worker_origin": self.worker_origin,
803        })
804    }
805
806    pub fn format_burn_rate(&self) -> String {
807        if !self.usage_metrics_available {
808            return "n/a".to_string();
809        }
810        if self.burn_rate_per_hr < 0.01 {
811            return String::from("-");
812        }
813        if self.burn_rate_per_hr < 1.0 {
814            format!(
815                "${:.2}/h{}",
816                self.burn_rate_per_hr,
817                if self.cost_estimate_unverified {
818                    "?"
819                } else {
820                    ""
821                }
822            )
823        } else {
824            format!(
825                "${:.1}/h{}",
826                self.burn_rate_per_hr,
827                if self.cost_estimate_unverified {
828                    "?"
829                } else {
830                    ""
831                }
832            )
833        }
834    }
835
836    pub fn telemetry_label(&self) -> &'static str {
837        self.telemetry_status.label()
838    }
839
840    pub fn has_usage_metrics(&self) -> bool {
841        self.usage_metrics_available
842    }
843}
844
845/// Truncate a string to at most `max_bytes` bytes, landing on a valid
846/// UTF-8 character boundary. Returns the original string if already short enough.
847pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
848    if s.len() <= max_bytes {
849        return s;
850    }
851    let mut end = max_bytes;
852    while end > 0 && !s.is_char_boundary(end) {
853        end -= 1;
854    }
855    &s[..end]
856}
857
858fn format_count(n: u64) -> String {
859    if n >= 1_000_000 {
860        format!("{:.1}M", n as f64 / 1_000_000.0)
861    } else if n >= 1_000 {
862        format!("{:.1}k", n as f64 / 1_000.0)
863    } else {
864        n.to_string()
865    }
866}
867
868fn subagent_label(path: &Path) -> String {
869    let components: Vec<String> = path
870        .components()
871        .map(|component| component.as_os_str().to_string_lossy().to_string())
872        .collect();
873
874    if let Some(tasks_idx) = components.iter().position(|component| component == "tasks") {
875        let relative = &components[tasks_idx + 1..];
876        if !relative.is_empty() {
877            let mut label = relative.join("/");
878            if let Some(stripped) = label.strip_suffix(".jsonl") {
879                label = stripped.to_string();
880            }
881            return label;
882        }
883    }
884
885    path.file_stem()
886        .and_then(|stem| stem.to_str())
887        .unwrap_or("subagent")
888        .to_string()
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894
895    fn make_session() -> ClaudeSession {
896        ClaudeSession::from_raw(RawSession {
897            pid: 1,
898            session_id: "session-1".into(),
899            cwd: "/tmp/project".into(),
900            started_at: 0,
901        })
902    }
903
904    #[test]
905    fn record_burn_sample_smooths_and_caps_window() {
906        let mut s = make_session();
907        // A steady burn settles the EWMA near the true rate and fills the window.
908        for _ in 0..crate::forecast::BURN_SAMPLE_CAP + 10 {
909            s.burn_rate_per_hr = 6.0;
910            s.record_burn_sample(2.0);
911        }
912        assert_eq!(s.burn_samples.len(), crate::forecast::BURN_SAMPLE_CAP);
913        assert!(
914            (s.burn_rate_ewma - 6.0).abs() < 0.5,
915            "ewma: {}",
916            s.burn_rate_ewma
917        );
918    }
919
920    #[test]
921    fn budget_eta_forecasts_time_to_cap() {
922        let mut s = make_session();
923        for _ in 0..20 {
924            s.burn_rate_per_hr = 5.0;
925            s.record_burn_sample(2.0);
926        }
927        s.cost_usd = 5.0; // $5 spent of a $15 cap ⇒ $10 headroom at ~$5/hr ≈ 2h
928        let band = s.budget_eta(15.0);
929        let mid = band.mid_hours.expect("burning, so an ETA exists");
930        assert!((mid - 2.0).abs() < 0.4, "eta hours: {mid}");
931    }
932
933    #[test]
934    fn subagent_breakdown_groups_completed_and_lists_active_rows() {
935        let mut session = make_session();
936        let completed = PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/agent-1.jsonl");
937        let active =
938            PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/nested/agent-2.jsonl");
939
940        session.active_subagent_jsonl_paths = vec![active.clone()];
941        session.subagent_rollups.insert(
942            completed,
943            SubagentRollup {
944                input_tokens: 10_000,
945                output_tokens: 2_000,
946                cost_usd: 0.25,
947                usage_metrics_available: true,
948                ..SubagentRollup::default()
949            },
950        );
951        session.subagent_rollups.insert(
952            active,
953            SubagentRollup {
954                input_tokens: 40_000,
955                output_tokens: 8_000,
956                cost_usd: 1.5,
957                usage_metrics_available: true,
958                ..SubagentRollup::default()
959            },
960        );
961
962        let rows = session.subagent_breakdown();
963        assert_eq!(rows.len(), 2);
964        assert_eq!(rows[0].display_label(), "completed");
965        assert_eq!(rows[0].state, SubagentState::Completed);
966        assert_eq!(rows[0].count, 1);
967        assert_eq!(rows[0].format_tokens(), "10.0k/2.0k");
968        assert_eq!(rows[1].display_label(), "nested/agent-2");
969        assert_eq!(rows[1].state, SubagentState::Active);
970        assert_eq!(rows[1].format_cost(), "$1.5");
971    }
972
973    #[test]
974    fn subagent_breakdown_collapses_multiple_completed_rows() {
975        let mut session = make_session();
976
977        for name in ["agent-1.jsonl", "agent-2.jsonl"] {
978            let path = PathBuf::from(format!("/tmp/claude-1/-tmp-project/session-1/tasks/{name}"));
979            session.subagent_rollups.insert(
980                path,
981                SubagentRollup {
982                    input_tokens: 10_000,
983                    output_tokens: 1_000,
984                    cost_usd: 0.2,
985                    usage_metrics_available: true,
986                    ..SubagentRollup::default()
987                },
988            );
989        }
990
991        let rows = session.subagent_breakdown();
992        assert_eq!(rows.len(), 1);
993        assert_eq!(rows[0].display_label(), "completed (2)");
994        assert_eq!(rows[0].count, 2);
995        assert_eq!(rows[0].format_tokens(), "20.0k/2.0k");
996    }
997
998    // ── Cognitive health tracking tests ──────────────────────────────
999
1000    #[test]
1001    fn error_window_flush() {
1002        let mut s = make_session();
1003        s.current_window_errors = 3;
1004        // Call record_activity 5 times to trigger one window flush
1005        for _ in 0..5 {
1006            s.record_activity();
1007        }
1008        assert_eq!(s.error_counts_per_window.len(), 1);
1009        assert_eq!(s.error_counts_per_window[0], 3);
1010        assert_eq!(s.current_window_errors, 0);
1011        assert_eq!(s.window_tick_counter, 0);
1012    }
1013
1014    #[test]
1015    fn baseline_error_rate_freezes() {
1016        let mut s = make_session();
1017        // Simulate 3 windows of errors
1018        for errors in [2, 3, 4] {
1019            s.current_window_errors = errors;
1020            for _ in 0..5 {
1021                s.record_activity();
1022            }
1023        }
1024        assert_eq!(s.error_counts_per_window.len(), 3);
1025        let baseline = s.baseline_error_rate.expect("baseline should be set");
1026        // baseline = (2+3+4)/3 = 3.0
1027        assert!((baseline - 3.0).abs() < 0.01);
1028
1029        // Add another window — baseline should NOT change
1030        s.current_window_errors = 10;
1031        for _ in 0..5 {
1032            s.record_activity();
1033        }
1034        assert_eq!(s.baseline_error_rate.unwrap(), baseline);
1035    }
1036
1037    // ── Remote session tests ────────────────────────────────────────
1038
1039    #[test]
1040    fn local_session_is_not_remote() {
1041        let s = make_session();
1042        assert!(!s.is_remote());
1043        assert!(s.worker_origin.is_none());
1044    }
1045
1046    #[test]
1047    fn from_remote_json_parses_basic_fields() {
1048        let json = serde_json::json!({
1049            "pid": 42,
1050            "project": "backend",
1051            "status": "Processing",
1052            "cost_usd": 1.23,
1053            "elapsed_secs": 600,
1054            "tokens_in": 50000,
1055            "tokens_out": 10000,
1056        });
1057        let session = ClaudeSession::from_remote_json("macbook-02", &json).unwrap();
1058        assert!(session.is_remote());
1059        assert_eq!(session.worker_origin.as_deref(), Some("macbook-02"));
1060        assert_eq!(session.pid, 42);
1061        assert_eq!(session.project_name, "[macbook-02] backend");
1062        assert_eq!(session.status, SessionStatus::Processing);
1063        assert!((session.cost_usd - 1.23).abs() < 0.01);
1064        assert_eq!(session.total_input_tokens, 50000);
1065        assert_eq!(session.total_output_tokens, 10000);
1066        assert!(session.usage_metrics_available);
1067    }
1068
1069    #[test]
1070    fn from_remote_json_handles_all_statuses() {
1071        for (label, expected) in [
1072            ("Needs Input", SessionStatus::NeedsInput),
1073            ("Processing", SessionStatus::Processing),
1074            ("Waiting", SessionStatus::WaitingInput),
1075            ("Idle", SessionStatus::Idle),
1076            ("Finished", SessionStatus::Finished),
1077            ("SomethingElse", SessionStatus::Unknown),
1078        ] {
1079            let json = serde_json::json!({"pid": 1, "project": "p", "status": label});
1080            let session = ClaudeSession::from_remote_json("w", &json).unwrap();
1081            assert_eq!(session.status, expected, "status mismatch for {label}");
1082        }
1083    }
1084
1085    #[test]
1086    fn from_remote_json_returns_none_on_missing_fields() {
1087        // Missing pid
1088        let json = serde_json::json!({"project": "x", "status": "Idle"});
1089        assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1090
1091        // Missing project
1092        let json = serde_json::json!({"pid": 1, "status": "Idle"});
1093        assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1094    }
1095
1096    #[test]
1097    fn remote_session_display_name_shows_worker_prefix() {
1098        let json = serde_json::json!({"pid": 1, "project": "api-server", "status": "Idle"});
1099        let session = ClaudeSession::from_remote_json("laptop-01", &json).unwrap();
1100        assert_eq!(session.display_name(), "[laptop-01] api-server");
1101    }
1102
1103    #[test]
1104    fn remote_session_json_includes_worker_origin() {
1105        let json = serde_json::json!({"pid": 1, "project": "test", "status": "Idle"});
1106        let session = ClaudeSession::from_remote_json("remote-w", &json).unwrap();
1107        let output = session.to_json_value();
1108        assert_eq!(
1109            output.get("worker_origin").and_then(|v| v.as_str()),
1110            Some("remote-w")
1111        );
1112    }
1113}