Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions crates/bashkit-js/__test__/terminal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,35 @@ test("terminal: timeout reports running command and Ctrl-C interrupts", async (t
const activity = term.activity();
t.is(activity.state, "running");
t.is(activity.command, "sleep 0.5; echo done");
// Sync send lands while the command still runs; Ctrl-C cancels at the
// next command boundary, so `echo done` never runs.
// Sync send lands while the command still runs; Ctrl-C drops it at once,
// so `echo done` never runs.
term.send("\x03");
t.is(await term.runUntilIdle(5000), "idle");
const [record] = term.takeTranscript();
t.is(record.exitCode, 130);
t.false(record.output.includes("done"));
});

test("terminal: wait_for and screen changes", async (t) => {
const term = new Terminal();
const out = await term.call({
input: "for i in 1 2 3; do echo tick$i; sleep 1; done<Enter>",
wait_ms: 10000,
wait_for: "tick2",
screen: "changes",
});
t.is(out.matched, true);
t.is(out.activity, "running");
t.is(out.screen, undefined);
t.true(out.screen_changes!.some((c) => c.text === "tick2"));
const after = await term.call({ input: "<C-c>", screen: "changes" });
t.is(after.activity, "prompt");
t.deepEqual(
after.screen_changes!.slice(-2).map((c) => c.text),
["^C", "$"],
);
});

test("terminal: exit and exitCode", async (t) => {
const term = new Terminal();
const out = await term.call("exit 3<Enter>");
Expand Down
20 changes: 7 additions & 13 deletions crates/bashkit-js/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,15 @@ impl Terminal {
env.spawn_future(run_until_idle(Arc::clone(&self.tool), timeout_ms))
}

/// Agent step as a JSON string; the JS wrapper parses it (see `call`).
/// Agent step: tool arguments as a JSON string in, result JSON out; the
/// JS wrapper builds and parses them (see `call`).
#[napi(js_name = "__callJson", ts_return_type = "Promise<string>")]
pub fn call_json<'env>(
&self,
env: &'env Env,
input: Option<String>,
wait_ms: Option<u32>,
args_json: String,
) -> napi::Result<PromiseRaw<'env, String>> {
env.spawn_future(call_json(Arc::clone(&self.tool), input, wait_ms))
env.spawn_future(call_json(Arc::clone(&self.tool), args_json))
}

/// Visible screen as plain text.
Expand Down Expand Up @@ -278,15 +278,9 @@ async fn run_until_idle(
}
}

