Skip to content

feat(python,node): expose live chunked stdout/stderr callbacks #1296

@oliverlambson

Description

@oliverlambson

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:

  1. A persistent interpreter/session
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions