1	//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).
2	//!
3	//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user
4	//! turns plus the agent output between them, with tool-usage sprints collapsed
5	//! to a single entry. Distinct from raw scrollback (bytes) and transcript
6	//! history (the full conversation); its job is *"what is this agent doing right
7	//! now, at a glance."*
8	//!
9	//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)
10	//!
11	//! **The adapter supplies the patterns; this primitive only runs them.** Exactly
12	//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,
13	//! adapter owns the pattern" principle (identical to opaque command templates).
14	//! The seam is three adapter-supplied pattern kinds:
15	//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —
16	//!   each *opens a span capturing everything, newlines included, until the next
17	//!   pattern match of any kind*.
18	//! - **`tools`** — a list (one regex per known tool) **plus an optional
19	//!   `catchall`** for unknowable user-installed tools — each extracting a tool
20	//!   `name` and an `arg` fragment via named capture groups.
21	//!
22	//! Window depth `N` and the tool-arg truncation width are spt-core-controlled
23	//! *presentation*; the template controls *extraction*.
24	//!
25	//! ## Scope (M3a is the primitive ONLY)
26	//!
27	//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4
28	//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
29	//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need
30	//! the broker + manifest + history store this crate deliberately does not build.
31	//!
32	//! ## OS-neutrality (Spike #4 / #5)
33	//!
34	//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The
35	//! parser therefore re-derives the digest from a bounded rolling text buffer and
36	//! reports the **tail** (last `N` turns): a repaint that resends a turn just
37	//! makes the latest copy win. ANSI control sequences (cursor moves, clears,
38	//! repaint machinery) are stripped before matching so the adapter patterns see
39	//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.
40
41	use regex::Regex;
42
43	/// A failure compiling an adapter-supplied pattern.
44	#[derive(Debug)]
45	pub enum DigestError {
46	    /// A pattern did not compile as a regex. Names which one.
47	    BadPattern { which: String, error: String },
48	}
49
50	impl std::fmt::Display for DigestError {
51	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52	        match self {
53	            DigestError::BadPattern { which, error } => {
54	                write!(f, "digest pattern {which} did not compile: {error}")
55	            }
56	        }
57	    }
58	}
59
60	impl std::error::Error for DigestError {}
61
62	/// The adapter-supplied patterns (regex source strings). `input` / `agent` are
63	/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.
64	#[derive(Debug, Clone)]
65	pub struct DigestPatterns {
66	    /// User-input boundary marker.
67	    pub input: String,
68	    /// Agent-output boundary marker.
69	    pub agent: String,
70	    /// One regex per known tool; each should name an `arg` group (and may name a
71	    /// `name` group — else the tool's matched text leads).
72	    pub tools: Vec<String>,
73	    /// Optional final catchall for user-installed tools.
74	    pub catchall: Option<String>,
75	}
76
77	/// Presentation knobs owned by spt-core (not the template).
78	#[derive(Debug, Clone, Copy)]
79	pub struct DigestConfig {
80	    /// Window depth — keep the last `window_turns` user turns (~3).
81	    pub window_turns: usize,
82	    /// Tool-arg truncation width in characters (~25).
83	    pub arg_truncation: usize,
84	    /// Strip ANSI control sequences before matching (default on — see module docs).
85	    pub strip_ansi: bool,
86	    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the
87	    /// front so memory stays bounded under an endless stream.
88	    pub max_scan_bytes: usize,
89	}
90
91	impl Default for DigestConfig {
92	    fn default() -> Self {
93	        Self { window_turns: 3, arg_truncation: 25, strip_ansi: true, max_scan_bytes: 64 * 1024 }
94	    }
95	}
96
97	/// A single collapsed tool invocation.
98	#[derive(Debug, Clone, PartialEq, Eq)]
99	pub struct ToolUse {
100	    /// The tool name (e.g. `Write`).
101	    pub name: String,
102	    /// The (truncated) argument fragment.
103	    pub arg: String,
104	}
105
106	/// One source-tagged entry within a turn.
107	#[derive(Debug, Clone, PartialEq, Eq)]
108	pub enum DigestEntry {
109	    /// Agent output text (between boundaries).
110	    Agent(String),
111	    /// A collapsed run of consecutive tool invocations (one "sprint").
112	    ToolSprint(Vec<ToolUse>),
113	}
114
115	/// One user turn: the user input that opened it (None for any pre-input
116	/// preamble) plus the agent output / tool sprints that followed, in order.
117	#[derive(Debug, Clone, PartialEq, Eq, Default)]
118	pub struct Turn {
119	    /// The user input text that opened this turn.
120	    pub input: Option<String>,
121	    /// Agent + tool-sprint entries that followed, in stream order.
122	    pub entries: Vec<DigestEntry>,
123	}
124
125	/// The structured rolling digest — the last `N` turns.
126	#[derive(Debug, Clone, PartialEq, Eq, Default)]
127	pub struct Digest {
128	    /// Turns oldest-to-newest, at most `window_turns` of them.
129	    pub turns: Vec<Turn>,
130	}
131
132	/// One located pattern hit while scanning the buffer.
133	enum Marker {
134	    Input { content: String },
135	    Agent { content: String },
136	    Tool(ToolUse),
137	}
138
139	/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
140	#[derive(Debug)]
141	pub struct DigestParser {
142	    input_re: Regex,
143	    agent_re: Regex,
144	    tool_res: Vec<Regex>,
145	    catchall_re: Option<Regex>,
146	    ansi_re: Option<Regex>,
147	    config: DigestConfig,
148	    /// Rolling, ANSI-stripped text buffer.
149	    buf: String,
150	}
151
152	impl DigestParser {
153	    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]
154	    /// naming the first pattern that fails to compile.
155	    //
156	    // [impl->REQ-TERM-4]
157	    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {
158	        let compile = |src: &str, which: &str| {
159	            Regex::new(src).map_err(|e| DigestError::BadPattern {
160	                which: which.to_string(),
161	                error: e.to_string(),
162	            })
163	        };
164	        let input_re = compile(&patterns.input, "input")?;
165	        let agent_re = compile(&patterns.agent, "agent")?;
166	        let mut tool_res = Vec::with_capacity(patterns.tools.len());
167	        for (i, t) in patterns.tools.iter().enumerate() {
168	            tool_res.push(compile(t, &format!("tools[{i}]"))?);
169	        }
170	        let catchall_re = match &patterns.catchall {
171	            Some(c) => Some(compile(c, "catchall")?),
172	            None => None,
173	        };
174	        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.
175	        let ansi_re = if config.strip_ansi {
176	            Some(
177	                Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]")
178	                    .expect("static ANSI regex compiles"),
179	            )
180	        } else {
181	            None
182	        };
183	        Ok(Self {
184	            input_re,
185	            agent_re,
186	            tool_res,
187	            catchall_re,
188	            ansi_re,
189	            config,
190	            buf: String::new(),
191	        })
192	    }
193
194	    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped
195	    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to
196	    /// the rolling cap.
197	    //
198	    // [impl->REQ-TERM-4]
199	    pub fn feed(&mut self, bytes: &[u8]) {
200	        let text = String::from_utf8_lossy(bytes);
201	        let cleaned = match &self.ansi_re {
202	            Some(re) => re.replace_all(&text, "").into_owned(),
203	            None => text.into_owned(),
204	        };
205	        // Normalize carriage returns left by ConPTY repaint so boundary patterns
206	        // and span content match on line-oriented text.
207	        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");
208	        self.buf.push_str(&cleaned);
209
210	        if self.buf.len() > self.config.max_scan_bytes {
211	            // Drop from the front, on a char boundary.
212	            let cut = self.buf.len() - self.config.max_scan_bytes;
213	            let mut idx = cut;
214	            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {
215	                idx += 1;
216	            }
217	            self.buf.drain(..idx);
218	        }
219	    }
220
221	    /// Re-derive the structured digest from the current buffer: scan all pattern
222	    /// hits in order, fold boundary spans + collapsed tool sprints into turns,
223	    /// and return the last `window_turns`.
224	    //
225	    // [impl->REQ-TERM-4]
226	    pub fn digest(&self) -> Digest {
227	        let markers = self.scan_markers();
228	        let mut turns: Vec<Turn> = Vec::new();
229	        let mut current = Turn::default();
230	        let mut current_started = false;
231
232	        for m in markers {
233	            match m {
234	                Marker::Input { content } => {
235	                    if current_started || !current.entries.is_empty() || current.input.is_some() {
236	                        turns.push(std::mem::take(&mut current));
237	                    }
238	                    current = Turn { input: Some(content), entries: Vec::new() };
239	                    current_started = true;
240	                }
241	                Marker::Agent { content } => {
242	                    if !content.is_empty() {
243	                        current.entries.push(DigestEntry::Agent(content));
244	                        current_started = true;
245	                    }
246	                }
247	                Marker::Tool(tu) => {
248	                    // Collapse into the trailing sprint if the last entry is one.
249	                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
250	                        sprint.push(tu);
251	                    } else {
252	                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));
253	                    }
254	                    current_started = true;
255	                }
256	            }
257	        }
258	        if current_started || !current.entries.is_empty() || current.input.is_some() {
259	            turns.push(current);
260	        }
261
262	        // Keep only the last N turns.
263	        let n = self.config.window_turns;
264	        if turns.len() > n {
265	            turns.drain(..turns.len() - n);
266	        }
267	        Digest { turns }
268	    }
269
270	    /// Locate every pattern hit in the buffer, sorted by position, and resolve
271	    /// boundary span content (text from a boundary to the next hit of any kind).
272	    fn scan_markers(&self) -> Vec<Marker> {
273	        // (start, end, kind) where kind carries the extracted payload.
274	        enum Kind {
275	            Input,
276	            Agent,
277	            Tool(ToolUse),
278	        }
279	        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();
280
281	        for m in self.input_re.find_iter(&self.buf) {
282	            hits.push((m.start(), m.end(), Kind::Input));
283	        }
284	        for m in self.agent_re.find_iter(&self.buf) {
285	            hits.push((m.start(), m.end(), Kind::Agent));
286	        }
287	        let mut push_tool = |re: &Regex| {
288	            for caps in re.captures_iter(&self.buf) {
289	                let whole = caps.get(0).unwrap();
290	                let name = caps
291	                    .name("name")
292	                    .map(|m| m.as_str().to_string())
293	                    .unwrap_or_else(|| {
294	                        // No name group: lead with the matched text's first token.
295	                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
296	                    });
297	                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
298	                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
299	                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
300	            }
301	        };
302	        for re in &self.tool_res {
303	            push_tool(re);
304	        }
305	        if let Some(re) = &self.catchall_re {
306	            // Only count a catchall hit where no specific tool already matched.
307	            for caps in re.captures_iter(&self.buf) {
308	                let whole = caps.get(0).unwrap();
309	                if hits.iter().any(|(s, e, k)| {
310	                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e
311	                }) {
312	                    continue;
313	                }
314	                let name = caps
315	                    .name("name")
316	                    .map(|m| m.as_str().to_string())
317	                    .unwrap_or_else(|| {
318	                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
319	                    });
320	                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
321	                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
322	                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
323	            }
324	        }
325
326	        // Order by position; on a tie, boundaries before tools (a boundary opens
327	        // a span, tools sit inside it).
328	        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };
329	        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));
330
331	        // Resolve boundary span content = text from this hit's end to the next
332	        // hit's start.
333	        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();
334	        let mut out = Vec::with_capacity(hits.len());
335	        for (i, (_, end, kind)) in hits.iter().enumerate() {
336	            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());
337	            let span = self.buf.get(*end..next_start).unwrap_or("").trim().to_string();
338	            match kind {
339	                Kind::Input => out.push(Marker::Input { content: span }),
340	                Kind::Agent => out.push(Marker::Agent { content: span }),
341	                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),
342	            }
343	        }
344	        out
345	    }
346	}
347
348	/// Truncate `arg` to `width` characters, appending `…` when shortened.
349	fn truncate_arg(arg: &str, width: usize) -> String {
350	    if arg.chars().count() <= width {
351	        return arg.to_string();
352	    }
353	    let mut s: String = arg.chars().take(width).collect();
354	    s.push('…');
355	    s
356	}
357
358	#[cfg(test)]
359	mod tests {
360	    use super::*;
361
362	    fn patterns() -> DigestPatterns {
363	        DigestPatterns {
364	            // Boundary markers (the prompt sigils an adapter would supply).
365	            input: r"\nUSER> ".to_string(),
366	            agent: r"\nAGENT> ".to_string(),
367	            tools: vec![
368	                r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
369	                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
370	            ],
371	            catchall: Some(r"(?P<name>[A-Z][a-zA-Z]+)\((?P<arg>[^)]*)\)".to_string()),
372	        }
373	    }
374
375	    fn parser() -> DigestParser {
376	        DigestParser::new(patterns(), DigestConfig { window_turns: 3, ..Default::default() })
377	            .expect("compile patterns")
378	    }
379
380	    // [unit->REQ-TERM-4]
381	    #[test]
382	    fn tags_input_agent_and_collapses_tool_sprints() {
383	        let mut p = parser();
384	        p.feed(b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n");
385	        let d = p.digest();
386	        assert_eq!(d.turns.len(), 1);
387	        let t = &d.turns[0];
388	        assert_eq!(t.input.as_deref(), Some("add a file"));
389	        // First entry: agent text; second: a single collapsed sprint of 2 tools.
390	        assert_eq!(t.entries[0], DigestEntry::Agent("sure, doing it".to_string()));
391	        match &t.entries[1] {
392	            DigestEntry::ToolSprint(s) => {
393	                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
394	                assert_eq!(s[0], ToolUse { name: "Write".into(), arg: "src/a.rs".into() });
395	                assert_eq!(s[1], ToolUse { name: "Bash".into(), arg: "cargo build".into() });
396	            }
397	            other => panic!("expected a tool sprint, got {other:?}"),
398	        }
399	    }
400
401	    // [unit->REQ-TERM-4]
402	    #[test]
403	    fn windows_to_last_n_turns() {
404	        let mut p = parser();
405	        for i in 0..6 {
406	            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());
407	        }
408	        let d = p.digest();
409	        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
410	        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
411	        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
412	    }
413
414	    // [unit->REQ-TERM-4]
415	    #[test]
416	    fn truncates_tool_args_to_width() {
417	        let cfg = DigestConfig { window_turns: 3, arg_truncation: 10, ..Default::default() };
418	        let mut p = DigestParser::new(patterns(), cfg).unwrap();
419	        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");
420	        let d = p.digest();
421	        let sprint = d.turns[0]
422	            .entries
423	            .iter()
424	            .find_map(|e| match e {
425	                DigestEntry::ToolSprint(s) => Some(s),
426	                _ => None,
427	            })
428	            .expect("a sprint");
429	        assert_eq!(sprint[0].name, "Bash");
430	        assert_eq!(sprint[0].arg, "this-is-a-…", "arg truncated to width with ellipsis");
431	    }
432
433	    // [unit->REQ-TERM-4]
434	    #[test]
435	    fn strips_ansi_and_tolerates_repaint_duplication() {
436	        let mut p = parser();
437	        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then
438	        // the real latest turn. ANSI is stripped; the tail (last N) wins.
439	        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");
440	        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");
441	        let d = p.digest();
442	        // No ANSI leaked into content.
443	        for t in &d.turns {
444	            if let Some(i) = &t.input {
445	                assert!(!i.contains('\x1b'), "ANSI stripped from input");
446	            }
447	            for e in &t.entries {
448	                if let DigestEntry::Agent(a) = e {
449	                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");
450	                    assert!(!a.contains("[2J"), "no CSI residue");
451	                }
452	            }
453	        }
454	        // The most recent turn is present and correct despite the repaint.
455	        let last = d.turns.last().unwrap();
456	        assert_eq!(last.input.as_deref(), Some("second"));
457	        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));
458	    }
459
460	    // [unit->REQ-TERM-4]
461	    #[test]
462	    fn bad_pattern_is_reported_by_name() {
463	        let mut pats = patterns();
464	        pats.agent = r"(unclosed".to_string();
465	        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
466	        match err {
467	            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),
468	        }
469	    }
470	}
471

