diff --git a/crates/bashkit-js/__test__/terminal.spec.ts b/crates/bashkit-js/__test__/terminal.spec.ts index d36822671..84f115e3f 100644 --- a/crates/bashkit-js/__test__/terminal.spec.ts +++ b/crates/bashkit-js/__test__/terminal.spec.ts @@ -76,6 +76,19 @@ test("terminal: wait_for and screen changes", async (t) => { ); }); +test("terminal: sessions share files", async (t) => { + const term = new Terminal(); + await term.call("echo hi > /tmp/s"); + const out = await term.call({ session: "other", input: "cat /tmp/s" }); + t.is(out.session, "other"); + t.is(out.commands[0].output, "hi\n"); + t.deepEqual( + out.sessions!.map((s) => s.name), + ["main", "other"], + ); + t.is((await term.call({ session: "other", close: true })).closed, true); +}); + test("terminal: exit and exitCode", async (t) => { const term = new Terminal(); const out = await term.call("exit 3"); diff --git a/crates/bashkit-js/src/terminal.rs b/crates/bashkit-js/src/terminal.rs index 9c4a53723..250ccfdd8 100644 --- a/crates/bashkit-js/src/terminal.rs +++ b/crates/bashkit-js/src/terminal.rs @@ -82,22 +82,31 @@ impl Terminal { cwd: None, env: None, }); - let mut builder = RustBash::builder(); - if let Some(u) = opts.username { - builder = builder.username(u); - } - if let Some(h) = opts.hostname { - builder = builder.hostname(h); - } - if let Some(c) = opts.cwd { - builder = builder.cwd(c); - } - for (k, v) in opts.env.unwrap_or_default() { - builder = builder.env(&k, &v); - } let size = TerminalSize::new(clamp_dim(opts.rows, 24), clamp_dim(opts.cols, 80)); + let (username, hostname, cwd) = (opts.username, opts.hostname, opts.cwd); + let env = opts.env.unwrap_or_default(); + // Each `session` passed to `call` gets a shell built the same way. + let factory = move || { + let mut builder = RustBash::builder(); + if let Some(u) = &username { + builder = builder.username(u.clone()); + } + if let Some(h) = &hostname { + builder = builder.hostname(h.clone()); + } + if let Some(c) = &cwd { + builder = builder.cwd(c.clone()); + } + for (k, v) in &env { + builder = builder.env(k, v); + } + builder + }; Self { - tool: Arc::new(Mutex::new(TerminalTool::with_size(builder, size))), + tool: Arc::new(Mutex::new(TerminalTool::with_sessions( + size, + Box::new(factory), + ))), } } diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 46b7a2095..b5ba795fe 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -2011,6 +2011,10 @@ export interface TerminalCallArgs { wait_for?: string; /** `full` (default), `changes` (only changed rows) or `none`. */ screen?: "full" | "changes" | "none"; + /** Terminal tab to use (default `main`); created on first use, files shared. */ + session?: string; + /** Close the named session instead of typing into it. */ + close?: boolean; } export interface TerminalCallResult { @@ -2022,6 +2026,14 @@ export interface TerminalCallResult { cursor?: { row: number; col: number }; /** Whether `wait_for` matched (only when it was given). */ matched?: boolean; + /** Session this result is for, when more than one is open. */ + session?: string; + /** Every open session and its activity, when more than one is open. */ + sessions?: Array<{ name: string; activity: string }>; + /** The session had exited and a fresh shell started for this call. */ + restarted?: boolean; + /** The session was closed (`close: true`). */ + closed?: boolean; /** `input`: a running command waits for a typed line (see `input_prompt`). */ activity: "prompt" | "continuation" | "running" | "input" | "exited"; /** Question on the cursor line while activity is `input`. */ diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index c847cf2cc..50e40caaa 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -1937,11 +1937,15 @@ class Terminal: wait_ms: int | None = None, wait_for: str | None = None, screen: Literal["full", "changes", "none"] | None = None, + session: str | None = None, + close: bool | None = None, ) -> dict[str, Any]: """Agent step: type ``input`` in Vim key notation, wait, report screen and commands. ``wait_for`` (regex) returns as soon as command output matches. ``screen="changes"`` returns only rows changed since the last call. + ``session`` names a terminal tab (created on first use, files shared); + ``close=True`` closes it. """ ... def screen_text(self) -> str: ... diff --git a/crates/bashkit-python/src/terminal.rs b/crates/bashkit-python/src/terminal.rs index df63ae0a3..f04174c54 100644 --- a/crates/bashkit-python/src/terminal.rs +++ b/crates/bashkit-python/src/terminal.rs @@ -69,25 +69,30 @@ impl PyTerminal { cwd: Option, env: Option>, ) -> PyResult { - let mut builder = Bash::builder(); - if let Some(u) = username { - builder = builder.username(u); - } - if let Some(h) = hostname { - builder = builder.hostname(h); - } - if let Some(c) = cwd { - builder = builder.cwd(c); - } - for (k, v) in env.unwrap_or_default() { - builder = builder.env(&k, &v); - } + let env = env.unwrap_or_default(); + // Each `session` passed to `call` gets a shell built the same way. + let factory = move || { + let mut builder = Bash::builder(); + if let Some(u) = &username { + builder = builder.username(u.clone()); + } + if let Some(h) = &hostname { + builder = builder.hostname(h.clone()); + } + if let Some(c) = &cwd { + builder = builder.cwd(c.clone()); + } + for (k, v) in &env { + builder = builder.env(k, v); + } + builder + }; let rt = make_runtime()?; // The session future is created inside the runtime so nothing in it // can observe a missing tokio context. let tool = { let _enter = rt.enter(); - TerminalTool::with_size(builder, TerminalSize::new(rows, cols)) + TerminalTool::with_sessions(TerminalSize::new(rows, cols), Box::new(factory)) }; Ok(Self { tool: Mutex::new(tool), @@ -151,9 +156,12 @@ impl PyTerminal { /// `"ihello:wq"`), wait up to `wait_ms`, and return a dict /// with `screen`, `activity`, `commands` and more. `wait_for` (regex) /// returns early once command output matches; `screen` is `"full"`, - /// `"changes"` or `"none"`. Argument names match `tool_definition()`, so - /// `call(**tool_args)` forwards a model's call. - #[pyo3(signature = (input="", wait_ms=None, wait_for=None, screen=None))] + /// `"changes"` or `"none"`; `session` names a terminal tab (created on + /// first use, sharing files) and `close=True` closes it. Argument names + /// match `tool_definition()`, so `call(**tool_args)` forwards a model's + /// call. + #[pyo3(signature = (input="", wait_ms=None, wait_for=None, screen=None, session=None, close=None))] + #[allow(clippy::too_many_arguments)] fn call( &self, py: Python<'_>, @@ -161,6 +169,8 @@ impl PyTerminal { wait_ms: Option, wait_for: Option<&str>, screen: Option<&str>, + session: Option<&str>, + close: Option, ) -> PyResult> { let mut args = serde_json::json!({ "input": input }); if let Some(ms) = wait_ms { @@ -172,6 +182,12 @@ impl PyTerminal { if let Some(m) = screen { args["screen"] = m.into(); } + if let Some(s) = session { + args["session"] = s.into(); + } + if let Some(c) = close { + args["close"] = c.into(); + } let out = py.detach(|| self.with_tool(|tool| self.rt.block_on(tool.call(args)))); let value = out.map_err(|e| PyValueError::new_err(e.to_string()))?; json_to_py(py, &value) diff --git a/crates/bashkit-python/tests/test_terminal.py b/crates/bashkit-python/tests/test_terminal.py index be1660c3e..409672cb0 100644 --- a/crates/bashkit-python/tests/test_terminal.py +++ b/crates/bashkit-python/tests/test_terminal.py @@ -66,6 +66,16 @@ def test_wait_for_and_screen_changes(): assert [c["text"] for c in out["screen_changes"]][-2:] == ["^C", "$"] +def test_sessions_share_files(): + t = Terminal() + t.call("echo hi > /tmp/s") + out = t.call("cat /tmp/s", session="other") + assert out["session"] == "other" + assert out["commands"][0]["output"] == "hi\n" + assert [s["name"] for s in out["sessions"]] == ["main", "other"] + assert t.call(session="other", close=True)["closed"] is True + + def test_exit_and_exit_code(): t = Terminal() out = t.call("exit 3") diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index b3284cef0..9aac33591 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -73,7 +73,7 @@ use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; pub(crate) use keys::{Key, ScreenGuard, read_key}; -pub use tool::{TERMINAL_TOOL_NAME, TerminalTool, TerminalToolError}; +pub use tool::{MAIN_SESSION, SessionFactory, TERMINAL_TOOL_NAME, TerminalTool, TerminalToolError}; pub(crate) use tty::{Tty, TtyEvent}; use crate::fs::FileSystem; diff --git a/crates/bashkit/src/terminal/tool.rs b/crates/bashkit/src/terminal/tool.rs index 72f9c7bcd..687eb34ea 100644 --- a/crates/bashkit/src/terminal/tool.rs +++ b/crates/bashkit/src/terminal/tool.rs @@ -22,6 +22,14 @@ //! Polls in 50 ms slices; the session keeps running between slices. //! - `screen: "changes"` returns only rows that differ from the previous //! call (any mode updates the baseline), to keep long sessions cheap. +//! - Multiple sessions (like terminal tabs) need [`TerminalTool::with_sessions`], +//! which takes a builder factory because `BashBuilder` is not `Clone`. +//! Extra sessions share the main session's filesystem, so a file written in +//! one is visible in the others; shell state (variables, cwd, jobs) is per +//! session. While a call waits on one session the others keep running, so a +//! `watch` or long loop in one tab progresses while the agent works in +//! another. At most 8 sessions; `close` drops one, and an exited session +//! restarts on the next call that types into it. use std::time::Duration; @@ -43,6 +51,14 @@ const MAX_INPUT_BYTES: usize = 64 * 1024; const MAX_PATTERN_BYTES: usize = 1024; const MAX_REGEX_SIZE: usize = 1 << 20; const WAIT_SLICE: Duration = Duration::from_millis(50); +/// Name of the session every tool starts with. +pub const MAIN_SESSION: &str = "main"; +// THREAT[TM-DOS-119]: each session is a full interpreter; cap them. +const MAX_SESSIONS: usize = 8; +const MAX_SESSION_NAME: usize = 32; + +/// Builds a fresh [`BashBuilder`] for each session of a multi-session tool. +pub type SessionFactory = Box BashBuilder + Send + Sync>; const DESCRIPTION: &str = "Interactive bash terminal in a sandbox. Type keys, get the screen back. \ Use it for full-screen programs such as vi and less, or a shell session that persists between calls."; @@ -74,11 +90,29 @@ pub struct TerminalToolError(String); /// # } /// ``` pub struct TerminalTool { + /// Index 0 is the main session. + sessions: Vec, + factory: Option, + size: TerminalSize, +} + +struct ToolSession { + name: String, terminal: Terminal, /// Screen rows at the previous report, for `screen: "changes"`. last_rows: Option>, } +impl ToolSession { + fn new(name: &str, terminal: Terminal) -> Self { + Self { + name: name.to_string(), + terminal, + last_rows: None, + } + } +} + /// How much of the screen a call returns. #[derive(Clone, Copy, PartialEq, Eq)] enum ScreenMode { @@ -96,21 +130,73 @@ impl TerminalTool { /// Start a session on a terminal of the given size. pub fn with_size(builder: BashBuilder, size: TerminalSize) -> Self { Self { - terminal: Terminal::with_size(builder, size), - last_rows: None, + sessions: vec![ToolSession::new( + MAIN_SESSION, + Terminal::with_size(builder, size), + )], + factory: None, + size, } } - /// The underlying terminal, for example to read files with + /// A tool that can open more sessions on demand (the `session` + /// argument). `factory` builds the shell for each one; sessions after + /// the first share its filesystem. + /// + /// ```rust + /// use bashkit::Bash; + /// use bashkit::terminal::{TerminalSize, TerminalTool}; + /// use serde_json::json; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let mut tool = TerminalTool::with_sessions(TerminalSize::default(), Box::new(Bash::builder)); + /// tool.call(json!({"input": "echo hi > /tmp/f"})).await.unwrap(); + /// let out = tool + /// .call(json!({"session": "logs", "input": "cat /tmp/f"})) + /// .await + /// .unwrap(); + /// assert_eq!(out["commands"][0]["output"], "hi\n"); + /// # } + /// ``` + pub fn with_sessions(size: TerminalSize, factory: SessionFactory) -> Self { + let main = Terminal::with_size(factory(), size); + Self { + sessions: vec![ToolSession::new(MAIN_SESSION, main)], + factory: Some(factory), + size, + } + } + + /// The main session's terminal, for example to read files with /// [`Terminal::fs`] or render [`Terminal::take_output`]. pub fn terminal(&self) -> &Terminal { - &self.terminal + &self.sessions[0].terminal } - /// Mutable access to the underlying terminal, to drive it directly + /// Mutable access to the main session's terminal, to drive it directly /// between tool calls. pub fn terminal_mut(&mut self) -> &mut Terminal { - &mut self.terminal + &mut self.sessions[0].terminal + } + + /// The terminal of a named session, if it is open. + pub fn session(&self, name: &str) -> Option<&Terminal> { + self.sessions + .iter() + .find(|s| s.name == name) + .map(|s| &s.terminal) + } + + /// Names of the open sessions, main first. + pub fn session_names(&self) -> Vec { + self.sessions.iter().map(|s| s.name.clone()).collect() + } + + fn new_terminal(&self) -> Option { + let factory = self.factory.as_ref()?; + let fs = self.sessions[0].terminal.fs(); + Some(Terminal::with_size(factory().fs(fs), self.size)) } /// Tool name: `terminal`. @@ -137,11 +223,17 @@ Set wait_for to a regex to return as soon as command output matches (for example 'listening' line); matched tells whether it did. Set screen to \"changes\" to get only the rows \ that changed since the last call, or \"none\" to skip the screen." .to_string() + + if self.factory.is_some() { + " Set session to a name to use another terminal tab (created on first use, files \ +shared, shell state separate; other tabs keep running); close: true closes it." + } else { + "" + } } /// JSON Schema for [`call`](Self::call) arguments. pub fn input_schema(&self) -> Value { - json!({ + let mut schema = json!({ "type": "object", "properties": { "input": { @@ -164,7 +256,19 @@ that changed since the last call, or \"none\" to skip the screen." "description": "full (default): whole screen. changes: only rows that differ from the previous call, as screen_changes. none: no screen." } } - }) + }); + if self.factory.is_some() { + schema["properties"]["session"] = json!({ + "type": "string", + "maxLength": MAX_SESSION_NAME, + "description": "Terminal tab to use (default \"main\"). A new name opens a new tab sharing the same files; up to 8 tabs." + }); + schema["properties"]["close"] = json!({ + "type": "boolean", + "description": "Close the named session (not main) instead of typing into it." + }); + } + schema } /// JSON Schema for the value [`call`](Self::call) returns. @@ -192,6 +296,14 @@ that changed since the last call, or \"none\" to skip the screen." "properties": {"row": {"type": "integer"}, "col": {"type": "integer"}} }, "matched": {"type": "boolean", "description": "Whether wait_for matched (only when wait_for was given)"}, + "session": {"type": "string", "description": "Session this result is for (multi-session tools)"}, + "sessions": { + "type": "array", + "description": "Every open session and its activity (multi-session tools)", + "items": {"type": "object", "properties": {"name": {"type": "string"}, "activity": {"type": "string"}}} + }, + "restarted": {"type": "boolean", "description": "The session had exited and a fresh shell was started for this call"}, + "closed": {"type": "boolean", "description": "The session was closed"}, "activity": { "type": "string", "enum": ["prompt", "continuation", "running", "input", "exited"], @@ -272,74 +384,189 @@ that changed since the last call, or \"none\" to skip the screen." } }; + let name = match args.get("session") { + None | Some(Value::Null) => MAIN_SESSION, + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(TerminalToolError("`session` must be a string".into())), + }; + let close = match args.get("close") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(TerminalToolError("`close` must be a boolean".into())), + }; + let idx = self.open_session(name, close)?; + let Some(idx) = idx else { + let mut out = json!({"session": name, "closed": true}); + out["sessions"] = self.sessions_json(); + return Ok(out); + }; + let mut restarted = false; + if !input.is_empty() + && matches!( + self.sessions[idx].terminal.activity(), + TerminalActivity::Exited(_) + ) + && let Some(fresh) = self.new_terminal() + { + self.sessions[idx].terminal = fresh; + self.sessions[idx].last_rows = None; + restarted = true; + } + let bytes = parse_keys(input); - let mark = self.terminal.tty().output_mark(); - if self.terminal.send(&bytes) < bytes.len() { + let terminal = &self.sessions[idx].terminal; + let mark = terminal.tty().output_mark(); + if terminal.send(&bytes) < bytes.len() { return Err(TerminalToolError( "terminal input buffer is full; wait for the running command first".into(), )); } let wait = Duration::from_millis(wait_ms); let (waiting, matched) = match wait_for { - None => (self.wait_idle(wait).await, None), + None => (self.wait_idle(idx, wait).await, None), Some(re) => { - let (waiting, matched) = self.wait_match(wait, mark, &re).await; + let (waiting, matched) = self.wait_match(idx, wait, mark, &re).await; (waiting, Some(matched)) } }; - let mut out = self.report(waiting, mode); + let mut out = self.report(idx, waiting, mode); if let Some(matched) = matched { out["matched"] = json!(matched); } + if restarted { + out["restarted"] = json!(true); + } Ok(out) } - async fn wait_idle(&mut self, wait: Duration) -> bool { - matches!( - crate::time_compat::timeout(wait, self.terminal.run_until_idle()).await, - Ok(TerminalStatus::Idle | TerminalStatus::Exited(_)) - ) + /// Index of session `name`, opening it if needed. `Ok(None)` when it was + /// closed (`close: true`). + fn open_session( + &mut self, + name: &str, + close: bool, + ) -> Result, TerminalToolError> { + if name.is_empty() + || name.len() > MAX_SESSION_NAME + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(TerminalToolError(format!( + "`session` must be 1-{MAX_SESSION_NAME} letters, digits, '-' or '_'" + ))); + } + let found = self.sessions.iter().position(|s| s.name == name); + if close { + return match found { + Some(0) => Err(TerminalToolError( + "the main session cannot be closed".into(), + )), + Some(i) => { + self.sessions.remove(i); + Ok(None) + } + None => Err(TerminalToolError(format!("no session named '{name}'"))), + }; + } + if let Some(i) = found { + return Ok(Some(i)); + } + if self.factory.is_none() { + return Err(TerminalToolError(format!( + "no session named '{name}'; this terminal has only the main session" + ))); + } + if self.sessions.len() >= MAX_SESSIONS { + return Err(TerminalToolError(format!( + "at most {MAX_SESSIONS} sessions; close one first" + ))); + } + let terminal = self.new_terminal().expect("factory checked above"); + self.sessions.push(ToolSession::new(name, terminal)); + Ok(Some(self.sessions.len() - 1)) + } + + /// Run session `idx` until it needs input or `wait` passes, while the + /// other sessions keep running in the background. + async fn wait_idle(&mut self, idx: usize, wait: Duration) -> bool { + let (before, rest) = self.sessions.split_at_mut(idx); + let (target, after) = rest.split_first_mut().expect("session index in range"); + let others = futures_util::future::join_all( + before + .iter_mut() + .chain(after.iter_mut()) + .map(|s| s.terminal.run_until_idle()), + ); + let background = async { + others.await; + std::future::pending::<()>().await; + }; + tokio::select! { + r = crate::time_compat::timeout(wait, target.terminal.run_until_idle()) => { + matches!(r, Ok(TerminalStatus::Idle | TerminalStatus::Exited(_))) + } + () = background => unreachable!("background never completes"), + } } /// Run until `re` matches output written since `mark`, the session needs /// input, or `wait` passes. Returns (waiting_for_input, matched). - async fn wait_match(&mut self, wait: Duration, mark: u64, re: ®ex::Regex) -> (bool, bool) { + async fn wait_match( + &mut self, + idx: usize, + wait: Duration, + mark: u64, + re: ®ex::Regex, + ) -> (bool, bool) { let deadline = crate::time_compat::Instant::now() + wait; loop { - if self.output_matches(mark, re) { - return (self.terminal.tty().is_idle(), true); + if self.output_matches(idx, mark, re) { + return (self.sessions[idx].terminal.tty().is_idle(), true); } let left = deadline.saturating_duration_since(crate::time_compat::Instant::now()); if left.is_zero() { return (false, false); } - if self.wait_idle(left.min(WAIT_SLICE)).await { - return (true, self.output_matches(mark, re)); + if self.wait_idle(idx, left.min(WAIT_SLICE)).await { + return (true, self.output_matches(idx, mark, re)); } } } - fn output_matches(&self, mark: u64, re: ®ex::Regex) -> bool { - let raw = self.terminal.tty().output_since(mark); + fn output_matches(&self, idx: usize, mark: u64, re: ®ex::Regex) -> bool { + let raw = self.sessions[idx].terminal.tty().output_since(mark); re.is_match(&strip_ansi(&String::from_utf8_lossy(&raw))) } - fn report(&mut self, waiting: bool, mode: ScreenMode) -> Value { + fn sessions_json(&self) -> Value { + json!( + self.sessions + .iter() + .map(|s| json!({"name": s.name, "activity": activity_name(&s.terminal)})) + .collect::>() + ) + } + + fn report(&mut self, idx: usize, waiting: bool, mode: ScreenMode) -> Value { + let multi = self.sessions.len() > 1; + let sessions = multi.then(|| self.sessions_json()); + let session = &mut self.sessions[idx]; + let terminal = &session.terminal; let mut out = json!({ "waiting_for_input": waiting, - "full_screen": self.terminal.is_alternate_screen(), - "commands": self - .terminal + "full_screen": terminal.is_alternate_screen(), + "commands": terminal .take_transcript() .into_iter() .map(record_json) .collect::>(), }); - let (activity, extra) = match self.terminal.activity() { + let (activity, extra) = match terminal.activity() { TerminalActivity::Starting | TerminalActivity::Prompt => ("prompt", None), TerminalActivity::ContinuationPrompt => ("continuation", None), TerminalActivity::Running { command } => { - if let Some(prompt) = self.terminal.input_prompt() { + if let Some(prompt) = terminal.input_prompt() { out["input_prompt"] = json!(prompt); ("input", Some(("running_command", json!(command)))) } else { @@ -352,11 +579,11 @@ that changed since the last call, or \"none\" to skip the screen." if let Some((key, value)) = extra { out[key] = value; } - let rows = self.terminal.screen_rows(); + let rows = terminal.screen_rows(); match mode { - ScreenMode::Full => out["screen"] = json!(self.terminal.screen_text()), + ScreenMode::Full => out["screen"] = json!(terminal.screen_text()), ScreenMode::Changes => { - let prev = self.last_rows.as_deref().unwrap_or(&[]); + let prev = session.last_rows.as_deref().unwrap_or(&[]); let resized = prev.len() != rows.len(); let changes: Vec = rows .iter() @@ -364,17 +591,32 @@ that changed since the last call, or \"none\" to skip the screen." .filter(|(i, row)| resized || prev.get(*i) != Some(*row)) .map(|(i, row)| json!({"row": i, "text": row})) .collect(); - let (row, col) = self.terminal.cursor(); + let (row, col) = terminal.cursor(); out["screen_changes"] = json!(changes); out["cursor"] = json!({"row": row, "col": col}); } ScreenMode::None => {} } - self.last_rows = Some(rows); + session.last_rows = Some(rows); + if let Some(sessions) = sessions { + out["session"] = json!(session.name); + out["sessions"] = sessions; + } out } } +/// The `activity` value reported for a terminal. +fn activity_name(terminal: &Terminal) -> &'static str { + match terminal.activity() { + TerminalActivity::Starting | TerminalActivity::Prompt => "prompt", + TerminalActivity::ContinuationPrompt => "continuation", + TerminalActivity::Running { .. } if terminal.input_prompt().is_some() => "input", + TerminalActivity::Running { .. } => "running", + TerminalActivity::Exited(_) => "exited", + } +} + fn compile_pattern(pattern: &str) -> Result { if pattern.len() > MAX_PATTERN_BYTES { return Err(TerminalToolError(format!( @@ -702,6 +944,115 @@ mod tests { assert_eq!(out["activity"], "running"); } + fn multi() -> TerminalTool { + TerminalTool::with_sessions(TerminalSize::default(), Box::new(Bash::builder)) + } + + #[tokio::test] + async fn sessions_share_files_not_shell_state() { + let mut tool = multi(); + tool.call(json!({"input": "cd /tmp; X=1; echo data > f"})) + .await + .unwrap(); + let out = tool + .call(json!({"session": "b", "input": "cat /tmp/f; echo x=$X; pwd"})) + .await + .unwrap(); + assert_eq!(out["session"], "b"); + assert_eq!(out["commands"][0]["output"], "data\nx=\n/home/user\n"); + let names: Vec<_> = out["sessions"] + .as_array() + .unwrap() + .iter() + .map(|s| s["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["main", "b"]); + assert_eq!(tool.session_names(), ["main", "b"]); + // Back on main, its state is intact. + let out = tool + .call(json!({"input": "echo x=$X; pwd"})) + .await + .unwrap(); + assert_eq!(out["commands"][0]["output"], "x=1\n/tmp\n"); + } + + #[tokio::test] + async fn other_sessions_keep_running() { + let mut tool = multi(); + // A slow writer in "bg"; return right away. + let out = tool + .call(json!({ + "session": "bg", + "input": "for i in 1 2 3; do echo $i >> /tmp/log; sleep 0.1; done", + "wait_ms": 0 + })) + .await + .unwrap(); + assert_eq!(out["activity"], "running"); + // Waiting in main drives bg too. + let out = tool + .call(json!({"input": "sleep 0.6; cat /tmp/log"})) + .await + .unwrap(); + assert_eq!(out["commands"][0]["output"], "1\n2\n3\n"); + let bg = &out["sessions"][1]; + assert_eq!(bg["name"], "bg"); + assert_eq!(bg["activity"], "prompt"); + } + + #[tokio::test] + async fn close_restart_and_limits() { + let mut tool = multi(); + tool.call(json!({"session": "t", "input": "exit 3"})) + .await + .unwrap(); + let out = tool + .call(json!({"session": "t", "input": "echo back"})) + .await + .unwrap(); + assert_eq!(out["restarted"], true); + assert_eq!(out["commands"][0]["output"], "back\n"); + let out = tool + .call(json!({"session": "t", "close": true})) + .await + .unwrap(); + assert_eq!(out["closed"], true); + assert_eq!(tool.session_names(), ["main"]); + assert!(tool.call(json!({"close": true})).await.is_err()); + assert!(tool.call(json!({"session": "a b"})).await.is_err()); + assert!( + tool.call(json!({"session": "nope", "close": true})) + .await + .is_err() + ); + for i in 1..MAX_SESSIONS { + tool.call(json!({"session": format!("s{i}"), "wait_ms": 0})) + .await + .unwrap(); + } + let err = tool.call(json!({"session": "extra"})).await.unwrap_err(); + assert!(err.to_string().contains("at most 8")); + } + + #[tokio::test] + async fn single_session_tool_rejects_other_sessions() { + let mut tool = TerminalTool::new(Bash::builder()); + assert!(tool.input_schema()["properties"].get("session").is_none()); + let err = tool.call(json!({"session": "b"})).await.unwrap_err(); + assert!(err.to_string().contains("only the main session")); + let out = tool + .call(json!({"session": "main", "input": "true"})) + .await + .unwrap(); + assert!(out.get("sessions").is_none()); + assert!( + multi().input_schema()["properties"] + .get("session") + .is_some() + ); + assert!(multi().system_prompt().contains("session")); + } + #[test] fn definition_matches_schema() { let tool = TerminalTool::new(Bash::builder()); diff --git a/docs/terminal.md b/docs/terminal.md index 7172e2f5a..0adcf858a 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -206,6 +206,24 @@ Two optional arguments keep agent loops short: `screen_changes` (`[{row, text}]`, only rows that differ from the previous call) and `cursor`; `"none"` returns no screen. +### Several sessions + +`TerminalTool::with_sessions(size, factory)` lets the agent open terminal tabs +with the `session` argument (`"main"` by default, up to 8). A new name opens a +new shell from `factory` that shares the main session's files; variables, the +working directory and jobs stay per session. While a call waits on one session +the others keep running, so a loop or `watch` in one tab progresses while the +agent works in another. Results carry `session` and `sessions` (each tab and +its activity) once more than one is open. `close: true` closes a tab, and a +tab whose shell exited starts a fresh one the next time keys are sent to it. +The Python and JavaScript `Terminal` classes always support sessions. + +```rust +let mut tool = TerminalTool::with_sessions(TerminalSize::default(), Box::new(Bash::builder)); +tool.call(json!({"session": "logs", "input": "watch -n1 'tail -3 /tmp/app.log'", "wait_ms": 0})).await?; +tool.call(json!({"input": "./run.sh"})).await?; // "logs" keeps refreshing meanwhile +``` + It is not a `BashTool`: that tool runs each call in a fresh shell, while a terminal keeps the shell, open programs and the screen between calls. diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index 26c2773af..021d8fb44 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -111,6 +111,21 @@ reply carries `matched`. `screen: "changes"` returns only rows that differ from the previous call plus the cursor, `"none"` drops the screen; every call updates the baseline. Long agent sessions resend a 24-row screen otherwise. +## Decision: sessions are tool-level tabs sharing one VFS + +`TerminalTool::with_sessions(size, factory)` holds up to 8 `Terminal`s +(TM-DOS-119); the `session` argument picks one, creating it on first use from +`factory().fs(main_fs)`. Why a factory: `BashBuilder` is not `Clone`, and +each session needs its own interpreter (shell state is per tab, like real +terminal tabs on one machine). Why shared files: tabs are only useful if a +command in one sees what another wrote. While waiting on the target session +the tool polls the others' `run_until_idle` in a `join_all` beside it, so +background tabs progress only during tool calls (nothing runs between calls, +same as a single session). `close` drops a tab (not `main`); an exited tab +restarts on the next call with non-empty input (`restarted: true`). +`TerminalTool::new` stays single-session and does not advertise `session` in +its schema. Python/JS bindings always use `with_sessions`. + ## Decision: line discipline split - Cooked mode (prompt): the shell loop's own small line editor (echo, cursor