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 configure_agent_environment(
71 cmd: &mut CommandBuilder,
72 workspace_root: Option<&Path>,
73 temporary_artifact_dir: Option<&Path>,
74) {
75 cmd.env("TERM", "xterm-256color");
77 cmd.env("COLORTERM", "truecolor");
78 cmd.env("PAGER", "cat");
79 cmd.env("GIT_PAGER", "cat");
80 cmd.env("HISTFILE", "");
85 cmd.env("COLUMNS", DEFAULT_COLS.to_string());
86 cmd.env("ROWS", DEFAULT_ROWS.to_string());
87 for key in crate::config::SERVER_ONLY_ENV {
89 cmd.env_remove(key);
90 }
91 if let Some(workspace_root) = workspace_root {
92 cmd.env("WINX_WORKSPACE_ROOT", workspace_root);
93 }
94 if let Some(temporary_artifact_dir) = temporary_artifact_dir {
95 cmd.env("WINX_TEMP_DIR", temporary_artifact_dir);
96 }
97}
98
99fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>, ShellKind) {
100 let requested = crate::config::env_text("WINX_ATTACH_TERMINAL")
101 .or_else(|| crate::config::env_text("WINX_USE_SCREEN"))
102 .unwrap_or_default();
103 if requested == "tmux"
104 || requested == "screen"
105 || crate::config::parse_bool(&requested) == Some(true)
106 {
107 let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
108 if requested == "tmux" && command_available("tmux") {
109 let mut cmd = CommandBuilder::new("tmux");
110 cmd.args(["new-session", "-A", "-s", &session, "bash"]);
111 if restricted_mode {
112 cmd.arg("-r");
113 }
114 return (cmd, Some(format!("tmux attach -t {session}")), ShellKind::Bash);
115 }
116 if command_available("screen") {
117 ensure_screenrc();
120 cleanup_orphaned_screens();
121 let mut cmd = CommandBuilder::new("screen");
122 cmd.args(["-q", "-S", &session, "bash"]);
123 if restricted_mode {
124 cmd.arg("-r");
125 }
126 return (cmd, Some(format!("screen -x {session}")), ShellKind::Bash);
127 }
128 }
129
130 let shell = preferred_shell(restricted_mode);
131 let kind = shell_kind_of(&shell);
133 let mut cmd = CommandBuilder::new(&shell);
134 match kind {
135 ShellKind::Bash if restricted_mode => {
137 cmd.arg("-r");
138 }
139 ShellKind::PowerShell => {
142 cmd.arg("-NoLogo");
143 }
144 ShellKind::Bash | ShellKind::Zsh => {}
145 }
146 if let Some(extra) = crate::config::env_text("WINX_SHELL_ARGS") {
149 for arg in extra.split_whitespace() {
150 cmd.arg(arg);
151 }
152 }
153 (cmd, None, kind)
154}
155
156pub fn configured_shell_kind(restricted_mode: bool) -> ShellKind {
160 shell_kind_of(&preferred_shell(restricted_mode))
161}
162
163#[cfg(windows)]
172fn ensure_windows_shell_available(restricted_mode: bool) -> Result<()> {
173 if !restricted_mode {
175 if let Ok(requested) = std::env::var("WINX_SHELL") {
176 let path = Path::new(&requested);
177 if path.is_absolute() && path.is_file() {
178 return Ok(());
179 }
180 }
181 }
182 if crate::utils::executable::bash_executable().is_some() {
183 return Ok(());
184 }
185 if crate::utils::executable::powershell_executable().is_some() {
186 if restricted_mode {
187 return Err(anyhow!(
188 "restricted mode needs bash -r; install Git for Windows or set WINX_SHELL to an \
189 absolute bash.exe path (PowerShell has no equivalent restricted mode)"
190 ));
191 }
192 return Ok(());
193 }
194 Err(anyhow!(
195 "no usable shell found: install Git for Windows (the System32 WSL launcher is not \
196 used) or PowerShell 7, or set WINX_SHELL to an absolute bash.exe or pwsh.exe path"
197 ))
198}
199
200fn preferred_shell(restricted_mode: bool) -> String {
201 if !restricted_mode {
202 if let Ok(requested) = std::env::var("WINX_SHELL") {
203 if requested == "zsh" && command_available("zsh") {
204 return "zsh".to_string();
205 }
206 if matches!(requested.as_str(), "pwsh" | "powershell") {
209 if let Some(found) = crate::utils::executable::powershell_executable() {
210 return found.to_string_lossy().into_owned();
211 }
212 }
213 if Path::new(&requested).is_absolute() && Path::new(&requested).is_file() {
216 return requested;
217 }
218 }
219 }
220 if cfg!(windows) {
221 if let Some(bash) = crate::utils::executable::bash_executable() {
222 return bash.to_string_lossy().into_owned();
223 }
224 if !restricted_mode {
227 if let Some(found) = crate::utils::executable::powershell_executable() {
228 return found.to_string_lossy().into_owned();
229 }
230 }
231 }
232 "bash".to_string()
233}
234
235fn powershell_bootstrap(nonce: &str) -> String {
247 format!(
248 "Remove-Module PSReadLine -ErrorAction SilentlyContinue; \
249 if (Get-Variable PSStyle -ErrorAction SilentlyContinue) {{ $PSStyle.OutputRendering = 'PlainText' }}; \
250 $env:PAGER = 'cat'; $env:GIT_PAGER = 'cat'; $global:LASTEXITCODE = 0; \
251 function global:prompt {{ $ok = $?; $ec = $global:LASTEXITCODE; \
252 $code = if ($ec -is [int] -and $ec -ne 0) {{ $ec }} elseif ($ok) {{ 0 }} else {{ 1 }}; \
253 $global:LASTEXITCODE = 0; \
254 \"\u{25c9} $($ExecutionContext.SessionState.Path.CurrentLocation.ProviderPath)\u{2500}\u{2500}\u{27a4}{nonce}:$code \" }}"
255 )
256}
257
258fn command_available(command: &str) -> bool {
259 crate::utils::executable::is_available(command)
260}
261
262fn ensure_screenrc() {
265 let Some(home) = home::home_dir() else {
266 return;
267 };
268 let screenrc = home.join(".screenrc");
269 if screenrc.exists() {
270 return;
271 }
272 let _ = std::fs::write(
273 &screenrc,
274 "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
275 );
276}
277
278fn cleanup_orphaned_screens() {
284 let Ok(output) = Command::new("screen").arg("-ls").output() else {
285 return;
286 };
287 let listing = String::from_utf8_lossy(&output.stdout);
289 for line in listing.lines() {
290 let Some(session) = line.split_whitespace().next() else {
291 continue;
292 };
293 let Some((_, name)) = session.split_once('.') else {
295 continue;
296 };
297 if let Some(creator_pid) = winx_creator_pid(name) {
298 if !process_exists(creator_pid) {
299 let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
300 }
301 }
302 }
303}
304
305fn winx_creator_pid(name: &str) -> Option<u32> {
307 name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
308}
309
310#[cfg(unix)]
313fn process_exists(pid: u32) -> bool {
314 crate::os::unix::process_exists(pid)
315}
316
317#[cfg(not(unix))]
318fn process_exists(pid: u32) -> bool {
319 crate::os::process_exists(pid)
320}
321
322pub(crate) fn native_cwd_from_prompt(cwd: &str) -> PathBuf {
327 if cfg!(windows) {
328 if let Some(native) = msys_to_native(cwd) {
329 return native;
330 }
331 }
332 PathBuf::from(cwd)
333}
334
335fn msys_to_native(cwd: &str) -> Option<PathBuf> {
336 let rest = cwd.strip_prefix("/cygdrive").unwrap_or(cwd);
337 let mut parts = rest.strip_prefix('/')?.splitn(2, '/');
338 let drive = parts.next()?;
339 if drive.len() != 1 || !drive.chars().all(|character| character.is_ascii_alphabetic()) {
340 return None;
341 }
342 let tail = parts.next().unwrap_or("");
343 let mut native = format!("{}:\\", drive.to_ascii_uppercase());
344 native.push_str(&tail.replace('/', "\\"));
345 Some(PathBuf::from(native))
346}
347
348fn timestamp_millis() -> u128 {
349 std::time::SystemTime::now()
350 .duration_since(std::time::UNIX_EPOCH)
351 .map_or(0, |duration| duration.as_millis())
352}
353
354fn prompt_text_mimics_activity(
357 recognizer: &dyn crate::state::turn::TurnRecognizer,
358 text: &str,
359) -> bool {
360 let lines: Vec<String> = text.lines().map(str::to_string).collect();
361 let folded = [text.split_whitespace().collect::<Vec<_>>().join(" ")];
362 let mimics_activity = |screen: &[String]| {
363 matches!(
364 recognizer.detect_input(&crate::state::turn::TurnInput::screen_only(screen)),
365 crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
366 )
367 };
368 mimics_activity(&lines) || mimics_activity(&folded)
369}
370
371#[cfg(test)]
372mod workflow_activity_tests;
373
374struct PromptActivityGate {
376 recognizer: Box<dyn crate::state::turn::TurnRecognizer>,
377 baseline_seq: u64,
378 observed: Arc<AtomicBool>,
379 submitted: bool,
380 ambiguous_echo: bool,
381 blocked_state: Option<crate::state::turn::TurnState>,
382}
383
384impl PromptActivityGate {
385 fn observe(&mut self, live: &LiveTerminal, seq: u64) {
386 if !self.submitted
387 || self.ambiguous_echo
388 || seq <= self.baseline_seq
389 || self.observed.load(AtomicOrdering::Acquire)
390 {
391 return;
392 }
393 let screen = live.snapshot(0);
394 let input = crate::state::turn::TurnInput {
395 screen: &screen,
396 osc_title: live.osc_title(),
397 osc_progress: live.osc_progress(),
398 };
399 let state = self.recognizer.detect_input(&input);
400 if self.blocked_state == Some(state) {
401 return;
402 }
403 self.blocked_state = None;
404 if matches!(
405 state,
406 crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
407 ) {
408 self.observed.store(true, AtomicOrdering::Release);
409 }
410 }
411}
412
413#[cfg(test)]
414mod workflow_reader_tests;
415
416fn spawn_pty_reader(
419 mut reader: Box<dyn Read + Send>,
420 output_tx: mpsc::SyncSender<String>,
421 live: Arc<StdMutex<LiveTerminal>>,
422 discard_throttle: Arc<AtomicBool>,
423 activity_seq: Arc<AtomicU64>,
424 queued_activity_seq: Arc<AtomicU64>,
425 prompt_activity: Arc<StdMutex<Option<PromptActivityGate>>>,
426) {
427 thread::spawn(move || {
428 let mut buf = [0u8; 4096];
429 let mut carry: Vec<u8> = Vec::new();
434 loop {
435 match reader.read(&mut buf) {
436 Ok(0) => {
437 debug!("PTY reader reached EOF");
438 if !carry.is_empty() {
440 let seq =
443 activity_seq.fetch_add(1, AtomicOrdering::Release).saturating_add(1);
444 if output_tx.send(String::from_utf8_lossy(&carry).into_owned()).is_ok() {
445 queued_activity_seq.store(seq, AtomicOrdering::Release);
446 }
447 }
448 break;
449 }
450 Ok(n) => {
451 debug!(bytes = n, "PTY reader chunk");
452 let seq = {
458 let mut emu =
459 live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
460 emu.feed(&buf[..n]);
461 let seq =
462 activity_seq.fetch_add(1, AtomicOrdering::Release).saturating_add(1);
463 if let Some(gate) = prompt_activity
464 .lock()
465 .unwrap_or_else(std::sync::PoisonError::into_inner)
466 .as_mut()
467 {
468 gate.observe(&emu, seq);
469 }
470 seq
471 };
472 carry.extend_from_slice(&buf[..n]);
475 let (chunk, rest) = decode_keep_incomplete(&carry);
476 carry = rest;
477 if !chunk.is_empty() {
478 if output_tx.send(chunk).is_err() {
479 break;
481 }
482 queued_activity_seq.store(seq, AtomicOrdering::Release);
484 }
485 if discard_throttle.load(AtomicOrdering::Relaxed) {
490 thread::sleep(DISCARD_READ_THROTTLE);
491 }
492 }
493 Err(e) => {
494 debug!("PTY reader thread error: {}", e);
495 break;
496 }
497 }
498 }
499 debug!("PTY reader thread exiting");
500 });
501}
502
503pub struct PtyShell {
508 master: Box<dyn MasterPty + Send>,
510 child: Box<dyn Child + Send + Sync>,
512 writer: Box<dyn Write + Send>,
514 output_rx: mpsc::Receiver<String>,
516 size: PtySize,
518 pub last_command: String,
520 pub output_buffer: String,
522 pub command_running: bool,
524 current_cwd: PathBuf,
526 command_started_at: Option<Instant>,
529 command_generation: u64,
532 incarnation: u64,
535 activity_seq: Arc<AtomicU64>,
536 queued_activity_seq: Arc<AtomicU64>,
538 pub(crate) prompt_receipts:
539 std::collections::HashMap<String, crate::runtime::workflow::PromptRecord>,
540 prompt_activity: Arc<StdMutex<Option<PromptActivityGate>>>,
541 pub last_exit_code: Option<i32>,
545 max_output_size: usize,
547 pub output_truncated: bool,
549 ring: LineRing,
553 pub last_returned_hash: Option<u64>,
556 delivered_output: String,
559 pending_completion_report: bool,
565 pub attach_hint: Option<String>,
567 prompt_end_marker: String,
573 live: Arc<StdMutex<LiveTerminal>>,
581 scratch_workspace_root: Option<PathBuf>,
586 scratch_path: Option<PathBuf>,
589 scratch_bytes: u64,
591 discard_throttle: Arc<AtomicBool>,
596 #[cfg(windows)]
600 job: Option<crate::os::windows::ProcessTreeJob>,
601}
602
603impl std::fmt::Debug for PtyShell {
604 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605 f.debug_struct("PtyShell")
606 .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
607 .field("last_command", &self.last_command)
608 .field("command_running", &self.command_running)
609 .field("current_cwd", &self.current_cwd)
610 .field("output_truncated", &self.output_truncated)
611 .field("output_buffer_len", &self.output_buffer.len())
612 .field("attach_hint", &self.attach_hint)
613 .finish_non_exhaustive()
614 }
615}
616
617#[cfg(unix)]
628fn killable_group(pid: u32) -> Option<i32> {
629 crate::os::unix::owned_process_group(pid)
630}
631
632#[cfg(unix)]
635fn signal_group(pgid: i32, signal: i32) {
636 let _ = crate::os::unix::signal_group(pgid, signal);
637}
638
639impl Drop for PtyShell {
640 fn drop(&mut self) {
654 #[cfg(unix)]
660 let pgid = if self.attach_hint.is_some() {
661 None
662 } else {
663 self.child.process_id().and_then(killable_group)
664 };
665
666 #[cfg(unix)]
667 if let Some(pgid) = pgid {
668 signal_group(pgid, libc::SIGTERM);
669 }
670
671 #[cfg(windows)]
673 if let Some(job) = self.job.as_ref() {
674 job.terminate();
675 }
676
677 let _ = self.child.kill();
679
680 #[cfg(unix)]
681 if let Some(pgid) = pgid {
682 signal_group(pgid, libc::SIGKILL);
683 }
684
685 let _ = self.child.wait();
686 }
687}
688
689impl PtyShell {
690 pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
699 Self::new_with_agent_paths(initial_dir, restricted_mode, None, None)
700 }
701
702 pub(crate) fn new_with_agent_paths(
705 initial_dir: &Path,
706 restricted_mode: bool,
707 workspace_root: Option<&Path>,
708 temporary_artifact_dir: Option<&Path>,
709 ) -> Result<Self> {
710 info!(
711 "Creating new PTY shell (restricted: {}) in {}",
712 restricted_mode,
713 initial_dir.display()
714 );
715
716 #[cfg(windows)]
720 ensure_windows_shell_available(restricted_mode)?;
721
722 let size =
724 PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
725
726 let pair = native_pty_system().openpty(size).context("Failed to open PTY pair")?;
728
729 let (mut cmd, attach_hint, shell_kind) = attachable_command(restricted_mode);
731 if shell_kind.is_powershell() && restricted_mode {
732 return Err(anyhow!(
733 "restricted mode needs bash -r; PowerShell has no equivalent restricted mode"
734 ));
735 }
736
737 configure_agent_environment(&mut cmd, workspace_root, temporary_artifact_dir);
738 let nonce = format!("{:016x}", rand::random::<u64>());
741 cmd.env(
746 "PROMPT_COMMAND",
747 format!(r#"__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec""#),
748 );
749 cmd.cwd(initial_dir);
750
751 let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
753 #[cfg(windows)]
754 let job = match crate::os::windows::ProcessTreeJob::new()
755 .and_then(|job| child.process_id().map_or(Ok(()), |pid| job.assign(pid)).map(|()| job))
756 {
757 Ok(job) => Some(job),
758 Err(error) => {
759 warn!(%error, "PTY child is not in a job object; background children may outlive the shell");
760 None
761 }
762 };
763
764 let reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
766 let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
767
768 let (output_tx, output_rx) = mpsc::sync_channel::<String>(1024);
772
773 let live = Arc::new(StdMutex::new(LiveTerminal::new(DEFAULT_ROWS, DEFAULT_COLS)));
776 let discard_throttle = Arc::new(AtomicBool::new(false));
777 let prompt_activity = Arc::new(StdMutex::new(None));
778
779 let activity_seq = Arc::new(AtomicU64::new(0));
780 let queued_activity_seq = Arc::new(AtomicU64::new(0));
781 spawn_pty_reader(
782 reader,
783 output_tx,
784 Arc::clone(&live),
785 Arc::clone(&discard_throttle),
786 activity_seq.clone(),
787 queued_activity_seq.clone(),
788 prompt_activity.clone(),
789 );
790
791 let mut shell = Self {
793 master: pair.master,
794 child,
795 writer,
796 output_rx,
797 size,
798 last_command: String::new(),
799 output_buffer: String::new(),
800 command_running: false,
801 current_cwd: initial_dir.to_path_buf(),
802 command_started_at: None,
803 command_generation: 0,
804 incarnation: rand::random::<u64>().max(1),
805 activity_seq,
806 queued_activity_seq,
807 prompt_receipts: std::collections::HashMap::new(),
808 prompt_activity,
809 last_exit_code: None,
810 max_output_size: MAX_OUTPUT_SIZE,
811 output_truncated: false,
812 ring: LineRing::new(RING_BUFFER_LINES, MAX_PARTIAL_LINE_BYTES),
813 last_returned_hash: None,
814 delivered_output: String::new(),
815 pending_completion_report: false,
816 attach_hint,
817 prompt_end_marker: format!("{WCGW_PROMPT_END}{nonce}"),
818 live,
819 scratch_workspace_root: None,
820 scratch_path: None,
821 scratch_bytes: 0,
822 discard_throttle,
823 #[cfg(windows)]
824 job,
825 };
826
827 shell.initialize_prompt(shell_kind, &nonce)?;
829
830 debug!("PTY shell created successfully");
831 Ok(shell)
832 }
833
834 fn initialize_prompt(&mut self, shell_kind: ShellKind, nonce: &str) -> Result<()> {
836 let prompt_statement = match shell_kind {
845 ShellKind::Zsh => format!(
849 r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() {{ local __winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec" }}"#
850 ),
851 ShellKind::Bash => format!(
852 r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec"'; PS1=''"#
853 ),
854 ShellKind::PowerShell => powershell_bootstrap(nonce),
855 };
856
857 if shell_kind.is_powershell() {
858 self.wait_for_initial_powershell_prompt();
864 }
865 self.write_command(&prompt_statement)?;
866
867 let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
874 let mut bootstrap = String::new();
875 let mut settled = false;
876 while Instant::now() < deadline {
877 match self.output_rx.recv_timeout(Duration::from_millis(50)) {
878 Ok(chunk) => {
879 self.answer_terminal_queries(&chunk);
880 bootstrap.push_str(&chunk);
881 if Self::check_prompt_complete(&bootstrap, &self.prompt_end_marker) {
882 settled = true;
883 break;
884 }
885 }
886 Err(mpsc::RecvTimeoutError::Timeout) => {}
887 Err(mpsc::RecvTimeoutError::Disconnected) => break,
888 }
889 }
890 if settled {
891 std::thread::sleep(Duration::from_millis(30));
892 } else {
893 warn!(
894 timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
895 "PTY shell did not print its first prompt in time; continuing anyway"
896 );
897 }
898 bootstrap.push_str(&self.drain_output());
899 debug!(
900 bytes = bootstrap.len(),
901 settled,
902 child_pid = ?self.child.process_id(),
903 "PTY prompt bootstrap drained"
904 );
905
906 Ok(())
907 }
908
909 fn wait_for_initial_powershell_prompt(&mut self) {
912 let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
913 let mut seen = String::new();
914 while Instant::now() < deadline {
915 match self.output_rx.recv_timeout(Duration::from_millis(50)) {
916 Ok(chunk) => {
917 self.answer_terminal_queries(&chunk);
918 seen.push_str(&chunk);
919 let clean = crate::state::terminal::strip_ansi_codes(&seen);
920 if clean.contains("> ") || clean.contains(">\u{a0}") {
921 std::thread::sleep(Duration::from_millis(100));
923 return;
924 }
925 }
926 Err(mpsc::RecvTimeoutError::Timeout) => {}
927 Err(mpsc::RecvTimeoutError::Disconnected) => return,
928 }
929 }
930 warn!(
931 timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
932 "PowerShell did not draw its initial prompt in time; sending the bootstrap anyway"
933 );
934 }
935
936 fn answer_terminal_queries(&mut self, chunk: &str) {
945 const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
946 if !chunk.contains(CURSOR_POSITION_REQUEST) {
947 return;
948 }
949 debug!("PTY answered a cursor position request");
950 if let Err(error) = self.writer.write_all(b"\x1b[1;1R").and_then(|()| self.writer.flush()) {
951 debug!("PTY cursor position report failed: {error}");
952 }
953 }
954
955 fn write_command(&mut self, command: &str) -> Result<()> {
957 let cmd_with_newline = format!("{command}\r");
963 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
964 self.writer.flush().context("Failed to flush PTY")?;
965 Ok(())
966 }
967
968 fn drain_output(&mut self) -> String {
970 let mut output = String::new();
971 let deadline = Instant::now() + Duration::from_millis(200);
972
973 while Instant::now() < deadline {
975 match self.output_rx.try_recv() {
976 Ok(chunk) => {
977 self.answer_terminal_queries(&chunk);
978 output.push_str(&chunk);
979
980 if output.len() > self.max_output_size {
982 self.output_truncated = true;
983 break;
984 }
985 }
986 Err(TryRecvError::Empty) => {
987 thread::sleep(Duration::from_millis(10));
989 }
990 Err(TryRecvError::Disconnected) => {
991 break;
993 }
994 }
995 }
996
997 output
998 }
999
1000 pub fn send_command(&mut self, command: &str) -> Result<()> {
1002 debug!(bytes = command.len(), "PTY sending command");
1003
1004 self.output_buffer.clear();
1006 self.output_truncated = false;
1007 self.reset_scratch();
1008 self.last_command = command.to_string();
1009 self.command_running = true;
1010 self.mark_command_started();
1011 self.last_exit_code = None;
1012 self.last_returned_hash = None;
1015 self.delivered_output.clear();
1016 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear_osc();
1019
1020 self.write_command(command)?;
1022
1023 Ok(())
1024 }
1025
1026 pub fn mark_command_started(&mut self) {
1028 self.prompt_receipts.clear();
1029 *self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1030 self.command_generation = self.command_generation.saturating_add(1);
1031 self.command_started_at = Some(Instant::now());
1032 self.pending_completion_report = true;
1033 }
1034
1035 pub fn completion_report_pending(&self) -> bool {
1038 !self.command_running && self.pending_completion_report
1039 }
1040
1041 pub fn mark_completion_reported(&mut self) {
1043 self.pending_completion_report = false;
1044 }
1045
1046 pub fn command_generation(&self) -> u64 {
1047 self.command_generation
1048 }
1049
1050 pub fn activity_seq(&self) -> u64 {
1052 self.activity_seq.load(AtomicOrdering::Acquire)
1053 }
1054
1055 pub(crate) fn poll_workflow_output(&mut self) -> u64 {
1059 let activity_seq = self.queued_activity_seq.load(AtomicOrdering::Acquire);
1060 self.poll_output_nonblocking();
1061 activity_seq
1062 }
1063
1064 pub(crate) fn arm_prompt_activity(
1067 &mut self,
1068 hint: &str,
1069 text: &str,
1070 ) -> crate::errors::Result<(u64, Arc<AtomicBool>)> {
1071 let recognizer = crate::state::turn::recognizer_for(hint);
1072 let ambiguous_echo = prompt_text_mimics_activity(recognizer.as_ref(), text);
1073 let live = self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1074 let screen = live.snapshot(0);
1075 let input = crate::state::turn::TurnInput {
1076 screen: &screen,
1077 osc_title: live.osc_title(),
1078 osc_progress: live.osc_progress(),
1079 };
1080 if recognizer.detect_input(&input) != crate::state::turn::TurnState::AwaitingInput {
1081 return Err(crate::errors::WinxError::InvalidInput(
1082 "native prompt requires a running command at a recognized input-ready prompt"
1083 .to_string(),
1084 ));
1085 }
1086 let baseline_seq = self.activity_seq();
1087 let observed = Arc::new(AtomicBool::new(false));
1088 *self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner) =
1089 Some(PromptActivityGate {
1090 recognizer,
1091 baseline_seq,
1092 observed: observed.clone(),
1093 submitted: false,
1094 ambiguous_echo,
1095 blocked_state: None,
1096 });
1097 Ok((baseline_seq, observed))
1098 }
1099
1100 pub(crate) fn submit_prompt_enter(&mut self) -> Result<()> {
1103 let live = self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1104 let mut gate =
1105 self.prompt_activity.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1106 let gate = gate.as_mut().ok_or_else(|| anyhow!("prompt activity gate is unavailable"))?;
1107 let screen = live.snapshot(0);
1108 let state = gate.recognizer.detect_input(&crate::state::turn::TurnInput {
1109 screen: &screen,
1110 osc_title: live.osc_title(),
1111 osc_progress: live.osc_progress(),
1112 });
1113 gate.blocked_state = matches!(
1114 state,
1115 crate::state::turn::TurnState::Busy | crate::state::turn::TurnState::AwaitingApproval
1116 )
1117 .then_some(state);
1118 self.writer.write_all(b"\r")?;
1119 self.writer.flush()?;
1120 gate.submitted = true;
1121 Ok(())
1122 }
1123
1124 pub(crate) fn retained_workflow(
1125 &self,
1126 activity_seq: u64,
1127 ) -> crate::runtime::workflow::RetainedWorkflow {
1128 crate::runtime::workflow::RetainedWorkflow {
1129 generation: self.command_generation(),
1130 activity_seq,
1131 screen: self.live_snapshot(0),
1132 osc_title: self.live_osc_title(),
1133 osc_progress: self.live_osc_progress(),
1134 receipts: self.prompt_receipts.clone(),
1135 }
1136 }
1137
1138 pub fn incarnation(&self) -> u64 {
1139 self.incarnation
1140 }
1141
1142 pub fn command_elapsed(&self) -> Option<Duration> {
1144 self.command_started_at.map(|started_at| started_at.elapsed())
1145 }
1146
1147 pub fn current_cwd(&self) -> &Path {
1149 &self.current_cwd
1150 }
1151
1152 pub fn delivered_output(&self) -> String {
1154 self.delivered_output.clone()
1155 }
1156
1157 pub fn mark_output_delivered(&mut self, output: &str) {
1159 self.delivered_output.clear();
1160 self.delivered_output.push_str(output);
1161 }
1162
1163 pub fn collect_scrollback(&self, lines: usize) -> String {
1168 let rendered =
1174 crate::state::terminal::render_terminal_output(&self.ring.raw(lines)).join("\n");
1175 match self.ring.scrollback_notice(lines) {
1176 Some(notice) => format!("{notice}\n{rendered}"),
1177 None => rendered,
1178 }
1179 }
1180
1181 pub fn fingerprint(text: &str) -> u64 {
1183 let mut hasher = DefaultHasher::new();
1184 text.hash(&mut hasher);
1185 hasher.finish()
1186 }
1187
1188 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
1193 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
1194 let start = Instant::now();
1195 let mut complete = false;
1196 let mut no_data_count = 0;
1197 let mut prompt_detected_at: Option<Instant> = None;
1198
1199 while start.elapsed() < timeout {
1200 match self.output_rx.try_recv() {
1201 Ok(chunk) => {
1202 self.answer_terminal_queries(&chunk);
1203 self.output_buffer.push_str(&chunk);
1204 self.ring.push_chunk(&chunk);
1205 no_data_count = 0;
1206
1207 if prompt_detected_at.is_none()
1209 && (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
1210 || Self::check_prompt_complete(
1211 &self.output_buffer,
1212 &self.prompt_end_marker,
1213 ))
1214 {
1215 prompt_detected_at = Some(Instant::now());
1216 debug!("Prompt detected, draining remaining output...");
1217 }
1218
1219 self.truncate_output_buffer_with_offload();
1221 }
1222 Err(TryRecvError::Empty) => {
1223 thread::sleep(Duration::from_millis(10));
1225 no_data_count += 1;
1226
1227 if let Some(detected_time) = prompt_detected_at {
1229 if detected_time.elapsed() > Duration::from_millis(100) {
1231 complete = true;
1232 debug!("Command completed - prompt detected and drained");
1233 break;
1234 }
1235 } else if no_data_count > 10
1236 && Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1237 {
1238 prompt_detected_at = Some(Instant::now());
1240 debug!("Prompt detected after wait, draining...");
1241 }
1242 }
1243 Err(TryRecvError::Disconnected) => {
1244 warn!("PTY reader disconnected");
1246 complete = true;
1247 break;
1248 }
1249 }
1250 }
1251
1252 if complete || prompt_detected_at.is_some() {
1253 self.command_running = false;
1254 self.last_exit_code =
1255 Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1256 self.update_current_cwd_from_prompt();
1257 complete = true;
1258 }
1259
1260 Ok((self.output_buffer.clone(), complete))
1261 }
1262
1263 pub fn poll_output_nonblocking(&mut self) -> bool {
1271 let mut prompt_seen = false;
1272 loop {
1273 match self.output_rx.try_recv() {
1274 Ok(chunk) => {
1275 self.answer_terminal_queries(&chunk);
1276 let chunk_has_prompt =
1277 Self::check_prompt_complete(&chunk, &self.prompt_end_marker);
1278 self.output_buffer.push_str(&chunk);
1279 self.ring.push_chunk(&chunk);
1280 if chunk_has_prompt
1281 || Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1282 {
1283 prompt_seen = true;
1284 }
1285 self.truncate_output_buffer_with_offload();
1286 }
1287 Err(TryRecvError::Empty) => break,
1288 Err(TryRecvError::Disconnected) => {
1289 self.command_running = false;
1290 return true;
1291 }
1292 }
1293 }
1294 if prompt_seen {
1295 self.command_running = false;
1296 self.last_exit_code =
1297 Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1298 self.update_current_cwd_from_prompt();
1299 }
1300 !self.command_running
1305 }
1306
1307 fn update_current_cwd_from_prompt(&mut self) {
1308 let Some(last) =
1309 self.output_buffer.lines().rev().find(|line| line.contains(&self.prompt_end_marker))
1310 else {
1311 return;
1312 };
1313 let clean = crate::state::terminal::strip_ansi_codes(last);
1314 let Some((before_marker, _)) = clean.rsplit_once(&self.prompt_end_marker) else {
1315 return;
1316 };
1317 let Some((_, cwd)) = before_marker.rsplit_once("◉ ") else {
1318 return;
1319 };
1320 let cwd = cwd.trim();
1321 if !cwd.is_empty() {
1322 self.current_cwd = native_cwd_from_prompt(cwd);
1323 }
1324 }
1325
1326 pub fn output_snapshot(&self) -> String {
1330 self.output_buffer.clone()
1331 }
1332
1333 fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
1341 Self::parse_prompt_exit_code(text, prompt_end).is_some()
1342 }
1343
1344 fn parse_prompt_exit_code(text: &str, prompt_end: &str) -> Option<i32> {
1350 let last = text.lines().rev().find(|line| !line.trim().is_empty())?;
1351 if !last.contains(WCGW_PROMPT_PATTERN) {
1352 return None;
1353 }
1354 let raw_tail = last.rsplit_once(prompt_end)?.1;
1360 let clean_tail = crate::state::terminal::strip_ansi_codes(raw_tail);
1361 let code_text = clean_tail.strip_prefix(':')?;
1362 let digits = code_text
1363 .char_indices()
1364 .take_while(|(index, c)| c.is_ascii_digit() || (*index == 0 && *c == '-'))
1365 .count();
1366 let (code, rest) = code_text.split_at(digits);
1367 if !rest.chars().all(char::is_whitespace) {
1368 return None;
1369 }
1370 let terminated = !rest.is_empty() || raw_tail.len() > clean_tail.len();
1374 if !terminated {
1375 return None;
1376 }
1377 code.parse::<i32>().ok()
1378 }
1379
1380 pub fn set_scratch_root(&mut self, root: &Path) {
1383 self.scratch_workspace_root = Some(root.to_path_buf());
1384 }
1385
1386 pub fn scratch_path(&self) -> Option<&Path> {
1389 self.scratch_path.as_deref()
1390 }
1391
1392 pub fn reset_scratch(&mut self) {
1395 self.scratch_path = None;
1396 self.scratch_bytes = 0;
1397 self.discard_throttle.store(false, AtomicOrdering::Relaxed);
1398 }
1399
1400 fn offload_dropped_head(&mut self, head: &str) {
1404 let Some(root) = self.scratch_workspace_root.clone() else {
1405 return;
1406 };
1407 let remaining = SCRATCH_MAX_BYTES.saturating_sub(self.scratch_bytes);
1411 if remaining == 0 {
1412 self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1416 return;
1417 }
1418 let head = match usize::try_from(remaining) {
1419 Ok(rem) if rem < head.len() => &head[..crate::utils::floor_char_boundary(head, rem)],
1420 _ => head,
1421 };
1422 if self.scratch_path.is_none() {
1423 self.scratch_path = crate::utils::scratch_file::new_scratch_path(&root);
1424 }
1425 let Some(path) = self.scratch_path.clone() else {
1426 return;
1427 };
1428 match crate::utils::scratch_file::append_scratch(&path, head.as_bytes()) {
1429 Ok(0) if self.scratch_bytes == 0 => self.scratch_path = None,
1430 Ok(written) => {
1431 self.scratch_bytes = self.scratch_bytes.saturating_add(written as u64);
1432 if self.scratch_bytes >= SCRATCH_MAX_BYTES {
1433 self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1434 }
1435 }
1436 Err(e) => debug!("scratch: append to {} failed: {e}", path.display()),
1437 }
1438 }
1439
1440 fn truncate_output_buffer_with_offload(&mut self) {
1445 if self.output_buffer.len() <= self.max_output_size {
1446 return;
1447 }
1448 self.output_truncated = true;
1449 let keep_size = self.max_output_size / 2;
1450 let cut = crate::utils::floor_char_boundary(
1453 &self.output_buffer,
1454 self.output_buffer.len() - keep_size,
1455 );
1456 let head = self.output_buffer[..cut].to_owned();
1457 self.offload_dropped_head(&head);
1458 self.output_buffer.replace_range(..cut, "\n(...output truncated...)\n");
1463 }
1464
1465 pub fn send_interrupt(&mut self) -> Result<()> {
1467 debug!("PTY sending Ctrl+C");
1468 self.writer
1469 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
1471 self.writer.flush()?;
1472 Ok(())
1473 }
1474
1475 pub fn send_eof(&mut self) -> Result<()> {
1477 debug!("PTY sending Ctrl+D");
1478 self.writer
1479 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
1481 self.writer.flush()?;
1482 Ok(())
1483 }
1484
1485 pub fn send_suspend(&mut self) -> Result<()> {
1487 debug!("PTY sending Ctrl+Z");
1488 self.writer
1489 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
1491 self.writer.flush()?;
1492 Ok(())
1493 }
1494
1495 pub fn send_text(&mut self, text: &str) -> Result<()> {
1497 debug!(bytes = text.len(), "PTY sending text");
1498 self.send_bytes(text.as_bytes()).context("Failed to send text")?;
1499 Ok(())
1500 }
1501
1502 pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
1504 self.writer.write_all(bytes).context("Failed to send bytes")?;
1505 self.writer.flush()?;
1506 Ok(())
1507 }
1508
1509 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
1511 let bytes: &[u8] = match key {
1512 "Enter" => b"\r",
1513 "Tab" => b"\t",
1514 "Backspace" => b"\x7F",
1515 "Escape" => b"\x1B",
1516 "Up" | "KeyUp" => b"\x1B[A",
1517 "Down" | "KeyDown" => b"\x1B[B",
1518 "Right" | "KeyRight" => b"\x1B[C",
1519 "Left" | "KeyLeft" => b"\x1B[D",
1520 "Home" => b"\x1B[H",
1521 "End" => b"\x1B[F",
1522 "PageUp" => b"\x1B[5~",
1523 "PageDown" => b"\x1B[6~",
1524 "Delete" => b"\x1B[3~",
1525 "Insert" => b"\x1B[2~",
1526 "CtrlC" | "Ctrl-C" => b"\x03",
1527 "CtrlD" | "Ctrl-D" => b"\x04",
1528 "CtrlZ" | "Ctrl-Z" => b"\x1A",
1529 "CtrlL" | "Ctrl-L" => b"\x0C",
1530 _ => return Err(anyhow!("Unknown special key: {key}")),
1531 };
1532
1533 debug!("PTY sending special key: {} ({:?})", key, bytes);
1534 self.send_bytes(bytes)?;
1535 Ok(())
1536 }
1537
1538 pub fn live_snapshot(&self, max_lines: usize) -> Vec<String> {
1543 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).snapshot(max_lines)
1547 }
1548
1549 pub fn live_in_alt_screen(&self) -> bool {
1552 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).in_alt_screen()
1553 }
1554
1555 pub fn live_osc_title(&self) -> String {
1559 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).osc_title().to_string()
1560 }
1561
1562 pub fn live_osc_progress(&self) -> String {
1565 self.live
1566 .lock()
1567 .unwrap_or_else(std::sync::PoisonError::into_inner)
1568 .osc_progress()
1569 .to_string()
1570 }
1571
1572 pub fn live_cursor_position(&self) -> (u16, u16) {
1575 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_position()
1576 }
1577
1578 pub(crate) fn live_cursor_line_prefix(&self) -> String {
1580 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_line_prefix()
1581 }
1582
1583 pub fn live_snapshot_diff(&self, max_lines: usize, threshold: usize) -> ScreenUpdate {
1587 self.live
1588 .lock()
1589 .unwrap_or_else(std::sync::PoisonError::into_inner)
1590 .snapshot_diff(max_lines, threshold)
1591 }
1592
1593 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
1595 debug!("PTY resizing to {}x{}", cols, rows);
1596
1597 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
1598
1599 self.master.resize(new_size).context("Failed to resize PTY")?;
1600
1601 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).resize(rows, cols);
1603
1604 self.size = new_size;
1605 Ok(())
1606 }
1607
1608 pub fn get_size(&self) -> (u16, u16) {
1610 (self.size.cols, self.size.rows)
1611 }
1612
1613 pub fn process_id(&self) -> Option<u32> {
1615 self.child.process_id()
1616 }
1617
1618 pub fn is_alive(&mut self) -> bool {
1620 self.child.try_wait().is_ok_and(|status| status.is_none())
1621 }
1622}
1623
1624pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
1626
1627pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
1629 let shell = PtyShell::new(initial_dir, restricted_mode)?;
1630 Ok(Arc::new(Mutex::new(Some(shell))))
1631}
1632
1633fn decode_keep_incomplete(bytes: &[u8]) -> (String, Vec<u8>) {
1642 let mut decoded = String::new();
1643 let mut rest = bytes;
1644 loop {
1645 match std::str::from_utf8(rest) {
1646 Ok(text) => {
1647 decoded.push_str(text);
1648 return (decoded, Vec::new());
1649 }
1650 Err(error) => {
1651 let valid = error.valid_up_to();
1652 decoded.push_str(&String::from_utf8_lossy(&rest[..valid]));
1655 match error.error_len() {
1656 None => return (decoded, rest[valid..].to_vec()),
1658 Some(bad) => {
1660 decoded.push('\u{FFFD}');
1661 rest = &rest[valid + bad..];
1662 }
1663 }
1664 }
1665 }
1666 }
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671 use super::*;
1672 #[cfg(unix)] use tempfile::TempDir;
1674
1675 #[test]
1676 fn decode_passes_clean_utf8_through() {
1677 let (text, carry) = decode_keep_incomplete("hello ✓ café".as_bytes());
1678 assert_eq!(text, "hello ✓ café");
1679 assert!(carry.is_empty(), "expected empty carry");
1680 }
1681
1682 #[test]
1683 fn decode_carries_incomplete_trailing_char_across_reads() {
1684 let (text1, carry1) = decode_keep_incomplete(&[b'a', 0xC3]);
1687 assert_eq!(text1, "a");
1688 assert_eq!(carry1, vec![0xC3]);
1689
1690 let mut next = carry1;
1691 next.push(0xA9); let (text2, carry2) = decode_keep_incomplete(&next);
1693 assert_eq!(text2, "é");
1694 assert!(carry2.is_empty(), "expected empty carry2");
1695 }
1696
1697 #[test]
1698 fn decode_replaces_genuinely_invalid_bytes() {
1699 let (text, carry) = decode_keep_incomplete(&[b'x', 0xFF, b'y']);
1701 assert_eq!(text, "x\u{FFFD}y");
1702 assert!(carry.is_empty(), "expected empty carry");
1703 }
1704
1705 #[test]
1706 fn decode_handles_large_invalid_burst_without_overflow() {
1707 let big = vec![0xFFu8; 200_000];
1711 let (text, carry) = decode_keep_incomplete(&big);
1712 assert!(carry.is_empty(), "expected empty carry");
1713 assert_eq!(text.chars().filter(|&c| c == '\u{FFFD}').count(), 200_000);
1714 }
1715
1716 #[test]
1717 fn screen_session_name_parser_is_strict() {
1718 assert_eq!(super::winx_creator_pid("winx-123-456"), Some(123));
1719 assert_eq!(super::winx_creator_pid("winx-nope-456"), None);
1720 assert_eq!(super::winx_creator_pid("other-123-456"), None);
1721 }
1722
1723 #[cfg(unix)]
1724 #[test]
1725 fn process_probe_recognizes_current_process_and_rejects_reserved_pids() {
1726 assert!(super::process_exists(std::process::id()));
1727 assert!(!super::process_exists(0));
1728 assert!(!super::process_exists(1));
1729 }
1730
1731 #[test]
1732 fn msys_paths_convert_to_native_drives() {
1733 use super::msys_to_native;
1734 assert_eq!(msys_to_native("/c/Users/x/ws"), Some(PathBuf::from("C:\\Users\\x\\ws")));
1735 assert_eq!(msys_to_native("/cygdrive/d/work"), Some(PathBuf::from("D:\\work")));
1736 assert_eq!(msys_to_native("/c"), Some(PathBuf::from("C:\\")));
1737 assert_eq!(msys_to_native("/home/x"), None);
1738 assert_eq!(msys_to_native("relative"), None);
1739 }
1740
1741 #[test]
1742 fn prompt_detection_is_suffix_anchored() {
1743 let end = "──➤deadbeefcafe0001";
1745 assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001:0 ", end));
1747 assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 \u{1b}[K", end));
1749 assert!(!PtyShell::check_prompt_complete(
1751 "menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
1752 end
1753 ));
1754 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 ls -la", end));
1756 assert!(!PtyShell::check_prompt_complete("just some output\n", end));
1758 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
1761 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce:0 ", end));
1762 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ", end));
1763 }
1764
1765 #[test]
1766 fn powershell_bootstrap_defines_a_marker_prompt_without_psreadline() {
1767 let bootstrap = super::powershell_bootstrap("cafe1234");
1768 assert!(bootstrap.contains("Remove-Module PSReadLine"), "{bootstrap}");
1769 assert!(bootstrap.contains("function global:prompt"), "{bootstrap}");
1770 assert!(bootstrap.contains("──➤cafe1234:$code "), "{bootstrap}");
1771 assert!(bootstrap.contains("$global:LASTEXITCODE = 0"), "{bootstrap}");
1772 assert!(!bootstrap.contains('\n'), "bootstrap must be a single input line");
1773 }
1774
1775 #[test]
1776 fn parses_exit_code_from_prompt_marker() {
1777 let end = "──➤cafe1234";
1778 assert_eq!(PtyShell::parse_prompt_exit_code("ok\n◉ /x──➤cafe1234:0 ", end), Some(0));
1779 assert_eq!(PtyShell::parse_prompt_exit_code("oops\n◉ /x──➤cafe1234:1 ", end), Some(1));
1780 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:42 ", end), Some(42));
1781 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:1", end), None);
1783 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127", end), None);
1784 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127 ", end), Some(127));
1785 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127\u{1b}[C", end), Some(127));
1787 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:0\u{1b}[K", end), Some(0));
1788 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:-1 ", end), Some(-1));
1789 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:12a ", end), None);
1790 assert_eq!(PtyShell::parse_prompt_exit_code("building...", end), None);
1792 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤wrong:0 ", end), None);
1794 }
1795
1796 use proptest::prelude::*;
1797
1798 proptest! {
1799 #[test]
1803 fn parse_prompt_exit_code_never_panics(
1804 text in prop::collection::vec(any::<char>(), 0..256),
1805 nonce in "[0-9a-f]{0,16}",
1806 ) {
1807 let s: String = text.into_iter().collect();
1808 let end = format!("{WCGW_PROMPT_END}{nonce}");
1809 let _ = PtyShell::parse_prompt_exit_code(&s, &end);
1811 }
1812
1813 #[test]
1815 fn parse_prompt_exit_code_round_trips(code in any::<i32>(), nonce in "[0-9a-f]{1,16}") {
1816 let end = format!("{WCGW_PROMPT_END}{nonce}");
1817 let line = format!("output line\n{WCGW_PROMPT_PATTERN} /home/x{end}:{code} ");
1818 prop_assert_eq!(PtyShell::parse_prompt_exit_code(&line, &end), Some(code));
1819 }
1820 }
1821
1822 #[cfg(unix)] #[test]
1824 fn test_pty_shell_creation() -> Result<()> {
1825 let temp_dir = TempDir::new()?;
1826 let result = PtyShell::new(temp_dir.path(), false);
1827 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
1828 Ok(())
1829 }
1830
1831 #[cfg(unix)] #[test]
1833 fn discard_throttle_engages_at_scratch_cap_and_resets_per_command() -> Result<()> {
1834 let temp_dir = TempDir::new()?;
1835 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1836 assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1837
1838 shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1841 shell.scratch_bytes = SCRATCH_MAX_BYTES;
1842 shell.offload_dropped_head("dropped beyond the budget");
1843 assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1844
1845 shell.send_command("echo done")?;
1847 assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1848 Ok(())
1849 }
1850
1851 #[cfg(unix)] #[test]
1853 fn discard_throttle_engages_when_a_write_reaches_the_cap() -> Result<()> {
1854 let temp_dir = TempDir::new()?;
1855 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1856 shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1857 shell.scratch_bytes = SCRATCH_MAX_BYTES - 1;
1859 shell.offload_dropped_head("xx");
1860 assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1861 Ok(())
1862 }
1863
1864 #[cfg(unix)] #[test]
1866 fn test_pty_shell_echo() -> Result<()> {
1867 let temp_dir = TempDir::new()?;
1868 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1869
1870 shell.send_command("echo 'hello pty'")?;
1871 let (output, complete) = shell.read_output(2.0)?;
1872
1873 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
1874 assert!(complete, "first command must complete: {output}");
1878 assert!(!output.contains("PROMPT_COMMAND"), "bootstrap leaked into output: {output}");
1879 assert_eq!(shell.last_exit_code, Some(0), "{output}");
1880 Ok(())
1881 }
1882
1883 #[cfg(unix)] #[test]
1885 fn agent_paths_are_exported_to_the_pty() -> Result<()> {
1886 let workspace = TempDir::new()?;
1887 let temporary_artifact_dir = workspace.path().join(".winx/tmp/session-test");
1888 let mut shell = PtyShell::new_with_agent_paths(
1889 workspace.path(),
1890 false,
1891 Some(workspace.path()),
1892 Some(&temporary_artifact_dir),
1893 )?;
1894
1895 shell.send_command("printf '%s\\n%s\\n' \"$WINX_WORKSPACE_ROOT\" \"$WINX_TEMP_DIR\"")?;
1896 let (output, _complete) = shell.read_output(2.0)?;
1897
1898 assert!(output.contains(&workspace.path().to_string_lossy().to_string()), "{output}");
1899 assert!(output.contains(&temporary_artifact_dir.to_string_lossy().to_string()), "{output}");
1900 Ok(())
1901 }
1902
1903 #[cfg(unix)] #[test]
1905 fn completed_nonblocking_poll_is_level_triggered() -> Result<()> {
1906 let temp_dir = TempDir::new()?;
1907 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1908
1909 shell.send_command("true")?;
1910 let deadline = Instant::now() + Duration::from_secs(2);
1911 while Instant::now() < deadline && !shell.poll_output_nonblocking() {
1912 std::thread::sleep(Duration::from_millis(10));
1913 }
1914
1915 assert!(!shell.command_running, "shell did not complete before the deadline");
1916 assert!(
1917 shell.poll_output_nonblocking(),
1918 "a later consumer must still observe the completed state"
1919 );
1920 Ok(())
1921 }
1922
1923 #[cfg(unix)] #[test]
1925 fn test_pty_shell_pwd() -> Result<()> {
1926 let temp_dir = TempDir::new()?;
1927 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1928
1929 shell.send_command("pwd && echo 'pwd_done'")?;
1932 let (output, _complete) = shell.read_output(2.0)?;
1933
1934 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
1936 Ok(())
1937 }
1938
1939 #[cfg(unix)] #[test]
1941 fn test_pty_resize() -> Result<()> {
1942 let temp_dir = TempDir::new()?;
1943 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1944
1945 let result = shell.resize(120, 40);
1946 assert!(result.is_ok());
1947
1948 let (cols, rows) = shell.get_size();
1949 assert_eq!(cols, 120);
1950 assert_eq!(rows, 40);
1951 Ok(())
1952 }
1953
1954 #[cfg(unix)]
1958 fn pid_alive(pid: i32) -> bool {
1959 u32::try_from(pid).is_ok_and(crate::os::unix::process_exists)
1960 }
1961
1962 #[cfg(unix)]
1963 #[test]
1964 fn dropping_shell_kills_orphaned_background_child() -> Result<()> {
1965 let dir = TempDir::new()?;
1970 let pidfile = dir.path().join("child.pid");
1971 let mut shell = PtyShell::new(dir.path(), false)?;
1972
1973 shell
1974 .send_command(&format!("sleep 300 & echo $! > {} ; echo spawned", pidfile.display()))?;
1975
1976 let mut child_pid = None;
1981 for _ in 0..150 {
1982 let _ = shell.read_output(0.1);
1983 if let Some(pid) =
1984 std::fs::read_to_string(&pidfile).ok().and_then(|c| c.trim().parse::<i32>().ok())
1985 {
1986 child_pid = Some(pid);
1987 break;
1988 }
1989 thread::sleep(Duration::from_millis(20));
1990 }
1991 let child_pid =
1992 child_pid.ok_or_else(|| anyhow!("background child pid never materialized"))?;
1993 assert!(pid_alive(child_pid), "background child should be running before drop");
1994
1995 drop(shell);
1996
1997 let mut gone = false;
1999 for _ in 0..150 {
2000 if !pid_alive(child_pid) {
2001 gone = true;
2002 break;
2003 }
2004 thread::sleep(Duration::from_millis(20));
2005 }
2006 assert!(gone, "background child {child_pid} survived shell teardown (orphan leak)");
2007 Ok(())
2008 }
2009}