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