//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).
//!
//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user
//! turns plus the agent output between them, with tool-usage sprints collapsed
//! to a single entry. Distinct from raw scrollback (bytes) and transcript
//! history (the full conversation); its job is *"what is this agent doing right
//! now, at a glance."*
//!
//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)
//!
//! **The adapter supplies the patterns; this primitive only runs them.** Exactly
//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,
//! adapter owns the pattern" principle (identical to opaque command templates).
//! The seam is three adapter-supplied pattern kinds:
//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —
//!   each *opens a span capturing everything, newlines included, until the next
//!   pattern match of any kind*.
//! - **`tools`** — a list (one regex per known tool) **plus an optional
//!   `catchall`** for unknowable user-installed tools — each extracting a tool
//!   `name` and an `arg` fragment via named capture groups.
//!
//! Window depth `N` and the tool-arg truncation width are spt-core-controlled
//! *presentation*; the template controls *extraction*.
//!
//! ## Scope (M3a is the primitive ONLY)
//!
//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4
//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need
//! the broker + manifest + history store this crate deliberately does not build.
//!
//! ## OS-neutrality (Spike #4 / #5)
//!
//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The
//! parser therefore re-derives the digest from a bounded rolling text buffer and
//! reports the **tail** (last `N` turns): a repaint that resends a turn just
//! makes the latest copy win. ANSI control sequences (cursor moves, clears,
//! repaint machinery) are stripped before matching so the adapter patterns see
//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.

