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 crate::utils::executable::{shell_kind_of, ShellKind};
11use anyhow::{anyhow, Context, Result};
12use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
13use std::collections::hash_map::DefaultHasher;
14use std::hash::{Hash, Hasher};
15use std::io::{Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
19use std::sync::mpsc::{self, TryRecvError};
20use std::sync::{Arc, Mutex as StdMutex};
21use std::thread;
22use std::time::{Duration, Instant};
23use tokio::sync::Mutex;
24use tracing::{debug, info, warn};
25
26use crate::state::line_ring::LineRing;
27use crate::state::live_terminal::{LiveTerminal, ScreenUpdate};
28
29/// Default terminal dimensions (columns x rows)
30pub const DEFAULT_COLS: u16 = 200;
31pub const DEFAULT_ROWS: u16 = 50;
32
33/// Maximum output buffer size to prevent memory issues
34const MAX_OUTPUT_SIZE: usize = 1_000_000;
35
36/// Cap on bytes streamed to one command's output-offload scratch file, so a
37/// command emitting gigabytes cannot fill the disk. The head beyond this is
38/// dropped (the agent still gets the first 50 MB plus the live tail).
39const SCRATCH_MAX_BYTES: u64 = 50 * 1024 * 1024;
40
41/// Sleep injected between PTY reads once the current command's output is pure
42/// discard (scratch budget exhausted — only the live tail is still retained).
43/// With 4 KiB reads this caps consumption at roughly 2 MB/s, so a runaway
44/// producer (`head -c 5T /dev/zero | tr '\0' x` was the production incident)
45/// back-pressures against the kernel PTY buffer instead of pinning a core in
46/// the terminal parsers at full stream speed. A real TUI redraw burst
47/// (~40 KiB/frame) still renders at ~50 fps under this cap, so interactive
48/// piloting is unaffected.
49const DISCARD_READ_THROTTLE: Duration = Duration::from_millis(2);
50
51/// How many fully-formed lines to keep in the per-shell ringbuffer. Callers can
52/// ask for at most this many lines of historical context via
53/// `StatusCheck.scrollback_lines`.
54pub const RING_BUFFER_LINES: usize = 2_000;
55
56/// Cap on the unterminated tail held in `line_ring_partial`. Bounds memory when
57/// a program emits megabytes without ever printing a newline (a `\r`-redrawn
58/// progress bar, a binary blob). Generous enough to hold any real terminal line.
59const MAX_PARTIAL_LINE_BYTES: usize = 64 * 1024;
60
61/// WCGW-style prompt pattern for command completion detection
62const WCGW_PROMPT_PATTERN: &str = "◉";
63const WCGW_PROMPT_END: &str = "──➤";
64/// Upper bound on waiting for a freshly spawned shell's first prompt. Linux bash
65/// answers in milliseconds; Git for Windows bash under `ConPTY` can take seconds
66/// on a cold start.
67const PROMPT_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(5);
68
69/// Environment every agent shell starts with, on top of the inherited one.
70fn configure_agent_environment(
71    cmd: &mut CommandBuilder,
72    workspace_root: Option<&Path>,
73    temporary_artifact_dir: Option<&Path>,
74) {
75    // Set up environment for proper terminal behavior
76    cmd.env("TERM", "xterm-256color");
77    cmd.env("COLORTERM", "truecolor");
78    cmd.env("PAGER", "cat");
79    cmd.env("GIT_PAGER", "cat");
80    // Agent commands (and the prompt bootstrap line) must not end up in the
81    // user's ~/.bash_history: bash skips saving history when HISTFILE is
82    // empty. Set before exec so it also holds under `bash -r`, which forbids
83    // changing HISTFILE afterwards.
84    cmd.env("HISTFILE", "");
85    cmd.env("COLUMNS", DEFAULT_COLS.to_string());
86    cmd.env("ROWS", DEFAULT_ROWS.to_string());
87    // Server telemetry and credentials stay with the server process.
88    for key in crate::config::SERVER_ONLY_ENV {
89        cmd.env_remove(key);
90    }
91    if let Some(workspace_root) = workspace_root {
92        cmd.env("WINX_WORKSPACE_ROOT", workspace_root);
93    }
94    if let Some(temporary_artifact_dir) = temporary_artifact_dir {
95        cmd.env("WINX_TEMP_DIR", temporary_artifact_dir);
96    }
97}
98
99fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>, ShellKind) {
100    let requested = crate::config::env_text("WINX_ATTACH_TERMINAL")
101        .or_else(|| crate::config::env_text("WINX_USE_SCREEN"))
102        .unwrap_or_default();
103    if requested == "tmux"
104        || requested == "screen"
105        || crate::config::parse_bool(&requested) == Some(true)
106    {
107        let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
108        if requested == "tmux" && command_available("tmux") {
109            let mut cmd = CommandBuilder::new("tmux");
110            cmd.args(["new-session", "-A", "-s", &session, "bash"]);
111            if restricted_mode {
112                cmd.arg("-r");
113            }
114            return (cmd, Some(format!("tmux attach -t {session}")), ShellKind::Bash);
115        }
116        if command_available("screen") {
117            // Parity with wcgw: ensure a sane ~/.screenrc and reap sessions whose
118            // creating winx process has died before spawning a fresh one.
119            ensure_screenrc();
120            cleanup_orphaned_screens();
121            let mut cmd = CommandBuilder::new("screen");
122            cmd.args(["-q", "-S", &session, "bash"]);
123            if restricted_mode {
124                cmd.arg("-r");
125            }
126            return (cmd, Some(format!("screen -x {session}")), ShellKind::Bash);
127        }
128    }
129
130    let shell = preferred_shell(restricted_mode);
131    // `WINX_SHELL=/bin/zsh` must get the zsh bootstrap too, not only `zsh`.
132    let kind = shell_kind_of(&shell);
133    let mut cmd = CommandBuilder::new(&shell);
134    match kind {
135        // zsh's restricted mode isn't the `-r` flag, so restricted always uses bash -r.
136        ShellKind::Bash if restricted_mode => {
137            cmd.arg("-r");
138        }
139        // The banner would otherwise land in the first command's output; the
140        // profile still runs so the user's PATH and aliases apply.
141        ShellKind::PowerShell => {
142            cmd.arg("-NoLogo");
143        }
144        ShellKind::Bash | ShellKind::Zsh => {}
145    }
146    // Operator-supplied extra shell arguments (`WINX_SHELL_ARGS="-i --login"`):
147    // whitespace-separated, applied to the direct shell path only.
148    if let Some(extra) = crate::config::env_text("WINX_SHELL_ARGS") {
149        for arg in extra.split_whitespace() {
150            cmd.arg(arg);
151        }
152    }
153    (cmd, None, kind)
154}
155
156/// The shell family a new PTY would get right now, from the same selection
157/// the spawner uses. Command policy (single-statement checks, static write
158/// analysis) consults it before a shell exists.
159pub fn configured_shell_kind(restricted_mode: bool) -> ShellKind {
160    shell_kind_of(&preferred_shell(restricted_mode))
161}
162
163/// Shell to spawn directly. Defaults to bash; honors `WINX_SHELL=zsh` when zsh is
164/// on PATH and we're not in restricted mode (zsh's restricted mode differs from
165/// `bash -r`, so restricted falls back to bash). On Windows the Git for Windows
166/// `bash.exe` is resolved to an absolute path so the `ConPTY` child never lands
167/// on the WSL launcher in `System32`.
168/// On Windows the shell must be an absolute Git for Windows `bash.exe`; the
169/// `System32` WSL launcher is never used. If no usable bash is found, fail with
170/// an actionable message instead of an opaque spawn error later.
171#[cfg(windows)]
172fn ensure_windows_shell_available(restricted_mode: bool) -> Result<()> {
173    // An absolute `WINX_SHELL` (honored only outside restricted mode) wins.
174    if !restricted_mode {
175        if let Ok(requested) = std::env::var("WINX_SHELL") {
176            let path = Path::new(&requested);
177            if path.is_absolute() && path.is_file() {
178                return Ok(());
179            }
180        }
181    }
182    if crate::utils::executable::bash_executable().is_some() {
183        return Ok(());
184    }
185    if crate::utils::executable::powershell_executable().is_some() {
186        if restricted_mode {
187            return Err(anyhow!(
188                "restricted mode needs bash -r; install Git for Windows or set WINX_SHELL to an \
189                 absolute bash.exe path (PowerShell has no equivalent restricted mode)"
190            ));
191        }
192        return Ok(());
193    }
194    Err(anyhow!(
195        "no usable shell found: install Git for Windows (the System32 WSL launcher is not \
196         used) or PowerShell 7, or set WINX_SHELL to an absolute bash.exe or pwsh.exe path"
197    ))
198}
199
200fn preferred_shell(restricted_mode: bool) -> String {
201    if !restricted_mode {
202        if let Ok(requested) = std::env::var("WINX_SHELL") {
203            if requested == "zsh" && command_available("zsh") {
204                return "zsh".to_string();
205            }
206            // `pwsh`/`powershell` select PowerShell wherever it is installed;
207            // restricted mode never does, because `bash -r` has no equivalent.
208            if matches!(requested.as_str(), "pwsh" | "powershell") {
209                if let Some(found) = crate::utils::executable::powershell_executable() {
210                    return found.to_string_lossy().into_owned();
211                }
212            }
213            // An absolute path selects that exact shell binary (a specific
214            // bash.exe on Windows, a custom build elsewhere).
215            if Path::new(&requested).is_absolute() && Path::new(&requested).is_file() {
216                return requested;
217            }
218        }
219    }
220    if cfg!(windows) {
221        if let Some(bash) = crate::utils::executable::bash_executable() {
222            return bash.to_string_lossy().into_owned();
223        }
224        // No Git for Windows: PowerShell keeps the native runtime usable
225        // without WSL, outside restricted mode.
226        if !restricted_mode {
227            if let Some(found) = crate::utils::executable::powershell_executable() {
228                return found.to_string_lossy().into_owned();
229            }
230        }
231    }
232    "bash".to_string()
233}
234
235/// One-line PowerShell bootstrap with the same completion contract as the
236/// bash prompt: `◉ <cwd>──➤<nonce>:<code> ` after every command.
237///
238/// `PSReadLine` is unloaded because it redraws the line with cursor movement
239/// that breaks marker detection; `PlainText` rendering keeps ANSI out of
240/// command output on PowerShell 7.2+. The exit code mirrors a POSIX shell:
241/// a native process code when one was set, `1` for a failed cmdlet
242/// (`$?` false), else `0`; `$LASTEXITCODE` is cleared so a later cmdlet
243/// does not inherit a stale native failure. Windows PowerShell 5.1 keeps
244/// `$?` true after a failing native command, which is why the code is read
245/// before `$?` is consulted.
246fn powershell_bootstrap(nonce: &str) -> String {
247    format!(
248        "Remove-Module PSReadLine -ErrorAction SilentlyContinue; \
249         if (Get-Variable PSStyle -ErrorAction SilentlyContinue) {{ $PSStyle.OutputRendering = 'PlainText' }}; \
250         $env:PAGER = 'cat'; $env:GIT_PAGER = 'cat'; $global:LASTEXITCODE = 0; \
251         function global:prompt {{ $ok = $?; $ec = $global:LASTEXITCODE; \
252         $code = if ($ec -is [int] -and $ec -ne 0) {{ $ec }} elseif ($ok) {{ 0 }} else {{ 1 }}; \
253         $global:LASTEXITCODE = 0; \
254         \"\u{25c9} $($ExecutionContext.SessionState.Path.CurrentLocation.ProviderPath)\u{2500}\u{2500}\u{27a4}{nonce}:$code \" }}"
255    )
256}
257
258fn command_available(command: &str) -> bool {
259    crate::utils::executable::is_available(command)
260}
261
262/// Create `~/.screenrc` with a large scrollback if the user has none, matching
263/// wcgw's `check_if_screen_command_available`. Never overwrites an existing file.
264fn ensure_screenrc() {
265    let Some(home) = home::home_dir() else {
266        return;
267    };
268    let screenrc = home.join(".screenrc");
269    if screenrc.exists() {
270        return;
271    }
272    let _ = std::fs::write(
273        &screenrc,
274        "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
275    );
276}
277
278/// Reap detached `winx-*` screen sessions whose creating process is gone.
279///
280/// The session name embeds the creator PID (`winx-<pid>-<ts>`), so an orphan is
281/// simply a session whose `<pid>` no longer exists — the wcgw equivalent of
282/// detecting `parent_pid == 1`. Best-effort: any failure is silently ignored.
283fn cleanup_orphaned_screens() {
284    let Ok(output) = Command::new("screen").arg("-ls").output() else {
285        return;
286    };
287    // `screen -ls` exits non-zero when sessions exist, so we parse stdout regardless.
288    let listing = String::from_utf8_lossy(&output.stdout);
289    for line in listing.lines() {
290        let Some(session) = line.split_whitespace().next() else {
291            continue;
292        };
293        // session token looks like "<screen_pid>.winx-<creator_pid>-<ts>"
294        let Some((_, name)) = session.split_once('.') else {
295            continue;
296        };
297        if let Some(creator_pid) = winx_creator_pid(name) {
298            if !process_exists(creator_pid) {
299                let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
300            }
301        }
302    }
303}
304
305/// Extract the creator PID from a `winx-<pid>-<ts>` screen session name.
306fn winx_creator_pid(name: &str) -> Option<u32> {
307    name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
308}
309
310/// Whether a process with `pid` is currently alive. `kill(pid, 0)` is portable
311/// across supported Unix platforms and sends no signal.
312#[cfg(unix)]
313fn process_exists(pid: u32) -> bool {
314    crate::os::unix::process_exists(pid)
315}
316
317#[cfg(not(unix))]
318fn process_exists(pid: u32) -> bool {
319    crate::os::process_exists(pid)
320}
321
322/// Git for Windows bash reports its cwd in MSYS form (`/c/Users/x`, or
323/// `/cygdrive/c/...` under Cygwin). On Windows, convert it back to the native
324/// `C:\Users\x` spelling so relative file targets and `cd` tracking resolve
325/// against a directory that exists for the rest of Winx.
326pub(crate) fn native_cwd_from_prompt(cwd: &str) -> PathBuf {
327    if cfg!(windows) {
328        if let Some(native) = msys_to_native(cwd) {
329            return native;
330        }
331    }
332    PathBuf::from(cwd)
333}
334
335fn msys_to_native(cwd: &str) -> Option<PathBuf> {
336    let rest = cwd.strip_prefix("/cygdrive").unwrap_or(cwd);
337    let mut parts = rest.strip_prefix('/')?.splitn(2, '/');
338    let drive = parts.next()?;
339    if drive.len() != 1 || !drive.chars().all(|character| character.is_ascii_alphabetic()) {
340        return None;
341    }
342    let tail = parts.next().unwrap_or("");
343    let mut native = format!("{}:\\", drive.to_ascii_uppercase());
344    native.push_str(&tail.replace('/', "\\"));
345    Some(PathBuf::from(native))
346}
347
348fn timestamp_millis() -> u128 {
349    std::time::SystemTime::now()
350        .duration_since(std::time::UNIX_EPOCH)
351        .map_or(0, |duration| duration.as_millis())
352}
353
354/// Prompt text can be echoed only after submission, with whitespace reflowed.
355/// A receipt containing recognizable activity text cannot establish independent work.
356fn prompt_text_mimics_activity(
357    recognizer: &dyn crate::state::turn::TurnRecognizer,
358    text: &str,
359) -> bool {
360    let lines: Vec<String> = text.lines().map(str::to_string).collect();
361    let folded = [text.split_whitespace().collect::<Vec<_>>().join(" ")];
362    let mimics_activity = |screen: &[String]| {
363        matches!(
364            recognizer.detect_input(&crate::state::turn::TurnInput::screen_only(screen)),
365            crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
366        )
367    };
368    mimics_activity(&lines) || mimics_activity(&folded)
369}
370
371#[cfg(test)]
372mod workflow_activity_tests;
373
374/// Only the current prompt owns a live reader gate. Earlier receipts retain their flag.
375struct PromptActivityGate {
376    recognizer: Box<dyn crate::state::turn::TurnRecognizer>,
377    baseline_seq: u64,
378    observed: Arc<AtomicBool>,
379    submitted: bool,
380    ambiguous_echo: bool,
381    blocked_state: Option<crate::state::turn::TurnState>,
382}
383
384impl PromptActivityGate {
385    fn observe(&mut self, live: &LiveTerminal, seq: u64) {
386        if !self.submitted
387            || self.ambiguous_echo
388            || seq <= self.baseline_seq
389            || self.observed.load(AtomicOrdering::Acquire)
390        {
391            return;
392        }
393        let screen = live.snapshot(0);
394        let input = crate::state::turn::TurnInput {
395            screen: &screen,
396            osc_title: live.osc_title(),
397            osc_progress: live.osc_progress(),
398        };
399        let state = self.recognizer.detect_input(&input);
400        if self.blocked_state == Some(state) {
401            return;
402        }
403        self.blocked_state = None;
404        if matches!(
405            state,
406            crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
407        ) {
408            self.observed.store(true, AtomicOrdering::Release);
409        }
410    }
411}
412
413#[cfg(test)]
414mod workflow_reader_tests;
415
416/// Background thread that pumps the PTY master into the output channel and the
417/// live emulator. Owns the blocking `read` loop so the async side never blocks.
418fn spawn_pty_reader(
419    mut reader: Box<dyn Read + Send>,
420    output_tx: mpsc::SyncSender<String>,
421    live: Arc<StdMutex<LiveTerminal>>,
422    discard_throttle: Arc<AtomicBool>,
423    activity_seq: Arc<AtomicU64>,
424    queued_activity_seq: Arc<AtomicU64>,
425    prompt_activity: Arc<StdMutex<Option<PromptActivityGate>>>,
426) {
427    thread::spawn(move || {
428        let mut buf = [0u8; 4096];
429        // Bytes of an incomplete trailing UTF-8 char held back for the next
430        // read. Without this, a multibyte glyph split across a 4096-byte read
431        // boundary would decode to two U+FFFDs — corrupting the output buffer
432        // and the prompt/CWD detection that runs over it.
433        let mut carry: Vec<u8> = Vec::new();
434        loop {
435            match reader.read(&mut buf) {
436                Ok(0) => {
437                    debug!("PTY reader reached EOF");
438                    // EOF - PTY closed. Flush any held bytes lossily.
439                    if !carry.is_empty() {
440                        // EOF can publish replacement bytes after the valid prefix
441                        // from the same read; give this final delivery a fresh sequence.
442                        let seq =
443                            activity_seq.fetch_add(1, AtomicOrdering::Release).saturating_add(1);
444                        if output_tx.send(String::from_utf8_lossy(&carry).into_owned()).is_ok() {
445                            queued_activity_seq.store(seq, AtomicOrdering::Release);
446                        }
447                    }
448                    break;
449                }
450                Ok(n) => {
451                    debug!(bytes = n, "PTY reader chunk");
452                    // Tap the raw bytes into the live emulator first (brief
453                    // lock; feed is O(chunk len)). Feeding bytes — not the
454                    // lossy String — keeps the persistent VTE parser exact
455                    // across chunk boundaries. Recover from a poisoned lock
456                    // instead of dropping the feed silently.
457                    let seq = {
458                        let mut emu =
459                            live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
460                        emu.feed(&buf[..n]);
461                        let seq =
462                            activity_seq.fetch_add(1, AtomicOrdering::Release).saturating_add(1);
463                        if let Some(gate) = prompt_activity
464                            .lock()
465                            .unwrap_or_else(std::sync::PoisonError::into_inner)
466                            .as_mut()
467                        {
468                            gate.observe(&emu, seq);
469                        }
470                        seq
471                    };
472                    // Decode the longest valid prefix; carry an incomplete
473                    // trailing char into the next read.
474                    carry.extend_from_slice(&buf[..n]);
475                    let (chunk, rest) = decode_keep_incomplete(&carry);
476                    carry = rest;
477                    if !chunk.is_empty() {
478                        if output_tx.send(chunk).is_err() {
479                            // Receiver dropped, exit thread.
480                            break;
481                        }
482                        // Publish only after enqueueing, outside the live/gate locks.
483                        queued_activity_seq.store(seq, AtomicOrdering::Release);
484                    }
485                    // Once the command's output is pure discard, stop
486                    // consuming at stream speed: the pause fills the kernel
487                    // PTY buffer and blocks the producer instead of burning
488                    // a core parsing bytes that are thrown away.
489                    if discard_throttle.load(AtomicOrdering::Relaxed) {
490                        thread::sleep(DISCARD_READ_THROTTLE);
491                    }
492                }
493                Err(e) => {
494                    debug!("PTY reader thread error: {}", e);
495                    break;
496                }
497            }
498        }
499        debug!("PTY reader thread exiting");
500    });
501}
502
503/// Real PTY-based interactive shell
504///
505/// Uses portable-pty for true pseudo-terminal functionality,
506/// enabling proper handling of interactive programs like sudo, vim, etc.
507pub struct PtyShell {
508    /// The PTY master handle for resize operations
509    master: Box<dyn MasterPty + Send>,
510    /// Child process running the shell
511    child: Box<dyn Child + Send + Sync>,
512    /// Writer for PTY input (taken from master)
513    writer: Box<dyn Write + Send>,
514    /// Channel receiver for output from reader thread
515    output_rx: mpsc::Receiver<String>,
516    /// Current terminal size
517    size: PtySize,
518    /// Last command executed
519    pub last_command: String,
520    /// Accumulated output buffer
521    pub output_buffer: String,
522    /// Whether a command is currently running
523    pub command_running: bool,
524    /// Last cwd observed in this shell's nonce-bearing prompt.
525    current_cwd: PathBuf,
526    /// Monotonic start time of the current or most recently completed command.
527    /// Status polling uses this instead of restarting the clock per tool call.
528    command_started_at: Option<Instant>,
529    /// Monotonic command generation used by daemon journals to distinguish two
530    /// consecutive commands whose terminal bytes happen to be identical.
531    command_generation: u64,
532    /// Random identity of this PTY instance. Unlike the numeric generation it
533    /// changes when a shell is reset and generations start again at zero.
534    incarnation: u64,
535    activity_seq: Arc<AtomicU64>,
536    /// Highest reader sequence whose decoded bytes have reached the output channel.
537    queued_activity_seq: Arc<AtomicU64>,
538    pub(crate) prompt_receipts:
539        std::collections::HashMap<String, crate::runtime::workflow::PromptRecord>,
540    prompt_activity: Arc<StdMutex<Option<PromptActivityGate>>>,
541    /// Exit code of the last completed foreground command, parsed from the prompt
542    /// marker (`──➤<nonce>:<code>`). `None` until a command finishes, and reset
543    /// to `None` while one is running.
544    pub last_exit_code: Option<i32>,
545    /// Maximum output size before truncation
546    max_output_size: usize,
547    /// Flag for output truncation
548    pub output_truncated: bool,
549    /// Bounded scrollback ring of raw PTY lines (newest at the back) plus the
550    /// in-flight partial line, with drop accounting so a truncated scrollback is
551    /// reported to the caller instead of silently shrinking. See `LineRing`.
552    ring: LineRing,
553    /// Hash of the last rendered output we shipped to the caller. Used by the
554    /// delta path in `status_check` to elide repeats when the screen is idle.
555    pub last_returned_hash: Option<u64>,
556    /// Cumulative PTY snapshot already delivered to the caller. Incremental
557    /// status polling diffs against this value, then advances it after rendering.
558    delivered_output: String,
559    /// True from a foreground command's start until a response has reported
560    /// its exit. The prompt can be drained by another reader (the daemon
561    /// journal) between two status polls; the next poll then finds the shell
562    /// idle and must still deliver the final output and exit code instead of
563    /// rejecting the call as if nothing had run.
564    pending_completion_report: bool,
565    /// Optional command a human can run to attach to the same terminal session.
566    pub attach_hint: Option<String>,
567    /// The exact suffix that ends this shell's prompt: `──➤<nonce>`. The nonce is
568    /// a per-shell random value embedded in `PROMPT_COMMAND`, so command output
569    /// can't impersonate the prompt (printing `◉ x──➤` no longer ends a command
570    /// early). Anchoring completion on this instead of the bare glyphs removes
571    /// the false-positive that truncated output when a program echoed them.
572    prompt_end_marker: String,
573    /// Live terminal emulator (fixed-viewport, `vt100`-backed) fed continuously
574    /// by the reader thread. Unlike the scrollback ring (raw lines), it keeps a
575    /// consolidated screen grid — cursor moves, redraws, alternate-screen
576    /// applied — so a TUI yields a stable, non-stacked snapshot. The fixed
577    /// viewport is what lets inline-rendering TUIs (the `agy`/Antigravity CLI,
578    /// which redraws with cursor-up + erase-line in the main screen) consolidate
579    /// instead of ghosting. See `live_snapshot`.
580    live: Arc<StdMutex<LiveTerminal>>,
581    /// Workspace root under which output-offload scratch files are written.
582    /// `None` until a caller sets it (the workspace is only known after
583    /// Initialize, and `execute_command` sets it per command). See
584    /// `crate::utils::scratch_file`.
585    scratch_workspace_root: Option<PathBuf>,
586    /// Scratch file holding the CURRENT command's dropped output head, created
587    /// lazily on the first truncation and reset per command.
588    scratch_path: Option<PathBuf>,
589    /// Bytes already streamed to `scratch_path`, used to enforce `SCRATCH_MAX_BYTES`.
590    scratch_bytes: u64,
591    /// Shared with the reader thread: when set, the current command's output
592    /// is pure discard (scratch budget exhausted), so the reader inserts
593    /// [`DISCARD_READ_THROTTLE`] between reads and lets the kernel PTY buffer
594    /// back-pressure the producer. Cleared on every new command.
595    discard_throttle: Arc<AtomicBool>,
596    /// Windows job object holding the shell and everything it spawns, so a
597    /// dropped shell takes its background children with it (the Unix path
598    /// signals the process group instead).
599    #[cfg(windows)]
600    job: Option<crate::os::windows::ProcessTreeJob>,
601}
602
603impl std::fmt::Debug for PtyShell {
604    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605        f.debug_struct("PtyShell")
606            .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
607            .field("last_command", &self.last_command)
608            .field("command_running", &self.command_running)
609            .field("current_cwd", &self.current_cwd)
610            .field("output_truncated", &self.output_truncated)
611            .field("output_buffer_len", &self.output_buffer.len())
612            .field("attach_hint", &self.attach_hint)
613            .finish_non_exhaustive()
614    }
615}
616
617/// Resolve the process group to signal for a PTY child, but ONLY when it is
618/// safe: the pid must be a real child (`> 1`) that leads its own group
619/// (`getpgid(pid) == pid`). portable-pty `setsid`s the shell, so a live PTY
620/// child is always its own group leader; if that ever stopped holding,
621/// `kill(-pgid, ...)` would hit some unrelated group, so we refuse instead of
622/// guessing.
623///
624/// The `pid > 1` check is load-bearing: `kill(-1, ...)` means "every process I
625/// may signal" and `kill(0, ...)` means "my own group" - either would be
626/// catastrophic from a teardown path.
627#[cfg(unix)]
628fn killable_group(pid: u32) -> Option<i32> {
629    crate::os::unix::owned_process_group(pid)
630}
631
632/// Send `signal` to the whole process group `pgid` (group-kill via negative
633/// pid). `pgid` must be one vetted by [`killable_group`].
634#[cfg(unix)]
635fn signal_group(pgid: i32, signal: i32) {
636    let _ = crate::os::unix::signal_group(pgid, signal);
637}
638
639impl Drop for PtyShell {
640    /// Kill and reap the shell child **and its whole process group** so neither
641    /// the shell nor anything it spawned leaks.
642    ///
643    /// `std::process::Child::drop` neither kills nor waits, and even
644    /// `Child::kill` signals only the shell itself: a background job it started
645    /// (`npm run dev &`, a `nohup`'d server) would be reparented to init and live
646    /// on. Because portable-pty runs the shell as a session/group leader, we can
647    /// signal the whole group with a negative pid - SIGTERM first so well-behaved
648    /// children can clean up, then `Child::kill` (SIGKILL on the leader, which
649    /// closes the PTY slave so the reader thread's `read()` returns EOF and the
650    /// thread exits), then SIGKILL the group to sweep anything still alive, then
651    /// reap. The pgid is captured while the leader is alive and reused: it stays
652    /// valid until we `wait()`, even after the leader dies. All best-effort.
653    fn drop(&mut self) {
654        // Only group-kill the direct bash/zsh path. Under screen/tmux (`attach_hint`
655        // is `Some`) the `Child` is a multiplexer *client*; the real shell runs in
656        // a detached daemon in another process group, so a group-kill here would
657        // miss the shell entirely and could disturb unrelated sessions of the same
658        // multiplexer. Let the multiplexer own its lifecycle in that case.
659        #[cfg(unix)]
660        let pgid = if self.attach_hint.is_some() {
661            None
662        } else {
663            self.child.process_id().and_then(killable_group)
664        };
665
666        #[cfg(unix)]
667        if let Some(pgid) = pgid {
668            signal_group(pgid, libc::SIGTERM);
669        }
670
671        // Windows: the job object takes the whole tree down at once.
672        #[cfg(windows)]
673        if let Some(job) = self.job.as_ref() {
674            job.terminate();
675        }
676
677        // SIGKILL the leader and close the PTY slave (unblocks the reader thread).
678        let _ = self.child.kill();
679
680        #[cfg(unix)]
681        if let Some(pgid) = pgid {
682            signal_group(pgid, libc::SIGKILL);
683        }
684
685        let _ = self.child.wait();
686    }
687}
688
689impl PtyShell {
690    /// Create a new PTY shell session
691    ///
692    /// # Arguments
693    /// * `initial_dir` - Starting directory for the shell
694    /// * `restricted_mode` - Whether to use bash restricted mode (-r)
695    ///
696    /// # Returns
697    /// A new `PtyShell` instance with an active bash session
698    pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
699        Self::new_with_agent_paths(initial_dir, restricted_mode, None, None)
700    }
701
702    /// Create a PTY with Winx's stable workspace and managed helper paths in
703    /// the child environment. Direct callers keep using [`Self::new`].
704    pub(crate) fn new_with_agent_paths(
705        initial_dir: &Path,
706        restricted_mode: bool,
707        workspace_root: Option<&Path>,
708        temporary_artifact_dir: Option<&Path>,
709    ) -> Result<Self> {
710        info!(
711            "Creating new PTY shell (restricted: {}) in {}",
712            restricted_mode,
713            initial_dir.display()
714        );
715
716        // On Windows, fail fast with an actionable message if no Git for Windows
717        // bash is available, rather than letting `CommandBuilder::new("bash")`
718        // fail later with an opaque OS error.
719        #[cfg(windows)]
720        ensure_windows_shell_available(restricted_mode)?;
721
722        // Configure terminal size
723        let size =
724            PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
725
726        // Open the PTY pair (master + slave)
727        let pair = native_pty_system().openpty(size).context("Failed to open PTY pair")?;
728
729        // Build the command
730        let (mut cmd, attach_hint, shell_kind) = attachable_command(restricted_mode);
731        if shell_kind.is_powershell() && restricted_mode {
732            return Err(anyhow!(
733                "restricted mode needs bash -r; PowerShell has no equivalent restricted mode"
734            ));
735        }
736
737        configure_agent_environment(&mut cmd, workspace_root, temporary_artifact_dir);
738        // Per-shell random nonce embedded in the prompt so command output can't
739        // forge the completion marker. 64 bits of entropy in hex.
740        let nonce = format!("{:016x}", rand::random::<u64>());
741        // WCGW-style prompt for command completion detection
742        // Note: removed \r\e[2K which was erasing the prompt before it could be detected
743        // `__winx_ec=$?` captures the user command's exit status as the very
744        // first thing PROMPT_COMMAND does, then we print it after the marker.
745        cmd.env(
746            "PROMPT_COMMAND",
747            format!(r#"__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec""#),
748        );
749        cmd.cwd(initial_dir);
750
751        // Spawn bash in the PTY slave
752        let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
753        #[cfg(windows)]
754        let job = match crate::os::windows::ProcessTreeJob::new()
755            .and_then(|job| child.process_id().map_or(Ok(()), |pid| job.assign(pid)).map(|()| job))
756        {
757            Ok(job) => Some(job),
758            Err(error) => {
759                warn!(%error, "PTY child is not in a job object; background children may outlive the shell");
760                None
761            }
762        };
763
764        // Get reader and writer from master
765        let reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
766        let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
767
768        // Bounded channel: if the consumer stalls, the reader thread blocks on
769        // send (natural backpressure via the PTY OS buffer) instead of growing
770        // memory without bound on a runaway producer like `yes`.
771        let (output_tx, output_rx) = mpsc::sync_channel::<String>(1024);
772
773        // Live terminal emulator, shared with the reader thread so the screen
774        // grid stays current without any consumer needing to poll.
775        let live = Arc::new(StdMutex::new(LiveTerminal::new(DEFAULT_ROWS, DEFAULT_COLS)));
776        let discard_throttle = Arc::new(AtomicBool::new(false));
777        let prompt_activity = Arc::new(StdMutex::new(None));
778
779        let activity_seq = Arc::new(AtomicU64::new(0));
780        let queued_activity_seq = Arc::new(AtomicU64::new(0));
781        spawn_pty_reader(
782            reader,
783            output_tx,
784            Arc::clone(&live),
785            Arc::clone(&discard_throttle),
786            activity_seq.clone(),
787            queued_activity_seq.clone(),
788            prompt_activity.clone(),
789        );
790
791        // Create the shell instance
792        let mut shell = Self {
793            master: pair.master,
794            child,
795            writer,
796            output_rx,
797            size,
798            last_command: String::new(),
799            output_buffer: String::new(),
800            command_running: false,
801            current_cwd: initial_dir.to_path_buf(),
802            command_started_at: None,
803            command_generation: 0,
804            incarnation: rand::random::<u64>().max(1),
805            activity_seq,
806            queued_activity_seq,
807            prompt_receipts: std::collections::HashMap::new(),
808            prompt_activity,
809            last_exit_code: None,
810            max_output_size: MAX_OUTPUT_SIZE,
811            output_truncated: false,
812            ring: LineRing::new(RING_BUFFER_LINES, MAX_PARTIAL_LINE_BYTES),
813            last_returned_hash: None,
814            delivered_output: String::new(),
815            pending_completion_report: false,
816            attach_hint,
817            prompt_end_marker: format!("{WCGW_PROMPT_END}{nonce}"),
818            live,
819            scratch_workspace_root: None,
820            scratch_path: None,
821            scratch_bytes: 0,
822            discard_throttle,
823            #[cfg(windows)]
824            job,
825        };
826
827        // Initialize the shell with WCGW-style prompt
828        shell.initialize_prompt(shell_kind, &nonce)?;
829
830        debug!("PTY shell created successfully");
831        Ok(shell)
832    }
833
834    /// Initialize the shell prompt for WCGW compatibility
835    fn initialize_prompt(&mut self, shell_kind: ShellKind, nonce: &str) -> Result<()> {
836        // Set up the dynamic prompt - matches WCGW Python PROMPT_STATEMENT.
837        // zsh ignores PROMPT_COMMAND, so it gets a precmd hook with a blanked
838        // default prompt instead.
839        // Blank the shell's own PS1/PROMPT so the line ends exactly at our `──➤`
840        // marker. Otherwise a user's ~/.bashrc/~/.zshrc PS1 (e.g. `[user@host]$`)
841        // is appended after the marker, and prompt detection — which anchors on a
842        // trailing `──➤` — only fires when the chunk happens to fragment right
843        // before the PS1, making command-completion detection flaky.
844        let prompt_statement = match shell_kind {
845            // Clear precmd_functions too: frameworks (oh-my-zsh/p10k) register the
846            // prompt via that array, so redefining `precmd` alone leaves their
847            // prompt in place and our `──➤` marker never ends the line.
848            ShellKind::Zsh => format!(
849                r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() {{ local __winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec" }}"#
850            ),
851            ShellKind::Bash => format!(
852                r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec"'; PS1=''"#
853            ),
854            ShellKind::PowerShell => powershell_bootstrap(nonce),
855        };
856
857        if shell_kind.is_powershell() {
858            // PSReadLine initializes asynchronously after the first prompt
859            // (it queries the cursor position first). Input written before
860            // that lands in its line editor with the Enter keys dropped, so
861            // the bootstrap and the first command fuse into one unsubmitted
862            // line. Wait for the default `PS ...>` prompt before typing.
863            self.wait_for_initial_powershell_prompt();
864        }
865        self.write_command(&prompt_statement)?;
866
867        // Wait for the first marker-bearing prompt instead of a fixed pause:
868        // everything before it (the shell's startup banner, the echoed
869        // bootstrap line) must never leak into the first command's output, and
870        // Git for Windows bash reaches its first prompt far later than Linux
871        // bash. Once the marker is seen, a short drain swallows the trailing
872        // bytes of that same prompt.
873        let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
874        let mut bootstrap = String::new();
875        let mut settled = false;
876        while Instant::now() < deadline {
877            match self.output_rx.recv_timeout(Duration::from_millis(50)) {
878                Ok(chunk) => {
879                    self.answer_terminal_queries(&chunk);
880                    bootstrap.push_str(&chunk);
881                    if Self::check_prompt_complete(&bootstrap, &self.prompt_end_marker) {
882                        settled = true;
883                        break;
884                    }
885                }
886                Err(mpsc::RecvTimeoutError::Timeout) => {}
887                Err(mpsc::RecvTimeoutError::Disconnected) => break,
888            }
889        }
890        if settled {
891            std::thread::sleep(Duration::from_millis(30));
892        } else {
893            warn!(
894                timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
895                "PTY shell did not print its first prompt in time; continuing anyway"
896            );
897        }
898        bootstrap.push_str(&self.drain_output());
899        debug!(
900            bytes = bootstrap.len(),
901            settled,
902            child_pid = ?self.child.process_id(),
903            "PTY prompt bootstrap drained"
904        );
905
906        Ok(())
907    }
908
909    /// Block until PowerShell has drawn its initial prompt (or the bootstrap
910    /// timeout elapses), answering cursor-position queries on the way.
911    fn wait_for_initial_powershell_prompt(&mut self) {
912        let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
913        let mut seen = String::new();
914        while Instant::now() < deadline {
915            match self.output_rx.recv_timeout(Duration::from_millis(50)) {
916                Ok(chunk) => {
917                    self.answer_terminal_queries(&chunk);
918                    seen.push_str(&chunk);
919                    let clean = crate::state::terminal::strip_ansi_codes(&seen);
920                    if clean.contains("> ") || clean.contains(">\u{a0}") {
921                        // Give PSReadLine a moment to finish arming its input loop.
922                        std::thread::sleep(Duration::from_millis(100));
923                        return;
924                    }
925                }
926                Err(mpsc::RecvTimeoutError::Timeout) => {}
927                Err(mpsc::RecvTimeoutError::Disconnected) => return,
928            }
929        }
930        warn!(
931            timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
932            "PowerShell did not draw its initial prompt in time; sending the bootstrap anyway"
933        );
934    }
935
936    /// Answer terminal queries the console host blocks on.
937    ///
938    /// Windows `ConPTY` is created with `INHERIT_CURSOR` (portable-pty's default)
939    /// and then asks the "terminal" for the cursor position with `ESC[6n`
940    /// before rendering a single byte of the child's output. Winx is that
941    /// terminal, so it must reply with a cursor position report or every shell
942    /// stays silent forever. The reply is harmless on Unix, where no PTY layer
943    /// issues the query.
944    fn answer_terminal_queries(&mut self, chunk: &str) {
945        const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
946        if !chunk.contains(CURSOR_POSITION_REQUEST) {
947            return;
948        }
949        debug!("PTY answered a cursor position request");
950        if let Err(error) = self.writer.write_all(b"\x1b[1;1R").and_then(|()| self.writer.flush()) {
951            debug!("PTY cursor position report failed: {error}");
952        }
953    }
954
955    /// Write a command to the PTY, submitting it with a carriage return.
956    fn write_command(&mut self, command: &str) -> Result<()> {
957        // Submit with `\r`, matching the foreground path (`send_special_key("Enter")`
958        // also sends `\r`) and a real terminal's Enter key. The two paths used to
959        // disagree — background sent `\n` here, foreground sent `\r` — which is a
960        // real difference for readline-based TUIs. bash's canonical line discipline
961        // maps CR to NL (ICRNL), so plain commands behave identically.
962        let cmd_with_newline = format!("{command}\r");
963        self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
964        self.writer.flush().context("Failed to flush PTY")?;
965        Ok(())
966    }
967
968    /// Drain any pending output from the PTY channel
969    fn drain_output(&mut self) -> String {
970        let mut output = String::new();
971        let deadline = Instant::now() + Duration::from_millis(200);
972
973        // Drain all available output from the channel
974        while Instant::now() < deadline {
975            match self.output_rx.try_recv() {
976                Ok(chunk) => {
977                    self.answer_terminal_queries(&chunk);
978                    output.push_str(&chunk);
979
980                    // Prevent runaway reads
981                    if output.len() > self.max_output_size {
982                        self.output_truncated = true;
983                        break;
984                    }
985                }
986                Err(TryRecvError::Empty) => {
987                    // No more data, wait briefly for more
988                    thread::sleep(Duration::from_millis(10));
989                }
990                Err(TryRecvError::Disconnected) => {
991                    // Reader thread died
992                    break;
993                }
994            }
995        }
996
997        output
998    }
999
1000    /// Send a command to the shell and start reading output
1001    pub fn send_command(&mut self, command: &str) -> Result<()> {
1002        debug!(bytes = command.len(), "PTY sending command");
1003
1004        // Clear previous state
1005        self.output_buffer.clear();
1006        self.output_truncated = false;
1007        self.reset_scratch();
1008        self.last_command = command.to_string();
1009        self.command_running = true;
1010        self.mark_command_started();
1011        self.last_exit_code = None;
1012        // A new command means the next status_check should return whatever
1013        // shows up — drop the dedup hash so we don't elide the first response.
1014        self.last_returned_hash = None;
1015        self.delivered_output.clear();
1016        // Drop retained OSC title/progress so the previous foreground
1017        // program's last title can't masquerade as this command's turn state.
1018        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear_osc();
1019
1020        // Write the command
1021        self.write_command(command)?;
1022
1023        Ok(())
1024    }
1025
1026    /// Start the monotonic runtime clock for a newly submitted command.
1027    pub fn mark_command_started(&mut self) {
1028        self.prompt_receipts.clear();
1029        *self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1030        self.command_generation = self.command_generation.saturating_add(1);
1031        self.command_started_at = Some(Instant::now());
1032        self.pending_completion_report = true;
1033    }
1034
1035    /// Whether the most recent foreground command has finished without any
1036    /// response having reported its exit yet.
1037    pub fn completion_report_pending(&self) -> bool {
1038        !self.command_running && self.pending_completion_report
1039    }
1040
1041    /// Record that a response reported the current command as finished.
1042    pub fn mark_completion_reported(&mut self) {
1043        self.pending_completion_report = false;
1044    }
1045
1046    pub fn command_generation(&self) -> u64 {
1047        self.command_generation
1048    }
1049
1050    /// Monotonic count of actual PTY reads, including redraws with identical text.
1051    pub fn activity_seq(&self) -> u64 {
1052        self.activity_seq.load(AtomicOrdering::Acquire)
1053    }
1054
1055    /// Drain an observation without advancing legacy output delivery cursors.
1056    /// Sample the enqueue watermark first: later queued bytes must remain visible
1057    /// as a newer sequence even if the reader advances during this capture.
1058    pub(crate) fn poll_workflow_output(&mut self) -> u64 {
1059        let activity_seq = self.queued_activity_seq.load(AtomicOrdering::Acquire);
1060        self.poll_output_nonblocking();
1061        activity_seq
1062    }
1063
1064    /// Arm strict activity beside the reader using the same full-screen snapshot
1065    /// that validates readiness. The reader uses the same live -> gate lock order.
1066    pub(crate) fn arm_prompt_activity(
1067        &mut self,
1068        hint: &str,
1069        text: &str,
1070    ) -> crate::errors::Result<(u64, Arc<AtomicBool>)> {
1071        let recognizer = crate::state::turn::recognizer_for(hint);
1072        let ambiguous_echo = prompt_text_mimics_activity(recognizer.as_ref(), text);
1073        let live = self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1074        let screen = live.snapshot(0);
1075        let input = crate::state::turn::TurnInput {
1076            screen: &screen,
1077            osc_title: live.osc_title(),
1078            osc_progress: live.osc_progress(),
1079        };
1080        if recognizer.detect_input(&input) != crate::state::turn::TurnState::AwaitingInput {
1081            return Err(crate::errors::WinxError::InvalidInput(
1082                "native prompt requires a running command at a recognized input-ready prompt"
1083                    .to_string(),
1084            ));
1085        }
1086        let baseline_seq = self.activity_seq();
1087        let observed = Arc::new(AtomicBool::new(false));
1088        *self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner) =
1089            Some(PromptActivityGate {
1090                recognizer,
1091                baseline_seq,
1092                observed: observed.clone(),
1093                submitted: false,
1094                ambiguous_echo,
1095                blocked_state: None,
1096            });
1097        Ok((baseline_seq, observed))
1098    }
1099
1100    /// Publish successful Enter delivery before the reader can classify its output.
1101    /// Typed echo present before Enter cannot itself satisfy strict activity.
1102    pub(crate) fn submit_prompt_enter(&mut self) -> Result<()> {
1103        let live = self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1104        let mut gate =
1105            self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1106        let gate = gate.as_mut().ok_or_else(|| anyhow!("prompt activity gate is unavailable"))?;
1107        let screen = live.snapshot(0);
1108        let state = gate.recognizer.detect_input(&crate::state::turn::TurnInput {
1109            screen: &screen,
1110            osc_title: live.osc_title(),
1111            osc_progress: live.osc_progress(),
1112        });
1113        gate.blocked_state = matches!(
1114            state,
1115            crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
1116        )
1117        .then_some(state);
1118        self.writer.write_all(b"\r")?;
1119        self.writer.flush()?;
1120        gate.submitted = true;
1121        Ok(())
1122    }
1123
1124    pub(crate) fn retained_workflow(
1125        &self,
1126        activity_seq: u64,
1127    ) -> crate::runtime::workflow::RetainedWorkflow {
1128        crate::runtime::workflow::RetainedWorkflow {
1129            generation: self.command_generation(),
1130            activity_seq,
1131            screen: self.live_snapshot(0),
1132            osc_title: self.live_osc_title(),
1133            osc_progress: self.live_osc_progress(),
1134            receipts: self.prompt_receipts.clone(),
1135        }
1136    }
1137
1138    pub fn incarnation(&self) -> u64 {
1139        self.incarnation
1140    }
1141
1142    /// Elapsed runtime since the current command was submitted.
1143    pub fn command_elapsed(&self) -> Option<Duration> {
1144        self.command_started_at.map(|started_at| started_at.elapsed())
1145    }
1146
1147    /// Current working directory last reported by this shell's prompt.
1148    pub fn current_cwd(&self) -> &Path {
1149        &self.current_cwd
1150    }
1151
1152    /// Snapshot used as the left-hand side of incremental output rendering.
1153    pub fn delivered_output(&self) -> String {
1154        self.delivered_output.clone()
1155    }
1156
1157    /// Mark the cumulative output snapshot as delivered after rendering it.
1158    pub fn mark_output_delivered(&mut self, output: &str) {
1159        self.delivered_output.clear();
1160        self.delivered_output.push_str(output);
1161    }
1162
1163    /// Return up to `lines` recent lines from the scrollback ring, oldest
1164    /// first. When older lines were evicted or a long unterminated line was
1165    /// clipped, a `[winx: …]` notice is prepended so the caller knows the
1166    /// scrollback is incomplete rather than silently receiving less than asked.
1167    pub fn collect_scrollback(&self, lines: usize) -> String {
1168        // The ring holds raw PTY lines. Replay them through the terminal
1169        // emulator so cursor movements (readline echo, in-place redraws) are
1170        // *applied* — not merely stripped — yielding what the screen actually
1171        // showed. A plain strip can't undo a `\x1b[D`, so it would leave
1172        // `>>> p>>> pr>>> pri...` echo noise behind; the emulator collapses it.
1173        let rendered =
1174            crate::state::terminal::render_terminal_output(&self.ring.raw(lines)).join("\n");
1175        match self.ring.scrollback_notice(lines) {
1176            Some(notice) => format!("{notice}\n{rendered}"),
1177            None => rendered,
1178        }
1179    }
1180
1181    /// Hash arbitrary rendered output into a u64 dedup key.
1182    pub fn fingerprint(text: &str) -> u64 {
1183        let mut hasher = DefaultHasher::new();
1184        text.hash(&mut hasher);
1185        hasher.finish()
1186    }
1187
1188    /// Read output from the PTY with timeout
1189    ///
1190    /// Returns (output, `is_complete`) tuple where `is_complete` indicates
1191    /// whether the command has finished (prompt detected)
1192    pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
1193        let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
1194        let start = Instant::now();
1195        let mut complete = false;
1196        let mut no_data_count = 0;
1197        let mut prompt_detected_at: Option<Instant> = None;
1198
1199        while start.elapsed() < timeout {
1200            match self.output_rx.try_recv() {
1201                Ok(chunk) => {
1202                    self.answer_terminal_queries(&chunk);
1203                    self.output_buffer.push_str(&chunk);
1204                    self.ring.push_chunk(&chunk);
1205                    no_data_count = 0;
1206
1207                    // Check for WCGW prompt indicating command completion
1208                    if prompt_detected_at.is_none()
1209                        && (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
1210                            || Self::check_prompt_complete(
1211                                &self.output_buffer,
1212                                &self.prompt_end_marker,
1213                            ))
1214                    {
1215                        prompt_detected_at = Some(Instant::now());
1216                        debug!("Prompt detected, draining remaining output...");
1217                    }
1218
1219                    // Truncate if too large (offloads the dropped head to scratch).
1220                    self.truncate_output_buffer_with_offload();
1221                }
1222                Err(TryRecvError::Empty) => {
1223                    // No data available, wait briefly
1224                    thread::sleep(Duration::from_millis(10));
1225                    no_data_count += 1;
1226
1227                    // If prompt was detected, check if we've drained long enough
1228                    if let Some(detected_time) = prompt_detected_at {
1229                        // Wait 100ms after prompt detection to capture any trailing output
1230                        if detected_time.elapsed() > Duration::from_millis(100) {
1231                            complete = true;
1232                            debug!("Command completed - prompt detected and drained");
1233                            break;
1234                        }
1235                    } else if no_data_count > 10
1236                        && Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1237                    {
1238                        // Prompt detected during empty reads
1239                        prompt_detected_at = Some(Instant::now());
1240                        debug!("Prompt detected after wait, draining...");
1241                    }
1242                }
1243                Err(TryRecvError::Disconnected) => {
1244                    // Reader thread died - PTY closed
1245                    warn!("PTY reader disconnected");
1246                    complete = true;
1247                    break;
1248                }
1249            }
1250        }
1251
1252        if complete || prompt_detected_at.is_some() {
1253            self.command_running = false;
1254            self.last_exit_code =
1255                Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1256            self.update_current_cwd_from_prompt();
1257            complete = true;
1258        }
1259
1260        Ok((self.output_buffer.clone(), complete))
1261    }
1262
1263    /// Non-blocking drain of whatever the reader thread has already queued.
1264    ///
1265    /// Unlike [`read_output`](Self::read_output), this never sleeps: it consumes
1266    /// every chunk currently available, updates prompt/completion state, and
1267    /// returns immediately. `prune_finished_shells` uses it so the global
1268    /// background-shell lock is never held across a blocking 100ms `read_output`.
1269    /// Returns `true` if the command has completed (prompt seen or PTY closed).
1270    pub fn poll_output_nonblocking(&mut self) -> bool {
1271        let mut prompt_seen = false;
1272        loop {
1273            match self.output_rx.try_recv() {
1274                Ok(chunk) => {
1275                    self.answer_terminal_queries(&chunk);
1276                    let chunk_has_prompt =
1277                        Self::check_prompt_complete(&chunk, &self.prompt_end_marker);
1278                    self.output_buffer.push_str(&chunk);
1279                    self.ring.push_chunk(&chunk);
1280                    if chunk_has_prompt
1281                        || Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1282                    {
1283                        prompt_seen = true;
1284                    }
1285                    self.truncate_output_buffer_with_offload();
1286                }
1287                Err(TryRecvError::Empty) => break,
1288                Err(TryRecvError::Disconnected) => {
1289                    self.command_running = false;
1290                    return true;
1291                }
1292            }
1293        }
1294        if prompt_seen {
1295            self.command_running = false;
1296            self.last_exit_code =
1297                Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1298            self.update_current_cwd_from_prompt();
1299        }
1300        // Completion is state, not an edge. Another consumer (notably the
1301        // background reaper) may have drained the prompt first; a later poll
1302        // must still report the already-idle shell as complete even when this
1303        // particular drain saw no new prompt bytes.
1304        !self.command_running
1305    }
1306
1307    fn update_current_cwd_from_prompt(&mut self) {
1308        let Some(last) =
1309            self.output_buffer.lines().rev().find(|line| line.contains(&self.prompt_end_marker))
1310        else {
1311            return;
1312        };
1313        let clean = crate::state::terminal::strip_ansi_codes(last);
1314        let Some((before_marker, _)) = clean.rsplit_once(&self.prompt_end_marker) else {
1315            return;
1316        };
1317        let Some((_, cwd)) = before_marker.rsplit_once("◉ ") else {
1318            return;
1319        };
1320        let cwd = cwd.trim();
1321        if !cwd.is_empty() {
1322            self.current_cwd = native_cwd_from_prompt(cwd);
1323        }
1324    }
1325
1326    /// Snapshot of the accumulated output buffer — the same text `read_output`
1327    /// returns. Lets callers poll non-blocking (`poll_output_nonblocking`) and
1328    /// grab the buffer once at the end instead of cloning it on every slice.
1329    pub fn output_snapshot(&self) -> String {
1330        self.output_buffer.clone()
1331    }
1332
1333    /// Check if the output ends with this shell's prompt.
1334    ///
1335    /// `prompt_end` is the per-shell `──➤<nonce>` suffix. Anchoring on the LAST
1336    /// non-empty line (not a global `contains`) plus the random nonce means
1337    /// command output that happens to print the prompt glyphs mid-stream — or
1338    /// even a forged `◉ x──➤ ` at end of line — can't be mistaken for the prompt
1339    /// and truncate output or end the command early.
1340    fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
1341        Self::parse_prompt_exit_code(text, prompt_end).is_some()
1342    }
1343
1344    /// If the last non-empty line is this shell's completion prompt
1345    /// (`◉ <pwd>──➤<nonce>:<code>`), return the exit code that follows the
1346    /// marker. Returns `None` when the prompt isn't present — which is exactly
1347    /// "the command is still running". The per-shell nonce plus the structured
1348    /// `:<digits>` suffix keep command output from forging completion.
1349    fn parse_prompt_exit_code(text: &str, prompt_end: &str) -> Option<i32> {
1350        let last = text.lines().rev().find(|line| !line.trim().is_empty())?;
1351        if !last.contains(WCGW_PROMPT_PATTERN) {
1352            return None;
1353        }
1354        // Everything after the last "──➤<nonce>" must be ":<digits>" followed by
1355        // the prompt's own terminator — this confirms the prompt ends the line
1356        // AND yields the code. The raw tail keeps its escape sequences: a
1357        // trailing erase/cursor sequence (":0 \x1b[K", or ConPTY's ":0\x1b[C")
1358        // counts as the terminator, so it is stripped only for parsing.
1359        let raw_tail = last.rsplit_once(prompt_end)?.1;
1360        let clean_tail = crate::state::terminal::strip_ansi_codes(raw_tail);
1361        let code_text = clean_tail.strip_prefix(':')?;
1362        let digits = code_text
1363            .char_indices()
1364            .take_while(|(index, c)| c.is_ascii_digit() || (*index == 0 && *c == '-'))
1365            .count();
1366        let (code, rest) = code_text.split_at(digits);
1367        if !rest.chars().all(char::is_whitespace) {
1368            return None;
1369        }
1370        // A read boundary can fall between the digits of "127": without the
1371        // terminator the record is still in flight, and accepting "1" now would
1372        // report the wrong exit code before the rest arrives.
1373        let terminated = !rest.is_empty() || raw_tail.len() > clean_tail.len();
1374        if !terminated {
1375            return None;
1376        }
1377        code.parse::<i32>().ok()
1378    }
1379
1380    /// Point output-offload scratch files at `root` (the workspace). Idempotent;
1381    /// callers set it per command so it always tracks the current workspace.
1382    pub fn set_scratch_root(&mut self, root: &Path) {
1383        self.scratch_workspace_root = Some(root.to_path_buf());
1384    }
1385
1386    /// The scratch file holding the current command's dropped output head, if any
1387    /// was offloaded this command.
1388    pub fn scratch_path(&self) -> Option<&Path> {
1389        self.scratch_path.as_deref()
1390    }
1391
1392    /// Forget the current command's scratch file so the next command starts a
1393    /// fresh one. Does not delete the file (pruned later by age).
1394    pub fn reset_scratch(&mut self) {
1395        self.scratch_path = None;
1396        self.scratch_bytes = 0;
1397        self.discard_throttle.store(false, AtomicOrdering::Relaxed);
1398    }
1399
1400    /// Append a just-dropped output `head` to the per-command scratch file so the
1401    /// agent can recover it via `ReadFiles`. Best-effort: needs a known workspace
1402    /// root, stops at `SCRATCH_MAX_BYTES`, and swallows IO errors.
1403    fn offload_dropped_head(&mut self, head: &str) {
1404        let Some(root) = self.scratch_workspace_root.clone() else {
1405            return;
1406        };
1407        // Clamp to the remaining byte budget so the file honors SCRATCH_MAX_BYTES
1408        // exactly (a single head can be up to keep_size, ~500 KB). Snap to a char
1409        // boundary so we never slice mid-UTF-8.
1410        let remaining = SCRATCH_MAX_BYTES.saturating_sub(self.scratch_bytes);
1411        if remaining == 0 {
1412            // From here on the head is discarded outright — reading faster
1413            // only burns CPU. Engage reader back-pressure until the next
1414            // command resets the scratch state.
1415            self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1416            return;
1417        }
1418        let head = match usize::try_from(remaining) {
1419            Ok(rem) if rem < head.len() => &head[..crate::utils::floor_char_boundary(head, rem)],
1420            _ => head,
1421        };
1422        if self.scratch_path.is_none() {
1423            self.scratch_path = crate::utils::scratch_file::new_scratch_path(&root);
1424        }
1425        let Some(path) = self.scratch_path.clone() else {
1426            return;
1427        };
1428        match crate::utils::scratch_file::append_scratch(&path, head.as_bytes()) {
1429            Ok(0) if self.scratch_bytes == 0 => self.scratch_path = None,
1430            Ok(written) => {
1431                self.scratch_bytes = self.scratch_bytes.saturating_add(written as u64);
1432                if self.scratch_bytes >= SCRATCH_MAX_BYTES {
1433                    self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1434                }
1435            }
1436            Err(e) => debug!("scratch: append to {} failed: {e}", path.display()),
1437        }
1438    }
1439
1440    /// Once `output_buffer` exceeds the cap, offload the head about to be dropped
1441    /// to the scratch file, then keep only the tail with a truncation marker.
1442    /// Shared by the blocking and non-blocking drain paths so the offload and the
1443    /// char-boundary handling live in one place.
1444    fn truncate_output_buffer_with_offload(&mut self) {
1445        if self.output_buffer.len() <= self.max_output_size {
1446            return;
1447        }
1448        self.output_truncated = true;
1449        let keep_size = self.max_output_size / 2;
1450        // Snap to a char boundary: a raw byte offset can land mid-UTF-8
1451        // (CJK/emoji/box-drawing/the prompt glyphs) and slicing there would panic.
1452        let cut = crate::utils::floor_char_boundary(
1453            &self.output_buffer,
1454            self.output_buffer.len() - keep_size,
1455        );
1456        let head = self.output_buffer[..cut].to_owned();
1457        self.offload_dropped_head(&head);
1458        // Replace the dropped head in place instead of `format!`-ing a brand-new
1459        // String: `replace_range` shifts the kept tail down once and reuses the
1460        // buffer's allocation. `cut` is already a char boundary (above), and 0 is
1461        // always one, so the range is valid.
1462        self.output_buffer.replace_range(..cut, "\n(...output truncated...)\n");
1463    }
1464
1465    /// Send Ctrl+C (interrupt) to the PTY
1466    pub fn send_interrupt(&mut self) -> Result<()> {
1467        debug!("PTY sending Ctrl+C");
1468        self.writer
1469            .write_all(&[0x03]) // ASCII ETX (Ctrl+C)
1470            .context("Failed to send Ctrl+C")?;
1471        self.writer.flush()?;
1472        Ok(())
1473    }
1474
1475    /// Send Ctrl+D (EOF) to the PTY
1476    pub fn send_eof(&mut self) -> Result<()> {
1477        debug!("PTY sending Ctrl+D");
1478        self.writer
1479            .write_all(&[0x04]) // ASCII EOT (Ctrl+D)
1480            .context("Failed to send Ctrl+D")?;
1481        self.writer.flush()?;
1482        Ok(())
1483    }
1484
1485    /// Send Ctrl+Z (suspend) to the PTY
1486    pub fn send_suspend(&mut self) -> Result<()> {
1487        debug!("PTY sending Ctrl+Z");
1488        self.writer
1489            .write_all(&[0x1A]) // ASCII SUB (Ctrl+Z)
1490            .context("Failed to send Ctrl+Z")?;
1491        self.writer.flush()?;
1492        Ok(())
1493    }
1494
1495    /// Send text directly to the PTY (for interactive input)
1496    pub fn send_text(&mut self, text: &str) -> Result<()> {
1497        debug!(bytes = text.len(), "PTY sending text");
1498        self.send_bytes(text.as_bytes()).context("Failed to send text")?;
1499        Ok(())
1500    }
1501
1502    /// Send raw bytes directly to the PTY.
1503    pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
1504        self.writer.write_all(bytes).context("Failed to send bytes")?;
1505        self.writer.flush()?;
1506        Ok(())
1507    }
1508
1509    /// Send a special key sequence
1510    pub fn send_special_key(&mut self, key: &str) -> Result<()> {
1511        let bytes: &[u8] = match key {
1512            "Enter" => b"\r",
1513            "Tab" => b"\t",
1514            "Backspace" => b"\x7F",
1515            "Escape" => b"\x1B",
1516            "Up" | "KeyUp" => b"\x1B[A",
1517            "Down" | "KeyDown" => b"\x1B[B",
1518            "Right" | "KeyRight" => b"\x1B[C",
1519            "Left" | "KeyLeft" => b"\x1B[D",
1520            "Home" => b"\x1B[H",
1521            "End" => b"\x1B[F",
1522            "PageUp" => b"\x1B[5~",
1523            "PageDown" => b"\x1B[6~",
1524            "Delete" => b"\x1B[3~",
1525            "Insert" => b"\x1B[2~",
1526            "CtrlC" | "Ctrl-C" => b"\x03",
1527            "CtrlD" | "Ctrl-D" => b"\x04",
1528            "CtrlZ" | "Ctrl-Z" => b"\x1A",
1529            "CtrlL" | "Ctrl-L" => b"\x0C",
1530            _ => return Err(anyhow!("Unknown special key: {key}")),
1531        };
1532
1533        debug!("PTY sending special key: {} ({:?})", key, bytes);
1534        self.send_bytes(bytes)?;
1535        Ok(())
1536    }
1537
1538    /// Snapshot the live terminal screen — a stable, consolidated view of what a
1539    /// human would currently see, with cursor moves/redraws applied and ANSI
1540    /// stripped. `max_lines` of 0 returns the full screen buffer. This is the
1541    /// foundation for piloting interactive TUIs (the `claude` CLI, vim, ...).
1542    pub fn live_snapshot(&self, max_lines: usize) -> Vec<String> {
1543        // Recover from a poisoned lock instead of returning a blank screen
1544        // forever — a single panic while holding `live` would otherwise wedge
1545        // every future snapshot (and `wait_for_turn`/`screen` with it).
1546        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).snapshot(max_lines)
1547    }
1548
1549    /// Whether the live terminal is currently on the alternate screen buffer
1550    /// (a full-screen app like vim/htop/less is running).
1551    pub fn live_in_alt_screen(&self) -> bool {
1552        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).in_alt_screen()
1553    }
1554
1555    /// The latest OSC 0/2 window title emitted by the foreground program (`""`
1556    /// when none) — an out-of-band turn signal for the manifest recognizers
1557    /// (claude/codex render a spinner glyph in the title while generating).
1558    pub fn live_osc_title(&self) -> String {
1559        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).osc_title().to_string()
1560    }
1561
1562    /// The latest OSC 9 payload after `9;` (`""` when none) — ConEmu-style
1563    /// progress reads `4;<state>;<percent>`.
1564    pub fn live_osc_progress(&self) -> String {
1565        self.live
1566            .lock()
1567            .unwrap_or_else(std::sync::PoisonError::into_inner)
1568            .osc_progress()
1569            .to_string()
1570    }
1571
1572    /// Cursor position `(row, col)` on the live screen (0-based), so a piloting
1573    /// agent knows where focus is in a menu/form. `(0, 0)` if the lock is poisoned.
1574    pub fn live_cursor_position(&self) -> (u16, u16) {
1575        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_position()
1576    }
1577
1578    /// Text left of the cursor on its row (see [`LiveTerminal::cursor_line_prefix`]).
1579    pub(crate) fn live_cursor_line_prefix(&self) -> String {
1580        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_line_prefix()
1581    }
1582
1583    /// Diff the live screen against what the client last saw, returning only the
1584    /// changed lines when the change is small (huge token savings over many
1585    /// polls). Updates the baseline as a side effect. See [`ScreenUpdate`].
1586    pub fn live_snapshot_diff(&self, max_lines: usize, threshold: usize) -> ScreenUpdate {
1587        self.live
1588            .lock()
1589            .unwrap_or_else(std::sync::PoisonError::into_inner)
1590            .snapshot_diff(max_lines, threshold)
1591    }
1592
1593    /// Resize the terminal
1594    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
1595        debug!("PTY resizing to {}x{}", cols, rows);
1596
1597        let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
1598
1599        self.master.resize(new_size).context("Failed to resize PTY")?;
1600
1601        // Keep the live emulator's viewport in lockstep with the PTY size.
1602        self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).resize(rows, cols);
1603
1604        self.size = new_size;
1605        Ok(())
1606    }
1607
1608    /// Get current terminal size
1609    pub fn get_size(&self) -> (u16, u16) {
1610        (self.size.cols, self.size.rows)
1611    }
1612
1613    /// OS process id of the shell that owns this PTY.
1614    pub fn process_id(&self) -> Option<u32> {
1615        self.child.process_id()
1616    }
1617
1618    /// Check if the shell is still alive
1619    pub fn is_alive(&mut self) -> bool {
1620        self.child.try_wait().is_ok_and(|status| status.is_none())
1621    }
1622}
1623
1624/// Thread-safe wrapper for `PtyShell`
1625pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
1626
1627/// Create a new shared PTY shell
1628pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
1629    let shell = PtyShell::new(initial_dir, restricted_mode)?;
1630    Ok(Arc::new(Mutex::new(Some(shell))))
1631}
1632
1633/// Split a byte slice into the longest valid-UTF-8 prefix (decoded) and the
1634/// trailing bytes of an incomplete final char (to carry into the next read).
1635/// Genuinely invalid sequences become a single U+FFFD and are consumed, so the
1636/// carry only ever holds a recoverable incomplete tail and never grows unbounded.
1637///
1638/// Iterative on purpose: a recursive version blows the reader thread's stack on
1639/// a burst of invalid bytes (e.g. `cat`-ing a binary), which aborts under
1640/// `panic = "abort"`. This stays O(1) in stack depth regardless of input.
1641fn decode_keep_incomplete(bytes: &[u8]) -> (String, Vec<u8>) {
1642    let mut decoded = String::new();
1643    let mut rest = bytes;
1644    loop {
1645        match std::str::from_utf8(rest) {
1646            Ok(text) => {
1647                decoded.push_str(text);
1648                return (decoded, Vec::new());
1649            }
1650            Err(error) => {
1651                let valid = error.valid_up_to();
1652                // `rest[..valid]` is valid UTF-8 by construction (no allocation
1653                // for the borrowed case).
1654                decoded.push_str(&String::from_utf8_lossy(&rest[..valid]));
1655                match error.error_len() {
1656                    // Incomplete final char: keep the tail for the next read.
1657                    None => return (decoded, rest[valid..].to_vec()),
1658                    // Genuinely invalid run: emit one replacement and skip past it.
1659                    Some(bad) => {
1660                        decoded.push('\u{FFFD}');
1661                        rest = &rest[valid + bad..];
1662                    }
1663                }
1664            }
1665        }
1666    }
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671    use super::*;
1672    #[cfg(unix)] // only the shell-spawning tests need a workspace
1673    use tempfile::TempDir;
1674
1675    #[test]
1676    fn decode_passes_clean_utf8_through() {
1677        let (text, carry) = decode_keep_incomplete("hello ✓ café".as_bytes());
1678        assert_eq!(text, "hello ✓ café");
1679        assert!(carry.is_empty(), "expected empty carry");
1680    }
1681
1682    #[test]
1683    fn decode_carries_incomplete_trailing_char_across_reads() {
1684        // "é" is 0xC3 0xA9; a read boundary splits it. The first half must NOT
1685        // turn into U+FFFD — it's held and completed on the next read.
1686        let (text1, carry1) = decode_keep_incomplete(&[b'a', 0xC3]);
1687        assert_eq!(text1, "a");
1688        assert_eq!(carry1, vec![0xC3]);
1689
1690        let mut next = carry1;
1691        next.push(0xA9); // continuation byte arrives in the next read
1692        let (text2, carry2) = decode_keep_incomplete(&next);
1693        assert_eq!(text2, "é");
1694        assert!(carry2.is_empty(), "expected empty carry2");
1695    }
1696
1697    #[test]
1698    fn decode_replaces_genuinely_invalid_bytes() {
1699        // 0xFF is never valid UTF-8 and is not an incomplete prefix.
1700        let (text, carry) = decode_keep_incomplete(&[b'x', 0xFF, b'y']);
1701        assert_eq!(text, "x\u{FFFD}y");
1702        assert!(carry.is_empty(), "expected empty carry");
1703    }
1704
1705    #[test]
1706    fn decode_handles_large_invalid_burst_without_overflow() {
1707        // Regression: the first (recursive) version blew the stack — one frame
1708        // per invalid byte — and aborted under panic="abort" on a binary dump.
1709        // The iterative version must handle far more than the 4KB reader buffer.
1710        let big = vec![0xFFu8; 200_000];
1711        let (text, carry) = decode_keep_incomplete(&big);
1712        assert!(carry.is_empty(), "expected empty carry");
1713        assert_eq!(text.chars().filter(|&c| c == '\u{FFFD}').count(), 200_000);
1714    }
1715
1716    #[test]
1717    fn screen_session_name_parser_is_strict() {
1718        assert_eq!(super::winx_creator_pid("winx-123-456"), Some(123));
1719        assert_eq!(super::winx_creator_pid("winx-nope-456"), None);
1720        assert_eq!(super::winx_creator_pid("other-123-456"), None);
1721    }
1722
1723    #[cfg(unix)]
1724    #[test]
1725    fn process_probe_recognizes_current_process_and_rejects_reserved_pids() {
1726        assert!(super::process_exists(std::process::id()));
1727        assert!(!super::process_exists(0));
1728        assert!(!super::process_exists(1));
1729    }
1730
1731    #[test]
1732    fn msys_paths_convert_to_native_drives() {
1733        use super::msys_to_native;
1734        assert_eq!(msys_to_native("/c/Users/x/ws"), Some(PathBuf::from("C:\\Users\\x\\ws")));
1735        assert_eq!(msys_to_native("/cygdrive/d/work"), Some(PathBuf::from("D:\\work")));
1736        assert_eq!(msys_to_native("/c"), Some(PathBuf::from("C:\\")));
1737        assert_eq!(msys_to_native("/home/x"), None);
1738        assert_eq!(msys_to_native("relative"), None);
1739    }
1740
1741    #[test]
1742    fn prompt_detection_is_suffix_anchored() {
1743        // Per-shell nonce + exit code after the arrow: `──➤<nonce>:<code>`.
1744        let end = "──➤deadbeefcafe0001";
1745        // real prompt on the last line -> complete
1746        assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001:0 ", end));
1747        // prompt with trailing ANSI erase -> still complete
1748        assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 \u{1b}[K", end));
1749        // the bug: ◉ and ──➤ appear MID-output, last line is normal -> NOT complete
1750        assert!(!PtyShell::check_prompt_complete(
1751            "menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
1752            end
1753        ));
1754        // command echoed after the prompt (not the waiting prompt) -> not complete
1755        assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 ls -la", end));
1756        // no prompt at all
1757        assert!(!PtyShell::check_prompt_complete("just some output\n", end));
1758        // the nonce's payoff: output that forges the bare glyphs but NOT the
1759        // nonce (or omits the exit code) must not be mistaken for the prompt.
1760        assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
1761        assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce:0 ", end));
1762        assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ", end));
1763    }
1764
1765    #[test]
1766    fn powershell_bootstrap_defines_a_marker_prompt_without_psreadline() {
1767        let bootstrap = super::powershell_bootstrap("cafe1234");
1768        assert!(bootstrap.contains("Remove-Module PSReadLine"), "{bootstrap}");
1769        assert!(bootstrap.contains("function global:prompt"), "{bootstrap}");
1770        assert!(bootstrap.contains("──➤cafe1234:$code "), "{bootstrap}");
1771        assert!(bootstrap.contains("$global:LASTEXITCODE = 0"), "{bootstrap}");
1772        assert!(!bootstrap.contains('\n'), "bootstrap must be a single input line");
1773    }
1774
1775    #[test]
1776    fn parses_exit_code_from_prompt_marker() {
1777        let end = "──➤cafe1234";
1778        assert_eq!(PtyShell::parse_prompt_exit_code("ok\n◉ /x──➤cafe1234:0 ", end), Some(0));
1779        assert_eq!(PtyShell::parse_prompt_exit_code("oops\n◉ /x──➤cafe1234:1 ", end), Some(1));
1780        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:42 ", end), Some(42));
1781        // A record split by a read boundary inside the digits is still in flight.
1782        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:1", end), None);
1783        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127", end), None);
1784        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127 ", end), Some(127));
1785        // ConPTY may replace the trailing space with a cursor move.
1786        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127\u{1b}[C", end), Some(127));
1787        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:0\u{1b}[K", end), Some(0));
1788        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:-1 ", end), Some(-1));
1789        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:12a ", end), None);
1790        // still running (no completion prompt yet) -> None
1791        assert_eq!(PtyShell::parse_prompt_exit_code("building...", end), None);
1792        // forged with the wrong nonce -> None
1793        assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤wrong:0 ", end), None);
1794    }
1795
1796    use proptest::prelude::*;
1797
1798    proptest! {
1799        // Exit-code parsing runs on untrusted program output — arbitrary text
1800        // (and a forgeable marker) must never panic, only ever return None or a
1801        // parsed i32.
1802        #[test]
1803        fn parse_prompt_exit_code_never_panics(
1804            text in prop::collection::vec(any::<char>(), 0..256),
1805            nonce in "[0-9a-f]{0,16}",
1806        ) {
1807            let s: String = text.into_iter().collect();
1808            let end = format!("{WCGW_PROMPT_END}{nonce}");
1809            // Must not panic; any Option<i32> is acceptable.
1810            let _ = PtyShell::parse_prompt_exit_code(&s, &end);
1811        }
1812
1813        // A well-formed marker round-trips the embedded code for any i32.
1814        #[test]
1815        fn parse_prompt_exit_code_round_trips(code in any::<i32>(), nonce in "[0-9a-f]{1,16}") {
1816            let end = format!("{WCGW_PROMPT_END}{nonce}");
1817            let line = format!("output line\n{WCGW_PROMPT_PATTERN} /home/x{end}:{code} ");
1818            prop_assert_eq!(PtyShell::parse_prompt_exit_code(&line, &end), Some(code));
1819        }
1820    }
1821
1822    #[cfg(unix)] // spawns a real shell
1823    #[test]
1824    fn test_pty_shell_creation() -> Result<()> {
1825        let temp_dir = TempDir::new()?;
1826        let result = PtyShell::new(temp_dir.path(), false);
1827        assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
1828        Ok(())
1829    }
1830
1831    #[cfg(unix)] // spawns a real shell
1832    #[test]
1833    fn discard_throttle_engages_at_scratch_cap_and_resets_per_command() -> Result<()> {
1834        let temp_dir = TempDir::new()?;
1835        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1836        assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1837
1838        // Saturated scratch budget: the next dropped head is pure discard and
1839        // must engage reader back-pressure.
1840        shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1841        shell.scratch_bytes = SCRATCH_MAX_BYTES;
1842        shell.offload_dropped_head("dropped beyond the budget");
1843        assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1844
1845        // A new command resets scratch state and releases the throttle.
1846        shell.send_command("echo done")?;
1847        assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1848        Ok(())
1849    }
1850
1851    #[cfg(unix)] // spawns a real shell
1852    #[test]
1853    fn discard_throttle_engages_when_a_write_reaches_the_cap() -> Result<()> {
1854        let temp_dir = TempDir::new()?;
1855        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1856        shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1857        // One byte of budget left: this write saturates the cap exactly.
1858        shell.scratch_bytes = SCRATCH_MAX_BYTES - 1;
1859        shell.offload_dropped_head("xx");
1860        assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1861        Ok(())
1862    }
1863
1864    #[cfg(unix)] // spawns a real shell
1865    #[test]
1866    fn test_pty_shell_echo() -> Result<()> {
1867        let temp_dir = TempDir::new()?;
1868        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1869
1870        shell.send_command("echo 'hello pty'")?;
1871        let (output, complete) = shell.read_output(2.0)?;
1872
1873        assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
1874        // The bootstrap (prompt export line, shell banner) must have been
1875        // consumed before the first command, and the first command must
1876        // already report its exit code.
1877        assert!(complete, "first command must complete: {output}");
1878        assert!(!output.contains("PROMPT_COMMAND"), "bootstrap leaked into output: {output}");
1879        assert_eq!(shell.last_exit_code, Some(0), "{output}");
1880        Ok(())
1881    }
1882
1883    #[cfg(unix)] // spawns a real shell
1884    #[test]
1885    fn agent_paths_are_exported_to_the_pty() -> Result<()> {
1886        let workspace = TempDir::new()?;
1887        let temporary_artifact_dir = workspace.path().join(".winx/tmp/session-test");
1888        let mut shell = PtyShell::new_with_agent_paths(
1889            workspace.path(),
1890            false,
1891            Some(workspace.path()),
1892            Some(&temporary_artifact_dir),
1893        )?;
1894
1895        shell.send_command("printf '%s\\n%s\\n' \"$WINX_WORKSPACE_ROOT\" \"$WINX_TEMP_DIR\"")?;
1896        let (output, _complete) = shell.read_output(2.0)?;
1897
1898        assert!(output.contains(&workspace.path().to_string_lossy().to_string()), "{output}");
1899        assert!(output.contains(&temporary_artifact_dir.to_string_lossy().to_string()), "{output}");
1900        Ok(())
1901    }
1902
1903    #[cfg(unix)] // spawns a real shell
1904    #[test]
1905    fn completed_nonblocking_poll_is_level_triggered() -> Result<()> {
1906        let temp_dir = TempDir::new()?;
1907        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1908
1909        shell.send_command("true")?;
1910        let deadline = Instant::now() + Duration::from_secs(2);
1911        while Instant::now() < deadline && !shell.poll_output_nonblocking() {
1912            std::thread::sleep(Duration::from_millis(10));
1913        }
1914
1915        assert!(!shell.command_running, "shell did not complete before the deadline");
1916        assert!(
1917            shell.poll_output_nonblocking(),
1918            "a later consumer must still observe the completed state"
1919        );
1920        Ok(())
1921    }
1922
1923    #[cfg(unix)] // spawns a real shell
1924    #[test]
1925    fn test_pty_shell_pwd() -> Result<()> {
1926        let temp_dir = TempDir::new()?;
1927        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1928
1929        // Simply verify shell responds to pwd command
1930        // Use single quotes like echo test for consistency
1931        shell.send_command("pwd && echo 'pwd_done'")?;
1932        let (output, _complete) = shell.read_output(2.0)?;
1933
1934        // Verify the echo marker appears (proves command executed)
1935        assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
1936        Ok(())
1937    }
1938
1939    #[cfg(unix)] // spawns a real shell
1940    #[test]
1941    fn test_pty_resize() -> Result<()> {
1942        let temp_dir = TempDir::new()?;
1943        let mut shell = PtyShell::new(temp_dir.path(), false)?;
1944
1945        let result = shell.resize(120, 40);
1946        assert!(result.is_ok());
1947
1948        let (cols, rows) = shell.get_size();
1949        assert_eq!(cols, 120);
1950        assert_eq!(rows, 40);
1951        Ok(())
1952    }
1953
1954    /// `kill(pid, 0)` probes existence without sending a signal: 0 = the pid
1955    /// exists, -1 (ESRCH) = it's gone. A freshly-killed orphan lingers briefly
1956    /// as a zombie under init until reaped, so callers poll.
1957    #[cfg(unix)]
1958    fn pid_alive(pid: i32) -> bool {
1959        u32::try_from(pid).is_ok_and(crate::os::unix::process_exists)
1960    }
1961
1962    #[cfg(unix)]
1963    #[test]
1964    fn dropping_shell_kills_orphaned_background_child() -> Result<()> {
1965        // A background job the shell starts must not outlive the shell. Spawn a
1966        // long `sleep` in the background, record its pid, drop the shell, and
1967        // confirm the sleep is gone - proving the group-kill reached past the
1968        // shell to a child `Child::kill` alone would have orphaned.
1969        let dir = TempDir::new()?;
1970        let pidfile = dir.path().join("child.pid");
1971        let mut shell = PtyShell::new(dir.path(), false)?;
1972
1973        shell
1974            .send_command(&format!("sleep 300 & echo $! > {} ; echo spawned", pidfile.display()))?;
1975
1976        // The PTY echoes the input line, so `out` containing "spawned" only proves
1977        // the line was sent - not that bash ran it. Under CI load bash can lag, so
1978        // poll the pidfile (it only appears once `echo $! > pidfile` executes) while
1979        // draining the PTY, instead of trusting the echo and racing the redirect.
1980        let mut child_pid = None;
1981        for _ in 0..150 {
1982            let _ = shell.read_output(0.1);
1983            if let Some(pid) =
1984                std::fs::read_to_string(&pidfile).ok().and_then(|c| c.trim().parse::<i32>().ok())
1985            {
1986                child_pid = Some(pid);
1987                break;
1988            }
1989            thread::sleep(Duration::from_millis(20));
1990        }
1991        let child_pid =
1992            child_pid.ok_or_else(|| anyhow!("background child pid never materialized"))?;
1993        assert!(pid_alive(child_pid), "background child should be running before drop");
1994
1995        drop(shell);
1996
1997        // The group-kill plus init reaping the orphan are async w.r.t. us; poll.
1998        let mut gone = false;
1999        for _ in 0..150 {
2000            if !pid_alive(child_pid) {
2001                gone = true;
2002                break;
2003            }
2004            thread::sleep(Duration::from_millis(20));
2005        }
2006        assert!(gone, "background child {child_pid} survived shell teardown (orphan leak)");
2007        Ok(())
2008    }
2009}