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