1#![allow(dead_code)]
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6use std::time::Instant;
7
8use tokio::sync::broadcast;
9use tokio::task::JoinHandle;
10
11use crate::error::RuntimeError;
12
13pub const DEFAULT_ROWS: u16 = 24;
14pub const DEFAULT_COLS: u16 = 80;
15const STREAM_CHANNEL_CAPACITY: usize = 256;
16
17#[derive(Debug, Clone, Hash, PartialEq, Eq)]
18pub struct TermHandle {
19 pub session_id: String,
20 pub local_id: u64,
21}
22
23impl TermHandle {
24 pub fn parse(s: &str) -> Option<Self> {
25 let rest = s.strip_prefix("term_")?;
26 let idx = rest.rfind('_')?;
27 let session_id = rest[..idx].to_string();
28 let local_id = rest[idx + 1..].parse().ok()?;
29 Some(Self {
30 session_id,
31 local_id,
32 })
33 }
34}
35
36impl std::fmt::Display for TermHandle {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 write!(f, "term_{}_{}", self.session_id, self.local_id)
39 }
40}
41
42#[derive(Debug, Clone)]
43pub enum TermState {
44 Running {
45 pid: u32,
46 started_at: u64,
47 },
48 Exited {
49 exit_code: Option<i32>,
50 ended_at: u64,
51 },
52 Failed {
53 error: String,
54 ended_at: u64,
55 },
56 Killed {
57 ended_at: u64,
58 },
59}
60
61impl TermState {
62 pub fn is_running(&self) -> bool {
63 matches!(self, TermState::Running { .. })
64 }
65
66 pub fn to_snapshot(&self) -> TermStateSnapshot {
67 match self {
68 TermState::Running { .. } => TermStateSnapshot::Running,
69 TermState::Exited { exit_code, .. } => TermStateSnapshot::Exited {
70 exit_code: *exit_code,
71 },
72 TermState::Failed { error, .. } => TermStateSnapshot::Failed {
73 error: error.clone(),
74 },
75 TermState::Killed { .. } => TermStateSnapshot::Killed,
76 }
77 }
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
81#[serde(tag = "kind")]
82pub enum TermStateSnapshot {
83 Running,
84 Exited { exit_code: Option<i32> },
85 Failed { error: String },
86 Killed,
87}
88
89#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
90pub struct TerminalScreen {
91 pub rows: u16,
92 pub cols: u16,
93 pub cells: Vec<TerminalCell>,
94 pub cursor: Option<(u16, u16)>,
95 pub alt_screen: bool,
96}
97
98#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
99pub struct TerminalCell {
100 pub chars: String,
101 pub fg: TerminalColor,
102 pub bg: TerminalColor,
103 pub bold: bool,
104 pub italic: bool,
105 pub underline: bool,
106 pub inverse: bool,
107 pub dim: bool,
108 pub wide: bool,
109 #[serde(default)]
110 pub wide_continuation: bool,
111}
112
113impl Default for TerminalCell {
114 fn default() -> Self {
115 Self {
116 chars: String::new(),
117 fg: TerminalColor::Default,
118 bg: TerminalColor::Default,
119 bold: false,
120 italic: false,
121 underline: false,
122 inverse: false,
123 dim: false,
124 wide: false,
125 wide_continuation: false,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131pub enum TerminalColor {
132 Default,
133 Idx(u8),
134 Rgb(u8, u8, u8),
135}
136
137impl From<vt100::Color> for TerminalColor {
138 fn from(c: vt100::Color) -> Self {
139 match c {
140 vt100::Color::Default => TerminalColor::Default,
141 vt100::Color::Idx(i) => TerminalColor::Idx(i),
142 vt100::Color::Rgb(r, g, b) => TerminalColor::Rgb(r, g, b),
143 }
144 }
145}
146
147pub fn snapshot_screen(parser: &vt100::Parser) -> TerminalScreen {
148 let screen = parser.screen();
149 let (rows, cols) = screen.size();
150 let mut cells = Vec::with_capacity(rows as usize * cols as usize);
151 for row in 0..rows {
152 for col in 0..cols {
153 if let Some(c) = screen.cell(row, col) {
154 cells.push(TerminalCell {
155 chars: c.contents().to_string(),
156 fg: c.fgcolor().into(),
157 bg: c.bgcolor().into(),
158 bold: c.bold(),
159 italic: c.italic(),
160 underline: c.underline(),
161 inverse: c.inverse(),
162 dim: c.dim(),
163 wide: c.is_wide(),
164 wide_continuation: c.is_wide_continuation(),
165 });
166 } else {
167 cells.push(TerminalCell::default());
168 }
169 }
170 }
171 let cursor = if screen.hide_cursor() {
172 None
173 } else {
174 Some(screen.cursor_position())
175 };
176 TerminalScreen {
177 rows,
178 cols,
179 cells,
180 cursor,
181 alt_screen: screen.alternate_screen(),
182 }
183}
184
185pub fn parse_ansi_to_screen(body: &str) -> TerminalScreen {
195 let body = body.strip_suffix('\n').unwrap_or(body);
196 let lines: Vec<&str> = body.split('\n').collect();
197 let rows = lines.len().max(1) as u16;
198 let cols = lines
199 .iter()
200 .map(|l| l.chars().count())
201 .max()
202 .unwrap_or(0)
203 .clamp(80, 256) as u16;
204 let mut parser = vt100::Parser::new(rows, cols, 0);
205 for (i, line) in lines.iter().enumerate() {
206 if i > 0 {
207 parser.process(b"\r\n");
208 }
209 parser.process(line.as_bytes());
210 }
211 snapshot_screen(&parser)
212}
213
214#[derive(Debug, Clone)]
215pub enum TermStreamEvent {
216 Chunk {
217 bytes: Vec<u8>,
218 screen: TerminalScreen,
219 state: TermState,
220 },
221 Exited {
222 exit_code: Option<i32>,
223 },
224}
225
226pub struct TermEntry {
227 pub handle: TermHandle,
228 pub session_id: String,
229 pub pty_size: portable_pty::PtySize,
230 pub parser: Arc<Mutex<vt100::Parser>>,
231 pub writer: Mutex<Box<dyn std::io::Write + Send>>,
232 pub state: Arc<Mutex<TermState>>,
233 pub stream_tx: broadcast::Sender<TermStreamEvent>,
234 pub log_path: PathBuf,
235 pub reader_task: Mutex<Option<JoinHandle<()>>>,
236 pub child: Mutex<Option<Box<dyn portable_pty::Child + Send + Sync>>>,
237 pub master: Mutex<Option<Box<dyn portable_pty::MasterPty + Send>>>,
238 pub started_at: Instant,
239 pub task_id: Mutex<Option<crate::task_registry::TaskId>>,
240}
241
242impl TermEntry {
243 pub fn snapshot(&self) -> TerminalScreen {
244 let parser = self.parser.lock().expect("parser poisoned");
245 snapshot_screen(&parser)
246 }
247
248 pub fn current_state(&self) -> TermState {
249 self.state.lock().expect("state poisoned").clone()
250 }
251
252 pub fn resize(&self, rows: u16, cols: u16) -> Result<(), RuntimeError> {
253 {
254 let mut parser = self.parser.lock().expect("parser poisoned");
255 parser.screen_mut().set_size(rows, cols);
256 }
257 let master = self.master.lock().expect("master poisoned");
258 if let Some(master) = master.as_ref() {
259 master
260 .resize(portable_pty::PtySize {
261 rows,
262 cols,
263 pixel_width: 0,
264 pixel_height: 0,
265 })
266 .map_err(|e| RuntimeError::ToolFailed(format!("term resize: pty resize: {e}")))?;
267 }
268 Ok(())
269 }
270}
271
272#[derive(Default)]
273pub struct TermRegistry {
274 entries: Mutex<HashMap<String, Arc<TermEntry>>>,
275 task_registry: Option<crate::task_registry::TaskRegistry>,
276}
277
278impl TermRegistry {
279 pub fn new() -> Self {
280 Self::default()
281 }
282
283 pub fn with_task_registry(mut self, tr: crate::task_registry::TaskRegistry) -> Self {
284 self.task_registry = Some(tr);
285 self
286 }
287
288 pub fn next_handle(&self, session_id: &str) -> TermHandle {
289 let local_id = uuid::Uuid::now_v7().as_u64_pair().0;
290 TermHandle {
291 session_id: session_id.to_string(),
292 local_id,
293 }
294 }
295
296 pub fn insert(&self, entry: Arc<TermEntry>) {
297 let key = entry.handle.to_string();
298 self.entries
299 .lock()
300 .expect("entries poisoned")
301 .insert(key, entry);
302 }
303
304 pub fn get(&self, handle_str: &str) -> Option<Arc<TermEntry>> {
305 self.entries
306 .lock()
307 .expect("entries poisoned")
308 .get(handle_str)
309 .cloned()
310 }
311
312 pub fn kill_all(&self) {
313 let entries = self.entries.lock().expect("entries poisoned");
314 for (_, entry) in entries.iter() {
315 let mut child = entry.child.lock().expect("child poisoned");
316 if let Some(child) = child.as_mut() {
317 let _ = child.kill();
318 }
319 }
320 }
321
322 pub fn lookup(
323 &self,
324 handle_str: &str,
325 session_id: &str,
326 ) -> Result<Arc<TermEntry>, RuntimeError> {
327 let handle = TermHandle::parse(handle_str).ok_or_else(|| {
328 RuntimeError::ToolFailed(format!("term: invalid handle: {handle_str}"))
329 })?;
330 if handle.session_id != session_id {
331 return Err(RuntimeError::ToolFailed(format!(
332 "term: handle {handle_str} does not belong to session {session_id}"
333 )));
334 }
335 self.get(handle_str).ok_or_else(|| {
336 RuntimeError::ToolFailed(format!("term: handle not found: {handle_str}"))
337 })
338 }
339
340 pub fn list(&self, session_id: &str) -> Vec<(String, TermState)> {
341 self.entries
342 .lock()
343 .expect("entries poisoned")
344 .iter()
345 .filter(|(_, e)| e.session_id == session_id)
346 .map(|(k, e)| (k.clone(), e.current_state()))
347 .collect()
348 }
349}
350
351fn now_ms() -> u64 {
352 std::time::SystemTime::now()
353 .duration_since(std::time::UNIX_EPOCH)
354 .map(|d| d.as_millis() as u64)
355 .unwrap_or(0)
356}
357
358const READ_BUF_SIZE: usize = 4096;
359
360impl TermRegistry {
361 #[allow(clippy::too_many_arguments)]
362 pub fn spawn_entry(
363 self: &Arc<Self>,
364 rows: u16,
365 cols: u16,
366 session_id: String,
367 session_dir: PathBuf,
368 pty_result: crate::sandbox::PtySpawnResult,
369 tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
370 label: String,
371 cancel: tokio_util::sync::CancellationToken,
372 events: Option<crate::event::EventSink>,
373 ) -> Result<(TermHandle, Arc<TermEntry>), RuntimeError> {
374 let handle = self.next_handle(&session_id);
375 let handle_str = handle.to_string();
376
377 std::fs::create_dir_all(&session_dir).map_err(|e| {
378 RuntimeError::ToolFailed(format!("term.spawn: create session_dir: {e}"))
379 })?;
380 let log_path = session_dir.join(format!("term_{}.log", handle_str));
381
382 let parser = vt100::Parser::new(rows, cols, 0);
383 let parser = Arc::new(Mutex::new(parser));
384 let state = Arc::new(Mutex::new(TermState::Running {
385 pid: 0,
386 started_at: now_ms(),
387 }));
388 let (stream_tx, _stream_rx) = broadcast::channel(STREAM_CHANNEL_CAPACITY);
389
390 let log_file = std::fs::OpenOptions::new()
391 .create(true)
392 .append(true)
393 .open(&log_path)
394 .map_err(|e| RuntimeError::ToolFailed(format!("term.spawn: open log: {e}")))?;
395
396 let entry = Arc::new(TermEntry {
397 handle: handle.clone(),
398 session_id: session_id.clone(),
399 pty_size: portable_pty::PtySize {
400 rows,
401 cols,
402 pixel_width: 0,
403 pixel_height: 0,
404 },
405 parser: parser.clone(),
406 writer: Mutex::new(pty_result.writer),
407 state: state.clone(),
408 stream_tx: stream_tx.clone(),
409 log_path: log_path.clone(),
410 reader_task: Mutex::new(None),
411 child: Mutex::new(Some(pty_result.child)),
412 master: Mutex::new(Some(pty_result.master)),
413 started_at: Instant::now(),
414 task_id: Mutex::new(None),
415 });
416
417 let kill_entry = entry.clone();
418 let task_id = self.task_registry.as_ref().map(|tr| {
419 let hook: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
420 let mut child = kill_entry.child.lock().expect("child poisoned");
421 if let Some(child) = child.as_mut() {
422 let _ = child.kill();
423 }
424 });
425 tr.register_with_kill_hook(
426 crate::task_registry::TaskKind::Terminal,
427 label,
428 handle_str.clone(),
429 session_id.clone(),
430 cancel,
431 Some(hook),
432 )
433 });
434 *entry.task_id.lock().unwrap() = task_id.clone();
435
436 let reader = pty_result.reader;
437 let handle_for_loop = handle_str.clone();
438 let task_registry = self.task_registry.clone();
439 let join = tokio::task::spawn_blocking(move || {
440 run_reader_loop(
441 reader,
442 parser,
443 state,
444 stream_tx,
445 log_file,
446 tui_stream_tx,
447 handle_for_loop,
448 task_registry,
449 task_id,
450 events,
451 );
452 });
453 *entry.reader_task.lock().expect("reader_task poisoned") = Some(join);
454
455 self.insert(entry.clone());
456 Ok((handle, entry))
457 }
458}
459
460impl Drop for TermRegistry {
461 fn drop(&mut self) {
462 self.kill_all();
463 }
464}
465
466#[allow(clippy::too_many_arguments)]
467fn run_reader_loop(
468 mut reader: Box<dyn std::io::Read + Send>,
469 parser: Arc<Mutex<vt100::Parser>>,
470 state: Arc<Mutex<TermState>>,
471 stream_tx: broadcast::Sender<TermStreamEvent>,
472 mut log_file: std::fs::File,
473 tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
474 handle: String,
475 task_registry: Option<crate::task_registry::TaskRegistry>,
476 task_id: Option<crate::task_registry::TaskId>,
477 events_sink: Option<crate::event::EventSink>,
478) {
479 let mut buf = [0u8; READ_BUF_SIZE];
480 let mut last_screen: Option<TerminalScreen> = None;
481 loop {
482 match reader.read(&mut buf) {
483 Ok(0) => break,
484 Ok(n) => {
485 let chunk = &buf[..n];
486 let _ = log_file.write_all(chunk);
487 let screen = {
488 let mut p = parser.lock().expect("parser poisoned");
489 p.process(chunk);
490 snapshot_screen(&p)
491 };
492 let screen_changed = last_screen.as_ref() != Some(&screen);
493 let st = state.lock().expect("state poisoned").clone();
494 let _ = stream_tx.send(TermStreamEvent::Chunk {
495 bytes: chunk.to_vec(),
496 screen: screen.clone(),
497 state: st.clone(),
498 });
499 if let Some(tx) = &tui_stream_tx {
500 let tui_screen = if screen_changed {
501 last_screen = Some(screen.clone());
502 Some(screen)
503 } else {
504 None
505 };
506 let _ = tx.send(crate::stream::StreamFrame::TerminalChunk {
507 handle: handle.clone(),
508 bytes: chunk.to_vec(),
509 screen: tui_screen,
510 state: st.to_snapshot(),
511 });
512 }
513 }
514 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
515 Err(_) => break,
516 }
517 }
518
519 let exit_code = None;
520 {
521 let mut s = state.lock().expect("state poisoned");
522 if matches!(*s, TermState::Running { .. }) {
525 *s = TermState::Exited {
526 exit_code,
527 ended_at: now_ms(),
528 };
529 }
530 }
531
532 if let Some(sink) = &events_sink {
535 let (final_screen, final_state) = {
536 let p = parser.lock().expect("parser poisoned");
537 let screen = snapshot_screen(&p);
538 let st = state.lock().expect("state poisoned").to_snapshot();
539 (screen, st)
540 };
541 sink.emit(crate::event::Event::TerminalFinalState {
542 handle: handle.clone(),
543 screen: final_screen,
544 state: final_state,
545 });
546 }
547
548 let _ = stream_tx.send(TermStreamEvent::Exited { exit_code });
549 if let Some(tx) = &tui_stream_tx {
550 let _ = tx.send(crate::stream::StreamFrame::TerminalExited { handle, exit_code });
551 }
552
553 if let (Some(tr), Some(tid)) = (task_registry, task_id) {
554 tr.finish(&tid, crate::task_registry::TaskStatus::Ok);
555 }
556}
557
558use std::io::Write;
559
560use std::path::Path;
561
562use crate::sandbox::PtySpawnResult;
563use crate::tool::{ApprovalLevel, Tier, Tool};
564use crate::value::Value;
565
566fn extract_string(
567 args: &crate::tool::ToolArgs,
568 name: &str,
569 pos: usize,
570) -> Result<String, RuntimeError> {
571 if let Some(v) = args.named(name) {
572 if let Value::Str(s) = v {
573 return Ok(s.clone());
574 }
575 return Err(RuntimeError::ToolFailed(format!(
576 "term: arg {name} must be string"
577 )));
578 }
579 if let Ok(Value::Str(s)) = args.positional(pos) {
580 return Ok(s.clone());
581 }
582 Err(RuntimeError::MissingArg(format!("term: {name}")))
583}
584
585fn extract_optional_string(args: &crate::tool::ToolArgs, name: &str) -> Option<String> {
586 args.named(name).and_then(|v| {
587 if let Value::Str(s) = v {
588 Some(s.clone())
589 } else {
590 None
591 }
592 })
593}
594
595fn extract_optional_int(args: &crate::tool::ToolArgs, name: &str) -> Option<i64> {
596 args.named(name).and_then(|v| {
597 if let Value::Int(i) = v {
598 Some(*i)
599 } else {
600 None
601 }
602 })
603}
604
605pub struct TermSpawn;
606
607impl Tool for TermSpawn {
608 fn name(&self) -> &str {
609 "term.spawn"
610 }
611 fn tier(&self) -> Tier {
612 Tier::Four
613 }
614 fn description(&self) -> Option<&str> {
615 Some(
616 "Spawn a PTY-backed interactive terminal. Supports TUI apps (vim, top, ssh, codex).\nReturns handle + state + dimensions. Does NOT return screen content — use\nterm.capture to read the screen.\n\nTypical flow:\n1. term.spawn(cmd: \"your command\", rows: 24, cols: 80)\n2. term.input(handle: \"...\", text: \"ls -la\") or key: \"enter\"\n3. term.capture(handle: \"...\") — returns screen as text by default\n4. Repeat 2-3 as needed\n5. term.kill(handle: \"...\")",
617 )
618 }
619 fn input_schema(&self) -> serde_json::Value {
620 serde_json::json!({
621 "type": "object",
622 "properties": {
623 "cmd": {"type": "string"},
624 "rows": {"type": "integer", "default": 24},
625 "cols": {"type": "integer", "default": 80},
626 "cwd": {"type": "string"},
627 "env": {"type": "object"}
628 }
629 })
630 }
631 fn call<'a>(
632 &'a self,
633 args: crate::tool::ToolArgs,
634 ctx: &'a crate::tool::ToolCtx,
635 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
636 Box::pin(async move { spawn_impl(args, ctx).await })
637 }
638}
639
640async fn spawn_impl(
641 args: crate::tool::ToolArgs,
642 ctx: &crate::tool::ToolCtx,
643) -> crate::tool::ToolResult {
644 let cmd_str = extract_optional_string(&args, "cmd");
645 let rows = extract_optional_int(&args, "rows")
646 .map(|v| v as u16)
647 .unwrap_or(DEFAULT_ROWS)
648 .max(1);
649 let cols = extract_optional_int(&args, "cols")
650 .map(|v| v as u16)
651 .unwrap_or(DEFAULT_COLS)
652 .max(2);
653 let cwd = extract_optional_string(&args, "cwd")
654 .map(std::path::PathBuf::from)
655 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
656 let env: Vec<(String, String)> = if let Some(Value::Struct(fields)) = args.named("env") {
657 fields
658 .iter()
659 .filter_map(|(k, v)| {
660 if let Value::Str(s) = v {
661 Some((k.clone(), s.clone()))
662 } else {
663 None
664 }
665 })
666 .collect()
667 } else {
668 Vec::new()
669 };
670
671 let registry = ctx
672 .term_registry
673 .clone()
674 .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: registry not available".into()))?;
675 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
676 let session_dir = ctx
677 .session_dir
678 .clone()
679 .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: session_dir not available".into()))?;
680
681 let pty_size = portable_pty::PtySize {
682 rows,
683 cols,
684 pixel_width: 0,
685 pixel_height: 0,
686 };
687 let default_shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".into());
688 let cmd_args: Vec<&str> = if let Some(ref c) = cmd_str {
689 vec!["sh", "-c", c.as_str()]
690 } else {
691 vec![default_shell.as_str()]
692 };
693 let env_refs: Vec<(String, String)> = env.clone();
694
695 let pty_result = if let Some(sandbox) = &ctx.sandbox {
696 match sandbox
697 .spawn_pty(&cmd_args, &env_refs, &cwd, pty_size)
698 .await
699 {
700 Ok(r) => r,
701 Err(e) => {
702 let msg = e.to_string();
703 if msg.contains("Operation not permitted") || msg.contains("denied") {
704 let outcome = crate::approval::request_approval(
705 ctx,
706 "term.spawn",
707 "term.spawn",
708 &args,
709 ApprovalLevel::Dangerous,
710 None,
711 )
712 .await;
713 match outcome {
714 crate::approval::ApprovalOutcome::Approve => {
715 sandbox
716 .spawn_pty_relaxed(&cmd_args, &env_refs, &cwd, pty_size)
717 .await?
718 }
719 crate::approval::ApprovalOutcome::Deny { reason } => {
720 return Err(RuntimeError::ToolFailed(format!(
721 "term.spawn denied: {reason}"
722 )));
723 }
724 }
725 } else {
726 return Err(e);
727 }
728 }
729 }
730 } else {
731 spawn_pty_direct(&cmd_args, &env_refs, &cwd, pty_size)?
732 };
733
734 let (handle, entry) = registry.spawn_entry(
735 rows,
736 cols,
737 session_id,
738 session_dir,
739 pty_result,
740 ctx.stream_tx.clone(),
741 cmd_str.unwrap_or_else(|| "terminal".into()),
742 {
743 let tc = ctx.cancel.clone();
744 tc.child_token()
745 },
746 ctx.events.clone(),
747 )?;
748
749 let state = entry.current_state();
750 let text = {
751 let parser = entry.parser.lock().expect("parser poisoned");
752 parser.screen().contents()
753 };
754 Ok(Value::Struct(vec![
755 ("handle".into(), Value::Str(handle.to_string())),
756 ("state".into(), state_to_value(&state)),
757 ("rows".into(), Value::Int(rows as i64)),
758 ("cols".into(), Value::Int(cols as i64)),
759 ("text".into(), Value::Str(text)),
760 ]))
761}
762
763fn spawn_pty_direct(
764 cmd: &[&str],
765 env: &[(String, String)],
766 cwd: &Path,
767 pty_size: portable_pty::PtySize,
768) -> Result<PtySpawnResult, RuntimeError> {
769 let pty_system = portable_pty::native_pty_system();
770 let pair = pty_system
771 .openpty(pty_size)
772 .map_err(|e| RuntimeError::ToolFailed(format!("openpty: {e}")))?;
773 let mut builder = portable_pty::CommandBuilder::new(cmd[0]);
774 for arg in &cmd[1..] {
775 builder.arg(arg);
776 }
777 builder.cwd(cwd);
778 for (k, v) in env {
779 builder.env(k, v);
780 }
781 let child = pair
782 .slave
783 .spawn_command(builder)
784 .map_err(|e| RuntimeError::ToolFailed(format!("pty spawn: {e}")))?;
785 let reader = pair
786 .master
787 .try_clone_reader()
788 .map_err(|e| RuntimeError::ToolFailed(format!("pty reader: {e}")))?;
789 let writer = pair
790 .master
791 .take_writer()
792 .map_err(|e| RuntimeError::ToolFailed(format!("pty writer: {e}")))?;
793 Ok(PtySpawnResult {
794 child,
795 reader,
796 writer,
797 master: pair.master,
798 })
799}
800
801fn screen_to_value(screen: &TerminalScreen) -> Value {
802 let cells: Vec<Value> = screen
803 .cells
804 .iter()
805 .map(|c| {
806 Value::Struct(vec![
807 ("chars".into(), Value::Str(c.chars.clone())),
808 ("fg".into(), color_to_value(c.fg)),
809 ("bg".into(), color_to_value(c.bg)),
810 ("bold".into(), Value::Bool(c.bold)),
811 ("italic".into(), Value::Bool(c.italic)),
812 ("underline".into(), Value::Bool(c.underline)),
813 ("inverse".into(), Value::Bool(c.inverse)),
814 ("dim".into(), Value::Bool(c.dim)),
815 ("wide".into(), Value::Bool(c.wide)),
816 ("wide_continuation".into(), Value::Bool(c.wide_continuation)),
817 ])
818 })
819 .collect();
820 Value::Struct(vec![
821 ("rows".into(), Value::Int(screen.rows as i64)),
822 ("cols".into(), Value::Int(screen.cols as i64)),
823 ("cells".into(), Value::List(cells)),
824 (
825 "cursor".into(),
826 match screen.cursor {
827 Some((r, c)) => Value::Struct(vec![
828 ("row".into(), Value::Int(r as i64)),
829 ("col".into(), Value::Int(c as i64)),
830 ]),
831 None => Value::Unit,
832 },
833 ),
834 ("alt_screen".into(), Value::Bool(screen.alt_screen)),
835 ])
836}
837
838fn color_to_value(c: TerminalColor) -> Value {
839 match c {
840 TerminalColor::Default => Value::Str("default".into()),
841 TerminalColor::Idx(i) => Value::Int(i as i64),
842 TerminalColor::Rgb(r, g, b) => Value::Struct(vec![
843 ("r".into(), Value::Int(r as i64)),
844 ("g".into(), Value::Int(g as i64)),
845 ("b".into(), Value::Int(b as i64)),
846 ]),
847 }
848}
849
850fn state_to_value(state: &TermState) -> Value {
851 match state {
852 TermState::Running { pid, started_at } => Value::Struct(vec![
853 ("kind".into(), Value::Str("running".into())),
854 ("pid".into(), Value::Int(*pid as i64)),
855 ("started_at".into(), Value::Int(*started_at as i64)),
856 ]),
857 TermState::Exited {
858 exit_code,
859 ended_at,
860 } => Value::Struct(vec![
861 ("kind".into(), Value::Str("exited".into())),
862 (
863 "exit_code".into(),
864 exit_code
865 .map(|c| Value::Int(c as i64))
866 .unwrap_or(Value::Unit),
867 ),
868 ("ended_at".into(), Value::Int(*ended_at as i64)),
869 ]),
870 TermState::Failed { error, ended_at } => Value::Struct(vec![
871 ("kind".into(), Value::Str("failed".into())),
872 ("error".into(), Value::Str(error.clone())),
873 ("ended_at".into(), Value::Int(*ended_at as i64)),
874 ]),
875 TermState::Killed { ended_at } => Value::Struct(vec![
876 ("kind".into(), Value::Str("killed".into())),
877 ("ended_at".into(), Value::Int(*ended_at as i64)),
878 ]),
879 }
880}
881
882pub struct TermInput;
883impl Tool for TermInput {
884 fn name(&self) -> &str {
885 "term.input"
886 }
887 fn tier(&self) -> Tier {
888 Tier::Four
889 }
890 fn description(&self) -> Option<&str> {
891 Some(
892 "Send input to a terminal's PTY. Use `text` for literal text, or `key` for\nspecial keys (enter, tab, esc, backspace, up, down, left, right, ctrl+c,\nctrl+d, ctrl+z). Use key: \"enter\" to submit a command, not text: \"\\r\".",
893 )
894 }
895 fn input_schema(&self) -> serde_json::Value {
896 serde_json::json!({
897 "type": "object",
898 "properties": {
899 "handle": {"type": "string"},
900 "text": {"type": "string", "description": "Literal text to write. Do NOT use \\r or \\n here — use key:\"enter\" instead."},
901 "key": {"type": "string", "enum": ["enter", "tab", "esc", "backspace", "up", "down", "left", "right", "ctrl+c", "ctrl+d", "ctrl+z"]}
902 },
903 "required": ["handle"]
904 })
905 }
906 fn call<'a>(
907 &'a self,
908 args: crate::tool::ToolArgs,
909 ctx: &'a crate::tool::ToolCtx,
910 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
911 Box::pin(async move {
912 let handle = extract_string(&args, "handle", 0)?;
913 let text = extract_optional_string(&args, "text").unwrap_or_default();
914 let key = extract_optional_string(&args, "key");
915 let registry = ctx.term_registry.clone().ok_or_else(|| {
916 RuntimeError::ToolFailed("term.input: registry not available".into())
917 })?;
918 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
919 let entry = registry.lookup(&handle, &session_id)?;
920
921 let mut payload = text.into_bytes();
922 if let Some(k) = &key {
923 payload.extend_from_slice(&key_to_bytes(k));
924 }
925 if payload.is_empty() {
926 return Err(RuntimeError::ToolFailed(
927 "term.input: provide at least one of `text` or `key`".into(),
928 ));
929 }
930
931 let n = {
932 let mut w = entry.writer.lock().expect("writer poisoned");
933 w.write_all(&payload)
934 .map_err(|e| RuntimeError::ToolFailed(format!("term.input write: {e}")))?;
935 payload.len()
936 };
937 Ok(Value::Struct(vec![
938 ("ok".into(), Value::Bool(true)),
939 ("bytes_written".into(), Value::Int(n as i64)),
940 ]))
941 })
942 }
943}
944
945fn key_to_bytes(key: &str) -> Vec<u8> {
946 match key {
947 "enter" => vec![b'\r'],
948 "tab" => vec![b'\t'],
949 "esc" => vec![0x1b],
950 "backspace" => vec![0x7f],
951 "up" => vec![0x1b, b'[', b'A'],
952 "down" => vec![0x1b, b'[', b'B'],
953 "right" => vec![0x1b, b'[', b'C'],
954 "left" => vec![0x1b, b'[', b'D'],
955 "ctrl+c" => vec![0x03],
956 "ctrl+d" => vec![0x04],
957 "ctrl+z" => vec![0x1a],
958 _ => Vec::new(),
959 }
960}
961
962pub struct TermCapture;
963impl Tool for TermCapture {
964 fn name(&self) -> &str {
965 "term.capture"
966 }
967 fn tier(&self) -> Tier {
968 Tier::Four
969 }
970 fn description(&self) -> Option<&str> {
971 Some(
972 "Read the terminal screen. Default returns plain text (format: \"text\").\nUse start_row/end_row to read only part of the screen — e.g. last 5 rows\nto check the prompt or command output without wasting context.\n\nBest practices:\n- After sending a command, capture to see the result.\n- Use start_row/end_row to read only the relevant part (e.g. last 10 rows).\n- format: \"screen\" returns full cell data with colors/styles — rarely needed,\n only use when you need to inspect TUI layout or colors.\n- Default format: \"text\" is sufficient for most cases (reading command output,\n checking prompts, seeing error messages).",
973 )
974 }
975 fn input_schema(&self) -> serde_json::Value {
976 serde_json::json!({
977 "type": "object",
978 "properties": {
979 "handle": {"type": "string"},
980 "format": {"type": "string", "enum": ["text", "screen"], "default": "text"},
981 "start_row": {"type": "integer", "default": 0, "description": "Start row (0-based). Default 0."},
982 "end_row": {"type": "integer", "description": "End row (exclusive). Default: full height."}
983 },
984 "required": ["handle"]
985 })
986 }
987 fn call<'a>(
988 &'a self,
989 args: crate::tool::ToolArgs,
990 ctx: &'a crate::tool::ToolCtx,
991 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
992 Box::pin(async move {
993 let handle = extract_string(&args, "handle", 0)?;
994 let format = extract_optional_string(&args, "format").unwrap_or_else(|| "text".into());
995 let registry = ctx.term_registry.clone().ok_or_else(|| {
996 RuntimeError::ToolFailed("term.capture: registry not available".into())
997 })?;
998 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
999 let entry = registry.lookup(&handle, &session_id)?;
1000 let screen = entry.snapshot();
1001 let start_row = extract_optional_int(&args, "start_row")
1002 .unwrap_or(0)
1003 .clamp(0, screen.rows as i64) as u16;
1004 let end_row = extract_optional_int(&args, "end_row")
1005 .unwrap_or(screen.rows as i64)
1006 .clamp(start_row as i64, screen.rows as i64) as u16;
1007 let state = entry.current_state();
1008 let mut fields = vec![
1009 ("handle".into(), Value::Str(handle.clone())),
1010 ("state".into(), state_to_value(&state)),
1011 ("rows".into(), Value::Int(screen.rows as i64)),
1012 ("cols".into(), Value::Int(screen.cols as i64)),
1013 ];
1014 if format == "screen" {
1015 let cols = screen.cols as usize;
1016 let start = start_row as usize * cols;
1017 let end = end_row as usize * cols;
1018 let cursor = screen.cursor.and_then(|(row, col)| {
1019 (start_row..end_row)
1020 .contains(&row)
1021 .then_some((row - start_row, col))
1022 });
1023 let partial_screen = TerminalScreen {
1024 rows: end_row - start_row,
1025 cols: screen.cols,
1026 cells: screen.cells[start..end].to_vec(),
1027 cursor,
1028 alt_screen: screen.alt_screen,
1029 };
1030 fields[2] = ("rows".into(), Value::Int(partial_screen.rows as i64));
1031 fields.push(("screen".into(), screen_to_value(&partial_screen)));
1032 } else {
1033 let parser = entry.parser.lock().expect("parser poisoned");
1034 let text = parser
1035 .screen()
1036 .rows(0, screen.cols)
1037 .skip(start_row as usize)
1038 .take((end_row - start_row) as usize)
1039 .collect::<Vec<_>>()
1040 .join("\n");
1041 fields.push(("text".into(), Value::Str(text)));
1042 }
1043 Ok(Value::Struct(fields))
1044 })
1045 }
1046}
1047
1048pub struct TermResize;
1049impl Tool for TermResize {
1050 fn name(&self) -> &str {
1051 "term.resize"
1052 }
1053 fn tier(&self) -> Tier {
1054 Tier::Four
1055 }
1056 fn description(&self) -> Option<&str> {
1057 Some("Resize a terminal's PTY dimensions. Sends SIGWINCH to the child process.")
1058 }
1059 fn input_schema(&self) -> serde_json::Value {
1060 serde_json::json!({
1061 "type": "object",
1062 "properties": {
1063 "handle": {"type": "string"},
1064 "rows": {"type": "integer"},
1065 "cols": {"type": "integer"}
1066 },
1067 "required": ["handle", "rows", "cols"]
1068 })
1069 }
1070 fn call<'a>(
1071 &'a self,
1072 args: crate::tool::ToolArgs,
1073 ctx: &'a crate::tool::ToolCtx,
1074 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1075 Box::pin(async move {
1076 let handle = extract_string(&args, "handle", 0)?;
1077 let rows = extract_optional_int(&args, "rows")
1078 .ok_or_else(|| RuntimeError::MissingArg("rows".into()))?
1079 as u16;
1080 let cols = extract_optional_int(&args, "cols")
1081 .ok_or_else(|| RuntimeError::MissingArg("cols".into()))?
1082 as u16;
1083 let registry = ctx.term_registry.clone().ok_or_else(|| {
1084 RuntimeError::ToolFailed("term.resize: registry not available".into())
1085 })?;
1086 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1087 let entry = registry.lookup(&handle, &session_id)?;
1088 entry.resize(rows, cols)?;
1089 Ok(Value::Struct(vec![
1090 ("ok".into(), Value::Bool(true)),
1091 ("rows".into(), Value::Int(rows as i64)),
1092 ("cols".into(), Value::Int(cols as i64)),
1093 ]))
1094 })
1095 }
1096}
1097
1098pub struct TermKill;
1099impl Tool for TermKill {
1100 fn name(&self) -> &str {
1101 "term.kill"
1102 }
1103 fn tier(&self) -> Tier {
1104 Tier::Four
1105 }
1106 fn description(&self) -> Option<&str> {
1107 Some("Kill a terminal process. The terminal handle remains in the registry for history.")
1108 }
1109 fn input_schema(&self) -> serde_json::Value {
1110 serde_json::json!({
1111 "type": "object",
1112 "properties": {"handle": {"type": "string"}},
1113 "required": ["handle"]
1114 })
1115 }
1116 fn call<'a>(
1117 &'a self,
1118 args: crate::tool::ToolArgs,
1119 ctx: &'a crate::tool::ToolCtx,
1120 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1121 Box::pin(async move {
1122 let handle = extract_string(&args, "handle", 0)?;
1123 let registry = ctx.term_registry.clone().ok_or_else(|| {
1124 RuntimeError::ToolFailed("term.kill: registry not available".into())
1125 })?;
1126 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1127 let entry = registry.lookup(&handle, &session_id)?;
1128 {
1129 let mut child = entry.child.lock().expect("child poisoned");
1130 if let Some(child) = child.as_mut() {
1131 let _ = child.kill();
1132 }
1133 }
1134 {
1135 let mut state = entry.state.lock().expect("state poisoned");
1136 *state = TermState::Killed { ended_at: now_ms() };
1137 }
1138 Ok(Value::Struct(vec![
1139 ("ok".into(), Value::Bool(true)),
1140 ("state".into(), Value::Str("killed".into())),
1141 ]))
1142 })
1143 }
1144}
1145
1146pub struct TermList;
1147impl Tool for TermList {
1148 fn name(&self) -> &str {
1149 "term.list"
1150 }
1151 fn tier(&self) -> Tier {
1152 Tier::Four
1153 }
1154 fn description(&self) -> Option<&str> {
1155 Some("List all terminal handles in the current session.")
1156 }
1157 fn input_schema(&self) -> serde_json::Value {
1158 serde_json::json!({
1159 "type": "object",
1160 "properties": {"all": {"type": "boolean", "default": false}}
1161 })
1162 }
1163 fn call<'a>(
1164 &'a self,
1165 args: crate::tool::ToolArgs,
1166 ctx: &'a crate::tool::ToolCtx,
1167 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1168 Box::pin(async move {
1169 let all = args
1170 .named("all")
1171 .and_then(|v| {
1172 if let Value::Bool(b) = v {
1173 Some(*b)
1174 } else {
1175 None
1176 }
1177 })
1178 .unwrap_or(false);
1179 let registry = ctx.term_registry.clone().ok_or_else(|| {
1180 RuntimeError::ToolFailed("term.list: registry not available".into())
1181 })?;
1182 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1183 let _ = all;
1184 let list = registry.list(&session_id);
1185 let entries: Vec<Value> = list
1186 .iter()
1187 .map(|(h, st)| {
1188 Value::Struct(vec![
1189 ("handle".into(), Value::Str(h.clone())),
1190 ("state".into(), state_to_value(st)),
1191 ])
1192 })
1193 .collect();
1194 Ok(Value::Struct(vec![(
1195 "terminals".into(),
1196 Value::List(entries),
1197 )]))
1198 })
1199 }
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204 use super::*;
1205
1206 #[test]
1207 fn handle_parse_roundtrip() {
1208 let h = TermHandle {
1209 session_id: "abc".into(),
1210 local_id: 7,
1211 };
1212 assert_eq!(h.to_string(), "term_abc_7");
1213 let back = TermHandle::parse("term_abc_7").unwrap();
1214 assert_eq!(back, h);
1215 }
1216
1217 #[test]
1218 fn handle_parse_rejects_bad_format() {
1219 assert!(TermHandle::parse("not_term").is_none());
1220 assert!(TermHandle::parse("term_nosuffix").is_none());
1221 assert!(TermHandle::parse("term_x_notnum").is_none());
1222 }
1223
1224 #[test]
1225 fn snapshot_screen_captures_text() {
1226 let mut parser = vt100::Parser::new(3, 5, 0);
1227 parser.process(b"hello");
1228 let screen = snapshot_screen(&parser);
1229 assert_eq!(screen.rows, 3);
1230 assert_eq!(screen.cols, 5);
1231 assert_eq!(screen.cells.len(), 15);
1232 assert_eq!(screen.cells[0].chars, "h");
1233 assert_eq!(screen.cells[4].chars, "o");
1234 }
1235
1236 #[test]
1237 fn registry_lookup_rejects_cross_session() {
1238 let registry = Arc::new(TermRegistry::new());
1239 let h = registry.next_handle("session_a");
1240 let entry = Arc::new(TermEntry {
1241 handle: h.clone(),
1242 session_id: "session_a".into(),
1243 pty_size: portable_pty::PtySize {
1244 rows: 24,
1245 cols: 80,
1246 pixel_width: 0,
1247 pixel_height: 0,
1248 },
1249 parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
1250 writer: Mutex::new(Box::new(std::io::sink())),
1251 state: Arc::new(Mutex::new(TermState::Running {
1252 pid: 0,
1253 started_at: 0,
1254 })),
1255 stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
1256 log_path: std::env::temp_dir().join("term_test_dummy.log"),
1257 reader_task: Mutex::new(None),
1258 child: Mutex::new(None),
1259 master: Mutex::new(None),
1260 started_at: Instant::now(),
1261 task_id: Mutex::new(None),
1262 });
1263 registry.insert(entry);
1264 assert!(registry.lookup(&h.to_string(), "session_a").is_ok());
1265 assert!(registry.lookup(&h.to_string(), "session_b").is_err());
1266 }
1267}