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