Skip to main content

tear_types/
session.rs

1//! Session — a top-level grouping of windows that survives across
2//! client disconnects.
3
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    freio::{Admission, Freio, RefusalReason},
10    id::{PaneId, SessionId, WindowId},
11    pane::{InputPolicy, TearPane},
12    window::TearWindow,
13    yurai::Yurai,
14};
15
16/// One session: the top-level entity in the multiplexer hierarchy.
17/// A session owns a set of windows; each window owns a layout tree
18/// of panes. Sessions persist across client attach/detach cycles —
19/// this is what makes tear (and tmux) a *multiplexer* rather than a
20/// shell wrapper.
21#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
22pub struct TearSession {
23    pub id: SessionId,
24    /// Operator-visible session name (`"work"`, `"infra"`,
25    /// `"deploy-staging"`). Stable across renames? No — `tear rename`
26    /// mutates without minting a new ID.
27    pub name: String,
28    /// Windows belonging to this session, keyed by id. BTreeMap so
29    /// the wire format orders deterministically.
30    pub windows: BTreeMap<WindowId, TearWindow>,
31    /// Panes belonging to this session, keyed by id. Stored flat at
32    /// the session level so a pane can move between windows without
33    /// changing its address (tmux's `join-pane` semantics).
34    pub panes: BTreeMap<PaneId, TearPane>,
35    /// Currently-focused window id. Must exist in `windows`.
36    pub active_window: WindowId,
37    /// Lifecycle state.
38    pub state: SessionState,
39    /// Unix-seconds-since-epoch when this session was created.
40    pub created_at_unix: u64,
41    /// Optional operator-set description / notes — surfaced by
42    /// `tear list` and by the status bar.
43    #[serde(default)]
44    pub description: String,
45    /// Provenance — who/what created this session. Lets operators
46    /// audit at a glance whether a session was opened by a human
47    /// shell, by an AI agent (via mado MCP / direct UDS), or by a
48    /// named automation. `tear list --source agent` filters; mado
49    /// MCP tools default to `Source::Agent` so an operator's
50    /// `tear list` separates "what I started" from "what the agent
51    /// started behind my back". Default = `Source::Human` (the
52    /// safe assumption when nothing said otherwise — pre-#6
53    /// sessions deserialise as Human).
54    #[serde(default)]
55    pub source: SessionSource,
56    /// The operator's brake. `#[serde(default)]` → [`Freio::Released`],
57    /// which is what every pre-freio session record already means, so
58    /// landing this field changes no existing behaviour.
59    #[serde(default)]
60    pub freio: Freio,
61}
62
63impl TearSession {
64    /// What input a pane ACTUALLY accepts right now.
65    ///
66    /// **The only way to answer this question.** Note what deliberately
67    /// does not exist: a `TearPane::admits()`. A pane alone cannot answer
68    /// it — the brake lives on the session — and a method that pretended
69    /// otherwise is exactly how a pane comes to report `Free` while
70    /// refusing input. The absent method is the seal.
71    ///
72    /// This also JOINS two gates that already exist rather than adding a
73    /// third: the `Locked` check inside `tear-core::send_keys` and the
74    /// `Leader` check in the daemon's serve loop. Two authorities over one
75    /// question was already one too many; freio must not make it three.
76    ///
77    /// ## The ordering is the design
78    ///
79    /// The brake is consulted BEFORE the policy lattice. That is what
80    /// makes it non-advisory: a pane explicitly pinned to `Free` still
81    /// cannot escape a brake, because the brake is answered before the pin
82    /// is ever read.
83    #[must_use]
84    pub fn admits(&self, pane: PaneId) -> Option<Admission> {
85        let p = self.panes.get(&pane)?;
86        // Brake first — see above.
87        //
88        // Only `Automation` panes are braked. `Unknown` and `Human` panes
89        // keep accepting input, deliberately: a brake that can lock the
90        // operator out of their own terminal during the emergency they
91        // engaged it for is worse than no brake. The cost is that a pane
92        // the daemon could not classify SURVIVES the brake — an honest
93        // miss, which the CLI reports by name rather than hiding.
94        if self.freio.is_engaged() && p.yurai.is_automation() {
95            return Some(Admission::Refuse(RefusalReason::Freio));
96        }
97        Some(match p.input_policy {
98            InputPolicy::Free => Admission::Accept,
99            InputPolicy::Locked => Admission::Refuse(RefusalReason::Policy),
100            InputPolicy::Leader { id } => Admission::OnlyLeader { id },
101        })
102    }
103
104    /// Panes this session's brake would NOT stop, because their
105    /// provenance is unknown.
106    ///
107    /// Exists so the miss above is reportable. An operator pressing a
108    /// panic button must be told what it did not reach — silence here
109    /// would let them believe everything stopped.
110    #[must_use]
111    pub fn unbrakable(&self) -> Vec<PaneId> {
112        self.panes
113            .iter()
114            .filter(|(_, p)| matches!(p.yurai, Yurai::Unknown))
115            .map(|(id, _)| *id)
116            .collect()
117    }
118}
119
120/// Session lifecycle states.
121#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
122#[serde(rename_all = "lowercase")]
123pub enum SessionState {
124    /// Session has at least one window with at least one running pane.
125    Active,
126    /// All windows closed; session retained per
127    /// `destroy-unattached off` semantics until explicitly killed.
128    Detached,
129}
130
131/// Who/what created a session. Operator-visible provenance.
132/// Internally tagged so the wire shape stays compact + future
133/// `Named(_)` etc. can land without churning the variant ordering.
134#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
135#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
136pub enum SessionSource {
137    /// A human user (CLI, `tear up`, ghostty/iTerm interactive shell).
138    Human,
139    /// An AI agent — Claude Code, Cursor, OpenCode, the mado MCP
140    /// surface. Default for sessions created via the MCP path so
141    /// operators can `tear list --source agent` and triage.
142    Agent,
143    /// A named automation (CI job, scheduled task, sidecar). The
144    /// id is operator-defined — e.g. `"pleme-ci-deploy"` —
145    /// surfaced verbatim in `tear list`. Lets one daemon hold
146    /// sessions from many automations without colliding under a
147    /// single `Agent` bucket.
148    Named(String),
149}
150
151impl Default for SessionSource {
152    fn default() -> Self {
153        SessionSource::Human
154    }
155}
156
157impl SessionSource {
158    /// Short label for `tear list` text output.
159    #[must_use]
160    pub fn label(&self) -> &str {
161        match self {
162            SessionSource::Human => "human",
163            SessionSource::Agent => "agent",
164            SessionSource::Named(_) => "named",
165        }
166    }
167}
168
169#[cfg(test)]
170mod freio_rows {
171    use super::*;
172    use crate::pane::PaneState;
173    use std::collections::BTreeMap;
174
175    fn pane(id: u64, yurai: Yurai, input_policy: InputPolicy) -> TearPane {
176        TearPane {
177            id: PaneId(id),
178            shell: "/bin/sh".into(),
179            args: vec![],
180            cwd: None,
181            env: vec![],
182            size_cells: (80, 24),
183            origin_cells: (0, 0),
184            state: PaneState::Running,
185            title: "sh".into(),
186            input_policy,
187            yurai,
188        }
189    }
190
191    fn session(panes: Vec<TearPane>, freio: Freio) -> TearSession {
192        let mut m = BTreeMap::new();
193        for p in panes {
194            m.insert(p.id, p);
195        }
196        TearSession {
197            id: SessionId(1),
198            name: "s".into(),
199            windows: BTreeMap::new(),
200            panes: m,
201            active_window: WindowId(1),
202            state: SessionState::Active,
203            created_at_unix: 0,
204            description: String::new(),
205            source: SessionSource::Human,
206            freio,
207        }
208    }
209
210    const ON: Freio = Freio::Engaged { at_unix: 1 };
211
212    /// The brake stops automation and leaves everything else alone.
213    #[test]
214    fn freio_brakes_only_automation_panes() {
215        let s = session(
216            vec![
217                pane(1, Yurai::Automation { label: None }, InputPolicy::Free),
218                pane(2, Yurai::Human, InputPolicy::Free),
219                pane(3, Yurai::Unknown, InputPolicy::Free),
220            ],
221            ON,
222        );
223        assert_eq!(
224            s.admits(PaneId(1)),
225            Some(Admission::Refuse(RefusalReason::Freio))
226        );
227        assert_eq!(s.admits(PaneId(2)), Some(Admission::Accept));
228        assert_eq!(
229            s.admits(PaneId(3)),
230            Some(Admission::Accept),
231            "an UNKNOWN pane must survive the brake — operator decision \
232             2026-08-01. A panic button that can lock you out of your own \
233             terminal during the emergency you pressed it for is not one."
234        );
235    }
236
237    /// ★ THE ORDERING ROW. The brake is consulted BEFORE the policy
238    /// lattice, which is the whole reason it is not advisory: a pane
239    /// explicitly pinned to `Free` still cannot escape it.
240    #[test]
241    fn an_explicitly_free_automation_pane_cannot_escape_the_brake() {
242        let s = session(
243            vec![pane(1, Yurai::Automation { label: None }, InputPolicy::Free)],
244            ON,
245        );
246        assert_eq!(
247            s.admits(PaneId(1)),
248            Some(Admission::Refuse(RefusalReason::Freio)),
249            "checking the policy first would let an explicitly-Free pane \
250             walk straight through the brake"
251        );
252    }
253
254    /// A braked automation pane refuses for the RIGHT reason — the two
255    /// refusals send an operator to different fixes.
256    #[test]
257    fn a_braked_pane_reports_freio_not_policy() {
258        let s = session(
259            vec![pane(1, Yurai::Automation { label: None }, InputPolicy::Locked)],
260            ON,
261        );
262        assert_eq!(
263            s.admits(PaneId(1)),
264            Some(Admission::Refuse(RefusalReason::Freio)),
265            "the brake is why this pane is refusing right now; saying \
266             'policy' would send the operator to unlock a pane that would \
267             still refuse"
268        );
269    }
270
271    /// Releasing restores the EXACT prior admission — a Locked pane stays
272    /// Locked, a Leader pane stays Leader. Release clears the brake; it
273    /// does not set every pane free.
274    #[test]
275    fn releasing_restores_the_exact_prior_admission() {
276        let panes = || {
277            vec![
278                pane(1, Yurai::Automation { label: None }, InputPolicy::Locked),
279                pane(2, Yurai::Automation { label: None }, InputPolicy::Leader { id: 7 }),
280                pane(3, Yurai::Automation { label: None }, InputPolicy::Free),
281            ]
282        };
283        let released = session(panes(), Freio::Released);
284        assert_eq!(
285            released.admits(PaneId(1)),
286            Some(Admission::Refuse(RefusalReason::Policy))
287        );
288        assert_eq!(
289            released.admits(PaneId(2)),
290            Some(Admission::OnlyLeader { id: 7 })
291        );
292        assert_eq!(released.admits(PaneId(3)), Some(Admission::Accept));
293    }
294
295    /// The honest miss is REPORTABLE. An operator pressing a panic button
296    /// must be told what it did not reach.
297    #[test]
298    fn the_panes_the_brake_cannot_reach_are_nameable() {
299        let s = session(
300            vec![
301                pane(1, Yurai::Automation { label: None }, InputPolicy::Free),
302                pane(2, Yurai::Unknown, InputPolicy::Free),
303                pane(3, Yurai::Unknown, InputPolicy::Free),
304            ],
305            ON,
306        );
307        let missed = s.unbrakable();
308        assert_eq!(
309            missed,
310            vec![PaneId(2), PaneId(3)],
311            "silence here would let an operator believe everything stopped"
312        );
313    }
314
315    #[test]
316    fn a_released_session_admits_exactly_as_before_freio_existed() {
317        let s = session(
318            vec![pane(1, Yurai::Automation { label: None }, InputPolicy::Free)],
319            Freio::Released,
320        );
321        assert_eq!(s.admits(PaneId(1)), Some(Admission::Accept));
322    }
323
324    #[test]
325    fn an_unknown_pane_is_not_admitted_at_all() {
326        let s = session(vec![], ON);
327        assert_eq!(s.admits(PaneId(99)), None, "no such pane is not 'accept'");
328    }
329}