use regex::Regex;

/// A failure compiling an adapter-supplied pattern.
#[derive(Debug)]
pub enum DigestError {
    /// A pattern did not compile as a regex. Names which one.
    BadPattern { which: String, error: String },
}

impl std::fmt::Display for DigestError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DigestError::BadPattern { which, error } => {
                write!(f, "digest pattern {which} did not compile: {error}")
            }
        }
    }
}

impl std::error::Error for DigestError {}

/// The adapter-supplied patterns (regex source strings). `input` / `agent` are
/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.
#[derive(Debug, Clone)]
pub struct DigestPatterns {
    /// User-input boundary marker.
    pub input: String,
    /// Agent-output boundary marker.
    pub agent: String,
    /// One regex per known tool; each should name an `arg` group (and may name a
    /// `name` group — else the tool's matched text leads).
    pub tools: Vec<String>,
    /// Optional final catchall for user-installed tools.
    pub catchall: Option<String>,
}

/// Presentation knobs owned by spt-core (not the template).
#[derive(Debug, Clone, Copy)]
pub struct DigestConfig {
    /// Window depth — keep the last `window_turns` user turns (~3).
    pub window_turns: usize,
    /// Tool-arg truncation width in characters (~25).
    pub arg_truncation: usize,
    /// Strip ANSI control sequences before matching (default on — see module docs).
    pub strip_ansi: bool,
    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the
    /// front so memory stays bounded under an endless stream.
    pub max_scan_bytes: usize,
}

impl Default for DigestConfig {
    fn default() -> Self {
        Self { window_turns: 3, arg_truncation: 25, strip_ansi: true, max_scan_bytes: 64 * 1024 }
    }
}

/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolUse {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The (truncated) argument fragment.
    pub arg: String,
}

