Add chat-friendly shell read rendering

Make workspace shell reads usable as direct chat-model input without changing the PTY or cursor model. This adds optional plain rendering and idle-window batching across CLI, SDK, and MCP while keeping raw reads backward-compatible.

Implement the rendering and wait-for-idle logic in the manager layer so the existing guest/backend shell transport stays unchanged. The new helper strips ANSI and other terminal control noise, handles carriage-return overwrite and backspace, and preserves raw cursor semantics even when plain output is requested.

Refresh the stable shell docs/examples to recommend --plain --wait-for-idle-ms 300, mark the 3.5.0 roadmap milestone done, and bump the package/catalog version to 3.5.0.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke covering shell open/write/read with ANSI plus delayed output.
This commit is contained in:
Thales Maciel 2026-03-13 01:10:26 -03:00
parent eecfd7a7d7
commit 21a88312b6
22 changed files with 539 additions and 45 deletions

View file

@ -330,12 +330,16 @@ class Pyro:
*,
cursor: int = 0,
max_chars: int = 65536,
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
return self._manager.read_shell(
workspace_id,
shell_id,
cursor=cursor,
max_chars=max_chars,
plain=plain,
wait_for_idle_ms=wait_for_idle_ms,
)
def write_shell(
@ -902,6 +906,8 @@ class Pyro:
shell_id: str,
cursor: int = 0,
max_chars: int = 65536,
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
"""Read merged PTY output from a workspace shell."""
return self.read_shell(
@ -909,6 +915,8 @@ class Pyro:
shell_id,
cursor=cursor,
max_chars=max_chars,
plain=plain,
wait_for_idle_ms=wait_for_idle_ms,
)
if _enabled("shell_write"):

View file

@ -532,6 +532,8 @@ def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None:
f"cursor={int(payload.get('cursor', 0))} "
f"next_cursor={int(payload.get('next_cursor', 0))} "
f"truncated={bool(payload.get('truncated', False))} "
f"plain={bool(payload.get('plain', False))} "
f"wait_for_idle_ms={payload.get('wait_for_idle_ms')} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
file=sys.stderr,
flush=True,
@ -1529,7 +1531,7 @@ def _build_parser() -> argparse.ArgumentParser:
Examples:
pyro workspace shell open WORKSPACE_ID
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT
pyro workspace shell close WORKSPACE_ID SHELL_ID
@ -1592,10 +1594,10 @@ def _build_parser() -> argparse.ArgumentParser:
epilog=dedent(
"""
Example:
pyro workspace shell read WORKSPACE_ID SHELL_ID --cursor 0
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
Shell output is written to stdout. The read summary is written to stderr.
Use --json for a deterministic structured response.
Use --plain for chat-friendly output and --json for a deterministic structured response.
"""
),
formatter_class=_HelpFormatter,
@ -1622,6 +1624,17 @@ def _build_parser() -> argparse.ArgumentParser:
default=65536,
help="Maximum number of characters to return from the current cursor position.",
)
workspace_shell_read_parser.add_argument(
"--plain",
action="store_true",
help="Strip terminal control sequences and normalize shell output for chat consumption.",
)
workspace_shell_read_parser.add_argument(
"--wait-for-idle-ms",
type=int,
default=None,
help="Wait for this many milliseconds of shell-idle time before returning output.",
)
workspace_shell_read_parser.add_argument(
"--json",
action="store_true",
@ -2652,6 +2665,8 @@ def main() -> None:
args.shell_id,
cursor=args.cursor,
max_chars=args.max_chars,
plain=bool(args.plain),
wait_for_idle_ms=args.wait_for_idle_ms,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):

View file

@ -83,7 +83,13 @@ PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
"--secret-env",
"--json",
)
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = (
"--cursor",
"--max-chars",
"--plain",
"--wait-for-idle-ms",
"--json",
)
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS = ("--json",)

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "3.4.0"
DEFAULT_CATALOG_VERSION = "3.5.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",

View file

