Skip to main content

tmux_mcp/
exec.rs

1//! Running a command in a pane, and waiting for one to say something.
2//!
3//! Both read the pane's output stream rather than its screen. A screen is what
4//! survived rendering; the stream is everything the program wrote, in the
5//! order it wrote it, including what has already scrolled away. Nothing here
6//! polls, and nothing here depends on tmux still holding a line in scrollback.
7
8use std::time::Duration;
9
10use tokio_util::sync::CancellationToken;
11
12use libtmux::{CaptureOptions, ControlLimits, ControlModeErrorKind, Error, Pane};
13use regex::bytes::Regex;
14use serde::Serialize;
15
16use crate::echo::{EchoKey, PaneEchoes};
17use crate::retained::MAX_BYTES as OUTPUT_LIMIT;
18#[cfg(test)]
19use crate::retained::{COMPACT_AFTER, RetainedBytes};
20use crate::text::TextFilter;
21#[cfg(test)]
22use crate::text::readable_from;
23
24const MAX_PATTERNS: usize = 32;
25const MAX_PATTERN_BYTES: usize = 4_096;
26const MAX_TOTAL_PATTERN_BYTES: usize = 16_384;
27
28/// The shared echo record and this pane's key into it, bundled so threading
29/// both through `wait_for_text`'s internal calls does not itself run into
30/// clippy's argument-count limit.
31#[derive(Clone, Copy)]
32pub(crate) struct EchoContext<'a> {
33    pub(crate) echoes: &'a PaneEchoes,
34    pub(crate) key: Option<&'a EchoKey>,
35}
36
37mod run;
38
39#[cfg(test)]
40pub(crate) use run::observing_prepared_shutdowns;
41#[cfg(test)]
42use run::{
43    FrameError, Scanner, TRAP_DECLARATION_LIMIT, find, frame_path, frame_with_random,
44    inherited_trap_capture, quote_shell_word, render_payload, stage_frame, staged_line,
45};
46pub(crate) use run::{
47    PrepareRunError, RunDispatch, RunProgress, prepare_run, readable, route_is_terminal_safe,
48    route_path_is_terminal_safe,
49};
50
51/// How a run finished.
52///
53/// Split from the wait outcomes rather than shared with them: a run cannot
54/// match a pattern and a wait cannot report a missing shell, and a vocabulary
55/// carrying both would have an agent checking for answers that never come.
56#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, schemars::JsonSchema)]
57#[serde(rename_all = "snake_case")]
58pub enum RunOutcome {
59    /// The command ran to completion and reported its status.
60    Completed,
61    /// The time the caller allowed ran out.
62    ///
63    /// This ends the waiting, not the command. The pane stays reserved for it
64    /// until it ends, so other pane input is refused; `send_keys` with keys
65    /// `["C-c"]` alone interrupts it.
66    Deadline,
67    /// The pane stopped writing for good.
68    PaneClosed,
69    /// The client withdrew the request while the run was still going.
70    Cancelled,
71    /// The pane never acknowledged the command.
72    ///
73    /// The keys were sent but the opening sentinel never came back. That is
74    /// what a pane looks like when it is not at a shell prompt: sitting in an
75    /// editor or a REPL, or still running something an earlier call left
76    /// behind. The text was typed into whatever is there.
77    ///
78    /// The evidence is absence, so a deadline too short for the pane's shell
79    /// to have echoed anything yet looks the same. Read it as "nothing came
80    /// back in the time allowed" and check the pane with `snapshot_pane`
81    /// before concluding it is stuck.
82    NoShell,
83}
84
85/// How a wait for text finished.
86#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, schemars::JsonSchema)]
87#[serde(rename_all = "snake_case")]
88pub enum WaitOutcome {
89    /// A wanted pattern was already in the pane's output before this began
90    /// watching, on a row above the one still being typed into, and is not
91    /// a line this server itself submitted moments earlier.
92    ///
93    /// A wait only sees what a pane writes after it starts, so this is never
94    /// folded into [`Self::Matched`]: the same pattern printed moments
95    /// earlier -- an earlier command's own output, say -- can already be
96    /// sitting there, and a caller that treated that as a fresh match would
97    /// act on something that happened before this call, not because of it.
98    /// A line this server typed and submitted with `send_keys` is discounted
99    /// rather than reported here or as [`Self::Matched`], for a short time
100    /// after it was submitted.
101    PresentAtEntry,
102    /// A wanted pattern's only occurrence is the row still being typed
103    /// into: text this server (or a person sharing the pane) sent and has
104    /// not submitted, not anything that has run.
105    ///
106    /// Submit it, then wait again: the next wait sees the command's own
107    /// output on a row above a new one still being typed into, which is
108    /// [`Self::PresentAtEntry`] or [`Self::Matched`] depending on when it
109    /// arrived, never this -- and never the submitted line's own echo,
110    /// which stays discounted for a short time after.
111    Pending,
112    /// A pattern matched, in output that arrived after the wait attached.
113    Matched,
114    /// A stop pattern matched, so the wait ended early.
115    Stopped,
116    /// The time the caller allowed ran out.
117    Deadline,
118    /// The pane stopped writing for good.
119    PaneClosed,
120    /// The client withdrew the request while the wait was still running.
121    Cancelled,
122}
123
124/// What a command did.
125#[derive(Clone, Debug, Serialize, schemars::JsonSchema)]
126pub struct RunView {
127    /// The pane the command ran in.
128    pub pane: String,
129    /// How the run finished.
130    pub outcome: RunOutcome,
131    /// The command's exit status, when it completed.
132    ///
133    /// Absent when the run did not complete, and when the command was killed
134    /// by a signal rather than exiting.
135    pub exit_status: Option<i32>,
136    /// Everything the command wrote, stdout and stderr interleaved in the
137    /// order the program wrote them.
138    ///
139    /// This is the raw output stream with escape sequences removed, not the
140    /// rendered screen: a line redrawn in place, such as a progress bar,
141    /// repeats. `capture_pane` shows the screen.
142    pub output: String,
143    /// How many bytes that was, before any truncation.
144    pub bytes: usize,
145    /// Whether the output was truncated from the front.
146    pub truncated: bool,
147}
148
149/// What a pane said while it was watched for a pattern.
150#[derive(Debug, Serialize, schemars::JsonSchema)]
151pub struct WaitView {
152    /// The pane that was watched.
153    pub pane: String,
154    /// How the wait finished.
155    pub outcome: WaitOutcome,
156    /// Which pattern matched, indexed into the list it came from.
157    pub matched_index: Option<usize>,
158    /// The pattern that matched, as it was given.
159    pub matched_pattern: Option<String>,
160    /// What the pane wrote, with escape sequences removed.
161    ///
162    /// This is the raw output stream, not the rendered screen: a line redrawn
163    /// in place, such as a line editor's echo, repeats. `capture_pane` shows
164    /// the screen.
165    pub text: String,
166    /// How many bytes arrived, before filtering or truncation.
167    pub bytes: usize,
168}
169
170/// A set of patterns to look for in a pane's output.
171pub(crate) struct Patterns {
172    compiled: Vec<Regex>,
173    sources: Vec<String>,
174}
175
176impl Patterns {
177    /// Compile patterns, as literal text or as regular expressions.
178    ///
179    /// # Errors
180    ///
181    /// Returns the offending pattern and the reason when one will not compile.
182    pub(crate) fn compile(
183        sources: &[String],
184        regex: bool,
185        match_case: bool,
186    ) -> Result<Self, (String, String)> {
187        if sources.len() > MAX_PATTERNS {
188            return Err((
189                "set".to_owned(),
190                format!("contains more than {MAX_PATTERNS} patterns"),
191            ));
192        }
193        let mut compiled = Vec::with_capacity(sources.len());
194        let mut total_bytes = 0_usize;
195        for (index, source) in sources.iter().enumerate() {
196            if source.len() > MAX_PATTERN_BYTES {
197                return Err((
198                    format!("{}", index + 1),
199                    format!("exceeds {MAX_PATTERN_BYTES} bytes"),
200                ));
201            }
202            total_bytes = total_bytes.saturating_add(source.len());
203            if total_bytes > MAX_TOTAL_PATTERN_BYTES {
204                return Err((
205                    "set".to_owned(),
206                    format!("exceeds {MAX_TOTAL_PATTERN_BYTES} bytes in total"),
207                ));
208            }
209            let body = if regex {
210                source.clone()
211            } else {
212                regex::escape(source)
213            };
214            let expression = if match_case {
215                body
216            } else {
217                format!("(?i){body}")
218            };
219            match Regex::new(&expression) {
220                Ok(pattern) => compiled.push(pattern),
221                Err(error) => return Err((source.clone(), error.to_string())),
222            }
223        }
224
225        Ok(Self {
226            compiled,
227            sources: sources.to_vec(),
228        })
229    }
230
231    /// Whether any pattern was given.
232    fn is_empty(&self) -> bool {
233        self.compiled.is_empty()
234    }
235
236    /// The first pattern that matches, with the index it was given at.
237    pub(crate) fn first_match(&self, haystack: &[u8]) -> Option<(usize, &str)> {
238        self.compiled
239            .iter()
240            .position(|pattern| pattern.is_match(haystack))
241            .map(|index| (index, self.sources[index].as_str()))
242    }
243}
244
245/// Watch a pane until a pattern matches, a stop pattern matches, or time runs
246/// out.
247///
248/// # Errors
249///
250/// Returns an error when the pane cannot be watched or read.
251pub(crate) async fn wait_for_text(
252    pane: &Pane,
253    patterns: &Patterns,
254    stops: &Patterns,
255    timeout: Duration,
256    cancelled: &CancellationToken,
257    echo: EchoContext<'_>,
258) -> Result<WaitView, Error> {
259    wait_for_text_with_limits(
260        pane,
261        patterns,
262        stops,
263        timeout,
264        cancelled,
265        ControlLimits::default(),
266        echo,
267    )
268    .await
269}
270
271/// Like [`wait_for_text`], with explicit control-mode frame budgets.
272///
273/// Every real caller wants [`wait_for_text`]'s default: this exists so a test
274/// can shrink the budget enough to force the frame-too-large shutdown error
275/// [`wait_for_text`] propagates instead of tolerating -- a branch no MCP
276/// tool argument can reach, since exposing a protocol-tuning knob to a
277/// caller of the tool would leak an implementation detail into its surface.
278///
279/// Split from [`wait_on_output`] at the attach point so a test driving a
280/// tiny budget can send its adversarial output only once attaching has
281/// provably finished, rather than racing a fixed delay against it.
282pub(crate) async fn wait_for_text_with_limits(
283    pane: &Pane,
284    patterns: &Patterns,
285    stops: &Patterns,
286    timeout: Duration,
287    cancelled: &CancellationToken,
288    limits: ControlLimits,
289    echo: EchoContext<'_>,
290) -> Result<WaitView, Error> {
291    // Attached first: a pattern that arrives while the screen is being read
292    // must still be seen. Reading first would lose one that landed between
293    // the capture and the attach, and wait out the deadline over output that
294    // did arrive. One landing in that gap is reported as present at entry
295    // instead, which is still true of the screen.
296    let output = pane.stream_output_with_limits(limits).await?;
297    if let Some(view) = read_present_at_entry(pane, patterns, echo).await? {
298        // The answer is already in hand; a failure closing a stream nothing
299        // read does not change it.
300        let _ = output.shutdown().await;
301        return Ok(view);
302    }
303    wait_on_output(pane, output, patterns, stops, timeout, cancelled, echo).await
304}
305
306/// One pane's screen, split at the row still being typed into.
307///
308/// Two tmux round trips read this, not one: the cursor row first, then the
309/// screen. They are not atomic, so a line arriving between the two can only
310/// move the cursor down and make `pending` cover a later row -- excluding
311/// more from `above`, never less -- which is the safe direction to be wrong
312/// in.
313struct Screen {
314    /// Every visible row above the one still being typed into, each
315    /// terminated with a newline: completed output, never text this server
316    /// or a person sent and has not submitted.
317    above: Vec<u8>,
318    /// The row still being typed into, terminated with a newline to match
319    /// `above`'s rows.
320    pending: Vec<u8>,
321}
322
323impl Screen {
324    /// Capture `pane`'s current screen, split at its cursor row.
325    ///
326    /// `None` when the pane cannot be read; the same failure surfaces again
327    /// from whatever the caller does next.
328    async fn capture(pane: &Pane) -> Option<Self> {
329        let cursor_row: usize = pane
330            .format("#{cursor_y}")
331            .await
332            .ok()?
333            .to_string_lossy()
334            .trim()
335            .parse()
336            .ok()?;
337        let lines = pane.capture_with(CaptureOptions::visible()).await.ok()?;
338        // A cursor row past the last captured line is conservative rather
339        // than a decode failure: treat every visible row as still pending.
340        let pending_row = cursor_row.min(lines.len().saturating_sub(1));
341
342        let mut above = Vec::new();
343        for line in lines.iter().take(pending_row) {
344            above.extend_from_slice(line.as_bytes());
345            above.push(b'\n');
346        }
347        let mut pending = lines
348            .get(pending_row)
349            .map_or_else(Vec::new, |line| line.as_bytes().to_vec());
350        pending.push(b'\n');
351
352        Some(Self { above, pending })
353    }
354
355    /// Both halves, in screen order, for a view that reports the whole
356    /// thing rather than only whichever half matched.
357    fn whole(&self) -> Vec<u8> {
358        let mut all = self.above.clone();
359        all.extend_from_slice(&self.pending);
360        all
361    }
362}
363
364/// Report a wanted pattern already in the pane's output, or still only on
365/// the row being typed into, before any stream attaches to watch for one
366/// arriving.
367///
368/// See [`WaitOutcome::PresentAtEntry`] and [`WaitOutcome::Pending`] for why
369/// these are distinct outcomes from [`WaitOutcome::Matched`] rather than a
370/// flag alongside it.
371async fn read_present_at_entry(
372    pane: &Pane,
373    patterns: &Patterns,
374    echo: EchoContext<'_>,
375) -> Result<Option<WaitView>, Error> {
376    // No patterns means "wait for anything at all", which nothing already on
377    // screen can pre-empt: there is nothing yet to call present.
378    if patterns.is_empty() {
379        return Ok(None);
380    }
381
382    // A screen that cannot be read is not a reason to refuse to wait; the
383    // same failure surfaces from the attach right after this.
384    let Some(screen) = Screen::capture(pane).await else {
385        return Ok(None);
386    };
387
388    // A line this server itself submitted moments ago -- before this wait
389    // even attached -- is not evidence of anything the pane did; discount it
390    // the same way a live match is discounted below. The row still being
391    // typed into is never masked: nothing not yet submitted is ever in
392    // `recent`.
393    let recent = echo
394        .key
395        .map(|key| echo.echoes.snapshot(key))
396        .unwrap_or_default();
397    let masked_above = crate::echo::mask(&screen.above, &recent);
398
399    // Nothing of this server's own is unsubmitted, so the row the cursor
400    // sits on is not mid-typing either -- reported the same as `above`
401    // rather than `pending`, since it is not this server's own question.
402    // This is what keeps a command whose output does not end in a newline
403    // (so the next prompt lands on the same row) from being hidden forever.
404    let pending_outcome = if echo.key.is_some_and(|key| echo.echoes.has_pending(key)) {
405        WaitOutcome::Pending
406    } else {
407        WaitOutcome::PresentAtEntry
408    };
409    let outcome = patterns
410        .first_match(&masked_above)
411        .map(|found| (WaitOutcome::PresentAtEntry, found))
412        .or_else(|| {
413            patterns
414                .first_match(&screen.pending)
415                .map(|found| (pending_outcome, found))
416        });
417    let Some((outcome, (index, source))) = outcome else {
418        return Ok(None);
419    };
420    let whole = screen.whole();
421
422    Ok(Some(WaitView {
423        pane: pane.id().to_string(),
424        outcome,
425        matched_index: Some(index),
426        matched_pattern: Some(source.to_owned()),
427        text: String::from_utf8_lossy(&whole).into_owned(),
428        bytes: whole.len(),
429    }))
430}
431
432/// Confirm a fresh match against `pane`'s completed rows, discounting every
433/// line `echoes` has recorded for it.
434///
435/// `sticky` accumulates every echo seen across the whole wait, not only this
436/// call's snapshot: `echoes` ages a record out on its own schedule (10
437/// seconds), and a wait may run longer than that. Losing the record mid-wait
438/// must not resurrect the very false match it existed to prevent, so once an
439/// echo is seen it stays discounted for the rest of this call.
440async fn confirmed_above(
441    pane: &Pane,
442    patterns: &Patterns,
443    echo: EchoContext<'_>,
444    sticky: &mut Vec<Vec<u8>>,
445) -> bool {
446    if let Some(key) = echo.key {
447        for line in echo.echoes.snapshot(key) {
448            if !sticky.contains(&line) {
449                sticky.push(line);
450            }
451        }
452    }
453    let Some(screen) = Screen::capture(pane).await else {
454        return false;
455    };
456    let mut haystack = crate::echo::mask(&screen.above, sticky);
457    // Nothing of this server's own is unsubmitted here, so whatever is on
458    // this row is not mid-typing -- most often a command's own output that
459    // did not end in a newline and left the next prompt on the same row,
460    // which the position rule alone would otherwise hide forever.
461    if !echo.key.is_some_and(|key| echo.echoes.has_pending(key)) {
462        haystack.extend_from_slice(&screen.pending);
463    }
464    patterns.first_match(&haystack).is_some()
465}
466
467/// The read loop [`wait_for_text_with_limits`] runs once attached.
468async fn wait_on_output(
469    pane: &Pane,
470    mut output: libtmux::control::PaneOutput,
471    patterns: &Patterns,
472    stops: &Patterns,
473    timeout: Duration,
474    cancelled: &CancellationToken,
475    echo: EchoContext<'_>,
476) -> Result<WaitView, Error> {
477    let mut filter = TextFilter::new();
478    let mut text: Vec<u8> = Vec::new();
479    let mut bytes = 0usize;
480    let mut outcome = WaitOutcome::Deadline;
481    let mut matched_index = None;
482    let mut matched_pattern = None;
483    let mut sticky_echoes: Vec<Vec<u8>> = Vec::new();
484    let deadline = tokio::time::Instant::now() + timeout;
485
486    loop {
487        let chunk = tokio::select! {
488            biased;
489            // Checked first so a request cancelled while output is already
490            // waiting still stops, rather than reading one more chunk.
491            () = cancelled.cancelled() => {
492                outcome = WaitOutcome::Cancelled;
493                break;
494            }
495            chunk = tokio::time::timeout_at(deadline, output.next_chunk()) => chunk,
496        };
497        match chunk {
498            Ok(Some(chunk)) => {
499                bytes = bytes.saturating_add(chunk.len());
500                filter.push(&chunk, &mut text);
501
502                if let Some((index, source)) = stops.first_match(&text) {
503                    outcome = WaitOutcome::Stopped;
504                    matched_index = Some(index);
505                    matched_pattern = Some(source.to_owned());
506                    break;
507                }
508                // No patterns means "wait for anything at all", which any
509                // output satisfies.
510                if patterns.is_empty() {
511                    if !text.is_empty() {
512                        outcome = WaitOutcome::Matched;
513                        break;
514                    }
515                } else if let Some((index, source)) = patterns.first_match(&text) {
516                    // Fresh bytes on this connection are not necessarily a
517                    // submitted line: the kernel echoes what was typed at
518                    // once, and a shell's line editor re-prints the buffer
519                    // when it starts reading, both genuinely new output that
520                    // can still be sitting on the row being typed into.
521                    // Confirmed only once the *current* screen shows the
522                    // pattern above that row, discounting a line this server
523                    // has itself recently submitted there.
524                    let confirmed = confirmed_above(pane, patterns, echo, &mut sticky_echoes).await;
525                    if confirmed {
526                        outcome = WaitOutcome::Matched;
527                        matched_index = Some(index);
528                        matched_pattern = Some(source.to_owned());
529                        break;
530                    }
531                }
532
533                if text.len() > OUTPUT_LIMIT {
534                    let excess = text.len() - OUTPUT_LIMIT;
535                    text.drain(..excess);
536                }
537            }
538            Ok(None) => {
539                outcome = WaitOutcome::PaneClosed;
540                break;
541            }
542            Err(_) => break,
543        }
544    }
545
546    let pane_id = output.pane().to_string();
547    // Ordinary EOF (`Closed`) is tolerated: the pane stopped being read, so
548    // that alone is not a failure. Any other shutdown error -- frame budget,
549    // timeout, executor shutdown -- is real and discards the view above.
550    if let Err(error) = output.shutdown().await
551        && !matches!(
552            error,
553            Error::ControlMode {
554                kind: ControlModeErrorKind::Closed,
555                ..
556            }
557        )
558    {
559        return Err(error);
560    }
561
562    // A chunk can arrive in the same instant the deadline elapses; without
563    // this, that race would report `Deadline` while `text` already holds a
564    // match, the same shape the .NET port hit.
565    let (mut outcome, mut matched_index, mut matched_pattern) =
566        reconcile_deadline(outcome, matched_index, matched_pattern, patterns, &text);
567    // `reconcile_deadline` reads the same accumulated buffer the main loop
568    // does, and is subject to the same trap: the promotion it just made can
569    // still be the pane's own not-yet-submitted line racing the deadline,
570    // not a genuine match. Confirmed the same way, against the row still
571    // being typed into, or the promotion is undone.
572    if matches!(outcome, WaitOutcome::Matched)
573        && !confirmed_above(pane, patterns, echo, &mut sticky_echoes).await
574    {
575        outcome = WaitOutcome::Deadline;
576        matched_index = None;
577        matched_pattern = None;
578    }
579
580    Ok(WaitView {
581        pane: pane_id,
582        outcome,
583        matched_index,
584        matched_pattern,
585        text: String::from_utf8_lossy(&text).into_owned(),
586        bytes,
587    })
588}
589
590/// Reclassify a timed-out wait as matched when the buffer it is about to
591/// report already contains a pattern.
592///
593/// Only `Deadline` is reconsidered: `Stopped`, `PaneClosed`, and `Cancelled`
594/// already carry their own reason and are returned unchanged.
595fn reconcile_deadline(
596    outcome: WaitOutcome,
597    matched_index: Option<usize>,
598    matched_pattern: Option<String>,
599    patterns: &Patterns,
600    text: &[u8],
601) -> (WaitOutcome, Option<usize>, Option<String>) {
602    if !matches!(outcome, WaitOutcome::Deadline) {
603        return (outcome, matched_index, matched_pattern);
604    }
605    match patterns.first_match(text) {
606        Some((index, source)) => (WaitOutcome::Matched, Some(index), Some(source.to_owned())),
607        None => (outcome, matched_index, matched_pattern),
608    }
609}
610
611#[cfg(test)]
612mod tests;