/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestEntry {
    /// Agent output text (between boundaries).
    Agent(String),
    /// A collapsed run of consecutive tool invocations (one "sprint").
    ToolSprint(Vec<ToolUse>),
}

/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Turn {
    /// The user input text that opened this turn.
    pub input: Option<String>,
    /// Agent + tool-sprint entries that followed, in stream order.
    pub entries: Vec<DigestEntry>,
}

/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Digest {
    /// Turns oldest-to-newest, at most `window_turns` of them.
    pub turns: Vec<Turn>,
}

/// One located pattern hit while scanning the buffer.
enum Marker {
    Input { content: String },
    Agent { content: String },
    Tool(ToolUse),
}

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
#[derive(Debug)]
pub struct DigestParser {
    input_re: Regex,
    agent_re: Regex,
    tool_res: Vec<Regex>,
    catchall_re: Option<Regex>,
    ansi_re: Option<Regex>,
    config: DigestConfig,
    /// Rolling, ANSI-stripped text buffer.
    buf: String,
}

impl DigestParser {
    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]
    /// naming the first pattern that fails to compile.
    //
    // [impl->REQ-TERM-4]
    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {
        let compile = |src: &str, which: &str| {
            Regex::new(src).map_err(|e| DigestError::BadPattern {
                which: which.to_string(),
                error: e.to_string(),
            })
        };
        let input_re = compile(&patterns.input, "input")?;
        let agent_re = compile(&patterns.agent, "agent")?;
        let mut tool_res = Vec::with_capacity(patterns.tools.len());
        for (i, t) in patterns.tools.iter().enumerate() {
            tool_res.push(compile(t, &format!("tools[{i}]"))?);
        }
        let catchall_re = match &patterns.catchall {
            Some(c) => Some(compile(c, "catchall")?),
            None => None,
        };
        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.
        let ansi_re = if config.strip_ansi {
            Some(
                Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]")
                    .expect("static ANSI regex compiles"),
            )
        } else {
            None
        };
        Ok(Self {
            input_re,
            agent_re,
            tool_res,
            catchall_re,
            ansi_re,
            config,
            buf: String::new(),
        })
    }

    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped
    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to
    /// the rolling cap.
    //
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) {
        let text = String::from_utf8_lossy(bytes);
        let cleaned = match &self.ansi_re {
            Some(re) => re.replace_all(&text, "").into_owned(),
            None => text.into_owned(),
        };
        // Normalize carriage returns left by ConPTY repaint so boundary patterns
        // and span content match on line-oriented text.
        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");
        self.buf.push_str(&cleaned);

        if self.buf.len() > self.config.max_scan_bytes {
            // Drop from the front, on a char boundary.
            let cut = self.buf.len() - self.config.max_scan_bytes;
            let mut idx = cut;
            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {
                idx += 1;
            }
            self.buf.drain(..idx);
        }
    }

    /// Re-derive the structured digest from the current buffer: scan all pattern
    /// hits in order, fold boundary spans + collapsed tool sprints into turns,
    /// and return the last `window_turns`.
    //
    // [impl->REQ-TERM-4]
    pub fn digest(&self) -> Digest {
        let markers = self.scan_markers();
        let mut turns: Vec<Turn> = Vec::new();
        let mut current = Turn::default();
        let mut current_started = false;

        for m in markers {
            match m {
                Marker::Input { content } => {
                    if current_started || !current.entries.is_empty() || current.input.is_some() {
                        turns.push(std::mem::take(&mut current));
                    }
                    current = Turn { input: Some(content), entries: Vec::new() };
                    current_started = true;
                }
                Marker::Agent { content } => {
                    if !content.is_empty() {
                        current.entries.push(DigestEntry::Agent(content));
                        current_started = true;
                    }
                }
                Marker::Tool(tu) => {
                    // Collapse into the trailing sprint if the last entry is one.
                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
                        sprint.push(tu);
                    } else {
                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));
                    }
                    current_started = true;
                }
            }
        }
        if current_started || !current.entries.is_empty() || current.input.is_some() {
            turns.push(current);
        }

        // Keep only the last N turns.
        let n = self.config.window_turns;
        if turns.len() > n {
            turns.drain(..turns.len() - n);
        }
        Digest { turns }
    }

    /// Locate every pattern hit in the buffer, sorted by position, and resolve
    /// boundary span content (text from a boundary to the next hit of any kind).
    fn scan_markers(&self) -> Vec<Marker> {
        // (start, end, kind) where kind carries the extracted payload.
        enum Kind {
            Input,
            Agent,
            Tool(ToolUse),
        }
        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();

        for m in self.input_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Input));
        }
        for m in self.agent_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Agent));
        }
        let mut push_tool = |re: &Regex| {
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        // No name group: lead with the matched text's first token.
                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
            }
        };
        for re in &self.tool_res {
            push_tool(re);
        }
        if let Some(re) = &self.catchall_re {
            // Only count a catchall hit where no specific tool already matched.
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                if hits.iter().any(|(s, e, k)| {
                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e
                }) {
                    continue;
                }
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
            }
        }

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };
        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));

        // Resolve boundary span content = text from this hit's end to the next
        // hit's start.
        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();
        let mut out = Vec::with_capacity(hits.len());
        for (i, (_, end, kind)) in hits.iter().enumerate() {
            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());
            let span = self.buf.get(*end..next_start).unwrap_or("").trim().to_string();
            match kind {
                Kind::Input => out.push(Marker::Input { content: span }),
                Kind::Agent => out.push(Marker::Agent { content: span }),
                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),
            }
        }
        out
    }
}

