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