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 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>, 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 pub own_cache_write_1h_tokens: u64,
111 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 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 pub burn_rate_ewma: f64,
145 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>, pub files_modified: HashMap<String, u32>, pub tool_usage: HashMap<String, ToolStats>, pub worktree_id: Option<String>, pub telemetry_status: TelemetryStatus,
157 pub usage_metrics_available: bool,
158 pub cost_estimate_unverified: bool,
159 pub model_profile_source: String,
160 pub last_msg_type: String,
162 pub last_stop_reason: String,
163 pub is_waiting_for_task: bool,
164 pub pending_tool_name: Option<String>,
166 pub pending_tool_input: Option<String>, pub pending_file_path: Option<String>, pub has_file_conflict: bool, pub last_tool_error: bool,
170 pub last_error_message: Option<String>,
171 pub recent_errors: Vec<ErrorEntry>, pub total_tokens_at_edit_count: u64,
175 pub edit_event_count: u32,
177 pub baseline_tokens_per_edit: Option<f64>,
179 pub error_counts_per_window: Vec<u32>, pub current_window_errors: u32,
183 pub window_tick_counter: u32,
185 pub baseline_error_rate: Option<f64>,
187 pub file_reads_since_edit: HashMap<String, u32>,
189 pub total_error_count: u32,
191 pub decay_score: u32,
193 pub worker_origin: Option<String>,
196}
197
198#[derive(Debug, Clone)]
200pub struct ErrorEntry {
201 pub tool_name: String,
202 pub message: String,
203}
204
205#[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 pub cache_write_1h_tokens: u64,
220 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 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 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 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 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 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 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 pub fn is_remote(&self) -> bool {
488 self.worker_origin.is_some()
489 }
490
491 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 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 session.context_max = 200_000; 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 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 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 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
869pub 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 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; 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 #[test]
1025 fn error_window_flush() {
1026 let mut s = make_session();
1027 s.current_window_errors = 3;
1028 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 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 assert!((baseline - 3.0).abs() < 0.01);
1052
1053 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 #[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 let json = serde_json::json!({"project": "x", "status": "Idle"});
1113 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1114
1115 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}