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