/// Truncate `arg` to `width` characters, appending `…` when shortened.
fn truncate_arg(arg: &str, width: usize) -> String {
    if arg.chars().count() <= width {
        return arg.to_string();
    }
    let mut s: String = arg.chars().take(width).collect();
    s.push('…');
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    fn patterns() -> DigestPatterns {
        DigestPatterns {
            // Boundary markers (the prompt sigils an adapter would supply).
            input: r"\nUSER> ".to_string(),
            agent: r"\nAGENT> ".to_string(),
            tools: vec![
                r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
            ],
            catchall: Some(r"(?P<name>[A-Z][a-zA-Z]+)\((?P<arg>[^)]*)\)".to_string()),
        }
    }

    fn parser() -> DigestParser {
        DigestParser::new(patterns(), DigestConfig { window_turns: 3, ..Default::default() })
            .expect("compile patterns")
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn tags_input_agent_and_collapses_tool_sprints() {
        let mut p = parser();
        p.feed(b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n");
        let d = p.digest();
        assert_eq!(d.turns.len(), 1);
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("add a file"));
        // First entry: agent text; second: a single collapsed sprint of 2 tools.
        assert_eq!(t.entries[0], DigestEntry::Agent("sure, doing it".to_string()));
        match &t.entries[1] {
            DigestEntry::ToolSprint(s) => {
                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
                assert_eq!(s[0], ToolUse { name: "Write".into(), arg: "src/a.rs".into() });
                assert_eq!(s[1], ToolUse { name: "Bash".into(), arg: "cargo build".into() });
            }
            other => panic!("expected a tool sprint, got {other:?}"),
        }
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn windows_to_last_n_turns() {
        let mut p = parser();
        for i in 0..6 {
            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());
        }
        let d = p.digest();
        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn truncates_tool_args_to_width() {
        let cfg = DigestConfig { window_turns: 3, arg_truncation: 10, ..Default::default() };
        let mut p = DigestParser::new(patterns(), cfg).unwrap();
        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");
        let d = p.digest();
        let sprint = d.turns[0]
            .entries
            .iter()
            .find_map(|e| match e {
                DigestEntry::ToolSprint(s) => Some(s),
                _ => None,
            })
            .expect("a sprint");
        assert_eq!(sprint[0].name, "Bash");
        assert_eq!(sprint[0].arg, "this-is-a-…", "arg truncated to width with ellipsis");
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn strips_ansi_and_tolerates_repaint_duplication() {
        let mut p = parser();
        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then
        // the real latest turn. ANSI is stripped; the tail (last N) wins.
        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");
        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");
        let d = p.digest();
        // No ANSI leaked into content.
        for t in &d.turns {
            if let Some(i) = &t.input {
                assert!(!i.contains('\x1b'), "ANSI stripped from input");
            }
            for e in &t.entries {
                if let DigestEntry::Agent(a) = e {
                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");
                    assert!(!a.contains("[2J"), "no CSI residue");
                }
            }
        }
        // The most recent turn is present and correct despite the repaint.
        let last = d.turns.last().unwrap();
        assert_eq!(last.input.as_deref(), Some("second"));
        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn bad_pattern_is_reported_by_name() {
        let mut pats = patterns();
        pats.agent = r"(unclosed".to_string();
        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
        match err {
            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),
        }
    }
}


NOTE fROM THE USER: proceed with next steps.


DigestParser primitive ready (M3a). Now read daemon internals — broker, brain (OutputEvent), msg/frame for IPC, R-TERM-3 substrate.


<owl_messages>
<owl_message from="doyle-psyche" priority="normal"><EVENT type="echo_commune" from="doyle-psyche" timestamp="2026-06-03T02:18:55-07:00" machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="d06af5dc1f22276c3dd0458b5fe39d8291057e3d" head_subject="feat(daemon): B7 — orphan-watch supervised-crash signoff (REQ-DAEMON-4)" note="Echo commune brief — auto-fired">&lt;live-context&gt;
B7 done (d06af5d). REQ-DAEMON-4 at [&quot;impl&quot;,&quot;unit&quot;]. orphan-watch: crash_grace knob + signoff_with factoring + harness_owner_gone (parent_pid anchor) + run_orphan_watch. Gate green.

Next: B8 — digest daemon-half. manifest pty_digest seam → DigestParser over broker PTY → `spt digest &lt;id&gt;` snapshot + delta-stream on R-TERM-3 substrate. REQ-TERM-4 gets `int`. Local addressing only (no [subnet:]id@node — M4). Orient: M3b-PLAN B8 row + ADR-0008 + brain.rs OutputEvent. Don't regress spawn_detached or gapless-handoff.
&lt;/live-context&gt;
&lt;project-context&gt;
**B6 DONE + verified + committed (c1c4f64, dev-freeform, tree clean).** REQ-DAEMON-3 (auto-start) + REQ-START-3 (in-memory seed, file bridge retired) both at [&quot;impl&quot;,&quot;unit&quot;,&quot;int&quot;]. Gate green: traceable-reqs 120/0 findings · clippy -D warnings · cargo test --workspace 0 failures · contract_e2e 4/4 incl. a REAL cold-start daemon E2E.

**What B6 built:** the per-machine daemon process stood up for the first time. `spt-daemon`: `endpoint.rs` (per-home socket names from SPT_HOME hash, shared by the auto-start spawn), `seedmap.rs` (in-memory `SeedRegistry` + put/take/ping seed-control IPC, thread-per-conn), `daemon.rs` (`Daemon::run` binds broker + seed-control; `is_running`/`ensure_running`). `spt`: hidden `spt daemon` subcommand; `ensure_daemon()` atop `api::run`; `cmd_seed` PUTs to daemon (no file); `bind_from_seed` TAKEs (consume-once). `spt-store::seed` reduced to the `Seed` data contract only — file I/O gone. **Broker/B2 (gapless-handoff) code untouched** — B6 added an isolated seed-control surface, deliberately low-risk.

**Remaining M3b:** B7 (orphan-watch) · B8 (digest daemon-half) · B9 (activation sweep + E2E). **Next session = B7.**

**B7 immediate next steps (orphan-watch):** carry the M2b graceful ordering (grace 1.1 / echo-before-signoff 3.3 / stale-sweep 3.2) into the **supervised-crash** path the always-on daemon now enables (M2b delivered graceful-only). Orient: M3b-PLAN B7 row; the M2b seams in spt-live (`graceful_signoff`, `sweep_stale_signoff`, `run_echo_commune`) already pure; `lifecycle.rs::graceful_signoff` (interim zero-grace path — B7 supplies the real grace/liveness recheck). REQ-DAEMON-4 (partial). Acceptance: a supervised crash of a hosted session triggers the same echo-before-signoff + grace + stale-sweep ordering as graceful teardown (regression test).

**Watch items for B7+:** the new daemon process model means tests that exec `spt api …` now auto-spawn a real daemon unless an in-process one is pre-started (see `start_inproc_daemon` in contract_e2e). The Windows handle-inheritance guard in `spawn_detached` is load-bearing for any pipe-capturing caller — don't regress it.

**End goal (M3b):** replace the interim no-daemon model with ADR-0004's broker/brain `spt-daemon` behind a versioned local IPC, so a brain self-update swaps with zero endpoint interruption. **End goal (project):** harness-independent core for an agent ecosystem (messaging, live-agent lifecycle, terminal hosting, P2P networking, runtime-manifest harness contract) — clean-room rebuild of `claude_skill_owl`.
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>


1	//! The brain (B2) — the restartable logic half that attaches to the broker.
2	//!
3	//! The brain holds all the volatile logic; it may be killed and restarted at
4	//! any moment (a routine self-update swaps it; a crash kills it). It connects to
5	//! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
6	//! (spawn / input / resize / kill), and consumes their output. Two invariants
7	//! this type encodes:
8	//!
9	//! - **`gen_start = now()` on cold-start *and* handoff (KNOWN-HAZARDS 2.4).**
10	//!   The wall-clock generation stamp is taken fresh on every start. A handed-off
11	//!   brain rehydrates its resume cursor and generation *counter* from prior
12	//!   state, but its `gen_start` is `now()`, **never** the stale value from that
13	//!   state — a rehydrated `gen_start` would fire time-based discriminators on
14	//!   the new generation. [`Brain::handoff`] takes the prior state by value yet
15	//!   ignores its `gen_start_ms`.
16	//! - **Gapless, exactly-once resume.** The brain tracks `next_seq` — the next
17	//!   output sequence it still needs. On handoff it `subscribe`s from that cursor;
18	//!   the broker replays buffered output from there. [`Brain::read_event`] accepts
19	//!   only the contiguous next `seq` (advancing the cursor), silently drops an
20	//!   already-seen `seq` (a replay duplicate), and treats a forward gap as a hard
21	//!   error — so a lost chunk can never pass silently.
22	// [impl->REQ-HAZARD-GEN-START-NOW]
23	// [impl->REQ-DAEMON-2]
24
25	use std::io;
26	use std::time::{SystemTime, UNIX_EPOCH};
27
28	use interprocess::local_socket::Stream;
29
30	use crate::codec::{read_frame, write_frame};
31	use crate::frame::{Envelope, Role};
32	use crate::msg::{
33	    decode_bytes, encode_bytes, AppliedEvent, ErrorEvent, ExitEvent, InputReq, KillReq, OutputEvent,
34	    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT,
35	    KIND_KILL, KIND_OUTPUT, KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
36	};
37	use crate::transport::{send_hello, LocalSocketTransport};
38
39	/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
40	/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
41	pub fn now_ms() -> u64 {
42	    SystemTime::now()
43	        .duration_since(UNIX_EPOCH)
44	        .map(|d| d.as_millis() as u64)
45	        .unwrap_or(0)
46	}
47
48	/// The brain's durable state across a handoff: enough to re-attach gaplessly and
49	/// advance the generation. `gen_start_ms` is recorded for observability but is
50	/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
51	/// fresh `gen_start` from `now()` (2.4).
52	#[derive(Debug, Clone)]
53	pub struct BrainState {
54	    /// The session the brain was driving.
55	    pub session_id: u64,
56	    /// The generation counter (incremented on each handoff).
57	    pub generation: u64,
58	    /// The next output sequence still needed (the resume cursor).
59	    pub next_seq: u64,
60	    /// The prior generation's wall-clock start (ignored as authoritative on
61	    /// handoff — kept only to demonstrate it is *not* carried forward).
62	    pub gen_start_ms: u64,
63	}
64
65	/// A demuxed broker→brain event.
66	#[derive(Debug)]
67	pub enum BrokerEvent {
68	    /// A contiguous chunk of a session's output (the cursor has advanced past it).
69	    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
70	    /// A spawn succeeded.
71	    Spawned { session_id: u64, pid: Option<u32> },
72	    /// A session's child exited.
73	    Exit { session_id: u64, code: Option<i32> },
74	    /// An idempotent effect is durably applied (or was deduped — `applied_now`
75	    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
76	    Applied { session_id: u64, op_id: u64, applied_now: bool },
77	    /// The broker reported a command error.
78	    Error { message: String },
79	    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
80	    Other(Envelope),
81	}
82
83	/// A live brain attached to a broker.
84	pub struct Brain {
85	    conn: Stream,
86	    generation: u64,
87	    gen_start_ms: u64,
88	    session_id: Option<u64>,
89	    /// The next output sequence still needed (resume cursor / dedup floor).
90	    next_seq: u64,
91	}
92
93	impl Brain {
94	    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
95	    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
96	        let mut conn = LocalSocketTransport::connect(name)?;
97	        send_hello(&mut conn, Role::Brain)?;
98	        Ok(Brain { conn, generation: 0, gen_start_ms: now_ms, session_id: None, next_seq: 0 })
99	    }
100
101	    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
102	    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
103	    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
104	    /// gapless resume.
105	    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
106	        let mut conn = LocalSocketTransport::connect(name)?;
107	        send_hello(&mut conn, Role::Brain)?;
108	        let mut brain = Brain {
109	            conn,
110	            generation: prior.generation + 1,
111	            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
112	            session_id: Some(prior.session_id),
113	            next_seq: prior.next_seq,
114	        };
115	        brain.subscribe(prior.session_id, prior.next_seq)?;
116	        Ok(brain)
117	    }
118
119	    /// The wall-clock generation start (epoch ms) — always this generation's own
120	    /// start time, never a rehydrated value.
121	    pub fn gen_start_ms(&self) -> u64 {
122	        self.gen_start_ms
123	    }
124
125	    /// The generation counter (0 on cold start, +1 per handoff).
126	    pub fn generation(&self) -> u64 {
127	        self.generation
128	    }
129
130	    /// The next output sequence still needed (the resume cursor).
131	    pub fn next_seq(&self) -> u64 {
132	        self.next_seq
133	    }
134
135	    /// The session id the brain is driving, if any.
136	    pub fn session_id(&self) -> Option<u64> {
137	        self.session_id
138	    }
139
140	    /// Snapshot durable state for a future handoff.
141	    pub fn snapshot(&self) -> BrainState {
142	        BrainState {
143	            session_id: self.session_id.expect("snapshot before a session exists"),
144	            generation: self.generation,
145	            next_seq: self.next_seq,
146	            gen_start_ms: self.gen_start_ms,
147	        }
148	    }
149
150	    /// Spawn a session under a broker PTY; the broker auto-subscribes this
151	    /// connection. Reads until the `spawned` reply (consuming any output that
152	    /// races ahead).
153	    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
154	        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
155	        loop {
156	            match self.read_event()? {
157	                BrokerEvent::Spawned { session_id, .. } => {
158	                    self.session_id = Some(session_id);
159	                    return Ok(session_id);
160	                }
161	                BrokerEvent::Error { message } => {
162	                    return Err(io::Error::other(message));
163	                }
164	                _ => continue,
165	            }
166	        }
167	    }
168
169	    /// Inject input bytes into the current session (send-keys), the **legacy
170	    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
171	    /// of an effect that originates from a durable source (a spool row, a
172	    /// sequenced injection), use [`Brain::send_effect`].
173	    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
174	        let sid = self.require_session()?;
175	        self.send(
176	            KIND_INPUT,
177	            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes), op_id: None })
178	                .expect("InputReq serializes"),
179	        )
180	    }
181
182	    /// Inject input **idempotently**: the write carries the durable `op_id` so a
183	    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
184	    /// `op_id` must come from a durable source and be reused verbatim on replay —
185	    /// the same logical effect must always carry the same id, so the broker's
186	    /// applied-set recognizes the re-drive. The broker answers with a
187	    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
188	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
189	    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
190	        let sid = self.require_session()?;
191	        self.send(
192	            KIND_INPUT,
193	            serde_json::to_value(InputReq {
194	                session_id: sid,
195	                data_b64: encode_bytes(bytes),
196	                op_id: Some(op_id),
197	            })
198	            .expect("InputReq serializes"),
199	        )
200	    }
201
202	    /// Resize the current session's PTY.
203	    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
204	        let sid = self.require_session()?;
205	        self.send(
206	            KIND_RESIZE,
207	            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
208	                .expect("ResizeReq serializes"),
209	        )
210	    }
211
212	    /// Terminate the current session's child.
213	    pub fn kill_session(&mut self) -> io::Result<()> {
214	        let sid = self.require_session()?;
215	        self.send(
216	            KIND_KILL,
217	            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
218	        )
219	    }
220
221	    /// Read output events until the accumulated bytes contain `needle`,
222	    /// appending all received output to `buf`. Returns the highest seq seen.
223	    /// A session `Exit` before the needle is found is an error (the child died).
224	    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
225	        loop {
226	            if contains(buf, needle) {
227	                return Ok(self.next_seq.saturating_sub(1));
228	            }
229	            match self.read_event()? {
230	                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
231	                BrokerEvent::Exit { .. } => {
232	                    return Err(io::Error::new(
233	                        io::ErrorKind::UnexpectedEof,
234	                        "session exited before the expected output arrived",
235	                    ));
236	                }
237	                BrokerEvent::Error { message } => {
238	                    return Err(io::Error::other(message));
239	                }
240	                _ => {}
241	            }
242	        }
243	    }
244
245	    /// Read and demux one broker event, enforcing exactly-once contiguous output:
246	    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
247	    /// (replay duplicate), and reject a forward gap (lost chunk).
248	    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
249	        loop {
250	            let env = read_frame(&mut self.conn)?;
251	            match env.kind.as_str() {
252	                KIND_OUTPUT => {
253	                    let ev: OutputEvent = serde_json::from_value(env.payload)
254	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
255	                    if ev.seq < self.next_seq {
256	                        continue; // already processed — a replay duplicate; dedup
257	                    }
258	                    if ev.seq > self.next_seq {
259	                        return Err(io::Error::new(
260	                            io::ErrorKind::InvalidData,
261	                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
262	                        ));
263	                    }
264	                    self.next_seq = ev.seq + 1;
265	                    let bytes = decode_bytes(&ev.data_b64)
266	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
267	                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
268	                }
269	                KIND_SPAWNED => {
270	                    let ev: Spawned = serde_json::from_value(env.payload)
271	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
272	                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
273	                }
274	                KIND_EXIT => {
275	                    let ev: ExitEvent = serde_json::from_value(env.payload)
276	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
277	                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
278	                }
279	                KIND_APPLIED => {
280	                    let ev: AppliedEvent = serde_json::from_value(env.payload)
281	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
282	                    return Ok(BrokerEvent::Applied {
283	                        session_id: ev.session_id,
284	                        op_id: ev.op_id,
285	                        applied_now: ev.applied_now,
286	                    });
287	                }
288	                KIND_ERROR => {
289	                    let ev: ErrorEvent = serde_json::from_value(env.payload)
290	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
291	                    return Ok(BrokerEvent::Error { message: ev.message });
292	                }
293	                _ => return Ok(BrokerEvent::Other(env)),
294	            }
295	        }
296	    }
297
298	    /// Re-attach to a session's output from `from_seq` (handoff resume).
299	    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
300	        self.send(
301	            KIND_SUBSCRIBE,
302	            serde_json::to_value(SubscribeReq { session_id, from_seq })
303	                .expect("SubscribeReq serializes"),
304	        )
305	    }
306
307	    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
308	        write_frame(&mut self.conn, &Envelope::new(kind, payload))
309	    }
310
311	    fn require_session(&self) -> io::Result<u64> {
312	        self.session_id
313	            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
314	    }
315	}
316
317	/// Whether `haystack` contains the contiguous `needle`.
318	fn contains(haystack: &[u8], needle: &[u8]) -> bool {
319	    if needle.is_empty() {
320	        return true;
321	    }
322	    haystack.windows(needle.len()).any(|w| w == needle)
323	}
324

