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 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 burn_rate_ewma: f64,
129 pub burn_samples: std::collections::VecDeque<f64>,
132 pub subagent_count: usize,
133 pub active_subagent_count: usize,
134 pub active_subagent_jsonl_paths: Vec<PathBuf>,
135 pub subagent_rollups: HashMap<PathBuf, SubagentRollup>,
136 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,
141 pub usage_metrics_available: bool,
142 pub cost_estimate_unverified: bool,
143 pub model_profile_source: String,
144 pub last_msg_type: String,
146 pub last_stop_reason: String,
147 pub is_waiting_for_task: bool,
148 pub pending_tool_name: Option<String>,
150 pub pending_tool_input: Option<String>, pub pending_file_path: Option<String>, pub has_file_conflict: bool, pub last_tool_error: bool,
154 pub last_error_message: Option<String>,
155 pub recent_errors: Vec<ErrorEntry>, pub total_tokens_at_edit_count: u64,
159 pub edit_event_count: u32,
161 pub baseline_tokens_per_edit: Option<f64>,
163 pub error_counts_per_window: Vec<u32>, pub current_window_errors: u32,
167 pub window_tick_counter: u32,
169 pub baseline_error_rate: Option<f64>,
171 pub file_reads_since_edit: HashMap<String, u32>,
173 pub total_error_count: u32,
175 pub decay_score: u32,
177 pub worker_origin: Option<String>,
180}
181
182#[derive(Debug, Clone)]
184pub struct ErrorEntry {
185 pub tool_name: String,
186 pub message: String,
187}
188
189#[derive(Debug, Clone, Default)]
191pub struct ToolStats {
192 pub calls: u32,
193}
194
195#[derive(Debug, Clone, Default)]
196pub struct SubagentRollup {
197 pub jsonl_offset: u64,
198 pub input_tokens: u64,
199 pub output_tokens: u64,
200 pub cache_read_tokens: u64,
201 pub cache_write_tokens: u64,
202 pub cost_usd: f64,
203 pub model: String,
204 pub cost_estimate_unverified: bool,
205 pub usage_metrics_available: bool,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum SubagentState {
210 Active,
211 Completed,
212}
213
214#[derive(Debug, Clone)]
215pub struct SubagentBreakdown {
216 pub label: String,
217 pub state: SubagentState,
218 pub count: usize,
219 pub input_tokens: u64,
220 pub output_tokens: u64,
221 pub cache_read_tokens: u64,
222 pub cache_write_tokens: u64,
223 pub cost_usd: f64,
224 pub usage_metrics_available: bool,
225 pub cost_estimate_unverified: bool,
226}
227
228impl SubagentBreakdown {
229 pub fn total_input_tokens(&self) -> u64 {
230 self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
231 }
232
233 pub fn state_label(&self) -> String {
234 match self.state {
235 SubagentState::Active => "Active".to_string(),
236 SubagentState::Completed if self.count > 1 => format!("Completed ({})", self.count),
237 SubagentState::Completed => "Completed".to_string(),
238 }
239 }
240
241 pub fn display_label(&self) -> String {
242 if self.state == SubagentState::Completed && self.label == "completed" && self.count > 1 {
243 format!("completed ({})", self.count)
244 } else {
245 self.label.clone()
246 }
247 }
248
249 pub fn format_tokens(&self) -> String {
250 if !self.usage_metrics_available {
251 return "n/a".to_string();
252 }
253 let total = self.total_input_tokens() + self.output_tokens;
254 if total == 0 {
255 return "-".to_string();
256 }
257 format_count(self.total_input_tokens()) + "/" + &format_count(self.output_tokens)
258 }
259
260 pub fn format_cost(&self) -> String {
261 if !self.usage_metrics_available {
262 return "n/a".to_string();
263 }
264 if self.cost_usd < 0.01 {
265 return "-".to_string();
266 }
267 if self.cost_usd < 1.0 {
268 format!(
269 "${:.2}{}",
270 self.cost_usd,
271 if self.cost_estimate_unverified {
272 "?"
273 } else {
274 ""
275 }
276 )
277 } else {
278 format!(
279 "${:.1}{}",
280 self.cost_usd,
281 if self.cost_estimate_unverified {
282 "?"
283 } else {
284 ""
285 }
286 )
287 }
288 }
289}
290
291impl ClaudeSession {
292 pub fn from_raw(raw: RawSession) -> Self {
293 let project_name = raw.cwd.rsplit('/').next().unwrap_or("unknown").to_string();
294
295 let now_ms = std::time::SystemTime::now()
296 .duration_since(std::time::UNIX_EPOCH)
297 .unwrap_or_default()
298 .as_millis() as u64;
299 let elapsed_ms = now_ms.saturating_sub(raw.started_at);
300 let elapsed = Duration::from_millis(elapsed_ms);
301
302 Self {
303 pid: raw.pid,
304 session_id: raw.session_id,
305 cwd: raw.cwd,
306 project_name,
307 started_at: raw.started_at,
308 elapsed,
309 tty: String::new(),
310 status: SessionStatus::Idle,
311 cpu_percent: 0.0,
312 cpu_history: Vec::new(),
313 mem_mb: 0.0,
314 own_input_tokens: 0,
315 own_output_tokens: 0,
316 own_cache_read_tokens: 0,
317 own_cache_write_tokens: 0,
318 subagent_input_tokens: 0,
319 subagent_output_tokens: 0,
320 subagent_cache_read_tokens: 0,
321 subagent_cache_write_tokens: 0,
322 total_input_tokens: 0,
323 total_output_tokens: 0,
324 model: String::new(),
325 command_args: String::new(),
326 session_name: String::new(),
327 jsonl_path: None,
328 jsonl_offset: 0,
329 last_message_ts: 0,
330 cache_read_tokens: 0,
331 cache_write_tokens: 0,
332 cost_usd: 0.0,
333 context_tokens: 0,
334 context_max: 0,
335 prev_cost_usd: 0.0,
336 burn_rate_per_hr: 0.0,
337 burn_rate_ewma: 0.0,
338 burn_samples: std::collections::VecDeque::new(),
339 subagent_count: 0,
340 active_subagent_count: 0,
341 active_subagent_jsonl_paths: Vec::new(),
342 subagent_rollups: HashMap::new(),
343 activity_history: Vec::new(),
344 files_modified: HashMap::new(),
345 tool_usage: HashMap::new(),
346 worktree_id: None,
347 telemetry_status: TelemetryStatus::Pending,
348 usage_metrics_available: false,
349 cost_estimate_unverified: false,
350 model_profile_source: "built-in".into(),
351 last_msg_type: String::new(),
352 last_stop_reason: String::new(),
353 is_waiting_for_task: false,
354 pending_tool_name: None,
355 pending_tool_input: None,
356 pending_file_path: None,
357 has_file_conflict: false,
358 last_tool_error: false,
359 last_error_message: None,
360 recent_errors: Vec::new(),
361 total_tokens_at_edit_count: 0,
362 edit_event_count: 0,
363 baseline_tokens_per_edit: None,
364 error_counts_per_window: Vec::new(),
365 current_window_errors: 0,
366 window_tick_counter: 0,
367 baseline_error_rate: None,
368 file_reads_since_edit: HashMap::new(),
369 total_error_count: 0,
370 decay_score: 0,
371 worker_origin: None,
372 }
373 }
374
375 pub fn record_activity(&mut self) {
378 let level = match self.status {
379 SessionStatus::Processing => 7,
380 SessionStatus::NeedsInput => 4,
381 SessionStatus::WaitingInput => 2,
382 SessionStatus::Unknown => 2,
383 SessionStatus::Idle => 1,
384 SessionStatus::Finished => 0,
385 };
386 self.activity_history.push(level);
387 if self.activity_history.len() > 15 {
388 self.activity_history.remove(0);
389 }
390
391 self.window_tick_counter += 1;
393 if self.window_tick_counter >= 5 {
394 self.error_counts_per_window
395 .push(self.current_window_errors);
396 if self.error_counts_per_window.len() > 10 {
397 self.error_counts_per_window.remove(0);
398 }
399 if self.baseline_error_rate.is_none() && self.error_counts_per_window.len() >= 3 {
401 let sum: u32 = self.error_counts_per_window.iter().sum();
402 self.baseline_error_rate =
403 Some(sum as f64 / self.error_counts_per_window.len() as f64);
404 }
405 self.current_window_errors = 0;
406 self.window_tick_counter = 0;
407 }
408 }
409
410 pub fn record_burn_sample(&mut self, interval_secs: f64) {
414 let sample = self.burn_rate_per_hr;
415 let alpha = crate::forecast::alpha_for_half_life(
416 crate::forecast::BURN_HALF_LIFE_SECS,
417 interval_secs,
418 );
419 let prev = if self.burn_samples.is_empty() {
420 None
421 } else {
422 Some(self.burn_rate_ewma)
423 };
424 self.burn_rate_ewma = crate::forecast::ewma(prev, sample, alpha);
425 self.burn_samples.push_back(sample);
426 while self.burn_samples.len() > crate::forecast::BURN_SAMPLE_CAP {
427 self.burn_samples.pop_front();
428 }
429 }
430
431 pub fn budget_eta(&self, budget_usd: f64) -> crate::forecast::EtaBand {
434 let headroom = budget_usd - self.cost_usd;
435 let samples: Vec<f64> = self.burn_samples.iter().copied().collect();
436 crate::forecast::budget_eta_band(headroom, self.burn_rate_ewma, &samples)
437 }
438
439 pub fn format_sparkline(&self) -> String {
441 const BLOCKS: &[char] = &[
442 ' ', '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}',
443 '\u{2587}', '\u{2588}',
444 ];
445 if self.activity_history.is_empty() {
446 return String::from("-");
447 }
448 self.activity_history
449 .iter()
450 .map(|&level| BLOCKS[level.min(8) as usize])
451 .collect()
452 }
453
454 pub fn display_name(&self) -> &str {
455 if !self.session_name.is_empty() {
456 &self.session_name
457 } else {
458 &self.project_name
459 }
460 }
461
462 pub fn is_remote(&self) -> bool {
464 self.worker_origin.is_some()
465 }
466
467 #[allow(dead_code)]
469 pub fn from_remote_json(worker_id: &str, json: &serde_json::Value) -> Option<Self> {
470 let pid = json.get("pid")?.as_u64()? as u32;
471 let project = json.get("project")?.as_str()?;
472 let status_str = json
473 .get("status")
474 .and_then(|v| v.as_str())
475 .unwrap_or("Unknown");
476 let status = match status_str {
477 "Needs Input" => SessionStatus::NeedsInput,
478 "Processing" => SessionStatus::Processing,
479 "Waiting" => SessionStatus::WaitingInput,
480 "Idle" => SessionStatus::Idle,
481 "Finished" => SessionStatus::Finished,
482 _ => SessionStatus::Unknown,
483 };
484
485 let elapsed_secs = json
486 .get("elapsed_secs")
487 .and_then(|v| v.as_u64())
488 .unwrap_or(0);
489 let now_ms = std::time::SystemTime::now()
490 .duration_since(std::time::UNIX_EPOCH)
491 .unwrap_or_default()
492 .as_millis() as u64;
493
494 let mut session = Self::from_raw(RawSession {
495 pid,
496 session_id: format!("remote-{worker_id}-{pid}"),
497 cwd: project.to_string(),
498 started_at: now_ms.saturating_sub(elapsed_secs * 1000),
499 });
500 session.status = status;
501 session.worker_origin = Some(worker_id.to_string());
502 session.project_name = format!("[{worker_id}] {project}");
503
504 if let Some(cost) = json.get("cost_usd").and_then(|v| v.as_f64()) {
506 session.cost_usd = cost;
507 session.usage_metrics_available = true;
508 }
509 if let Some(burn) = json.get("burn_rate_per_hr").and_then(|v| v.as_f64()) {
510 session.burn_rate_per_hr = burn;
511 }
512 if let Some(ctx) = json.get("context_pct").and_then(|v| v.as_f64()) {
513 session.context_max = 200_000; session.context_tokens = ((ctx / 100.0) * session.context_max as f64) as u64;
516 }
517 if let Some(t_in) = json.get("tokens_in").and_then(|v| v.as_u64()) {
518 session.total_input_tokens = t_in;
519 session.usage_metrics_available = true;
520 }
521 if let Some(t_out) = json.get("tokens_out").and_then(|v| v.as_u64()) {
522 session.total_output_tokens = t_out;
523 }
524 if let Some(model) = json.get("model").and_then(|v| v.as_str()) {
525 session.model = model.to_string();
526 }
527 if let Some(subs) = json.get("subagents").and_then(|v| v.as_u64()) {
528 session.subagent_count = subs as usize;
529 }
530 if let Some(decay) = json.get("decay_score").and_then(|v| v.as_u64()) {
531 session.decay_score = decay as u32;
532 }
533
534 Some(session)
535 }
536
537 pub fn format_subagent_summary(&self) -> String {
538 if self.subagent_count == 0 {
539 return "0".to_string();
540 }
541 if self.active_subagent_count == 0 || self.active_subagent_count == self.subagent_count {
542 return self.subagent_count.to_string();
543 }
544 format!(
545 "{} total ({} active)",
546 self.subagent_count, self.active_subagent_count
547 )
548 }
549
550 pub fn subagent_breakdown(&self) -> Vec<SubagentBreakdown> {
551 if self.subagent_rollups.is_empty() {
552 return Vec::new();
553 }
554
555 let active_paths: HashSet<&PathBuf> = self.active_subagent_jsonl_paths.iter().collect();
556 let mut active_rows = Vec::new();
557 let mut completed_rows = Vec::new();
558
559 for (path, rollup) in &self.subagent_rollups {
560 let row = SubagentBreakdown {
561 label: subagent_label(path),
562 state: if active_paths.contains(path) {
563 SubagentState::Active
564 } else {
565 SubagentState::Completed
566 },
567 count: 1,
568 input_tokens: rollup.input_tokens,
569 output_tokens: rollup.output_tokens,
570 cache_read_tokens: rollup.cache_read_tokens,
571 cache_write_tokens: rollup.cache_write_tokens,
572 cost_usd: rollup.cost_usd,
573 usage_metrics_available: rollup.usage_metrics_available,
574 cost_estimate_unverified: rollup.cost_estimate_unverified,
575 };
576
577 if row.state == SubagentState::Active {
578 active_rows.push(row);
579 } else {
580 completed_rows.push(row);
581 }
582 }
583
584 active_rows.sort_by(|a, b| a.label.cmp(&b.label));
585
586 let mut rows = Vec::new();
587 if !completed_rows.is_empty() {
588 let mut aggregate = SubagentBreakdown {
589 label: "completed".to_string(),
590 state: SubagentState::Completed,
591 count: completed_rows.len(),
592 input_tokens: 0,
593 output_tokens: 0,
594 cache_read_tokens: 0,
595 cache_write_tokens: 0,
596 cost_usd: 0.0,
597 usage_metrics_available: false,
598 cost_estimate_unverified: false,
599 };
600
601 for row in completed_rows {
602 aggregate.input_tokens += row.input_tokens;
603 aggregate.output_tokens += row.output_tokens;
604 aggregate.cache_read_tokens += row.cache_read_tokens;
605 aggregate.cache_write_tokens += row.cache_write_tokens;
606 aggregate.cost_usd += row.cost_usd;
607 aggregate.usage_metrics_available |= row.usage_metrics_available;
608 aggregate.cost_estimate_unverified |= row.cost_estimate_unverified;
609 }
610
611 rows.push(aggregate);
612 }
613
614 rows.extend(active_rows);
615 rows
616 }
617
618 pub fn format_elapsed(&self) -> String {
619 let secs = self.elapsed.as_secs();
620 let h = secs / 3600;
621 let m = (secs % 3600) / 60;
622 let s = secs % 60;
623 if h > 0 {
624 format!("{h:02}:{m:02}:{s:02}")
625 } else {
626 format!("{m:02}:{s:02}")
627 }
628 }
629
630 pub fn format_tokens(&self) -> String {
631 if !self.usage_metrics_available {
632 return "n/a".to_string();
633 }
634 let total = self.total_input_tokens + self.total_output_tokens;
635 if total == 0 {
636 return String::from("-");
637 }
638 format_count(self.total_input_tokens) + "/" + &format_count(self.total_output_tokens)
639 }
640
641 pub fn format_mem(&self) -> String {
642 if self.mem_mb < 1.0 {
643 return String::from("-");
644 }
645 format!("{:.0}M", self.mem_mb)
646 }
647
648 pub fn format_cost(&self) -> String {
649 if !self.usage_metrics_available {
650 return "n/a".to_string();
651 }
652 if self.cost_usd < 0.01 {
653 return String::from("-");
654 }
655 if self.cost_usd < 1.0 {
656 format!(
657 "${:.2}{}",
658 self.cost_usd,
659 if self.cost_estimate_unverified {
660 "?"
661 } else {
662 ""
663 }
664 )
665 } else {
666 format!(
667 "${:.1}{}",
668 self.cost_usd,
669 if self.cost_estimate_unverified {
670 "?"
671 } else {
672 ""
673 }
674 )
675 }
676 }
677
678 pub fn context_percent(&self) -> f64 {
679 if !self.usage_metrics_available {
680 return 0.0;
681 }
682 if self.context_max == 0 || self.context_tokens == 0 {
683 return 0.0;
684 }
685 (self.context_tokens as f64 / self.context_max as f64) * 100.0
686 }
687
688 pub fn format_context(&self) -> String {
690 if !self.usage_metrics_available {
691 return "n/a".to_string();
692 }
693 if self.context_tokens == 0 {
694 return String::from("-");
695 }
696 let pct = self.context_percent();
697 format!("{}%", pct as u32)
698 }
699
700 pub fn format_context_bar(&self, width: usize) -> String {
702 if !self.usage_metrics_available {
703 return "n/a".to_string();
704 }
705 let pct = self.context_percent();
706 if pct == 0.0 {
707 return String::from("-");
708 }
709 let filled = ((pct / 100.0) * width as f64).round() as usize;
710 let empty = width.saturating_sub(filled);
711 format!(
712 "{}{} {}%",
713 "█".repeat(filled),
714 "░".repeat(empty),
715 pct as u32
716 )
717 }
718
719 pub fn to_json_value(&self) -> serde_json::Value {
721 let cost_usd = if self.usage_metrics_available {
722 serde_json::json!((self.cost_usd * 100.0).round() / 100.0)
723 } else {
724 serde_json::Value::Null
725 };
726 let burn_rate = if self.usage_metrics_available {
727 serde_json::json!((self.burn_rate_per_hr * 100.0).round() / 100.0)
728 } else {
729 serde_json::Value::Null
730 };
731 let context_pct = if self.usage_metrics_available {
732 serde_json::json!((self.context_percent() * 100.0).round() / 100.0)
733 } else {
734 serde_json::Value::Null
735 };
736 let tokens_in = if self.usage_metrics_available {
737 serde_json::json!(self.total_input_tokens)
738 } else {
739 serde_json::Value::Null
740 };
741 let tokens_out = if self.usage_metrics_available {
742 serde_json::json!(self.total_output_tokens)
743 } else {
744 serde_json::Value::Null
745 };
746
747 serde_json::json!({
748 "pid": self.pid,
749 "project": self.display_name(),
750 "status": self.status.to_string(),
751 "telemetry": {
752 "state": self.telemetry_status.label(),
753 "usage_metrics_available": self.usage_metrics_available,
754 },
755 "estimate": {
756 "verified": !self.cost_estimate_unverified,
757 "profile_source": self.model_profile_source,
758 },
759 "context_pct": context_pct,
760 "cost_usd": cost_usd,
761 "burn_rate_per_hr": burn_rate,
762 "elapsed_secs": self.elapsed.as_secs(),
763 "cpu": self.cpu_percent,
764 "mem_mb": (self.mem_mb * 100.0).round() / 100.0,
765 "tokens_in": tokens_in,
766 "tokens_out": tokens_out,
767 "subagents": self.subagent_count,
768 "active_subagents": self.active_subagent_count,
769 "subagent_breakdown": self.subagent_breakdown().into_iter().map(|row| {
770 serde_json::json!({
771 "label": row.display_label(),
772 "state": row.state_label(),
773 "count": row.count,
774 "tokens_in": if row.usage_metrics_available {
775 serde_json::json!(row.total_input_tokens())
776 } else {
777 serde_json::Value::Null
778 },
779 "tokens_out": if row.usage_metrics_available {
780 serde_json::json!(row.output_tokens)
781 } else {
782 serde_json::Value::Null
783 },
784 "cost_usd": if row.usage_metrics_available {
785 serde_json::json!((row.cost_usd * 100.0).round() / 100.0)
786 } else {
787 serde_json::Value::Null
788 },
789 })
790 }).collect::<Vec<_>>(),
791 "decay_score": if self.usage_metrics_available { serde_json::json!(self.decay_score) } else { serde_json::Value::Null },
792 "last_error": self.last_error_message,
793 "recent_errors": self.recent_errors.iter().map(|e| {
794 serde_json::json!({
795 "tool": e.tool_name,
796 "message": e.message,
797 })
798 }).collect::<Vec<_>>(),
799 "files_modified": self.files_modified,
800 "tool_usage": self.tool_usage.iter().map(|(k, v)| {
801 (k.clone(), serde_json::json!({"calls": v.calls}))
802 }).collect::<serde_json::Map<String, serde_json::Value>>(),
803 "worker_origin": self.worker_origin,
804 })
805 }
806
807 pub fn format_burn_rate(&self) -> String {
808 if !self.usage_metrics_available {
809 return "n/a".to_string();
810 }
811 if self.burn_rate_per_hr < 0.01 {
812 return String::from("-");
813 }
814 if self.burn_rate_per_hr < 1.0 {
815 format!(
816 "${:.2}/h{}",
817 self.burn_rate_per_hr,
818 if self.cost_estimate_unverified {
819 "?"
820 } else {
821 ""
822 }
823 )
824 } else {
825 format!(
826 "${:.1}/h{}",
827 self.burn_rate_per_hr,
828 if self.cost_estimate_unverified {
829 "?"
830 } else {
831 ""
832 }
833 )
834 }
835 }
836
837 pub fn telemetry_label(&self) -> &'static str {
838 self.telemetry_status.label()
839 }
840
841 pub fn has_usage_metrics(&self) -> bool {
842 self.usage_metrics_available
843 }
844}
845
846pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
849 if s.len() <= max_bytes {
850 return s;
851 }
852 let mut end = max_bytes;
853 while end > 0 && !s.is_char_boundary(end) {
854 end -= 1;
855 }
856 &s[..end]
857}
858
859fn format_count(n: u64) -> String {
860 if n >= 1_000_000 {
861 format!("{:.1}M", n as f64 / 1_000_000.0)
862 } else if n >= 1_000 {
863 format!("{:.1}k", n as f64 / 1_000.0)
864 } else {
865 n.to_string()
866 }
867}
868
869fn subagent_label(path: &Path) -> String {
870 let components: Vec<String> = path
871 .components()
872 .map(|component| component.as_os_str().to_string_lossy().to_string())
873 .collect();
874
875 if let Some(tasks_idx) = components.iter().position(|component| component == "tasks") {
876 let relative = &components[tasks_idx + 1..];
877 if !relative.is_empty() {
878 let mut label = relative.join("/");
879 if let Some(stripped) = label.strip_suffix(".jsonl") {
880 label = stripped.to_string();
881 }
882 return label;
883 }
884 }
885
886 path.file_stem()
887 .and_then(|stem| stem.to_str())
888 .unwrap_or("subagent")
889 .to_string()
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895
896 fn make_session() -> ClaudeSession {
897 ClaudeSession::from_raw(RawSession {
898 pid: 1,
899 session_id: "session-1".into(),
900 cwd: "/tmp/project".into(),
901 started_at: 0,
902 })
903 }
904
905 #[test]
906 fn record_burn_sample_smooths_and_caps_window() {
907 let mut s = make_session();
908 for _ in 0..crate::forecast::BURN_SAMPLE_CAP + 10 {
910 s.burn_rate_per_hr = 6.0;
911 s.record_burn_sample(2.0);
912 }
913 assert_eq!(s.burn_samples.len(), crate::forecast::BURN_SAMPLE_CAP);
914 assert!(
915 (s.burn_rate_ewma - 6.0).abs() < 0.5,
916 "ewma: {}",
917 s.burn_rate_ewma
918 );
919 }
920
921 #[test]
922 fn budget_eta_forecasts_time_to_cap() {
923 let mut s = make_session();
924 for _ in 0..20 {
925 s.burn_rate_per_hr = 5.0;
926 s.record_burn_sample(2.0);
927 }
928 s.cost_usd = 5.0; let band = s.budget_eta(15.0);
930 let mid = band.mid_hours.expect("burning, so an ETA exists");
931 assert!((mid - 2.0).abs() < 0.4, "eta hours: {mid}");
932 }
933
934 #[test]
935 fn subagent_breakdown_groups_completed_and_lists_active_rows() {
936 let mut session = make_session();
937 let completed = PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/agent-1.jsonl");
938 let active =
939 PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/nested/agent-2.jsonl");
940
941 session.active_subagent_jsonl_paths = vec![active.clone()];
942 session.subagent_rollups.insert(
943 completed,
944 SubagentRollup {
945 input_tokens: 10_000,
946 output_tokens: 2_000,
947 cost_usd: 0.25,
948 usage_metrics_available: true,
949 ..SubagentRollup::default()
950 },
951 );
952 session.subagent_rollups.insert(
953 active,
954 SubagentRollup {
955 input_tokens: 40_000,
956 output_tokens: 8_000,
957 cost_usd: 1.5,
958 usage_metrics_available: true,
959 ..SubagentRollup::default()
960 },
961 );
962
963 let rows = session.subagent_breakdown();
964 assert_eq!(rows.len(), 2);
965 assert_eq!(rows[0].display_label(), "completed");
966 assert_eq!(rows[0].state, SubagentState::Completed);
967 assert_eq!(rows[0].count, 1);
968 assert_eq!(rows[0].format_tokens(), "10.0k/2.0k");
969 assert_eq!(rows[1].display_label(), "nested/agent-2");
970 assert_eq!(rows[1].state, SubagentState::Active);
971 assert_eq!(rows[1].format_cost(), "$1.5");
972 }
973
974 #[test]
975 fn subagent_breakdown_collapses_multiple_completed_rows() {
976 let mut session = make_session();
977
978 for name in ["agent-1.jsonl", "agent-2.jsonl"] {
979 let path = PathBuf::from(format!("/tmp/claude-1/-tmp-project/session-1/tasks/{name}"));
980 session.subagent_rollups.insert(
981 path,
982 SubagentRollup {
983 input_tokens: 10_000,
984 output_tokens: 1_000,
985 cost_usd: 0.2,
986 usage_metrics_available: true,
987 ..SubagentRollup::default()
988 },
989 );
990 }
991
992 let rows = session.subagent_breakdown();
993 assert_eq!(rows.len(), 1);
994 assert_eq!(rows[0].display_label(), "completed (2)");
995 assert_eq!(rows[0].count, 2);
996 assert_eq!(rows[0].format_tokens(), "20.0k/2.0k");
997 }
998
999 #[test]
1002 fn error_window_flush() {
1003 let mut s = make_session();
1004 s.current_window_errors = 3;
1005 for _ in 0..5 {
1007 s.record_activity();
1008 }
1009 assert_eq!(s.error_counts_per_window.len(), 1);
1010 assert_eq!(s.error_counts_per_window[0], 3);
1011 assert_eq!(s.current_window_errors, 0);
1012 assert_eq!(s.window_tick_counter, 0);
1013 }
1014
1015 #[test]
1016 fn baseline_error_rate_freezes() {
1017 let mut s = make_session();
1018 for errors in [2, 3, 4] {
1020 s.current_window_errors = errors;
1021 for _ in 0..5 {
1022 s.record_activity();
1023 }
1024 }
1025 assert_eq!(s.error_counts_per_window.len(), 3);
1026 let baseline = s.baseline_error_rate.expect("baseline should be set");
1027 assert!((baseline - 3.0).abs() < 0.01);
1029
1030 s.current_window_errors = 10;
1032 for _ in 0..5 {
1033 s.record_activity();
1034 }
1035 assert_eq!(s.baseline_error_rate.unwrap(), baseline);
1036 }
1037
1038 #[test]
1041 fn local_session_is_not_remote() {
1042 let s = make_session();
1043 assert!(!s.is_remote());
1044 assert!(s.worker_origin.is_none());
1045 }
1046
1047 #[test]
1048 fn from_remote_json_parses_basic_fields() {
1049 let json = serde_json::json!({
1050 "pid": 42,
1051 "project": "backend",
1052 "status": "Processing",
1053 "cost_usd": 1.23,
1054 "elapsed_secs": 600,
1055 "tokens_in": 50000,
1056 "tokens_out": 10000,
1057 });
1058 let session = ClaudeSession::from_remote_json("macbook-02", &json).unwrap();
1059 assert!(session.is_remote());
1060 assert_eq!(session.worker_origin.as_deref(), Some("macbook-02"));
1061 assert_eq!(session.pid, 42);
1062 assert_eq!(session.project_name, "[macbook-02] backend");
1063 assert_eq!(session.status, SessionStatus::Processing);
1064 assert!((session.cost_usd - 1.23).abs() < 0.01);
1065 assert_eq!(session.total_input_tokens, 50000);
1066 assert_eq!(session.total_output_tokens, 10000);
1067 assert!(session.usage_metrics_available);
1068 }
1069
1070 #[test]
1071 fn from_remote_json_handles_all_statuses() {
1072 for (label, expected) in [
1073 ("Needs Input", SessionStatus::NeedsInput),
1074 ("Processing", SessionStatus::Processing),
1075 ("Waiting", SessionStatus::WaitingInput),
1076 ("Idle", SessionStatus::Idle),
1077 ("Finished", SessionStatus::Finished),
1078 ("SomethingElse", SessionStatus::Unknown),
1079 ] {
1080 let json = serde_json::json!({"pid": 1, "project": "p", "status": label});
1081 let session = ClaudeSession::from_remote_json("w", &json).unwrap();
1082 assert_eq!(session.status, expected, "status mismatch for {label}");
1083 }
1084 }
1085
1086 #[test]
1087 fn from_remote_json_returns_none_on_missing_fields() {
1088 let json = serde_json::json!({"project": "x", "status": "Idle"});
1090 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1091
1092 let json = serde_json::json!({"pid": 1, "status": "Idle"});
1094 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1095 }
1096
1097 #[test]
1098 fn remote_session_display_name_shows_worker_prefix() {
1099 let json = serde_json::json!({"pid": 1, "project": "api-server", "status": "Idle"});
1100 let session = ClaudeSession::from_remote_json("laptop-01", &json).unwrap();
1101 assert_eq!(session.display_name(), "[laptop-01] api-server");
1102 }
1103
1104 #[test]
1105 fn remote_session_json_includes_worker_origin() {
1106 let json = serde_json::json!({"pid": 1, "project": "test", "status": "Idle"});
1107 let session = ClaudeSession::from_remote_json("remote-w", &json).unwrap();
1108 let output = session.to_json_value();
1109 assert_eq!(
1110 output.get("worker_origin").and_then(|v| v.as_str()),
1111 Some("remote-w")
1112 );
1113 }
1114}