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