Skip to main content

claudectl_core/
session.rs

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,   // Blocked — waiting for user to approve/confirm (permission prompt)
11    Processing,   // Actively generating or executing tools
12    WaitingInput, // Done responding, waiting for user's next prompt
13    Unknown,      // Process is alive, but transcript telemetry is unavailable
14    Idle,         // No recent activity, stale session
15    Finished,     // Process exited
16}
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    /// Read by the send hotkey (#510), which is why this no longer carries an
93    /// `allow(dead_code)` — see the #465 audit for why those expire.
94    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>, // Last N CPU readings for smoothing
103    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    /// The subset of `own_cache_write_tokens` written with a 1-hour TTL, which
109    /// bills at 2x base input rather than 1.25x.
110    pub own_cache_write_1h_tokens: u64,
111    /// The message id whose usage was counted most recently.
112    ///
113    /// Claude Code writes one JSONL line per content block of an assistant
114    /// turn — `thinking`, `text`, `tool_use` — and repeats the turn's whole
115    /// `usage` on each. Counting every line charged the same tokens two or
116    /// three times over. Duplicate lines are always adjacent (verified across
117    /// 4814 turns in 25 transcripts), so remembering one id is enough, and it
118    /// has to live on the session because parsing is incremental and a turn's
119    /// lines can straddle two reads.
120    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    /// 1-hour-TTL subset of `subagent_cache_write_tokens`.
126    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    /// EWMA-smoothed burn rate ($/hr) used for budget forecasting. Damps the
143    /// tick-to-tick flicker of `burn_rate_per_hr`. See `forecast` (#370).
144    pub burn_rate_ewma: f64,
145    /// Recent instantaneous burn samples ($/hr), capped at `BURN_SAMPLE_CAP`,
146    /// for the p10/p90 ETA band. In-memory only (rebuilt at runtime).
147    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>, // Ring buffer of status levels (0-7) for sparkline, one per tick
153    pub files_modified: HashMap<String, u32>, // file path -> edit count
154    pub tool_usage: HashMap<String, ToolStats>, // tool name -> call count & tokens
155    pub worktree_id: Option<String>, // Resolved git toplevel + git-dir, for conflict detection
156    pub telemetry_status: TelemetryStatus,
157    pub usage_metrics_available: bool,
158    pub cost_estimate_unverified: bool,
159    pub model_profile_source: String,
160    /// Persisted across ticks so status inference works when no new JSONL arrives.
161    pub last_msg_type: String,
162    pub last_stop_reason: String,
163    pub is_waiting_for_task: bool,
164    /// Pending tool call details for rule-based auto-actions.
165    pub pending_tool_name: Option<String>,
166    pub pending_tool_input: Option<String>, // Extracted command string (for Bash)
167    pub pending_file_path: Option<String>,  // File path for pending Edit/Write/NotebookEdit
168    pub has_file_conflict: bool,            // Pending file edit conflicts with another session
169    pub last_tool_error: bool,
170    pub last_error_message: Option<String>,
171    pub recent_errors: Vec<ErrorEntry>, // Last 5 errors (ring buffer)
172    // ── Cognitive health tracking ────────────────────────────────────
173    /// Cumulative tokens at each Edit/Write event (for efficiency trending).
174    pub total_tokens_at_edit_count: u64,
175    /// Number of Edit/Write events (for averaging tokens-per-edit).
176    pub edit_event_count: u32,
177    /// Baseline tokens-per-edit, frozen after first 5 edits.
178    pub baseline_tokens_per_edit: Option<f64>,
179    /// Error count ring buffer: one entry per window (~10s each).
180    pub error_counts_per_window: Vec<u32>, // max 10 entries
181    /// Accumulator for current error window.
182    pub current_window_errors: u32,
183    /// Ticks since last window flush.
184    pub window_tick_counter: u32,
185    /// Baseline error rate (errors per window), frozen after 3 windows.
186    pub baseline_error_rate: Option<f64>,
187    /// File reads since last edit: path -> read count. Reset when file is edited.
188    pub file_reads_since_edit: HashMap<String, u32>,
189    /// All-time error count.
190    pub total_error_count: u32,
191    /// Cached composite decay score (0-100), recomputed each tick.
192    pub decay_score: u32,
193    /// If set, this session is from a remote worker (not local).
194    /// Terminal actions (approve, kill, etc.) are disabled for remote sessions;
195    /// switching attaches over ssh when the fields below are known.
196    pub worker_origin: Option<String>,
197    /// Which coding agent this session belongs to — `claude-code`, `codex`
198    /// (#524). Defaults to `claude-code` in every existing constructor, so
199    /// nothing that predates the field changes behaviour.
200    ///
201    /// The type keeps its name. A Codex session fills the same fields and
202    /// flows through the same views, and renaming a struct that is public API
203    /// across three crates buys nothing a field does not.
204    pub agent_family: String,
205    /// The tmux pane (`session:window.pane`) this session runs in, on the
206    /// machine that owns it. Filled by the relay's collector, which ships it
207    /// to peers so they can attach; `None` when the session isn't in tmux.
208    pub tmux_target: Option<String>,
209    /// Remote sessions only: the login on the owning machine, as that machine
210    /// advertised it. `None` from a peer too old to send it.
211    pub remote_user: Option<String>,
212    /// Remote sessions only: the address this machine reaches the owning peer
213    /// at, from the relay connection. `None` when the relay doesn't know it.
214    pub remote_host: Option<String>,
215}
216
217/// A captured tool error with context.
218#[derive(Debug, Clone)]
219pub struct ErrorEntry {
220    pub tool_name: String,
221    pub message: String,
222}
223
224/// Per-tool usage statistics.
225#[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    /// The 1-hour-TTL subset of `cache_write_tokens`, billed at 2x base input.
238    pub cache_write_1h_tokens: u64,
239    /// The last turn counted, so a turn written as several JSONL lines is not
240    /// charged once per line. Same reason as `ClaudeSession::last_usage_msg_id`.
241    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            // Every existing caller of `from_raw` is a Claude Code session;
416            // `from_codex_rollout` overrides this.
417            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    /// Record current status into the activity sparkline ring buffer.
425    /// Max 15 entries (one per tick, at 2s default = 30s of history).
426    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        // Flush error window every 5 ticks (~10s at default 2s interval)
441        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            // Freeze baseline error rate after 3 windows
449            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    /// Fold the current instantaneous `burn_rate_per_hr` into the EWMA and the
460    /// rolling sample window. Call once per refresh after burn is recomputed.
461    /// `interval_secs` is the refresh cadence (shapes the EWMA half-life).
462    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    /// Forecast time-to-budget for this session given a per-session cap, as a
481    /// p10/p90 interval. `None` when the session isn't burning or has no cap.
482    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    /// Render the sparkline as unicode block characters.
489    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    /// Whether this session is from a remote worker (not local).
512    pub fn is_remote(&self) -> bool {
513        self.worker_origin.is_some()
514    }
515
516    /// Whether this is a Claude Code session (#524).
517    ///
518    /// The question to ask when the answer is about Claude Code's *file
519    /// layout* — `~/.claude/projects` transcripts, `/tmp/claude-<uid>`
520    /// subagent tasks — rather than about permission to act. A remote Claude
521    /// session is still a Claude Code session; `is_actionable` is what says
522    /// you cannot touch it.
523    pub fn is_claude_code(&self) -> bool {
524        self.agent_family == crate::agent::vocab::CLAUDE_CODE
525    }
526
527    /// Whether claudectl can *act* on this session — send input, interrupt,
528    /// approve, kill (#524).
529    ///
530    /// False for two different reasons, which is why this is one predicate
531    /// rather than scattered checks:
532    ///
533    /// * A **remote** session's process is on another machine.
534    /// * A **non-Claude-Code** session has no process claudectl can address.
535    ///   For Codex specifically this is not "not implemented yet": the
536    ///   ChatGPT app runs one `codex … app-server` for every session at once,
537    ///   so there is no pid that means *this* session, and the pid we do hold
538    ///   is 0. Signalling it would reach the caller's own process group.
539    ///
540    /// Read it as the question "may I touch this?", not "is this local?" —
541    /// `is_remote` answers the latter and is still the right check for
542    /// display and fleet labelling.
543    pub fn is_actionable(&self) -> bool {
544        self.unactionable_reason().is_none()
545    }
546
547    /// Why this session cannot be acted on, as a line fit for a status bar —
548    /// or `None` when it can.
549    ///
550    /// The two reasons are genuinely different and a caller that collapses
551    /// them tells the user something false, so the reason travels with the
552    /// verdict rather than being re-derived at each of the gate sites.
553    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            // Deliberately not "not supported yet". With the ChatGPT app one
559            // `codex … app-server` serves every session, so there is no pid
560            // that means this one; see `agent::codex`.
561            return Some("Codex session — claudectl has no process to act on");
562        }
563        None
564    }
565
566    /// Whether this session's `cost_usd` means anything (#524).
567    ///
568    /// Codex is subscription-billed and its transcripts record tokens only,
569    /// so a Codex session's cost is *absent*, not zero. Anything summing or
570    /// rendering cost should ask this rather than trusting a 0.0 that cannot
571    /// be told apart from a session that genuinely cost nothing.
572    pub fn has_known_cost(&self) -> bool {
573        self.agent_family != crate::agent::vocab::CODEX
574    }
575
576    /// Build a session from a parsed Codex rollout transcript (#524).
577    ///
578    /// `pid` is 0 and `is_actionable()` is false — see that method for why
579    /// there is no process to hold. `cost_usd` stays 0 with
580    /// `usage_metrics_available` false, so it reads as absent rather than
581    /// free.
582    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            // `task_complete` ends a turn, not a session: the agent is done
596            // responding and waiting for the next prompt.
597            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        // Codex measures its context window against `total_tokens`.
608        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        // Absent, not free. See `has_known_cost`.
631        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    /// Build a ClaudeSession from remote JSON (as received via heartbeat/HTTP).
641    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        // Both end up in an ssh command line, and a peer chooses them. Anything
676        // that could read as an option or carry shell syntax is dropped, which
677        // leaves the session looking like one from a peer too old to send it.
678        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        // Populate metrics from JSON when available
693        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            // Reverse-engineer context_tokens/context_max from percentage
702            session.context_max = 200_000; // reasonable default
703            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    /// Format context as "450k/1M 45%" or a visual bar
877    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    /// Visual bar for context usage: ████░░ 62%
889    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    /// Produce a JSON-serializable value for --json export.
908    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            // Carried to peers so a fleet lease authority can renew this
938            // session's leases while it's alive (#569).
939            "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
1038/// Truncate a string to at most `max_bytes` bytes, landing on a valid
1039/// UTF-8 character boundary. Returns the original string if already short enough.
1040pub 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    // ── #524 agent family and the action gate ──────────────────────────
1098
1099    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    /// The safety property this whole phase turns on: nothing may offer to
1128    /// send input to, interrupt, or kill a Codex session. There is no pid
1129    /// that means *this* session — with the ChatGPT app one `app-server`
1130    /// process serves them all — and the pid we hold is 0, which
1131    /// `kill(0, sig)` aims at the caller's own process group.
1132    #[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    /// A remote session and a Codex session are unactionable for different
1149    /// reasons, and a caller that collapses them tells the user something
1150    /// false.
1151    #[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        // A remote session is still a Claude Code session.
1163        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        // `task_complete` ends a turn, not a session.
1200        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    /// The rollout is the transcript. `resolve_jsonl_paths` must not later
1214    /// point this at a `~/.claude/projects` file.
1215    #[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        // A steady burn settles the EWMA near the true rate and fills the window.
1231        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; // $5 spent of a $15 cap ⇒ $10 headroom at ~$5/hr ≈ 2h
1251        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    // ── Cognitive health tracking tests ──────────────────────────────
1322
1323    #[test]
1324    fn error_window_flush() {
1325        let mut s = make_session();
1326        s.current_window_errors = 3;
1327        // Call record_activity 5 times to trigger one window flush
1328        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        // Simulate 3 windows of errors
1341        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        // baseline = (2+3+4)/3 = 3.0
1350        assert!((baseline - 3.0).abs() < 0.01);
1351
1352        // Add another window — baseline should NOT change
1353        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    // ── Remote session tests ────────────────────────────────────────
1361
1362    #[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        // Missing pid
1411        let json = serde_json::json!({"project": "x", "status": "Idle"});
1412        assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1413
1414        // Missing project
1415        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}