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}