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