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