async fn call_json(
tool: Arc<Mutex<TerminalTool>>,
input: Option<String>,
wait_ms: Option<u32>,
) -> napi::Result<String> {
let mut args = serde_json::json!({ "input": input.unwrap_or_default() });
if let Some(ms) = wait_ms {
args["wait_ms"] = ms.into();
}
async fn call_json(tool: Arc<Mutex<TerminalTool>>, args_json: String) -> napi::Result<String> {
let args: serde_json::Value = serde_json::from_str(&args_json)
.map_err(|e| napi::Error::from_reason(format!("invalid call arguments: {e}")))?;
let mut guard = tool.lock().await;
let out = guard
.call(args)
Expand Down
36 changes: 30 additions & 6 deletions crates/bashkit-js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2001,9 +2001,27 @@ export interface TerminalToolDefinition {
};
}

/** Arguments of {@link Terminal.call}, named as in `toolDefinition()`. */
export interface TerminalCallArgs {
/** Keys in Vim notation (`"ls<Enter>"`, `"<C-c>"`). */
input?: string;
/** How long to wait for the session to need input (default 5000). */
wait_ms?: number;
/** Regex: return as soon as command output printed during the call matches. */
wait_for?: string;
/** `full` (default), `changes` (only changed rows) or `none`. */
screen?: "full" | "changes" | "none";
}

export interface TerminalCallResult {
/** Visible screen as plain text. */
screen: string;
/** Visible screen as plain text (screen `full`). */
screen?: string;
/** Rows changed since the previous call (screen `changes`). */
screen_changes?: Array<{ row: number; text: string }>;
/** Cursor position, 0-based (screen `changes`). */
cursor?: { row: number; col: number };
/** Whether `wait_for` matched (only when it was given). */
matched?: boolean;
activity: "prompt" | "continuation" | "running" | "exited";
/** Command line still running (activity `running`). */
running_command?: string;
Expand Down Expand Up @@ -2059,10 +2077,16 @@ export class Terminal {
/**
* Agent step: type `input` in Vim key notation (`"ls<Enter>"`,
* `"ihi<Esc>:wq<Enter>"`, `"<C-c>"`), wait up to `waitMs` (default 5000),
* and report the screen, activity and finished commands.
*/
async call(input = "", waitMs?: number): Promise<TerminalCallResult> {
return JSON.parse(await this.native.__callJson(input, waitMs));
* and report the screen, activity and finished commands. Pass an object
* to forward a model's tool call as is (`wait_for`, `screen`, ...).
*/
async call(
input: string | TerminalCallArgs = "",
waitMs?: number,
): Promise<TerminalCallResult> {
const args: TerminalCallArgs =
typeof input === "string" ? { input, wait_ms: waitMs } : input;
return JSON.parse(await this.native.__callJson(JSON.stringify(args)));
}

/** Visible screen as plain text. */
Expand Down
14 changes: 12 additions & 2 deletions crates/bashkit-python/bashkit/_bashkit.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -1931,8 +1931,18 @@ class Terminal:
def run_until_idle(self, timeout: float | None = None) -> Literal["idle", "exited", "timeout"]:
"""Run until the session needs input or exits, or ``timeout`` seconds pass."""
...
def call(self, input: str = "", wait_ms: int | None = None) -> dict[str, Any]:
"""Agent step: type ``input`` in Vim key notation, wait, report screen and commands."""
def call(
self,
input: str = "",
wait_ms: int | None = None,
wait_for: str | None = None,
screen: Literal["full", "changes", "none"] | 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.
"""
...
def screen_text(self) -> str: ...
def history_text(self) -> str: ...
Expand Down
22 changes: 19 additions & 3 deletions crates/bashkit-python/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,29 @@ impl PyTerminal {

/// Agent-style step: type `input` (Vim key notation such as
/// `"ihello<Esc>:wq<Enter>"`), wait up to `wait_ms`, and return a dict
/// with `screen`, `activity`, `commands` and more. See `tool_definition()`.
#[pyo3(signature = (input="", wait_ms=None))]
fn call(&self, py: Python<'_>, input: &str, wait_ms: Option<u64>) -> PyResult<Py<PyAny>> {
/// 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))]
fn call(
&self,
py: Python<'_>,
input: &str,
wait_ms: Option<u64>,
wait_for: Option<&str>,
screen: Option<&str>,
) -> PyResult<Py<PyAny>> {
let mut args = serde_json::json!({ "input": input });
if let Some(ms) = wait_ms {
args["wait_ms"] = ms.into();
}
if let Some(p) = wait_for {
args["wait_for"] = p.into();
}
if let Some(m) = screen {
args["screen"] = m.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)
Expand Down
17 changes: 17 additions & 0 deletions crates/bashkit-python/tests/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ def test_timeout_reports_running_command():
assert t.take_transcript()[0]["output"] == "done\n"


def test_wait_for_and_screen_changes():
t = Terminal()
out = t.call(
"for i in 1 2 3; do echo tick$i; sleep 1; done<Enter>",
wait_ms=10000,
wait_for="tick2",
screen="changes",
)
assert out["matched"] is True
assert out["activity"] == "running"
assert "screen" not in out
assert any(c["text"] == "tick2" for c in out["screen_changes"])
out = t.call(**{"input": "<C-c>", "screen": "changes"})
assert out["activity"] == "prompt"
assert [c["text"] for c in out["screen_changes"]][-2:] == ["^C", "$"]


def test_exit_and_exit_code():
t = Terminal()
out = t.call("exit 3<Enter>")
Expand Down
6 changes: 6 additions & 0 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,12 @@ impl Interpreter {
}
}

/// Set `$?` from outside a command (terminal carries it across lines).
#[cfg(feature = "terminal")]
pub(crate) fn set_last_exit_code(&mut self, code: i32) {
self.last_exit_code = code;
}

/// Return a shared cancellation token. Set it to `true` from any thread
/// to abort execution at the next command boundary.
pub fn cancellation_token(&self) -> Arc<AtomicBool> {
Expand Down
85 changes: 74 additions & 11 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,27 @@ pub struct Bash {
/// excluded from the execution timeout.
#[cfg(feature = "terminal")]
input_wait_clock: Option<time_compat::InputWaitClock>,
/// Set by [`terminal::Terminal`]: a notify fired on Ctrl-C. While set,
/// `exec` aborts the running command mid-way when it fires and the
/// cancellation token is set, instead of waiting for the next command
/// boundary.
#[cfg(feature = "terminal")]
interrupt: Option<Arc<tokio::sync::Notify>>,
/// `$?` for the next exec: an interactive shell keeps `$?` across lines.
#[cfg(feature = "terminal")]
carried_exit_code: Option<i32>,
}

/// Resolve once `notify` fires while `cancel` is set. A notify permit left
/// over from an earlier command (the flag already reset) is ignored.
#[cfg(feature = "terminal")]
async fn wait_for_interrupt(notify: &tokio::sync::Notify, cancel: &std::sync::atomic::AtomicBool) {
loop {
notify.notified().await;
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
}
}

impl Default for Bash {
Expand Down Expand Up @@ -954,6 +975,10 @@ impl Bash {
async fn exec_impl(&mut self, script: &str, invocation: Invocation) -> Result<ExecResult> {
// THREAT[TM-ISO-005/006/007]: Reset transient state between exec() calls
self.interpreter.reset_transient_state();
#[cfg(feature = "terminal")]
if let Some(code) = self.carried_exit_code.take() {
self.interpreter.set_last_exit_code(code);
}

// THREAT[TM-DOS-059]: Count every host exec() call at the boundary so
// malformed or parser-expensive scripts cannot bypass session limits.
Expand Down Expand Up @@ -1166,17 +1191,44 @@ impl Bash {
// prevent sleep and pending async callbacks from bypassing the budget.
let execution_timeout = self.interpreter.limits().timeout;
#[cfg(feature = "terminal")]
let timed = match self.input_wait_clock.clone() {
Some(clock) => {
crate::time_compat::timeout_excluding_input_wait(
execution_timeout,
&clock,
self.interpreter.execute(&ast),
)
.await
}
None => {
crate::time_compat::timeout(execution_timeout, self.interpreter.execute(&ast)).await
let timed = {
let clock = self.input_wait_clock.clone();
let interrupt = self.interrupt.clone();
let cancel = self.interpreter.cancellation_token();
let run = async {
match clock {
Some(clock) => {
crate::time_compat::timeout_excluding_input_wait(
execution_timeout,
&clock,
self.interpreter.execute(&ast),
)
.await
}
None => {
crate::time_compat::timeout(
execution_timeout,
self.interpreter.execute(&ast),
)
.await
}
}
};
// Terminal Ctrl-C: drop the running command mid-way, the same way
// the execution timeout does, then reconcile interpreter state.
let outcome = match interrupt {
Some(notify) => tokio::select! {
r = run => Some(r),
() = wait_for_interrupt(&notify, &cancel) => None,
},
None => Some(run.await),
};
match outcome {
Some(r) => r,
None => {
self.interpreter.clear_cancelled_execution_state();
Ok(Err(Error::Cancelled))
}
}
};
#[cfg(not(feature = "terminal"))]
Expand Down Expand Up @@ -1370,6 +1422,13 @@ impl Bash {
.await
}

/// Seed `$?` for the next exec (terminal: `$?` survives across lines,
/// and Ctrl-C reports 130).
#[cfg(feature = "terminal")]
pub(crate) fn carry_exit_code(&mut self, code: i32) {
self.carried_exit_code = Some(code);
}

/// Return a shared cancellation token.
///
/// Set the token to `true` from any thread to abort execution at the next
Expand Down Expand Up @@ -3846,6 +3905,10 @@ impl BashBuilder {
host_mounts: HostMounts::default(),
#[cfg(feature = "terminal")]
input_wait_clock: None,
#[cfg(feature = "terminal")]
interrupt: None,
#[cfg(feature = "terminal")]
carried_exit_code: None,
}
}
}
Expand Down
Loading
Loading