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, Processing, WaitingInput, Unknown, Idle, Finished, }
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>, 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 own_cache_write_1h_tokens: u64,
110 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 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 pub burn_rate_ewma: f64,
144 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>, pub files_modified: HashMap<String, u32>, pub tool_usage: HashMap<String, ToolStats>, pub worktree_id: Option<String>, pub telemetry_status: TelemetryStatus,
156 pub usage_metrics_available: bool,
157 pub cost_estimate_unverified: bool,
158 pub model_profile_source: String,
159 pub last_msg_type: String,
161 pub last_stop_reason: String,
162 pub is_waiting_for_task: bool,
163 pub pending_tool_name: Option<String>,
165 pub pending_tool_input: Option<String>, pub pending_file_path: Option<String>, pub has_file_conflict: bool, pub last_tool_error: bool,
169 pub last_error_message: Option<String>,
170 pub recent_errors: Vec<ErrorEntry>, pub total_tokens_at_edit_count: u64,
174 pub edit_event_count: u32,
176 pub baseline_tokens_per_edit: Option<f64>,
178 pub error_counts_per_window: Vec<u32>, pub current_window_errors: u32,
182 pub window_tick_counter: u32,
184 pub baseline_error_rate: Option<f64>,
186 pub file_reads_since_edit: HashMap<String, u32>,
188 pub total_error_count: u32,
190 pub decay_score: u32,
192 pub worker_origin: Option<String>,
195}
196
197#[derive(Debug, Clone)]
199pub struct ErrorEntry {
200 pub tool_name: String,
201 pub message: String,
202}
203
204#[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 pub cache_write_1h_tokens: u64,
219 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 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 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 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 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 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 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 pub fn is_remote(&self) -> bool {
487 self.worker_origin.is_some()
488 }
489
490 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 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 session.context_max = 200_000; 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 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 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 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
868pub 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 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; 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 #[test]
1024 fn error_window_flush() {
1025 let mut s = make_session();
1026 s.current_window_errors = 3;
1027 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 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 assert!((baseline - 3.0).abs() < 0.01);
1051
1052 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 #[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 let json = serde_json::json!({"project": "x", "status": "Idle"});
1112 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1113
1114 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}