//! The brain (B2) — the restartable logic half that attaches to the broker.
//!
//! The brain holds all the volatile logic; it may be killed and restarted at
//! any moment (a routine self-update swaps it; a crash kills it). It connects to
//! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
//! (spawn / input / resize / kill), and consumes their output. Two invariants
//! this type encodes:
//!
//! - **`gen_start = now()` on cold-start *and* handoff (KNOWN-HAZARDS 2.4).**
//!   The wall-clock generation stamp is taken fresh on every start. A handed-off
//!   brain rehydrates its resume cursor and generation *counter* from prior
//!   state, but its `gen_start` is `now()`, **never** the stale value from that
//!   state — a rehydrated `gen_start` would fire time-based discriminators on
//!   the new generation. [`Brain::handoff`] takes the prior state by value yet
//!   ignores its `gen_start_ms`.
//! - **Gapless, exactly-once resume.** The brain tracks `next_seq` — the next
//!   output sequence it still needs. On handoff it `subscribe`s from that cursor;
//!   the broker replays buffered output from there. [`Brain::read_event`] accepts
//!   only the contiguous next `seq` (advancing the cursor), silently drops an
//!   already-seen `seq` (a replay duplicate), and treats a forward gap as a hard
//!   error — so a lost chunk can never pass silently.
// [impl->REQ-HAZARD-GEN-START-NOW]
// [impl->REQ-DAEMON-2]

