agentmux_srv\backend/
osc_extractor.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Stateful byte-stream OSC sequence extractor.
5//!
6//! Parses OSC 0 and OSC 2 sequences from PTY output and normalises
7//! Claude Code window-title payloads into bare conversation-topic strings.
8//! Used by the agent-pane PTY read loop to surface the session topic as
9//! `term:activity` block metadata without leaking raw escape bytes into
10//! the FileStore.
11//!
12//! Design notes:
13//! - State machine buffers across PTY read() calls so sequences split
14//!   at chunk boundaries are assembled correctly.
15//! - Buffer is capped at MAX_PAYLOAD_BYTES; overflow discards the
16//!   partial sequence and resets state (no unbounded memory growth).
17//! - Non-UTF-8 bytes are replaced with U+FFFD via from_utf8_lossy.
18//! - Terminal panes use xterm.js to handle OSC natively; this extractor
19//!   is applied ONLY to agent-pane PTY streams.
20
21const MAX_PAYLOAD_BYTES: usize = 4096;
22
23#[derive(Debug, Clone, PartialEq)]
24enum State {
25    Idle,
26    AfterEsc,
27    InOsc,
28    InOscAfterEsc,
29}
30
31/// A complete, normalised OSC title event.
32pub struct OscEvent {
33    /// OSC parameter number (0 or 2).
34    pub ps: u16,
35    /// Payload with Claude Code prefixes stripped and bare startup titles discarded.
36    pub payload: String,
37}
38
39/// Stateful OSC extractor — create one instance per PTY stream and call
40/// `feed()` for each chunk.
41pub struct OscExtractor {
42    state: State,
43    payload_buf: Vec<u8>,
44}
45
46impl OscExtractor {
47    pub fn new() -> Self {
48        OscExtractor {
49            state: State::Idle,
50            payload_buf: Vec::new(),
51        }
52    }
53
54    /// Process one PTY chunk.
55    ///
56    /// Returns `(cleaned_bytes, events)`:
57    /// - `cleaned_bytes`: the input with all OSC sequences removed; write
58    ///   this to FileStore in place of the raw chunk.
59    /// - `events`: any complete OSC 0/2 title events found (payloads already
60    ///   normalised; empty payload strings are never emitted).
61    pub fn feed(&mut self, chunk: &[u8]) -> (Vec<u8>, Vec<OscEvent>) {
62        let mut out = Vec::with_capacity(chunk.len());
63        let mut events = Vec::new();
64
65        let mut i = 0;
66        while i < chunk.len() {
67            let byte = chunk[i];
68            i += 1;
69
70            match self.state {
71                State::Idle => {
72                    if byte == 0x1b {
73                        self.state = State::AfterEsc;
74                    } else {
75                        out.push(byte);
76                    }
77                }
78                State::AfterEsc => {
79                    if byte == 0x5d {
80                        // ESC ] — OSC start
81                        self.state = State::InOsc;
82                        self.payload_buf.clear();
83                    } else if byte == 0x1b {
84                        // Another ESC — emit previous ESC verbatim, stay in AfterEsc
85                        out.push(0x1b);
86                    } else {
87                        // Not an OSC — emit ESC + this byte verbatim
88                        out.push(0x1b);
89                        out.push(byte);
90                        self.state = State::Idle;
91                    }
92                }
93                State::InOsc => {
94                    if byte == 0x07 {
95                        // BEL terminator — sequence complete
96                        if let Some(ev) = self.complete_osc() {
97                            events.push(ev);
98                        }
99                        self.state = State::Idle;
100                    } else if byte == 0x1b {
101                        // Possible ST start (ESC \)
102                        self.state = State::InOscAfterEsc;
103                    } else if self.payload_buf.len() < MAX_PAYLOAD_BYTES {
104                        self.payload_buf.push(byte);
105                    } else {
106                        // Buffer overflow — discard partial sequence and reset
107                        self.payload_buf.clear();
108                        self.state = State::Idle;
109                        continue;
110                    }
111                }
112                State::InOscAfterEsc => {
113                    if byte == 0x5c {
114                        // ST (ESC \) — sequence complete
115                        if let Some(ev) = self.complete_osc() {
116                            events.push(ev);
117                        }
118                        self.state = State::Idle;
119                    } else {
120                        // ESC was part of payload, not ST — push it and reprocess byte
121                        if self.payload_buf.len() < MAX_PAYLOAD_BYTES {
122                            self.payload_buf.push(0x1b);
123                        } else {
124                            self.payload_buf.clear();
125                            self.state = State::Idle;
126                            continue;
127                        }
128                        self.state = State::InOsc;
129                        // Reprocess current byte in InOsc
130                        if byte == 0x07 {
131                            if let Some(ev) = self.complete_osc() {
132                                events.push(ev);
133                            }
134                            self.state = State::Idle;
135                        } else if byte == 0x1b {
136                            self.state = State::InOscAfterEsc;
137                        } else if self.payload_buf.len() < MAX_PAYLOAD_BYTES {
138                            self.payload_buf.push(byte);
139                        } else {
140                            self.payload_buf.clear();
141                            self.state = State::Idle;
142                        }
143                    }
144                }
145            }
146        }
147
148        (out, events)
149    }
150
151    fn complete_osc(&mut self) -> Option<OscEvent> {
152        let raw = std::mem::take(&mut self.payload_buf);
153        let s = String::from_utf8_lossy(&raw);
154
155        // OSC payload format: "<ps>;<data>"
156        let semicolon = s.find(';')?;
157        let ps_str = &s[..semicolon];
158        let ps: u16 = ps_str.parse().ok()?;
159
160        // Only OSC 0 and 2 carry window title
161        if ps != 0 && ps != 2 {
162            return None;
163        }
164
165        let data = &s[semicolon + 1..];
166        let payload = normalise_title(data);
167        if payload.is_empty() {
168            return None;
169        }
170
171        Some(OscEvent { ps, payload })
172    }
173}
174
175/// Strip Claude Code title prefixes; discard bare startup/idle titles.
176///
177/// Observed formats (GitHub issues #21677, #23355, #27197):
178///   "claude - auth refactor"  → "auth refactor"
179///   "Claude: editing auth.rs" → "editing auth.rs"
180///   "Claude Code: summary"    → "summary"
181///   "claude"                  → discard (startup idle title, no topic)
182///   "Claude Code"             → discard (post-launch before topic, no topic)
183fn normalise_title(s: &str) -> String {
184    let stripped = if let Some(rest) = s.strip_prefix("claude - ") {
185        rest
186    } else if let Some(rest) = s.strip_prefix("Claude - ") {
187        rest
188    } else if let Some(rest) = s.strip_prefix("Claude: ") {
189        rest
190    } else if let Some(rest) = s.strip_prefix("Claude Code: ") {
191        rest
192    } else {
193        s
194    };
195
196    let trimmed = stripped.trim();
197    if trimmed.is_empty()
198        || trimmed.eq_ignore_ascii_case("claude")
199        || trimmed.eq_ignore_ascii_case("claude code")
200    {
201        return String::new();
202    }
203
204    trimmed.to_string()
205}
206
207// ====================================================================
208// Tests
209// ====================================================================
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn feed_str(ext: &mut OscExtractor, s: &str) -> (String, Vec<String>) {
216        let (cleaned, events) = ext.feed(s.as_bytes());
217        (
218            String::from_utf8_lossy(&cleaned).into_owned(),
219            events.into_iter().map(|e| e.payload).collect(),
220        )
221    }
222
223    #[test]
224    fn bel_terminator() {
225        let mut ext = OscExtractor::new();
226        let (cleaned, evs) = feed_str(&mut ext, "\x1b]0;claude - auth refactor\x07hello");
227        assert_eq!(cleaned, "hello");
228        assert_eq!(evs, ["auth refactor"]);
229    }
230
231    #[test]
232    fn st_terminator() {
233        let mut ext = OscExtractor::new();
234        let (cleaned, evs) = feed_str(&mut ext, "\x1b]0;Claude: editing auth.rs\x1b\\hello");
235        assert_eq!(cleaned, "hello");
236        assert_eq!(evs, ["editing auth.rs"]);
237    }
238
239    #[test]
240    fn osc2_handled() {
241        let mut ext = OscExtractor::new();
242        let (_, evs) = feed_str(&mut ext, "\x1b]2;Claude Code: summary\x07");
243        assert_eq!(evs, ["summary"]);
244    }
245
246    #[test]
247    fn non_title_osc_stripped_no_event() {
248        let mut ext = OscExtractor::new();
249        let (cleaned, evs) = feed_str(&mut ext, "\x1b]7;file:///home/user\x07text");
250        assert_eq!(cleaned, "text");
251        assert!(evs.is_empty());
252    }
253
254    #[test]
255    fn bare_startup_title_discarded() {
256        for title in &["claude", "CLAUDE", "Claude Code", "claude code", "CLAUDE CODE"] {
257            let input = format!("\x1b]0;{}\x07", title);
258            let mut ext = OscExtractor::new();
259            let (_, evs) = ext.feed(input.as_bytes());
260            assert!(evs.is_empty(), "expected discard for '{title}' but got event");
261        }
262    }
263
264    #[test]
265    fn cross_chunk_split_bel() {
266        let mut ext = OscExtractor::new();
267        // Split right before BEL
268        let (_, evs1) = ext.feed(b"\x1b]0;claude - auth refactor");
269        assert!(evs1.is_empty());
270        let (cleaned, evs2) = ext.feed(b"\x07rest");
271        assert_eq!(evs2.iter().map(|e| e.payload.as_str()).collect::<Vec<_>>(), ["auth refactor"]);
272        assert_eq!(String::from_utf8_lossy(&cleaned), "rest");
273    }
274
275    #[test]
276    fn cross_chunk_split_at_every_byte() {
277        let input = b"\x1b]0;claude - topic\x07";
278        // Split at every possible byte offset
279        for split in 1..input.len() {
280            let mut ext = OscExtractor::new();
281            let (_, evs1) = ext.feed(&input[..split]);
282            let (_, evs2) = ext.feed(&input[split..]);
283            let all_evs: Vec<_> = evs1.iter().chain(evs2.iter()).map(|e| e.payload.as_str()).collect();
284            assert_eq!(all_evs, ["topic"], "split at byte {split} failed");
285        }
286    }
287
288    #[test]
289    fn buffer_overflow_guard() {
290        let mut ext = OscExtractor::new();
291        // Payload larger than MAX_PAYLOAD_BYTES — should not panic; state resets
292        let big: Vec<u8> = std::iter::once(b'\x1b')
293            .chain(std::iter::once(b']'))
294            .chain(b"0;".iter().copied())
295            .chain(vec![b'x'; MAX_PAYLOAD_BYTES + 10])
296            .chain(std::iter::once(b'\x07'))
297            .collect();
298        let (_, evs) = ext.feed(&big);
299        assert!(evs.is_empty());
300        // Extractor should work normally after overflow
301        let (_, evs2) = ext.feed(b"\x1b]0;claude - recovery\x07");
302        assert_eq!(evs2.iter().map(|e| e.payload.as_str()).collect::<Vec<_>>(), ["recovery"]);
303    }
304
305    #[test]
306    fn non_utf8_replaced() {
307        let mut ext = OscExtractor::new();
308        // "claude - " prefix + invalid UTF-8 byte 0xFF
309        let payload: Vec<u8> = b"\x1b]0;claude - topic\xff\x07".to_vec();
310        let (_, evs) = ext.feed(&payload);
311        // Should produce an event (not panic); payload contains replacement char
312        assert_eq!(evs.len(), 1);
313        assert!(evs[0].payload.contains("topic"));
314    }
315
316    #[test]
317    fn passthrough_bytes_unchanged() {
318        let mut ext = OscExtractor::new();
319        let (cleaned, _) = ext.feed(b"hello world");
320        assert_eq!(cleaned, b"hello world");
321    }
322
323    #[test]
324    fn esc_not_followed_by_bracket_passed_through() {
325        let mut ext = OscExtractor::new();
326        let (cleaned, evs) = feed_str(&mut ext, "\x1b[32mgreen\x1b[0m");
327        // CSI sequences (ESC [) should pass through unchanged
328        assert_eq!(cleaned, "\x1b[32mgreen\x1b[0m");
329        assert!(evs.is_empty());
330    }
331}