1use bamboo_agent_core::{AgentEvent, BashCompletionInfo, BashCompletionSink};
2use bamboo_infrastructure::process::{
3 build_command_environment, decode_process_line_lossy, hide_window_for_tokio_command,
4 preferred_bash_shell, trace_windows_command, CommandEnvironmentDiagnostics,
5};
6use dashmap::DashMap;
7use regex::Regex;
8use std::path::Path;
9use std::process::Stdio;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, OnceLock};
12use tokio::io::AsyncBufReadExt;
13use tokio::io::AsyncWriteExt;
14use tokio::io::BufReader;
15use tokio::process::{Child, ChildStdin, Command};
16use tokio::sync::mpsc;
17use tokio::sync::Mutex;
18use tokio::sync::Notify;
19use tokio::time::{sleep, timeout, Duration};
20use tracing::warn;
21
22pub(crate) const MAX_OUTPUT_LINES: usize = 20_000;
26const COMPLETED_SESSION_TTL_SECS: u64 = 300;
27const COMPLETION_TAIL_LINES: usize = 50;
31const COMPLETION_TAIL_MAX_BYTES: usize = 4096;
34const STDIN_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
38
39#[derive(Debug)]
40pub struct ShellSession {
41 pub id: String,
42 pub command: String,
43 pub session_id: Option<String>,
47 pub environment: CommandEnvironmentDiagnostics,
48 kill_notify: Arc<Notify>,
54 stdin: Arc<Mutex<Option<ChildStdin>>>,
60 output: Arc<Mutex<Vec<String>>>,
61 base_index: Arc<Mutex<usize>>,
62 running: Arc<AtomicBool>,
63 exit_code: Arc<Mutex<Option<i32>>>,
64}
65
66impl ShellSession {
67 pub fn status(&self) -> &'static str {
68 if self.running.load(Ordering::Relaxed) {
69 "running"
70 } else {
71 "completed"
72 }
73 }
74
75 pub async fn exit_code(&self) -> Option<i32> {
76 *self.exit_code.lock().await
77 }
78
79 pub async fn read_output_since(
80 &self,
81 cursor: usize,
82 filter: Option<&Regex>,
83 ) -> (Vec<String>, usize, usize) {
84 let output = self.output.lock().await;
85 let base_index = self.base_index.lock().await;
86
87 let base = *base_index;
88 let effective_cursor = cursor.max(base);
89 let dropped_lines = effective_cursor.saturating_sub(cursor);
90 let start = effective_cursor.saturating_sub(base);
91 let new_lines = if start >= output.len() {
92 Vec::new()
93 } else {
94 output[start..]
95 .iter()
96 .filter(|line| filter.map(|re| re.is_match(line)).unwrap_or(true))
97 .cloned()
98 .collect()
99 };
100
101 let next_cursor = base + output.len();
102 (new_lines, next_cursor, dropped_lines)
103 }
104
105 #[allow(clippy::unused_async)] pub async fn kill(&self) -> Result<(), String> {
113 self.running.store(false, Ordering::Relaxed);
114 self.kill_notify.notify_one();
115 Ok(())
116 }
117
118 pub async fn write_stdin(&self, data: &str, append_newline: bool) -> Result<(), String> {
128 let mut guard = self.stdin.lock().await;
129 let stdin = guard.as_mut().ok_or_else(|| {
130 format!(
131 "Shell '{}' has no interactive stdin pipe; spawn it via Bash with interactive=true",
132 self.id
133 )
134 })?;
135 let mut bytes = data.as_bytes().to_vec();
136 if append_newline {
137 bytes.push(b'\n');
138 }
139 timeout(STDIN_WRITE_TIMEOUT, stdin.write_all(&bytes))
143 .await
144 .map_err(|_| {
145 format!(
146 "Timed out after {}s writing to stdin of shell '{}' (consumer not draining)",
147 STDIN_WRITE_TIMEOUT.as_secs(),
148 self.id
149 )
150 })?
151 .map_err(|e| format!("Failed to write to stdin of shell '{}': {}", self.id, e))?;
152 timeout(STDIN_WRITE_TIMEOUT, stdin.flush())
153 .await
154 .map_err(|_| {
155 format!(
156 "Timed out after {}s flushing stdin of shell '{}'",
157 STDIN_WRITE_TIMEOUT.as_secs(),
158 self.id
159 )
160 })?
161 .map_err(|e| format!("Failed to flush stdin of shell '{}': {}", self.id, e))?;
162 Ok(())
163 }
164
165 pub async fn close_stdin(&self) -> bool {
175 self.stdin.lock().await.take().is_some()
176 }
177}
178
179fn sessions() -> &'static DashMap<String, Arc<ShellSession>> {
180 static SESSIONS: OnceLock<DashMap<String, Arc<ShellSession>>> = OnceLock::new();
181 SESSIONS.get_or_init(DashMap::new)
182}
183
184async fn push_line(output: &Arc<Mutex<Vec<String>>>, base_index: &Arc<Mutex<usize>>, line: String) {
185 let mut buffer = output.lock().await;
186 buffer.push(line);
187 if buffer.len() > MAX_OUTPUT_LINES {
188 let overflow = buffer.len() - MAX_OUTPUT_LINES;
189 buffer.drain(0..overflow);
190 let mut base = base_index.lock().await;
191 *base += overflow;
192 }
193}
194
195async fn pump_stream_lines<T>(
196 stream_name: &'static str,
197 reader: T,
198 output: Arc<Mutex<Vec<String>>>,
199 base_index: Arc<Mutex<usize>>,
200) where
201 T: tokio::io::AsyncRead + Unpin,
202{
203 let mut reader = BufReader::new(reader);
204 let mut line_bytes = Vec::new();
205
206 loop {
207 line_bytes.clear();
208 match reader.read_until(b'\n', &mut line_bytes).await {
209 Ok(0) => break,
210 Ok(_) => {
211 let line = decode_process_line_lossy(&mut line_bytes);
212 push_line(&output, &base_index, line).await;
213 }
214 Err(e) => {
215 warn!("Background shell {stream_name} read failed: {e}");
216 break;
217 }
218 }
219 }
220}
221
222#[allow(clippy::too_many_arguments)]
223pub async fn spawn_background(
224 command: &str,
225 cwd: Option<&Path>,
226 event_tx: Option<mpsc::Sender<AgentEvent>>,
227 session_id: Option<String>,
228 interactive: bool,
229 bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
230) -> Result<Arc<ShellSession>, String> {
231 let shell = preferred_bash_shell();
232 trace_windows_command(
233 "agent.bash.background",
234 &shell.program,
235 [shell.arg, command],
236 );
237 let overrides = bamboo_llm::Config::current_env_vars();
238 let prepared_env = build_command_environment(&overrides).await;
239 let mut cmd = Command::new(&shell.program);
240 hide_window_for_tokio_command(&mut cmd);
241 if let Some(cwd) = cwd {
242 cmd.current_dir(cwd);
243 }
244 prepared_env.apply_to_tokio_command(&mut cmd);
245 cmd.arg(shell.arg).arg(command);
246 if interactive {
251 cmd.stdin(Stdio::piped());
252 } else {
253 cmd.stdin(Stdio::null());
254 }
255 cmd.stdout(Stdio::piped())
256 .stderr(Stdio::piped())
257 .kill_on_drop(true);
258
259 let mut child = cmd
260 .spawn()
261 .map_err(|e| format!("Failed to spawn background shell: {}", e))?;
262
263 let stdout = child
264 .stdout
265 .take()
266 .ok_or_else(|| "Failed to capture shell stdout".to_string())?;
267 let stderr = child
268 .stderr
269 .take()
270 .ok_or_else(|| "Failed to capture shell stderr".to_string())?;
271 let stdin_handle = if interactive {
274 child.stdin.take()
275 } else {
276 None
277 };
278
279 let shell_id = uuid::Uuid::new_v4().to_string();
280 let output = Arc::new(Mutex::new(Vec::new()));
281 let base_index = Arc::new(Mutex::new(0usize));
282 let running = Arc::new(AtomicBool::new(true));
283 let exit_code = Arc::new(Mutex::new(None));
284 let kill_notify = Arc::new(Notify::new());
285
286 let session = Arc::new(ShellSession {
287 id: shell_id.clone(),
288 command: command.to_string(),
289 session_id,
290 environment: prepared_env.diagnostics.clone(),
291 kill_notify: kill_notify.clone(),
292 stdin: Arc::new(Mutex::new(stdin_handle)),
293 output: output.clone(),
294 base_index: base_index.clone(),
295 running: running.clone(),
296 exit_code: exit_code.clone(),
297 });
298
299 let stdout_pump = {
300 let output = output.clone();
301 let base_index = base_index.clone();
302 tokio::spawn(async move {
303 pump_stream_lines("stdout", stdout, output, base_index).await;
304 })
305 };
306
307 let stderr_pump = {
308 let output = output.clone();
309 let base_index = base_index.clone();
310 tokio::spawn(async move {
311 pump_stream_lines("stderr", stderr, output, base_index).await;
312 })
313 };
314
315 spawn_completion_poll(
316 child,
317 kill_notify,
318 shell_id.clone(),
319 command.to_string(),
320 running,
321 exit_code,
322 output.clone(),
323 session.session_id.clone(),
324 event_tx,
325 bash_completion_sink,
326 vec![stdout_pump, stderr_pump],
327 );
328
329 sessions().insert(shell_id, session.clone());
330 Ok(session)
331}
332
333#[allow(clippy::too_many_arguments)]
341fn spawn_completion_poll(
342 mut child: Child,
343 kill_notify: Arc<Notify>,
344 shell_id: String,
345 command: String,
346 running: Arc<AtomicBool>,
347 exit_code: Arc<Mutex<Option<i32>>>,
348 output: Arc<Mutex<Vec<String>>>,
349 session_id: Option<String>,
350 event_tx: Option<mpsc::Sender<AgentEvent>>,
351 bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
352 pump_handles: Vec<tokio::task::JoinHandle<()>>,
356) {
357 let session_id_for_gc = shell_id.clone();
358 let bash_id_for_event = shell_id.clone();
359 let bash_id_for_sink = shell_id;
360 let command_for_event = command.clone();
361 let command_for_sink = command;
362 tokio::spawn(async move {
363 let wait_result = tokio::select! {
369 result = child.wait() => result,
370 _ = kill_notify.notified() => {
371 let _ = child.start_kill();
372 child.wait().await
373 }
374 };
375 let (status_str, exit_code_value) = match wait_result {
376 Ok(status) => {
377 let code = status.code();
378 (
381 if code.is_none() {
382 "killed"
383 } else {
384 "completed"
385 },
386 code,
387 )
388 }
389 Err(_) => ("error", None),
390 };
391 *exit_code.lock().await = exit_code_value;
392 running.store(false, Ordering::Relaxed);
393
394 if let Some(tx) = &event_tx {
402 let event = AgentEvent::BashCompleted {
403 bash_id: bash_id_for_event,
404 command: command_for_event,
405 exit_code: exit_code_value,
406 status: status_str.to_string(),
407 };
408 if timeout(Duration::from_millis(500), tx.send(event))
409 .await
410 .is_err()
411 {
412 warn!(
413 bash_id = %session_id_for_gc,
414 "BashCompleted signal dropped (event channel saturated or closed after 500ms)"
415 );
416 }
417 }
418
419 if let (Some(sink), Some(session_id)) = (bash_completion_sink, session_id) {
427 for handle in pump_handles {
430 let _ = timeout(Duration::from_secs(1), handle).await;
431 }
432 let output_tail = output_tail(&output).await;
433 sink.on_bash_completed(BashCompletionInfo {
434 session_id,
435 bash_id: bash_id_for_sink,
436 command: command_for_sink,
437 exit_code: exit_code_value,
438 status: status_str.to_string(),
439 output_tail,
440 });
441 }
442
443 sleep(Duration::from_secs(COMPLETED_SESSION_TTL_SECS)).await;
444 let _ = remove_shell(&session_id_for_gc);
445 });
446}
447
448async fn output_tail(output: &Arc<Mutex<Vec<String>>>) -> String {
452 let buffer = output.lock().await;
453 let start = buffer.len().saturating_sub(COMPLETION_TAIL_LINES);
454 let joined = buffer[start..].join("\n");
455 if joined.len() <= COMPLETION_TAIL_MAX_BYTES {
456 return joined;
457 }
458 let mut cut = joined.len() - COMPLETION_TAIL_MAX_BYTES;
459 while cut < joined.len() && !joined.is_char_boundary(cut) {
460 cut += 1;
461 }
462 format!("…{}", &joined[cut..])
463}
464
465#[allow(clippy::too_many_arguments)]
476pub async fn adopt_running_child(
477 child: Child,
478 stdout_reader: impl tokio::io::AsyncRead + Unpin + Send + 'static,
479 stderr_reader: impl tokio::io::AsyncRead + Unpin + Send + 'static,
480 seeded_stdout_lines: Vec<String>,
481 seeded_stderr_lines: Vec<String>,
482 command: &str,
483 session_id: Option<String>,
484 environment: CommandEnvironmentDiagnostics,
485 event_tx: Option<mpsc::Sender<AgentEvent>>,
486 bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
487) -> Result<Arc<ShellSession>, String> {
488 let shell_id = uuid::Uuid::new_v4().to_string();
489 let output = Arc::new(Mutex::new(Vec::new()));
490 let base_index = Arc::new(Mutex::new(0usize));
491 let running = Arc::new(AtomicBool::new(true));
492 let exit_code = Arc::new(Mutex::new(None));
493 let kill_notify = Arc::new(Notify::new());
494
495 for line in seeded_stdout_lines.iter().chain(seeded_stderr_lines.iter()) {
500 push_line(&output, &base_index, line.clone()).await;
501 }
502
503 let session = Arc::new(ShellSession {
504 id: shell_id.clone(),
505 command: command.to_string(),
506 session_id,
507 environment,
508 kill_notify: kill_notify.clone(),
509 stdin: Arc::new(Mutex::new(None)),
512 output: output.clone(),
513 base_index: base_index.clone(),
514 running: running.clone(),
515 exit_code: exit_code.clone(),
516 });
517
518 let stdout_pump = {
523 let output = output.clone();
524 let base_index = base_index.clone();
525 tokio::spawn(async move {
526 pump_stream_lines("stdout", stdout_reader, output, base_index).await;
527 })
528 };
529 let stderr_pump = {
530 let output = output.clone();
531 let base_index = base_index.clone();
532 tokio::spawn(async move {
533 pump_stream_lines("stderr", stderr_reader, output, base_index).await;
534 })
535 };
536
537 spawn_completion_poll(
538 child,
539 kill_notify,
540 shell_id.clone(),
541 command.to_string(),
542 running,
543 exit_code,
544 output.clone(),
545 session.session_id.clone(),
546 event_tx,
547 bash_completion_sink,
548 vec![stdout_pump, stderr_pump],
549 );
550
551 sessions().insert(shell_id, session.clone());
552 Ok(session)
553}
554
555pub fn get_shell(id: &str) -> Option<Arc<ShellSession>> {
556 sessions().get(id).map(|entry| entry.value().clone())
557}
558
559pub fn remove_shell(id: &str) -> Option<Arc<ShellSession>> {
560 sessions().remove(id).map(|(_, value)| value)
561}
562
563pub fn running_shells_for_session(session_id: &str) -> Vec<String> {
575 sessions()
576 .iter()
577 .filter(|entry| {
578 entry
579 .session_id
580 .as_deref()
581 .is_some_and(|sid| sid == session_id)
582 && entry.status() == "running"
583 })
584 .map(|entry| entry.id.clone())
585 .collect()
586}