use std::io;
use std::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};
use crate::msg::{
    decode_bytes, encode_bytes, AppliedEvent, ErrorEvent, ExitEvent, InputReq, KillReq, OutputEvent,
    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT,
    KIND_KILL, KIND_OUTPUT, KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::transport::{send_hello, LocalSocketTransport};

/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
pub fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// The brain's durable state across a handoff: enough to re-attach gaplessly and
/// advance the generation. `gen_start_ms` is recorded for observability but is
/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
/// fresh `gen_start` from `now()` (2.4).
#[derive(Debug, Clone)]
pub struct BrainState {
    /// The session the brain was driving.
    pub session_id: u64,
    /// The generation counter (incremented on each handoff).
    pub generation: u64,
    /// The next output sequence still needed (the resume cursor).
    pub next_seq: u64,
    /// The prior generation's wall-clock start (ignored as authoritative on
    /// handoff — kept only to demonstrate it is *not* carried forward).
    pub gen_start_ms: u64,
}

/// A demuxed broker→brain event.
#[derive(Debug)]
pub enum BrokerEvent {
    /// A contiguous chunk of a session's output (the cursor has advanced past it).
    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
    /// A spawn succeeded.
    Spawned { session_id: u64, pid: Option<u32> },
    /// A session's child exited.
    Exit { session_id: u64, code: Option<i32> },
    /// An idempotent effect is durably applied (or was deduped — `applied_now`
    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
    Applied { session_id: u64, op_id: u64, applied_now: bool },
    /// The broker reported a command error.
    Error { message: String },
    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
    Other(Envelope),
}

/// A live brain attached to a broker.
pub struct Brain {
    conn: Stream,
    generation: u64,
    gen_start_ms: u64,
    session_id: Option<u64>,
    /// The next output sequence still needed (resume cursor / dedup floor).
    next_seq: u64,
}

impl Brain {
    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        Ok(Brain { conn, generation: 0, gen_start_ms: now_ms, session_id: None, next_seq: 0 })
    }

    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
    /// gapless resume.
    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        let mut brain = Brain {
            conn,
            generation: prior.generation + 1,
            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
            session_id: Some(prior.session_id),
            next_seq: prior.next_seq,
        };
        brain.subscribe(prior.session_id, prior.next_seq)?;
        Ok(brain)
    }

    /// The wall-clock generation start (epoch ms) — always this generation's own
    /// start time, never a rehydrated value.
    pub fn gen_start_ms(&self) -> u64 {
        self.gen_start_ms
    }

    /// The generation counter (0 on cold start, +1 per handoff).
    pub fn generation(&self) -> u64 {
        self.generation
    }

    /// The next output sequence still needed (the resume cursor).
    pub fn next_seq(&self) -> u64 {
        self.next_seq
    }

    /// The session id the brain is driving, if any.
    pub fn session_id(&self) -> Option<u64> {
        self.session_id
    }

    /// Snapshot durable state for a future handoff.
    pub fn snapshot(&self) -> BrainState {
        BrainState {
            session_id: self.session_id.expect("snapshot before a session exists"),
            generation: self.generation,
            next_seq: self.next_seq,
            gen_start_ms: self.gen_start_ms,
        }
    }

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, .. } => {
                    self.session_id = Some(session_id);
                    return Ok(session_id);
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Inject input bytes into the current session (send-keys), the **legacy
    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
    /// of an effect that originates from a durable source (a spool row, a
    /// sequenced injection), use [`Brain::send_effect`].
    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes), op_id: None })
                .expect("InputReq serializes"),
        )
    }

    /// Inject input **idempotently**: the write carries the durable `op_id` so a
    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
    /// `op_id` must come from a durable source and be reused verbatim on replay —
    /// the same logical effect must always carry the same id, so the broker's
    /// applied-set recognizes the re-drive. The broker answers with a
    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq {
                session_id: sid,
                data_b64: encode_bytes(bytes),
                op_id: Some(op_id),
            })
            .expect("InputReq serializes"),
        )
    }

    /// Resize the current session's PTY.
    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_RESIZE,
            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
                .expect("ResizeReq serializes"),
        )
    }

    /// Terminate the current session's child.
    pub fn kill_session(&mut self) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_KILL,
            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
        )
    }

    /// Read output events until the accumulated bytes contain `needle`,
    /// appending all received output to `buf`. Returns the highest seq seen.
    /// A session `Exit` before the needle is found is an error (the child died).
    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
        loop {
            if contains(buf, needle) {
                return Ok(self.next_seq.saturating_sub(1));
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
                BrokerEvent::Exit { .. } => {
                    return Err(io::Error::new(
                        io::ErrorKind::UnexpectedEof,
                        "session exited before the expected output arrived",
                    ));
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => {}
            }
        }
    }

    /// Read and demux one broker event, enforcing exactly-once contiguous output:
    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
    /// (replay duplicate), and reject a forward gap (lost chunk).
    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
        loop {
            let env = read_frame(&mut self.conn)?;
            match env.kind.as_str() {
                KIND_OUTPUT => {
                    let ev: OutputEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    if ev.seq < self.next_seq {
                        continue; // already processed — a replay duplicate; dedup
                    }
                    if ev.seq > self.next_seq {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
                        ));
                    }
                    self.next_seq = ev.seq + 1;
                    let bytes = decode_bytes(&ev.data_b64)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
                }
                KIND_SPAWNED => {
                    let ev: Spawned = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
                }
                KIND_EXIT => {
                    let ev: ExitEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
                }
                KIND_APPLIED => {
                    let ev: AppliedEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Applied {
                        session_id: ev.session_id,
                        op_id: ev.op_id,
                        applied_now: ev.applied_now,
                    });
                }
                KIND_ERROR => {
                    let ev: ErrorEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Error { message: ev.message });
                }
                _ => return Ok(BrokerEvent::Other(env)),
            }
        }
    }

    /// Re-attach to a session's output from `from_seq` (handoff resume).
    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
        self.send(
            KIND_SUBSCRIBE,
            serde_json::to_value(SubscribeReq { session_id, from_seq })
                .expect("SubscribeReq serializes"),
        )
    }

    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
        write_frame(&mut self.conn, &Envelope::new(kind, payload))
    }

    fn require_session(&self) -> io::Result<u64> {
        self.session_id
            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
    }
}

/// Whether `haystack` contains the contiguous `needle`.
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
    if needle.is_empty() {
        return true;
    }
    haystack.windows(needle.len()).any(|w| w == needle)
}


Echo commune = FYI. Keep reading. Need broker.rs, msg.rs (IPC frames), daemon.rs, stream.rs (R-TERM-3), config.rs, manifest seam.