aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 19:23:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 19:23:13 +0300
commit82468a0e03360219898ebf3735bdf559182009f8 (patch)
tree0ee5213fe041a539f23e846f9148493528249512 /src/pane
parent36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff)
feat: add manifest-based detection engine
Diffstat (limited to 'src/pane')
-rw-r--r--src/pane/agent_detection.rs13
-rw-r--r--src/pane/osc.rs495
-rw-r--r--src/pane/terminal.rs62
3 files changed, 567 insertions, 3 deletions
diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs
index e6afd368..d891c284 100644
--- a/src/pane/agent_detection.rs
+++ b/src/pane/agent_detection.rs
@@ -512,11 +512,22 @@ pub(super) fn decide_screen_detection_publish(
}
}
+#[allow(dead_code)] // shim for tests; detection_update_for_publish_with_osc is the real path
pub(super) fn detection_update_for_publish(
agent: Option<Agent>,
content: &str,
process_exited: bool,
) -> Option<crate::detect::AgentDetection> {
+ detection_update_for_publish_with_osc(agent, content, "", "", process_exited)
+}
+
+pub(super) fn detection_update_for_publish_with_osc(
+ agent: Option<Agent>,
+ content: &str,
+ osc_title: &str,
+ osc_progress: &str,
+ process_exited: bool,
+) -> Option<crate::detect::AgentDetection> {
if process_exited {
return Some(crate::detect::AgentDetection {
state: AgentState::Idle,
@@ -527,7 +538,7 @@ pub(super) fn detection_update_for_publish(
});
}
- let detection = crate::detect::detect_agent(agent, content);
+ let detection = crate::detect::detect_agent_with_osc(agent, content, osc_title, osc_progress);
(!detection.skip_state_update).then_some(detection)
}
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 23ab433a..e7b97a79 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -440,6 +440,263 @@ fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> {
None
}
+/// Maximum retained string length for agent OSC title and progress payloads.
+/// Title text is untrusted model output; cap it to bound memory and log size.
+const AGENT_OSC_MAX_CHARS: usize = 256;
+
+/// Always-on tracker that retains the latest OSC 0/2 title and OSC 9 progress
+/// payload emitted by the child process. Nothing here affects rendering; this
+/// is pure passive capture for the detection engine (Stage C / Stage D).
+///
+/// - `latest_title` — last OSC 0 or OSC 2 payload, sanitized. An empty
+/// payload (e.g. `\x1b]0;\x07`) clears the stored value.
+/// - `latest_progress` — last OSC 9 payload (the part after `9;`), stored
+/// as-is after sanitization. E.g. `"4;3;"` or `"4;0;"`.
+#[derive(Debug, Default)]
+pub(super) struct AgentOscStateTracker {
+ state: Osc52ForwarderState,
+ body: Vec<u8>,
+ latest_title: Option<String>,
+ latest_progress: Option<String>,
+}
+
+impl AgentOscStateTracker {
+ pub(super) fn observe(&mut self, bytes: &[u8]) {
+ for &byte in bytes {
+ match self.state {
+ Osc52ForwarderState::Ground => {
+ if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ }
+ }
+ Osc52ForwarderState::Escape => {
+ if byte == b']' {
+ self.body.clear();
+ self.state = Osc52ForwarderState::OscBody;
+ } else if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ } else {
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ Osc52ForwarderState::OscBody => match byte {
+ 0x07 => {
+ self.finalize();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ 0x1b => self.state = Osc52ForwarderState::OscEscape,
+ _ => self.body.push(byte),
+ },
+ Osc52ForwarderState::OscEscape => {
+ if byte == b'\\' {
+ self.finalize();
+ self.state = Osc52ForwarderState::Ground;
+ } else {
+ self.body.push(0x1b);
+ self.body.push(byte);
+ self.state = Osc52ForwarderState::OscBody;
+ }
+ }
+ }
+
+ if self.body.len() > 4096 {
+ self.body.clear();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ }
+
+ fn finalize(&mut self) {
+ if let Some((command, payload)) = parse_agent_osc_body(&self.body) {
+ match command {
+ b"0" | b"2" => {
+ if payload.is_empty() {
+ self.latest_title = None;
+ } else {
+ self.latest_title =
+ Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS));
+ }
+ }
+ b"9" => {
+ self.latest_progress =
+ Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS));
+ }
+ _ => {}
+ }
+ }
+ self.body.clear();
+ }
+
+ /// Returns the latest retained OSC title, or `""` if none has been seen or
+ /// the last title was an empty clear.
+ #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C
+ pub(super) fn latest_title(&self) -> &str {
+ self.latest_title.as_deref().unwrap_or("")
+ }
+
+ /// Returns the latest retained OSC 9 progress payload, or `""` if none.
+ #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C
+ pub(super) fn latest_progress(&self) -> &str {
+ self.latest_progress.as_deref().unwrap_or("")
+ }
+
+ /// Drops the retained title and progress so a new foreground agent cannot
+ /// inherit OSC evidence emitted by a previous process. The in-flight parse
+ /// state is kept: a sequence spanning the agent change finalizes normally
+ /// and is attributed to the new agent.
+ pub(super) fn clear_retained(&mut self) {
+ self.latest_title = None;
+ self.latest_progress = None;
+ }
+}
+
+/// Splits an OSC body at the first `;`, returning `(command, payload)`.
+/// Returns `None` if there is no `;`.
+fn parse_agent_osc_body(body: &[u8]) -> Option<(&[u8], &[u8])> {
+ let sep = body.iter().position(|&b| b == b';')?;
+ Some((&body[..sep], &body[sep + 1..]))
+}
+
+fn sanitize_agent_osc_string(payload: &[u8], max_chars: usize) -> String {
+ let text = String::from_utf8_lossy(payload);
+ let mut out = String::new();
+ for ch in text.chars().filter(|ch| !ch.is_control()).take(max_chars) {
+ out.push(ch);
+ }
+ out
+}
+
+/// Reconstructs selected OSC sequences for local evidence capture while
+/// debugging agent title/status behavior. This is intentionally passive:
+/// nothing here affects terminal rendering or detection state.
+#[derive(Debug)]
+pub(super) struct OscDebugTracker {
+ enabled: bool,
+ state: Osc52ForwarderState,
+ body: Vec<u8>,
+ pending: Vec<OscDebugEvent>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct OscDebugEvent {
+ pub(super) command: String,
+ pub(super) payload: String,
+}
+
+impl OscDebugTracker {
+ pub(super) fn from_env() -> Self {
+ Self {
+ enabled: osc_debug_enabled_from_env(),
+ state: Osc52ForwarderState::Ground,
+ body: Vec::new(),
+ pending: Vec::new(),
+ }
+ }
+
+ pub(super) fn observe(&mut self, bytes: &[u8]) {
+ if !self.enabled {
+ return;
+ }
+
+ for &byte in bytes {
+ match self.state {
+ Osc52ForwarderState::Ground => {
+ if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ }
+ }
+ Osc52ForwarderState::Escape => {
+ if byte == b']' {
+ self.body.clear();
+ self.state = Osc52ForwarderState::OscBody;
+ } else if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ } else {
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ Osc52ForwarderState::OscBody => match byte {
+ 0x07 => {
+ self.finalize();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ 0x1b => self.state = Osc52ForwarderState::OscEscape,
+ _ => self.body.push(byte),
+ },
+ Osc52ForwarderState::OscEscape => {
+ if byte == b'\\' {
+ self.finalize();
+ self.state = Osc52ForwarderState::Ground;
+ } else {
+ self.body.push(0x1b);
+ self.body.push(byte);
+ self.state = Osc52ForwarderState::OscBody;
+ }
+ }
+ }
+
+ if self.body.len() > 4096 {
+ self.body.clear();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ }
+
+ fn finalize(&mut self) {
+ if let Some(event) = parse_osc_debug_event(&self.body) {
+ self.pending.push(event);
+ }
+ self.body.clear();
+ }
+
+ pub(super) fn drain_pending(&mut self) -> Vec<OscDebugEvent> {
+ std::mem::take(&mut self.pending)
+ }
+}
+
+impl Default for OscDebugTracker {
+ fn default() -> Self {
+ Self::from_env()
+ }
+}
+
+fn osc_debug_enabled_from_env() -> bool {
+ std::env::var("HERDR_DEBUG_OSC_EVIDENCE")
+ .map(|value| {
+ matches!(
+ value.trim().to_ascii_lowercase().as_str(),
+ "1" | "true" | "yes" | "on"
+ )
+ })
+ .unwrap_or(false)
+}
+
+fn parse_osc_debug_event(body: &[u8]) -> Option<OscDebugEvent> {
+ let separator = body.iter().position(|byte| *byte == b';')?;
+ let command = &body[..separator];
+ let payload = &body[separator + 1..];
+ if !matches!(command, b"0" | b"2" | b"9" | b"21337") {
+ return None;
+ }
+ Some(OscDebugEvent {
+ command: std::str::from_utf8(command).ok()?.to_string(),
+ payload: sanitized_osc_debug_payload(payload),
+ })
+}
+
+fn sanitized_osc_debug_payload(payload: &[u8]) -> String {
+ const MAX_CHARS: usize = 512;
+ let text = String::from_utf8_lossy(payload);
+ let mut sanitized = String::new();
+ for ch in text.chars().filter(|ch| !ch.is_control()).take(MAX_CHARS) {
+ sanitized.push(ch);
+ }
+ if text.chars().count() > MAX_CHARS {
+ sanitized.push_str("...");
+ }
+ sanitized
+}
+
fn parse_file_uri_cwd(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
let path = if rest.starts_with('/') {
@@ -724,6 +981,15 @@ mod tests {
events.into_iter().map(|event| event.event).collect()
}
+ fn enabled_osc_debug_tracker() -> OscDebugTracker {
+ OscDebugTracker {
+ enabled: true,
+ state: Osc52ForwarderState::Ground,
+ body: Vec::new(),
+ pending: Vec::new(),
+ }
+ }
+
#[test]
fn default_color_tracker_detects_split_osc_11_sequences() {
let mut tracker = DefaultColorOscTracker::default();
@@ -766,6 +1032,235 @@ mod tests {
);
}
+ // -----------------------------------------------------------------------
+ // AgentOscStateTracker tests
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn agent_osc_osc0_title_with_bel() {
+ let mut t = AgentOscStateTracker::default();
+ t.observe("hello\x1b]0;braille title\x07world".as_bytes());
+ assert_eq!(t.latest_title(), "braille title");
+ assert_eq!(t.latest_progress(), "");
+ }
+
+ #[test]
+ fn agent_osc_osc2_title_with_st() {
+ let mut t = AgentOscStateTracker::default();
+ t.observe("hello\x1b]2;static title\x1b\\world".as_bytes());
+ assert_eq!(t.latest_title(), "static title");
+ assert_eq!(t.latest_progress(), "");
+ }
+
+ #[test]
+ fn agent_osc_empty_osc0_clears_title() {
+ let mut t = AgentOscStateTracker::default();
+ // First set a title.
+ t.observe(b"\x1b]0;some title\x07");
+ assert_eq!(t.latest_title(), "some title");
+ // Then clear it with an empty payload (Codex pattern).
+ t.observe(b"\x1b]0;\x07");
+ assert_eq!(t.latest_title(), "");
+ }
+
+ #[test]
+ fn agent_osc_osc9_sets_progress_with_bel() {
+ let mut t = AgentOscStateTracker::default();
+ t.observe(b"\x1b]9;4;3;\x07");
+ assert_eq!(t.latest_progress(), "4;3;");
+ assert_eq!(t.latest_title(), "");
+ }
+
+ #[test]
+ fn agent_osc_osc9_clear_progress_with_st() {
+ let mut t = AgentOscStateTracker::default();
+ t.observe(b"\x1b]9;4;3;\x07");
+ assert_eq!(t.latest_progress(), "4;3;");
+ t.observe(b"\x1b]9;4;0;\x1b\\");
+ assert_eq!(t.latest_progress(), "4;0;");
+ }
+
+ #[test]
+ fn agent_osc_split_sequence_across_chunks() {
+ let mut t = AgentOscStateTracker::default();
+ t.observe(b"\x1b]9;4;3");
+ assert_eq!(t.latest_progress(), "");
+ t.observe(b";\x07");
+ assert_eq!(t.latest_progress(), "4;3;");
+ }
+
+ #[test]
+ fn agent_osc_bel_and_st_terminators_both_work() {
+ let mut t = AgentOscStateTracker::default();
+ t.observe(b"\x1b]0;title-bel\x07");
+ assert_eq!(t.latest_title(), "title-bel");
+ t.observe(b"\x1b]0;title-st\x1b\\");
+ assert_eq!(t.latest_title(), "title-st");
+ }
+
+ #[test]
+ fn agent_osc_oversized_payload_is_discarded_and_recovers() {
+ let mut t = AgentOscStateTracker::default();
+ // Set a title first.
+ t.observe(b"\x1b]0;before\x07");
+ assert_eq!(t.latest_title(), "before");
+
+ // Feed an oversized OSC body (> 4096 bytes).
+ let mut oversized = Vec::from(b"\x1b]0;".as_slice());
+ oversized.extend(std::iter::repeat_n(b'x', 4097));
+ oversized.push(0x07);
+ t.observe(&oversized);
+ // The oversized body is dropped; the previously stored title is kept.
+ assert_eq!(t.latest_title(), "before");
+
+ // After recovery, subsequent valid sequences are captured normally.
+ t.observe(b"\x1b]0;after\x07");
+ assert_eq!(t.latest_title(), "after");
+ }
+
+ #[test]
+ fn agent_osc_cap_length_is_respected() {
+ let mut t = AgentOscStateTracker::default();
+ // Build a title of AGENT_OSC_MAX_CHARS + 50 ASCII chars.
+ let long_title: String = "a".repeat(AGENT_OSC_MAX_CHARS + 50);
+ let seq = format!("\x1b]0;{long_title}\x07");
+ t.observe(seq.as_bytes());
+ assert_eq!(t.latest_title().len(), AGENT_OSC_MAX_CHARS);
+ }
+
+ #[test]
+ fn agent_osc_control_chars_stripped() {
+ let mut t = AgentOscStateTracker::default();
+ t.observe(b"\x1b]0;before\x01after\x07");
+ assert_eq!(t.latest_title(), "beforeafter");
+ }
+
+ #[test]
+ fn agent_osc_unrelated_osc_does_not_overwrite_title() {
+ let mut t = AgentOscStateTracker::default();
+ t.observe(b"\x1b]0;my title\x07");
+ // OSC 4 (palette color), OSC 52 (clipboard) — should not touch title/progress.
+ t.observe(b"\x1b]4;1;rgb:aa/bb/cc\x07");
+ t.observe(b"\x1b]52;c;aGVsbG8=\x07");
+ assert_eq!(t.latest_title(), "my title");
+ assert_eq!(t.latest_progress(), "");
+ }
+
+ #[test]
+ fn agent_osc_interleaved_sequences() {
+ let mut t = AgentOscStateTracker::default();
+ // OSC 0 title, then OSC 9 progress, then OSC 2 title update.
+ t.observe(b"\x1b]0;first\x07\x1b]9;4;3;\x07\x1b]2;second\x07");
+ assert_eq!(t.latest_title(), "second");
+ assert_eq!(t.latest_progress(), "4;3;");
+ }
+
+ #[test]
+ fn agent_osc_default_state_is_empty() {
+ let t = AgentOscStateTracker::default();
+ assert_eq!(t.latest_title(), "");
+ assert_eq!(t.latest_progress(), "");
+ }
+
+ // -----------------------------------------------------------------------
+ // OscDebugTracker tests (existing)
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn osc_debug_tracker_detects_title_with_bel() {
+ let mut tracker = enabled_osc_debug_tracker();
+
+ tracker.observe("hello\x1b]0;✻ working title\x07world".as_bytes());
+
+ assert_eq!(
+ tracker.drain_pending(),
+ vec![OscDebugEvent {
+ command: "0".to_string(),
+ payload: "✻ working title".to_string(),
+ }]
+ );
+ }
+
+ #[test]
+ fn osc_debug_tracker_detects_title_with_st() {
+ let mut tracker = enabled_osc_debug_tracker();
+
+ tracker.observe("hello\x1b]2;static title\x1b\\world".as_bytes());
+
+ assert_eq!(
+ tracker.drain_pending(),
+ vec![OscDebugEvent {
+ command: "2".to_string(),
+ payload: "static title".to_string(),
+ }]
+ );
+ }
+
+ #[test]
+ fn osc_debug_tracker_detects_split_status_sequences() {
+ let mut tracker = enabled_osc_debug_tracker();
+
+ tracker.observe(b"\x1b]9;4;3");
+ assert!(tracker.drain_pending().is_empty());
+ tracker.observe(b"\x07\x1b]21337;status=working\x1b\\");
+
+ assert_eq!(
+ tracker.drain_pending(),
+ vec![
+ OscDebugEvent {
+ command: "9".to_string(),
+ payload: "4;3".to_string(),
+ },
+ OscDebugEvent {
+ command: "21337".to_string(),
+ payload: "status=working".to_string(),
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn osc_debug_tracker_ignores_untracked_osc_commands() {
+ let mut tracker = enabled_osc_debug_tracker();
+
+ tracker.observe(b"\x1b]52;c;SGVsbG8=\x07\x1b]7;file:///tmp\x07");
+
+ assert!(tracker.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc_debug_tracker_sanitizes_control_characters() {
+ let mut tracker = enabled_osc_debug_tracker();
+
+ tracker.observe(b"\x1b]0;before\x01after\x07");
+
+ assert_eq!(
+ tracker.drain_pending(),
+ vec![OscDebugEvent {
+ command: "0".to_string(),
+ payload: "beforeafter".to_string(),
+ }]
+ );
+ }
+
+ #[test]
+ fn osc_debug_tracker_recovers_after_oversized_payload() {
+ let mut tracker = enabled_osc_debug_tracker();
+ let oversized = vec![b'a'; 4097];
+
+ tracker.observe(b"\x1b]0;");
+ tracker.observe(&oversized);
+ tracker.observe(b"\x07\x1b]0;ok\x07");
+
+ assert_eq!(
+ tracker.drain_pending(),
+ vec![OscDebugEvent {
+ command: "0".to_string(),
+ payload: "ok".to_string(),
+ }]
+ );
+ }
+
#[test]
fn default_color_event_tracker_detects_queries_sets_and_resets() {
let mut tracker = DefaultColorEventTracker::default();
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index d697f76d..9caa3dba 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -23,8 +23,9 @@ use super::{
osc::{
contains_scrollback_clear_sequence, current_transient_default_color_owner,
maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed,
- write_host_terminal_theme, CwdOscTracker, DefaultColorEvent, DefaultColorEventTracker,
- DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder,
+ write_host_terminal_theme, AgentOscStateTracker, CwdOscTracker, DefaultColorEvent,
+ DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery,
+ DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker,
},
xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
};
@@ -125,6 +126,8 @@ pub(crate) struct GhosttyPaneCore {
pub child_default_background_changed: bool,
pub osc52_forwarder: Osc52Forwarder,
pub cwd_osc_tracker: CwdOscTracker,
+ pub osc_debug_tracker: OscDebugTracker,
+ pub agent_osc_state: AgentOscStateTracker,
pub xtgettcap_query_tracker: XtgettcapQueryTracker,
}
@@ -267,6 +270,21 @@ impl PaneTerminal {
.maybe_restore_host_terminal_theme(pane_id, shell_pid)
}
+ #[allow(dead_code)] // exposed for Stage C (detection loop wiring)
+ pub fn agent_osc_title(&self) -> String {
+ self.ghostty.agent_osc_title()
+ }
+
+ #[allow(dead_code)] // exposed for Stage C (detection loop wiring)
+ pub fn agent_osc_progress(&self) -> String {
+ self.ghostty.agent_osc_progress()
+ }
+
+ /// Clears retained OSC title/progress evidence on foreground agent change.
+ pub fn clear_agent_osc_state(&self) {
+ self.ghostty.clear_agent_osc_state()
+ }
+
pub fn keyboard_protocol(
&self,
fallback: crate::input::KeyboardProtocol,
@@ -364,6 +382,8 @@ impl GhosttyPaneTerminal {
child_default_background_changed: false,
osc52_forwarder: Osc52Forwarder::default(),
cwd_osc_tracker: CwdOscTracker::default(),
+ osc_debug_tracker: OscDebugTracker::default(),
+ agent_osc_state: AgentOscStateTracker::default(),
xtgettcap_query_tracker: XtgettcapQueryTracker::default(),
}),
key_encoder: Mutex::new(key_encoder),
@@ -417,6 +437,34 @@ impl GhosttyPaneTerminal {
)
}
+ /// Returns the latest OSC 0/2 title retained for agent detection, or `""`
+ /// if no title has been seen or the last update was an empty clear.
+ #[allow(dead_code)] // exposed for Stage C (detection loop wiring)
+ pub fn agent_osc_title(&self) -> String {
+ self.core
+ .lock()
+ .map(|core| core.agent_osc_state.latest_title().to_owned())
+ .unwrap_or_default()
+ }
+
+ /// Returns the latest OSC 9 progress payload retained for agent detection,
+ /// or `""` if none has been seen.
+ #[allow(dead_code)] // exposed for Stage C (detection loop wiring)
+ pub fn agent_osc_progress(&self) -> String {
+ self.core
+ .lock()
+ .map(|core| core.agent_osc_state.latest_progress().to_owned())
+ .unwrap_or_default()
+ }
+
+ /// Clears retained OSC title/progress evidence when the pane's foreground
+ /// agent changes, so a new agent process starts from a blank OSC slate.
+ pub fn clear_agent_osc_state(&self) {
+ if let Ok(mut core) = self.core.lock() {
+ core.agent_osc_state.clear_retained();
+ }
+ }
+
pub fn process_pty_bytes(
&self,
pane_id: PaneId,
@@ -451,6 +499,16 @@ impl GhosttyPaneTerminal {
let clipboard_writes = core.osc52_forwarder.drain_pending();
core.cwd_osc_tracker.observe(bytes);
let reported_cwd = core.cwd_osc_tracker.drain_latest();
+ core.osc_debug_tracker.observe(bytes);
+ for event in core.osc_debug_tracker.drain_pending() {
+ debug!(
+ pane = pane_id.raw(),
+ osc_command = %event.command,
+ osc_payload = ?event.payload,
+ "agent OSC evidence observed"
+ );
+ }
+ core.agent_osc_state.observe(bytes);
let alternate_screen = core
.terminal