Skip to main content

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