When embedding bashkit outside the Rust core, output is only available as final buffered stdout / stderr strings after execution completes, which loses the incremental chunk ordering available from the core streaming path.
The Rust core already has a streaming execution path (exec_streaming(...)), but the high-level bindings should expose a small, explicit callback API for live output. This would make bashkit much easier to use in apps, CLIs, agents, TUIs, and web backends that want to show logs or react to output incrementally.
Use cases
- Print and flush output while a long-running command runs
- Build a stateful shell session inside a Python or JS app
- Parse log lines incrementally instead of waiting for the full result
- Preserve a more accurate view of stdout/stderr chunk ordering than the final buffered
ExecResult provides
- Show users "what has happened so far" instead of reconstructing output after the command finishes
- Surface progress output in agent / chat / terminal UIs
- Keep final
ExecResult behavior, but also get live chunks
Proposed behavior
Add an optional per-call output callback on Bash and BashTool.
The callback should receive chunked (stdout_chunk, stderr_chunk) pairs during execution, while still returning the normal final ExecResult at the end.
This is not a request for PTY emulation or exact byte-level terminal ordering. Chunked callback semantics matching the existing Rust core are enough.
Minimal API shape
Python
OutputHandler = Callable[[str, str], None]
bash = Bash()
tool = BashTool()
await bash.execute(commands, on_output=handler)
bash.execute_sync(commands, on_output=handler)
await tool.execute(commands, on_output=handler)
tool.execute_sync(commands, on_output=handler)
JavaScript / TypeScript
Something in this shape would be sufficient:
type OnOutput = (stdoutChunk: string, stderrChunk: string) => void;
const bash = new Bash();
const tool = new BashTool();
await bash.execute(commands, { onOutput });
bash.executeSync(commands, { onOutput });
await tool.execute(commands, { onOutput });
tool.executeSync(commands, { onOutput });
Examples
Minimal example
bash = Bash()
def on_output(stdout: str, stderr: str) -> None:
"""Unbuffered output handler"""
if stdout:
print("[stdout]", stdout, end="", flush=True)
if stderr:
print("[stderr]", stderr, end="", flush=True)
bash.execute_sync(
"for i in 1 2 3; do echo out-$i; echo err-$i >&2; done",
on_output=on_output,
)
# Output:
# [stdout] out-1
# [stderr] err-1
# [stdout] out-2
# [stderr] err-2
# [stdout] out-3
# [stderr] err-3
Line-buffered example
This is more complex but more representative of what (my) real-world usage could look like
Because callbacks are chunked rather than newline-aligned, callers that want
to process complete lines generally need a tiny per-stream buffer.
This example intentionally splits each logical line across multiple callbacks
using separate printf calls. Without line buffering, the raw callback stream
can look more like out-, then 1\n, then err-, then 1\n.
import sys
from typing import Callable
from bashkit import Bash
bash = Bash()
def line_buffered(
on_line: Callable[[str, str], None]
) -> tuple[Callable[[str, str], None], Callable[[], None]]:
"""Line-buffered output handler"""
pending = {"stdout": "", "stderr": ""}
def on_output(stdout: str, stderr: str) -> None:
for stream, chunk in (("stdout", stdout), ("stderr", stderr)):
if not chunk:
continue
pending[stream] += chunk
while "\n" in pending[stream]:
line, pending[stream] = pending[stream].split("\n", 1)
on_line(stream, line)
def flush() -> None:
for stream, remainder in pending.items():
if remainder:
on_line(stream, remainder)
pending[stream] = ""
return on_output, flush
def on_line(stream: str, line: str) -> None:
target = sys.stdout if stream == "stdout" else sys.stderr
print(f"[{stream}] {line}", file=target, flush=True)
on_output, flush = line_buffered(on_line)
bash.execute_sync(
"""
for i in 1 2 3; do
printf 'out-'
printf '%s\n' "$i"
printf 'err-' >&2
printf '%s\n' "$i" >&2
done
""",
on_output=on_output,
)
flush()
# Output:
# [stdout] out-1
# [stderr] err-1
# [stdout] out-2
# [stderr] err-2
# [stdout] out-3
# [stderr] err-3
Expected semantics
- The callback is optional.
- The callback fires during execution, not only at the end.
- The callback receives chunked stdout/stderr pairs.
- Either side may be empty for a given callback.
- The final
ExecResult.stdout / ExecResult.stderr should still contain the full accumulated output.
- Concatenating all stdout chunks should equal final
stdout.
- Concatenating all stderr chunks should equal final
stderr.
- Callback exceptions/errors should abort execution and surface clearly to the caller.
Non-goals / clarifications
- Not asking for exact byte-level interleaving fidelity.
- Not asking for PTY emulation.
- Not asking to change current background job semantics.
- Not asking to remove the existing buffered
ExecResult behavior.
Why this matters
Without a live callback surface, embedded bashkit sessions are much less useful for real interactive tooling. In practice, consumers want both:
- A persistent interpreter/session
- Incremental output while a command is running
The Rust core already supports the second part; exposing it cleanly in bindings would unlock a strong embedding story.
Acceptance criteria
Bash and BashTool expose optional live output callbacks
- Docs include at least one example showing
print(..., flush=True) / equivalent
- Tests verify chunk concatenation matches final
ExecResult
- Tests cover callback failure/error propagation
- Docs clearly state that output is chunked, not byte-accurate terminal interleaving
When embedding bashkit outside the Rust core, output is only available as final buffered
stdout/stderrstrings after execution completes, which loses the incremental chunk ordering available from the core streaming path.The Rust core already has a streaming execution path (
exec_streaming(...)), but the high-level bindings should expose a small, explicit callback API for live output. This would make bashkit much easier to use in apps, CLIs, agents, TUIs, and web backends that want to show logs or react to output incrementally.Use cases
ExecResultprovidesExecResultbehavior, but also get live chunksProposed behavior
Add an optional per-call output callback on
BashandBashTool.The callback should receive chunked
(stdout_chunk, stderr_chunk)pairs during execution, while still returning the normal finalExecResultat the end.This is not a request for PTY emulation or exact byte-level terminal ordering. Chunked callback semantics matching the existing Rust core are enough.
Minimal API shape
Python
JavaScript / TypeScript
Something in this shape would be sufficient:
Examples
Minimal example
Line-buffered example
Because callbacks are chunked rather than newline-aligned, callers that want
to process complete lines generally need a tiny per-stream buffer.
This example intentionally splits each logical line across multiple callbacks
using separate
printfcalls. Without line buffering, the raw callback streamcan look more like
out-, then1\n, thenerr-, then1\n.Expected semantics
ExecResult.stdout/ExecResult.stderrshould still contain the full accumulated output.stdout.stderr.Non-goals / clarifications
ExecResultbehavior.Why this matters
Without a live callback surface, embedded bashkit sessions are much less useful for real interactive tooling. In practice, consumers want both:
The Rust core already supports the second part; exposing it cleanly in bindings would unlock a strong embedding story.
Acceptance criteria
BashandBashToolexpose optional live output callbacksprint(..., flush=True)/ equivalentExecResult