diff --git a/contracts/capability-parity.json b/contracts/capability-parity.json index 9aa0e0061..9f5e8b898 100644 --- a/contracts/capability-parity.json +++ b/contracts/capability-parity.json @@ -165,7 +165,7 @@ }, "napi": { "label": "NAPI JavaScript", - "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite"], + "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite", "interactive_terminal"], "evidence": { "execution_limits": ["crates/bashkit-js/__test__/security.spec.ts#WB: command limit is enforced"], "cancellation": ["crates/bashkit-js/__test__/basic.spec.ts#Bash: cancel works after reset"], @@ -181,14 +181,14 @@ "mounts": ["crates/bashkit-js/__test__/vfs.spec.ts#real filesystem requires and enforces allowedMountPaths"], "snapshots": ["crates/bashkit-js/__test__/snapshot-history.spec.ts#commit and checkout round-trip shell state and files"], "runtime_python": ["crates/bashkit-js/__test__/integration.spec.ts#integration: python opt-in executes embedded Python"], - "runtime_sqlite": ["crates/bashkit-js/__test__/runtime-compat/sqlite.test.mjs#sqlite"] + "runtime_sqlite": ["crates/bashkit-js/__test__/runtime-compat/sqlite.test.mjs#sqlite"], + "interactive_terminal": ["crates/bashkit-js/__test__/terminal.spec.ts#terminal: call edits a file in vi"] }, "unsupported": { "stdin": "execute() accepts a command string and output callback, not top-level host stdin.", "host_call_suspension": "NAPI custom builtins await callbacks directly; no request and resume handle is exposed.", "transport_hooks": "NAPI exposes network policy and credentials but no JavaScript HttpTransport hook.", - "runtime_typescript": "The NAPI package does not compile or expose the TypeScript runtime.", - "interactive_terminal": "Not exposed in the NAPI package yet." + "runtime_typescript": "The NAPI package does not compile or expose the TypeScript runtime." } }, "browser_wasm": { diff --git a/crates/bashkit-js/Cargo.toml b/crates/bashkit-js/Cargo.toml index 6eb1e6bec..e345c4e68 100644 --- a/crates/bashkit-js/Cargo.toml +++ b/crates/bashkit-js/Cargo.toml @@ -14,7 +14,7 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] -bashkit = { path = "../bashkit", features = ["python", "realfs", "jq", "interop", "http_client", "sqlite"] } +bashkit = { path = "../bashkit", features = ["python", "realfs", "jq", "interop", "http_client", "sqlite", "terminal"] } bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python", "jq"] } napi = { workspace = true } napi-derive = { workspace = true } diff --git a/crates/bashkit-js/__test__/terminal.spec.ts b/crates/bashkit-js/__test__/terminal.spec.ts new file mode 100644 index 000000000..109ce8f0e --- /dev/null +++ b/crates/bashkit-js/__test__/terminal.spec.ts @@ -0,0 +1,112 @@ +import test from "ava"; +import { Bash, Terminal } from "../wrapper.js"; + +// ============================================================================ +// Terminal: interactive in-process terminal (vi, less, persistent shell) +// ============================================================================ + +test("terminal: low-level loop runs commands and reads screen", async (t) => { + const term = new Terminal({ rows: 10, cols: 40 }); + t.is(await term.runUntilIdle(), "idle"); + term.send("echo hello\r"); + t.is(await term.runUntilIdle(), "idle"); + t.is(term.screenText(), "$ echo hello\nhello\n$"); + t.is(term.activity().state, "prompt"); + const [record] = term.takeTranscript(); + t.deepEqual(record, { + command: "echo hello", + output: "hello\n", + exitCode: 0, + outputTruncated: false, + }); +}); + +test("terminal: call edits a file in vi", async (t) => { + const term = new Terminal(); + let out = await term.call("vi /tmp/n.txt"); + t.is(out.activity, "running"); + t.is(out.running_command, "vi /tmp/n.txt"); + t.true(out.full_screen); + out = await term.call("ihello:wq"); + t.is(out.activity, "prompt"); + t.is(out.commands[0].exit_code, 0); + t.is((await term.readFile("/tmp/n.txt")).toString(), "hello\n"); +}); + +test("terminal: history keeps scrolled-off output", async (t) => { + const term = new Terminal({ rows: 5, cols: 30 }); + await term.call("seq 1 40"); + t.true(term.historyText().includes("\n1\n2\n")); + t.false(term.screenText().includes("\n1\n")); +}); + +test("terminal: timeout reports running command and Ctrl-C interrupts", async (t) => { + const term = new Terminal(); + term.send("sleep 0.5; echo done\r"); + t.is(await term.runUntilIdle(30), "timeout"); + 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. + 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: exit and exitCode", async (t) => { + const term = new Terminal(); + const out = await term.call("exit 3"); + t.is(out.activity, "exited"); + t.is(out.exit_code, 3); + t.is(term.exitCode, 3); + t.is(await term.runUntilIdle(), "exited"); +}); + +test("terminal: options apply to the session", async (t) => { + const term = new Terminal({ + username: "ada", + cwd: "/tmp", + env: { GREETING: "hi" }, + }); + const out = await term.call("echo $GREETING $(whoami) $PWD"); + t.is(out.commands[0].output, "hi ada /tmp\n"); +}); + +test("terminal: send accepts Buffer and raw output drains", async (t) => { + const term = new Terminal(); + term.send(Buffer.from("printf 'a\\nb\\n'\r")); + await term.runUntilIdle(); + t.true(term.takeOutput().toString().includes("a\r\nb\r\n")); + t.is(term.takeOutput().length, 0); +}); + +test("terminal: resize and size", async (t) => { + const term = new Terminal({ rows: 24, cols: 80 }); + term.resize(30, 100); + t.deepEqual(term.size(), [30, 100]); + const out = await term.call("echo $COLUMNS $LINES"); + t.is(out.commands[0].output, "100 30\n"); +}); + +test("terminal: tool metadata", (t) => { + const term = new Terminal(); + const def = term.toolDefinition(); + t.is(def.function.name, "terminal"); + t.truthy(def.function.parameters.properties.input); + t.true(term.systemPrompt().startsWith("terminal:")); +}); + +test("terminal: oversized input is rejected", async (t) => { + const term = new Terminal(); + await t.throwsAsync(() => term.call("a".repeat(64 * 1024 + 1))); +}); + +test("terminal: pagers stay non-interactive outside the terminal", (t) => { + const bash = new Bash(); + const result = bash.executeSync("seq 1 3 | less; vi /tmp/x"); + t.is(result.stdout, "1\n2\n3\n"); + t.is(result.exitCode, 1); +}); diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index cd0fded65..d656e82a0 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -13,6 +13,8 @@ //! work. This prevents CodeQL `rust/access-invalid-pointer` alerts caused by //! holding a raw-pointer-derived `&self` across `block_on` or `.await` points. +mod terminal; + use bashkit::interop::fs::{ BashkitFsAbiHandleV1, BashkitFsAbiOwnedHandleV1, export_filesystem, import_filesystem, }; diff --git a/crates/bashkit-js/src/terminal.rs b/crates/bashkit-js/src/terminal.rs new file mode 100644 index 000000000..ccbbfa722 --- /dev/null +++ b/crates/bashkit-js/src/terminal.rs @@ -0,0 +1,304 @@ +//! `Terminal`: interactive in-process terminal (vi, less, a persistent shell) +//! for Node, Bun and Deno. +//! +//! Decisions: +//! - Wraps the core `TerminalTool`, so JS gets both the low-level loop +//! (`send` / `runUntilIdle` / `screenText`) and the agent-shaped +//! `call(input, waitMs)` with Vim key notation. +//! - `runUntilIdle` drives the session in short slices (`SLICE`), releasing the +//! lock between them. The core loop is cancellation-safe, so this changes +//! nothing for the session, and it lets `send("\x03")` or `screenText()` +//! from JS get in while a long command runs instead of waiting for it. +//! - Async methods are plain methods that clone the `Arc` and spawn a free +//! async function (`Env::spawn_future`), not `async fn(&self)`: the pending +//! promise never dereferences the napi-wrapped object (CodeQL +//! `rust/access-invalid-pointer`). +//! - Construction options are a small subset of `Bash` options (identity, +//! cwd, env, size). + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use bashkit::Bash as RustBash; +use bashkit::terminal::{TerminalActivity, TerminalSize, TerminalStatus, TerminalTool}; +use napi::Env; +use napi::bindgen_prelude::{Buffer, Either, PromiseRaw}; +use napi_derive::napi; +use tokio::sync::Mutex; + +const SLICE: Duration = Duration::from_millis(20); + +#[napi(object)] +pub struct TerminalOptions { + pub rows: Option, + pub cols: Option, + pub username: Option, + pub hostname: Option, + pub cwd: Option, + pub env: Option>, +} + +/// One finished command line. +#[napi(object)] +pub struct TerminalCommandRecord { + pub command: String, + /// Combined stdout and stderr with `\n` line endings. + pub output: String, + pub exit_code: i32, + pub output_truncated: bool, +} + +/// What the session is doing. +#[napi(object)] +pub struct TerminalActivityInfo { + /// `"prompt"`, `"continuation"`, `"running"` or `"exited"`. + pub state: String, + /// The running command line (state `"running"`). + pub command: Option, + /// Shell exit code (state `"exited"`). + pub exit_code: Option, +} + +fn clamp_dim(v: Option, default: u16) -> u16 { + v.map_or(default, |n| n.min(u32::from(u16::MAX)) as u16) +} + +/// Interactive bash session on an in-memory terminal. +#[napi] +pub struct Terminal { + tool: Arc>, +} + +#[napi] +impl Terminal { + #[napi(constructor)] + pub fn new(options: Option) -> Self { + let opts = options.unwrap_or(TerminalOptions { + rows: None, + cols: None, + username: None, + hostname: None, + 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)); + Self { + tool: Arc::new(Mutex::new(TerminalTool::with_size(builder, size))), + } + } + + /// Clone the Arc before blocking on the lock, so no raw-pointer-derived + /// `&self` is held while blocked (same pattern as `block_on_with` in lib.rs). + fn with(&self, f: impl FnOnce(&mut TerminalTool) -> R) -> R { + let tool = Arc::clone(&self.tool); + let mut guard = tool.blocking_lock(); + f(&mut guard) + } + + /// Queue raw input as if typed (`"\r"` Enter, `"\x1b"` Escape, + /// `"\x03"` Ctrl-C). Returns how many bytes were accepted. + #[napi] + pub fn send(&self, data: Either) -> u32 { + let bytes: Vec = match data { + Either::A(s) => s.into_bytes(), + Either::B(b) => b.to_vec(), + }; + self.with(|t| t.terminal().send(&bytes)) as u32 + } + + /// Run until the session needs input or exits. Resolves `"idle"`, + /// `"exited"`, or `"timeout"` when `timeoutMs` passes first (the command + /// keeps running state; call again or send Ctrl-C). + #[napi(ts_return_type = "Promise<'idle' | 'exited' | 'timeout'>")] + pub fn run_until_idle<'env>( + &self, + env: &'env Env, + timeout_ms: Option, + ) -> napi::Result> { + 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`). + #[napi(js_name = "__callJson", ts_return_type = "Promise")] + pub fn call_json<'env>( + &self, + env: &'env Env, + input: Option, + wait_ms: Option, + ) -> napi::Result> { + env.spawn_future(call_json(Arc::clone(&self.tool), input, wait_ms)) + } + + /// Visible screen as plain text. + #[napi] + pub fn screen_text(&self) -> String { + self.with(|t| t.terminal().screen_text()) + } + + /// Screen plus up to 1000 lines of scrollback. + #[napi] + pub fn history_text(&self) -> String { + self.with(|t| t.terminal().history_text()) + } + + /// Raw output bytes (with escape sequences) since the last call, for a + /// renderer such as xterm.js. + #[napi] + pub fn take_output(&self) -> Buffer { + Buffer::from(self.with(|t| t.terminal().take_output())) + } + + /// Commands that finished since the last call. + #[napi] + pub fn take_transcript(&self) -> Vec { + self.with(|t| t.terminal().take_transcript()) + .into_iter() + .map(|r| TerminalCommandRecord { + command: r.command, + output: r.output, + exit_code: r.exit_code, + output_truncated: r.output_truncated, + }) + .collect() + } + + /// What the session is doing. + #[napi] + pub fn activity(&self) -> TerminalActivityInfo { + let (state, command, exit_code) = match self.with(|t| t.terminal().activity()) { + TerminalActivity::Starting | TerminalActivity::Prompt => ("prompt", None, None), + TerminalActivity::ContinuationPrompt => ("continuation", None, None), + TerminalActivity::Running { command } => ("running", Some(command), None), + TerminalActivity::Exited(code) => ("exited", None, Some(code)), + }; + TerminalActivityInfo { + state: state.into(), + command, + exit_code, + } + } + + /// Cursor position as `[row, col]`, zero-based. + #[napi(ts_return_type = "[number, number]")] + pub fn cursor(&self) -> Vec { + let (r, c) = self.with(|t| t.terminal().cursor()); + vec![u32::from(r), u32::from(c)] + } + + /// True while a full-screen program such as `vi` is open. + #[napi] + pub fn is_alternate_screen(&self) -> bool { + self.with(|t| t.terminal().is_alternate_screen()) + } + + /// Resize the terminal; a running `vi` redraws. + #[napi] + pub fn resize(&self, rows: u32, cols: u32) { + let size = TerminalSize::new(clamp_dim(Some(rows), 24), clamp_dim(Some(cols), 80)); + self.with(|t| t.terminal().resize(size)); + } + + /// `[rows, cols]`. + #[napi(ts_return_type = "[number, number]")] + pub fn size(&self) -> Vec { + let s = self.with(|t| t.terminal().size()); + vec![u32::from(s.rows), u32::from(s.cols)] + } + + /// Shell exit code once the session has exited. + #[napi(getter)] + pub fn exit_code(&self) -> Option { + self.with(|t| t.terminal().exit_code()) + } + + /// Read a file from the session's virtual filesystem. + #[napi(ts_return_type = "Promise")] + pub fn read_file<'env>( + &self, + env: &'env Env, + path: String, + ) -> napi::Result> { + env.spawn_future(read_file(Arc::clone(&self.tool), path)) + } + + /// OpenAI-compatible function definition for `call`, as JSON. + #[napi(js_name = "__toolDefinitionJson")] + pub fn tool_definition_json(&self) -> String { + self.with(|t| t.tool_definition()).to_string() + } + + /// Terse usage guide for a system prompt. + #[napi] + pub fn system_prompt(&self) -> String { + self.with(|t| t.system_prompt()) + } +} + +// Async work runs in free functions that own a cloned `Arc`: the spawned +// future never touches the napi-wrapped `Terminal`, so it stays valid even if +// JS drops the object while the promise is pending. + +async fn run_until_idle( + tool: Arc>, + timeout_ms: Option, +) -> napi::Result { + let deadline = + timeout_ms.map(|ms| tokio::time::Instant::now() + Duration::from_millis(u64::from(ms))); + loop { + let mut slice = SLICE; + if let Some(d) = deadline { + let left = d.saturating_duration_since(tokio::time::Instant::now()); + if left.is_zero() { + return Ok("timeout".into()); + } + slice = slice.min(left); + } + let mut guard = tool.lock().await; + match tokio::time::timeout(slice, guard.terminal_mut().run_until_idle()).await { + Ok(TerminalStatus::Idle) => return Ok("idle".into()), + Ok(TerminalStatus::Exited(_)) => return Ok("exited".into()), + Err(_) => {} + } + drop(guard); + tokio::task::yield_now().await; + } +} + +async fn call_json( + tool: Arc>, + input: Option, + wait_ms: Option, +) -> napi::Result { + let mut args = serde_json::json!({ "input": input.unwrap_or_default() }); + if let Some(ms) = wait_ms { + args["wait_ms"] = ms.into(); + } + let mut guard = tool.lock().await; + let out = guard + .call(args) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(out.to_string()) +} + +async fn read_file(tool: Arc>, path: String) -> napi::Result { + let fs = tool.lock().await.terminal().fs(); + fs.read_file(std::path::Path::new(&path)) + .await + .map(Buffer::from) + .map_err(|e| napi::Error::from_reason(e.to_string())) +} diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index f06fdab8c..b64c3758e 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -14,6 +14,10 @@ import type { AnalyzedCommand, AnalyzedRedirect, ExecutionProfileName as NativeExecutionProfileName, + Terminal as NativeTerminalType, + TerminalOptions, + TerminalCommandRecord, + TerminalActivityInfo, } from "./index.cjs"; import { BashError, @@ -25,6 +29,7 @@ import { export type { ScriptAnalysis, AnalyzedCommand, AnalyzedRedirect }; export { BashError }; +export type { TerminalOptions, TerminalCommandRecord, TerminalActivityInfo }; /** Closed typed execution-policy selectors. */ export const ExecutionProfile = Object.freeze({ @@ -37,6 +42,7 @@ export type ExecutionProfileName = const NativeBash: typeof NativeBashType = native.Bash; const NativeBashTool: typeof NativeBashToolType = native.BashTool; +const NativeTerminal: typeof NativeTerminalType = native.Terminal; const nativeGetVersion: () => string = native.getVersion; const nativeCreateFileSystem: () => any = native.__createFileSystem; const nativeRealFileSystem: ( @@ -1946,6 +1952,151 @@ export function snapshotDiff( return native.snapshotDiff(commitA, commitB, objects); } +/** Result of {@link Terminal.call}. */ +/** OpenAI-compatible function definition returned by `Terminal.toolDefinition()`. */ +export interface TerminalToolDefinition { + type: "function"; + function: { + name: string; + description: string; + parameters: { + type: "object"; + properties: Record>; + required?: string[]; + }; + }; +} + +export interface TerminalCallResult { + /** Visible screen as plain text. */ + screen: string; + activity: "prompt" | "continuation" | "running" | "exited"; + /** Command line still running (activity `running`). */ + running_command?: string; + /** Shell exit code (activity `exited`). */ + exit_code?: number; + /** False when `waitMs` ran out while a command was still working. */ + waiting_for_input: boolean; + /** A full-screen program such as vi or less is open. */ + full_screen: boolean; + /** Commands that finished during this call. */ + commands: Array<{ + command: string; + output: string; + exit_code: number; + output_truncated?: boolean; + }>; +} + +/** + * Interactive bash session on an in-memory terminal: type keys, run until + * the shell needs input, read the screen as text. `vi`, `less` and `more` + * work. + * + * @example + * ```typescript + * const t = new Terminal({ rows: 24, cols: 80 }); + * await t.call("vi notes.txt"); + * const out = await t.call("ihello:wq"); + * out.commands[0].exit_code; // 0 + * ``` + */ +export class Terminal { + private readonly native: NativeTerminalType; + + constructor(options?: TerminalOptions) { + this.native = new NativeTerminal(options); + } + + /** Queue raw input as if typed (`"\r"` Enter, `"\x1b"` Escape, `"\x03"` Ctrl-C). */ + send(data: string | Uint8Array): number { + return this.native.send( + typeof data === "string" ? data : Buffer.from(data), + ); + } + + /** Run until the session needs input or exits, or `timeoutMs` passes. */ + runUntilIdle(timeoutMs?: number): Promise<"idle" | "exited" | "timeout"> { + return this.native.runUntilIdle(timeoutMs) as Promise< + "idle" | "exited" | "timeout" + >; + } + + /** + * Agent step: type `input` in Vim key notation (`"ls"`, + * `"ihi:wq"`, `""`), wait up to `waitMs` (default 5000), + * and report the screen, activity and finished commands. + */ + async call(input = "", waitMs?: number): Promise { + return JSON.parse(await this.native.__callJson(input, waitMs)); + } + + /** Visible screen as plain text. */ + screenText(): string { + return this.native.screenText(); + } + + /** Screen plus up to 1000 lines of scrollback. */ + historyText(): string { + return this.native.historyText(); + } + + /** Raw output bytes since the last call, for a renderer such as xterm.js. */ + takeOutput(): Buffer { + return this.native.takeOutput(); + } + + /** Commands that finished since the last call. */ + takeTranscript(): TerminalCommandRecord[] { + return this.native.takeTranscript(); + } + + /** What the session is doing. */ + activity(): TerminalActivityInfo { + return this.native.activity(); + } + + /** Cursor position `[row, col]`, zero-based. */ + cursor(): [number, number] { + return this.native.cursor(); + } + + /** True while a full-screen program such as `vi` is open. */ + isAlternateScreen(): boolean { + return this.native.isAlternateScreen(); + } + + /** Resize the terminal; a running `vi` redraws. */ + resize(rows: number, cols: number): void { + this.native.resize(rows, cols); + } + + /** `[rows, cols]`. */ + size(): [number, number] { + return this.native.size(); + } + + /** Shell exit code once the session has exited. */ + get exitCode(): number | null { + return this.native.exitCode ?? null; + } + + /** Read a file from the session's virtual filesystem. */ + readFile(path: string): Promise { + return this.native.readFile(path); + } + + /** OpenAI-compatible function definition for {@link Terminal.call}. */ + toolDefinition(): TerminalToolDefinition { + return JSON.parse(this.native.__toolDefinitionJson()); + } + + /** Terse usage guide for a system prompt. */ + systemPrompt(): string { + return this.native.systemPrompt(); + } +} + /** * Get the bashkit version string. */ diff --git a/docs/terminal.md b/docs/terminal.md index 0a096fe13..5831c82c1 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -217,6 +217,31 @@ print(t.screen_text(), t.history_text(), t.take_transcript(), t.activity()) `TerminalTool`. Calls are synchronous and release the GIL while the session runs; `timeout` bounds the wait. +## From JavaScript + +The `@everruns/bashkit` package (Node, Bun, Deno) exports the same class: + +```typescript +import { Terminal } from "@everruns/bashkit"; + +const term = new Terminal({ rows: 24, cols: 80, cwd: "/tmp" }); +let out = await term.call("vi notes.txt"); // Vim key notation +out = await term.call("ihello:wq"); +console.log(out.commands[0].exit_code); // 0 +console.log((await term.readFile("/tmp/notes.txt")).toString()); // "hello\n" + +// Low level: raw keys, bounded waits, screen and transcript. +term.send("sleep 10; echo done\r"); +await term.runUntilIdle(100); // "idle", "exited" or "timeout" +term.send("\x03"); // Ctrl-C while the command runs +await term.runUntilIdle(); +console.log(term.screenText(), term.takeTranscript(), term.activity()); +``` + +`runUntilIdle` and `call` are async and yield between short slices, so +`send`, `screenText` and the other sync methods work while a command runs. +`toolDefinition()` and `systemPrompt()` return the `TerminalTool` metadata. + ## vi `vi [FILE]` opens the editor on the alternate screen; quitting restores the diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index 12c5be19c..ab9eaf35a 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -14,8 +14,8 @@ tags: ## Status Implemented behind the `terminal` cargo feature (off by default). Python -exposes it as `bashkit.Terminal` (the Python package enables the feature); -JS and C bindings are not wired yet. +exposes it as `bashkit.Terminal` and the NAPI package as `Terminal` (both +packages enable the feature); browser wasm and C bindings are not wired yet. Code: `crates/bashkit/src/terminal/` (`Terminal`, the `Tty` device), `crates/bashkit/src/builtins/vi.rs`, `InputWaitClock` in @@ -174,9 +174,12 @@ redirected (L-TERM-004). ## Follow-ups -- JS (NAPI, browser wasm) and C bindings. Python ships `bashkit.Terminal` +- Browser wasm and C bindings. Shipped: Python `bashkit.Terminal` (`crates/bashkit-python/src/terminal.rs`, sync API on a per-instance - current-thread runtime, wraps `TerminalTool`). + current-thread runtime, wraps `TerminalTool`) and NAPI `Terminal` + (`crates/bashkit-js/src/terminal.rs`, async `runUntilIdle`/`call` that + drive the session in 20 ms slices so sync `send("\x03")` and `screenText()` + interleave with a long command). - Reader-backed stdin so `read` blocks on the terminal (lifts L-TERM-002 and helps L-CLI-002). - Expose the device to custom builtins for host-defined TUIs; `stty`/`tput`. diff --git a/knowledge/status/capability-parity.md b/knowledge/status/capability-parity.md index 65d12f766..9f9865aae 100644 --- a/knowledge/status/capability-parity.md +++ b/knowledge/status/capability-parity.md @@ -40,7 +40,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason | Explicit embedded Python opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | | Explicit embedded TypeScript opt-in | ✅ | ✅ | — | — | — | — | — | — | | Explicit embedded SQLite opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | -| In-process interactive terminal session (keystrokes in, screen text and per-command transcript out; vi, less) | ✅ | — | — | — | ✅ | — | — | — | +| In-process interactive terminal session (keystrokes in, screen text and per-command transcript out; vi, less) | ✅ | — | — | — | ✅ | ✅ | — | — | ## Intentional exclusions @@ -102,7 +102,6 @@ A dash means the feature is intentionally unsupported and has a recorded reason - `host_call_suspension`: NAPI custom builtins await callbacks directly; no request and resume handle is exposed. - `transport_hooks`: NAPI exposes network policy and credentials but no JavaScript HttpTransport hook. - `runtime_typescript`: The NAPI package does not compile or expose the TypeScript runtime. -- `interactive_terminal`: Not exposed in the NAPI package yet. ### Browser WASM