@ -60,6 +60,7 @@ from pyro_mcp.workspace_files import (
write_workspace_file,
)
from pyro_mcp.workspace_ports import DEFAULT_PUBLISHED_PORT_HOST
from pyro_mcp.workspace_shell_output import render_plain_shell_output
from pyro_mcp.workspace_shells import (
create_local_shell,
get_local_shell,
@ -97,6 +98,9 @@ WORKSPACE_SECRET_MAX_BYTES = 64 * 1024
DEFAULT_SHELL_COLS = 120
DEFAULT_SHELL_ROWS = 30
DEFAULT_SHELL_MAX_CHARS = 65536
DEFAULT_SHELL_WAIT_FOR_IDLE_MS = 300
MAX_SHELL_WAIT_FOR_IDLE_MS = 10000
SHELL_IDLE_POLL_INTERVAL_SECONDS = 0.05
DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES = 65536
DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES = DEFAULT_WORKSPACE_FILE_READ_LIMIT
WORKSPACE_FILE_MAX_BYTES = WORKSPACE_FILE_MAX_LIMIT
@ -4597,24 +4601,42 @@ class VmManager:
*,
cursor: int = 0,
max_chars: int = DEFAULT_SHELL_MAX_CHARS,
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
if cursor < 0:
raise ValueError("cursor must not be negative")
if max_chars <= 0:
raise ValueError("max_chars must be positive")
if wait_for_idle_ms is not None and (
wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS
):
raise ValueError(
f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}"
)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_shell_locked(workspace)
shell = self._load_workspace_shell_locked(workspace_id, shell_id)
redact_values = self._workspace_secret_redact_values_locked(workspace)
try:
payload = self._backend.read_shell(
instance,
workspace_id=workspace_id,
shell_id=shell_id,
cursor=cursor,
max_chars=max_chars,
)
if wait_for_idle_ms is None:
payload = self._backend.read_shell(
instance,
workspace_id=workspace_id,
shell_id=shell_id,
cursor=cursor,
max_chars=max_chars,
)
else:
payload = self._read_shell_with_idle_wait(
instance=instance,
workspace_id=workspace_id,
shell_id=shell_id,
cursor=cursor,
max_chars=max_chars,
wait_for_idle_ms=wait_for_idle_ms,
)
except Exception as exc:
raise _redact_exception(exc, redact_values) from exc
updated_shell = self._workspace_shell_record_from_payload(
@ -4631,17 +4653,84 @@ class VmManager:
workspace.metadata = dict(instance.metadata)
self._save_workspace_locked(workspace)
self._save_workspace_shell_locked(updated_shell)
raw_output = _redact_text(str(payload.get("output", "")), redact_values)
rendered_output = render_plain_shell_output(raw_output) if plain else raw_output
truncated = bool(payload.get("truncated", False))
if plain and len(rendered_output) > max_chars:
rendered_output = rendered_output[:max_chars]
truncated = True
response = self._serialize_workspace_shell(updated_shell)
response.update(
{
"cursor": int(payload.get("cursor", cursor)),
"next_cursor": int(payload.get("next_cursor", cursor)),
"output": _redact_text(str(payload.get("output", "")), redact_values),
"truncated": bool(payload.get("truncated", False)),
"output": rendered_output,
"truncated": truncated,
"plain": plain,
"wait_for_idle_ms": wait_for_idle_ms,
}
)
return response
def _read_shell_with_idle_wait(
self,
*,
instance: VmInstance,
workspace_id: str,
shell_id: str,
cursor: int,
max_chars: int,
wait_for_idle_ms: int,
) -> dict[str, Any]:
wait_seconds = wait_for_idle_ms / 1000
current_cursor = cursor
remaining_chars = max_chars
raw_chunks: list[str] = []
last_payload: dict[str, Any] | None = None
saw_output = False
deadline = time.monotonic() + wait_seconds
while True:
payload = self._backend.read_shell(
instance,
workspace_id=workspace_id,
shell_id=shell_id,
cursor=current_cursor,
max_chars=remaining_chars,
)
last_payload = payload
next_cursor = int(payload.get("next_cursor", current_cursor))
chunk = str(payload.get("output", ""))
advanced = next_cursor > current_cursor or bool(chunk)
if advanced:
saw_output = True
if chunk:
raw_chunks.append(chunk)
consumed = max(0, next_cursor - current_cursor)
current_cursor = next_cursor
remaining_chars = max(0, remaining_chars - consumed)
deadline = time.monotonic() + wait_seconds
if remaining_chars <= 0 or str(payload.get("state", "")) != "running":
break
time.sleep(SHELL_IDLE_POLL_INTERVAL_SECONDS)
continue
if str(payload.get("state", "")) != "running":
break
if time.monotonic() >= deadline:
break
if not saw_output and wait_seconds <= 0:
break
time.sleep(SHELL_IDLE_POLL_INTERVAL_SECONDS)
if last_payload is None:
raise RuntimeError(f"shell {shell_id} did not return a read payload")
if current_cursor == cursor:
return last_payload
aggregated = dict(last_payload)
aggregated["cursor"] = cursor
aggregated["next_cursor"] = current_cursor
aggregated["output"] = "".join(raw_chunks)
aggregated["truncated"] = bool(last_payload.get("truncated", False)) or remaining_chars <= 0
return aggregated
def write_shell(
self,
workspace_id: str,

View file

@ -0,0 +1,116 @@
"""Helpers for chat-friendly workspace shell output rendering."""
from __future__ import annotations
def _apply_csi(
final: str,
parameters: str,
line: list[str],
cursor: int,
lines: list[str],
) -> tuple[list[str], int, list[str]]:
if final == "K":
mode = parameters or "0"
if mode in {"0", ""}:
del line[cursor:]
elif mode == "1":
for index in range(min(cursor, len(line))):
line[index] = " "
elif mode == "2":
line.clear()
cursor = 0
elif final == "J":
mode = parameters or "0"
if mode in {"2", "3"}:
lines.clear()
line.clear()
cursor = 0
return line, cursor, lines
def _consume_escape_sequence(
text: str,
index: int,
line: list[str],
cursor: int,
lines: list[str],
) -> tuple[int, list[str], int, list[str]]:
if index + 1 >= len(text):
return len(text), line, cursor, lines
leader = text[index + 1]
if leader == "[":
cursor_index = index + 2
while cursor_index < len(text):
char = text[cursor_index]
if "\x40" <= char <= "\x7e":
parameters = text[index + 2 : cursor_index]
line, cursor, lines = _apply_csi(char, parameters, line, cursor, lines)
return cursor_index + 1, line, cursor, lines
cursor_index += 1
return len(text), line, cursor, lines
if leader in {"]", "P", "_", "^"}:
cursor_index = index + 2
while cursor_index < len(text):
char = text[cursor_index]
if char == "\x07":
return cursor_index + 1, line, cursor, lines
if char == "\x1b" and cursor_index + 1 < len(text) and text[cursor_index + 1] == "\\":
return cursor_index + 2, line, cursor, lines
cursor_index += 1
return len(text), line, cursor, lines
if leader == "O":
return min(index + 3, len(text)), line, cursor, lines
return min(index + 2, len(text)), line, cursor, lines
def render_plain_shell_output(raw_text: str) -> str:
"""Render PTY output into chat-friendly plain text."""
lines: list[str] = []
line: list[str] = []
cursor = 0
ended_with_newline = False
index = 0
while index < len(raw_text):
char = raw_text[index]
if char == "\x1b":
index, line, cursor, lines = _consume_escape_sequence(
raw_text,
index,
line,
cursor,
lines,
)
ended_with_newline = False
continue
if char == "\r":
cursor = 0
ended_with_newline = False
index += 1
continue
if char == "\n":
lines.append("".join(line))
line = []
cursor = 0
ended_with_newline = True
index += 1
continue
if char == "\b":
if cursor > 0:
cursor -= 1
if cursor < len(line):
del line[cursor]
ended_with_newline = False
index += 1
continue
if char == "\t" or (ord(char) >= 32 and ord(char) != 127):
if cursor < len(line):
line[cursor] = char
else:
line.append(char)
cursor += 1
ended_with_newline = False
index += 1
if line or ended_with_newline:
lines.append("".join(line))
return "\n".join(lines)