1use crate::utils::executable::{shell_kind_of, ShellKind};
11use anyhow::{anyhow, Context, Result};
12use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
13use std::collections::hash_map::DefaultHasher;
14use std::hash::{Hash, Hasher};
15use std::io::{Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
19use std::sync::mpsc::{self, TryRecvError};
20use std::sync::{Arc, Mutex as StdMutex};
21use std::thread;
22use std::time::{Duration, Instant};
23use tokio::sync::Mutex;
24use tracing::{debug, info, warn};
25
26use crate::state::line_ring::LineRing;
27use crate::state::live_terminal::{LiveTerminal, ScreenUpdate};
28
29pub const DEFAULT_COLS: u16 = 200;
31pub const DEFAULT_ROWS: u16 = 50;
32
33const MAX_OUTPUT_SIZE: usize = 1_000_000;
35
36const SCRATCH_MAX_BYTES: u64 = 50 * 1024 * 1024;
40
41const DISCARD_READ_THROTTLE: Duration = Duration::from_millis(2);
50
51pub const RING_BUFFER_LINES: usize = 2_000;
55
56const MAX_PARTIAL_LINE_BYTES: usize = 64 * 1024;
60
61const WCGW_PROMPT_PATTERN: &str = "◉";
63const WCGW_PROMPT_END: &str = "──➤";
64const PROMPT_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(5);
68
69fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>, ShellKind) {
70 let requested = crate::config::env_text("WINX_ATTACH_TERMINAL")
71 .or_else(|| crate::config::env_text("WINX_USE_SCREEN"))
72 .unwrap_or_default();
73 if requested == "tmux"
74 || requested == "screen"
75 || crate::config::parse_bool(&requested) == Some(true)
76 {
77 let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
78 if requested == "tmux" && command_available("tmux") {
79 let mut cmd = CommandBuilder::new("tmux");
80 cmd.args(["new-session", "-A", "-s", &session, "bash"]);
81 if restricted_mode {
82 cmd.arg("-r");
83 }
84 return (cmd, Some(format!("tmux attach -t {session}")), ShellKind::Bash);
85 }
86 if command_available("screen") {
87 ensure_screenrc();
90 cleanup_orphaned_screens();
91 let mut cmd = CommandBuilder::new("screen");
92 cmd.args(["-q", "-S", &session, "bash"]);
93 if restricted_mode {
94 cmd.arg("-r");
95 }
96 return (cmd, Some(format!("screen -x {session}")), ShellKind::Bash);
97 }
98 }
99
100 let shell = preferred_shell(restricted_mode);
101 let kind = shell_kind_of(&shell);
103 let mut cmd = CommandBuilder::new(&shell);
104 match kind {
105 ShellKind::Bash if restricted_mode => {
107 cmd.arg("-r");
108 }
109 ShellKind::PowerShell => {
112 cmd.arg("-NoLogo");
113 }
114 ShellKind::Bash | ShellKind::Zsh => {}
115 }
116 if let Some(extra) = crate::config::env_text("WINX_SHELL_ARGS") {
119 for arg in extra.split_whitespace() {
120 cmd.arg(arg);
121 }
122 }
123 (cmd, None, kind)
124}
125
126pub fn configured_shell_kind(restricted_mode: bool) -> ShellKind {
130 shell_kind_of(&preferred_shell(restricted_mode))
131}
132
133#[cfg(windows)]
142fn ensure_windows_shell_available(restricted_mode: bool) -> Result<()> {
143 if !restricted_mode {
145 if let Ok(requested) = std::env::var("WINX_SHELL") {
146 let path = Path::new(&requested);
147 if path.is_absolute() && path.is_file() {
148 return Ok(());
149 }
150 }
151 }
152 if crate::utils::executable::bash_executable().is_some() {
153 return Ok(());
154 }
155 if crate::utils::executable::powershell_executable().is_some() {
156 if restricted_mode {
157 return Err(anyhow!(
158 "restricted mode needs bash -r; install Git for Windows or set WINX_SHELL to an \
159 absolute bash.exe path (PowerShell has no equivalent restricted mode)"
160 ));
161 }
162 return Ok(());
163 }
164 Err(anyhow!(
165 "no usable shell found: install Git for Windows (the System32 WSL launcher is not \
166 used) or PowerShell 7, or set WINX_SHELL to an absolute bash.exe or pwsh.exe path"
167 ))
168}
169
170fn preferred_shell(restricted_mode: bool) -> String {
171 if !restricted_mode {
172 if let Ok(requested) = std::env::var("WINX_SHELL") {
173 if requested == "zsh" && command_available("zsh") {
174 return "zsh".to_string();
175 }
176 if matches!(requested.as_str(), "pwsh" | "powershell") {
179 if let Some(found) = crate::utils::executable::powershell_executable() {
180 return found.to_string_lossy().into_owned();
181 }
182 }
183 if Path::new(&requested).is_absolute() && Path::new(&requested).is_file() {
186 return requested;
187 }
188 }
189 }
190 if cfg!(windows) {
191 if let Some(bash) = crate::utils::executable::bash_executable() {
192 return bash.to_string_lossy().into_owned();
193 }
194 if !restricted_mode {
197 if let Some(found) = crate::utils::executable::powershell_executable() {
198 return found.to_string_lossy().into_owned();
199 }
200 }
201 }
202 "bash".to_string()
203}
204
205fn powershell_bootstrap(nonce: &str) -> String {
217 format!(
218 "Remove-Module PSReadLine -ErrorAction SilentlyContinue; \
219 if (Get-Variable PSStyle -ErrorAction SilentlyContinue) {{ $PSStyle.OutputRendering = 'PlainText' }}; \
220 $env:PAGER = 'cat'; $env:GIT_PAGER = 'cat'; $global:LASTEXITCODE = 0; \
221 function global:prompt {{ $ok = $?; $ec = $global:LASTEXITCODE; \
222 $code = if ($ec -is [int] -and $ec -ne 0) {{ $ec }} elseif ($ok) {{ 0 }} else {{ 1 }}; \
223 $global:LASTEXITCODE = 0; \
224 \"\u{25c9} $($ExecutionContext.SessionState.Path.CurrentLocation.ProviderPath)\u{2500}\u{2500}\u{27a4}{nonce}:$code \" }}"
225 )
226}
227
228fn command_available(command: &str) -> bool {
229 crate::utils::executable::is_available(command)
230}
231
232fn ensure_screenrc() {
235 let Some(home) = home::home_dir() else {
236 return;
237 };
238 let screenrc = home.join(".screenrc");
239 if screenrc.exists() {
240 return;
241 }
242 let _ = std::fs::write(
243 &screenrc,
244 "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
245 );
246}
247
248fn cleanup_orphaned_screens() {
254 let Ok(output) = Command::new("screen").arg("-ls").output() else {
255 return;
256 };
257 let listing = String::from_utf8_lossy(&output.stdout);
259 for line in listing.lines() {
260 let Some(session) = line.split_whitespace().next() else {
261 continue;
262 };
263 let Some((_, name)) = session.split_once('.') else {
265 continue;
266 };
267 if let Some(creator_pid) = winx_creator_pid(name) {
268 if !process_exists(creator_pid) {
269 let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
270 }
271 }
272 }
273}
274
275fn winx_creator_pid(name: &str) -> Option<u32> {
277 name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
278}
279
280#[cfg(unix)]
283fn process_exists(pid: u32) -> bool {
284 crate::os::unix::process_exists(pid)
285}
286
287#[cfg(not(unix))]
288fn process_exists(pid: u32) -> bool {
289 crate::os::process_exists(pid)
290}
291
292pub(crate) fn native_cwd_from_prompt(cwd: &str) -> PathBuf {
297 if cfg!(windows) {
298 if let Some(native) = msys_to_native(cwd) {
299 return native;
300 }
301 }
302 PathBuf::from(cwd)
303}
304
305fn msys_to_native(cwd: &str) -> Option<PathBuf> {
306 let rest = cwd.strip_prefix("/cygdrive").unwrap_or(cwd);
307 let mut parts = rest.strip_prefix('/')?.splitn(2, '/');
308 let drive = parts.next()?;
309 if drive.len() != 1 || !drive.chars().all(|character| character.is_ascii_alphabetic()) {
310 return None;
311 }
312 let tail = parts.next().unwrap_or("");
313 let mut native = format!("{}:\\", drive.to_ascii_uppercase());
314 native.push_str(&tail.replace('/', "\\"));
315 Some(PathBuf::from(native))
316}
317
318fn timestamp_millis() -> u128 {
319 std::time::SystemTime::now()
320 .duration_since(std::time::UNIX_EPOCH)
321 .map_or(0, |duration| duration.as_millis())
322}
323
324fn prompt_text_mimics_activity(
327 recognizer: &dyn crate::state::turn::TurnRecognizer,
328 text: &str,
329) -> bool {
330 let lines: Vec<String> = text.lines().map(str::to_string).collect();
331 let folded = [text.split_whitespace().collect::<Vec<_>>().join(" ")];
332 let mimics_activity = |screen: &[String]| {
333 matches!(
334 recognizer.detect_input(&crate::state::turn::TurnInput::screen_only(screen)),
335 crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
336 )
337 };
338 mimics_activity(&lines) || mimics_activity(&folded)
339}
340
341#[cfg(test)]
342mod workflow_activity_tests;
343
344struct PromptActivityGate {
346 recognizer: Box<dyn crate::state::turn::TurnRecognizer>,
347 baseline_seq: u64,
348 observed: Arc<AtomicBool>,
349 submitted: bool,
350 ambiguous_echo: bool,
351 blocked_state: Option<crate::state::turn::TurnState>,
352}
353
354impl PromptActivityGate {
355 fn observe(&mut self, live: &LiveTerminal, seq: u64) {
356 if !self.submitted
357 || self.ambiguous_echo
358 || seq <= self.baseline_seq
359 || self.observed.load(AtomicOrdering::Acquire)
360 {
361 return;
362 }
363 let screen = live.snapshot(0);
364 let input = crate::state::turn::TurnInput {
365 screen: &screen,
366 osc_title: live.osc_title(),
367 osc_progress: live.osc_progress(),
368 };
369 let state = self.recognizer.detect_input(&input);
370 if self.blocked_state == Some(state) {
371 return;
372 }
373 self.blocked_state = None;
374 if matches!(
375 state,
376 crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
377 ) {
378 self.observed.store(true, AtomicOrdering::Release);
379 }
380 }
381}
382
383#[cfg(test)]
384mod workflow_reader_tests;
385
386fn spawn_pty_reader(
389 mut reader: Box<dyn Read + Send>,
390 output_tx: mpsc::SyncSender<String>,
391 live: Arc<StdMutex<LiveTerminal>>,
392 discard_throttle: Arc<AtomicBool>,
393 activity_seq: Arc<AtomicU64>,
394 queued_activity_seq: Arc<AtomicU64>,
395 prompt_activity: Arc<StdMutex<Option<PromptActivityGate>>>,
396) {
397 thread::spawn(move || {
398 let mut buf = [0u8; 4096];
399 let mut carry: Vec<u8> = Vec::new();
404 loop {
405 match reader.read(&mut buf) {
406 Ok(0) => {
407 debug!("PTY reader reached EOF");
408 if !carry.is_empty() {
410 let seq =
413 activity_seq.fetch_add(1, AtomicOrdering::Release).saturating_add(1);
414 if output_tx.send(String::from_utf8_lossy(&carry).into_owned()).is_ok() {
415 queued_activity_seq.store(seq, AtomicOrdering::Release);
416 }
417 }
418 break;
419 }
420 Ok(n) => {
421 debug!(bytes = n, "PTY reader chunk");
422 let seq = {
428 let mut emu =
429 live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
430 emu.feed(&buf[..n]);
431 let seq =
432 activity_seq.fetch_add(1, AtomicOrdering::Release).saturating_add(1);
433 if let Some(gate) = prompt_activity
434 .lock()
435 .unwrap_or_else(std::sync::PoisonError::into_inner)
436 .as_mut()
437 {
438 gate.observe(&emu, seq);
439 }
440 seq
441 };
442 carry.extend_from_slice(&buf[..n]);
445 let (chunk, rest) = decode_keep_incomplete(&carry);
446 carry = rest;
447 if !chunk.is_empty() {
448 if output_tx.send(chunk).is_err() {
449 break;
451 }
452 queued_activity_seq.store(seq, AtomicOrdering::Release);
454 }
455 if discard_throttle.load(AtomicOrdering::Relaxed) {
460 thread::sleep(DISCARD_READ_THROTTLE);
461 }
462 }
463 Err(e) => {
464 debug!("PTY reader thread error: {}", e);
465 break;
466 }
467 }
468 }
469 debug!("PTY reader thread exiting");
470 });
471}
472
473pub struct PtyShell {
478 master: Box<dyn MasterPty + Send>,
480 child: Box<dyn Child + Send + Sync>,
482 writer: Box<dyn Write + Send>,
484 output_rx: mpsc::Receiver<String>,
486 size: PtySize,
488 pub last_command: String,
490 pub output_buffer: String,
492 pub command_running: bool,
494 current_cwd: PathBuf,
496 command_started_at: Option<Instant>,
499 command_generation: u64,
502 incarnation: u64,
505 activity_seq: Arc<AtomicU64>,
506 queued_activity_seq: Arc<AtomicU64>,
508 pub(crate) prompt_receipts:
509 std::collections::HashMap<String, crate::runtime::workflow::PromptRecord>,
510 prompt_activity: Arc<StdMutex<Option<PromptActivityGate>>>,
511 pub last_exit_code: Option<i32>,
515 max_output_size: usize,
517 pub output_truncated: bool,
519 ring: LineRing,
523 pub last_returned_hash: Option<u64>,
526 delivered_output: String,
529 pending_completion_report: bool,
535 pub attach_hint: Option<String>,
537 prompt_end_marker: String,
543 live: Arc<StdMutex<LiveTerminal>>,
551 scratch_workspace_root: Option<PathBuf>,
556 scratch_path: Option<PathBuf>,
559 scratch_bytes: u64,
561 discard_throttle: Arc<AtomicBool>,
566 #[cfg(windows)]
570 job: Option<crate::os::windows::ProcessTreeJob>,
571}
572
573impl std::fmt::Debug for PtyShell {
574 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575 f.debug_struct("PtyShell")
576 .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
577 .field("last_command", &self.last_command)
578 .field("command_running", &self.command_running)
579 .field("current_cwd", &self.current_cwd)
580 .field("output_truncated", &self.output_truncated)
581 .field("output_buffer_len", &self.output_buffer.len())
582 .field("attach_hint", &self.attach_hint)
583 .finish_non_exhaustive()
584 }
585}
586
587#[cfg(unix)]
598fn killable_group(pid: u32) -> Option<i32> {
599 crate::os::unix::owned_process_group(pid)
600}
601
602#[cfg(unix)]
605fn signal_group(pgid: i32, signal: i32) {
606 let _ = crate::os::unix::signal_group(pgid, signal);
607}
608
609impl Drop for PtyShell {
610 fn drop(&mut self) {
624 #[cfg(unix)]
630 let pgid = if self.attach_hint.is_some() {
631 None
632 } else {
633 self.child.process_id().and_then(killable_group)
634 };
635
636 #[cfg(unix)]
637 if let Some(pgid) = pgid {
638 signal_group(pgid, libc::SIGTERM);
639 }
640
641 #[cfg(windows)]
643 if let Some(job) = self.job.as_ref() {
644 job.terminate();
645 }
646
647 let _ = self.child.kill();
649
650 #[cfg(unix)]
651 if let Some(pgid) = pgid {
652 signal_group(pgid, libc::SIGKILL);
653 }
654
655 let _ = self.child.wait();
656 }
657}
658
659impl PtyShell {
660 pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
669 Self::new_with_agent_paths(initial_dir, restricted_mode, None, None)
670 }
671
672 pub(crate) fn new_with_agent_paths(
675 initial_dir: &Path,
676 restricted_mode: bool,
677 workspace_root: Option<&Path>,
678 temporary_artifact_dir: Option<&Path>,
679 ) -> Result<Self> {
680 info!(
681 "Creating new PTY shell (restricted: {}) in {}",
682 restricted_mode,
683 initial_dir.display()
684 );
685
686 #[cfg(windows)]
690 ensure_windows_shell_available(restricted_mode)?;
691
692 let size =
694 PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
695
696 let pair = native_pty_system().openpty(size).context("Failed to open PTY pair")?;
698
699 let (mut cmd, attach_hint, shell_kind) = attachable_command(restricted_mode);
701 if shell_kind.is_powershell() && restricted_mode {
702 return Err(anyhow!(
703 "restricted mode needs bash -r; PowerShell has no equivalent restricted mode"
704 ));
705 }
706
707 cmd.env("TERM", "xterm-256color");
709 cmd.env("COLORTERM", "truecolor");
710 cmd.env("PAGER", "cat");
711 cmd.env("GIT_PAGER", "cat");
712 cmd.env("HISTFILE", "");
717 cmd.env("COLUMNS", DEFAULT_COLS.to_string());
718 cmd.env("ROWS", DEFAULT_ROWS.to_string());
719 if let Some(workspace_root) = workspace_root {
720 cmd.env("WINX_WORKSPACE_ROOT", workspace_root);
721 }
722 if let Some(temporary_artifact_dir) = temporary_artifact_dir {
723 cmd.env("WINX_TEMP_DIR", temporary_artifact_dir);
724 }
725 let nonce = format!("{:016x}", rand::random::<u64>());
728 cmd.env(
733 "PROMPT_COMMAND",
734 format!(r#"__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec""#),
735 );
736 cmd.cwd(initial_dir);
737
738 let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
740 #[cfg(windows)]
741 let job = match crate::os::windows::ProcessTreeJob::new()
742 .and_then(|job| child.process_id().map_or(Ok(()), |pid| job.assign(pid)).map(|()| job))
743 {
744 Ok(job) => Some(job),
745 Err(error) => {
746 warn!(%error, "PTY child is not in a job object; background children may outlive the shell");
747 None
748 }
749 };
750
751 let reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
753 let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
754
755 let (output_tx, output_rx) = mpsc::sync_channel::<String>(1024);
759
760 let live = Arc::new(StdMutex::new(LiveTerminal::new(DEFAULT_ROWS, DEFAULT_COLS)));
763 let discard_throttle = Arc::new(AtomicBool::new(false));
764 let prompt_activity = Arc::new(StdMutex::new(None));
765
766 let activity_seq = Arc::new(AtomicU64::new(0));
767 let queued_activity_seq = Arc::new(AtomicU64::new(0));
768 spawn_pty_reader(
769 reader,
770 output_tx,
771 Arc::clone(&live),
772 Arc::clone(&discard_throttle),
773 activity_seq.clone(),
774 queued_activity_seq.clone(),
775 prompt_activity.clone(),
776 );
777
778 let mut shell = Self {
780 master: pair.master,
781 child,
782 writer,
783 output_rx,
784 size,
785 last_command: String::new(),
786 output_buffer: String::new(),
787 command_running: false,
788 current_cwd: initial_dir.to_path_buf(),
789 command_started_at: None,
790 command_generation: 0,
791 incarnation: rand::random::<u64>().max(1),
792 activity_seq,
793 queued_activity_seq,
794 prompt_receipts: std::collections::HashMap::new(),
795 prompt_activity,
796 last_exit_code: None,
797 max_output_size: MAX_OUTPUT_SIZE,
798 output_truncated: false,
799 ring: LineRing::new(RING_BUFFER_LINES, MAX_PARTIAL_LINE_BYTES),
800 last_returned_hash: None,
801 delivered_output: String::new(),
802 pending_completion_report: false,
803 attach_hint,
804 prompt_end_marker: format!("{WCGW_PROMPT_END}{nonce}"),
805 live,
806 scratch_workspace_root: None,
807 scratch_path: None,
808 scratch_bytes: 0,
809 discard_throttle,
810 #[cfg(windows)]
811 job,
812 };
813
814 shell.initialize_prompt(shell_kind, &nonce)?;
816
817 debug!("PTY shell created successfully");
818 Ok(shell)
819 }
820
821 fn initialize_prompt(&mut self, shell_kind: ShellKind, nonce: &str) -> Result<()> {
823 let prompt_statement = match shell_kind {
832 ShellKind::Zsh => format!(
836 r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() {{ local __winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec" }}"#
837 ),
838 ShellKind::Bash => format!(
839 r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec"'; PS1=''"#
840 ),
841 ShellKind::PowerShell => powershell_bootstrap(nonce),
842 };
843
844 if shell_kind.is_powershell() {
845 self.wait_for_initial_powershell_prompt();
851 }
852 self.write_command(&prompt_statement)?;
853
854 let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
861 let mut bootstrap = String::new();
862 let mut settled = false;
863 while Instant::now() < deadline {
864 match self.output_rx.recv_timeout(Duration::from_millis(50)) {
865 Ok(chunk) => {
866 self.answer_terminal_queries(&chunk);
867 bootstrap.push_str(&chunk);
868 if Self::check_prompt_complete(&bootstrap, &self.prompt_end_marker) {
869 settled = true;
870 break;
871 }
872 }
873 Err(mpsc::RecvTimeoutError::Timeout) => {}
874 Err(mpsc::RecvTimeoutError::Disconnected) => break,
875 }
876 }
877 if settled {
878 std::thread::sleep(Duration::from_millis(30));
879 } else {
880 warn!(
881 timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
882 "PTY shell did not print its first prompt in time; continuing anyway"
883 );
884 }
885 bootstrap.push_str(&self.drain_output());
886 debug!(
887 bytes = bootstrap.len(),
888 settled,
889 child_pid = ?self.child.process_id(),
890 "PTY prompt bootstrap drained"
891 );
892
893 Ok(())
894 }
895
896 fn wait_for_initial_powershell_prompt(&mut self) {
899 let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
900 let mut seen = String::new();
901 while Instant::now() < deadline {
902 match self.output_rx.recv_timeout(Duration::from_millis(50)) {
903 Ok(chunk) => {
904 self.answer_terminal_queries(&chunk);
905 seen.push_str(&chunk);
906 let clean = crate::state::terminal::strip_ansi_codes(&seen);
907 if clean.contains("> ") || clean.contains(">\u{a0}") {
908 std::thread::sleep(Duration::from_millis(100));
910 return;
911 }
912 }
913 Err(mpsc::RecvTimeoutError::Timeout) => {}
914 Err(mpsc::RecvTimeoutError::Disconnected) => return,
915 }
916 }
917 warn!(
918 timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
919 "PowerShell did not draw its initial prompt in time; sending the bootstrap anyway"
920 );
921 }
922
923 fn answer_terminal_queries(&mut self, chunk: &str) {
932 const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
933 if !chunk.contains(CURSOR_POSITION_REQUEST) {
934 return;
935 }
936 debug!("PTY answered a cursor position request");
937 if let Err(error) = self.writer.write_all(b"\x1b[1;1R").and_then(|()| self.writer.flush()) {
938 debug!("PTY cursor position report failed: {error}");
939 }
940 }
941
942 fn write_command(&mut self, command: &str) -> Result<()> {
944 let cmd_with_newline = format!("{command}\r");
950 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
951 self.writer.flush().context("Failed to flush PTY")?;
952 Ok(())
953 }
954
955 fn drain_output(&mut self) -> String {
957 let mut output = String::new();
958 let deadline = Instant::now() + Duration::from_millis(200);
959
960 while Instant::now() < deadline {
962 match self.output_rx.try_recv() {
963 Ok(chunk) => {
964 self.answer_terminal_queries(&chunk);
965 output.push_str(&chunk);
966
967 if output.len() > self.max_output_size {
969 self.output_truncated = true;
970 break;
971 }
972 }
973 Err(TryRecvError::Empty) => {
974 thread::sleep(Duration::from_millis(10));
976 }
977 Err(TryRecvError::Disconnected) => {
978 break;
980 }
981 }
982 }
983
984 output
985 }
986
987 pub fn send_command(&mut self, command: &str) -> Result<()> {
989 debug!(bytes = command.len(), "PTY sending command");
990
991 self.output_buffer.clear();
993 self.output_truncated = false;
994 self.reset_scratch();
995 self.last_command = command.to_string();
996 self.command_running = true;
997 self.mark_command_started();
998 self.last_exit_code = None;
999 self.last_returned_hash = None;
1002 self.delivered_output.clear();
1003 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear_osc();
1006
1007 self.write_command(command)?;
1009
1010 Ok(())
1011 }
1012
1013 pub fn mark_command_started(&mut self) {
1015 self.prompt_receipts.clear();
1016 *self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1017 self.command_generation = self.command_generation.saturating_add(1);
1018 self.command_started_at = Some(Instant::now());
1019 self.pending_completion_report = true;
1020 }
1021
1022 pub fn completion_report_pending(&self) -> bool {
1025 !self.command_running && self.pending_completion_report
1026 }
1027
1028 pub fn mark_completion_reported(&mut self) {
1030 self.pending_completion_report = false;
1031 }
1032
1033 pub fn command_generation(&self) -> u64 {
1034 self.command_generation
1035 }
1036
1037 pub fn activity_seq(&self) -> u64 {
1039 self.activity_seq.load(AtomicOrdering::Acquire)
1040 }
1041
1042 pub(crate) fn poll_workflow_output(&mut self) -> u64 {
1046 let activity_seq = self.queued_activity_seq.load(AtomicOrdering::Acquire);
1047 self.poll_output_nonblocking();
1048 activity_seq
1049 }
1050
1051 pub(crate) fn arm_prompt_activity(
1054 &mut self,
1055 hint: &str,
1056 text: &str,
1057 ) -> crate::errors::Result<(u64, Arc<AtomicBool>)> {
1058 let recognizer = crate::state::turn::recognizer_for(hint);
1059 let ambiguous_echo = prompt_text_mimics_activity(recognizer.as_ref(), text);
1060 let live = self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1061 let screen = live.snapshot(0);
1062 let input = crate::state::turn::TurnInput {
1063 screen: &screen,
1064 osc_title: live.osc_title(),
1065 osc_progress: live.osc_progress(),
1066 };
1067 if recognizer.detect_input(&input) != crate::state::turn::TurnState::AwaitingInput {
1068 return Err(crate::errors::WinxError::InvalidInput(
1069 "native prompt requires a running command at a recognized input-ready prompt"
1070 .to_string(),
1071 ));
1072 }
1073 let baseline_seq = self.activity_seq();
1074 let observed = Arc::new(AtomicBool::new(false));
1075 *self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner) =
1076 Some(PromptActivityGate {
1077 recognizer,
1078 baseline_seq,
1079 observed: observed.clone(),
1080 submitted: false,
1081 ambiguous_echo,
1082 blocked_state: None,
1083 });
1084 Ok((baseline_seq, observed))
1085 }
1086
1087 pub(crate) fn submit_prompt_enter(&mut self) -> Result<()> {
1090 let live = self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1091 let mut gate =
1092 self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1093 let gate = gate.as_mut().ok_or_else(|| anyhow!("prompt activity gate is unavailable"))?;
1094 let screen = live.snapshot(0);
1095 let state = gate.recognizer.detect_input(&crate::state::turn::TurnInput {
1096 screen: &screen,
1097 osc_title: live.osc_title(),
1098 osc_progress: live.osc_progress(),
1099 });
1100 gate.blocked_state = matches!(
1101 state,
1102 crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
1103 )
1104 .then_some(state);
1105 self.writer.write_all(b"\r")?;
1106 self.writer.flush()?;
1107 gate.submitted = true;
1108 Ok(())
1109 }
1110
1111 pub(crate) fn retained_workflow(
1112 &self,
1113 activity_seq: u64,
1114 ) -> crate::runtime::workflow::RetainedWorkflow {
1115 crate::runtime::workflow::RetainedWorkflow {
1116 generation: self.command_generation(),
1117 activity_seq,
1118 screen: self.live_snapshot(0),
1119 osc_title: self.live_osc_title(),
1120 osc_progress: self.live_osc_progress(),
1121 receipts: self.prompt_receipts.clone(),
1122 }
1123 }
1124
1125 pub fn incarnation(&self) -> u64 {
1126 self.incarnation
1127 }
1128
1129 pub fn command_elapsed(&self) -> Option<Duration> {
1131 self.command_started_at.map(|started_at| started_at.elapsed())
1132 }
1133
1134 pub fn current_cwd(&self) -> &Path {
1136 &self.current_cwd
1137 }
1138
1139 pub fn delivered_output(&self) -> String {
1141 self.delivered_output.clone()
1142 }
1143
1144 pub fn mark_output_delivered(&mut self, output: &str) {
1146 self.delivered_output.clear();
1147 self.delivered_output.push_str(output);
1148 }
1149
1150 pub fn collect_scrollback(&self, lines: usize) -> String {
1155 let rendered =
1161 crate::state::terminal::render_terminal_output(&self.ring.raw(lines)).join("\n");
1162 match self.ring.scrollback_notice(lines) {
1163 Some(notice) => format!("{notice}\n{rendered}"),
1164 None => rendered,
1165 }
1166 }
1167
1168 pub fn fingerprint(text: &str) -> u64 {
1170 let mut hasher = DefaultHasher::new();
1171 text.hash(&mut hasher);
1172 hasher.finish()
1173 }
1174
1175 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
1180 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
1181 let start = Instant::now();
1182 let mut complete = false;
1183 let mut no_data_count = 0;
1184 let mut prompt_detected_at: Option<Instant> = None;
1185
1186 while start.elapsed() < timeout {
1187 match self.output_rx.try_recv() {
1188 Ok(chunk) => {
1189 self.answer_terminal_queries(&chunk);
1190 self.output_buffer.push_str(&chunk);
1191 self.ring.push_chunk(&chunk);
1192 no_data_count = 0;
1193
1194 if prompt_detected_at.is_none()
1196 && (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
1197 || Self::check_prompt_complete(
1198 &self.output_buffer,
1199 &self.prompt_end_marker,
1200 ))
1201 {
1202 prompt_detected_at = Some(Instant::now());
1203 debug!("Prompt detected, draining remaining output...");
1204 }
1205
1206 self.truncate_output_buffer_with_offload();
1208 }
1209 Err(TryRecvError::Empty) => {
1210 thread::sleep(Duration::from_millis(10));
1212 no_data_count += 1;
1213
1214 if let Some(detected_time) = prompt_detected_at {
1216 if detected_time.elapsed() > Duration::from_millis(100) {
1218 complete = true;
1219 debug!("Command completed - prompt detected and drained");
1220 break;
1221 }
1222 } else if no_data_count > 10
1223 && Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1224 {
1225 prompt_detected_at = Some(Instant::now());
1227 debug!("Prompt detected after wait, draining...");
1228 }
1229 }
1230 Err(TryRecvError::Disconnected) => {
1231 warn!("PTY reader disconnected");
1233 complete = true;
1234 break;
1235 }
1236 }
1237 }
1238
1239 if complete || prompt_detected_at.is_some() {
1240 self.command_running = false;
1241 self.last_exit_code =
1242 Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1243 self.update_current_cwd_from_prompt();
1244 complete = true;
1245 }
1246
1247 Ok((self.output_buffer.clone(), complete))
1248 }
1249
1250 pub fn poll_output_nonblocking(&mut self) -> bool {
1258 let mut prompt_seen = false;
1259 loop {
1260 match self.output_rx.try_recv() {
1261 Ok(chunk) => {
1262 self.answer_terminal_queries(&chunk);
1263 let chunk_has_prompt =
1264 Self::check_prompt_complete(&chunk, &self.prompt_end_marker);
1265 self.output_buffer.push_str(&chunk);
1266 self.ring.push_chunk(&chunk);
1267 if chunk_has_prompt
1268 || Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1269 {
1270 prompt_seen = true;
1271 }
1272 self.truncate_output_buffer_with_offload();
1273 }
1274 Err(TryRecvError::Empty) => break,
1275 Err(TryRecvError::Disconnected) => {
1276 self.command_running = false;
1277 return true;
1278 }
1279 }
1280 }
1281 if prompt_seen {
1282 self.command_running = false;
1283 self.last_exit_code =
1284 Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1285 self.update_current_cwd_from_prompt();
1286 }
1287 !self.command_running
1292 }
1293
1294 fn update_current_cwd_from_prompt(&mut self) {
1295 let Some(last) =
1296 self.output_buffer.lines().rev().find(|line| line.contains(&self.prompt_end_marker))
1297 else {
1298 return;
1299 };
1300 let clean = crate::state::terminal::strip_ansi_codes(last);
1301 let Some((before_marker, _)) = clean.rsplit_once(&self.prompt_end_marker) else {
1302 return;
1303 };
1304 let Some((_, cwd)) = before_marker.rsplit_once("◉ ") else {
1305 return;
1306 };
1307 let cwd = cwd.trim();
1308 if !cwd.is_empty() {
1309 self.current_cwd = native_cwd_from_prompt(cwd);
1310 }
1311 }
1312
1313 pub fn output_snapshot(&self) -> String {
1317 self.output_buffer.clone()
1318 }
1319
1320 fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
1328 Self::parse_prompt_exit_code(text, prompt_end).is_some()
1329 }
1330
1331 fn parse_prompt_exit_code(text: &str, prompt_end: &str) -> Option<i32> {
1337 let last = text.lines().rev().find(|line| !line.trim().is_empty())?;
1338 if !last.contains(WCGW_PROMPT_PATTERN) {
1339 return None;
1340 }
1341 let raw_tail = last.rsplit_once(prompt_end)?.1;
1347 let clean_tail = crate::state::terminal::strip_ansi_codes(raw_tail);
1348 let code_text = clean_tail.strip_prefix(':')?;
1349 let digits = code_text
1350 .char_indices()
1351 .take_while(|(index, c)| c.is_ascii_digit() || (*index == 0 && *c == '-'))
1352 .count();
1353 let (code, rest) = code_text.split_at(digits);
1354 if !rest.chars().all(char::is_whitespace) {
1355 return None;
1356 }
1357 let terminated = !rest.is_empty() || raw_tail.len() > clean_tail.len();
1361 if !terminated {
1362 return None;
1363 }
1364 code.parse::<i32>().ok()
1365 }
1366
1367 pub fn set_scratch_root(&mut self, root: &Path) {
1370 self.scratch_workspace_root = Some(root.to_path_buf());
1371 }
1372
1373 pub fn scratch_path(&self) -> Option<&Path> {
1376 self.scratch_path.as_deref()
1377 }
1378
1379 pub fn reset_scratch(&mut self) {
1382 self.scratch_path = None;
1383 self.scratch_bytes = 0;
1384 self.discard_throttle.store(false, AtomicOrdering::Relaxed);
1385 }
1386
1387 fn offload_dropped_head(&mut self, head: &str) {
1391 let Some(root) = self.scratch_workspace_root.clone() else {
1392 return;
1393 };
1394 let remaining = SCRATCH_MAX_BYTES.saturating_sub(self.scratch_bytes);
1398 if remaining == 0 {
1399 self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1403 return;
1404 }
1405 let head = match usize::try_from(remaining) {
1406 Ok(rem) if rem < head.len() => &head[..crate::utils::floor_char_boundary(head, rem)],
1407 _ => head,
1408 };
1409 if self.scratch_path.is_none() {
1410 self.scratch_path = crate::utils::scratch_file::new_scratch_path(&root);
1411 }
1412 let Some(path) = self.scratch_path.clone() else {
1413 return;
1414 };
1415 match crate::utils::scratch_file::append_scratch(&path, head.as_bytes()) {
1416 Ok(0) if self.scratch_bytes == 0 => self.scratch_path = None,
1417 Ok(written) => {
1418 self.scratch_bytes = self.scratch_bytes.saturating_add(written as u64);
1419 if self.scratch_bytes >= SCRATCH_MAX_BYTES {
1420 self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1421 }
1422 }
1423 Err(e) => debug!("scratch: append to {} failed: {e}", path.display()),
1424 }
1425 }
1426
1427 fn truncate_output_buffer_with_offload(&mut self) {
1432 if self.output_buffer.len() <= self.max_output_size {
1433 return;
1434 }
1435 self.output_truncated = true;
1436 let keep_size = self.max_output_size / 2;
1437 let cut = crate::utils::floor_char_boundary(
1440 &self.output_buffer,
1441 self.output_buffer.len() - keep_size,
1442 );
1443 let head = self.output_buffer[..cut].to_owned();
1444 self.offload_dropped_head(&head);
1445 self.output_buffer.replace_range(..cut, "\n(...output truncated...)\n");
1450 }
1451
1452 pub fn send_interrupt(&mut self) -> Result<()> {
1454 debug!("PTY sending Ctrl+C");
1455 self.writer
1456 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
1458 self.writer.flush()?;
1459 Ok(())
1460 }
1461
1462 pub fn send_eof(&mut self) -> Result<()> {
1464 debug!("PTY sending Ctrl+D");
1465 self.writer
1466 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
1468 self.writer.flush()?;
1469 Ok(())
1470 }
1471
1472 pub fn send_suspend(&mut self) -> Result<()> {
1474 debug!("PTY sending Ctrl+Z");
1475 self.writer
1476 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
1478 self.writer.flush()?;
1479 Ok(())
1480 }
1481
1482 pub fn send_text(&mut self, text: &str) -> Result<()> {
1484 debug!(bytes = text.len(), "PTY sending text");
1485 self.send_bytes(text.as_bytes()).context("Failed to send text")?;
1486 Ok(())
1487 }
1488
1489 pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
1491 self.writer.write_all(bytes).context("Failed to send bytes")?;
1492 self.writer.flush()?;
1493 Ok(())
1494 }
1495
1496 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
1498 let bytes: &[u8] = match key {
1499 "Enter" => b"\r",
1500 "Tab" => b"\t",
1501 "Backspace" => b"\x7F",
1502 "Escape" => b"\x1B",
1503 "Up" | "KeyUp" => b"\x1B[A",
1504 "Down" | "KeyDown" => b"\x1B[B",
1505 "Right" | "KeyRight" => b"\x1B[C",
1506 "Left" | "KeyLeft" => b"\x1B[D",
1507 "Home" => b"\x1B[H",
1508 "End" => b"\x1B[F",
1509 "PageUp" => b"\x1B[5~",
1510 "PageDown" => b"\x1B[6~",
1511 "Delete" => b"\x1B[3~",
1512 "Insert" => b"\x1B[2~",
1513 "CtrlC" | "Ctrl-C" => b"\x03",
1514 "CtrlD" | "Ctrl-D" => b"\x04",
1515 "CtrlZ" | "Ctrl-Z" => b"\x1A",
1516 "CtrlL" | "Ctrl-L" => b"\x0C",
1517 _ => return Err(anyhow!("Unknown special key: {key}")),
1518 };
1519
1520 debug!("PTY sending special key: {} ({:?})", key, bytes);
1521 self.send_bytes(bytes)?;
1522 Ok(())
1523 }
1524
1525 pub fn live_snapshot(&self, max_lines: usize) -> Vec<String> {
1530 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).snapshot(max_lines)
1534 }
1535
1536 pub fn live_in_alt_screen(&self) -> bool {
1539 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).in_alt_screen()
1540 }
1541
1542 pub fn live_osc_title(&self) -> String {
1546 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).osc_title().to_string()
1547 }
1548
1549 pub fn live_osc_progress(&self) -> String {
1552 self.live
1553 .lock()
1554 .unwrap_or_else(std::sync::PoisonError::into_inner)
1555 .osc_progress()
1556 .to_string()
1557 }
1558
1559 pub fn live_cursor_position(&self) -> (u16, u16) {
1562 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_position()
1563 }
1564
1565 pub fn live_snapshot_diff(&self, max_lines: usize, threshold: usize) -> ScreenUpdate {
1569 self.live
1570 .lock()
1571 .unwrap_or_else(std::sync::PoisonError::into_inner)
1572 .snapshot_diff(max_lines, threshold)
1573 }
1574
1575 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
1577 debug!("PTY resizing to {}x{}", cols, rows);
1578
1579 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
1580
1581 self.master.resize(new_size).context("Failed to resize PTY")?;
1582
1583 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).resize(rows, cols);
1585
1586 self.size = new_size;
1587 Ok(())
1588 }
1589
1590 pub fn get_size(&self) -> (u16, u16) {
1592 (self.size.cols, self.size.rows)
1593 }
1594
1595 pub fn process_id(&self) -> Option<u32> {
1597 self.child.process_id()
1598 }
1599
1600 pub fn is_alive(&mut self) -> bool {
1602 self.child.try_wait().is_ok_and(|status| status.is_none())
1603 }
1604}
1605
1606pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
1608
1609pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
1611 let shell = PtyShell::new(initial_dir, restricted_mode)?;
1612 Ok(Arc::new(Mutex::new(Some(shell))))
1613}
1614
1615fn decode_keep_incomplete(bytes: &[u8]) -> (String, Vec<u8>) {
1624 let mut decoded = String::new();
1625 let mut rest = bytes;
1626 loop {
1627 match std::str::from_utf8(rest) {
1628 Ok(text) => {
1629 decoded.push_str(text);
1630 return (decoded, Vec::new());
1631 }
1632 Err(error) => {
1633 let valid = error.valid_up_to();
1634 decoded.push_str(&String::from_utf8_lossy(&rest[..valid]));
1637 match error.error_len() {
1638 None => return (decoded, rest[valid..].to_vec()),
1640 Some(bad) => {
1642 decoded.push('\u{FFFD}');
1643 rest = &rest[valid + bad..];
1644 }
1645 }
1646 }
1647 }
1648 }
1649}
1650
1651#[cfg(test)]
1652mod tests {
1653 use super::*;
1654 #[cfg(unix)] use tempfile::TempDir;
1656
1657 #[test]
1658 fn decode_passes_clean_utf8_through() {
1659 let (text, carry) = decode_keep_incomplete("hello ✓ café".as_bytes());
1660 assert_eq!(text, "hello ✓ café");
1661 assert!(carry.is_empty(), "expected empty carry");
1662 }
1663
1664 #[test]
1665 fn decode_carries_incomplete_trailing_char_across_reads() {
1666 let (text1, carry1) = decode_keep_incomplete(&[b'a', 0xC3]);
1669 assert_eq!(text1, "a");
1670 assert_eq!(carry1, vec![0xC3]);
1671
1672 let mut next = carry1;
1673 next.push(0xA9); let (text2, carry2) = decode_keep_incomplete(&next);
1675 assert_eq!(text2, "é");
1676 assert!(carry2.is_empty(), "expected empty carry2");
1677 }
1678
1679 #[test]
1680 fn decode_replaces_genuinely_invalid_bytes() {
1681 let (text, carry) = decode_keep_incomplete(&[b'x', 0xFF, b'y']);
1683 assert_eq!(text, "x\u{FFFD}y");
1684 assert!(carry.is_empty(), "expected empty carry");
1685 }
1686
1687 #[test]
1688 fn decode_handles_large_invalid_burst_without_overflow() {
1689 let big = vec![0xFFu8; 200_000];
1693 let (text, carry) = decode_keep_incomplete(&big);
1694 assert!(carry.is_empty(), "expected empty carry");
1695 assert_eq!(text.chars().filter(|&c| c == '\u{FFFD}').count(), 200_000);
1696 }
1697
1698 #[test]
1699 fn screen_session_name_parser_is_strict() {
1700 assert_eq!(super::winx_creator_pid("winx-123-456"), Some(123));
1701 assert_eq!(super::winx_creator_pid("winx-nope-456"), None);
1702 assert_eq!(super::winx_creator_pid("other-123-456"), None);
1703 }
1704
1705 #[cfg(unix)]
1706 #[test]
1707 fn process_probe_recognizes_current_process_and_rejects_reserved_pids() {
1708 assert!(super::process_exists(std::process::id()));
1709 assert!(!super::process_exists(0));
1710 assert!(!super::process_exists(1));
1711 }
1712
1713 #[test]
1714 fn msys_paths_convert_to_native_drives() {
1715 use super::msys_to_native;
1716 assert_eq!(msys_to_native("/c/Users/x/ws"), Some(PathBuf::from("C:\\Users\\x\\ws")));
1717 assert_eq!(msys_to_native("/cygdrive/d/work"), Some(PathBuf::from("D:\\work")));
1718 assert_eq!(msys_to_native("/c"), Some(PathBuf::from("C:\\")));
1719 assert_eq!(msys_to_native("/home/x"), None);
1720 assert_eq!(msys_to_native("relative"), None);
1721 }
1722
1723 #[test]
1724 fn prompt_detection_is_suffix_anchored() {
1725 let end = "──➤deadbeefcafe0001";
1727 assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001:0 ", end));
1729 assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 \u{1b}[K", end));
1731 assert!(!PtyShell::check_prompt_complete(
1733 "menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
1734 end
1735 ));
1736 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 ls -la", end));
1738 assert!(!PtyShell::check_prompt_complete("just some output\n", end));
1740 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
1743 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce:0 ", end));
1744 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ", end));
1745 }
1746
1747 #[test]
1748 fn powershell_bootstrap_defines_a_marker_prompt_without_psreadline() {
1749 let bootstrap = super::powershell_bootstrap("cafe1234");
1750 assert!(bootstrap.contains("Remove-Module PSReadLine"), "{bootstrap}");
1751 assert!(bootstrap.contains("function global:prompt"), "{bootstrap}");
1752 assert!(bootstrap.contains("──➤cafe1234:$code "), "{bootstrap}");
1753 assert!(bootstrap.contains("$global:LASTEXITCODE = 0"), "{bootstrap}");
1754 assert!(!bootstrap.contains('\n'), "bootstrap must be a single input line");
1755 }
1756
1757 #[test]
1758 fn parses_exit_code_from_prompt_marker() {
1759 let end = "──➤cafe1234";
1760 assert_eq!(PtyShell::parse_prompt_exit_code("ok\n◉ /x──➤cafe1234:0 ", end), Some(0));
1761 assert_eq!(PtyShell::parse_prompt_exit_code("oops\n◉ /x──➤cafe1234:1 ", end), Some(1));
1762 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:42 ", end), Some(42));
1763 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:1", end), None);
1765 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127", end), None);
1766 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127 ", end), Some(127));
1767 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127\u{1b}[C", end), Some(127));
1769 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:0\u{1b}[K", end), Some(0));
1770 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:-1 ", end), Some(-1));
1771 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:12a ", end), None);
1772 assert_eq!(PtyShell::parse_prompt_exit_code("building...", end), None);
1774 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤wrong:0 ", end), None);
1776 }
1777
1778 use proptest::prelude::*;
1779
1780 proptest! {
1781 #[test]
1785 fn parse_prompt_exit_code_never_panics(
1786 text in prop::collection::vec(any::<char>(), 0..256),
1787 nonce in "[0-9a-f]{0,16}",
1788 ) {
1789 let s: String = text.into_iter().collect();
1790 let end = format!("{WCGW_PROMPT_END}{nonce}");
1791 let _ = PtyShell::parse_prompt_exit_code(&s, &end);
1793 }
1794
1795 #[test]
1797 fn parse_prompt_exit_code_round_trips(code in any::<i32>(), nonce in "[0-9a-f]{1,16}") {
1798 let end = format!("{WCGW_PROMPT_END}{nonce}");
1799 let line = format!("output line\n{WCGW_PROMPT_PATTERN} /home/x{end}:{code} ");
1800 prop_assert_eq!(PtyShell::parse_prompt_exit_code(&line, &end), Some(code));
1801 }
1802 }
1803
1804 #[cfg(unix)] #[test]
1806 fn test_pty_shell_creation() -> Result<()> {
1807 let temp_dir = TempDir::new()?;
1808 let result = PtyShell::new(temp_dir.path(), false);
1809 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
1810 Ok(())
1811 }
1812
1813 #[cfg(unix)] #[test]
1815 fn discard_throttle_engages_at_scratch_cap_and_resets_per_command() -> Result<()> {
1816 let temp_dir = TempDir::new()?;
1817 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1818 assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1819
1820 shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1823 shell.scratch_bytes = SCRATCH_MAX_BYTES;
1824 shell.offload_dropped_head("dropped beyond the budget");
1825 assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1826
1827 shell.send_command("echo done")?;
1829 assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1830 Ok(())
1831 }
1832
1833 #[cfg(unix)] #[test]
1835 fn discard_throttle_engages_when_a_write_reaches_the_cap() -> Result<()> {
1836 let temp_dir = TempDir::new()?;
1837 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1838 shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1839 shell.scratch_bytes = SCRATCH_MAX_BYTES - 1;
1841 shell.offload_dropped_head("xx");
1842 assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1843 Ok(())
1844 }
1845
1846 #[cfg(unix)] #[test]
1848 fn test_pty_shell_echo() -> Result<()> {
1849 let temp_dir = TempDir::new()?;
1850 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1851
1852 shell.send_command("echo 'hello pty'")?;
1853 let (output, complete) = shell.read_output(2.0)?;
1854
1855 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
1856 assert!(complete, "first command must complete: {output}");
1860 assert!(!output.contains("PROMPT_COMMAND"), "bootstrap leaked into output: {output}");
1861 assert_eq!(shell.last_exit_code, Some(0), "{output}");
1862 Ok(())
1863 }
1864
1865 #[cfg(unix)] #[test]
1867 fn agent_paths_are_exported_to_the_pty() -> Result<()> {
1868 let workspace = TempDir::new()?;
1869 let temporary_artifact_dir = workspace.path().join(".winx/tmp/session-test");
1870 let mut shell = PtyShell::new_with_agent_paths(
1871 workspace.path(),
1872 false,
1873 Some(workspace.path()),
1874 Some(&temporary_artifact_dir),
1875 )?;
1876
1877 shell.send_command("printf '%s\\n%s\\n' \"$WINX_WORKSPACE_ROOT\" \"$WINX_TEMP_DIR\"")?;
1878 let (output, _complete) = shell.read_output(2.0)?;
1879
1880 assert!(output.contains(&workspace.path().to_string_lossy().to_string()), "{output}");
1881 assert!(output.contains(&temporary_artifact_dir.to_string_lossy().to_string()), "{output}");
1882 Ok(())
1883 }
1884
1885 #[cfg(unix)] #[test]
1887 fn completed_nonblocking_poll_is_level_triggered() -> Result<()> {
1888 let temp_dir = TempDir::new()?;
1889 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1890
1891 shell.send_command("true")?;
1892 let deadline = Instant::now() + Duration::from_secs(2);
1893 while Instant::now() < deadline && !shell.poll_output_nonblocking() {
1894 std::thread::sleep(Duration::from_millis(10));
1895 }
1896
1897 assert!(!shell.command_running, "shell did not complete before the deadline");
1898 assert!(
1899 shell.poll_output_nonblocking(),
1900 "a later consumer must still observe the completed state"
1901 );
1902 Ok(())
1903 }
1904
1905 #[cfg(unix)] #[test]
1907 fn test_pty_shell_pwd() -> Result<()> {
1908 let temp_dir = TempDir::new()?;
1909 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1910
1911 shell.send_command("pwd && echo 'pwd_done'")?;
1914 let (output, _complete) = shell.read_output(2.0)?;
1915
1916 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
1918 Ok(())
1919 }
1920
1921 #[cfg(unix)] #[test]
1923 fn test_pty_resize() -> Result<()> {
1924 let temp_dir = TempDir::new()?;
1925 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1926
1927 let result = shell.resize(120, 40);
1928 assert!(result.is_ok());
1929
1930 let (cols, rows) = shell.get_size();
1931 assert_eq!(cols, 120);
1932 assert_eq!(rows, 40);
1933 Ok(())
1934 }
1935
1936 #[cfg(unix)]
1940 fn pid_alive(pid: i32) -> bool {
1941 u32::try_from(pid).is_ok_and(crate::os::unix::process_exists)
1942 }
1943
1944 #[cfg(unix)]
1945 #[test]
1946 fn dropping_shell_kills_orphaned_background_child() -> Result<()> {
1947 let dir = TempDir::new()?;
1952 let pidfile = dir.path().join("child.pid");
1953 let mut shell = PtyShell::new(dir.path(), false)?;
1954
1955 shell
1956 .send_command(&format!("sleep 300 & echo $! > {} ; echo spawned", pidfile.display()))?;
1957
1958 let mut child_pid = None;
1963 for _ in 0..150 {
1964 let _ = shell.read_output(0.1);
1965 if let Some(pid) =
1966 std::fs::read_to_string(&pidfile).ok().and_then(|c| c.trim().parse::<i32>().ok())
1967 {
1968 child_pid = Some(pid);
1969 break;
1970 }
1971 thread::sleep(Duration::from_millis(20));
1972 }
1973 let child_pid =
1974 child_pid.ok_or_else(|| anyhow!("background child pid never materialized"))?;
1975 assert!(pid_alive(child_pid), "background child should be running before drop");
1976
1977 drop(shell);
1978
1979 let mut gone = false;
1981 for _ in 0..150 {
1982 if !pid_alive(child_pid) {
1983 gone = true;
1984 break;
1985 }
1986 thread::sleep(Duration::from_millis(20));
1987 }
1988 assert!(gone, "background child {child_pid} survived shell teardown (orphan leak)");
1989 Ok(())
1990 }
1991}