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