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}