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>,
197 pub agent_family: String,
205 pub tmux_target: Option<String>,
209 pub remote_user: Option<String>,
212 pub remote_host: Option<String>,
215}
216
217#[derive(Debug, Clone)]
219pub struct ErrorEntry {
220 pub tool_name: String,
221 pub message: String,
222}
223
224#[derive(Debug, Clone, Default)]
226pub struct ToolStats {
227 pub calls: u32,
228}
229
230#[derive(Debug, Clone, Default)]
231pub struct SubagentRollup {
232 pub jsonl_offset: u64,
233 pub input_tokens: u64,
234 pub output_tokens: u64,
235 pub cache_read_tokens: u64,
236 pub cache_write_tokens: u64,
237 pub cache_write_1h_tokens: u64,
239 pub last_usage_msg_id: Option<String>,
242 pub cost_usd: f64,
243 pub model: String,
244 pub cost_estimate_unverified: bool,
245 pub usage_metrics_available: bool,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum SubagentState {
250 Active,
251 Completed,
252}
253
254#[derive(Debug, Clone)]
255pub struct SubagentBreakdown {
256 pub label: String,
257 pub state: SubagentState,
258 pub count: usize,
259 pub input_tokens: u64,
260 pub output_tokens: u64,
261 pub cache_read_tokens: u64,
262 pub cache_write_tokens: u64,
263 pub cost_usd: f64,
264 pub usage_metrics_available: bool,
265 pub cost_estimate_unverified: bool,
266}
267
268impl SubagentBreakdown {
269 pub fn total_input_tokens(&self) -> u64 {
270 self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
271 }
272
273 pub fn state_label(&self) -> String {
274 match self.state {
275 SubagentState::Active => "Active".to_string(),
276 SubagentState::Completed if self.count > 1 => format!("Completed ({})", self.count),
277 SubagentState::Completed => "Completed".to_string(),
278 }
279 }
280
281 pub fn display_label(&self) -> String {
282 if self.state == SubagentState::Completed && self.label == "completed" && self.count > 1 {
283 format!("completed ({})", self.count)
284 } else {
285 self.label.clone()
286 }
287 }
288
289 pub fn format_tokens(&self) -> String {
290 if !self.usage_metrics_available {
291 return "n/a".to_string();
292 }
293 let total = self.total_input_tokens() + self.output_tokens;
294 if total == 0 {
295 return "-".to_string();
296 }
297 format_count(self.total_input_tokens()) + "/" + &format_count(self.output_tokens)
298 }
299
300 pub fn format_cost(&self) -> String {
301 if !self.usage_metrics_available {
302 return "n/a".to_string();
303 }
304 if self.cost_usd < 0.01 {
305 return "-".to_string();
306 }
307 if self.cost_usd < 1.0 {
308 format!(
309 "${:.2}{}",
310 self.cost_usd,
311 if self.cost_estimate_unverified {
312 "?"
313 } else {
314 ""
315 }
316 )
317 } else {
318 format!(
319 "${:.1}{}",
320 self.cost_usd,
321 if self.cost_estimate_unverified {
322 "?"
323 } else {
324 ""
325 }
326 )
327 }
328 }
329}
330
331impl ClaudeSession {
332 pub fn from_raw(raw: RawSession) -> Self {
333 let project_name = raw.cwd.rsplit('/').next().unwrap_or("unknown").to_string();
334
335 let now_ms = std::time::SystemTime::now()
336 .duration_since(std::time::UNIX_EPOCH)
337 .unwrap_or_default()
338 .as_millis() as u64;
339 let elapsed_ms = now_ms.saturating_sub(raw.started_at);
340 let elapsed = Duration::from_millis(elapsed_ms);
341
342 Self {
343 pid: raw.pid,
344 session_id: raw.session_id,
345 cwd: raw.cwd,
346 project_name,
347 started_at: raw.started_at,
348 elapsed,
349 tty: String::new(),
350 status: SessionStatus::Idle,
351 cpu_percent: 0.0,
352 cpu_history: Vec::new(),
353 mem_mb: 0.0,
354 own_input_tokens: 0,
355 own_output_tokens: 0,
356 own_cache_read_tokens: 0,
357 own_cache_write_tokens: 0,
358 own_cache_write_1h_tokens: 0,
359 last_usage_msg_id: None,
360 subagent_input_tokens: 0,
361 subagent_output_tokens: 0,
362 subagent_cache_read_tokens: 0,
363 subagent_cache_write_tokens: 0,
364 subagent_cache_write_1h_tokens: 0,
365 total_input_tokens: 0,
366 total_output_tokens: 0,
367 model: String::new(),
368 command_args: String::new(),
369 session_name: String::new(),
370 jsonl_path: None,
371 jsonl_offset: 0,
372 last_message_ts: 0,
373 cache_read_tokens: 0,
374 cache_write_tokens: 0,
375 cost_usd: 0.0,
376 context_tokens: 0,
377 context_max: 0,
378 prev_cost_usd: 0.0,
379 burn_rate_per_hr: 0.0,
380 burn_rate_ewma: 0.0,
381 burn_samples: std::collections::VecDeque::new(),
382 subagent_count: 0,
383 active_subagent_count: 0,
384 active_subagent_jsonl_paths: Vec::new(),
385 subagent_rollups: HashMap::new(),
386 activity_history: Vec::new(),
387 files_modified: HashMap::new(),
388 tool_usage: HashMap::new(),
389 worktree_id: None,
390 telemetry_status: TelemetryStatus::Pending,
391 usage_metrics_available: false,
392 cost_estimate_unverified: false,
393 model_profile_source: "built-in".into(),
394 last_msg_type: String::new(),
395 last_stop_reason: String::new(),
396 is_waiting_for_task: false,
397 pending_tool_name: None,
398 pending_tool_input: None,
399 pending_file_path: None,
400 has_file_conflict: false,
401 last_tool_error: false,
402 last_error_message: None,
403 recent_errors: Vec::new(),
404 total_tokens_at_edit_count: 0,
405 edit_event_count: 0,
406 baseline_tokens_per_edit: None,
407 error_counts_per_window: Vec::new(),
408 current_window_errors: 0,
409 window_tick_counter: 0,
410 baseline_error_rate: None,
411 file_reads_since_edit: HashMap::new(),
412 total_error_count: 0,
413 decay_score: 0,
414 worker_origin: None,
415 agent_family: crate::agent::vocab::CLAUDE_CODE.to_string(),
418 tmux_target: None,
419 remote_user: None,
420 remote_host: None,
421 }
422 }
423
424 pub fn record_activity(&mut self) {
427 let level = match self.status {
428 SessionStatus::Processing => 7,
429 SessionStatus::NeedsInput => 4,
430 SessionStatus::WaitingInput => 2,
431 SessionStatus::Unknown => 2,
432 SessionStatus::Idle => 1,
433 SessionStatus::Finished => 0,
434 };
435 self.activity_history.push(level);
436 if self.activity_history.len() > 15 {
437 self.activity_history.remove(0);
438 }
439
440 self.window_tick_counter += 1;
442 if self.window_tick_counter >= 5 {
443 self.error_counts_per_window
444 .push(self.current_window_errors);
445 if self.error_counts_per_window.len() > 10 {
446 self.error_counts_per_window.remove(0);
447 }
448 if self.baseline_error_rate.is_none() && self.error_counts_per_window.len() >= 3 {
450 let sum: u32 = self.error_counts_per_window.iter().sum();
451 self.baseline_error_rate =
452 Some(sum as f64 / self.error_counts_per_window.len() as f64);
453 }
454 self.current_window_errors = 0;
455 self.window_tick_counter = 0;
456 }
457 }
458
459 pub fn record_burn_sample(&mut self, interval_secs: f64) {
463 let sample = self.burn_rate_per_hr;
464 let alpha = crate::forecast::alpha_for_half_life(
465 crate::forecast::BURN_HALF_LIFE_SECS,
466 interval_secs,
467 );
468 let prev = if self.burn_samples.is_empty() {
469 None
470 } else {
471 Some(self.burn_rate_ewma)
472 };
473 self.burn_rate_ewma = crate::forecast::ewma(prev, sample, alpha);
474 self.burn_samples.push_back(sample);
475 while self.burn_samples.len() > crate::forecast::BURN_SAMPLE_CAP {
476 self.burn_samples.pop_front();
477 }
478 }
479
480 pub fn budget_eta(&self, budget_usd: f64) -> crate::forecast::EtaBand {
483 let headroom = budget_usd - self.cost_usd;
484 let samples: Vec<f64> = self.burn_samples.iter().copied().collect();
485 crate::forecast::budget_eta_band(headroom, self.burn_rate_ewma, &samples)
486 }
487
488 pub fn format_sparkline(&self) -> String {
490 const BLOCKS: &[char] = &[
491 ' ', '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}',
492 '\u{2587}', '\u{2588}',
493 ];
494 if self.activity_history.is_empty() {
495 return String::from("-");
496 }
497 self.activity_history
498 .iter()
499 .map(|&level| BLOCKS[level.min(8) as usize])
500 .collect()
501 }
502
503 pub fn display_name(&self) -> &str {
504 if !self.session_name.is_empty() {
505 &self.session_name
506 } else {
507 &self.project_name
508 }
509 }
510
511 pub fn is_remote(&self) -> bool {
513 self.worker_origin.is_some()
514 }
515
516 pub fn is_claude_code(&self) -> bool {
524 self.agent_family == crate::agent::vocab::CLAUDE_CODE
525 }
526
527 pub fn is_actionable(&self) -> bool {
544 self.unactionable_reason().is_none()
545 }
546
547 pub fn unactionable_reason(&self) -> Option<&'static str> {
554 if self.is_remote() {
555 return Some("Remote session — action not available");
556 }
557 if !self.is_claude_code() {
558 return Some("Codex session — claudectl has no process to act on");
562 }
563 None
564 }
565
566 pub fn has_known_cost(&self) -> bool {
573 self.agent_family != crate::agent::vocab::CODEX
574 }
575
576 pub fn from_codex_rollout(summary: &crate::agent::codex::RolloutSummary) -> Self {
583 use crate::agent::codex::Lifecycle;
584
585 let mut session = Self::from_raw(RawSession {
586 pid: 0,
587 session_id: summary.session_id.clone(),
588 cwd: summary.cwd.clone(),
589 started_at: summary.started_at_secs.saturating_mul(1000),
590 });
591
592 session.agent_family = crate::agent::vocab::CODEX.to_string();
593 session.status = match summary.lifecycle {
594 Lifecycle::Working => SessionStatus::Processing,
595 Lifecycle::Idle => SessionStatus::WaitingInput,
598 Lifecycle::Unknown => SessionStatus::Unknown,
599 };
600
601 session.own_input_tokens = summary.input_tokens;
602 session.own_output_tokens = summary.output_tokens;
603 session.own_cache_read_tokens = summary.cached_input_tokens;
604 session.total_input_tokens = summary.input_tokens;
605 session.total_output_tokens = summary.output_tokens;
606 session.cache_read_tokens = summary.cached_input_tokens;
607 session.context_tokens = summary.total_tokens;
609 session.context_max = summary.context_window.unwrap_or(0);
610
611 if let Some(model) = &summary.model {
612 session.model = model.clone();
613 }
614 session.pending_tool_name = summary.last_tool.clone();
615 session.tool_usage = summary
616 .last_tool
617 .as_ref()
618 .map(|t| {
619 let mut m = HashMap::new();
620 m.insert(
621 t.clone(),
622 ToolStats {
623 calls: summary.tool_calls,
624 },
625 );
626 m
627 })
628 .unwrap_or_default();
629
630 session.cost_usd = 0.0;
632 session.usage_metrics_available = false;
633 session.cost_estimate_unverified = true;
634 session.model_profile_source = "codex (tokens only, no cost)".to_string();
635 session.jsonl_path = Some(summary.path.clone());
636
637 session
638 }
639
640 pub fn from_remote_json(worker_id: &str, json: &serde_json::Value) -> Option<Self> {
642 let pid = json.get("pid")?.as_u64()? as u32;
643 let project = json.get("project")?.as_str()?;
644 let status_str = json
645 .get("status")
646 .and_then(|v| v.as_str())
647 .unwrap_or("Unknown");
648 let status = match status_str {
649 "Needs Input" => SessionStatus::NeedsInput,
650 "Processing" => SessionStatus::Processing,
651 "Waiting" => SessionStatus::WaitingInput,
652 "Idle" => SessionStatus::Idle,
653 "Finished" => SessionStatus::Finished,
654 _ => SessionStatus::Unknown,
655 };
656
657 let elapsed_secs = json
658 .get("elapsed_secs")
659 .and_then(|v| v.as_u64())
660 .unwrap_or(0);
661 let now_ms = std::time::SystemTime::now()
662 .duration_since(std::time::UNIX_EPOCH)
663 .unwrap_or_default()
664 .as_millis() as u64;
665
666 let mut session = Self::from_raw(RawSession {
667 pid,
668 session_id: format!("remote-{worker_id}-{pid}"),
669 cwd: project.to_string(),
670 started_at: now_ms.saturating_sub(elapsed_secs * 1000),
671 });
672 session.status = status;
673 session.worker_origin = Some(worker_id.to_string());
674 session.project_name = format!("[{worker_id}] {project}");
675 let text = |key: &str, extra: &str| {
679 json.get(key)
680 .and_then(|v| v.as_str())
681 .filter(|s| !s.is_empty() && !s.starts_with('-'))
682 .filter(|s| {
683 s.chars().all(|c| {
684 c.is_ascii_alphanumeric() || "._-".contains(c) || extra.contains(c)
685 })
686 })
687 .map(str::to_string)
688 };
689 session.tmux_target = text("tmux_target", ": ");
690 session.remote_user = text("os_user", "");
691
692 if let Some(cost) = json.get("cost_usd").and_then(|v| v.as_f64()) {
694 session.cost_usd = cost;
695 session.usage_metrics_available = true;
696 }
697 if let Some(burn) = json.get("burn_rate_per_hr").and_then(|v| v.as_f64()) {
698 session.burn_rate_per_hr = burn;
699 }
700 if let Some(ctx) = json.get("context_pct").and_then(|v| v.as_f64()) {
701 session.context_max = 200_000; session.context_tokens = ((ctx / 100.0) * session.context_max as f64) as u64;
704 }
705 if let Some(t_in) = json.get("tokens_in").and_then(|v| v.as_u64()) {
706 session.total_input_tokens = t_in;
707 session.usage_metrics_available = true;
708 }
709 if let Some(t_out) = json.get("tokens_out").and_then(|v| v.as_u64()) {
710 session.total_output_tokens = t_out;
711 }
712 if let Some(model) = json.get("model").and_then(|v| v.as_str()) {
713 session.model = model.to_string();
714 }
715 if let Some(subs) = json.get("subagents").and_then(|v| v.as_u64()) {
716 session.subagent_count = subs as usize;
717 }
718 if let Some(decay) = json.get("decay_score").and_then(|v| v.as_u64()) {
719 session.decay_score = decay as u32;
720 }
721
722 Some(session)
723 }
724
725 pub fn format_subagent_summary(&self) -> String {
726 if self.subagent_count == 0 {
727 return "0".to_string();
728 }
729 if self.active_subagent_count == 0 || self.active_subagent_count == self.subagent_count {
730 return self.subagent_count.to_string();
731 }
732 format!(
733 "{} total ({} active)",
734 self.subagent_count, self.active_subagent_count
735 )
736 }
737
738 pub fn subagent_breakdown(&self) -> Vec<SubagentBreakdown> {
739 if self.subagent_rollups.is_empty() {
740 return Vec::new();
741 }
742
743 let active_paths: HashSet<&PathBuf> = self.active_subagent_jsonl_paths.iter().collect();
744 let mut active_rows = Vec::new();
745 let mut completed_rows = Vec::new();
746
747 for (path, rollup) in &self.subagent_rollups {
748 let row = SubagentBreakdown {
749 label: subagent_label(path),
750 state: if active_paths.contains(path) {
751 SubagentState::Active
752 } else {
753 SubagentState::Completed
754 },
755 count: 1,
756 input_tokens: rollup.input_tokens,
757 output_tokens: rollup.output_tokens,
758 cache_read_tokens: rollup.cache_read_tokens,
759 cache_write_tokens: rollup.cache_write_tokens,
760 cost_usd: rollup.cost_usd,
761 usage_metrics_available: rollup.usage_metrics_available,
762 cost_estimate_unverified: rollup.cost_estimate_unverified,
763 };
764
765 if row.state == SubagentState::Active {
766 active_rows.push(row);
767 } else {
768 completed_rows.push(row);
769 }
770 }
771
772 active_rows.sort_by(|a, b| a.label.cmp(&b.label));
773
774 let mut rows = Vec::new();
775 if !completed_rows.is_empty() {
776 let mut aggregate = SubagentBreakdown {
777 label: "completed".to_string(),
778 state: SubagentState::Completed,
779 count: completed_rows.len(),
780 input_tokens: 0,
781 output_tokens: 0,
782 cache_read_tokens: 0,
783 cache_write_tokens: 0,
784 cost_usd: 0.0,
785 usage_metrics_available: false,
786 cost_estimate_unverified: false,
787 };
788
789 for row in completed_rows {
790 aggregate.input_tokens += row.input_tokens;
791 aggregate.output_tokens += row.output_tokens;
792 aggregate.cache_read_tokens += row.cache_read_tokens;
793 aggregate.cache_write_tokens += row.cache_write_tokens;
794 aggregate.cost_usd += row.cost_usd;
795 aggregate.usage_metrics_available |= row.usage_metrics_available;
796 aggregate.cost_estimate_unverified |= row.cost_estimate_unverified;
797 }
798
799 rows.push(aggregate);
800 }
801
802 rows.extend(active_rows);
803 rows
804 }
805
806 pub fn format_elapsed(&self) -> String {
807 let secs = self.elapsed.as_secs();
808 let h = secs / 3600;
809 let m = (secs % 3600) / 60;
810 let s = secs % 60;
811 if h > 0 {
812 format!("{h:02}:{m:02}:{s:02}")
813 } else {
814 format!("{m:02}:{s:02}")
815 }
816 }
817
818 pub fn format_tokens(&self) -> String {
819 if !self.usage_metrics_available {
820 return "n/a".to_string();
821 }
822 let total = self.total_input_tokens + self.total_output_tokens;
823 if total == 0 {
824 return String::from("-");
825 }
826 format_count(self.total_input_tokens) + "/" + &format_count(self.total_output_tokens)
827 }
828
829 pub fn format_mem(&self) -> String {
830 if self.mem_mb < 1.0 {
831 return String::from("-");
832 }
833 format!("{:.0}M", self.mem_mb)
834 }
835
836 pub fn format_cost(&self) -> String {
837 if !self.usage_metrics_available {
838 return "n/a".to_string();
839 }
840 if self.cost_usd < 0.01 {
841 return String::from("-");
842 }
843 if self.cost_usd < 1.0 {
844 format!(
845 "${:.2}{}",
846 self.cost_usd,
847 if self.cost_estimate_unverified {
848 "?"
849 } else {
850 ""
851 }
852 )
853 } else {
854 format!(
855 "${:.1}{}",
856 self.cost_usd,
857 if self.cost_estimate_unverified {
858 "?"
859 } else {
860 ""
861 }
862 )
863 }
864 }
865
866 pub fn context_percent(&self) -> f64 {
867 if !self.usage_metrics_available {
868 return 0.0;
869 }
870 if self.context_max == 0 || self.context_tokens == 0 {
871 return 0.0;
872 }
873 (self.context_tokens as f64 / self.context_max as f64) * 100.0
874 }
875
876 pub fn format_context(&self) -> String {
878 if !self.usage_metrics_available {
879 return "n/a".to_string();
880 }
881 if self.context_tokens == 0 {
882 return String::from("-");
883 }
884 let pct = self.context_percent();
885 format!("{}%", pct as u32)
886 }
887
888 pub fn format_context_bar(&self, width: usize) -> String {
890 if !self.usage_metrics_available {
891 return "n/a".to_string();
892 }
893 let pct = self.context_percent();
894 if pct == 0.0 {
895 return String::from("-");
896 }
897 let filled = ((pct / 100.0) * width as f64).round() as usize;
898 let empty = width.saturating_sub(filled);
899 format!(
900 "{}{} {}%",
901 "█".repeat(filled),
902 "░".repeat(empty),
903 pct as u32
904 )
905 }
906
907 pub fn to_json_value(&self) -> serde_json::Value {
909 let cost_usd = if self.usage_metrics_available {
910 serde_json::json!((self.cost_usd * 100.0).round() / 100.0)
911 } else {
912 serde_json::Value::Null
913 };
914 let burn_rate = if self.usage_metrics_available {
915 serde_json::json!((self.burn_rate_per_hr * 100.0).round() / 100.0)
916 } else {
917 serde_json::Value::Null
918 };
919 let context_pct = if self.usage_metrics_available {
920 serde_json::json!((self.context_percent() * 100.0).round() / 100.0)
921 } else {
922 serde_json::Value::Null
923 };
924 let tokens_in = if self.usage_metrics_available {
925 serde_json::json!(self.total_input_tokens)
926 } else {
927 serde_json::Value::Null
928 };
929 let tokens_out = if self.usage_metrics_available {
930 serde_json::json!(self.total_output_tokens)
931 } else {
932 serde_json::Value::Null
933 };
934
935 serde_json::json!({
936 "pid": self.pid,
937 "session_id": self.session_id,
940 "project": self.display_name(),
941 "status": self.status.to_string(),
942 "telemetry": {
943 "state": self.telemetry_status.label(),
944 "usage_metrics_available": self.usage_metrics_available,
945 },
946 "estimate": {
947 "verified": !self.cost_estimate_unverified,
948 "profile_source": self.model_profile_source,
949 },
950 "context_pct": context_pct,
951 "cost_usd": cost_usd,
952 "burn_rate_per_hr": burn_rate,
953 "elapsed_secs": self.elapsed.as_secs(),
954 "cpu": self.cpu_percent,
955 "mem_mb": (self.mem_mb * 100.0).round() / 100.0,
956 "tokens_in": tokens_in,
957 "tokens_out": tokens_out,
958 "subagents": self.subagent_count,
959 "active_subagents": self.active_subagent_count,
960 "subagent_breakdown": self.subagent_breakdown().into_iter().map(|row| {
961 serde_json::json!({
962 "label": row.display_label(),
963 "state": row.state_label(),
964 "count": row.count,
965 "tokens_in": if row.usage_metrics_available {
966 serde_json::json!(row.total_input_tokens())
967 } else {
968 serde_json::Value::Null
969 },
970 "tokens_out": if row.usage_metrics_available {
971 serde_json::json!(row.output_tokens)
972 } else {
973 serde_json::Value::Null
974 },
975 "cost_usd": if row.usage_metrics_available {
976 serde_json::json!((row.cost_usd * 100.0).round() / 100.0)
977 } else {
978 serde_json::Value::Null
979 },
980 })
981 }).collect::<Vec<_>>(),
982 "decay_score": if self.usage_metrics_available { serde_json::json!(self.decay_score) } else { serde_json::Value::Null },
983 "last_error": self.last_error_message,
984 "recent_errors": self.recent_errors.iter().map(|e| {
985 serde_json::json!({
986 "tool": e.tool_name,
987 "message": e.message,
988 })
989 }).collect::<Vec<_>>(),
990 "files_modified": self.files_modified,
991 "tool_usage": self.tool_usage.iter().map(|(k, v)| {
992 (k.clone(), serde_json::json!({"calls": v.calls}))
993 }).collect::<serde_json::Map<String, serde_json::Value>>(),
994 "worker_origin": self.worker_origin,
995 "tmux_target": self.tmux_target,
996 })
997 }
998
999 pub fn format_burn_rate(&self) -> String {
1000 if !self.usage_metrics_available {
1001 return "n/a".to_string();
1002 }
1003 if self.burn_rate_per_hr < 0.01 {
1004 return String::from("-");
1005 }
1006 if self.burn_rate_per_hr < 1.0 {
1007 format!(
1008 "${:.2}/h{}",
1009 self.burn_rate_per_hr,
1010 if self.cost_estimate_unverified {
1011 "?"
1012 } else {
1013 ""
1014 }
1015 )
1016 } else {
1017 format!(
1018 "${:.1}/h{}",
1019 self.burn_rate_per_hr,
1020 if self.cost_estimate_unverified {
1021 "?"
1022 } else {
1023 ""
1024 }
1025 )
1026 }
1027 }
1028
1029 pub fn telemetry_label(&self) -> &'static str {
1030 self.telemetry_status.label()
1031 }
1032
1033 pub fn has_usage_metrics(&self) -> bool {
1034 self.usage_metrics_available
1035 }
1036}
1037
1038pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
1041 if s.len() <= max_bytes {
1042 return s;
1043 }
1044 let mut end = max_bytes;
1045 while end > 0 && !s.is_char_boundary(end) {
1046 end -= 1;
1047 }
1048 &s[..end]
1049}
1050
1051fn format_count(n: u64) -> String {
1052 if n >= 1_000_000 {
1053 format!("{:.1}M", n as f64 / 1_000_000.0)
1054 } else if n >= 1_000 {
1055 format!("{:.1}k", n as f64 / 1_000.0)
1056 } else {
1057 n.to_string()
1058 }
1059}
1060
1061fn subagent_label(path: &Path) -> String {
1062 let components: Vec<String> = path
1063 .components()
1064 .map(|component| component.as_os_str().to_string_lossy().to_string())
1065 .collect();
1066
1067 if let Some(tasks_idx) = components.iter().position(|component| component == "tasks") {
1068 let relative = &components[tasks_idx + 1..];
1069 if !relative.is_empty() {
1070 let mut label = relative.join("/");
1071 if let Some(stripped) = label.strip_suffix(".jsonl") {
1072 label = stripped.to_string();
1073 }
1074 return label;
1075 }
1076 }
1077
1078 path.file_stem()
1079 .and_then(|stem| stem.to_str())
1080 .unwrap_or("subagent")
1081 .to_string()
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087
1088 fn make_session() -> ClaudeSession {
1089 ClaudeSession::from_raw(RawSession {
1090 pid: 1,
1091 session_id: "session-1".into(),
1092 cwd: "/tmp/project".into(),
1093 started_at: 0,
1094 })
1095 }
1096
1097 fn codex_summary() -> crate::agent::codex::RolloutSummary {
1100 crate::agent::codex::RolloutSummary {
1101 session_id: "01a12c5d".into(),
1102 cwd: "/Users/dev/project".into(),
1103 started_at_secs: 1_700_000_000,
1104 last_event_secs: 1_700_000_100,
1105 input_tokens: 33_434,
1106 cached_input_tokens: 22_912,
1107 output_tokens: 215,
1108 total_tokens: 33_649,
1109 context_window: Some(272_000),
1110 model: Some("gpt-6-astra".into()),
1111 lifecycle: crate::agent::codex::Lifecycle::Idle,
1112 tool_calls: 3,
1113 last_tool: Some("Bash".into()),
1114 path: std::path::PathBuf::from("/Users/dev/.codex/sessions/2026/10/11/r.jsonl"),
1115 }
1116 }
1117
1118 #[test]
1119 fn a_local_claude_session_is_actionable() {
1120 let s = make_session();
1121 assert!(s.is_claude_code());
1122 assert!(s.is_actionable());
1123 assert_eq!(s.unactionable_reason(), None);
1124 assert!(s.has_known_cost());
1125 }
1126
1127 #[test]
1133 fn a_codex_session_is_never_actionable() {
1134 let s = ClaudeSession::from_codex_rollout(&codex_summary());
1135 assert!(!s.is_claude_code());
1136 assert!(
1137 !s.is_actionable(),
1138 "a Codex session must never be actionable — see from_codex_rollout"
1139 );
1140 assert_eq!(s.pid, 0, "there is no process to hold");
1141 let reason = s.unactionable_reason().expect("must carry a reason");
1142 assert!(
1143 reason.to_lowercase().contains("codex"),
1144 "the reason must name Codex rather than claiming the session is remote: {reason}"
1145 );
1146 }
1147
1148 #[test]
1152 fn remote_and_codex_give_different_reasons() {
1153 let mut remote = make_session();
1154 remote.worker_origin = Some("mini".into());
1155 let codex = ClaudeSession::from_codex_rollout(&codex_summary());
1156
1157 assert_ne!(remote.unactionable_reason(), codex.unactionable_reason());
1158 assert!(
1159 remote.unactionable_reason().unwrap().contains("Remote"),
1160 "the remote wording must not regress"
1161 );
1162 assert!(remote.is_claude_code());
1164 }
1165
1166 #[test]
1167 fn a_codex_session_carries_tokens_and_context_but_no_cost() {
1168 let s = ClaudeSession::from_codex_rollout(&codex_summary());
1169 assert_eq!(s.own_input_tokens, 33_434);
1170 assert_eq!(s.own_output_tokens, 215);
1171 assert_eq!(s.own_cache_read_tokens, 22_912);
1172 assert_eq!(
1173 s.context_tokens, 33_649,
1174 "Codex measures its window against total_tokens"
1175 );
1176 assert_eq!(s.context_max, 272_000);
1177
1178 assert_eq!(s.cost_usd, 0.0);
1179 assert!(
1180 !s.has_known_cost(),
1181 "Codex is subscription-billed; its cost is absent, not zero — a \
1182 0.0 that reads as known would understate nothing but would let a \
1183 fleet total claim completeness it does not have"
1184 );
1185 assert!(!s.usage_metrics_available);
1186 }
1187
1188 #[test]
1189 fn a_codex_session_maps_lifecycle_to_status() {
1190 use crate::agent::codex::Lifecycle;
1191 let mut summary = codex_summary();
1192
1193 summary.lifecycle = Lifecycle::Working;
1194 assert_eq!(
1195 ClaudeSession::from_codex_rollout(&summary).status,
1196 SessionStatus::Processing
1197 );
1198
1199 summary.lifecycle = Lifecycle::Idle;
1201 assert_eq!(
1202 ClaudeSession::from_codex_rollout(&summary).status,
1203 SessionStatus::WaitingInput
1204 );
1205
1206 summary.lifecycle = Lifecycle::Unknown;
1207 assert_eq!(
1208 ClaudeSession::from_codex_rollout(&summary).status,
1209 SessionStatus::Unknown
1210 );
1211 }
1212
1213 #[test]
1216 fn a_codex_session_keeps_its_rollout_as_its_transcript() {
1217 let s = ClaudeSession::from_codex_rollout(&codex_summary());
1218 assert_eq!(
1219 s.jsonl_path.as_deref(),
1220 Some(codex_summary().path.as_path())
1221 );
1222 assert_eq!(s.project_name, "project");
1223 assert_eq!(s.model, "gpt-6-astra");
1224 assert_eq!(s.pending_tool_name.as_deref(), Some("Bash"));
1225 }
1226
1227 #[test]
1228 fn record_burn_sample_smooths_and_caps_window() {
1229 let mut s = make_session();
1230 for _ in 0..crate::forecast::BURN_SAMPLE_CAP + 10 {
1232 s.burn_rate_per_hr = 6.0;
1233 s.record_burn_sample(2.0);
1234 }
1235 assert_eq!(s.burn_samples.len(), crate::forecast::BURN_SAMPLE_CAP);
1236 assert!(
1237 (s.burn_rate_ewma - 6.0).abs() < 0.5,
1238 "ewma: {}",
1239 s.burn_rate_ewma
1240 );
1241 }
1242
1243 #[test]
1244 fn budget_eta_forecasts_time_to_cap() {
1245 let mut s = make_session();
1246 for _ in 0..20 {
1247 s.burn_rate_per_hr = 5.0;
1248 s.record_burn_sample(2.0);
1249 }
1250 s.cost_usd = 5.0; let band = s.budget_eta(15.0);
1252 let mid = band.mid_hours.expect("burning, so an ETA exists");
1253 assert!((mid - 2.0).abs() < 0.4, "eta hours: {mid}");
1254 }
1255
1256 #[test]
1257 fn subagent_breakdown_groups_completed_and_lists_active_rows() {
1258 let mut session = make_session();
1259 let completed = PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/agent-1.jsonl");
1260 let active =
1261 PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/nested/agent-2.jsonl");
1262
1263 session.active_subagent_jsonl_paths = vec![active.clone()];
1264 session.subagent_rollups.insert(
1265 completed,
1266 SubagentRollup {
1267 input_tokens: 10_000,
1268 output_tokens: 2_000,
1269 cost_usd: 0.25,
1270 usage_metrics_available: true,
1271 ..SubagentRollup::default()
1272 },
1273 );
1274 session.subagent_rollups.insert(
1275 active,
1276 SubagentRollup {
1277 input_tokens: 40_000,
1278 output_tokens: 8_000,
1279 cost_usd: 1.5,
1280 usage_metrics_available: true,
1281 ..SubagentRollup::default()
1282 },
1283 );
1284
1285 let rows = session.subagent_breakdown();
1286 assert_eq!(rows.len(), 2);
1287 assert_eq!(rows[0].display_label(), "completed");
1288 assert_eq!(rows[0].state, SubagentState::Completed);
1289 assert_eq!(rows[0].count, 1);
1290 assert_eq!(rows[0].format_tokens(), "10.0k/2.0k");
1291 assert_eq!(rows[1].display_label(), "nested/agent-2");
1292 assert_eq!(rows[1].state, SubagentState::Active);
1293 assert_eq!(rows[1].format_cost(), "$1.5");
1294 }
1295
1296 #[test]
1297 fn subagent_breakdown_collapses_multiple_completed_rows() {
1298 let mut session = make_session();
1299
1300 for name in ["agent-1.jsonl", "agent-2.jsonl"] {
1301 let path = PathBuf::from(format!("/tmp/claude-1/-tmp-project/session-1/tasks/{name}"));
1302 session.subagent_rollups.insert(
1303 path,
1304 SubagentRollup {
1305 input_tokens: 10_000,
1306 output_tokens: 1_000,
1307 cost_usd: 0.2,
1308 usage_metrics_available: true,
1309 ..SubagentRollup::default()
1310 },
1311 );
1312 }
1313
1314 let rows = session.subagent_breakdown();
1315 assert_eq!(rows.len(), 1);
1316 assert_eq!(rows[0].display_label(), "completed (2)");
1317 assert_eq!(rows[0].count, 2);
1318 assert_eq!(rows[0].format_tokens(), "20.0k/2.0k");
1319 }
1320
1321 #[test]
1324 fn error_window_flush() {
1325 let mut s = make_session();
1326 s.current_window_errors = 3;
1327 for _ in 0..5 {
1329 s.record_activity();
1330 }
1331 assert_eq!(s.error_counts_per_window.len(), 1);
1332 assert_eq!(s.error_counts_per_window[0], 3);
1333 assert_eq!(s.current_window_errors, 0);
1334 assert_eq!(s.window_tick_counter, 0);
1335 }
1336
1337 #[test]
1338 fn baseline_error_rate_freezes() {
1339 let mut s = make_session();
1340 for errors in [2, 3, 4] {
1342 s.current_window_errors = errors;
1343 for _ in 0..5 {
1344 s.record_activity();
1345 }
1346 }
1347 assert_eq!(s.error_counts_per_window.len(), 3);
1348 let baseline = s.baseline_error_rate.expect("baseline should be set");
1349 assert!((baseline - 3.0).abs() < 0.01);
1351
1352 s.current_window_errors = 10;
1354 for _ in 0..5 {
1355 s.record_activity();
1356 }
1357 assert_eq!(s.baseline_error_rate.unwrap(), baseline);
1358 }
1359
1360 #[test]
1363 fn local_session_is_not_remote() {
1364 let s = make_session();
1365 assert!(!s.is_remote());
1366 assert!(s.worker_origin.is_none());
1367 }
1368
1369 #[test]
1370 fn from_remote_json_parses_basic_fields() {
1371 let json = serde_json::json!({
1372 "pid": 42,
1373 "project": "backend",
1374 "status": "Processing",
1375 "cost_usd": 1.23,
1376 "elapsed_secs": 600,
1377 "tokens_in": 50000,
1378 "tokens_out": 10000,
1379 });
1380 let session = ClaudeSession::from_remote_json("macbook-02", &json).unwrap();
1381 assert!(session.is_remote());
1382 assert_eq!(session.worker_origin.as_deref(), Some("macbook-02"));
1383 assert_eq!(session.pid, 42);
1384 assert_eq!(session.project_name, "[macbook-02] backend");
1385 assert_eq!(session.status, SessionStatus::Processing);
1386 assert!((session.cost_usd - 1.23).abs() < 0.01);
1387 assert_eq!(session.total_input_tokens, 50000);
1388 assert_eq!(session.total_output_tokens, 10000);
1389 assert!(session.usage_metrics_available);
1390 }
1391
1392 #[test]
1393 fn from_remote_json_handles_all_statuses() {
1394 for (label, expected) in [
1395 ("Needs Input", SessionStatus::NeedsInput),
1396 ("Processing", SessionStatus::Processing),
1397 ("Waiting", SessionStatus::WaitingInput),
1398 ("Idle", SessionStatus::Idle),
1399 ("Finished", SessionStatus::Finished),
1400 ("SomethingElse", SessionStatus::Unknown),
1401 ] {
1402 let json = serde_json::json!({"pid": 1, "project": "p", "status": label});
1403 let session = ClaudeSession::from_remote_json("w", &json).unwrap();
1404 assert_eq!(session.status, expected, "status mismatch for {label}");
1405 }
1406 }
1407
1408 #[test]
1409 fn from_remote_json_returns_none_on_missing_fields() {
1410 let json = serde_json::json!({"project": "x", "status": "Idle"});
1412 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1413
1414 let json = serde_json::json!({"pid": 1, "status": "Idle"});
1416 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1417 }
1418
1419 #[test]
1420 fn remote_session_display_name_shows_worker_prefix() {
1421 let json = serde_json::json!({"pid": 1, "project": "api-server", "status": "Idle"});
1422 let session = ClaudeSession::from_remote_json("laptop-01", &json).unwrap();
1423 assert_eq!(session.display_name(), "[laptop-01] api-server");
1424 }
1425
1426 #[test]
1427 fn from_remote_json_reads_attach_fields() {
1428 let json = serde_json::json!({
1429 "pid": 1, "project": "p", "status": "Idle",
1430 "tmux_target": "claude-staffai:0.0", "os_user": "baradas",
1431 });
1432 let session = ClaudeSession::from_remote_json("mini", &json).unwrap();
1433 assert_eq!(session.tmux_target.as_deref(), Some("claude-staffai:0.0"));
1434 assert_eq!(session.remote_user.as_deref(), Some("baradas"));
1435 }
1436
1437 #[test]
1438 fn from_remote_json_drops_attach_fields_that_could_be_ssh_options() {
1439 let json = serde_json::json!({
1440 "pid": 1, "project": "p", "status": "Idle",
1441 "tmux_target": "-x", "os_user": "-oProxyCommand=sh",
1442 });
1443 let session = ClaudeSession::from_remote_json("mini", &json).unwrap();
1444 assert!(session.tmux_target.is_none());
1445 assert!(session.remote_user.is_none());
1446
1447 let json = serde_json::json!({
1448 "pid": 1, "project": "p", "status": "Idle", "os_user": "bob;rm",
1449 });
1450 let session = ClaudeSession::from_remote_json("mini", &json).unwrap();
1451 assert!(session.remote_user.is_none());
1452 }
1453
1454 #[test]
1455 fn from_remote_json_from_an_older_peer_has_no_attach_fields() {
1456 let json = serde_json::json!({"pid": 1, "project": "p", "status": "Idle"});
1457 let session = ClaudeSession::from_remote_json("mini", &json).unwrap();
1458 assert!(session.tmux_target.is_none());
1459 assert!(session.remote_user.is_none());
1460 }
1461
1462 #[test]
1463 fn remote_session_json_includes_worker_origin() {
1464 let json = serde_json::json!({"pid": 1, "project": "test", "status": "Idle"});
1465 let session = ClaudeSession::from_remote_json("remote-w", &json).unwrap();
1466 let output = session.to_json_value();
1467 assert_eq!(
1468 output.get("worker_origin").and_then(|v| v.as_str()),
1469 Some("remote-w")
1470 );
1471 }
1472}