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 pub fn from_remote_json(worker_id: &str, json: &serde_json::Value) -> Option<Self> {
469 let pid = json.get("pid")?.as_u64()? as u32;
470 let project = json.get("project")?.as_str()?;
471 let status_str = json
472 .get("status")
473 .and_then(|v| v.as_str())
474 .unwrap_or("Unknown");
475 let status = match status_str {
476 "Needs Input" => SessionStatus::NeedsInput,
477 "Processing" => SessionStatus::Processing,
478 "Waiting" => SessionStatus::WaitingInput,
479 "Idle" => SessionStatus::Idle,
480 "Finished" => SessionStatus::Finished,
481 _ => SessionStatus::Unknown,
482 };
483
484 let elapsed_secs = json
485 .get("elapsed_secs")
486 .and_then(|v| v.as_u64())
487 .unwrap_or(0);
488 let now_ms = std::time::SystemTime::now()
489 .duration_since(std::time::UNIX_EPOCH)
490 .unwrap_or_default()
491 .as_millis() as u64;
492
493 let mut session = Self::from_raw(RawSession {
494 pid,
495 session_id: format!("remote-{worker_id}-{pid}"),
496 cwd: project.to_string(),
497 started_at: now_ms.saturating_sub(elapsed_secs * 1000),
498 });
499 session.status = status;
500 session.worker_origin = Some(worker_id.to_string());
501 session.project_name = format!("[{worker_id}] {project}");
502
503 if let Some(cost) = json.get("cost_usd").and_then(|v| v.as_f64()) {
505 session.cost_usd = cost;
506 session.usage_metrics_available = true;
507 }
508 if let Some(burn) = json.get("burn_rate_per_hr").and_then(|v| v.as_f64()) {
509 session.burn_rate_per_hr = burn;
510 }
511 if let Some(ctx) = json.get("context_pct").and_then(|v| v.as_f64()) {
512 session.context_max = 200_000; session.context_tokens = ((ctx / 100.0) * session.context_max as f64) as u64;
515 }
516 if let Some(t_in) = json.get("tokens_in").and_then(|v| v.as_u64()) {
517 session.total_input_tokens = t_in;
518 session.usage_metrics_available = true;
519 }
520 if let Some(t_out) = json.get("tokens_out").and_then(|v| v.as_u64()) {
521 session.total_output_tokens = t_out;
522 }
523 if let Some(model) = json.get("model").and_then(|v| v.as_str()) {
524 session.model = model.to_string();
525 }
526 if let Some(subs) = json.get("subagents").and_then(|v| v.as_u64()) {
527 session.subagent_count = subs as usize;
528 }
529 if let Some(decay) = json.get("decay_score").and_then(|v| v.as_u64()) {
530 session.decay_score = decay as u32;
531 }
532
533 Some(session)
534 }
535
536 pub fn format_subagent_summary(&self) -> String {
537 if self.subagent_count == 0 {
538 return "0".to_string();
539 }
540 if self.active_subagent_count == 0 || self.active_subagent_count == self.subagent_count {
541 return self.subagent_count.to_string();
542 }
543 format!(
544 "{} total ({} active)",
545 self.subagent_count, self.active_subagent_count
546 )
547 }
548
549 pub fn subagent_breakdown(&self) -> Vec<SubagentBreakdown> {
550 if self.subagent_rollups.is_empty() {
551 return Vec::new();
552 }
553
554 let active_paths: HashSet<&PathBuf> = self.active_subagent_jsonl_paths.iter().collect();
555 let mut active_rows = Vec::new();
556 let mut completed_rows = Vec::new();
557
558 for (path, rollup) in &self.subagent_rollups {
559 let row = SubagentBreakdown {
560 label: subagent_label(path),
561 state: if active_paths.contains(path) {
562 SubagentState::Active
563 } else {
564 SubagentState::Completed
565 },
566 count: 1,
567 input_tokens: rollup.input_tokens,
568 output_tokens: rollup.output_tokens,
569 cache_read_tokens: rollup.cache_read_tokens,
570 cache_write_tokens: rollup.cache_write_tokens,
571 cost_usd: rollup.cost_usd,
572 usage_metrics_available: rollup.usage_metrics_available,
573 cost_estimate_unverified: rollup.cost_estimate_unverified,
574 };
575
576 if row.state == SubagentState::Active {
577 active_rows.push(row);
578 } else {
579 completed_rows.push(row);
580 }
581 }
582
583 active_rows.sort_by(|a, b| a.label.cmp(&b.label));
584
585 let mut rows = Vec::new();
586 if !completed_rows.is_empty() {
587 let mut aggregate = SubagentBreakdown {
588 label: "completed".to_string(),
589 state: SubagentState::Completed,
590 count: completed_rows.len(),
591 input_tokens: 0,
592 output_tokens: 0,
593 cache_read_tokens: 0,
594 cache_write_tokens: 0,
595 cost_usd: 0.0,
596 usage_metrics_available: false,
597 cost_estimate_unverified: false,
598 };
599
600 for row in completed_rows {
601 aggregate.input_tokens += row.input_tokens;
602 aggregate.output_tokens += row.output_tokens;
603 aggregate.cache_read_tokens += row.cache_read_tokens;
604 aggregate.cache_write_tokens += row.cache_write_tokens;
605 aggregate.cost_usd += row.cost_usd;
606 aggregate.usage_metrics_available |= row.usage_metrics_available;
607 aggregate.cost_estimate_unverified |= row.cost_estimate_unverified;
608 }
609
610 rows.push(aggregate);
611 }
612
613 rows.extend(active_rows);
614 rows
615 }
616
617 pub fn format_elapsed(&self) -> String {
618 let secs = self.elapsed.as_secs();
619 let h = secs / 3600;
620 let m = (secs % 3600) / 60;
621 let s = secs % 60;
622 if h > 0 {
623 format!("{h:02}:{m:02}:{s:02}")
624 } else {
625 format!("{m:02}:{s:02}")
626 }
627 }
628
629 pub fn format_tokens(&self) -> String {
630 if !self.usage_metrics_available {
631 return "n/a".to_string();
632 }
633 let total = self.total_input_tokens + self.total_output_tokens;
634 if total == 0 {
635 return String::from("-");
636 }
637 format_count(self.total_input_tokens) + "/" + &format_count(self.total_output_tokens)
638 }
639
640 pub fn format_mem(&self) -> String {
641 if self.mem_mb < 1.0 {
642 return String::from("-");
643 }
644 format!("{:.0}M", self.mem_mb)
645 }
646
647 pub fn format_cost(&self) -> String {
648 if !self.usage_metrics_available {
649 return "n/a".to_string();
650 }
651 if self.cost_usd < 0.01 {
652 return String::from("-");
653 }
654 if self.cost_usd < 1.0 {
655 format!(
656 "${:.2}{}",
657 self.cost_usd,
658 if self.cost_estimate_unverified {
659 "?"
660 } else {
661 ""
662 }
663 )
664 } else {
665 format!(
666 "${:.1}{}",
667 self.cost_usd,
668 if self.cost_estimate_unverified {
669 "?"
670 } else {
671 ""
672 }
673 )
674 }
675 }
676
677 pub fn context_percent(&self) -> f64 {
678 if !self.usage_metrics_available {
679 return 0.0;
680 }
681 if self.context_max == 0 || self.context_tokens == 0 {
682 return 0.0;
683 }
684 (self.context_tokens as f64 / self.context_max as f64) * 100.0
685 }
686
687 pub fn format_context(&self) -> String {
689 if !self.usage_metrics_available {
690 return "n/a".to_string();
691 }
692 if self.context_tokens == 0 {
693 return String::from("-");
694 }
695 let pct = self.context_percent();
696 format!("{}%", pct as u32)
697 }
698
699 pub fn format_context_bar(&self, width: usize) -> String {
701 if !self.usage_metrics_available {
702 return "n/a".to_string();
703 }
704 let pct = self.context_percent();
705 if pct == 0.0 {
706 return String::from("-");
707 }
708 let filled = ((pct / 100.0) * width as f64).round() as usize;
709 let empty = width.saturating_sub(filled);
710 format!(
711 "{}{} {}%",
712 "█".repeat(filled),
713 "░".repeat(empty),
714 pct as u32
715 )
716 }
717
718 pub fn to_json_value(&self) -> serde_json::Value {
720 let cost_usd = if self.usage_metrics_available {
721 serde_json::json!((self.cost_usd * 100.0).round() / 100.0)
722 } else {
723 serde_json::Value::Null
724 };
725 let burn_rate = if self.usage_metrics_available {
726 serde_json::json!((self.burn_rate_per_hr * 100.0).round() / 100.0)
727 } else {
728 serde_json::Value::Null
729 };
730 let context_pct = if self.usage_metrics_available {
731 serde_json::json!((self.context_percent() * 100.0).round() / 100.0)
732 } else {
733 serde_json::Value::Null
734 };
735 let tokens_in = if self.usage_metrics_available {
736 serde_json::json!(self.total_input_tokens)
737 } else {
738 serde_json::Value::Null
739 };
740 let tokens_out = if self.usage_metrics_available {
741 serde_json::json!(self.total_output_tokens)
742 } else {
743 serde_json::Value::Null
744 };
745
746 serde_json::json!({
747 "pid": self.pid,
748 "project": self.display_name(),
749 "status": self.status.to_string(),
750 "telemetry": {
751 "state": self.telemetry_status.label(),
752 "usage_metrics_available": self.usage_metrics_available,
753 },
754 "estimate": {
755 "verified": !self.cost_estimate_unverified,
756 "profile_source": self.model_profile_source,
757 },
758 "context_pct": context_pct,
759 "cost_usd": cost_usd,
760 "burn_rate_per_hr": burn_rate,
761 "elapsed_secs": self.elapsed.as_secs(),
762 "cpu": self.cpu_percent,
763 "mem_mb": (self.mem_mb * 100.0).round() / 100.0,
764 "tokens_in": tokens_in,
765 "tokens_out": tokens_out,
766 "subagents": self.subagent_count,
767 "active_subagents": self.active_subagent_count,
768 "subagent_breakdown": self.subagent_breakdown().into_iter().map(|row| {
769 serde_json::json!({
770 "label": row.display_label(),
771 "state": row.state_label(),
772 "count": row.count,
773 "tokens_in": if row.usage_metrics_available {
774 serde_json::json!(row.total_input_tokens())
775 } else {
776 serde_json::Value::Null
777 },
778 "tokens_out": if row.usage_metrics_available {
779 serde_json::json!(row.output_tokens)
780 } else {
781 serde_json::Value::Null
782 },
783 "cost_usd": if row.usage_metrics_available {
784 serde_json::json!((row.cost_usd * 100.0).round() / 100.0)
785 } else {
786 serde_json::Value::Null
787 },
788 })
789 }).collect::<Vec<_>>(),
790 "decay_score": if self.usage_metrics_available { serde_json::json!(self.decay_score) } else { serde_json::Value::Null },
791 "last_error": self.last_error_message,
792 "recent_errors": self.recent_errors.iter().map(|e| {
793 serde_json::json!({
794 "tool": e.tool_name,
795 "message": e.message,
796 })
797 }).collect::<Vec<_>>(),
798 "files_modified": self.files_modified,
799 "tool_usage": self.tool_usage.iter().map(|(k, v)| {
800 (k.clone(), serde_json::json!({"calls": v.calls}))
801 }).collect::<serde_json::Map<String, serde_json::Value>>(),
802 "worker_origin": self.worker_origin,
803 })
804 }
805
806 pub fn format_burn_rate(&self) -> String {
807 if !self.usage_metrics_available {
808 return "n/a".to_string();
809 }
810 if self.burn_rate_per_hr < 0.01 {
811 return String::from("-");
812 }
813 if self.burn_rate_per_hr < 1.0 {
814 format!(
815 "${:.2}/h{}",
816 self.burn_rate_per_hr,
817 if self.cost_estimate_unverified {
818 "?"
819 } else {
820 ""
821 }
822 )
823 } else {
824 format!(
825 "${:.1}/h{}",
826 self.burn_rate_per_hr,
827 if self.cost_estimate_unverified {
828 "?"
829 } else {
830 ""
831 }
832 )
833 }
834 }
835
836 pub fn telemetry_label(&self) -> &'static str {
837 self.telemetry_status.label()
838 }
839
840 pub fn has_usage_metrics(&self) -> bool {
841 self.usage_metrics_available
842 }
843}
844
845pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
848 if s.len() <= max_bytes {
849 return s;
850 }
851 let mut end = max_bytes;
852 while end > 0 && !s.is_char_boundary(end) {
853 end -= 1;
854 }
855 &s[..end]
856}
857
858fn format_count(n: u64) -> String {
859 if n >= 1_000_000 {
860 format!("{:.1}M", n as f64 / 1_000_000.0)
861 } else if n >= 1_000 {
862 format!("{:.1}k", n as f64 / 1_000.0)
863 } else {
864 n.to_string()
865 }
866}
867
868fn subagent_label(path: &Path) -> String {
869 let components: Vec<String> = path
870 .components()
871 .map(|component| component.as_os_str().to_string_lossy().to_string())
872 .collect();
873
874 if let Some(tasks_idx) = components.iter().position(|component| component == "tasks") {
875 let relative = &components[tasks_idx + 1..];
876 if !relative.is_empty() {
877 let mut label = relative.join("/");
878 if let Some(stripped) = label.strip_suffix(".jsonl") {
879 label = stripped.to_string();
880 }
881 return label;
882 }
883 }
884
885 path.file_stem()
886 .and_then(|stem| stem.to_str())
887 .unwrap_or("subagent")
888 .to_string()
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 fn make_session() -> ClaudeSession {
896 ClaudeSession::from_raw(RawSession {
897 pid: 1,
898 session_id: "session-1".into(),
899 cwd: "/tmp/project".into(),
900 started_at: 0,
901 })
902 }
903
904 #[test]
905 fn record_burn_sample_smooths_and_caps_window() {
906 let mut s = make_session();
907 for _ in 0..crate::forecast::BURN_SAMPLE_CAP + 10 {
909 s.burn_rate_per_hr = 6.0;
910 s.record_burn_sample(2.0);
911 }
912 assert_eq!(s.burn_samples.len(), crate::forecast::BURN_SAMPLE_CAP);
913 assert!(
914 (s.burn_rate_ewma - 6.0).abs() < 0.5,
915 "ewma: {}",
916 s.burn_rate_ewma
917 );
918 }
919
920 #[test]
921 fn budget_eta_forecasts_time_to_cap() {
922 let mut s = make_session();
923 for _ in 0..20 {
924 s.burn_rate_per_hr = 5.0;
925 s.record_burn_sample(2.0);
926 }
927 s.cost_usd = 5.0; let band = s.budget_eta(15.0);
929 let mid = band.mid_hours.expect("burning, so an ETA exists");
930 assert!((mid - 2.0).abs() < 0.4, "eta hours: {mid}");
931 }
932
933 #[test]
934 fn subagent_breakdown_groups_completed_and_lists_active_rows() {
935 let mut session = make_session();
936 let completed = PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/agent-1.jsonl");
937 let active =
938 PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/nested/agent-2.jsonl");
939
940 session.active_subagent_jsonl_paths = vec![active.clone()];
941 session.subagent_rollups.insert(
942 completed,
943 SubagentRollup {
944 input_tokens: 10_000,
945 output_tokens: 2_000,
946 cost_usd: 0.25,
947 usage_metrics_available: true,
948 ..SubagentRollup::default()
949 },
950 );
951 session.subagent_rollups.insert(
952 active,
953 SubagentRollup {
954 input_tokens: 40_000,
955 output_tokens: 8_000,
956 cost_usd: 1.5,
957 usage_metrics_available: true,
958 ..SubagentRollup::default()
959 },
960 );
961
962 let rows = session.subagent_breakdown();
963 assert_eq!(rows.len(), 2);
964 assert_eq!(rows[0].display_label(), "completed");
965 assert_eq!(rows[0].state, SubagentState::Completed);
966 assert_eq!(rows[0].count, 1);
967 assert_eq!(rows[0].format_tokens(), "10.0k/2.0k");
968 assert_eq!(rows[1].display_label(), "nested/agent-2");
969 assert_eq!(rows[1].state, SubagentState::Active);
970 assert_eq!(rows[1].format_cost(), "$1.5");
971 }
972
973 #[test]
974 fn subagent_breakdown_collapses_multiple_completed_rows() {
975 let mut session = make_session();
976
977 for name in ["agent-1.jsonl", "agent-2.jsonl"] {
978 let path = PathBuf::from(format!("/tmp/claude-1/-tmp-project/session-1/tasks/{name}"));
979 session.subagent_rollups.insert(
980 path,
981 SubagentRollup {
982 input_tokens: 10_000,
983 output_tokens: 1_000,
984 cost_usd: 0.2,
985 usage_metrics_available: true,
986 ..SubagentRollup::default()
987 },
988 );
989 }
990
991 let rows = session.subagent_breakdown();
992 assert_eq!(rows.len(), 1);
993 assert_eq!(rows[0].display_label(), "completed (2)");
994 assert_eq!(rows[0].count, 2);
995 assert_eq!(rows[0].format_tokens(), "20.0k/2.0k");
996 }
997
998 #[test]
1001 fn error_window_flush() {
1002 let mut s = make_session();
1003 s.current_window_errors = 3;
1004 for _ in 0..5 {
1006 s.record_activity();
1007 }
1008 assert_eq!(s.error_counts_per_window.len(), 1);
1009 assert_eq!(s.error_counts_per_window[0], 3);
1010 assert_eq!(s.current_window_errors, 0);
1011 assert_eq!(s.window_tick_counter, 0);
1012 }
1013
1014 #[test]
1015 fn baseline_error_rate_freezes() {
1016 let mut s = make_session();
1017 for errors in [2, 3, 4] {
1019 s.current_window_errors = errors;
1020 for _ in 0..5 {
1021 s.record_activity();
1022 }
1023 }
1024 assert_eq!(s.error_counts_per_window.len(), 3);
1025 let baseline = s.baseline_error_rate.expect("baseline should be set");
1026 assert!((baseline - 3.0).abs() < 0.01);
1028
1029 s.current_window_errors = 10;
1031 for _ in 0..5 {
1032 s.record_activity();
1033 }
1034 assert_eq!(s.baseline_error_rate.unwrap(), baseline);
1035 }
1036
1037 #[test]
1040 fn local_session_is_not_remote() {
1041 let s = make_session();
1042 assert!(!s.is_remote());
1043 assert!(s.worker_origin.is_none());
1044 }
1045
1046 #[test]
1047 fn from_remote_json_parses_basic_fields() {
1048 let json = serde_json::json!({
1049 "pid": 42,
1050 "project": "backend",
1051 "status": "Processing",
1052 "cost_usd": 1.23,
1053 "elapsed_secs": 600,
1054 "tokens_in": 50000,
1055 "tokens_out": 10000,
1056 });
1057 let session = ClaudeSession::from_remote_json("macbook-02", &json).unwrap();
1058 assert!(session.is_remote());
1059 assert_eq!(session.worker_origin.as_deref(), Some("macbook-02"));
1060 assert_eq!(session.pid, 42);
1061 assert_eq!(session.project_name, "[macbook-02] backend");
1062 assert_eq!(session.status, SessionStatus::Processing);
1063 assert!((session.cost_usd - 1.23).abs() < 0.01);
1064 assert_eq!(session.total_input_tokens, 50000);
1065 assert_eq!(session.total_output_tokens, 10000);
1066 assert!(session.usage_metrics_available);
1067 }
1068
1069 #[test]
1070 fn from_remote_json_handles_all_statuses() {
1071 for (label, expected) in [
1072 ("Needs Input", SessionStatus::NeedsInput),
1073 ("Processing", SessionStatus::Processing),
1074 ("Waiting", SessionStatus::WaitingInput),
1075 ("Idle", SessionStatus::Idle),
1076 ("Finished", SessionStatus::Finished),
1077 ("SomethingElse", SessionStatus::Unknown),
1078 ] {
1079 let json = serde_json::json!({"pid": 1, "project": "p", "status": label});
1080 let session = ClaudeSession::from_remote_json("w", &json).unwrap();
1081 assert_eq!(session.status, expected, "status mismatch for {label}");
1082 }
1083 }
1084
1085 #[test]
1086 fn from_remote_json_returns_none_on_missing_fields() {
1087 let json = serde_json::json!({"project": "x", "status": "Idle"});
1089 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1090
1091 let json = serde_json::json!({"pid": 1, "status": "Idle"});
1093 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1094 }
1095
1096 #[test]
1097 fn remote_session_display_name_shows_worker_prefix() {
1098 let json = serde_json::json!({"pid": 1, "project": "api-server", "status": "Idle"});
1099 let session = ClaudeSession::from_remote_json("laptop-01", &json).unwrap();
1100 assert_eq!(session.display_name(), "[laptop-01] api-server");
1101 }
1102
1103 #[test]
1104 fn remote_session_json_includes_worker_origin() {
1105 let json = serde_json::json!({"pid": 1, "project": "test", "status": "Idle"});
1106 let session = ClaudeSession::from_remote_json("remote-w", &json).unwrap();
1107 let output = session.to_json_value();
1108 assert_eq!(
1109 output.get("worker_origin").and_then(|v| v.as_str()),
1110 Some("remote-w")
1111 );
1112 }
1113}