diff --git a/CHANGELOG.md b/CHANGELOG.md index da17211..e641fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c5841da..7b56dad 100644 --- a/README.md +++ b/README.md @@ -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)` diff --git a/docs/first-run.md b/docs/first-run.md index cf41d68..d943ed4 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -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 diff --git a/docs/install.md b/docs/install.md index b40d593..2e168b0 100644 --- a/docs/install.md +++ b/docs/install.md @@ -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, diff --git a/docs/integrations.md b/docs/integrations.md index 866044b..b25ab3c 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -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: diff --git a/docs/public-contract.md b/docs/public-contract.md index ad96936..7c76615 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -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`. diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 2293153..3277df2 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -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 diff --git a/docs/roadmap/llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md b/docs/roadmap/llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md index 747454d..e46edc3 100644 --- a/docs/roadmap/llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md +++ b/docs/roadmap/llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md @@ -1,6 +1,6 @@ # `3.5.0` Chat-Friendly Shell Output -Status: Planned +Status: Done ## Goal diff --git a/examples/python_shell.py b/examples/python_shell.py index 85887f7..e30606c 100644 --- a/examples/python_shell.py +++ b/examples/python_shell.py @@ -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="") diff --git a/examples/python_workspace.py b/examples/python_workspace.py index 79e338e..d10673f 100644 --- a/examples/python_workspace.py +++ b/examples/python_workspace.py @@ -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( diff --git a/pyproject.toml b/pyproject.toml index ff5944a..104eb17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index df7f0a4..2fadd0b 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -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"): diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index cd7964b..74dd57c 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -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): diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index 9b51acf..8a85fa2 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -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",) diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 0290562..b93c969 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -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", diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index c3fb329..9bb41b0 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -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, diff --git a/src/pyro_mcp/workspace_shell_output.py b/src/pyro_mcp/workspace_shell_output.py new file mode 100644 index 0000000..94dacc6 --- /dev/null +++ b/src/pyro_mcp/workspace_shell_output.py @@ -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) diff --git a/tests/test_api.py b/tests/test_api.py index 2c8fba6..b882fef 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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, }, ), ( diff --git a/tests/test_cli.py b/tests/test_cli.py index 49e6f75..0b8ebb5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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"}), diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index baca81d..df83a80 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -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: diff --git a/tests/test_workspace_shell_output.py b/tests/test_workspace_shell_output.py new file mode 100644 index 0000000..c11756d --- /dev/null +++ b/tests/test_workspace_shell_output.py @@ -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" diff --git a/uv.lock b/uv.lock index 209a741..383d764 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "3.4.0" +version = "3.5.0" source = { editable = "." } dependencies = [ { name = "mcp" },