Add persistent workspace shell sessions

Let agents inhabit a workspace across separate calls instead of only submitting one-shot execs.

Add workspace shell open/read/write/signal/close across the CLI, Python SDK, and MCP server, with persisted shell records, a local PTY-backed mock implementation, and guest-agent support for real Firecracker workspaces.

Mark the 2.5.0 roadmap milestone done, refresh docs/examples and the release metadata, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, and UV_CACHE_DIR=.uv-cache make dist-check.
This commit is contained in:
Thales Maciel 2026-03-12 02:31:57 -03:00
parent 2de31306b6
commit 3f8293ad24
28 changed files with 3265 additions and 81 deletions

View file

@ -19,6 +19,7 @@ from pyro_mcp.vm_manager import (
DEFAULT_MEM_MIB,
DEFAULT_VCPU_COUNT,
WORKSPACE_GUEST_PATH,
WORKSPACE_SHELL_SIGNAL_NAMES,
)
@ -237,6 +238,37 @@ def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
def _print_workspace_shell_summary_human(payload: dict[str, Any], *, prefix: str) -> None:
print(
f"[{prefix}] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"shell_id={str(payload.get('shell_id', 'unknown'))} "
f"state={str(payload.get('state', 'unknown'))} "
f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} "
f"cols={int(payload.get('cols', 0))} "
f"rows={int(payload.get('rows', 0))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
file=sys.stderr,
flush=True,
)
def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None:
_write_stream(str(payload.get("output", "")), stream=sys.stdout)
print(
"[workspace-shell-read] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"shell_id={str(payload.get('shell_id', 'unknown'))} "
f"state={str(payload.get('state', 'unknown'))} "
f"cursor={int(payload.get('cursor', 0))} "
f"next_cursor={int(payload.get('next_cursor', 0))} "
f"truncated={bool(payload.get('truncated', False))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
file=sys.stderr,
flush=True,
)
class _HelpFormatter(
argparse.RawDescriptionHelpFormatter,
argparse.ArgumentDefaultsHelpFormatter,
@ -269,6 +301,7 @@ def _build_parser() -> argparse.ArgumentParser:
Need repeated commands in one workspace after that?
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace shell open WORKSPACE_ID
Use `pyro mcp serve` only after the CLI validation path works.
"""
@ -476,6 +509,7 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./repo --dest src
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro workspace shell open WORKSPACE_ID
pyro workspace logs WORKSPACE_ID
"""
),
@ -633,6 +667,191 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_shell_parser = workspace_subparsers.add_parser(
"shell",
help="Open and manage persistent interactive shells.",
description=(
"Open one or more persistent interactive PTY shell sessions inside a started "
"workspace."
),
epilog=dedent(
"""
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 signal WORKSPACE_ID SHELL_ID --signal INT
pyro workspace shell close WORKSPACE_ID SHELL_ID
Use `workspace exec` for one-shot commands. Use `workspace shell` when you need
an interactive process that keeps its state between calls.
"""
),
formatter_class=_HelpFormatter,
)
workspace_shell_subparsers = workspace_shell_parser.add_subparsers(
dest="workspace_shell_command",
required=True,
metavar="SHELL",
)
workspace_shell_open_parser = workspace_shell_subparsers.add_parser(
"open",
help="Open a persistent interactive shell.",
description="Open a new PTY shell inside a started workspace.",
epilog="Example:\n pyro workspace shell open WORKSPACE_ID --cwd src",
formatter_class=_HelpFormatter,
)
workspace_shell_open_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_shell_open_parser.add_argument(
"--cwd",
default=WORKSPACE_GUEST_PATH,
help="Shell working directory. Relative values resolve inside `/workspace`.",
)
workspace_shell_open_parser.add_argument(
"--cols",
type=int,
default=120,
help="Shell terminal width in columns.",
)
workspace_shell_open_parser.add_argument(
"--rows",
type=int,
default=30,
help="Shell terminal height in rows.",
)
workspace_shell_open_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_shell_read_parser = workspace_shell_subparsers.add_parser(
"read",
help="Read merged PTY output from a shell.",
description="Read merged text output from a persistent workspace shell.",
epilog=dedent(
"""
Example:
pyro workspace shell read WORKSPACE_ID SHELL_ID --cursor 0
Shell output is written to stdout. The read summary is written to stderr.
Use --json for a deterministic structured response.
"""
),
formatter_class=_HelpFormatter,
)
workspace_shell_read_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_shell_read_parser.add_argument(
"shell_id",
metavar="SHELL_ID",
help="Persistent shell identifier returned by `workspace shell open`.",
)
workspace_shell_read_parser.add_argument(
"--cursor",
type=int,
default=0,
help="Character offset into the merged shell output buffer.",
)
workspace_shell_read_parser.add_argument(
"--max-chars",
type=int,
default=65536,
help="Maximum number of characters to return from the current cursor position.",
)
workspace_shell_read_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_shell_write_parser = workspace_shell_subparsers.add_parser(
"write",
help="Write text input into a shell.",
description="Write text input into a persistent workspace shell.",
epilog="Example:\n pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'",
formatter_class=_HelpFormatter,
)
workspace_shell_write_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_shell_write_parser.add_argument(
"shell_id",
metavar="SHELL_ID",
help="Persistent shell identifier returned by `workspace shell open`.",
)
workspace_shell_write_parser.add_argument(
"--input",
required=True,
help="Text to send to the shell.",
)
workspace_shell_write_parser.add_argument(
"--no-newline",
action="store_true",
help="Do not append a trailing newline after the provided input.",
)
workspace_shell_write_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_shell_signal_parser = workspace_shell_subparsers.add_parser(
"signal",
help="Send a signal to a shell process group.",
description="Send a control signal to a persistent workspace shell.",
epilog="Example:\n pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT",
formatter_class=_HelpFormatter,
)
workspace_shell_signal_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_shell_signal_parser.add_argument(
"shell_id",
metavar="SHELL_ID",
help="Persistent shell identifier returned by `workspace shell open`.",
)
workspace_shell_signal_parser.add_argument(
"--signal",
default="INT",
choices=WORKSPACE_SHELL_SIGNAL_NAMES,
help="Signal name to send to the shell process group.",
)
workspace_shell_signal_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_shell_close_parser = workspace_shell_subparsers.add_parser(
"close",
help="Close a persistent shell.",
description="Close a persistent workspace shell and release its PTY state.",
epilog="Example:\n pyro workspace shell close WORKSPACE_ID SHELL_ID",
formatter_class=_HelpFormatter,
)
workspace_shell_close_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_shell_close_parser.add_argument(
"shell_id",
metavar="SHELL_ID",
help="Persistent shell identifier returned by `workspace shell open`.",
)
workspace_shell_close_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_status_parser = workspace_subparsers.add_parser(
"status",
help="Inspect one workspace.",
@ -929,6 +1148,99 @@ def main() -> None:
raise SystemExit(1) from exc
_print_workspace_sync_human(payload)
return
if args.workspace_command == "shell":
if args.workspace_shell_command == "open":
try:
payload = pyro.open_shell(
args.workspace_id,
cwd=args.cwd,
cols=args.cols,
rows=args.rows,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-open")
return
if args.workspace_shell_command == "read":
try:
payload = pyro.read_shell(
args.workspace_id,
args.shell_id,
cursor=args.cursor,
max_chars=args.max_chars,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_shell_read_human(payload)
return
if args.workspace_shell_command == "write":
try:
payload = pyro.write_shell(
args.workspace_id,
args.shell_id,
input=args.input,
append_newline=not bool(args.no_newline),
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-write")
return
if args.workspace_shell_command == "signal":
try:
payload = pyro.signal_shell(
args.workspace_id,
args.shell_id,
signal_name=args.signal,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_shell_summary_human(
payload,
prefix="workspace-shell-signal",
)
return
if args.workspace_shell_command == "close":
try:
payload = pyro.close_shell(args.workspace_id, args.shell_id)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-close")
return
if args.workspace_command == "status":
payload = pyro.status_workspace(args.workspace_id)
if bool(args.json):