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

@ -2,6 +2,16 @@
All notable user-visible changes to `pyro-mcp` are documented here.
## 3.5.0
- Added chat-friendly shell reads with `--plain` and `--wait-for-idle-ms` across the CLI,
Python SDK, and MCP server so PTY sessions can be fed back into a chat model without
client-side ANSI cleanup.
- Kept raw cursor-based shell reads intact for advanced clients while adding manager-side
output rendering and idle batching on top of the existing guest/backend shell transport.
- Updated the stable shell examples and docs to recommend `workspace shell read --plain
--wait-for-idle-ms 300` for model-facing interactive loops.
## 3.4.0
- Added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and

View file

@ -22,7 +22,7 @@ It exposes the same runtime in three public forms:
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
- What's new in 3.4.0: [CHANGELOG.md#340](CHANGELOG.md#340)
- What's new in 3.5.0: [CHANGELOG.md#350](CHANGELOG.md#350)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Integration targets: [docs/integrations.md](docs/integrations.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md)
@ -59,7 +59,7 @@ What success looks like:
```bash
Platform: linux-x86_64
Runtime: PASS
Catalog version: 3.4.0
Catalog version: 3.5.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
@ -189,7 +189,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 3.4.0
Catalog version: 3.5.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -282,7 +282,7 @@ pyro workspace reset WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN
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 close WORKSPACE_ID SHELL_ID
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
@ -305,14 +305,15 @@ Persistent workspaces start in `/workspace` and keep command history until you d
machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when
you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import
later host-side changes into a started workspace. Sync is non-atomic in `3.4.0`; if it fails
later host-side changes into a started workspace. Sync is non-atomic in `3.5.0`; if it fails
partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot.
Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time
baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use
`pyro workspace snapshot *` and `pyro workspace reset` when you want explicit checkpoints and
full-sandbox recovery. Use `pyro workspace exec` for one-shot
non-interactive commands inside a live workspace, and `pyro workspace shell *` when you need a
persistent PTY session that keeps interactive shell state between calls. Use
persistent PTY session that keeps interactive shell state between calls. Prefer
`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell reads. Use
`pyro workspace service *` when the workspace needs one or more long-running background processes.
Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep
`--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the
@ -518,7 +519,7 @@ Persistent workspace tools:
- `service_logs(workspace_id, service_name, tail_lines=200)`
- `service_stop(workspace_id, service_name)`
- `shell_open(workspace_id, cwd="/workspace", cols=120, rows=30, secret_env=null)`
- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536)`
- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
- `shell_write(workspace_id, shell_id, input, append_newline=true)`
- `shell_signal(workspace_id, shell_id, signal_name="INT")`
- `shell_close(workspace_id, shell_id)`

View file

@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
```bash
$ uvx --from pyro-mcp pyro env list
Catalog version: 3.4.0
Catalog version: 3.5.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -182,9 +182,9 @@ $ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TO
$ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
[workspace-shell-write] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID
$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
/workspace
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False execution_mode=guest_vsock
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False plain=True wait_for_idle_ms=300 execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
[workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
@ -252,7 +252,7 @@ State: started
Use `--seed-path` when the workspace should start from a host directory or a local
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
`pyro workspace sync push` when you need to import later host-side changes into a started
workspace. Sync is non-atomic in `3.4.0`; if it fails partway through, prefer `pyro workspace reset`
workspace. Sync is non-atomic in `3.5.0`; if it fails partway through, prefer `pyro workspace reset`
to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current
`/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create
named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the

View file

