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