Skip to main content

winx_code_agent/state/
pty.rs

1//! Real PTY implementation using portable-pty
2//!
3//! This module provides a true pseudo-terminal interface for interactive
4//! shell sessions, enabling proper handling of:
5//! - ANSI escape sequences and colors
6//! - Interactive programs (sudo, vim, less, etc.)
7//! - Terminal resize events
8//! - Job control signals (Ctrl+C, Ctrl+Z, etc.)
9
10use anyhow::{anyhow, Context, Result};
11use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
12use std::collections::hash_map::DefaultHasher;
13use std::hash::{Hash, Hasher};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
18use std::sync::mpsc::{self, TryRecvError};
19use std::sync::{Arc, Mutex as StdMutex};
20use std::thread;
21use std::time::{Duration, Instant};
22use tokio::sync::Mutex;
23use tracing::{debug, info, warn};
24
25use crate::state::line_ring::LineRing;
26use crate::state::live_terminal::{LiveTerminal, ScreenUpdate};
27
28/// Default terminal dimensions (columns x rows)
29pub const DEFAULT_COLS: u16 = 200;
30pub const DEFAULT_ROWS: u16 = 50;
31
32/// Maximum output buffer size to prevent memory issues
33const MAX_OUTPUT_SIZE: usize = 1_000_000;
34
35/// Cap on bytes streamed to one command's output-offload scratch file, so a
36/// command emitting gigabytes cannot fill the disk. The head beyond this is
37/// dropped (the agent still gets the first 50 MB plus the live tail).
38const SCRATCH_MAX_BYTES: u64 = 50 * 1024 * 1024;
39
40/// Sleep injected between PTY reads once the current command's output is pure
41/// discard (scratch budget exhausted — only the live tail is still retained).
42/// With 4 KiB reads this caps consumption at roughly 2 MB/s, so a runaway
43/// producer (`head -c 5T /dev/zero | tr '\0' x` was the production incident)
44/// back-pressures against the kernel PTY buffer instead of pinning a core in
45/// the terminal parsers at full stream speed. A real TUI redraw burst
46/// (~40 KiB/frame) still renders at ~50 fps under this cap, so interactive
47/// piloting is unaffected.
48const DISCARD_READ_THROTTLE: Duration = Duration::from_millis(2);
49
50/// How many fully-formed lines to keep in the per-shell ringbuffer. Callers can
51/// ask for at most this many lines of historical context via
52/// `StatusCheck.scrollback_lines`.
53pub const RING_BUFFER_LINES: usize = 2_000;
54
55/// Cap on the unterminated tail held in `line_ring_partial`. Bounds memory when
56/// a program emits megabytes without ever printing a newline (a `\r`-redrawn
57/// progress bar, a binary blob). Generous enough to hold any real terminal line.
58const MAX_PARTIAL_LINE_BYTES: usize = 64 * 1024;
59
60/// WCGW-style prompt pattern for command completion detection
61const WCGW_PROMPT_PATTERN: &str = "◉";
62const WCGW_PROMPT_END: &str = "──➤";
63
64fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>, bool) {
65    let requested = crate::config::env_text("WINX_ATTACH_TERMINAL")
66        .or_else(|| crate::config::env_text("WINX_USE_SCREEN"))
67        .unwrap_or_default();
68    if requested == "tmux"
69        || requested == "screen"
70        || crate::config::parse_bool(&requested) == Some(true)
71    {
72        let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
73        if requested == "tmux" && command_available("tmux") {
74            let mut cmd = CommandBuilder::new("tmux");
75            cmd.args(["new-session", "-A", "-s", &session, "bash"]);
76            if restricted_mode {
77                cmd.arg("-r");
78            }
79            return (cmd, Some(format!("tmux attach -t {session}")), false);
80        }
81        if command_available("screen") {
82            // Parity with wcgw: ensure a sane ~/.screenrc and reap sessions whose
83            // creating winx process has died before spawning a fresh one.
84            ensure_screenrc();
85            cleanup_orphaned_screens();
86            let mut cmd = CommandBuilder::new("screen");
87            cmd.args(["-q", "-S", &session, "bash"]);
88            if restricted_mode {
89                cmd.arg("-r");
90            }
91            return (cmd, Some(format!("screen -x {session}")), false);
92        }
93    }
94
95    let shell = preferred_shell(restricted_mode);
96    let is_zsh = shell == "zsh";
97    let mut cmd = CommandBuilder::new(&shell);
98    // zsh's restricted mode isn't the `-r` flag, so restricted always uses bash -r.
99    if restricted_mode && !is_zsh {
100        cmd.arg("-r");
101    }
102    (cmd, None, is_zsh)
103}
104
105/// Shell to spawn directly. Defaults to bash; honors `WINX_SHELL=zsh` when zsh is
106/// on PATH and we're not in restricted mode (zsh's restricted mode differs from
107/// `bash -r`, so restricted falls back to bash).
108fn preferred_shell(restricted_mode: bool) -> String {
109    if !restricted_mode {
110        if let Ok(requested) = std::env::var("WINX_SHELL") {
111            if requested == "zsh" && command_available("zsh") {
112                return "zsh".to_string();
113            }
114        }
115    }
116    "bash".to_string()
117}
118
119fn command_available(command: &str) -> bool {
120    Command::new("sh")
121        .args(["-c", &format!("command -v {command}")])
122        .output()
123        .is_ok_and(|output| output.status.success())
124}
125
126/// Create `~/.screenrc` with a large scrollback if the user has none, matching
127/// wcgw's `check_if_screen_command_available`. Never overwrites an existing file.
128fn ensure_screenrc() {
129    let Some(home) = home::home_dir() else {
130        return;
131    };
132    let screenrc = home.join(".screenrc");
133    if screenrc.exists() {
134        return;
135    }
136    let _ = std::fs::write(
137        &screenrc,
138        "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
139    );
140}
141
142/// Reap detached `winx-*` screen sessions whose creating process is gone.
143///
144/// The session name embeds the creator PID (`winx-<pid>-<ts>`), so an orphan is
145/// simply a session whose `<pid>` no longer exists — the wcgw equivalent of
146/// detecting `parent_pid == 1`. Best-effort: any failure is silently ignored.
147fn cleanup_orphaned_screens() {
148    let Ok(output) = Command::new("screen").arg("-ls").output() else {
149        return;
150    };
151    // `screen -ls` exits non-zero when sessions exist, so we parse stdout regardless.
152    let listing = String::from_utf8_lossy(&output.stdout);
153    for line in listing.lines() {
154        let Some(session) = line.split_whitespace().next() else {
155            continue;
156        };
157        // session token looks like "<screen_pid>.winx-<creator_pid>-<ts>"
158        let Some((_, name)) = session.split_once('.') else {
159            continue;
160        };
161        if let Some(creator_pid) = winx_creator_pid(name) {
162            if !process_exists(creator_pid) {
163                let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
164            }
165        }
166    }
167}
168
169/// Extract the creator PID from a `winx-<pid>-<ts>` screen session name.
170fn winx_creator_pid(name: &str) -> Option<u32> {
171    name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
172}
173
174/// Whether a process with `pid` is currently alive. `kill(pid, 0)` is portable
175/// across supported Unix platforms and sends no signal.
176#[cfg(unix)]
177fn process_exists(pid: u32) -> bool {
178    crate::os::unix::process_exists(pid)
179}
180
181#[cfg(not(unix))]
182fn process_exists(pid: u32) -> bool {
183    std::path::Path::new("/proc").join(pid.to_string()).exists()
184}
185
186fn timestamp_millis() -> u128 {
187    std::time::SystemTime::now()
188        .duration_since(std::time::UNIX_EPOCH)
189        .map_or(0, |duration| duration.as_millis())
190}
191
192/// Background thread that pumps the PTY master into the output channel and the
193/// live emulator. Owns the blocking `read` loop so the async side never blocks.
194fn spawn_pty_reader(
195    mut reader: Box<dyn Read + Send>,
196    output_tx: mpsc::SyncSender<String>,
197    live: Arc<StdMutex<LiveTerminal>>,
198    discard_throttle: Arc<AtomicBool>,
199) {
200    thread::spawn(move || {
201        let mut buf = [0u8; 4096];
202        // Bytes of an incomplete trailing UTF-8 char held back for the next
203        // read. Without this, a multibyte glyph split across a 4096-byte read
204        // boundary would decode to two U+FFFDs — corrupting the output buffer
205        // and the prompt/CWD detection that runs over it.
206        let mut carry: Vec<u8> = Vec::new();
207        loop {
208            match reader.read(&mut buf) {
209                Ok(0) => {
210                    // EOF - PTY closed. Flush any held bytes lossily.
211                    if !carry.is_empty() {
212                        let _ = output_tx.send(String::from_utf8_lossy(&carry).into_owned());
213                    }
214                    break;
215                }
216                Ok(n) => {
217                    // Tap the raw bytes into the live emulator first (brief
218                    // lock; feed is O(chunk len)). Feeding bytes — not the
219                    // lossy String — keeps the persistent VTE parser exact
220                    // across chunk boundaries. Recover from a poisoned lock
221                    // instead of dropping the feed silently.
222                    {
223                        let mut emu =
224                            live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
225                        emu.feed(&buf[..n]);
226                    }
227                    // Decode the longest valid prefix; carry an incomplete
228                    // trailing char into the next read.
229                    carry.extend_from_slice(&buf[..n]);
230                    let (chunk, rest) = decode_keep_incomplete(&carry);
231                    carry = rest;
232                    if !chunk.is_empty() && output_tx.send(chunk).is_err() {
233                        // Receiver dropped, exit thread
234                        break;
235                    }
236                    // Once the command's output is pure discard, stop
237                    // consuming at stream speed: the pause fills the kernel
238                    // PTY buffer and blocks the producer instead of burning
239                    // a core parsing bytes that are thrown away.
240                    if discard_throttle.load(AtomicOrdering::Relaxed) {
241                        thread::sleep(DISCARD_READ_THROTTLE);
242                    }
243                }
244                Err(e) => {
245                    debug!("PTY reader thread error: {}", e);
246                    break;
247                }
248            }
249        }
250        debug!("PTY reader thread exiting");
251    });
252}
253
254/// Real PTY-based interactive shell
255///
256/// Uses portable-pty for true pseudo-terminal functionality,
257/// enabling proper handling of interactive programs like sudo, vim, etc.
258pub struct PtyShell {
259    /// The PTY master handle for resize operations
260    master: Box<dyn MasterPty + Send>,
261    /// Child process running the shell
262    child: Box<dyn Child + Send + Sync>,
263    /// Writer for PTY input (taken from master)
264    writer: Box<dyn Write + Send>,
265    /// Channel receiver for output from reader thread
266    output_rx: mpsc::Receiver<String>,
267    /// Current terminal size
268    size: PtySize,
269    /// Last command executed
270    pub last_command: String,
271    /// Accumulated output buffer
272    pub output_buffer: String,
273    /// Whether a command is currently running
274    pub command_running: bool,
275    /// Last cwd observed in this shell's nonce-bearing prompt.
276    current_cwd: PathBuf,
277    /// Monotonic start time of the current or most recently completed command.
278    /// Status polling uses this instead of restarting the clock per tool call.
279    command_started_at: Option<Instant>,
280    /// Monotonic command generation used by daemon journals to distinguish two
281    /// consecutive commands whose terminal bytes happen to be identical.
282    command_generation: u64,
283    /// Random identity of this PTY instance. Unlike the numeric generation it
284    /// changes when a shell is reset and generations start again at zero.
285    incarnation: u64,
286    /// Exit code of the last completed foreground command, parsed from the prompt
287    /// marker (`──➤<nonce>:<code>`). `None` until a command finishes, and reset
288    /// to `None` while one is running.
289    pub last_exit_code: Option<i32>,
290    /// Maximum output size before truncation
291    max_output_size: usize,
292    /// Flag for output truncation
293    pub output_truncated: bool,
294    /// Bounded scrollback ring of raw PTY lines (newest at the back) plus the
295    /// in-flight partial line, with drop accounting so a truncated scrollback is
296    /// reported to the caller instead of silently shrinking. See `LineRing`.
297    ring: LineRing,
298    /// Hash of the last rendered output we shipped to the caller. Used by the
299    /// delta path in `status_check` to elide repeats when the screen is idle.
300    pub last_returned_hash: Option<u64>,
301    /// Cumulative PTY snapshot already delivered to the caller. Incremental
302    /// status polling diffs against this value, then advances it after rendering.
303    delivered_output: String,
304    /// True from a foreground command's start until a response has reported
305    /// its exit. The prompt can be drained by another reader (the daemon
306    /// journal) between two status polls; the next poll then finds the shell
307    /// idle and must still deliver the final output and exit code instead of
308    /// rejecting the call as if nothing had run.
309    pending_completion_report: bool,
310    /// Optional command a human can run to attach to the same terminal session.
311    pub attach_hint: Option<String>,
312    /// The exact suffix that ends this shell's prompt: `──➤<nonce>`. The nonce is
313    /// a per-shell random value embedded in `PROMPT_COMMAND`, so command output
314    /// can't impersonate the prompt (printing `◉ x──➤` no longer ends a command
315    /// early). Anchoring completion on this instead of the bare glyphs removes
316    /// the false-positive that truncated output when a program echoed them.
317    prompt_end_marker: String,
318    /// Live terminal emulator (fixed-viewport, `vt100`-backed) fed continuously
319    /// by the reader thread. Unlike the scrollback ring (raw lines), it keeps a
320    /// consolidated screen grid — cursor moves, redraws, alternate-screen
321    /// applied — so a TUI yields a stable, non-stacked snapshot. The fixed
322    /// viewport is what lets inline-rendering TUIs (the `agy`/Antigravity CLI,
323    /// which redraws with cursor-up + erase-line in the main screen) consolidate
324    /// instead of ghosting. See `live_snapshot`.
325    live: Arc<StdMutex<LiveTerminal>>,
326    /// Workspace root under which output-offload scratch files are written.
327    /// `None` until a caller sets it (the workspace is only known after
328    /// Initialize, and `execute_command` sets it per command). See
329    /// `crate::utils::scratch_file`.
330    scratch_workspace_root: Option<PathBuf>,
331    /// Scratch file holding the CURRENT command's dropped output head, created
332    /// lazily on the first truncation and reset per command.
333    scratch_path: Option<PathBuf>,
334    /// Bytes already streamed to `scratch_path`, used to enforce `SCRATCH_MAX_BYTES`.
335    scratch_bytes: u64,
336    /// Shared with the reader thread: when set, the current command's output
337    /// is pure discard (scratch budget exhausted), so the reader inserts
338    /// [`DISCARD_READ_THROTTLE`] between reads and lets the kernel PTY buffer
339    /// back-pressure the producer. Cleared on every new command.
340    discard_throttle: Arc<AtomicBool>,
341}
342
343impl std::fmt::Debug for PtyShell {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        f.debug_struct("PtyShell")
346            .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
347            .field("last_command", &self.last_command)
348            .field("command_running", &self.command_running)
349            .field("current_cwd", &self.current_cwd)
350            .field("output_truncated", &self.output_truncated)
351            .field("output_buffer_len", &self.output_buffer.len())
352            .field("attach_hint", &self.attach_hint)
353            .finish_non_exhaustive()
354    }
355}
356
357/// Resolve the process group to signal for a PTY child, but ONLY when it is
358/// safe: the pid must be a real child (`> 1`) that leads its own group
359/// (`getpgid(pid) == pid`). portable-pty `setsid`s the shell, so a live PTY
360/// child is always its own group leader; if that ever stopped holding,
361/// `kill(-pgid, ...)` would hit some unrelated group, so we refuse instead of
362/// guessing.
363///
364/// The `pid > 1` check is load-bearing: `kill(-1, ...)` means "every process I
365/// may signal" and `kill(0, ...)` means "my own group" - either would be
366/// catastrophic from a teardown path.
367#[cfg(unix)]
368fn killable_group(pid: u32) -> Option<i32> {
369    crate::os::unix::owned_process_group(pid)
370}
371
372/// Send `signal` to the whole process group `pgid` (group-kill via negative
373/// pid). `pgid` must be one vetted by [`killable_group`].
374#[cfg(unix)]
375fn signal_group(pgid: i32, signal: i32) {
376    let _ = crate::os::unix::signal_group(pgid, signal);
377}
378
379impl Drop for PtyShell {
380    /// Kill and reap the shell child **and its whole process group** so neither
381    /// the shell nor anything it spawned leaks.
382    ///
383    /// `std::process::Child::drop` neither kills nor waits, and even
384    /// `Child::kill` signals only the shell itself: a background job it started
385    /// (`npm run dev &`, a `nohup`'d server) would be reparented to init and live
386    /// on. Because portable-pty runs the shell as a session/group leader, we can
387    /// signal the whole group with a negative pid - SIGTERM first so well-behaved
388    /// children can clean up, then `Child::kill` (SIGKILL on the leader, which
389    /// closes the PTY slave so the reader thread's `read()` returns EOF and the
390    /// thread exits), then SIGKILL the group to sweep anything still alive, then
391    /// reap. The pgid is captured while the leader is alive and reused: it stays
392    /// valid until we `wait()`, even after the leader dies. All best-effort.
393    fn drop(&mut self) {
394        // Only group-kill the direct bash/zsh path. Under screen/tmux (`attach_hint`
395        // is `Some`) the `Child` is a multiplexer *client*; the real shell runs in
396        // a detached daemon in another process group, so a group-kill here would
397        // miss the shell entirely and could disturb unrelated sessions of the same
398        // multiplexer. Let the multiplexer own its lifecycle in that case.
399        #[cfg(unix)]
400        let pgid = if self.attach_hint.is_some() {
401            None
402        } else {
403            self.child.process_id().and_then(killable_group)
404        };
405
406        #[cfg(unix)]
407        if let Some(pgid) = pgid {
408            signal_group(pgid, libc::SIGTERM);
409        }
410
411        // SIGKILL the leader and close the PTY slave (unblocks the reader thread).
412        let _ = self.child.kill();
413
414        #[cfg(unix)]
415        if let Some(pgid) = pgid {
416            signal_group(pgid, libc::SIGKILL);
417        }
418
419        let _ = self.child.wait();
420    }
421}
422
423impl PtyShell {
424    /// Create a new PTY shell session
425    ///
426    /// # Arguments
427    /// * `initial_dir` - Starting directory for the shell
428    /// * `restricted_mode` - Whether to use bash restricted mode (-r)
429    ///
430    /// # Returns
431    /// A new `PtyShell` instance with an active bash session
432    pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
433        Self::new_with_agent_paths(initial_dir, restricted_mode, None, None)
434    }
435
436    /// Create a PTY with Winx's stable workspace and managed helper paths in
437    /// the child environment. Direct callers keep using [`Self::new`].
438    pub(crate) fn new_with_agent_paths(
439        initial_dir: &Path,
440        restricted_mode: bool,
441        workspace_root: Option<&Path>,
442        temporary_artifact_dir: Option<&Path>,
443    ) -> Result<Self> {
444        info!(
445            "Creating new PTY shell (restricted: {}) in {}",
446            restricted_mode,
447            initial_dir.display()
448        );
449
450        // Initialize the native PTY system
451        let pty_system = native_pty_system();
452
453        // Configure terminal size
454        let size =
455            PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
456
457        // Open the PTY pair (master + slave)
458        let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
459
460        // Build the command
461        let (mut cmd, attach_hint, is_zsh) = attachable_command(restricted_mode);
462
463        // Set up environment for proper terminal behavior
464        cmd.env("TERM", "xterm-256color");
465        cmd.env("COLORTERM", "truecolor");
466        cmd.env("PAGER", "cat");
467        cmd.env("GIT_PAGER", "cat");
468        cmd.env("COLUMNS", DEFAULT_COLS.to_string());
469        cmd.env("ROWS", DEFAULT_ROWS.to_string());
470        if let Some(workspace_root) = workspace_root {
471            cmd.env("WINX_WORKSPACE_ROOT", workspace_root);
472        }
473        if let Some(temporary_artifact_dir) = temporary_artifact_dir {
474            cmd.env("WINX_TEMP_DIR", temporary_artifact_dir);
475        }
476        // Per-shell random nonce embedded in the prompt so command output can't
477        // forge the completion marker. 64 bits of entropy in hex.
478        let nonce = format!("{:016x}", rand::random::<u64>());
479        // WCGW-style prompt for command completion detection
480        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
481        // `__winx_ec=$?` captures the user command's exit status as the very
482        // first thing PROMPT_COMMAND does, then we print it after the marker.
483        cmd.env(
484            "PROMPT_COMMAND",
485            format!(r#"__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec""#),
486        );
487        cmd.cwd(initial_dir);
488
489        // Spawn bash in the PTY slave
490        let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
491
492        // Get reader and writer from master
493        let reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
494        let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
495
496        // Bounded channel: if the consumer stalls, the reader thread blocks on
497        // send (natural backpressure via the PTY OS buffer) instead of growing
498        // memory without bound on a runaway producer like `yes`.
499        let (output_tx, output_rx) = mpsc::sync_channel::<String>(1024);
500
501        // Live terminal emulator, shared with the reader thread so the screen
502        // grid stays current without any consumer needing to poll.
503        let live = Arc::new(StdMutex::new(LiveTerminal::new(DEFAULT_ROWS, DEFAULT_COLS)));
504        let live_reader = Arc::clone(&live);
505        let discard_throttle = Arc::new(AtomicBool::new(false));
506        let throttle_reader = Arc::clone(&discard_throttle);
507
508        spawn_pty_reader(reader, output_tx, live_reader, throttle_reader);
509
510        // Create the shell instance
511        let mut shell = Self {
512            master: pair.master,
513            child,
514            writer,
515            output_rx,
516            size,
517            last_command: String::new(),
518            output_buffer: String::new(),
519            command_running: false,
520            current_cwd: initial_dir.to_path_buf(),
521            command_started_at: None,
522            command_generation: 0,
523            incarnation: rand::random::<u64>().max(1),
524            last_exit_code: None,
525            max_output_size: MAX_OUTPUT_SIZE,
526            output_truncated: false,
527            ring: LineRing::new(RING_BUFFER_LINES, MAX_PARTIAL_LINE_BYTES),
528            last_returned_hash: None,
529            delivered_output: String::new(),
530            pending_completion_report: false,
531            attach_hint,
532            prompt_end_marker: format!("{WCGW_PROMPT_END}{nonce}"),
533            live,
534            scratch_workspace_root: None,
535            scratch_path: None,
536            scratch_bytes: 0,
537            discard_throttle,
538        };
539
540        // Initialize the shell with WCGW-style prompt
541        shell.initialize_prompt(is_zsh, &nonce)?;
542
543        debug!("PTY shell created successfully");
544        Ok(shell)
545    }
546
547    /// Initialize the shell prompt for WCGW compatibility
548    fn initialize_prompt(&mut self, is_zsh: bool, nonce: &str) -> Result<()> {
549        // Set up the dynamic prompt - matches WCGW Python PROMPT_STATEMENT.
550        // zsh ignores PROMPT_COMMAND, so it gets a precmd hook with a blanked
551        // default prompt instead.
552        // Blank the shell's own PS1/PROMPT so the line ends exactly at our `──➤`
553        // marker. Otherwise a user's ~/.bashrc/~/.zshrc PS1 (e.g. `[user@host]$`)
554        // is appended after the marker, and prompt detection — which anchors on a
555        // trailing `──➤` — only fires when the chunk happens to fragment right
556        // before the PS1, making command-completion detection flaky.
557        let prompt_statement = if is_zsh {
558            // Clear precmd_functions too: frameworks (oh-my-zsh/p10k) register the
559            // prompt via that array, so redefining `precmd` alone leaves their
560            // prompt in place and our `──➤` marker never ends the line.
561            format!(
562                r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() {{ local __winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec" }}"#
563            )
564        } else {
565            format!(
566                r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec"'; PS1=''"#
567            )
568        };
569
570        self.write_command(&prompt_statement)?;
571
572        // Wait for prompt to be ready
573        std::thread::sleep(Duration::from_millis(100));
574        let _ = self.drain_output();
575
576        Ok(())
577    }
578
579    /// Write a command to the PTY, submitting it with a carriage return.
580    fn write_command(&mut self, command: &str) -> Result<()> {
581        // Submit with `\r`, matching the foreground path (`send_special_key("Enter")`
582        // also sends `\r`) and a real terminal's Enter key. The two paths used to
583        // disagree — background sent `\n` here, foreground sent `\r` — which is a
584        // real difference for readline-based TUIs. bash's canonical line discipline
585        // maps CR to NL (ICRNL), so plain commands behave identically.
586        let cmd_with_newline = format!("{command}\r");
587        self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
588        self.writer.flush().context("Failed to flush PTY")?;
589        Ok(())
590    }
591
592    /// Drain any pending output from the PTY channel
593    fn drain_output(&mut self) -> String {
594        let mut output = String::new();
595        let deadline = Instant::now() + Duration::from_millis(200);
596
597        // Drain all available output from the channel
598        while Instant::now() < deadline {
599            match self.output_rx.try_recv() {
600                Ok(chunk) => {
601                    output.push_str(&chunk);
602
603                    // Prevent runaway reads
604                    if output.len() > self.max_output_size {
605                        self.output_truncated = true;
606                        break;
607                    }
608                }
609                Err(TryRecvError::Empty) => {
610                    // No more data, wait briefly for more
611                    thread::sleep(Duration::from_millis(10));
612                }
613                Err(TryRecvError::Disconnected) => {
614                    // Reader thread died
615                    break;
616                }
617            }
618        }
619
620        output
621    }
622
623    /// Send a command to the shell and start reading output
624    pub fn send_command(&mut self, command: &str) -> Result<()> {
625        debug!(bytes = command.len(), "PTY sending command");
626
627        // Clear previous state
628        self.output_buffer.clear();
629        self.output_truncated = false;
630        self.reset_scratch();
631        self.last_command = command.to_string();
632        self.command_running = true;
633        self.mark_command_started();
634        self.last_exit_code = None;
635        // A new command means the next status_check should return whatever
636        // shows up — drop the dedup hash so we don't elide the first response.
637        self.last_returned_hash = None;
638        self.delivered_output.clear();
639        // Drop retained OSC title/progress so the previous foreground
640        // program's last title can't masquerade as this command's turn state.
641        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear_osc();
642
643        // Write the command
644        self.write_command(command)?;
645
646        Ok(())
647    }
648
649    /// Start the monotonic runtime clock for a newly submitted command.
650    pub fn mark_command_started(&mut self) {
651        self.command_generation = self.command_generation.saturating_add(1);
652        self.command_started_at = Some(Instant::now());
653        self.pending_completion_report = true;
654    }
655
656    /// Whether the most recent foreground command has finished without any
657    /// response having reported its exit yet.
658    pub fn completion_report_pending(&self) -> bool {
659        !self.command_running && self.pending_completion_report
660    }
661
662    /// Record that a response reported the current command as finished.
663    pub fn mark_completion_reported(&mut self) {
664        self.pending_completion_report = false;
665    }
666
667    pub fn command_generation(&self) -> u64 {
668        self.command_generation
669    }
670
671    pub fn incarnation(&self) -> u64 {
672        self.incarnation
673    }
674
675    /// Elapsed runtime since the current command was submitted.
676    pub fn command_elapsed(&self) -> Option<Duration> {
677        self.command_started_at.map(|started_at| started_at.elapsed())
678    }
679
680    /// Current working directory last reported by this shell's prompt.
681    pub fn current_cwd(&self) -> &Path {
682        &self.current_cwd
683    }
684
685    /// Snapshot used as the left-hand side of incremental output rendering.
686    pub fn delivered_output(&self) -> String {
687        self.delivered_output.clone()
688    }
689
690    /// Mark the cumulative output snapshot as delivered after rendering it.
691    pub fn mark_output_delivered(&mut self, output: &str) {
692        self.delivered_output.clear();
693        self.delivered_output.push_str(output);
694    }
695
696    /// Return up to `lines` recent lines from the scrollback ring, oldest
697    /// first. When older lines were evicted or a long unterminated line was
698    /// clipped, a `[winx: …]` notice is prepended so the caller knows the
699    /// scrollback is incomplete rather than silently receiving less than asked.
700    pub fn collect_scrollback(&self, lines: usize) -> String {
701        // The ring holds raw PTY lines. Replay them through the terminal
702        // emulator so cursor movements (readline echo, in-place redraws) are
703        // *applied* — not merely stripped — yielding what the screen actually
704        // showed. A plain strip can't undo a `\x1b[D`, so it would leave
705        // `>>> p>>> pr>>> pri...` echo noise behind; the emulator collapses it.
706        let rendered =
707            crate::state::terminal::render_terminal_output(&self.ring.raw(lines)).join("\n");
708        match self.ring.scrollback_notice(lines) {
709            Some(notice) => format!("{notice}\n{rendered}"),
710            None => rendered,
711        }
712    }
713
714    /// Hash arbitrary rendered output into a u64 dedup key.
715    pub fn fingerprint(text: &str) -> u64 {
716        let mut hasher = DefaultHasher::new();
717        text.hash(&mut hasher);
718        hasher.finish()
719    }
720
721    /// Read output from the PTY with timeout
722    ///
723    /// Returns (output, `is_complete`) tuple where `is_complete` indicates
724    /// whether the command has finished (prompt detected)
725    pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
726        let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
727        let start = Instant::now();
728        let mut complete = false;
729        let mut no_data_count = 0;
730        let mut prompt_detected_at: Option<Instant> = None;
731
732        while start.elapsed() < timeout {
733            match self.output_rx.try_recv() {
734                Ok(chunk) => {
735                    self.output_buffer.push_str(&chunk);
736                    self.ring.push_chunk(&chunk);
737                    no_data_count = 0;
738
739                    // Check for WCGW prompt indicating command completion
740                    if prompt_detected_at.is_none()
741                        && (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
742                            || Self::check_prompt_complete(
743                                &self.output_buffer,
744                                &self.prompt_end_marker,
745                            ))
746                    {
747                        prompt_detected_at = Some(Instant::now());
748                        debug!("Prompt detected, draining remaining output...");
749                    }
750
751                    // Truncate if too large (offloads the dropped head to scratch).
752                    self.truncate_output_buffer_with_offload();
753                }
754                Err(TryRecvError::Empty) => {
755                    // No data available, wait briefly
756                    thread::sleep(Duration::from_millis(10));
757                    no_data_count += 1;
758
759                    // If prompt was detected, check if we've drained long enough
760                    if let Some(detected_time) = prompt_detected_at {
761                        // Wait 100ms after prompt detection to capture any trailing output
762                        if detected_time.elapsed() > Duration::from_millis(100) {
763                            complete = true;
764                            debug!("Command completed - prompt detected and drained");
765                            break;
766                        }
767                    } else if no_data_count > 10
768                        && Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
769                    {
770                        // Prompt detected during empty reads
771                        prompt_detected_at = Some(Instant::now());
772                        debug!("Prompt detected after wait, draining...");
773                    }
774                }
775                Err(TryRecvError::Disconnected) => {
776                    // Reader thread died - PTY closed
777                    warn!("PTY reader disconnected");
778                    complete = true;
779                    break;
780                }
781            }
782        }
783
784        if complete || prompt_detected_at.is_some() {
785            self.command_running = false;
786            self.last_exit_code =
787                Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
788            self.update_current_cwd_from_prompt();
789            complete = true;
790        }
791
792        Ok((self.output_buffer.clone(), complete))
793    }
794
795    /// Non-blocking drain of whatever the reader thread has already queued.
796    ///
797    /// Unlike [`read_output`](Self::read_output), this never sleeps: it consumes
798    /// every chunk currently available, updates prompt/completion state, and
799    /// returns immediately. `prune_finished_shells` uses it so the global
800    /// background-shell lock is never held across a blocking 100ms `read_output`.
801    /// Returns `true` if the command has completed (prompt seen or PTY closed).
802    pub fn poll_output_nonblocking(&mut self) -> bool {
803        let mut prompt_seen = false;
804        loop {
805            match self.output_rx.try_recv() {
806                Ok(chunk) => {
807                    let chunk_has_prompt =
808                        Self::check_prompt_complete(&chunk, &self.prompt_end_marker);
809                    self.output_buffer.push_str(&chunk);
810                    self.ring.push_chunk(&chunk);
811                    if chunk_has_prompt
812                        || Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
813                    {
814                        prompt_seen = true;
815                    }
816                    self.truncate_output_buffer_with_offload();
817                }
818                Err(TryRecvError::Empty) => break,
819                Err(TryRecvError::Disconnected) => {
820                    self.command_running = false;
821                    return true;
822                }
823            }
824        }
825        if prompt_seen {
826            self.command_running = false;
827            self.last_exit_code =
828                Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
829            self.update_current_cwd_from_prompt();
830        }
831        // Completion is state, not an edge. Another consumer (notably the
832        // background reaper) may have drained the prompt first; a later poll
833        // must still report the already-idle shell as complete even when this
834        // particular drain saw no new prompt bytes.
835        !self.command_running
836    }
837
838    fn update_current_cwd_from_prompt(&mut self) {
839        let Some(last) =
840            self.output_buffer.lines().rev().find(|line| line.contains(&self.prompt_end_marker))
841        else {
842            return;
843        };
844        let clean = crate::state::terminal::strip_ansi_codes(last);
845        let Some((before_marker, _)) = clean.rsplit_once(&self.prompt_end_marker) else {
846            return;
847        };
848        let Some((_, cwd)) = before_marker.rsplit_once("◉ ") else {
849            return;
850        };
851        let cwd = cwd.trim();
852        if !cwd.is_empty() {
853            self.current_cwd = PathBuf::from(cwd);
854        }
855    }
856
857    /// Snapshot of the accumulated output buffer — the same text `read_output`
858    /// returns. Lets callers poll non-blocking (`poll_output_nonblocking`) and
859    /// grab the buffer once at the end instead of cloning it on every slice.
860    pub fn output_snapshot(&self) -> String {
861        self.output_buffer.clone()
862    }
863
864    /// Check if the output ends with this shell's prompt.
865    ///
866    /// `prompt_end` is the per-shell `──➤<nonce>` suffix. Anchoring on the LAST
867    /// non-empty line (not a global `contains`) plus the random nonce means
868    /// command output that happens to print the prompt glyphs mid-stream — or
869    /// even a forged `◉ x──➤ ` at end of line — can't be mistaken for the prompt
870    /// and truncate output or end the command early.
871    fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
872        Self::parse_prompt_exit_code(text, prompt_end).is_some()
873    }
874
875    /// If the last non-empty line is this shell's completion prompt
876    /// (`◉ <pwd>──➤<nonce>:<code>`), return the exit code that follows the
877    /// marker. Returns `None` when the prompt isn't present — which is exactly
878    /// "the command is still running". The per-shell nonce plus the structured
879    /// `:<digits>` suffix keep command output from forging completion.
880    fn parse_prompt_exit_code(text: &str, prompt_end: &str) -> Option<i32> {
881        let last = text.lines().rev().find(|line| !line.trim().is_empty())?;
882        // Strip ANSI so a trailing erase/cursor sequence after the code
883        // (e.g. ":0 \x1b[K") doesn't defeat the suffix check.
884        let clean = crate::state::terminal::strip_ansi_codes(last);
885        let clean = clean.trim_end();
886        if !clean.contains(WCGW_PROMPT_PATTERN) {
887            return None;
888        }
889        // Everything after the last "──➤<nonce>" must be ":<digits>" and nothing
890        // else — this confirms the prompt ends the line AND yields the code.
891        let after = clean.rsplit_once(prompt_end)?.1;
892        after.strip_prefix(':')?.trim().parse::<i32>().ok()
893    }
894
895    /// Point output-offload scratch files at `root` (the workspace). Idempotent;
896    /// callers set it per command so it always tracks the current workspace.
897    pub fn set_scratch_root(&mut self, root: &Path) {
898        self.scratch_workspace_root = Some(root.to_path_buf());
899    }
900
901    /// The scratch file holding the current command's dropped output head, if any
902    /// was offloaded this command.
903    pub fn scratch_path(&self) -> Option<&Path> {
904        self.scratch_path.as_deref()
905    }
906
907    /// Forget the current command's scratch file so the next command starts a
908    /// fresh one. Does not delete the file (pruned later by age).
909    pub fn reset_scratch(&mut self) {
910        self.scratch_path = None;
911        self.scratch_bytes = 0;
912        self.discard_throttle.store(false, AtomicOrdering::Relaxed);
913    }
914
915    /// Append a just-dropped output `head` to the per-command scratch file so the
916    /// agent can recover it via `ReadFiles`. Best-effort: needs a known workspace
917    /// root, stops at `SCRATCH_MAX_BYTES`, and swallows IO errors.
918    fn offload_dropped_head(&mut self, head: &str) {
919        let Some(root) = self.scratch_workspace_root.clone() else {
920            return;
921        };
922        // Clamp to the remaining byte budget so the file honors SCRATCH_MAX_BYTES
923        // exactly (a single head can be up to keep_size, ~500 KB). Snap to a char
924        // boundary so we never slice mid-UTF-8.
925        let remaining = SCRATCH_MAX_BYTES.saturating_sub(self.scratch_bytes);
926        if remaining == 0 {
927            // From here on the head is discarded outright — reading faster
928            // only burns CPU. Engage reader back-pressure until the next
929            // command resets the scratch state.
930            self.discard_throttle.store(true, AtomicOrdering::Relaxed);
931            return;
932        }
933        let head = match usize::try_from(remaining) {
934            Ok(rem) if rem < head.len() => &head[..crate::utils::floor_char_boundary(head, rem)],
935            _ => head,
936        };
937        if self.scratch_path.is_none() {
938            self.scratch_path = crate::utils::scratch_file::new_scratch_path(&root);
939        }
940        let Some(path) = self.scratch_path.clone() else {
941            return;
942        };
943        match crate::utils::scratch_file::append_scratch(&path, head.as_bytes()) {
944            Ok(0) if self.scratch_bytes == 0 => self.scratch_path = None,
945            Ok(written) => {
946                self.scratch_bytes = self.scratch_bytes.saturating_add(written as u64);
947                if self.scratch_bytes >= SCRATCH_MAX_BYTES {
948                    self.discard_throttle.store(true, AtomicOrdering::Relaxed);
949                }
950            }
951            Err(e) => debug!("scratch: append to {} failed: {e}", path.display()),
952        }
953    }
954
955    /// Once `output_buffer` exceeds the cap, offload the head about to be dropped
956    /// to the scratch file, then keep only the tail with a truncation marker.
957    /// Shared by the blocking and non-blocking drain paths so the offload and the
958    /// char-boundary handling live in one place.
959    fn truncate_output_buffer_with_offload(&mut self) {
960        if self.output_buffer.len() <= self.max_output_size {
961            return;
962        }
963        self.output_truncated = true;
964        let keep_size = self.max_output_size / 2;
965        // Snap to a char boundary: a raw byte offset can land mid-UTF-8
966        // (CJK/emoji/box-drawing/the prompt glyphs) and slicing there would panic.
967        let cut = crate::utils::floor_char_boundary(
968            &self.output_buffer,
969            self.output_buffer.len() - keep_size,
970        );
971        let head = self.output_buffer[..cut].to_owned();
972        self.offload_dropped_head(&head);
973        // Replace the dropped head in place instead of `format!`-ing a brand-new
974        // String: `replace_range` shifts the kept tail down once and reuses the
975        // buffer's allocation. `cut` is already a char boundary (above), and 0 is
976        // always one, so the range is valid.
977        self.output_buffer.replace_range(..cut, "\n(...output truncated...)\n");
978    }
979
980    /// Send Ctrl+C (interrupt) to the PTY
981    pub fn send_interrupt(&mut self) -> Result<()> {
982        debug!("PTY sending Ctrl+C");
983        self.writer
984            .write_all(&[0x03]) // ASCII ETX (Ctrl+C)
985            .context("Failed to send Ctrl+C")?;
986        self.writer.flush()?;
987        Ok(())
988    }
989
990    /// Send Ctrl+D (EOF) to the PTY
991    pub fn send_eof(&mut self) -> Result<()> {
992        debug!("PTY sending Ctrl+D");
993        self.writer
994            .write_all(&[0x04]) // ASCII EOT (Ctrl+D)
995            .context("Failed to send Ctrl+D")?;
996        self.writer.flush()?;
997        Ok(())
998    }
999
1000    /// Send Ctrl+Z (suspend) to the PTY
1001    pub fn send_suspend(&mut self) -> Result<()> {
1002        debug!("PTY sending Ctrl+Z");
1003        self.writer
1004            .write_all(&[0x1A]) // ASCII SUB (Ctrl+Z)
1005            .context("Failed to send Ctrl+Z")?;
1006        self.writer.flush()?;
1007        Ok(())
1008    }
1009
1010    /// Send text directly to the PTY (for interactive input)
1011    pub fn send_text(&mut self, text: &str) -> Result<()> {
1012        debug!(bytes = text.len(), "PTY sending text");
1013        self.send_bytes(text.as_bytes()).context("Failed to send text")?;
1014        Ok(())
1015    }
1016
1017    /// Send raw bytes directly to the PTY.
1018    pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
1019        self.writer.write_all(bytes).context("Failed to send bytes")?;
1020        self.writer.flush()?;
1021        Ok(())
1022    }
1023
1024    /// Send a special key sequence
1025    pub fn send_special_key(&mut self, key: &str) -> Result<()> {
1026        let bytes: &[u8] = match key {
1027            "Enter" => b"\r",
1028            "Tab" => b"\t",
1029            "Backspace" => b"\x7F",
1030            "Escape" => b"\x1B",
1031            "Up" | "KeyUp" => b"\x1B[A",
1032            "Down" | "KeyDown" => b"\x1B[B",
1033            "Right" | "KeyRight" => b"\x1B[C",
1034            "Left" | "KeyLeft" => b"\x1B[D",
1035            "Home" => b"\x1B[H",
1036            "End" => b"\x1B[F",
1037            "PageUp" => b"\x1B[5~",
1038            "PageDown" => b"\x1B[6~",
1039            "Delete" => b"\x1B[3~",
1040            "Insert" => b"\x1B[2~",
1041            "CtrlC" | "Ctrl-C" => b"\x03",
1042            "CtrlD" | "Ctrl-D" => b"\x04",
1043            "CtrlZ" | "Ctrl-Z" => b"\x1A",
1044            "CtrlL" | "Ctrl-L" => b"\x0C",
1045            _ => return Err(anyhow!("Unknown special key: {key}")),
1046        };
1047
1048        debug!("PTY sending special key: {} ({:?})", key, bytes);
1049        self.send_bytes(bytes)?;
1050        Ok(())
1051    }
1052
1053    /// Snapshot the live terminal screen — a stable, consolidated view of what a
1054    /// human would currently see, with cursor moves/redraws applied and ANSI
1055    /// stripped. `max_lines` of 0 returns the full screen buffer. This is the
1056    /// foundation for piloting interactive TUIs (the `claude` CLI, vim, ...).
1057    pub fn live_snapshot(&self, max_lines: usize) -> Vec<String> {
1058        // Recover from a poisoned lock instead of returning a blank screen
1059        // forever — a single panic while holding `live` would otherwise wedge
1060        // every future snapshot (and `wait_for_turn`/`screen` with it).
1061        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).snapshot(max_lines)
1062    }
1063
1064    /// Whether the live terminal is currently on the alternate screen buffer
1065    /// (a full-screen app like vim/htop/less is running).
1066    pub fn live_in_alt_screen(&self) -> bool {
1067        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).in_alt_screen()
1068    }
1069
1070    /// The latest OSC 0/2 window title emitted by the foreground program (`""`
1071    /// when none) — an out-of-band turn signal for the manifest recognizers
1072    /// (claude/codex render a spinner glyph in the title while generating).
1073    pub fn live_osc_title(&self) -> String {
1074        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).osc_title().to_string()
1075    }
1076
1077    /// The latest OSC 9 payload after `9;` (`""` when none) — ConEmu-style
1078    /// progress reads `4;<state>;<percent>`.
1079    pub fn live_osc_progress(&self) -> String {
1080        self.live
1081            .lock()
1082            .unwrap_or_else(std::sync::PoisonError::into_inner)
1083            .osc_progress()
1084            .to_string()
1085    }
1086
1087    /// Cursor position `(row, col)` on the live screen (0-based), so a piloting
1088    /// agent knows where focus is in a menu/form. `(0, 0)` if the lock is poisoned.
1089    pub fn live_cursor_position(&self) -> (u16, u16) {
1090        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_position()
1091    }
1092
1093    /// Diff the live screen against what the client last saw, returning only the
1094    /// changed lines when the change is small (huge token savings over many
1095    /// polls). Updates the baseline as a side effect. See [`ScreenUpdate`].
1096    pub fn live_snapshot_diff(&self, max_lines: usize, threshold: usize) -> ScreenUpdate {
1097        self.live
1098            .lock()
1099            .unwrap_or_else(std::sync::PoisonError::into_inner)
1100            .snapshot_diff(max_lines, threshold)
1101    }
1102
1103    /// Resize the terminal
1104    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
1105        debug!("PTY resizing to {}x{}", cols, rows);
1106
1107        let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
1108
1109        self.master.resize(new_size).context("Failed to resize PTY")?;
1110
1111        // Keep the live emulator's viewport in lockstep with the PTY size.
1112        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).resize(rows, cols);
1113
1114        self.size = new_size;
1115        Ok(())
1116    }
1117
1118    /// Get current terminal size
1119    pub fn get_size(&self) -> (u16, u16) {
1120        (self.size.cols, self.size.rows)
1121    }
1122
1123    /// OS process id of the shell that owns this PTY.
1124    pub fn process_id(&self) -> Option<u32> {
1125        self.child.process_id()
1126    }
1127
1128    /// Check if the shell is still alive
1129    pub fn is_alive(&mut self) -> bool {
1130        self.child.try_wait().is_ok_and(|status| status.is_none())
1131    }
1132}
1133
1134/// Thread-safe wrapper for `PtyShell`
1135pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
1136
1137/// Create a new shared PTY shell
1138pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
1139    let shell = PtyShell::new(initial_dir, restricted_mode)?;
1140    Ok(Arc::new(Mutex::new(Some(shell))))
1141}
1142
1143/// Split a byte slice into the longest valid-UTF-8 prefix (decoded) and the
1144/// trailing bytes of an incomplete final char (to carry into the next read).
1145/// Genuinely invalid sequences become a single U+FFFD and are consumed, so the
1146/// carry only ever holds a recoverable incomplete tail and never grows unbounded.
1147///
1148/// Iterative on purpose: a recursive version blows the reader thread's stack on
1149/// a burst of invalid bytes (e.g. `cat`-ing a binary), which aborts under
1150/// `panic = "abort"`. This stays O(1) in stack depth regardless of input.
1151fn decode_keep_incomplete(bytes: &[u8]) -> (String, Vec<u8>) {
1152    let mut decoded = String::new();
1153    let mut rest = bytes;
1154    loop {
1155        match std::str::from_utf8(rest) {
1156            Ok(text) => {
1157                decoded.push_str(text);
1158                return (decoded, Vec::new());
1159            }
1160            Err(error) => {
1161                let valid = error.valid_up_to();
1162                // `rest[..valid]` is valid UTF-8 by construction (no allocation
1163                // for the borrowed case).
1164                decoded.push_str(&String::from_utf8_lossy(&rest[..valid]));
1165                match error.error_len() {
1166                    // Incomplete final char: keep the tail for the next read.
1167                    None => return (decoded, rest[valid..].to_vec()),
1168                    // Genuinely invalid run: emit one replacement and skip past it.
1169                    Some(bad) => {
1170                        decoded.push('\u{FFFD}');
1171                        rest = &rest[valid + bad..];
1172                    }
1173                }
1174            }
1175        }
1176    }
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181    use super::*;
1182    use tempfile::TempDir;
1183
1184    #[test]
1185    fn decode_passes_clean_utf8_through() {
1186        let (text, carry) = decode_keep_incomplete("hello ✓ café".as_bytes());
1187        assert_eq!(text, "hello ✓ café");
1188        assert!(carry.is_empty());
1189    }
1190
1191    #[test]
1192    fn decode_carries_incomplete_trailing_char_across_reads() {
1193        // "é" is 0xC3 0xA9; a read boundary splits it. The first half must NOT
1194        // turn into U+FFFD — it's held and completed on the next read.
1195        let (text1, carry1) = decode_keep_incomplete(&[b'a', 0xC3]);
1196        assert_eq!(text1, "a");
1197        assert_eq!(carry1, vec![0xC3]);
1198
1199        let mut next = carry1;
1200        next.push(0xA9); // continuation byte arrives in the next read
1201        let (text2, carry2) = decode_keep_incomplete(&next);
1202        assert_eq!(text2, "é");
1203        assert!(carry2.is_empty());
1204    }
1205
1206    #[test]
1207    fn decode_replaces_genuinely_invalid_bytes() {
1208        // 0xFF is never valid UTF-8 and is not an incomplete prefix.
1209        let (text, carry) = decode_keep_incomplete(&[b'x', 0xFF, b'y']);
1210        assert_eq!(text, "x\u{FFFD}y");
1211        assert!(carry.is_empty());
1212    }
1213
1214    #[test]
1215    fn decode_handles_large_invalid_burst_without_overflow() {
1216        // Regression: the first (recursive) version blew the stack — one frame
1217        // per invalid byte — and aborted under panic="abort" on a binary dump.
1218        // The iterative version must handle far more than the 4KB reader buffer.
1219        let big = vec![0xFFu8; 200_000];
1220        let (text, carry) = decode_keep_incomplete(&big);
1221        assert!(carry.is_empty());
1222        assert_eq!(text.chars().filter(|&c| c == '\u{FFFD}').count(), 200_000);
1223    }
1224
1225    #[test]
1226    fn screen_session_name_parser_is_strict() {
1227        assert_eq!(super::winx_creator_pid("winx-123-456"), Some(123));
1228        assert_eq!(super::winx_creator_pid("winx-nope-456"), None);
1229        assert_eq!(super::winx_creator_pid("other-123-456"), None);
1230    }
1231
1232    #[cfg(unix)]
1233    #[test]
1234    fn process_probe_recognizes_current_process_and_rejects_reserved_pids() {
1235        assert!(super::process_exists(std::process::id()));
1236        assert!(!super::process_exists(0));
1237        assert!(!super::process_exists(1));
1238    }
1239
1240    #[test]
1241    fn prompt_detection_is_suffix_anchored() {
1242        // Per-shell nonce + exit code after the arrow: `──➤<nonce>:<code>`.
1243        let end = "──➤deadbeefcafe0001";
1244        // real prompt on the last line -> complete
1245        assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001:0 ", end));
1246        // prompt with trailing ANSI erase -> still complete
1247        assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 \u{1b}[K", end));
1248        // the bug: ◉ and ──➤ appear MID-output, last line is normal -> NOT complete
1249        assert!(!PtyShell::check_prompt_complete(
1250            "menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
1251            end
1252        ));
1253        // command echoed after the prompt (not the waiting prompt) -> not complete
1254        assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 ls -la", end));
1255        // no prompt at all
1256        assert!(!PtyShell::check_prompt_complete("just some output\n", end));
1257        // the nonce's payoff: output that forges the bare glyphs but NOT the
1258        // nonce (or omits the exit code) must not be mistaken for the prompt.
1259        assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
1260        assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce:0 ", end));
1261        assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ", end));
1262    }
1263
1264    #[test]
1265    fn parses_exit_code_from_prompt_marker() {
1266        let end = "──➤cafe1234";
1267        assert_eq!(PtyShell::parse_prompt_exit_code("ok\n◉ /x──➤cafe1234:0 ", end), Some(0));
1268        assert_eq!(PtyShell::parse_prompt_exit_code("oops\n◉ /x──➤cafe1234:1 ", end), Some(1));
1269        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:42 ", end), Some(42));
1270        // still running (no completion prompt yet) -> None
1271        assert_eq!(PtyShell::parse_prompt_exit_code("building...", end), None);
1272        // forged with the wrong nonce -> None
1273        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤wrong:0 ", end), None);
1274    }
1275
1276    use proptest::prelude::*;
1277
1278    proptest! {
1279        // Exit-code parsing runs on untrusted program output — arbitrary text
1280        // (and a forgeable marker) must never panic, only ever return None or a
1281        // parsed i32.
1282        #[test]
1283        fn parse_prompt_exit_code_never_panics(
1284            text in prop::collection::vec(any::<char>(), 0..256),
1285            nonce in "[0-9a-f]{0,16}",
1286        ) {
1287            let s: String = text.into_iter().collect();
1288            let end = format!("{WCGW_PROMPT_END}{nonce}");
1289            // Must not panic; any Option<i32> is acceptable.
1290            let _ = PtyShell::parse_prompt_exit_code(&s, &end);
1291        }
1292
1293        // A well-formed marker round-trips the embedded code for any i32.
1294        #[test]
1295        fn parse_prompt_exit_code_round_trips(code in any::<i32>(), nonce in "[0-9a-f]{1,16}") {
1296            let end = format!("{WCGW_PROMPT_END}{nonce}");
1297            let line = format!("output line\n{WCGW_PROMPT_PATTERN} /home/x{end}:{code} ");
1298            prop_assert_eq!(PtyShell::parse_prompt_exit_code(&line, &end), Some(code));
1299        }
1300    }
1301
1302    #[test]
1303    fn test_pty_shell_creation() -> Result<()> {
1304        let temp_dir = TempDir::new()?;
1305        let result = PtyShell::new(temp_dir.path(), false);
1306        assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
1307        Ok(())
1308    }
1309
1310    #[test]
1311    fn discard_throttle_engages_at_scratch_cap_and_resets_per_command() -> Result<()> {
1312        let temp_dir = TempDir::new()?;
1313        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1314        assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1315
1316        // Saturated scratch budget: the next dropped head is pure discard and
1317        // must engage reader back-pressure.
1318        shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1319        shell.scratch_bytes = SCRATCH_MAX_BYTES;
1320        shell.offload_dropped_head("dropped beyond the budget");
1321        assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1322
1323        // A new command resets scratch state and releases the throttle.
1324        shell.send_command("echo done")?;
1325        assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1326        Ok(())
1327    }
1328
1329    #[test]
1330    fn discard_throttle_engages_when_a_write_reaches_the_cap() -> Result<()> {
1331        let temp_dir = TempDir::new()?;
1332        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1333        shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1334        // One byte of budget left: this write saturates the cap exactly.
1335        shell.scratch_bytes = SCRATCH_MAX_BYTES - 1;
1336        shell.offload_dropped_head("xx");
1337        assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1338        Ok(())
1339    }
1340
1341    #[test]
1342    fn test_pty_shell_echo() -> Result<()> {
1343        let temp_dir = TempDir::new()?;
1344        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1345
1346        shell.send_command("echo 'hello pty'")?;
1347        let (output, _complete) = shell.read_output(2.0)?;
1348
1349        assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
1350        Ok(())
1351    }
1352
1353    #[test]
1354    fn agent_paths_are_exported_to_the_pty() -> Result<()> {
1355        let workspace = TempDir::new()?;
1356        let temporary_artifact_dir = workspace.path().join(".winx/tmp/session-test");
1357        let mut shell = PtyShell::new_with_agent_paths(
1358            workspace.path(),
1359            false,
1360            Some(workspace.path()),
1361            Some(&temporary_artifact_dir),
1362        )?;
1363
1364        shell.send_command("printf '%s\\n%s\\n' \"$WINX_WORKSPACE_ROOT\" \"$WINX_TEMP_DIR\"")?;
1365        let (output, _complete) = shell.read_output(2.0)?;
1366
1367        assert!(output.contains(&workspace.path().to_string_lossy().to_string()), "{output}");
1368        assert!(output.contains(&temporary_artifact_dir.to_string_lossy().to_string()), "{output}");
1369        Ok(())
1370    }
1371
1372    #[test]
1373    fn completed_nonblocking_poll_is_level_triggered() -> Result<()> {
1374        let temp_dir = TempDir::new()?;
1375        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1376
1377        shell.send_command("true")?;
1378        let deadline = Instant::now() + Duration::from_secs(2);
1379        while Instant::now() < deadline && !shell.poll_output_nonblocking() {
1380            std::thread::sleep(Duration::from_millis(10));
1381        }
1382
1383        assert!(!shell.command_running, "shell did not complete before the deadline");
1384        assert!(
1385            shell.poll_output_nonblocking(),
1386            "a later consumer must still observe the completed state"
1387        );
1388        Ok(())
1389    }
1390
1391    #[test]
1392    fn test_pty_shell_pwd() -> Result<()> {
1393        let temp_dir = TempDir::new()?;
1394        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1395
1396        // Simply verify shell responds to pwd command
1397        // Use single quotes like echo test for consistency
1398        shell.send_command("pwd && echo 'pwd_done'")?;
1399        let (output, _complete) = shell.read_output(2.0)?;
1400
1401        // Verify the echo marker appears (proves command executed)
1402        assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
1403        Ok(())
1404    }
1405
1406    #[test]
1407    fn test_pty_resize() -> Result<()> {
1408        let temp_dir = TempDir::new()?;
1409        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1410
1411        let result = shell.resize(120, 40);
1412        assert!(result.is_ok());
1413
1414        let (cols, rows) = shell.get_size();
1415        assert_eq!(cols, 120);
1416        assert_eq!(rows, 40);
1417        Ok(())
1418    }
1419
1420    /// `kill(pid, 0)` probes existence without sending a signal: 0 = the pid
1421    /// exists, -1 (ESRCH) = it's gone. A freshly-killed orphan lingers briefly
1422    /// as a zombie under init until reaped, so callers poll.
1423    #[cfg(unix)]
1424    fn pid_alive(pid: i32) -> bool {
1425        u32::try_from(pid).is_ok_and(crate::os::unix::process_exists)
1426    }
1427
1428    #[cfg(unix)]
1429    #[test]
1430    fn dropping_shell_kills_orphaned_background_child() -> Result<()> {
1431        // A background job the shell starts must not outlive the shell. Spawn a
1432        // long `sleep` in the background, record its pid, drop the shell, and
1433        // confirm the sleep is gone - proving the group-kill reached past the
1434        // shell to a child `Child::kill` alone would have orphaned.
1435        let dir = TempDir::new()?;
1436        let pidfile = dir.path().join("child.pid");
1437        let mut shell = PtyShell::new(dir.path(), false)?;
1438
1439        shell
1440            .send_command(&format!("sleep 300 & echo $! > {} ; echo spawned", pidfile.display()))?;
1441
1442        // The PTY echoes the input line, so `out` containing "spawned" only proves
1443        // the line was sent - not that bash ran it. Under CI load bash can lag, so
1444        // poll the pidfile (it only appears once `echo $! > pidfile` executes) while
1445        // draining the PTY, instead of trusting the echo and racing the redirect.
1446        let mut child_pid = None;
1447        for _ in 0..150 {
1448            let _ = shell.read_output(0.1);
1449            if let Some(pid) =
1450                std::fs::read_to_string(&pidfile).ok().and_then(|c| c.trim().parse::<i32>().ok())
1451            {
1452                child_pid = Some(pid);
1453                break;
1454            }
1455            thread::sleep(Duration::from_millis(20));
1456        }
1457        let child_pid =
1458            child_pid.ok_or_else(|| anyhow!("background child pid never materialized"))?;
1459        assert!(pid_alive(child_pid), "background child should be running before drop");
1460
1461        drop(shell);
1462
1463        // The group-kill plus init reaping the orphan are async w.r.t. us; poll.
1464        let mut gone = false;
1465        for _ in 0..150 {
1466            if !pid_alive(child_pid) {
1467                gone = true;
1468                break;
1469            }
1470            thread::sleep(Duration::from_millis(20));
1471        }
1472        assert!(gone, "background child {child_pid} survived shell teardown (orphan leak)");
1473        Ok(())
1474    }
1475}