@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 3.4.0
Catalog version: 3.5.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -251,7 +251,7 @@ pyro workspace reset WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN
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 close WORKSPACE_ID SHELL_ID
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
@ -274,12 +274,13 @@ Workspace commands default to the persistent `/workspace` directory inside the g
the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path`
when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync
is non-atomic in `3.4.0`; if it fails partway through, prefer `pyro workspace reset` to recover
is non-atomic in `3.5.0`; if it fails partway through, prefer `pyro workspace reset` to recover
from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace
tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named
checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use
`pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need an
interactive PTY that survives across separate calls. Use `pyro workspace service *` when the
interactive PTY that survives across separate calls. Prefer
`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell loops. Use `pyro workspace service *` when the
workspace needs long-running background processes with typed readiness probes. Service metadata and
logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or
export results. Use `--network-policy egress` when the workspace needs outbound guest networking,

View file

@ -86,7 +86,7 @@ Recommended default:
- `Pyro.create_workspace(..., network_policy="egress+published-ports")` + `Pyro.start_service(..., published_ports=[...])` when the host must probe one workspace service
- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer
- `Pyro.start_service(..., secret_env=...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace
- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace
- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(..., plain=True, wait_for_idle_ms=300)` when the agent needs an interactive PTY inside the workspace
Lifecycle note:

View file

@ -107,6 +107,7 @@ Behavioral guarantees:
- `pyro workspace patch apply WORKSPACE_ID --patch TEXT` applies one unified text patch with add/modify/delete operations under `/workspace`.
- `pyro workspace shell open --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into the opened shell environment.
- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace.
- `pyro workspace shell read --plain --wait-for-idle-ms 300` is the recommended chat-facing read mode; raw shell reads remain available without `--plain`.
- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`.
- `pyro workspace update` changes only discovery metadata such as `name` and key/value `labels`.
- Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized.
@ -157,7 +158,7 @@ Supported public entrypoints:
- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
- `Pyro.stop_service(workspace_id, service_name)`
- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)`
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
- `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")`
- `Pyro.close_shell(workspace_id, shell_id)`
@ -207,7 +208,7 @@ Stable public method names:
- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
- `stop_service(workspace_id, service_name)`
- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)`
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
- `signal_shell(workspace_id, shell_id, *, signal_name="INT")`
- `close_shell(workspace_id, shell_id)`
@ -260,7 +261,7 @@ Behavioral defaults:
- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive.
- `Pyro.open_shell(..., secret_env=...)` maps persisted workspace secrets into the shell environment when that shell opens.
- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace.
- `Pyro.read_shell(...)` reads merged text output from that shell by cursor.
- `Pyro.read_shell(...)` reads merged text output from that shell by cursor, with optional plain rendering and idle batching for chat-facing consumers.
- `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session.
- `Pyro.update_workspace(...)` changes only discovery metadata such as `name` and key/value `labels`.

View file

@ -6,7 +6,7 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface.
Current baseline is `3.4.0`:
Current baseline is `3.5.0`:
- the stable workspace contract exists across CLI, SDK, and MCP
- one-shot `pyro run` still exists as the narrow entrypoint
@ -48,7 +48,7 @@ More concretely, the model should not need to:
1. [`3.2.0` Model-Native Workspace File Ops](llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md) - Done
2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) - Done
3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) - Done
4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md)
4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) - Done
5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md)
Completed so far:
@ -61,6 +61,8 @@ Completed so far:
- `3.4.0` added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and
`workspace-full`, plus canonical profile-based OpenAI and MCP examples so chat hosts can start
narrow and widen only when needed.
- `3.5.0` added chat-friendly shell reads with plain-text rendering and idle batching so PTY
sessions are readable enough to feed directly back into a chat model.
## Expected Outcome

View file

@ -1,6 +1,6 @@
# `3.5.0` Chat-Friendly Shell Output
Status: Planned
Status: Done
## Goal

View file

@ -19,7 +19,13 @@ def main() -> None:
pyro.write_shell(workspace_id, shell_id, input="pwd")
deadline = time.time() + 5
while True:
read = pyro.read_shell(workspace_id, shell_id, cursor=0)
read = pyro.read_shell(
workspace_id,
shell_id,
cursor=0,
plain=True,
wait_for_idle_ms=300,
)
output = str(read["output"])
if "/workspace" in output or time.time() >= deadline:
print(output, end="")

View file

@ -83,7 +83,13 @@ def main() -> None:
shell_id,
input='printf "%s\\n" "$API_TOKEN"',
)
shell_output = pyro.read_shell(workspace_id, shell_id, cursor=0)
shell_output = pyro.read_shell(
workspace_id,
shell_id,
cursor=0,
plain=True,
wait_for_idle_ms=300,
)
print(f"shell_output_len={len(shell_output['output'])}")
pyro.close_shell(workspace_id, shell_id)
pyro.start_service(

View file

@ -1,6 +1,6 @@
[project]
name = "pyro-mcp"
version = "3.4.0"
version = "3.5.0"
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
readme = "README.md"
license = { file = "LICENSE" }

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)

View file

