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