Skip to main content

atman_runtime/tools/
term.rs

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
185/// Parse a string containing ANSI escape sequences into a [TerminalScreen].
186///
187/// Used by the TUI to render ` ```ansi ` code blocks with real colors.
188/// cols is sized to the widest line (clamped 80..=256) so nothing wraps.
189///
190/// `\n` is upgraded to `\r\n` because vt100 treats bare LF as "line feed"
191/// (move down, keep column) — most ANSI output expects carriage return too.
192/// Trailing newlines are stripped so they don't trigger a scroll that
193/// pushes the first line off the visible screen.
194pub 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        // Only transition to Exited if not already Killed — term.kill sets
523        // Killed before the reader loop observes the closed PTY.
524        if matches!(*s, TermState::Running { .. }) {
525            *s = TermState::Exited {
526                exit_code,
527                ended_at: now_ms(),
528            };
529        }
530    }
531
532    // Persist the last screen state before notifying exit — restore reads this
533    // to recover the full cell grid (with colors) that stream frames never wrote.
534    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}