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:
parent
2de31306b6
commit
3f8293ad24
28 changed files with 3265 additions and 81 deletions
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue