Skip to main content

vtcode_core/tools/pty/manager/
session_ops.rs

1use super::super::PtyCloseMode;
2use super::super::formatting::{format_terminal_file, sanitize_session_id};
3use super::super::manager_utils::exit_status_code;
4use super::super::session::{CommandEchoState, PtySessionHandle};
5use super::PtyManager;
6use crate::tools::types::VTCodePtySession;
7use crate::utils::file_utils::{ensure_dir_exists, write_file_with_context};
8use anyhow::{Context, Result, anyhow};
9use portable_pty::PtySize;
10use std::io::Write;
11use std::sync::Arc;
12use tracing::warn;
13
14impl PtyManager {
15    pub fn list_sessions(&self) -> Vec<VTCodePtySession> {
16        let sessions = self.inner.sessions.lock();
17        sessions.values().map(|handle| handle.snapshot_metadata()).collect()
18    }
19
20    pub fn snapshot_session(&self, session_id: &str) -> Result<VTCodePtySession> {
21        let handle = self.session_handle(session_id)?;
22        Ok(handle.snapshot_metadata())
23    }
24
25    pub fn read_session_output(&self, session_id: &str, drain: bool) -> Result<Option<String>> {
26        let handle = self.session_handle(session_id)?;
27        Ok(handle.read_output(drain))
28    }
29
30    pub(crate) fn output_stats(&self, session_id: &str) -> Result<Option<super::super::PtyOutputStats>> {
31        let handle = self.session_handle(session_id)?;
32        let scrollback_truncated = handle.scrollback.lock().output_truncated();
33        let spool_ready = handle.output_spool_ready.load(std::sync::atomic::Ordering::Acquire);
34        let spool_finished = handle.output_spool_finished.load(std::sync::atomic::Ordering::Acquire);
35        let spool_failed = handle.output_spool_failed.load(std::sync::atomic::Ordering::Acquire);
36        Ok(Some(super::super::PtyOutputStats {
37            total_bytes: handle.output_total_bytes.load(std::sync::atomic::Ordering::Relaxed),
38            truncated: scrollback_truncated || spool_failed,
39            spool_path: handle.output_spool_path.clone(),
40            spool_available: spool_ready && !spool_failed,
41            spool_complete: spool_ready && spool_finished && !spool_failed,
42            spool_integrity: handle.output_spool_integrity.lock().clone(),
43        }))
44    }
45
46    /// Check if all output from this PTY session has been consumed.
47    pub fn is_output_drained(&self, session_id: &str) -> Result<bool> {
48        let handle = self.session_handle(session_id)?;
49        Ok(handle.is_output_drained())
50    }
51
52    pub fn send_input_to_session(&self, session_id: &str, data: &[u8], append_newline: bool) -> Result<usize> {
53        let handle = self.session_handle(session_id)?;
54
55        // Acquire last_input lock once and update conditionally
56        {
57            let mut last_input = handle.last_input.lock();
58            *last_input = if let Ok(input_text) = std::str::from_utf8(data) {
59                CommandEchoState::new(input_text, append_newline)
60            } else {
61                None
62            };
63        }
64
65        // Acquire writer lock once for all write operations
66        {
67            let mut writer_guard = handle.writer.lock();
68            let writer = writer_guard
69                .as_mut()
70                .ok_or_else(|| anyhow!("PTY session '{session_id}' is no longer writable"))?;
71
72            writer.write_all(data).context("failed to write input to PTY session")?;
73
74            if append_newline {
75                writer.write_all(b"\n").context("failed to write newline to PTY session")?;
76            }
77
78            writer.flush().context("failed to flush PTY session input")?;
79        }
80
81        let written = data.len() + if append_newline { 1 } else { 0 };
82        Ok(written)
83    }
84
85    pub fn resize_session(&self, session_id: &str, size: PtySize) -> Result<VTCodePtySession> {
86        let handle = self.session_handle(session_id)?;
87
88        {
89            let master = handle.master.lock();
90            master.resize(size).context("failed to resize PTY session")?;
91        }
92
93        {
94            let mut screen_state = handle.screen_state.lock();
95            screen_state.resize(size);
96        }
97
98        Ok(handle.snapshot_metadata())
99    }
100
101    pub fn is_session_completed(&self, session_id: &str) -> Result<Option<i32>> {
102        let handle = self.session_handle(session_id)?;
103        let mut child = handle.child.lock();
104        child
105            .try_wait()
106            .context("failed to poll PTY session status")
107            .map(|opt| opt.map(exit_status_code))
108    }
109
110    pub fn terminate_session(&self, session_id: &str) -> Result<()> {
111        let handle = self.session_handle(session_id)?;
112
113        {
114            let mut writer_guard = handle.writer.lock();
115            if let Some(mut writer) = writer_guard.take() {
116                let _ = writer.write_all(b"exit\n");
117                let _ = writer.flush();
118            }
119        }
120
121        handle.graceful_terminate();
122        Ok(())
123    }
124
125    pub fn force_terminate_session(&self, session_id: &str) -> Result<()> {
126        let handle = self.session_handle(session_id)?;
127        handle.force_terminate();
128        Ok(())
129    }
130
131    /// Sync all terminal sessions to files for dynamic context discovery
132    ///
133    /// This implements Cursor-style dynamic context discovery:
134    /// - Each terminal session is written to `.vtcode/terminals/{session_id}.txt`
135    /// - Includes metadata header (cwd, last command, exit code)
136    /// - Agent can reference terminal output via grep/read_file
137    pub async fn sync_sessions_to_files(&self) -> Result<Vec<std::path::PathBuf>> {
138        let terminals_dir = self.workspace_root.join(".vtcode").join("terminals");
139        ensure_dir_exists(&terminals_dir)
140            .await
141            .with_context(|| format!("Failed to create terminals directory: {}", terminals_dir.display()))?;
142
143        let sessions = self.list_sessions();
144        let mut written_files = Vec::with_capacity(sessions.len());
145
146        for session in &sessions {
147            let output = match self.read_session_output(&session.id, false) {
148                Ok(Some(output)) => output,
149                Ok(None) => String::new(),
150                Err(_) => continue,
151            };
152
153            let content = format_terminal_file(session, &output);
154            let file_path = terminals_dir.join(format!("{}.txt", sanitize_session_id(&session.id)));
155
156            if let Err(e) = write_file_with_context(&file_path, &content, "terminal session file").await {
157                warn!(
158                    session_id = %session.id,
159                    error = %e,
160                    "Failed to sync terminal session to file"
161                );
162                continue;
163            }
164
165            written_files.push(file_path);
166        }
167
168        // Write index file
169        let index_content = self.generate_terminals_index(&sessions);
170        let index_path = terminals_dir.join("INDEX.md");
171        write_file_with_context(&index_path, &index_content, "terminals index")
172            .await
173            .with_context(|| format!("Failed to write terminals index: {}", index_path.display()))?;
174
175        tracing::info!(sessions = sessions.len(), files = written_files.len(), "Synced terminal sessions to files");
176
177        Ok(written_files)
178    }
179
180    /// Generate INDEX.md content for terminal sessions
181    fn generate_terminals_index(&self, sessions: &[VTCodePtySession]) -> String {
182        let mut content = String::new();
183        content.push_str("# Terminal Sessions Index\n\n");
184        content.push_str("This file lists all active terminal sessions for dynamic discovery.\n");
185        content.push_str("Use `read_file` on individual session files for full output.\n\n");
186
187        if sessions.is_empty() {
188            content.push_str("*No active terminal sessions.*\n");
189        } else {
190            content.push_str(&format!("**Active Sessions**: {}\n\n", sessions.len()));
191            content.push_str("| Session ID | Command | Working Dir | Size |\n");
192            content.push_str("|------------|---------|-------------|------|\n");
193
194            for session in sessions {
195                let cwd = session.working_dir.as_deref().unwrap_or("-");
196                let cmd_truncated = vtcode_commons::formatting::truncate_byte_budget(&session.command, 22, "...");
197
198                content.push_str(&format!(
199                    "| `{}` | {} | {} | {}x{} |\n",
200                    session.id,
201                    cmd_truncated.replace('|', "\\|"),
202                    cwd.replace('|', "\\|"),
203                    session.cols,
204                    session.rows
205                ));
206            }
207
208            content.push_str("\n## Session Details\n\n");
209            for session in sessions {
210                content.push_str(&format!("### {}\n\n", session.id));
211                content.push_str(&format!("- **Command**: `{}`\n", session.command));
212                if !session.args.is_empty() {
213                    content.push_str(&format!("- **Args**: {}\n", session.args.join(" ")));
214                }
215                if let Some(cwd) = &session.working_dir {
216                    content.push_str(&format!("- **Working Dir**: {cwd}\n"));
217                }
218                content.push_str(&format!("- **Terminal Size**: {}x{}\n", session.cols, session.rows));
219                content
220                    .push_str(&format!("- **File**: `.vtcode/terminals/{}.txt`\n\n", sanitize_session_id(&session.id)));
221            }
222        }
223
224        content.push_str("---\n");
225        content.push_str("*Generated automatically. Do not edit manually.*\n");
226
227        content
228    }
229
230    /// Get the terminals directory path
231    pub fn terminals_dir(&self) -> std::path::PathBuf {
232        self.workspace_root.join(".vtcode").join("terminals")
233    }
234
235    pub fn close_session(&self, session_id: &str) -> Result<VTCodePtySession> {
236        self.close_session_with_mode(session_id, PtyCloseMode::Graceful)
237    }
238
239    pub fn close_session_with_mode(&self, session_id: &str, mode: PtyCloseMode) -> Result<VTCodePtySession> {
240        // Remove session from global map first
241        let handle = {
242            let mut sessions = self.inner.sessions.lock();
243            sessions
244                .remove(session_id)
245                .ok_or_else(|| anyhow!("PTY session '{session_id}' not found"))?
246        };
247
248        // Lock order: writer -> child -> reader_thread (follow documented order)
249
250        // 1. Close writer. The `exit\n` courtesy is skipped on Immediate: a
251        // user-initiated exit kills the group right after, so the write would
252        // only race the SIGKILL.
253        if mode == PtyCloseMode::Graceful {
254            let mut writer_guard = handle.writer.lock();
255            if let Some(mut writer) = writer_guard.take() {
256                let _ = writer.write_all(b"exit\n");
257                let _ = writer.flush();
258            }
259        } else {
260            drop(handle.writer.lock().take());
261        }
262
263        // 2. Terminate child process. Graceful uses SIGTERM first, then SIGKILL
264        // after a grace period; Immediate signals SIGKILL to the group without
265        // waiting for a grace window.
266        match mode {
267            PtyCloseMode::Graceful => handle.graceful_terminate(),
268            PtyCloseMode::Immediate => handle.force_terminate(),
269        }
270
271        // 3. Join reader thread with the same bounded helper Drop uses.
272        // An unbounded join here blocked the async runloop whenever the PTY
273        // reader stayed stuck on `read`, freezing stop/close and the composer.
274        {
275            let mut thread_guard = handle.reader_thread.lock();
276            if let Some(reader_thread) = thread_guard.take() {
277                super::super::session::join_reader_thread_bounded(reader_thread);
278            }
279        }
280
281        // Snapshot metadata acquires the PTY master plus screen and scrollback state.
282        Ok(handle.snapshot_metadata())
283    }
284
285    pub fn terminate_all_sessions(&self) {
286        self.terminate_all_sessions_with_mode(PtyCloseMode::Graceful);
287    }
288
289    pub fn terminate_all_sessions_with_mode(&self, mode: PtyCloseMode) {
290        let session_ids: Vec<String> = {
291            let sessions = self.inner.sessions.lock();
292            sessions.keys().cloned().collect()
293        };
294        for id in session_ids {
295            if let Err(e) = self.close_session_with_mode(&id, mode) {
296                warn!("Failed to close PTY session {}: {}", id, e);
297            }
298        }
299    }
300
301    fn session_handle(&self, session_id: &str) -> Result<Arc<PtySessionHandle>> {
302        let sessions = self.inner.sessions.lock();
303        sessions
304            .get(session_id)
305            .cloned()
306            .ok_or_else(|| anyhow!("PTY session '{session_id}' not found"))
307    }
308}