Skip to main content

ai_session/core/
pty.rs

1//! PTY (Pseudo-Terminal) management
2
3use anyhow::Result;
4use portable_pty::{Child, CommandBuilder, PtySize, native_pty_system};
5use std::io::{Read, Write};
6use std::sync::{Arc, Mutex};
7use tokio::time::{Duration, timeout};
8
9/// Handle to a PTY
10pub struct PtyHandle {
11    /// PTY size
12    size: PtySize,
13    /// Child process
14    child: Arc<Mutex<Option<Box<dyn Child + Send>>>>,
15    /// Reader handle (thread-safe)
16    reader: Arc<Mutex<Option<Box<dyn Read + Send>>>>,
17    /// Writer handle
18    writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
19}
20
21impl PtyHandle {
22    /// Create a new PTY with the specified size
23    pub fn new(rows: u16, cols: u16) -> Result<Self> {
24        let size = PtySize {
25            rows,
26            cols,
27            pixel_width: 0,
28            pixel_height: 0,
29        };
30
31        Ok(Self {
32            size,
33            child: Arc::new(Mutex::new(None)),
34            reader: Arc::new(Mutex::new(None)),
35            writer: Arc::new(Mutex::new(None)),
36        })
37    }
38
39    /// Spawn a command in the PTY
40    pub async fn spawn_command(&self, cmd: CommandBuilder) -> Result<()> {
41        let pty_system = native_pty_system();
42        let pair = pty_system.openpty(self.size)?;
43
44        let child = pair.slave.spawn_command(cmd)?;
45        let mut child_lock = self.child.lock().unwrap();
46        *child_lock = Some(child);
47
48        // Initialize reader and writer
49        let reader = pair.master.try_clone_reader()?;
50        let mut reader_lock = self.reader.lock().unwrap();
51        *reader_lock = Some(reader);
52
53        let writer = pair.master.take_writer()?;
54        let mut writer_lock = self.writer.lock().unwrap();
55        *writer_lock = Some(writer);
56
57        Ok(())
58    }
59
60    /// Write data to the PTY
61    pub async fn write(&self, data: &[u8]) -> Result<()> {
62        let mut writer_lock = self.writer.lock().unwrap();
63        if let Some(writer) = writer_lock.as_mut() {
64            writer.write_all(data)?;
65            writer.flush()?;
66        } else {
67            return Err(anyhow::anyhow!("PTY not initialized"));
68        }
69        Ok(())
70    }
71
72    /// Read data from the PTY with timeout
73    pub async fn read(&self) -> Result<Vec<u8>> {
74        let reader_arc = self.reader.clone();
75        let result = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
76            let mut reader_lock = reader_arc.lock().unwrap();
77            if let Some(reader) = reader_lock.as_mut() {
78                let mut buffer = vec![0u8; 4096];
79
80                // Set non-blocking mode and try to read
81                match reader.read(&mut buffer) {
82                    Ok(0) => Ok(Vec::new()), // No data available
83                    Ok(n) => {
84                        buffer.truncate(n);
85                        Ok(buffer)
86                    }
87                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
88                        Ok(Vec::new()) // No data available
89                    }
90                    Err(e) => Err(anyhow::anyhow!("Read error: {}", e)),
91                }
92            } else {
93                Err(anyhow::anyhow!("PTY reader not initialized"))
94            }
95        })
96        .await??;
97
98        Ok(result)
99    }
100
101    /// Resize the PTY
102    pub async fn resize(&self, rows: u16, cols: u16) -> Result<()> {
103        // Update internal size
104        let _new_size = PtySize {
105            rows,
106            cols,
107            pixel_width: 0,
108            pixel_height: 0,
109        };
110
111        // For now, we just update the internal size
112        // In a full implementation, we'd resize the actual PTY
113        // self.size = new_size; // Can't modify due to &self
114
115        Ok(())
116    }
117
118    /// Get the current PTY size
119    pub fn size(&self) -> (u16, u16) {
120        (self.size.rows, self.size.cols)
121    }
122
123    /// Check if child process is running
124    pub fn is_running(&self) -> bool {
125        if let Ok(child_lock) = self.child.lock()
126            && let Some(_child) = child_lock.as_ref()
127        {
128            // For portable-pty, we assume the process is running if we have a handle
129            // In a production implementation, we'd check the actual process status
130            return true;
131        }
132        false
133    }
134
135    /// Read data from PTY with timeout (for testing)
136    pub async fn read_with_timeout(&self, timeout_ms: u64) -> Result<Vec<u8>> {
137        match timeout(Duration::from_millis(timeout_ms), self.read()).await {
138            Ok(result) => result,
139            Err(_) => Ok(Vec::new()), // Timeout - return empty data
140        }
141    }
142
143    /// Spawn Claude Code in the PTY with --dangerously-skip-permissions flag
144    ///
145    /// This method launches Claude CLI in a PTY session for true parallel multi-agent execution.
146    /// Each call creates an independent Claude process that can run concurrently with others.
147    ///
148    /// # Arguments
149    /// * `prompt` - The prompt/instruction to send to Claude
150    /// * `working_dir` - Working directory for the Claude session
151    /// * `max_turns` - Maximum number of conversation turns (default: 1 for single task)
152    ///
153    /// # Example
154    /// ```no_run
155    /// use ai_session::core::pty::PtyHandle;
156    /// use std::path::Path;
157    ///
158    /// #[tokio::main]
159    /// async fn main() -> anyhow::Result<()> {
160    ///     let pty = PtyHandle::new(24, 80)?;
161    ///     pty.spawn_claude("Create a hello world function", Path::new("/tmp"), Some(3)).await?;
162    ///     
163    ///     // Read output with timeout
164    ///     let output = pty.read_with_timeout(30000).await?;
165    ///     println!("Claude output: {}", String::from_utf8_lossy(&output));
166    ///     Ok(())
167    /// }
168    /// ```
169    pub async fn spawn_claude(
170        &self,
171        prompt: &str,
172        working_dir: &std::path::Path,
173        max_turns: Option<u32>,
174    ) -> Result<()> {
175        let mut cmd = CommandBuilder::new("claude");
176        cmd.arg("--dangerously-skip-permissions");
177        cmd.arg("-p");
178        cmd.arg(prompt);
179        cmd.arg("--output-format");
180        cmd.arg("json");
181
182        // Set max turns if specified
183        if let Some(turns) = max_turns {
184            cmd.arg("--max-turns");
185            cmd.arg(turns.to_string());
186        }
187
188        cmd.cwd(working_dir);
189
190        self.spawn_command(cmd).await
191    }
192
193    /// Spawn Claude Code and wait for completion, returning the output
194    ///
195    /// This is a convenience method that spawns Claude, waits for it to finish,
196    /// and returns the collected output.
197    ///
198    /// # Arguments
199    /// * `prompt` - The prompt/instruction to send to Claude
200    /// * `working_dir` - Working directory for the Claude session
201    /// * `max_turns` - Maximum number of conversation turns
202    /// * `timeout_ms` - Timeout in milliseconds to wait for completion
203    pub async fn spawn_claude_and_wait(
204        &self,
205        prompt: &str,
206        working_dir: &std::path::Path,
207        max_turns: Option<u32>,
208        timeout_ms: u64,
209    ) -> Result<String> {
210        self.spawn_claude(prompt, working_dir, max_turns).await?;
211
212        // Collect output until process completes or timeout
213        let mut output = Vec::new();
214        let start = std::time::Instant::now();
215        let timeout_duration = Duration::from_millis(timeout_ms);
216
217        loop {
218            if start.elapsed() > timeout_duration {
219                break;
220            }
221
222            // Read available output
223            let chunk = self.read_with_timeout(500).await?;
224            if !chunk.is_empty() {
225                output.extend_from_slice(&chunk);
226            }
227
228            // Check if process has finished
229            if !self.is_running() {
230                // Read any remaining output
231                let remaining = self.read_with_timeout(100).await?;
232                output.extend_from_slice(&remaining);
233                break;
234            }
235
236            // Small delay to avoid busy-waiting
237            tokio::time::sleep(Duration::from_millis(100)).await;
238        }
239
240        Ok(String::from_utf8_lossy(&output).to_string())
241    }
242
243    /// Spawn Claude Code with a specific session ID for later resumption
244    ///
245    /// This method allows you to specify a session ID that can be used later
246    /// with `resume_claude` to continue the conversation.
247    ///
248    /// # Arguments
249    /// * `prompt` - The prompt/instruction to send to Claude
250    /// * `working_dir` - Working directory for the Claude session
251    /// * `session_id` - UUID to use as the Claude session ID
252    /// * `max_turns` - Maximum number of conversation turns
253    ///
254    /// # Example
255    /// ```no_run
256    /// use ai_session::core::pty::PtyHandle;
257    /// use std::path::Path;
258    ///
259    /// #[tokio::main]
260    /// async fn main() -> anyhow::Result<()> {
261    ///     let pty = PtyHandle::new(24, 80)?;
262    ///     let session_id = "2c4e029f-3411-442a-b24c-33001c78cd14";
263    ///
264    ///     // Start a new session with specific ID
265    ///     pty.spawn_claude_with_session(
266    ///         "Create a hello world function",
267    ///         Path::new("/tmp"),
268    ///         session_id,
269    ///         Some(3),
270    ///     ).await?;
271    ///
272    ///     // Later, resume the same session
273    ///     let pty2 = PtyHandle::new(24, 80)?;
274    ///     pty2.resume_claude(session_id, Path::new("/tmp")).await?;
275    ///     Ok(())
276    /// }
277    /// ```
278    pub async fn spawn_claude_with_session(
279        &self,
280        prompt: &str,
281        working_dir: &std::path::Path,
282        session_id: &str,
283        max_turns: Option<u32>,
284    ) -> Result<()> {
285        let mut cmd = CommandBuilder::new("claude");
286        cmd.arg("--dangerously-skip-permissions");
287        cmd.arg("--session-id");
288        cmd.arg(session_id);
289        cmd.arg("-p");
290        cmd.arg(prompt);
291        cmd.arg("--output-format");
292        cmd.arg("json");
293
294        if let Some(turns) = max_turns {
295            cmd.arg("--max-turns");
296            cmd.arg(turns.to_string());
297        }
298
299        cmd.cwd(working_dir);
300
301        self.spawn_command(cmd).await
302    }
303
304    /// Resume a Claude Code session by session ID
305    ///
306    /// This method resumes a previous Claude conversation using the session ID
307    /// that was used when the session was created.
308    ///
309    /// # Arguments
310    /// * `session_id` - The Claude session ID to resume
311    /// * `working_dir` - Working directory for the Claude session
312    ///
313    /// # Example
314    /// ```no_run
315    /// use ai_session::core::pty::PtyHandle;
316    /// use std::path::Path;
317    ///
318    /// #[tokio::main]
319    /// async fn main() -> anyhow::Result<()> {
320    ///     let pty = PtyHandle::new(24, 80)?;
321    ///
322    ///     // Resume a previous session
323    ///     pty.resume_claude(
324    ///         "2c4e029f-3411-442a-b24c-33001c78cd14",
325    ///         Path::new("/tmp"),
326    ///     ).await?;
327    ///
328    ///     Ok(())
329    /// }
330    /// ```
331    pub async fn resume_claude(
332        &self,
333        session_id: &str,
334        working_dir: &std::path::Path,
335    ) -> Result<()> {
336        let mut cmd = CommandBuilder::new("claude");
337        cmd.arg("--resume");
338        cmd.arg(session_id);
339
340        cmd.cwd(working_dir);
341
342        self.spawn_command(cmd).await
343    }
344
345    /// Resume a Claude Code session interactively with a new prompt
346    ///
347    /// This method resumes a previous Claude conversation and sends a new prompt.
348    ///
349    /// # Arguments
350    /// * `session_id` - The Claude session ID to resume
351    /// * `prompt` - New prompt to send to the resumed session
352    /// * `working_dir` - Working directory for the Claude session
353    /// * `max_turns` - Maximum number of conversation turns
354    pub async fn resume_claude_with_prompt(
355        &self,
356        session_id: &str,
357        prompt: &str,
358        working_dir: &std::path::Path,
359        max_turns: Option<u32>,
360    ) -> Result<()> {
361        let mut cmd = CommandBuilder::new("claude");
362        cmd.arg("--dangerously-skip-permissions");
363        cmd.arg("--resume");
364        cmd.arg(session_id);
365        cmd.arg("-p");
366        cmd.arg(prompt);
367        cmd.arg("--output-format");
368        cmd.arg("json");
369
370        if let Some(turns) = max_turns {
371            cmd.arg("--max-turns");
372            cmd.arg(turns.to_string());
373        }
374
375        cmd.cwd(working_dir);
376
377        self.spawn_command(cmd).await
378    }
379}