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    profile: Arc<Mutex<Option<crate::sandbox::TempProfile>>>,
239    pub started_at: Instant,
240    pub task_id: Mutex<Option<crate::task_registry::TaskId>>,
241}
242
243impl TermEntry {
244    pub fn snapshot(&self) -> TerminalScreen {
245        let parser = self.parser.lock().expect("parser poisoned");
246        snapshot_screen(&parser)
247    }
248
249    pub fn current_state(&self) -> TermState {
250        self.state.lock().expect("state poisoned").clone()
251    }
252
253    pub fn resize(&self, rows: u16, cols: u16) -> Result<(), RuntimeError> {
254        {
255            let mut parser = self.parser.lock().expect("parser poisoned");
256            parser.screen_mut().set_size(rows, cols);
257        }
258        let master = self.master.lock().expect("master poisoned");
259        if let Some(master) = master.as_ref() {
260            master
261                .resize(portable_pty::PtySize {
262                    rows,
263                    cols,
264                    pixel_width: 0,
265                    pixel_height: 0,
266                })
267                .map_err(|e| RuntimeError::ToolFailed(format!("term resize: pty resize: {e}")))?;
268        }
269        Ok(())
270    }
271
272    fn stop(&self) -> bool {
273        {
274            let mut state = self.state.lock().expect("state poisoned");
275            if !state.is_running() {
276                return false;
277            }
278            *state = TermState::Killed { ended_at: now_ms() };
279        }
280        let mut child = self.child.lock().expect("child poisoned");
281        if let Some(child) = child.as_mut() {
282            let _ = child.kill();
283        }
284        drop(child);
285        self.profile.lock().expect("profile poisoned").take();
286        true
287    }
288}
289
290#[derive(Default)]
291pub struct TermRegistry {
292    entries: Mutex<HashMap<String, Arc<TermEntry>>>,
293    task_registry: Option<crate::task_registry::TaskRegistry>,
294}
295
296impl TermRegistry {
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    pub fn with_task_registry(mut self, tr: crate::task_registry::TaskRegistry) -> Self {
302        self.task_registry = Some(tr);
303        self
304    }
305
306    pub fn next_handle(&self, session_id: &str) -> TermHandle {
307        let local_id = uuid::Uuid::now_v7().as_u64_pair().0;
308        TermHandle {
309            session_id: session_id.to_string(),
310            local_id,
311        }
312    }
313
314    pub fn insert(&self, entry: Arc<TermEntry>) {
315        let key = entry.handle.to_string();
316        self.entries
317            .lock()
318            .expect("entries poisoned")
319            .insert(key, entry);
320    }
321
322    pub fn get(&self, handle_str: &str) -> Option<Arc<TermEntry>> {
323        self.entries
324            .lock()
325            .expect("entries poisoned")
326            .get(handle_str)
327            .cloned()
328    }
329
330    pub fn kill_all(&self) {
331        let entries = self
332            .entries
333            .lock()
334            .expect("entries poisoned")
335            .values()
336            .cloned()
337            .collect::<Vec<_>>();
338        for entry in entries {
339            entry.stop();
340        }
341    }
342
343    pub fn lookup(
344        &self,
345        handle_str: &str,
346        session_id: &str,
347    ) -> Result<Arc<TermEntry>, RuntimeError> {
348        let handle = TermHandle::parse(handle_str).ok_or_else(|| {
349            RuntimeError::ToolFailed(format!("term: invalid handle: {handle_str}"))
350        })?;
351        if handle.session_id != session_id {
352            return Err(RuntimeError::ToolFailed(format!(
353                "term: handle {handle_str} does not belong to session {session_id}"
354            )));
355        }
356        self.get(handle_str).ok_or_else(|| {
357            RuntimeError::ToolFailed(format!("term: handle not found: {handle_str}"))
358        })
359    }
360
361    pub fn list(&self, session_id: &str) -> Vec<(String, TermState)> {
362        self.entries
363            .lock()
364            .expect("entries poisoned")
365            .iter()
366            .filter(|(_, e)| e.session_id == session_id)
367            .map(|(k, e)| (k.clone(), e.current_state()))
368            .collect()
369    }
370}
371
372fn now_ms() -> u64 {
373    std::time::SystemTime::now()
374        .duration_since(std::time::UNIX_EPOCH)
375        .map(|d| d.as_millis() as u64)
376        .unwrap_or(0)
377}
378
379const READ_BUF_SIZE: usize = 4096;
380
381fn open_term_log(path: &Path) -> std::io::Result<std::fs::File> {
382    std::fs::OpenOptions::new()
383        .create(true)
384        .append(true)
385        .open(path)
386}
387
388impl TermRegistry {
389    #[allow(clippy::too_many_arguments)]
390    pub fn spawn_entry(
391        self: &Arc<Self>,
392        rows: u16,
393        cols: u16,
394        session_id: String,
395        session_dir: PathBuf,
396        pty_result: crate::sandbox::PtySpawnResult,
397        tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
398        label: String,
399        command: String,
400        call_intent: Option<crate::message::ToolCallIntent>,
401        tool_use_id: Option<String>,
402        cancel: tokio_util::sync::CancellationToken,
403        events: Option<crate::event::EventSink>,
404        flow_run_id: Option<String>,
405    ) -> Result<(TermHandle, Arc<TermEntry>), RuntimeError> {
406        self.spawn_entry_with_log_opener(
407            rows,
408            cols,
409            session_id,
410            session_dir,
411            pty_result,
412            tui_stream_tx,
413            label,
414            command,
415            call_intent,
416            tool_use_id,
417            cancel,
418            events,
419            flow_run_id,
420            open_term_log,
421        )
422    }
423
424    #[allow(clippy::too_many_arguments)]
425    fn spawn_entry_with_log_opener<F>(
426        self: &Arc<Self>,
427        rows: u16,
428        cols: u16,
429        session_id: String,
430        session_dir: PathBuf,
431        pty_result: crate::sandbox::PtySpawnResult,
432        tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
433        label: String,
434        command: String,
435        call_intent: Option<crate::message::ToolCallIntent>,
436        tool_use_id: Option<String>,
437        cancel: tokio_util::sync::CancellationToken,
438        events: Option<crate::event::EventSink>,
439        flow_run_id: Option<String>,
440        open_log: F,
441    ) -> Result<(TermHandle, Arc<TermEntry>), RuntimeError>
442    where
443        F: FnOnce(&Path) -> std::io::Result<std::fs::File>,
444    {
445        let handle = self.next_handle(&session_id);
446        let handle_str = handle.to_string();
447
448        let crate::sandbox::PtySpawnResult {
449            mut child,
450            reader,
451            writer,
452            master,
453            profile,
454        } = pty_result;
455
456        if let Err(error) = std::fs::create_dir_all(&session_dir) {
457            crate::sandbox::terminate_pty_child(child.as_mut());
458            return Err(RuntimeError::ToolFailed(format!(
459                "term.spawn: create session_dir: {error}"
460            )));
461        }
462        let log_path = session_dir.join(format!("term_{}.log", handle_str));
463
464        let parser = vt100::Parser::new(rows, cols, 0);
465        let parser = Arc::new(Mutex::new(parser));
466        let state = Arc::new(Mutex::new(TermState::Running {
467            pid: 0,
468            started_at: now_ms(),
469        }));
470        let (stream_tx, _stream_rx) = broadcast::channel(STREAM_CHANNEL_CAPACITY);
471        let log_file = match open_log(&log_path) {
472            Ok(file) => file,
473            Err(error) => {
474                crate::sandbox::terminate_pty_child(child.as_mut());
475                return Err(RuntimeError::ToolFailed(format!(
476                    "term.spawn: open log: {error}"
477                )));
478            }
479        };
480
481        let profile = Arc::new(Mutex::new(profile));
482        let entry = Arc::new(TermEntry {
483            handle: handle.clone(),
484            session_id: session_id.clone(),
485            pty_size: portable_pty::PtySize {
486                rows,
487                cols,
488                pixel_width: 0,
489                pixel_height: 0,
490            },
491            parser: parser.clone(),
492            writer: Mutex::new(writer),
493            state: state.clone(),
494            stream_tx: stream_tx.clone(),
495            log_path: log_path.clone(),
496            reader_task: Mutex::new(None),
497            child: Mutex::new(Some(child)),
498            master: Mutex::new(Some(master)),
499            profile: profile.clone(),
500            started_at: Instant::now(),
501            task_id: Mutex::new(None),
502        });
503
504        let kill_entry = entry.clone();
505        let task_id = self.task_registry.as_ref().map(|tr| {
506            let hook: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
507                kill_entry.stop();
508            });
509            tr.register_with_kill_hook(
510                crate::task_registry::TaskKind::Terminal,
511                crate::task_registry::TaskDisplay {
512                    label,
513                    command: Some(command),
514                },
515                handle_str.clone(),
516                session_id.clone(),
517                cancel,
518                Some(hook),
519            )
520        });
521        *entry.task_id.lock().unwrap() = task_id.clone();
522
523        let handle_for_loop = handle_str.clone();
524        let task_registry = self.task_registry.clone();
525        let join = tokio::task::spawn_blocking(move || {
526            run_reader_loop(
527                reader,
528                parser,
529                state,
530                stream_tx,
531                log_file,
532                tui_stream_tx,
533                handle_for_loop,
534                task_registry,
535                task_id,
536                events,
537                flow_run_id,
538                call_intent,
539                tool_use_id,
540                profile,
541            );
542        });
543        *entry.reader_task.lock().expect("reader_task poisoned") = Some(join);
544
545        self.insert(entry.clone());
546        Ok((handle, entry))
547    }
548}
549
550impl Drop for TermRegistry {
551    fn drop(&mut self) {
552        self.kill_all();
553    }
554}
555
556#[allow(clippy::too_many_arguments)]
557fn run_reader_loop(
558    mut reader: Box<dyn std::io::Read + Send>,
559    parser: Arc<Mutex<vt100::Parser>>,
560    state: Arc<Mutex<TermState>>,
561    stream_tx: broadcast::Sender<TermStreamEvent>,
562    mut log_file: std::fs::File,
563    tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
564    handle: String,
565    task_registry: Option<crate::task_registry::TaskRegistry>,
566    task_id: Option<crate::task_registry::TaskId>,
567    events_sink: Option<crate::event::EventSink>,
568    flow_run_id: Option<String>,
569    call_intent: Option<crate::message::ToolCallIntent>,
570    tool_use_id: Option<String>,
571    profile: Arc<Mutex<Option<crate::sandbox::TempProfile>>>,
572) {
573    let mut buf = [0u8; READ_BUF_SIZE];
574    let mut last_screen: Option<TerminalScreen> = None;
575    loop {
576        match reader.read(&mut buf) {
577            Ok(0) => break,
578            Ok(n) => {
579                let chunk = &buf[..n];
580                let _ = log_file.write_all(chunk);
581                let screen = {
582                    let mut p = parser.lock().expect("parser poisoned");
583                    p.process(chunk);
584                    snapshot_screen(&p)
585                };
586                let screen_changed = last_screen.as_ref() != Some(&screen);
587                let st = state.lock().expect("state poisoned").clone();
588                let _ = stream_tx.send(TermStreamEvent::Chunk {
589                    bytes: chunk.to_vec(),
590                    screen: screen.clone(),
591                    state: st.clone(),
592                });
593                if let Some(tx) = &tui_stream_tx {
594                    let tui_screen = if screen_changed {
595                        last_screen = Some(screen.clone());
596                        Some(screen)
597                    } else {
598                        None
599                    };
600                    let _ = tx.send(crate::stream::StreamFrame::TerminalChunk {
601                        handle: handle.clone(),
602                        tool_use_id: tool_use_id.clone(),
603                        bytes: chunk.to_vec(),
604                        screen: tui_screen,
605                        state: st.to_snapshot(),
606                        call_intent: call_intent.clone(),
607                        run_id: flow_run_id.clone(),
608                    });
609                }
610            }
611            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
612            Err(_) => break,
613        }
614    }
615
616    let (exit_code, task_status) = {
617        let mut s = state.lock().expect("state poisoned");
618        if matches!(*s, TermState::Running { .. }) {
619            *s = TermState::Exited {
620                exit_code: None,
621                ended_at: now_ms(),
622            };
623        }
624        let exit_code = match &*s {
625            TermState::Exited { exit_code, .. } => *exit_code,
626            _ => None,
627        };
628        let task_status = match &*s {
629            TermState::Killed { .. } => crate::task_registry::TaskStatus::Killed,
630            TermState::Failed { .. } => crate::task_registry::TaskStatus::Err,
631            TermState::Exited {
632                exit_code: Some(code),
633                ..
634            } if *code != 0 => crate::task_registry::TaskStatus::Err,
635            TermState::Exited { .. } => crate::task_registry::TaskStatus::Ok,
636            TermState::Running { .. } => unreachable!("reader exit must finalize terminal state"),
637        };
638        (exit_code, task_status)
639    };
640
641    // Persist the last screen state before notifying exit — restore reads this
642    // to recover the full cell grid (with colors) that stream frames never wrote.
643    if let Some(sink) = &events_sink {
644        let (final_screen, final_state) = {
645            let p = parser.lock().expect("parser poisoned");
646            let screen = snapshot_screen(&p);
647            let st = state.lock().expect("state poisoned").to_snapshot();
648            (screen, st)
649        };
650        sink.emit(crate::event::Event::TerminalFinalState {
651            handle: handle.clone(),
652            screen: final_screen,
653            state: final_state,
654        });
655    }
656
657    let _ = stream_tx.send(TermStreamEvent::Exited { exit_code });
658    if let Some(tx) = &tui_stream_tx {
659        let _ = tx.send(crate::stream::StreamFrame::TerminalExited {
660            handle,
661            tool_use_id,
662            exit_code,
663            call_intent,
664            run_id: flow_run_id,
665        });
666    }
667
668    profile.lock().expect("profile poisoned").take();
669    if let (Some(tr), Some(tid)) = (task_registry, task_id) {
670        tr.finish(&tid, task_status);
671    }
672}
673
674use std::io::Write;
675
676use std::path::Path;
677
678use crate::sandbox::PtySpawnResult;
679use crate::tool::{Tier, Tool};
680use crate::value::Value;
681
682fn extract_string(
683    args: &crate::tool::ToolArgs,
684    name: &str,
685    pos: usize,
686) -> Result<String, RuntimeError> {
687    if let Some(v) = args.named(name) {
688        if let Value::Str(s) = v {
689            return Ok(s.clone());
690        }
691        return Err(RuntimeError::ToolFailed(format!(
692            "term: arg {name} must be string"
693        )));
694    }
695    if let Ok(Value::Str(s)) = args.positional(pos) {
696        return Ok(s.clone());
697    }
698    Err(RuntimeError::MissingArg(format!("term: {name}")))
699}
700
701fn extract_optional_string(args: &crate::tool::ToolArgs, name: &str) -> Option<String> {
702    args.named(name).and_then(|v| {
703        if let Value::Str(s) = v {
704            Some(s.clone())
705        } else {
706            None
707        }
708    })
709}
710
711fn extract_optional_int(args: &crate::tool::ToolArgs, name: &str) -> Option<i64> {
712    args.named(name).and_then(|v| {
713        if let Value::Int(i) = v {
714            Some(*i)
715        } else {
716            None
717        }
718    })
719}
720
721pub struct TermSpawn;
722
723impl Tool for TermSpawn {
724    fn name(&self) -> &str {
725        "term.spawn"
726    }
727    fn tier(&self) -> Tier {
728        Tier::Four
729    }
730    fn description(&self) -> Option<&str> {
731        Some(
732            "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: \"...\")",
733        )
734    }
735    fn input_schema(&self) -> serde_json::Value {
736        serde_json::json!({
737            "type": "object",
738            "properties": {
739                "cmd": {"type": "string"},
740                "rows": {"type": "integer", "default": 24},
741                "cols": {"type": "integer", "default": 80},
742                "cwd": {"type": "string"},
743                "env": {"type": "object"}
744            }
745        })
746    }
747    fn invocation_provenance(
748        &self,
749        args: &crate::tool::ToolArgs,
750        ctx: &crate::tool::ToolCtx,
751    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
752        let explicit = extract_optional_string(args, "cwd").map(std::path::PathBuf::from);
753        Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
754            .with_cwd(ctx, explicit.as_deref())?
755            .with_risk(crate::trust::RiskKind::ProcessSpawn))
756    }
757
758    fn call<'a>(
759        &'a self,
760        args: crate::tool::ToolArgs,
761        ctx: &'a crate::tool::ToolCtx,
762    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
763        Box::pin(async move { spawn_impl(args, ctx).await })
764    }
765}
766
767async fn spawn_impl(
768    args: crate::tool::ToolArgs,
769    ctx: &crate::tool::ToolCtx,
770) -> crate::tool::ToolResult {
771    let cmd_str = extract_optional_string(&args, "cmd");
772    let rows = extract_optional_int(&args, "rows")
773        .map(|v| v as u16)
774        .unwrap_or(DEFAULT_ROWS)
775        .max(1);
776    let cols = extract_optional_int(&args, "cols")
777        .map(|v| v as u16)
778        .unwrap_or(DEFAULT_COLS)
779        .max(2);
780    let explicit_cwd = extract_optional_string(&args, "cwd").map(std::path::PathBuf::from);
781    let cwd = ctx.resolve_cwd(explicit_cwd.as_deref())?;
782    let env: Vec<(String, String)> = if let Some(Value::Struct(fields)) = args.named("env") {
783        fields
784            .iter()
785            .filter_map(|(k, v)| {
786                if let Value::Str(s) = v {
787                    Some((k.clone(), s.clone()))
788                } else {
789                    None
790                }
791            })
792            .collect()
793    } else {
794        Vec::new()
795    };
796
797    let registry = ctx
798        .term_registry
799        .clone()
800        .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: registry not available".into()))?;
801    let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
802    let session_dir = ctx
803        .session_dir
804        .clone()
805        .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: session_dir not available".into()))?;
806
807    let pty_size = portable_pty::PtySize {
808        rows,
809        cols,
810        pixel_width: 0,
811        pixel_height: 0,
812    };
813    let default_shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".into());
814    let command = cmd_str.clone().unwrap_or_else(|| default_shell.clone());
815    let cmd_args: Vec<&str> = if let Some(ref c) = cmd_str {
816        vec!["sh", "-c", c.as_str()]
817    } else {
818        vec![default_shell.as_str()]
819    };
820    let env_refs: Vec<(String, String)> = env.clone();
821
822    let authorization = ctx.invocation_authorization_for("term.spawn")?;
823    let pty_result = match authorization.execution_boundary() {
824        crate::permission::ExecutionBoundary::Sandboxed => {
825            let sandbox = ctx.sandbox.as_ref().ok_or_else(|| {
826                RuntimeError::ToolFailed(
827                    "term.spawn: sandbox unavailable for controlled execution".into(),
828                )
829            })?;
830            sandbox
831                .spawn_pty(&cmd_args, &env_refs, &cwd, pty_size, authorization)
832                .await
833                .map_err(|error| error.into_runtime("term.spawn"))?
834        }
835        crate::permission::ExecutionBoundary::Direct => {
836            spawn_pty_direct(&cmd_args, &env_refs, &cwd, pty_size)?
837        }
838    };
839
840    let (handle, entry) = registry.spawn_entry(
841        rows,
842        cols,
843        session_id,
844        session_dir,
845        pty_result,
846        ctx.stream_tx.clone(),
847        ctx.call_intent
848            .as_ref()
849            .map(|intent| intent.as_str().to_owned())
850            .or(cmd_str)
851            .unwrap_or_else(|| "terminal".into()),
852        command,
853        ctx.call_intent.clone(),
854        ctx.tool_use_id.clone(),
855        {
856            let tc = ctx.cancel.clone();
857            tc.child_token()
858        },
859        ctx.events.clone(),
860        ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
861    )?;
862
863    let state = entry.current_state();
864    let text = {
865        let parser = entry.parser.lock().expect("parser poisoned");
866        parser.screen().contents()
867    };
868    Ok(Value::Struct(vec![
869        ("handle".into(), Value::Str(handle.to_string())),
870        ("state".into(), state_to_value(&state)),
871        ("rows".into(), Value::Int(rows as i64)),
872        ("cols".into(), Value::Int(cols as i64)),
873        ("text".into(), Value::Str(text)),
874    ]))
875}
876
877fn spawn_pty_direct(
878    cmd: &[&str],
879    env: &[(String, String)],
880    cwd: &Path,
881    pty_size: portable_pty::PtySize,
882) -> Result<PtySpawnResult, RuntimeError> {
883    let pty_system = portable_pty::native_pty_system();
884    let pair = pty_system
885        .openpty(pty_size)
886        .map_err(|e| RuntimeError::ToolFailed(format!("openpty: {e}")))?;
887    let mut builder = portable_pty::CommandBuilder::new(cmd[0]);
888    for arg in &cmd[1..] {
889        builder.arg(arg);
890    }
891    builder.cwd(cwd);
892    for (k, v) in env {
893        builder.env(k, v);
894    }
895    let child = pair
896        .slave
897        .spawn_command(builder)
898        .map_err(|e| RuntimeError::ToolFailed(format!("pty spawn: {e}")))?;
899    crate::sandbox::complete_pty_spawn(child, pair.master, None)
900}
901
902fn cell_text(screen: &TerminalScreen, row: u16, col: u16) -> String {
903    let idx = (row as usize) * (screen.cols as usize) + (col as usize);
904    match screen.cells.get(idx) {
905        Some(cell) if cell.wide_continuation => String::new(),
906        Some(cell) if cell.chars.is_empty() => " ".to_string(),
907        Some(cell) => cell.chars.clone(),
908        None => " ".to_string(),
909    }
910}
911
912fn find_pattern_on_screen(screen: &TerminalScreen, pattern: &str) -> Option<(u16, u16)> {
913    for r in 0..screen.rows {
914        let row_text: String = (0..screen.cols)
915            .map(|c| cell_text(screen, r, c))
916            .collect::<String>()
917            .trim_end()
918            .to_string();
919        if let Some(pos) = row_text.find(pattern) {
920            return Some((r, pos as u16));
921        }
922    }
923    None
924}
925
926impl crate::watch::Watchable for TermEntry {
927    fn watch_output(
928        self: std::sync::Arc<Self>,
929        pattern: String,
930        cancel: tokio_util::sync::CancellationToken,
931    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::watch::WatchResult> + Send>>
932    {
933        let stream_tx = self.stream_tx.clone();
934        let state = self.state.clone();
935        let parser = self.parser.clone();
936        Box::pin(async move {
937            // Subscribe BEFORE checking state — closes the TOCTOU window where
938            // the terminal exits between the check and subscribe().
939            let mut rx = stream_tx.subscribe();
940            {
941                let st = state.lock().unwrap().clone();
942                if !st.is_running() {
943                    let p = parser.lock().unwrap();
944                    let screen = snapshot_screen(&p);
945                    if let Some((r, c)) = find_pattern_on_screen(&screen, &pattern) {
946                        return crate::watch::WatchResult::Matched {
947                            row: Some(r),
948                            col: Some(c),
949                            text: pattern,
950                        };
951                    }
952                    return crate::watch::WatchResult::SourceExited;
953                }
954            }
955            loop {
956                tokio::select! {
957                    _ = cancel.cancelled() => return crate::watch::WatchResult::Cancelled,
958                    result = rx.recv() => match result {
959                        Ok(crate::tools::term::TermStreamEvent::Chunk { screen, .. }) => {
960                            if let Some((r, c)) = find_pattern_on_screen(&screen, &pattern) {
961                                return crate::watch::WatchResult::Matched {
962                                    row: Some(r),
963                                    col: Some(c),
964                                    text: pattern,
965                                };
966                            }
967                        }
968                        _ => return crate::watch::WatchResult::SourceExited,
969                    }
970                }
971            }
972        })
973    }
974}
975
976fn screen_to_value(screen: &TerminalScreen) -> Value {
977    let cells: Vec<Value> = screen
978        .cells
979        .iter()
980        .map(|c| {
981            Value::Struct(vec![
982                ("chars".into(), Value::Str(c.chars.clone())),
983                ("fg".into(), color_to_value(c.fg)),
984                ("bg".into(), color_to_value(c.bg)),
985                ("bold".into(), Value::Bool(c.bold)),
986                ("italic".into(), Value::Bool(c.italic)),
987                ("underline".into(), Value::Bool(c.underline)),
988                ("inverse".into(), Value::Bool(c.inverse)),
989                ("dim".into(), Value::Bool(c.dim)),
990                ("wide".into(), Value::Bool(c.wide)),
991                ("wide_continuation".into(), Value::Bool(c.wide_continuation)),
992            ])
993        })
994        .collect();
995    Value::Struct(vec![
996        ("rows".into(), Value::Int(screen.rows as i64)),
997        ("cols".into(), Value::Int(screen.cols as i64)),
998        ("cells".into(), Value::List(cells)),
999        (
1000            "cursor".into(),
1001            match screen.cursor {
1002                Some((r, c)) => Value::Struct(vec![
1003                    ("row".into(), Value::Int(r as i64)),
1004                    ("col".into(), Value::Int(c as i64)),
1005                ]),
1006                None => Value::Unit,
1007            },
1008        ),
1009        ("alt_screen".into(), Value::Bool(screen.alt_screen)),
1010    ])
1011}
1012
1013fn color_to_value(c: TerminalColor) -> Value {
1014    match c {
1015        TerminalColor::Default => Value::Str("default".into()),
1016        TerminalColor::Idx(i) => Value::Int(i as i64),
1017        TerminalColor::Rgb(r, g, b) => Value::Struct(vec![
1018            ("r".into(), Value::Int(r as i64)),
1019            ("g".into(), Value::Int(g as i64)),
1020            ("b".into(), Value::Int(b as i64)),
1021        ]),
1022    }
1023}
1024
1025fn state_to_value(state: &TermState) -> Value {
1026    match state {
1027        TermState::Running { pid, started_at } => Value::Struct(vec![
1028            ("kind".into(), Value::Str("running".into())),
1029            ("pid".into(), Value::Int(*pid as i64)),
1030            ("started_at".into(), Value::Int(*started_at as i64)),
1031        ]),
1032        TermState::Exited {
1033            exit_code,
1034            ended_at,
1035        } => Value::Struct(vec![
1036            ("kind".into(), Value::Str("exited".into())),
1037            (
1038                "exit_code".into(),
1039                exit_code
1040                    .map(|c| Value::Int(c as i64))
1041                    .unwrap_or(Value::Unit),
1042            ),
1043            ("ended_at".into(), Value::Int(*ended_at as i64)),
1044        ]),
1045        TermState::Failed { error, ended_at } => Value::Struct(vec![
1046            ("kind".into(), Value::Str("failed".into())),
1047            ("error".into(), Value::Str(error.clone())),
1048            ("ended_at".into(), Value::Int(*ended_at as i64)),
1049        ]),
1050        TermState::Killed { ended_at } => Value::Struct(vec![
1051            ("kind".into(), Value::Str("killed".into())),
1052            ("ended_at".into(), Value::Int(*ended_at as i64)),
1053        ]),
1054    }
1055}
1056
1057pub struct TermInput;
1058impl Tool for TermInput {
1059    fn name(&self) -> &str {
1060        "term.input"
1061    }
1062    fn tier(&self) -> Tier {
1063        Tier::Four
1064    }
1065    fn description(&self) -> Option<&str> {
1066        Some(
1067            "Send input to a terminal's PTY. Use `text` for literal text, `key` for\nspecial keys (enter, tab, esc, backspace, up, down, left, right, ctrl+c,\nctrl+d, ctrl+z), or `mouse` for mouse events. Use key: \"enter\" to\nsubmit a command, not text: \"\\r\". Mouse actions: click, double_click,\nlong_press, drag, press, release, move, scroll_up, scroll_down.\nUse term.find to locate text on screen before clicking.",
1068        )
1069    }
1070    fn input_schema(&self) -> serde_json::Value {
1071        serde_json::json!({
1072            "type": "object",
1073            "properties": {
1074                "handle": {"type": "string"},
1075                "text": {"type": "string", "description": "Literal text to write. Do NOT use \\r or \\n here — use key:\"enter\" instead."},
1076                "key": {"type": "string", "enum": ["enter", "tab", "esc", "backspace", "up", "down", "left", "right", "ctrl+c", "ctrl+d", "ctrl+z"]},
1077                "mouse": {
1078                    "type": "object",
1079                    "properties": {
1080                        "action": {
1081                            "type": "string",
1082                            "enum": ["click", "double_click", "long_press", "drag", "press", "release", "move", "scroll_up", "scroll_down"],
1083                            "description": "click=press+release, double_click=two clicks, long_press=press+500ms+release, drag=press+move+release (needs x2,y2), move=hover, scroll_up/down=wheel"
1084                        },
1085                        "button": {"type": "string", "enum": ["left", "right", "middle"], "default": "left"},
1086                        "x": {"type": "integer", "description": "Column (0-indexed, same as term.find col)"},
1087                        "y": {"type": "integer", "description": "Row (0-indexed, same as term.find row)"},
1088                        "x2": {"type": "integer", "description": "End column for drag"},
1089                        "y2": {"type": "integer", "description": "End row for drag"}
1090                    },
1091                    "required": ["action", "x", "y"],
1092                    "description": "Mouse event. Coordinates are 0-indexed — pass term.find (col,row) directly."
1093                }
1094            },
1095            "required": ["handle"]
1096        })
1097    }
1098    fn call<'a>(
1099        &'a self,
1100        args: crate::tool::ToolArgs,
1101        ctx: &'a crate::tool::ToolCtx,
1102    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1103        Box::pin(async move {
1104            let handle = extract_string(&args, "handle", 0)?;
1105            let text = extract_optional_string(&args, "text").unwrap_or_default();
1106            let key = extract_optional_string(&args, "key");
1107            let mouse = args.named("mouse");
1108            let registry = ctx.term_registry.clone().ok_or_else(|| {
1109                RuntimeError::ToolFailed("term.input: registry not available".into())
1110            })?;
1111            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1112            let entry = registry.lookup(&handle, &session_id)?;
1113
1114            // Build steps: (bytes, delay_after).
1115            let mut steps: Vec<(Vec<u8>, std::time::Duration)> = Vec::new();
1116            let mut first = text.into_bytes();
1117            if let Some(k) = &key {
1118                first.extend_from_slice(&key_to_bytes(k));
1119            }
1120            if !first.is_empty() {
1121                steps.push((first, std::time::Duration::ZERO));
1122            }
1123            if let Some(m) = &mouse {
1124                steps.extend(mouse_to_steps(m)?);
1125            }
1126            if steps.is_empty() {
1127                return Err(RuntimeError::ToolFailed(
1128                    "term.input: provide at least one of `text`, `key`, or `mouse`".into(),
1129                ));
1130            }
1131
1132            let mut total = 0usize;
1133            for (payload, delay) in steps {
1134                if !payload.is_empty() {
1135                    let mut w = entry.writer.lock().expect("writer poisoned");
1136                    w.write_all(&payload)
1137                        .map_err(|e| RuntimeError::ToolFailed(format!("term.input write: {e}")))?;
1138                    total += payload.len();
1139                    drop(w);
1140                }
1141                if !delay.is_zero() {
1142                    tokio::time::sleep(delay).await;
1143                }
1144            }
1145            Ok(Value::Struct(vec![
1146                ("ok".into(), Value::Bool(true)),
1147                ("bytes_written".into(), Value::Int(total as i64)),
1148            ]))
1149        })
1150    }
1151}
1152
1153fn key_to_bytes(key: &str) -> Vec<u8> {
1154    match key {
1155        "enter" => vec![b'\r'],
1156        "tab" => vec![b'\t'],
1157        "esc" => vec![0x1b],
1158        "backspace" => vec![0x7f],
1159        "up" => vec![0x1b, b'[', b'A'],
1160        "down" => vec![0x1b, b'[', b'B'],
1161        "right" => vec![0x1b, b'[', b'C'],
1162        "left" => vec![0x1b, b'[', b'D'],
1163        "ctrl+c" => vec![0x03],
1164        "ctrl+d" => vec![0x04],
1165        "ctrl+z" => vec![0x1a],
1166        _ => Vec::new(),
1167    }
1168}
1169
1170/// SGR mouse bytes. Input coords are 0-indexed (term.find convention);
1171/// SGR protocol is 1-indexed so we +1 internally.
1172fn sgr(btn: u32, action: &str, col0: i64, row0: i64) -> Vec<u8> {
1173    let x = col0 + 1;
1174    let y = row0 + 1;
1175    match action {
1176        "press" => format!("\x1b[<{btn};{x};{y}M").into_bytes(),
1177        "release" => format!("\x1b[<{btn};{x};{y}m").into_bytes(),
1178        "move" => format!("\x1b[<{btn};{x};{y}M", btn = btn + 32).into_bytes(),
1179        _ => Vec::new(),
1180    }
1181}
1182
1183/// Build (bytes, delay) steps from a mouse parameter.
1184/// Coords are 0-indexed — pass term.find (col, row) directly as (x, y).
1185fn mouse_to_steps(val: &Value) -> Result<Vec<(Vec<u8>, std::time::Duration)>, RuntimeError> {
1186    let fields = match val {
1187        Value::Struct(f) => f,
1188        _ => {
1189            return Err(RuntimeError::ToolFailed(
1190                "term.input: mouse must be an object".into(),
1191            ));
1192        }
1193    };
1194    let get_str = |name: &str| -> Result<&str, RuntimeError> {
1195        fields
1196            .iter()
1197            .find(|(k, _)| k == name)
1198            .and_then(|(_, v)| {
1199                if let Value::Str(s) = v {
1200                    Some(s.as_str())
1201                } else {
1202                    None
1203                }
1204            })
1205            .ok_or_else(|| RuntimeError::ToolFailed(format!("term.input: mouse.{name} missing")))
1206    };
1207    let get_int = |name: &str| -> Result<i64, RuntimeError> {
1208        fields
1209            .iter()
1210            .find(|(k, _)| k == name)
1211            .and_then(|(_, v)| {
1212                if let Value::Int(i) = v {
1213                    Some(*i)
1214                } else {
1215                    None
1216                }
1217            })
1218            .ok_or_else(|| RuntimeError::ToolFailed(format!("term.input: mouse.{name} missing")))
1219    };
1220    let get_opt_int = |name: &str| -> Option<i64> {
1221        fields.iter().find(|(k, _)| k == name).and_then(|(_, v)| {
1222            if let Value::Int(i) = v {
1223                Some(*i)
1224            } else {
1225                None
1226            }
1227        })
1228    };
1229
1230    let action = get_str("action")?;
1231    let button_str = get_opt_str(fields, "button").unwrap_or("left");
1232    let x = get_int("x")?;
1233    let y = get_int("y")?;
1234    let btn: u32 = match button_str {
1235        "left" => 0,
1236        "middle" => 1,
1237        "right" => 2,
1238        _ => {
1239            return Err(RuntimeError::ToolFailed(format!(
1240                "term.input: mouse.button must be left/right/middle, got {button_str}"
1241            )));
1242        }
1243    };
1244
1245    let z = std::time::Duration::ZERO;
1246    let gap = std::time::Duration::from_millis(50);
1247    let long = std::time::Duration::from_millis(500);
1248
1249    match action {
1250        "press" => Ok(vec![(sgr(btn, "press", x, y), z)]),
1251        "release" => Ok(vec![(sgr(btn, "release", x, y), z)]),
1252        "click" => Ok(vec![
1253            (sgr(btn, "press", x, y), z),
1254            (sgr(btn, "release", x, y), z),
1255        ]),
1256        "double_click" => Ok(vec![
1257            (sgr(btn, "press", x, y), z),
1258            (sgr(btn, "release", x, y), gap),
1259            (sgr(btn, "press", x, y), z),
1260            (sgr(btn, "release", x, y), z),
1261        ]),
1262        "long_press" => Ok(vec![
1263            (sgr(btn, "press", x, y), long),
1264            (sgr(btn, "release", x, y), z),
1265        ]),
1266        "drag" => {
1267            let x2 = get_opt_int("x2").ok_or_else(|| {
1268                RuntimeError::ToolFailed("term.input: mouse.drag requires x2".into())
1269            })?;
1270            let y2 = get_opt_int("y2").ok_or_else(|| {
1271                RuntimeError::ToolFailed("term.input: mouse.drag requires y2".into())
1272            })?;
1273            let mut steps = vec![(sgr(btn, "press", x, y), z)];
1274            let dx = x2 - x;
1275            let dy = y2 - y;
1276            let n = dx.unsigned_abs().max(dy.unsigned_abs());
1277            for i in 1..=n {
1278                let cx = x + dx * i as i64 / n as i64;
1279                let cy = y + dy * i as i64 / n as i64;
1280                steps.push((sgr(btn, "move", cx, cy), z));
1281            }
1282            steps.push((sgr(btn, "release", x2, y2), z));
1283            Ok(steps)
1284        }
1285        "move" => Ok(vec![(sgr(0, "move", x, y), z)]),
1286        "scroll_up" => Ok(vec![(
1287            format!("\x1b[<64;{};{}M", x + 1, y + 1).into_bytes(),
1288            z,
1289        )]),
1290        "scroll_down" => Ok(vec![(
1291            format!("\x1b[<65;{};{}M", x + 1, y + 1).into_bytes(),
1292            z,
1293        )]),
1294        _ => Err(RuntimeError::ToolFailed(format!(
1295            "term.input: mouse.action must be click/double_click/long_press/drag/press/release/move/scroll_up/scroll_down, got {action}"
1296        ))),
1297    }
1298}
1299
1300fn get_opt_str<'a>(fields: &'a [(String, Value)], name: &'a str) -> Option<&'a str> {
1301    fields.iter().find(|(k, _)| k == name).and_then(|(_, v)| {
1302        if let Value::Str(s) = v {
1303            Some(s.as_str())
1304        } else {
1305            None
1306        }
1307    })
1308}
1309
1310pub struct TermCapture;
1311impl Tool for TermCapture {
1312    fn name(&self) -> &str {
1313        "term.capture"
1314    }
1315    fn tier(&self) -> Tier {
1316        Tier::Four
1317    }
1318    fn description(&self) -> Option<&str> {
1319        Some(
1320            "Read the terminal screen. Default returns plain text (format: \"text\").\n\
1321             Use start_row/end_row and start_col/end_col to read a rectangular region.\n\n\
1322             Best practices:\n\
1323             - After sending a command, capture to see the result.\n\
1324             - Use start_row/end_row to read only the relevant part (e.g. last 10 rows).\n\
1325             - Use start_col/end_col to read a column range (e.g. skip line numbers).\n\
1326             - format: \"screen\" returns full cell data with colors/styles.\n\
1327             - Default format: \"text\" is sufficient for most cases.",
1328        )
1329    }
1330    fn input_schema(&self) -> serde_json::Value {
1331        serde_json::json!({
1332            "type": "object",
1333            "properties": {
1334                "handle": {"type": "string"},
1335                "format": {"type": "string", "enum": ["text", "screen"], "default": "text"},
1336                "start_row": {"type": "integer", "default": 0, "description": "Start row (0-based). Default 0."},
1337                "end_row": {"type": "integer", "description": "End row (exclusive). Default: full height."},
1338                "start_col": {"type": "integer", "default": 0, "description": "Start column (0-based). Default 0."},
1339                "end_col": {"type": "integer", "description": "End column (exclusive). Default: full width."}
1340            },
1341            "required": ["handle"]
1342        })
1343    }
1344    fn call<'a>(
1345        &'a self,
1346        args: crate::tool::ToolArgs,
1347        ctx: &'a crate::tool::ToolCtx,
1348    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1349        Box::pin(async move {
1350            let handle = extract_string(&args, "handle", 0)?;
1351            let format = extract_optional_string(&args, "format").unwrap_or_else(|| "text".into());
1352            let registry = ctx.term_registry.clone().ok_or_else(|| {
1353                RuntimeError::ToolFailed("term.capture: registry not available".into())
1354            })?;
1355            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1356            let entry = registry.lookup(&handle, &session_id)?;
1357            let screen = entry.snapshot();
1358            let start_row = extract_optional_int(&args, "start_row")
1359                .unwrap_or(0)
1360                .clamp(0, screen.rows as i64) as u16;
1361            let requested_end_row = extract_optional_int(&args, "end_row")
1362                .unwrap_or(screen.rows as i64)
1363                .clamp(start_row as i64, screen.rows as i64)
1364                as u16;
1365            let start_col = extract_optional_int(&args, "start_col")
1366                .unwrap_or(0)
1367                .clamp(0, screen.cols as i64) as u16;
1368            let end_col = extract_optional_int(&args, "end_col")
1369                .unwrap_or(screen.cols as i64)
1370                .clamp(start_col as i64, screen.cols as i64) as u16;
1371            let width = usize::from(end_col.saturating_sub(start_col));
1372            let max_rows_by_bytes = ctx
1373                .tool_output_budget
1374                .max_bytes
1375                .checked_div(width)
1376                .unwrap_or(ctx.tool_output_budget.max_lines);
1377            let max_rows = ctx
1378                .tool_output_budget
1379                .max_lines
1380                .min(max_rows_by_bytes.max(1))
1381                .min(u16::MAX as usize) as u16;
1382            let end_row = if format == "screen" {
1383                requested_end_row.min(start_row.saturating_add(max_rows))
1384            } else {
1385                requested_end_row
1386            };
1387            let state = entry.current_state();
1388            let mut fields = vec![
1389                ("handle".into(), Value::Str(handle.clone())),
1390                ("state".into(), state_to_value(&state)),
1391                ("rows".into(), Value::Int((end_row - start_row) as i64)),
1392                ("cols".into(), Value::Int((end_col - start_col) as i64)),
1393            ];
1394            if format == "screen" {
1395                fields.push((
1396                    "continuation".into(),
1397                    Value::Struct(vec![
1398                        ("type".into(), Value::Str("TerminalArea".into())),
1399                        ("next_row".into(), Value::Int(end_row as i64)),
1400                        ("start_col".into(), Value::Int(start_col as i64)),
1401                        ("end_col".into(), Value::Int(end_col as i64)),
1402                        ("has_more".into(), Value::Bool(end_row < screen.rows)),
1403                    ]),
1404                ));
1405                let sub_rows = end_row - start_row;
1406                let sub_cols = end_col - start_col;
1407                let sub_cells: Vec<TerminalCell> = (start_row..end_row)
1408                    .flat_map(|r| {
1409                        (start_col..end_col)
1410                            .map(|c| {
1411                                screen
1412                                    .cells
1413                                    .get((r as usize) * (screen.cols as usize) + (c as usize))
1414                                    .cloned()
1415                                    .unwrap_or_default()
1416                            })
1417                            .collect::<Vec<_>>()
1418                    })
1419                    .collect();
1420                let cursor = screen.cursor.and_then(|(row, col)| {
1421                    if (start_row..end_row).contains(&row) && (start_col..end_col).contains(&col) {
1422                        Some((row - start_row, col - start_col))
1423                    } else {
1424                        None
1425                    }
1426                });
1427                let partial_screen = TerminalScreen {
1428                    rows: sub_rows,
1429                    cols: sub_cols,
1430                    cells: sub_cells,
1431                    cursor,
1432                    alt_screen: screen.alt_screen,
1433                };
1434                fields.push(("screen".into(), screen_to_value(&partial_screen)));
1435            } else {
1436                let text = (start_row..end_row)
1437                    .map(|r| {
1438                        let row_text: String = (start_col..end_col)
1439                            .map(|c| cell_text(&screen, r, c))
1440                            .collect();
1441                        row_text.trim_end().to_string()
1442                    })
1443                    .collect::<Vec<_>>()
1444                    .join("\n");
1445                let cut =
1446                    crate::tools::tool_output::bounded_text_prefix(&text, ctx.tool_output_budget);
1447                if cut == text.len() {
1448                    fields.push(("text".into(), Value::Str(text)));
1449                } else {
1450                    let output_id = ctx
1451                        .output_store
1452                        .as_deref()
1453                        .and_then(|store| store.register("term_capture", &text))
1454                        .ok_or_else(|| {
1455                            RuntimeError::ToolFailed(
1456                                "term.capture: output exceeded budget, but no output_id is available for output.read".into(),
1457                            )
1458                        })?;
1459                    let total_lines = text.split_inclusive('\n').count();
1460                    fields.push(("content".into(), Value::Str(text[..cut].to_string())));
1461                    fields.push(("output_id".into(), Value::Str(output_id)));
1462                    fields.push(("total_lines".into(), Value::Int(total_lines as i64)));
1463                    fields.push(("total_bytes".into(), Value::Int(text.len() as i64)));
1464                    fields.push((
1465                        "next".into(),
1466                        Value::Struct(vec![
1467                            ("mode".into(), Value::Str("bytes".into())),
1468                            ("offset".into(), Value::Int(cut as i64)),
1469                            ("has_more".into(), Value::Bool(cut < text.len())),
1470                        ]),
1471                    ));
1472                }
1473            }
1474            Ok(Value::Struct(fields))
1475        })
1476    }
1477}
1478
1479pub struct TermFind;
1480impl Tool for TermFind {
1481    fn name(&self) -> &str {
1482        "term.find"
1483    }
1484    fn tier(&self) -> Tier {
1485        Tier::Four
1486    }
1487    fn description(&self) -> Option<&str> {
1488        Some(
1489            "Search terminal screen for text and/or style. Returns list of matches.\n\n\
1490             - pattern: substring to search for (case-sensitive). Omit for style-only search.\n\
1491             - style: optional filter. Only cells matching ALL specified style fields count.\n\
1492             - Combine both: find red 'error' text, bold prompts, etc.\n\n\
1493             Style fields: bold, italic, underline, inverse, dim, fg, bg.\n\
1494             Colors: name (\"red\") or {r,g,b} struct (discover via term.capture format=\"screen\").\n\n\
1495             Returns: { matches: [{row, col, text}], count: N }",
1496        )
1497    }
1498    fn input_schema(&self) -> serde_json::Value {
1499        serde_json::json!({
1500            "type": "object",
1501            "properties": {
1502                "handle": {"type": "string"},
1503                "pattern": {"type": "string", "description": "Substring to search for. Omit for style-only search."},
1504                "style": {
1505                    "type": "object",
1506                    "properties": {
1507                        "bold": {"type": "boolean"},
1508                        "italic": {"type": "boolean"},
1509                        "underline": {"type": "boolean"},
1510                        "inverse": {"type": "boolean"},
1511                        "dim": {"type": "boolean"},
1512                        "fg": {"type": "string", "description": "Color name or {r,g,b} struct"},
1513                        "bg": {"type": "string", "description": "Color name or {r,g,b} struct"}
1514                    },
1515                    "description": "Optional style filter. All specified fields must match."
1516                },
1517                "start_row": {"type": "integer", "default": 0},
1518                "end_row": {"type": "integer", "description": "Exclusive. Default: full height."}
1519            },
1520            "required": ["handle"]
1521        })
1522    }
1523    fn call<'a>(
1524        &'a self,
1525        args: crate::tool::ToolArgs,
1526        ctx: &'a crate::tool::ToolCtx,
1527    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1528        Box::pin(async move {
1529            let handle = extract_string(&args, "handle", 0)?;
1530            let pattern = extract_optional_string(&args, "pattern");
1531            let style_filter = parse_style_filter(&args)?;
1532            if pattern.is_none() && style_filter.is_none() {
1533                return Err(RuntimeError::ToolFailed(
1534                    "term.find: provide at least pattern or style".into(),
1535                ));
1536            }
1537            let registry = ctx.term_registry.clone().ok_or_else(|| {
1538                RuntimeError::ToolFailed("term.find: registry not available".into())
1539            })?;
1540            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1541            let entry = registry.lookup(&handle, &session_id)?;
1542            let screen = entry.snapshot();
1543            let start_row = extract_optional_int(&args, "start_row")
1544                .unwrap_or(0)
1545                .clamp(0, screen.rows as i64) as u16;
1546            let end_row = extract_optional_int(&args, "end_row")
1547                .unwrap_or(screen.rows as i64)
1548                .clamp(start_row as i64, screen.rows as i64) as u16;
1549
1550            let mut matches = Vec::new();
1551            for r in start_row..end_row {
1552                let row_cells: Vec<&TerminalCell> =
1553                    (0..screen.cols).map(|c| cell_ref(&screen, r, c)).collect();
1554                let row_text: String = row_cells
1555                    .iter()
1556                    .map(|c| {
1557                        if c.wide_continuation {
1558                            ""
1559                        } else {
1560                            c.chars.as_str()
1561                        }
1562                    })
1563                    .collect::<String>();
1564                let row_text_trimmed = row_text.trim_end();
1565
1566                if let Some(ref pat) = pattern {
1567                    let mut start = 0;
1568                    while let Some(pos) = row_text_trimmed[start..].find(pat) {
1569                        let abs_col = start + pos;
1570                        let style_ok = style_filter
1571                            .as_ref()
1572                            .map(|sf| sf.matches(row_cells.get(abs_col).copied()))
1573                            .unwrap_or(true);
1574                        if style_ok {
1575                            let matched_text = pat.clone();
1576                            matches.push(Value::Struct(vec![
1577                                ("row".into(), Value::Int(r as i64)),
1578                                ("col".into(), Value::Int(abs_col as i64)),
1579                                ("text".into(), Value::Str(matched_text)),
1580                            ]));
1581                        }
1582                        start += pos + pat.len();
1583                        if start >= row_text_trimmed.len() {
1584                            break;
1585                        }
1586                    }
1587                } else if let Some(ref sf) = style_filter {
1588                    let mut col = 0usize;
1589                    while col < screen.cols as usize {
1590                        if sf.matches(row_cells.get(col).copied())
1591                            && !row_cells[col].chars.is_empty()
1592                        {
1593                            let start_col = col;
1594                            let mut text = String::new();
1595                            while col < screen.cols as usize
1596                                && sf.matches(row_cells.get(col).copied())
1597                                && !row_cells[col].wide_continuation
1598                            {
1599                                text.push_str(&row_cells[col].chars);
1600                                col += 1;
1601                            }
1602                            if !text.trim().is_empty() {
1603                                matches.push(Value::Struct(vec![
1604                                    ("row".into(), Value::Int(r as i64)),
1605                                    ("col".into(), Value::Int(start_col as i64)),
1606                                    ("text".into(), Value::Str(text)),
1607                                ]));
1608                            }
1609                        } else {
1610                            col += 1;
1611                        }
1612                    }
1613                }
1614            }
1615            let count = matches.len() as i64;
1616            Ok(Value::Struct(vec![
1617                ("matches".into(), Value::List(matches)),
1618                ("count".into(), Value::Int(count)),
1619            ]))
1620        })
1621    }
1622}
1623
1624fn cell_ref(screen: &TerminalScreen, row: u16, col: u16) -> &TerminalCell {
1625    let idx = (row as usize) * (screen.cols as usize) + (col as usize);
1626    screen.cells.get(idx).unwrap_or(&DEFAULT_CELL)
1627}
1628
1629static DEFAULT_CELL: TerminalCell = TerminalCell {
1630    chars: String::new(),
1631    fg: TerminalColor::Default,
1632    bg: TerminalColor::Default,
1633    bold: false,
1634    italic: false,
1635    underline: false,
1636    inverse: false,
1637    dim: false,
1638    wide: false,
1639    wide_continuation: false,
1640};
1641
1642struct StyleFilter {
1643    bold: Option<bool>,
1644    italic: Option<bool>,
1645    underline: Option<bool>,
1646    inverse: Option<bool>,
1647    dim: Option<bool>,
1648    fg: Option<TerminalColor>,
1649    bg: Option<TerminalColor>,
1650}
1651
1652impl StyleFilter {
1653    fn matches(&self, cell: Option<&TerminalCell>) -> bool {
1654        let Some(cell) = cell else {
1655            return false;
1656        };
1657        if self.bold == Some(true) && !cell.bold {
1658            return false;
1659        }
1660        if self.bold == Some(false) && cell.bold {
1661            return false;
1662        }
1663        if self.italic == Some(true) && !cell.italic {
1664            return false;
1665        }
1666        if self.italic == Some(false) && cell.italic {
1667            return false;
1668        }
1669        if self.underline == Some(true) && !cell.underline {
1670            return false;
1671        }
1672        if self.underline == Some(false) && cell.underline {
1673            return false;
1674        }
1675        if self.inverse == Some(true) && !cell.inverse {
1676            return false;
1677        }
1678        if self.inverse == Some(false) && cell.inverse {
1679            return false;
1680        }
1681        if self.dim == Some(true) && !cell.dim {
1682            return false;
1683        }
1684        if self.dim == Some(false) && cell.dim {
1685            return false;
1686        }
1687        if let Some(fg) = &self.fg {
1688            if !color_matches(fg, &cell.fg) {
1689                return false;
1690            }
1691        }
1692        if let Some(bg) = &self.bg {
1693            if !color_matches(bg, &cell.bg) {
1694                return false;
1695            }
1696        }
1697        true
1698    }
1699}
1700
1701fn parse_style_filter(args: &crate::tool::ToolArgs) -> Result<Option<StyleFilter>, RuntimeError> {
1702    let style = match args.named("style") {
1703        Some(v) => v,
1704        None => return Ok(None),
1705    };
1706    Ok(Some(StyleFilter {
1707        bold: get_optional_bool(style, "bold"),
1708        italic: get_optional_bool(style, "italic"),
1709        underline: get_optional_bool(style, "underline"),
1710        inverse: get_optional_bool(style, "inverse"),
1711        dim: get_optional_bool(style, "dim"),
1712        fg: get_optional_color(style, "fg")?,
1713        bg: get_optional_color(style, "bg")?,
1714    }))
1715}
1716
1717fn get_optional_bool(style: &Value, field: &str) -> Option<bool> {
1718    match style.field(field) {
1719        Some(Value::Bool(b)) => Some(*b),
1720        _ => None,
1721    }
1722}
1723
1724fn get_optional_color(style: &Value, field: &str) -> Result<Option<TerminalColor>, RuntimeError> {
1725    match style.field(field) {
1726        Some(v) => parse_color(v).map(Some).ok_or_else(|| {
1727            RuntimeError::ToolFailed(format!(
1728                "term.find: invalid color for '{field}' — use name (\"red\") or {{r,g,b}} struct"
1729            ))
1730        }),
1731        None => Ok(None),
1732    }
1733}
1734
1735fn parse_color(val: &Value) -> Option<TerminalColor> {
1736    match val {
1737        Value::Str(name) => color_name_to_terminal(name),
1738        Value::Struct(fields) => {
1739            let r = fields
1740                .iter()
1741                .find(|(k, _)| k == "r")
1742                .and_then(|(_, v)| match v {
1743                    Value::Int(n) => Some(*n),
1744                    _ => None,
1745                })?;
1746            let g = fields
1747                .iter()
1748                .find(|(k, _)| k == "g")
1749                .and_then(|(_, v)| match v {
1750                    Value::Int(n) => Some(*n),
1751                    _ => None,
1752                })?;
1753            let b = fields
1754                .iter()
1755                .find(|(k, _)| k == "b")
1756                .and_then(|(_, v)| match v {
1757                    Value::Int(n) => Some(*n),
1758                    _ => None,
1759                })?;
1760            Some(TerminalColor::Rgb(r as u8, g as u8, b as u8))
1761        }
1762        _ => None,
1763    }
1764}
1765
1766fn color_name_to_terminal(name: &str) -> Option<TerminalColor> {
1767    let idx = match name {
1768        "default" => return Some(TerminalColor::Default),
1769        "black" => 0,
1770        "red" => 1,
1771        "green" => 2,
1772        "yellow" => 3,
1773        "blue" => 4,
1774        "magenta" => 5,
1775        "cyan" => 6,
1776        "white" => 7,
1777        _ => return None,
1778    };
1779    Some(TerminalColor::Idx(idx))
1780}
1781
1782fn color_matches(desired: &TerminalColor, actual: &TerminalColor) -> bool {
1783    match (desired, actual) {
1784        (TerminalColor::Default, TerminalColor::Default) => true,
1785        (TerminalColor::Idx(d), TerminalColor::Idx(a)) => *d == *a || (*d < 8 && *a == *d + 8),
1786        (TerminalColor::Rgb(dr, dg, db), TerminalColor::Rgb(ar, ag, ab)) => {
1787            dr == ar && dg == ag && db == ab
1788        }
1789        _ => false,
1790    }
1791}
1792
1793pub struct TermResize;
1794impl Tool for TermResize {
1795    fn name(&self) -> &str {
1796        "term.resize"
1797    }
1798    fn tier(&self) -> Tier {
1799        Tier::Four
1800    }
1801    fn description(&self) -> Option<&str> {
1802        Some("Resize a terminal's PTY dimensions. Sends SIGWINCH to the child process.")
1803    }
1804    fn input_schema(&self) -> serde_json::Value {
1805        serde_json::json!({
1806            "type": "object",
1807            "properties": {
1808                "handle": {"type": "string"},
1809                "rows": {"type": "integer"},
1810                "cols": {"type": "integer"}
1811            },
1812            "required": ["handle", "rows", "cols"]
1813        })
1814    }
1815    fn call<'a>(
1816        &'a self,
1817        args: crate::tool::ToolArgs,
1818        ctx: &'a crate::tool::ToolCtx,
1819    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1820        Box::pin(async move {
1821            let handle = extract_string(&args, "handle", 0)?;
1822            let rows = extract_optional_int(&args, "rows")
1823                .ok_or_else(|| RuntimeError::MissingArg("rows".into()))?
1824                as u16;
1825            let cols = extract_optional_int(&args, "cols")
1826                .ok_or_else(|| RuntimeError::MissingArg("cols".into()))?
1827                as u16;
1828            let registry = ctx.term_registry.clone().ok_or_else(|| {
1829                RuntimeError::ToolFailed("term.resize: registry not available".into())
1830            })?;
1831            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1832            let entry = registry.lookup(&handle, &session_id)?;
1833            entry.resize(rows, cols)?;
1834            Ok(Value::Struct(vec![
1835                ("ok".into(), Value::Bool(true)),
1836                ("rows".into(), Value::Int(rows as i64)),
1837                ("cols".into(), Value::Int(cols as i64)),
1838            ]))
1839        })
1840    }
1841}
1842
1843pub struct TermKill;
1844impl Tool for TermKill {
1845    fn name(&self) -> &str {
1846        "term.kill"
1847    }
1848    fn tier(&self) -> Tier {
1849        Tier::Four
1850    }
1851    fn description(&self) -> Option<&str> {
1852        Some("Kill a terminal process. The terminal handle remains in the registry for history.")
1853    }
1854    fn input_schema(&self) -> serde_json::Value {
1855        serde_json::json!({
1856            "type": "object",
1857            "properties": {"handle": {"type": "string"}},
1858            "required": ["handle"]
1859        })
1860    }
1861    fn call<'a>(
1862        &'a self,
1863        args: crate::tool::ToolArgs,
1864        ctx: &'a crate::tool::ToolCtx,
1865    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1866        Box::pin(async move {
1867            let handle = extract_string(&args, "handle", 0)?;
1868            let registry = ctx.term_registry.clone().ok_or_else(|| {
1869                RuntimeError::ToolFailed("term.kill: registry not available".into())
1870            })?;
1871            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1872            let entry = registry.lookup(&handle, &session_id)?;
1873            entry.stop();
1874            Ok(Value::Struct(vec![
1875                ("ok".into(), Value::Bool(true)),
1876                ("state".into(), Value::Str("killed".into())),
1877            ]))
1878        })
1879    }
1880}
1881
1882pub struct TermList;
1883impl Tool for TermList {
1884    fn name(&self) -> &str {
1885        "term.list"
1886    }
1887    fn tier(&self) -> Tier {
1888        Tier::Four
1889    }
1890    fn description(&self) -> Option<&str> {
1891        Some("List all terminal handles in the current session.")
1892    }
1893    fn input_schema(&self) -> serde_json::Value {
1894        serde_json::json!({
1895            "type": "object",
1896            "properties": {"all": {"type": "boolean", "default": false}}
1897        })
1898    }
1899    fn call<'a>(
1900        &'a self,
1901        args: crate::tool::ToolArgs,
1902        ctx: &'a crate::tool::ToolCtx,
1903    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1904        Box::pin(async move {
1905            let all = args
1906                .named("all")
1907                .and_then(|v| {
1908                    if let Value::Bool(b) = v {
1909                        Some(*b)
1910                    } else {
1911                        None
1912                    }
1913                })
1914                .unwrap_or(false);
1915            let registry = ctx.term_registry.clone().ok_or_else(|| {
1916                RuntimeError::ToolFailed("term.list: registry not available".into())
1917            })?;
1918            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1919            let _ = all;
1920            let list = registry.list(&session_id);
1921            let entries: Vec<Value> = list
1922                .iter()
1923                .map(|(h, st)| {
1924                    Value::Struct(vec![
1925                        ("handle".into(), Value::Str(h.clone())),
1926                        ("state".into(), state_to_value(st)),
1927                    ])
1928                })
1929                .collect();
1930            Ok(Value::Struct(vec![(
1931                "terminals".into(),
1932                Value::List(entries),
1933            )]))
1934        })
1935    }
1936}
1937
1938#[cfg(test)]
1939mod tests {
1940    use super::*;
1941    use crate::tool::{ToolArgs, ToolCtx};
1942    use std::sync::atomic::{AtomicUsize, Ordering};
1943
1944    #[derive(Debug)]
1945    struct RecordingChild {
1946        kill_calls: Arc<AtomicUsize>,
1947        wait_calls: Arc<AtomicUsize>,
1948    }
1949
1950    #[derive(Debug)]
1951    struct RecordingKiller(Arc<AtomicUsize>);
1952
1953    impl portable_pty::ChildKiller for RecordingKiller {
1954        fn kill(&mut self) -> std::io::Result<()> {
1955            self.0.fetch_add(1, Ordering::SeqCst);
1956            Ok(())
1957        }
1958
1959        fn clone_killer(&self) -> Box<dyn portable_pty::ChildKiller + Send + Sync> {
1960            Box::new(Self(self.0.clone()))
1961        }
1962    }
1963
1964    impl portable_pty::ChildKiller for RecordingChild {
1965        fn kill(&mut self) -> std::io::Result<()> {
1966            self.kill_calls.fetch_add(1, Ordering::SeqCst);
1967            Ok(())
1968        }
1969
1970        fn clone_killer(&self) -> Box<dyn portable_pty::ChildKiller + Send + Sync> {
1971            Box::new(RecordingKiller(self.kill_calls.clone()))
1972        }
1973    }
1974
1975    impl portable_pty::Child for RecordingChild {
1976        fn try_wait(&mut self) -> std::io::Result<Option<portable_pty::ExitStatus>> {
1977            Ok(None)
1978        }
1979
1980        fn wait(&mut self) -> std::io::Result<portable_pty::ExitStatus> {
1981            self.wait_calls.fetch_add(1, Ordering::SeqCst);
1982            Ok(portable_pty::ExitStatus::with_exit_code(0))
1983        }
1984
1985        fn process_id(&self) -> Option<u32> {
1986            Some(42)
1987        }
1988    }
1989
1990    #[derive(Clone, Copy)]
1991    enum PtyIoFailure {
1992        Reader,
1993        Writer,
1994        Never,
1995    }
1996
1997    struct FailingMaster(PtyIoFailure);
1998
1999    impl portable_pty::MasterPty for FailingMaster {
2000        fn resize(&self, _size: portable_pty::PtySize) -> anyhow::Result<()> {
2001            Ok(())
2002        }
2003
2004        fn get_size(&self) -> anyhow::Result<portable_pty::PtySize> {
2005            Ok(portable_pty::PtySize::default())
2006        }
2007
2008        fn try_clone_reader(&self) -> anyhow::Result<Box<dyn std::io::Read + Send>> {
2009            if matches!(self.0, PtyIoFailure::Reader) {
2010                anyhow::bail!("reader sentinel")
2011            }
2012            Ok(Box::new(std::io::empty()))
2013        }
2014
2015        fn take_writer(&self) -> anyhow::Result<Box<dyn std::io::Write + Send>> {
2016            if matches!(self.0, PtyIoFailure::Writer) {
2017                anyhow::bail!("writer sentinel")
2018            }
2019            Ok(Box::new(std::io::sink()))
2020        }
2021
2022        #[cfg(unix)]
2023        fn process_group_leader(&self) -> Option<i32> {
2024            None
2025        }
2026
2027        #[cfg(unix)]
2028        fn as_raw_fd(&self) -> Option<std::os::fd::RawFd> {
2029            None
2030        }
2031
2032        #[cfg(unix)]
2033        fn tty_name(&self) -> Option<PathBuf> {
2034            None
2035        }
2036    }
2037
2038    fn recording_child() -> (RecordingChild, Arc<AtomicUsize>, Arc<AtomicUsize>) {
2039        let kill_calls = Arc::new(AtomicUsize::new(0));
2040        let wait_calls = Arc::new(AtomicUsize::new(0));
2041        (
2042            RecordingChild {
2043                kill_calls: kill_calls.clone(),
2044                wait_calls: wait_calls.clone(),
2045            },
2046            kill_calls,
2047            wait_calls,
2048        )
2049    }
2050
2051    struct ChannelReader {
2052        chunks: std::sync::mpsc::Receiver<Vec<u8>>,
2053        pending: std::io::Cursor<Vec<u8>>,
2054    }
2055
2056    impl std::io::Read for ChannelReader {
2057        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2058            if self.pending.position() < self.pending.get_ref().len() as u64 {
2059                return self.pending.read(buf);
2060            }
2061            match self.chunks.recv() {
2062                Ok(chunk) => {
2063                    self.pending = std::io::Cursor::new(chunk);
2064                    self.pending.read(buf)
2065                }
2066                Err(_) => Ok(0),
2067            }
2068        }
2069    }
2070
2071    fn recording_pty_result(
2072        profile: crate::sandbox::TempProfile,
2073    ) -> (PtySpawnResult, Arc<AtomicUsize>, Arc<AtomicUsize>, PathBuf) {
2074        let profile_path = profile.path().to_path_buf();
2075        let (child, kill_calls, wait_calls) = recording_child();
2076        (
2077            PtySpawnResult {
2078                child: Box::new(child),
2079                reader: Box::new(std::io::empty()),
2080                writer: Box::new(std::io::sink()),
2081                master: Box::new(FailingMaster(PtyIoFailure::Never)),
2082                profile: Some(profile),
2083            },
2084            kill_calls,
2085            wait_calls,
2086            profile_path,
2087        )
2088    }
2089
2090    #[tokio::test]
2091    async fn successful_pty_releases_profile_after_exit_and_preserves_history() {
2092        let root = tempfile::tempdir().unwrap();
2093        let profile = crate::sandbox::TempProfile::create("live profile").unwrap();
2094        let profile_path = profile.path().to_path_buf();
2095        let (child, _, _) = recording_child();
2096        let (chunks_tx, chunks_rx) = std::sync::mpsc::channel();
2097        let pty_result = PtySpawnResult {
2098            child: Box::new(child),
2099            reader: Box::new(ChannelReader {
2100                chunks: chunks_rx,
2101                pending: std::io::Cursor::new(Vec::new()),
2102            }),
2103            writer: Box::new(std::io::sink()),
2104            master: Box::new(FailingMaster(PtyIoFailure::Never)),
2105            profile: Some(profile),
2106        };
2107        let registry = Arc::new(TermRegistry::new());
2108        let (handle, entry) = registry
2109            .spawn_entry(
2110                24,
2111                80,
2112                "success".into(),
2113                root.path().to_path_buf(),
2114                pty_result,
2115                None,
2116                "success".into(),
2117                "printf success".into(),
2118                None,
2119                None,
2120                tokio_util::sync::CancellationToken::new(),
2121                None,
2122                None,
2123            )
2124            .unwrap();
2125
2126        assert!(profile_path.exists());
2127        chunks_tx.send(b"completed output".to_vec()).unwrap();
2128        drop(chunks_tx);
2129        let reader_task = entry
2130            .reader_task
2131            .lock()
2132            .expect("reader_task poisoned")
2133            .take()
2134            .unwrap();
2135        reader_task.await.unwrap();
2136
2137        assert!(!profile_path.exists());
2138        assert!(matches!(entry.current_state(), TermState::Exited { .. }));
2139        let historical = registry.lookup(&handle.to_string(), "success").unwrap();
2140        let screen = historical.snapshot();
2141        let first_row = screen.cells[..usize::from(screen.cols)]
2142            .iter()
2143            .map(|cell| cell.chars.as_str())
2144            .collect::<String>();
2145        assert!(first_row.contains("completed output"));
2146    }
2147
2148    #[tokio::test]
2149    async fn operator_stop_stays_killed_after_reader_exit() {
2150        let root = tempfile::tempdir().unwrap();
2151        let tasks = crate::task_registry::TaskRegistry::new();
2152        let registry = Arc::new(TermRegistry::new().with_task_registry(tasks.clone()));
2153        let (child, kill_calls, _) = recording_child();
2154        let (chunks_tx, chunks_rx) = std::sync::mpsc::channel();
2155        let pty_result = PtySpawnResult {
2156            child: Box::new(child),
2157            reader: Box::new(ChannelReader {
2158                chunks: chunks_rx,
2159                pending: std::io::Cursor::new(Vec::new()),
2160            }),
2161            writer: Box::new(std::io::sink()),
2162            master: Box::new(FailingMaster(PtyIoFailure::Never)),
2163            profile: None,
2164        };
2165        let (handle, entry) = registry
2166            .spawn_entry(
2167                24,
2168                80,
2169                "session".into(),
2170                root.path().to_path_buf(),
2171                pty_result,
2172                None,
2173                "terminal".into(),
2174                "sleep 10".into(),
2175                None,
2176                None,
2177                tokio_util::sync::CancellationToken::new(),
2178                None,
2179                None,
2180            )
2181            .unwrap();
2182
2183        assert!(matches!(
2184            tasks.kill_by_handle_from_operator(&handle.to_string(), "session"),
2185            crate::task_registry::KillOutcome::Killed { .. }
2186        ));
2187        assert!(matches!(entry.current_state(), TermState::Killed { .. }));
2188        assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2189
2190        drop(chunks_tx);
2191        let reader_task = entry
2192            .reader_task
2193            .lock()
2194            .expect("reader_task poisoned")
2195            .take()
2196            .unwrap();
2197        reader_task.await.unwrap();
2198
2199        assert_eq!(
2200            tasks.lookup_by_handle(&handle.to_string()).unwrap().status,
2201            crate::task_registry::TaskStatus::Killed
2202        );
2203        assert!(matches!(entry.current_state(), TermState::Killed { .. }));
2204    }
2205
2206    #[test]
2207    fn pty_reader_failure_terminates_child_and_drops_profile() {
2208        let profile = crate::sandbox::TempProfile::create("reader profile").unwrap();
2209        let profile_path = profile.path().to_path_buf();
2210        let (child, kill_calls, wait_calls) = recording_child();
2211        let error = crate::sandbox::complete_pty_spawn(
2212            Box::new(child),
2213            Box::new(FailingMaster(PtyIoFailure::Reader)),
2214            Some(profile),
2215        )
2216        .err()
2217        .expect("reader acquisition must fail");
2218        assert!(error.to_string().contains("reader sentinel"));
2219        assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2220        assert_eq!(wait_calls.load(Ordering::SeqCst), 1);
2221        assert!(!profile_path.exists());
2222    }
2223
2224    #[test]
2225    fn pty_writer_failure_terminates_child_and_drops_profile() {
2226        let profile = crate::sandbox::TempProfile::create("writer profile").unwrap();
2227        let profile_path = profile.path().to_path_buf();
2228        let (child, kill_calls, wait_calls) = recording_child();
2229        let error = crate::sandbox::complete_pty_spawn(
2230            Box::new(child),
2231            Box::new(FailingMaster(PtyIoFailure::Writer)),
2232            Some(profile),
2233        )
2234        .err()
2235        .expect("writer acquisition must fail");
2236        assert!(error.to_string().contains("writer sentinel"));
2237        assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2238        assert_eq!(wait_calls.load(Ordering::SeqCst), 1);
2239        assert!(!profile_path.exists());
2240    }
2241
2242    #[test]
2243    fn session_directory_failure_leaves_no_terminal_resources() {
2244        let root = tempfile::tempdir().unwrap();
2245        let blocker = root.path().join("not-a-directory");
2246        let session_dir = blocker.join("session");
2247        std::fs::write(&blocker, "block directory creation").unwrap();
2248        let profile = crate::sandbox::TempProfile::create("directory profile").unwrap();
2249        let (pty_result, kill_calls, wait_calls, profile_path) = recording_pty_result(profile);
2250        let tasks = crate::task_registry::TaskRegistry::new();
2251        let registry = Arc::new(TermRegistry::new().with_task_registry(tasks.clone()));
2252        let error = registry
2253            .spawn_entry(
2254                24,
2255                80,
2256                "dir-failure".into(),
2257                session_dir.clone(),
2258                pty_result,
2259                None,
2260                "terminal".into(),
2261                "sh".into(),
2262                None,
2263                None,
2264                tokio_util::sync::CancellationToken::new(),
2265                None,
2266                None,
2267            )
2268            .err()
2269            .expect("session directory creation must fail");
2270        assert!(error.to_string().contains("create session_dir"));
2271        assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2272        assert_eq!(wait_calls.load(Ordering::SeqCst), 1);
2273        assert!(!profile_path.exists());
2274        assert!(registry.list("dir-failure").is_empty());
2275        assert!(
2276            tasks
2277                .list(&crate::task_registry::TaskFilter::all())
2278                .is_empty()
2279        );
2280        assert!(!session_dir.exists());
2281    }
2282
2283    #[test]
2284    fn log_open_failure_after_directory_creation_leaves_no_terminal_resources() {
2285        let root = tempfile::tempdir().unwrap();
2286        let session_dir = root.path().join("session");
2287        let profile = crate::sandbox::TempProfile::create("log profile").unwrap();
2288        let (pty_result, kill_calls, wait_calls, profile_path) = recording_pty_result(profile);
2289        let tasks = crate::task_registry::TaskRegistry::new();
2290        let registry = Arc::new(TermRegistry::new().with_task_registry(tasks.clone()));
2291        let opener_calls = AtomicUsize::new(0);
2292        let error = registry
2293            .spawn_entry_with_log_opener(
2294                24,
2295                80,
2296                "log-failure".into(),
2297                session_dir.clone(),
2298                pty_result,
2299                None,
2300                "terminal".into(),
2301                "sh".into(),
2302                None,
2303                None,
2304                tokio_util::sync::CancellationToken::new(),
2305                None,
2306                None,
2307                |path| {
2308                    opener_calls.fetch_add(1, Ordering::SeqCst);
2309                    assert!(session_dir.is_dir());
2310                    assert!(path.starts_with(&session_dir));
2311                    Err(std::io::Error::other("log sentinel"))
2312                },
2313            )
2314            .err()
2315            .expect("log opening must fail");
2316        assert!(error.to_string().contains("log sentinel"));
2317        assert_eq!(opener_calls.load(Ordering::SeqCst), 1);
2318        assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2319        assert_eq!(wait_calls.load(Ordering::SeqCst), 1);
2320        assert!(!profile_path.exists());
2321        assert!(registry.list("log-failure").is_empty());
2322        assert!(
2323            tasks
2324                .list(&crate::task_registry::TaskFilter::all())
2325                .is_empty()
2326        );
2327        assert!(std::fs::read_dir(&session_dir).unwrap().next().is_none());
2328    }
2329
2330    #[test]
2331    fn spawn_provenance_uses_cwd_not_cmd() {
2332        let dir = tempfile::tempdir().unwrap();
2333        let ctx = ToolCtx::default();
2334        let args = ToolArgs {
2335            named: vec![
2336                ("cmd".into(), Value::Str("/bin/echo hi".into())),
2337                ("cwd".into(), Value::Str(dir.path().display().to_string())),
2338            ],
2339            ..ToolArgs::default()
2340        };
2341        let provenance = TermSpawn.invocation_provenance(&args, &ctx).unwrap();
2342        let cwd = provenance.cwd.expect("cwd recorded");
2343        assert_eq!(
2344            std::fs::canonicalize(&cwd).unwrap(),
2345            std::fs::canonicalize(dir.path()).unwrap()
2346        );
2347        assert_eq!(provenance.path, None);
2348        assert!(
2349            provenance
2350                .risks
2351                .contains(&crate::trust::RiskKind::ProcessSpawn)
2352        );
2353    }
2354
2355    struct StrictRecordingSandbox {
2356        pty_calls: AtomicUsize,
2357    }
2358
2359    struct DenyingSandbox {
2360        strict_calls: AtomicUsize,
2361    }
2362
2363    impl crate::sandbox::Sandbox for DenyingSandbox {
2364        fn spawn<'a>(
2365            &'a self,
2366            _cmd: &'a [&'a str],
2367            _env: &'a [(String, String)],
2368            _cwd: &'a Path,
2369            _authorization: &'a crate::permission::InvocationAuthorization,
2370        ) -> crate::tool::BoxFut<'a, Result<std::process::Output, RuntimeError>> {
2371            Box::pin(async { Err(RuntimeError::ToolFailed("Operation not permitted".into())) })
2372        }
2373
2374        fn prepare_background(
2375            &self,
2376            _cmd: &[&str],
2377            _env: &[(String, String)],
2378            _cwd: &Path,
2379            _authorization: &crate::permission::InvocationAuthorization,
2380        ) -> Result<Box<dyn crate::sandbox::BackgroundLauncher>, crate::sandbox::SandboxLaunchError>
2381        {
2382            Err(crate::sandbox::SandboxLaunchError::Runtime(
2383                RuntimeError::ToolFailed("background unsupported".into()),
2384            ))
2385        }
2386
2387        fn spawn_pty<'a>(
2388            &'a self,
2389            _cmd: &'a [&'a str],
2390            _env: &'a [(String, String)],
2391            _cwd: &'a Path,
2392            _pty_size: portable_pty::PtySize,
2393            authorization: &'a crate::permission::InvocationAuthorization,
2394        ) -> crate::tool::BoxFut<'a, Result<PtySpawnResult, crate::sandbox::SandboxLaunchError>>
2395        {
2396            self.strict_calls.fetch_add(1, Ordering::SeqCst);
2397            Box::pin(async move {
2398                Err(crate::sandbox::SandboxLaunchError::Denied(Box::new(
2399                    crate::sandbox::SandboxDenial {
2400                        operation: crate::sandbox::SandboxOperation::PtySpawn,
2401                        reason: "Operation not permitted".into(),
2402                        provenance: authorization.provenance().clone(),
2403                    },
2404                )))
2405            })
2406        }
2407
2408        fn is_available(&self) -> bool {
2409            true
2410        }
2411
2412        fn kind(&self) -> &'static str {
2413            "test-denial"
2414        }
2415    }
2416
2417    impl crate::sandbox::Sandbox for StrictRecordingSandbox {
2418        fn spawn<'a>(
2419            &'a self,
2420            _cmd: &'a [&'a str],
2421            _env: &'a [(String, String)],
2422            _cwd: &'a Path,
2423            _authorization: &'a crate::permission::InvocationAuthorization,
2424        ) -> crate::tool::BoxFut<'a, Result<std::process::Output, RuntimeError>> {
2425            Box::pin(async { Err(RuntimeError::ToolFailed("strict sentinel".into())) })
2426        }
2427
2428        fn prepare_background(
2429            &self,
2430            _cmd: &[&str],
2431            _env: &[(String, String)],
2432            _cwd: &Path,
2433            _authorization: &crate::permission::InvocationAuthorization,
2434        ) -> Result<Box<dyn crate::sandbox::BackgroundLauncher>, crate::sandbox::SandboxLaunchError>
2435        {
2436            Err(crate::sandbox::SandboxLaunchError::Runtime(
2437                RuntimeError::ToolFailed("background unsupported".into()),
2438            ))
2439        }
2440
2441        fn spawn_pty<'a>(
2442            &'a self,
2443            _cmd: &'a [&'a str],
2444            _env: &'a [(String, String)],
2445            _cwd: &'a Path,
2446            _pty_size: portable_pty::PtySize,
2447            _authorization: &'a crate::permission::InvocationAuthorization,
2448        ) -> crate::tool::BoxFut<'a, Result<PtySpawnResult, crate::sandbox::SandboxLaunchError>>
2449        {
2450            self.pty_calls.fetch_add(1, Ordering::SeqCst);
2451            Box::pin(async {
2452                Err(crate::sandbox::SandboxLaunchError::Runtime(
2453                    RuntimeError::ToolFailed("strict sentinel".into()),
2454                ))
2455            })
2456        }
2457
2458        fn is_available(&self) -> bool {
2459            true
2460        }
2461        fn kind(&self) -> &'static str {
2462            "test"
2463        }
2464    }
2465
2466    fn managed_term_ctx(
2467        workspace: &Path,
2468        registry: Arc<TermRegistry>,
2469        session_dir: &Path,
2470    ) -> ToolCtx {
2471        let mut ctx = ToolCtx::new()
2472            .with_trust(crate::trust::TrustConfig {
2473                mode: crate::trust::TrustMode::Reckless,
2474                ..crate::trust::TrustConfig::default()
2475            })
2476            .with_term_registry(registry)
2477            .with_session_dir(session_dir.to_path_buf())
2478            .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
2479                workspace.to_path_buf(),
2480            ))
2481            .with_workspace(crate::git_workspace::WorkspaceBinding {
2482                workspace_id: "test".into(),
2483                repository_root: workspace.to_path_buf(),
2484                path: workspace.to_path_buf(),
2485                branch: None,
2486            });
2487        ctx.session_id = Some("r4".into());
2488        ctx.for_tool_invocation(crate::tool::Tier::Four)
2489            .authorized_for(crate::permission::InvocationAuthorization::new(
2490                crate::permission::PermissionRequestId::now(),
2491                "test-call",
2492                "term.spawn",
2493                crate::permission::ResourceProvenance::none(),
2494                crate::permission::ExecutionBoundary::Direct,
2495            ))
2496    }
2497
2498    fn term_args(cwd: &Path) -> ToolArgs {
2499        ToolArgs {
2500            positional: vec![],
2501            named: vec![
2502                ("cmd".into(), Value::Str("exit 0".into())),
2503                ("cwd".into(), Value::Str(cwd.to_string_lossy().into())),
2504            ],
2505        }
2506    }
2507
2508    fn brokered_term_ctx(
2509        workspace: &Path,
2510        registry: Arc<TermRegistry>,
2511        session_dir: &Path,
2512        trust: crate::trust::TrustConfig,
2513    ) -> ToolCtx {
2514        let flows = Arc::new(crate::tools::agent_ctrl::FlowRegistry::default());
2515        let identity = flows
2516            .register_root(
2517                "r4".into(),
2518                crate::event::FlowRunId::now(),
2519                crate::flow_authority::EffectiveAuthority::root(&trust, true, None),
2520            )
2521            .unwrap();
2522        let broker = crate::permission::PermissionBroker::shared(Arc::clone(&flows));
2523        let mut ctx = managed_term_ctx(workspace, registry, session_dir)
2524            .with_flow_registry(flows)
2525            .with_permission_broker(broker)
2526            .with_trust(trust)
2527            .with_approval(Arc::new(crate::session::ApprovalRegistry::new()))
2528            .with_anchors(None, Some(identity.run_id.clone()), None);
2529        ctx.flow_identity = Some(identity);
2530        ctx.for_tool_invocation(crate::tool::Tier::Four)
2531    }
2532
2533    #[tokio::test]
2534    async fn sandbox_strict_path_receives_external_cwd_before_fs_policy() {
2535        let workspace = tempfile::tempdir().unwrap();
2536        let session_dir = tempfile::tempdir().unwrap();
2537        let registry = Arc::new(TermRegistry::new());
2538        let sandbox = Arc::new(StrictRecordingSandbox {
2539            pty_calls: AtomicUsize::new(0),
2540        });
2541        let args = term_args(Path::new(env!("CARGO_MANIFEST_DIR")));
2542        let mut trust = eager_spawn_policy();
2543        trust.risks.eager.outside_workspace = Some(crate::trust::PolicyAction::Auto);
2544        let ctx = brokered_term_ctx(workspace.path(), registry, session_dir.path(), trust)
2545            .with_sandbox(sandbox.clone());
2546        let ctx = authorize_term_spawn(ctx, &args).await;
2547        let error = spawn_impl(args, &ctx).await.unwrap_err();
2548        assert!(error.to_string().contains("strict sentinel"));
2549        assert_eq!(sandbox.pty_calls.load(Ordering::SeqCst), 1);
2550    }
2551
2552    #[tokio::test]
2553    async fn direct_authorization_skips_available_sandbox() {
2554        let workspace = tempfile::tempdir().unwrap();
2555        let session_dir = tempfile::tempdir().unwrap();
2556        let registry = Arc::new(TermRegistry::new());
2557        let sandbox = Arc::new(StrictRecordingSandbox {
2558            pty_calls: AtomicUsize::new(0),
2559        });
2560        let ctx = managed_term_ctx(workspace.path(), Arc::clone(&registry), session_dir.path())
2561            .with_sandbox(sandbox.clone());
2562
2563        spawn_impl(term_args(workspace.path()), &ctx).await.unwrap();
2564
2565        assert_eq!(sandbox.pty_calls.load(Ordering::SeqCst), 0);
2566        registry.kill_all();
2567    }
2568
2569    async fn authorize_term_spawn(ctx: ToolCtx, args: &ToolArgs) -> ToolCtx {
2570        match crate::approval::request_approval(
2571            &ctx,
2572            "term.spawn",
2573            "term.spawn",
2574            args,
2575            crate::tool::ApprovalLevel::Dangerous,
2576            Some(&TermSpawn),
2577        )
2578        .await
2579        {
2580            crate::approval::ApprovalOutcome::Approve { authorization } => {
2581                ctx.authorized_for(*authorization)
2582            }
2583            crate::approval::ApprovalOutcome::Deny { reason } => {
2584                panic!("strict term spawn authorization denied: {reason}")
2585            }
2586        }
2587    }
2588
2589    fn eager_spawn_policy() -> crate::trust::TrustConfig {
2590        crate::trust::TrustConfig {
2591            mode: crate::trust::TrustMode::Eager,
2592            escalation: crate::trust::EscalationPolicy::Deny,
2593            ..crate::trust::TrustConfig::default()
2594        }
2595    }
2596
2597    #[tokio::test]
2598    async fn sandbox_denial_never_relaunches_or_registers_resources() {
2599        let workspace = tempfile::tempdir().unwrap();
2600        let session_dir = tempfile::tempdir().unwrap();
2601        let tasks = crate::task_registry::TaskRegistry::new();
2602        let registry = Arc::new(TermRegistry::new().with_task_registry(tasks.clone()));
2603        let sandbox = Arc::new(DenyingSandbox {
2604            strict_calls: AtomicUsize::new(0),
2605        });
2606        let ctx = brokered_term_ctx(
2607            workspace.path(),
2608            registry.clone(),
2609            session_dir.path(),
2610            eager_spawn_policy(),
2611        )
2612        .with_sandbox(sandbox.clone())
2613        .with_task_registry(tasks.clone());
2614        let args = term_args(workspace.path());
2615        let ctx = authorize_term_spawn(ctx, &args).await;
2616
2617        let error = spawn_impl(args, &ctx).await.unwrap_err();
2618
2619        assert!(error.to_string().contains("Operation not permitted"));
2620        assert_eq!(sandbox.strict_calls.load(Ordering::SeqCst), 1);
2621        assert!(registry.list("r4").is_empty());
2622        assert!(
2623            tasks
2624                .list(&crate::task_registry::TaskFilter::all())
2625                .is_empty()
2626        );
2627    }
2628
2629    #[tokio::test]
2630    async fn direct_spawn_allows_external_temp_cwd() {
2631        let workspace = tempfile::tempdir().unwrap();
2632        let external = tempfile::tempdir().unwrap();
2633        let session_dir = tempfile::tempdir().unwrap();
2634        let registry = Arc::new(TermRegistry::new());
2635        let ctx = managed_term_ctx(workspace.path(), registry.clone(), session_dir.path());
2636        spawn_impl(term_args(external.path()), &ctx).await.unwrap();
2637        assert_eq!(registry.list("r4").len(), 1);
2638        registry.kill_all();
2639    }
2640
2641    #[tokio::test]
2642    async fn direct_spawn_allows_external_cwd_under_full_access() {
2643        let workspace = tempfile::tempdir().unwrap();
2644        let session_dir = tempfile::tempdir().unwrap();
2645        let registry = Arc::new(TermRegistry::new());
2646        let ctx = managed_term_ctx(workspace.path(), registry.clone(), session_dir.path())
2647            .with_fs_access(crate::fs_access::FsAccessPolicy::danger_full_access());
2648        spawn_impl(term_args(Path::new(env!("CARGO_MANIFEST_DIR"))), &ctx)
2649            .await
2650            .unwrap();
2651        assert_eq!(registry.list("r4").len(), 1);
2652        registry.kill_all();
2653    }
2654
2655    #[test]
2656    fn handle_parse_roundtrip() {
2657        let h = TermHandle {
2658            session_id: "abc".into(),
2659            local_id: 7,
2660        };
2661        assert_eq!(h.to_string(), "term_abc_7");
2662        let back = TermHandle::parse("term_abc_7").unwrap();
2663        assert_eq!(back, h);
2664    }
2665
2666    #[test]
2667    fn handle_parse_rejects_bad_format() {
2668        assert!(TermHandle::parse("not_term").is_none());
2669        assert!(TermHandle::parse("term_nosuffix").is_none());
2670        assert!(TermHandle::parse("term_x_notnum").is_none());
2671    }
2672
2673    #[test]
2674    fn snapshot_screen_captures_text() {
2675        let mut parser = vt100::Parser::new(3, 5, 0);
2676        parser.process(b"hello");
2677        let screen = snapshot_screen(&parser);
2678        assert_eq!(screen.rows, 3);
2679        assert_eq!(screen.cols, 5);
2680        assert_eq!(screen.cells.len(), 15);
2681        assert_eq!(screen.cells[0].chars, "h");
2682        assert_eq!(screen.cells[4].chars, "o");
2683    }
2684
2685    #[tokio::test]
2686    async fn capture_spills_text_to_output_store_with_byte_continuation() {
2687        const ROWS: u16 = 4096;
2688        const COLS: u16 = 256;
2689        const CONTENT_COLS: usize = 255;
2690        const EXPECTED_TEXT_BYTES: usize = ROWS as usize * CONTENT_COLS + (ROWS as usize - 1);
2691
2692        let registry = Arc::new(TermRegistry::new());
2693        let handle = registry.next_handle("session_a");
2694        let row = "a".repeat(CONTENT_COLS);
2695        let expected_text = vec![row.as_str(); ROWS as usize].join("\n");
2696        assert_eq!(expected_text.len(), EXPECTED_TEXT_BYTES);
2697        assert_eq!(EXPECTED_TEXT_BYTES, 1_048_575);
2698        let terminal_input = expected_text.replace('\n', "\r\n");
2699        let mut parser = vt100::Parser::new(ROWS, COLS, 0);
2700        parser.process(terminal_input.as_bytes());
2701        let entry = Arc::new(TermEntry {
2702            handle: handle.clone(),
2703            session_id: "session_a".into(),
2704            pty_size: portable_pty::PtySize {
2705                rows: ROWS,
2706                cols: COLS,
2707                pixel_width: 0,
2708                pixel_height: 0,
2709            },
2710            parser: Arc::new(Mutex::new(parser)),
2711            writer: Mutex::new(Box::new(std::io::sink())),
2712            state: Arc::new(Mutex::new(TermState::Running {
2713                pid: 0,
2714                started_at: 0,
2715            })),
2716            stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
2717            log_path: std::env::temp_dir().join("term_capture_budget.log"),
2718            reader_task: Mutex::new(None),
2719            child: Mutex::new(None),
2720            master: Mutex::new(None),
2721            profile: Arc::new(Mutex::new(None)),
2722            started_at: Instant::now(),
2723            task_id: Mutex::new(None),
2724        });
2725        registry.insert(entry);
2726        let output_dir = tempfile::tempdir().unwrap();
2727        let mut ctx = crate::tool::ToolCtx::new()
2728            .with_term_registry(registry)
2729            .with_session_dir(output_dir.path().to_path_buf());
2730        ctx.session_id = Some("session_a".into());
2731        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2732            max_lines: 32,
2733            max_bytes: 64 * 1024,
2734            max_line_bytes: 64 * 1024,
2735        };
2736
2737        let first = TermCapture
2738            .call(
2739                crate::tool::ToolArgs {
2740                    positional: Vec::new(),
2741                    named: vec![("handle".into(), Value::Str(handle.to_string()))],
2742                },
2743                &ctx,
2744            )
2745            .await
2746            .unwrap();
2747        let Value::Struct(fields) = first else {
2748            panic!("expected capture fields");
2749        };
2750        assert!(matches!(
2751            fields.iter().find(|(name, _)| name == "rows"),
2752            Some((_, Value::Int(rows))) if *rows == i64::from(ROWS)
2753        ));
2754        assert!(matches!(
2755            fields.iter().find(|(name, _)| name == "total_lines"),
2756            Some((_, Value::Int(lines))) if *lines == i64::from(ROWS)
2757        ));
2758        assert!(matches!(
2759            fields.iter().find(|(name, _)| name == "total_bytes"),
2760            Some((_, Value::Int(bytes))) if *bytes == EXPECTED_TEXT_BYTES as i64
2761        ));
2762        assert!(!fields.iter().any(|(name, _)| name == "continuation"));
2763        assert!(!fields.iter().any(|(name, _)| name == "TerminalArea"));
2764        assert!(!fields.iter().any(|(name, _)| name == "next_row"));
2765        assert!(!fields.iter().any(|(name, _)| name == "next_col"));
2766        assert!(!fields.iter().any(|(name, _)| name == "text"));
2767        let content = fields.iter().find_map(|(name, value)| {
2768            (name == "content").then(|| match value {
2769                Value::Str(text) => text.as_str(),
2770                _ => panic!("expected content string"),
2771            })
2772        });
2773        let Value::Str(output_id) = fields
2774            .iter()
2775            .find(|(name, _)| name == "output_id")
2776            .map(|(_, value)| value)
2777            .unwrap()
2778        else {
2779            panic!("expected output_id");
2780        };
2781        let Value::Struct(next) = fields
2782            .iter()
2783            .find(|(name, _)| name == "next")
2784            .map(|(_, value)| value)
2785            .unwrap()
2786        else {
2787            panic!("expected byte continuation");
2788        };
2789        let offset = next
2790            .iter()
2791            .find_map(|(name, value)| (name == "offset").then_some(value))
2792            .and_then(|value| match value {
2793                Value::Int(offset) => Some(*offset as usize),
2794                _ => None,
2795            })
2796            .unwrap();
2797        assert!(matches!(
2798            next.iter().find(|(name, _)| name == "mode"),
2799            Some((_, Value::Str(mode))) if mode == "bytes"
2800        ));
2801        let store = ctx.output_store.as_deref().unwrap();
2802        let mut reconstructed = content.unwrap().to_string();
2803        let mut page_offset = offset;
2804        loop {
2805            let page = store
2806                .read_bytes(output_id, page_offset, usize::MAX, ctx.tool_output_budget)
2807                .unwrap();
2808            assert_eq!(page.offset, page_offset);
2809            reconstructed.push_str(&page.content);
2810            if !page.has_more {
2811                break;
2812            }
2813            assert!(page.next_offset > page_offset);
2814            page_offset = page.next_offset;
2815        }
2816        assert_eq!(reconstructed.len(), EXPECTED_TEXT_BYTES);
2817        assert_eq!(reconstructed, expected_text);
2818
2819        let untruncated = TermCapture
2820            .call(
2821                crate::tool::ToolArgs {
2822                    positional: Vec::new(),
2823                    named: vec![
2824                        ("handle".into(), Value::Str(handle.to_string())),
2825                        ("end_row".into(), Value::Int(1)),
2826                    ],
2827                },
2828                &ctx,
2829            )
2830            .await
2831            .unwrap();
2832        let Value::Struct(fields) = untruncated else {
2833            panic!("expected capture fields");
2834        };
2835        assert!(matches!(
2836            fields.iter().find(|(name, _)| name == "text"),
2837            Some((_, Value::Str(text))) if text == &row
2838        ));
2839        assert!(!fields.iter().any(|(name, _)| name == "continuation"));
2840        assert!(!fields.iter().any(|(name, _)| name == "output_id"));
2841        assert!(!fields.iter().any(|(name, _)| name == "next"));
2842
2843        let screen_capture = TermCapture
2844            .call(
2845                crate::tool::ToolArgs {
2846                    positional: Vec::new(),
2847                    named: vec![
2848                        ("handle".into(), Value::Str(handle.to_string())),
2849                        ("format".into(), Value::Str("screen".into())),
2850                    ],
2851                },
2852                &ctx,
2853            )
2854            .await
2855            .unwrap();
2856        let Value::Struct(fields) = screen_capture else {
2857            panic!("expected capture fields");
2858        };
2859        let Value::Struct(continuation) = fields
2860            .iter()
2861            .find(|(name, _)| name == "continuation")
2862            .map(|(_, value)| value)
2863            .unwrap()
2864        else {
2865            panic!("expected screen continuation");
2866        };
2867        assert!(matches!(
2868            continuation.iter().find(|(name, _)| name == "type"),
2869            Some((_, Value::Str(kind))) if kind == "TerminalArea"
2870        ));
2871        assert!(continuation.iter().any(|(name, _)| name == "next_row"));
2872        assert!(fields.iter().any(|(name, _)| name == "screen"));
2873    }
2874
2875    #[test]
2876    fn registry_lookup_rejects_cross_session() {
2877        let registry = Arc::new(TermRegistry::new());
2878        let h = registry.next_handle("session_a");
2879        let entry = Arc::new(TermEntry {
2880            handle: h.clone(),
2881            session_id: "session_a".into(),
2882            pty_size: portable_pty::PtySize {
2883                rows: 24,
2884                cols: 80,
2885                pixel_width: 0,
2886                pixel_height: 0,
2887            },
2888            parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
2889            writer: Mutex::new(Box::new(std::io::sink())),
2890            state: Arc::new(Mutex::new(TermState::Running {
2891                pid: 0,
2892                started_at: 0,
2893            })),
2894            stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
2895            log_path: std::env::temp_dir().join("term_test_dummy.log"),
2896            reader_task: Mutex::new(None),
2897            child: Mutex::new(None),
2898            master: Mutex::new(None),
2899            profile: Arc::new(Mutex::new(None)),
2900            started_at: Instant::now(),
2901            task_id: Mutex::new(None),
2902        });
2903        registry.insert(entry);
2904        assert!(registry.lookup(&h.to_string(), "session_a").is_ok());
2905        assert!(registry.lookup(&h.to_string(), "session_b").is_err());
2906    }
2907
2908    fn make_screen(rows: u16, cols: u16, text: &str) -> TerminalScreen {
2909        let mut parser = vt100::Parser::new(rows, cols, 0);
2910        parser.process(text.as_bytes());
2911        snapshot_screen(&parser)
2912    }
2913
2914    #[test]
2915    fn cell_text_returns_chars_for_filled_cell() {
2916        let screen = make_screen(1, 5, "hello");
2917        assert_eq!(cell_text(&screen, 0, 0), "h");
2918        assert_eq!(cell_text(&screen, 0, 4), "o");
2919    }
2920
2921    #[test]
2922    fn cell_text_returns_space_for_empty_cell() {
2923        let screen = make_screen(1, 5, "hi");
2924        assert_eq!(cell_text(&screen, 0, 2), " ");
2925    }
2926
2927    #[test]
2928    fn find_pattern_returns_position() {
2929        let screen = make_screen(2, 10, "hello\r\nworld");
2930        let pos = find_pattern_on_screen(&screen, "world");
2931        assert_eq!(pos, Some((1, 0)));
2932    }
2933
2934    #[test]
2935    fn find_pattern_returns_none_when_absent() {
2936        let screen = make_screen(1, 5, "hello");
2937        assert!(find_pattern_on_screen(&screen, "xyz").is_none());
2938    }
2939
2940    #[test]
2941    fn color_name_maps_to_ansi_idx() {
2942        assert_eq!(color_name_to_terminal("red"), Some(TerminalColor::Idx(1)));
2943        assert_eq!(
2944            color_name_to_terminal("default"),
2945            Some(TerminalColor::Default)
2946        );
2947        assert_eq!(color_name_to_terminal("nonexistent"), None);
2948    }
2949
2950    #[test]
2951    fn color_matches_bright_variant_to_base_name() {
2952        let desired = TerminalColor::Idx(1);
2953        let actual_bright = TerminalColor::Idx(9);
2954        assert!(color_matches(&desired, &actual_bright));
2955        let actual_normal = TerminalColor::Idx(1);
2956        assert!(color_matches(&desired, &actual_normal));
2957    }
2958
2959    #[test]
2960    fn color_matches_rgb_exact() {
2961        let desired = TerminalColor::Rgb(255, 128, 0);
2962        let actual = TerminalColor::Rgb(255, 128, 0);
2963        assert!(color_matches(&desired, &actual));
2964        let wrong = TerminalColor::Rgb(255, 128, 1);
2965        assert!(!color_matches(&desired, &wrong));
2966    }
2967
2968    #[test]
2969    fn parse_color_accepts_name_and_rgb() {
2970        assert_eq!(
2971            parse_color(&Value::Str("blue".into())),
2972            Some(TerminalColor::Idx(4))
2973        );
2974        let rgb = Value::Struct(vec![
2975            ("r".into(), Value::Int(10)),
2976            ("g".into(), Value::Int(20)),
2977            ("b".into(), Value::Int(30)),
2978        ]);
2979        assert_eq!(parse_color(&rgb), Some(TerminalColor::Rgb(10, 20, 30)));
2980    }
2981}