@ -37,7 +37,7 @@ def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
assert str(result["stdout"]) == "ok\n"
def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
def test_pyro_create_server_registers_full_profile_and_shell_read_schema(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
@ -46,13 +46,17 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
)
)
async def _run() -> list[str]:
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
server = pyro.create_server()
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
tool_map = {tool.name: tool.model_dump() for tool in tools}
return sorted(tool_map), tool_map
tool_names = asyncio.run(_run())
tool_names, tool_map = asyncio.run(_run())
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS))
shell_read_properties = tool_map["shell_read"]["inputSchema"]["properties"]
assert "plain" in shell_read_properties
assert "wait_for_idle_ms" in shell_read_properties
def test_pyro_create_server_vm_run_profile_registers_only_vm_run(tmp_path: Path) -> None:
@ -977,6 +981,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
*,
cursor: int = 0,
max_chars: int = 65536,
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
calls.append(
(
@ -986,6 +992,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
"shell_id": shell_id,
"cursor": cursor,
"max_chars": max_chars,
"plain": plain,
"wait_for_idle_ms": wait_for_idle_ms,
},
)
)
@ -1097,6 +1105,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
"shell_id": "shell-1",
"cursor": 5,
"max_chars": 1024,
"plain": True,
"wait_for_idle_ms": 300,
},
)
)
@ -1212,6 +1222,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
"shell_id": "shell-1",
"cursor": 5,
"max_chars": 1024,
"plain": True,
"wait_for_idle_ms": 300,
},
),
(

View file

@ -278,6 +278,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
).format_help()
assert "Shell output is written to stdout." in workspace_shell_read_help
assert "--plain" in workspace_shell_read_help
assert "--wait-for-idle-ms" in workspace_shell_read_help
workspace_shell_write_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "write"
@ -2259,11 +2261,15 @@ def test_cli_workspace_shell_open_and_read_human(
*,
cursor: int,
max_chars: int,
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert shell_id == "shell-123"
assert cursor == 0
assert max_chars == 1024
assert plain is True
assert wait_for_idle_ms == 300
return {
"workspace_id": workspace_id,
"shell_id": shell_id,
@ -2279,6 +2285,8 @@ def test_cli_workspace_shell_open_and_read_human(
"next_cursor": 14,
"output": "pyro$ pwd\n",
"truncated": False,
"plain": plain,
"wait_for_idle_ms": wait_for_idle_ms,
}
class OpenParser:
@ -2309,6 +2317,8 @@ def test_cli_workspace_shell_open_and_read_human(
shell_id="shell-123",
cursor=0,
max_chars=1024,
plain=True,
wait_for_idle_ms=300,
json=False,
)
@ -2319,6 +2329,8 @@ def test_cli_workspace_shell_open_and_read_human(
assert "pyro$ pwd\n" in captured.out
assert "[workspace-shell-open] workspace_id=workspace-123 shell_id=shell-123" in captured.err
assert "[workspace-shell-read] workspace_id=workspace-123 shell_id=shell-123" in captured.err
assert "plain=True" in captured.err
assert "wait_for_idle_ms=300" in captured.err
def test_cli_workspace_shell_write_signal_close_json(
@ -2478,7 +2490,11 @@ def test_cli_workspace_shell_open_and_read_json(
*,
cursor: int,
max_chars: int,
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
assert plain is False
assert wait_for_idle_ms is None
return {
"workspace_id": workspace_id,
"shell_id": shell_id,
@ -2494,6 +2510,8 @@ def test_cli_workspace_shell_open_and_read_json(
"next_cursor": max_chars,
"output": "pyro$ pwd\n",
"truncated": False,
"plain": plain,
"wait_for_idle_ms": wait_for_idle_ms,
}
class OpenParser:
@ -2526,6 +2544,8 @@ def test_cli_workspace_shell_open_and_read_json(
shell_id="shell-123",
cursor=0,
max_chars=1024,
plain=False,
wait_for_idle_ms=None,
json=True,
)
@ -2655,7 +2675,16 @@ def test_cli_workspace_shell_write_signal_close_human(
("shell_command", "kwargs"),
[
("open", {"cwd": "/workspace", "cols": 120, "rows": 30}),
("read", {"shell_id": "shell-123", "cursor": 0, "max_chars": 1024}),
(
"read",
{
"shell_id": "shell-123",
"cursor": 0,
"max_chars": 1024,
"plain": False,
"wait_for_idle_ms": None,
},
),
("write", {"shell_id": "shell-123", "input": "pwd", "no_newline": False}),
("signal", {"shell_id": "shell-123", "signal": "INT"}),
("close", {"shell_id": "shell-123"}),

View file

@ -1239,6 +1239,144 @@ def test_workspace_shell_lifecycle_and_rehydration(tmp_path: Path) -> None:
manager_rehydrated.read_shell(workspace_id, second_shell_id)
def test_workspace_read_shell_plain_renders_control_sequences(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)
workspace_id = str(created["workspace_id"])
opened = manager.open_shell(workspace_id)
shell_id = str(opened["shell_id"])
monkeypatch.setattr(
manager._backend, # noqa: SLF001
"read_shell",
lambda *args, **kwargs: {
"shell_id": shell_id,
"cwd": "/workspace",
"cols": 120,
"rows": 30,
"state": "running",
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "host_compat",
"cursor": 0,
"next_cursor": 15,
"output": "hello\r\x1b[2Kbye\n",
"truncated": False,
},
)
read = manager.read_shell(
workspace_id,
shell_id,
cursor=0,
max_chars=1024,
plain=True,
)
assert read["output"] == "bye\n"
assert read["plain"] is True
assert read["wait_for_idle_ms"] is None
def test_workspace_read_shell_wait_for_idle_batches_output(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)
workspace_id = str(created["workspace_id"])
opened = manager.open_shell(workspace_id)
shell_id = str(opened["shell_id"])
payloads = [
{
"shell_id": shell_id,
"cwd": "/workspace",
"cols": 120,
"rows": 30,
"state": "running",
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "host_compat",
"cursor": 0,
"next_cursor": 4,
"output": "one\n",
"truncated": False,
},
{
"shell_id": shell_id,
"cwd": "/workspace",
"cols": 120,
"rows": 30,
"state": "running",
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "host_compat",
"cursor": 4,
"next_cursor": 8,
"output": "two\n",
"truncated": False,
},
{
"shell_id": shell_id,
"cwd": "/workspace",
"cols": 120,
"rows": 30,
"state": "running",
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "host_compat",
"cursor": 8,
"next_cursor": 8,
"output": "",
"truncated": False,
},
]
def fake_read_shell(*args: Any, **kwargs: Any) -> dict[str, Any]:
del args, kwargs
return payloads.pop(0)
monotonic_values = iter([0.0, 0.05, 0.10, 0.41])
monkeypatch.setattr(manager._backend, "read_shell", fake_read_shell) # noqa: SLF001
monkeypatch.setattr(time, "monotonic", lambda: next(monotonic_values))
monkeypatch.setattr(time, "sleep", lambda _: None)
read = manager.read_shell(
workspace_id,
shell_id,
cursor=0,
max_chars=1024,
wait_for_idle_ms=300,
)
assert read["output"] == "one\ntwo\n"
assert read["next_cursor"] == 8
assert read["wait_for_idle_ms"] == 300
assert read["plain"] is False
def test_workspace_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
archive_path = tmp_path / "bad.tgz"
with tarfile.open(archive_path, "w:gz") as archive:
@ -3092,6 +3230,24 @@ def test_workspace_stop_and_start_preserve_logs_and_clear_live_state(tmp_path: P
assert rerun["stdout"] == "hello from seed\n"
def test_workspace_read_shell_rejects_invalid_wait_for_idle_ms(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)
workspace_id = str(created["workspace_id"])
opened = manager.open_shell(workspace_id)
shell_id = str(opened["shell_id"])
with pytest.raises(ValueError, match="wait_for_idle_ms must be between 1 and 10000"):
manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=1024, wait_for_idle_ms=0)
def test_workspace_stop_flushes_guest_filesystem_before_stopping(
tmp_path: Path,
) -> None:

View file

@ -0,0 +1,36 @@
from __future__ import annotations
from pyro_mcp.workspace_shell_output import render_plain_shell_output
def test_render_plain_shell_output_strips_ansi_osc_and_controls() -> None:
raw = "\x1b]0;title\x07\x1b[31mred\x1b[0m\t\x01done"
assert render_plain_shell_output(raw) == "red\tdone"
def test_render_plain_shell_output_handles_carriage_return_and_backspace() -> None:
raw = "hello\r\x1b[2Kbye\nabc\b\bZ"
assert render_plain_shell_output(raw) == "bye\naZ"
def test_render_plain_shell_output_preserves_trailing_newlines() -> None:
assert render_plain_shell_output("line one\n") == "line one\n"
assert render_plain_shell_output("\n") == "\n"
def test_render_plain_shell_output_handles_line_clear_modes_and_overwrite() -> None:
assert render_plain_shell_output("abcde\rab\x1b[1KZ") == " Zde"
assert render_plain_shell_output("hello\x1b[2Kx") == "x"
def test_render_plain_shell_output_handles_full_screen_clear() -> None:
assert render_plain_shell_output("one\ntwo\x1b[2Jz") == "z"
assert render_plain_shell_output("one\ntwo\x1b[3Jz") == "z"
def test_render_plain_shell_output_ignores_incomplete_and_non_csi_escape_sequences() -> None:
assert render_plain_shell_output("\x1b") == ""
assert render_plain_shell_output("\x1b[") == ""
assert render_plain_shell_output("\x1b]title\x1b\\ok") == "ok"
assert render_plain_shell_output("a\x1bOPb") == "ab"
assert render_plain_shell_output("a\x1bXb") == "ab"

2
uv.lock generated
View file

@ -706,7 +706,7 @@ crypto = [
[[package]]
name = "pyro-mcp"
version = "3.4.0"
version = "3.5.0"
source = { editable = "." }
dependencies = [
{ name = "mcp" },