Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

LiveShell is a generic local execution substrate for Python. It provides persistent shell sessions, synchronous and asynchronous APIs, durable command records, replayable command events, and a small JSON-lines daemon protocol for process-managed integrations.

It is intentionally reusable infrastructure. LiveShell owns local sessions, commands, output events, cancellation, and backend capability discovery. It does not include agent, plan, scheduler, work-packet, evidence, policy, URL-handler, or network-server concepts.
It is intentionally reusable infrastructure. LiveShell owns local sessions, commands, output events, cancellation, and backend capability discovery. It does not include agent, plan, scheduler, work-packet, evidence, policy, URL-handler, or remote-network-server concepts. Its optional daemon transport is a loopback-only TCP socket (see [Transports](docs/PROTOCOL.md#transports)); there is no remote listener.

## What It Provides

Expand Down Expand Up @@ -134,7 +134,7 @@ Async code can use methods such as `discover_capabilities_async`, `create_sessio

## Daemon Protocol

The daemon speaks JSON lines over stdio:
The daemon speaks JSON-line protocol frames over stdio or the loopback TCP socket transport:

- Each request is one JSON object followed by a newline.
- Each response is one JSON object followed by a newline.
Expand All @@ -154,18 +154,31 @@ Example response:
{"id":"req_1","ok":true,"result":{"protocol_version":"1.0","capabilities":[]}}
```

Start a long-running daemon on stdio:
Start a long-running daemon on stdio (lifetime is bound to the launching process's pipes):

```powershell
liveshell daemon stdio --state-dir .\.liveshell-state
```

Process exactly one request and exit, which is useful for deterministic tests:
For stdio, process exactly one request and exit, which is useful for deterministic tests:

```powershell
liveshell daemon stdio --once --state-dir .\.liveshell-state
```

Start a long-running daemon on the loopback TCP socket transport (keeps running after clients disconnect):

```powershell
liveshell daemon serve --state-dir .\.liveshell-state
```

Attach from a fresh client after the socket daemon is running:

```python
with LiveShellClient.connect(".liveshell-state") as client:
...
```

Supported protocol methods:

- `capability.discover`
Expand Down Expand Up @@ -255,6 +268,9 @@ liveshell --json-pretty capability discover
liveshell run --kind cmd --command "echo hello" --timeout-seconds 5
liveshell daemon stdio
liveshell daemon stdio --once
liveshell daemon start --state-dir .\.liveshell-state
liveshell daemon serve --host 127.0.0.1 --port 0 --state-dir .\.liveshell-state
liveshell daemon stop --state-dir .\.liveshell-state
liveshell daemon status
liveshell daemon shutdown --reason maintenance
liveshell session list --state-dir .\.liveshell-state
Expand All @@ -267,9 +283,9 @@ liveshell command cancel --command-id cmd_... --state-dir .\.liveshell-state

`liveshell run` is a one-shot convenience command. It starts a local stdio daemon, creates a session, runs one command, waits for the durable result envelope, closes the session, and exits.

Long-lived live sessions, command start, and active command cancellation require the daemon process that owns the in-memory shell session. Direct `session create`, `command start`, and active `command cancel` CLI paths fail clearly outside that daemon instead of faking success against only the SQLite store. Use `LiveShellClient` or send protocol requests to a running stdio daemon for live session control.
Long-lived live sessions, command start, and active command cancellation require the daemon process that owns the in-memory shell session. Direct `session create`, `command start`, and active `command cancel` CLI paths fail clearly outside that daemon instead of faking success against only the SQLite store. Use `LiveShellClient` or send protocol requests to a running stdio or socket daemon for live session control. `liveshell daemon start` launches a detached, persistent socket daemon (loopback TCP) that survives the launching process; attach to it from a fresh client with `LiveShellClient.connect(state_dir)` and stop it with `liveshell daemon stop`.

`daemon.status` reads local state-dir daemon metadata. `daemon.shutdown` writes a local shutdown marker; stdio daemons also support the reliable `daemon.shutdown` protocol method over their stdin. A default network server, OS URL protocol handler, and hidden command execution from links are intentionally not provided.
`daemon.status` reads local state-dir daemon metadata. `daemon.shutdown` writes a local shutdown marker; live daemons also support the reliable `daemon.shutdown` protocol method over their channel (stdin for stdio, the socket for a socket daemon). An auto-started network server, OS URL protocol handler, and hidden command execution from links are intentionally not provided; the socket daemon is opt-in and binds to a loopback IPv4 address only (default `127.0.0.1`).

## Capability Discovery

Expand Down Expand Up @@ -354,9 +370,9 @@ Command output is durable and replayable, but it may contain secrets. LiveShell

Persistent sessions own their working directory. Set `cwd` on `session.create`; per-command `cwd` is accepted only when it matches the session cwd. Create a separate session for a different working directory.

OS URL protocol handlers, deep links, network servers, and hidden command execution from URLs are intentionally not implemented.
OS URL protocol handlers, deep links, remote or auto-started network servers, and hidden command execution from URLs are intentionally not implemented.

Local named pipe or Unix socket daemon transport is not included in this slice. The stdio protocol is the supported live transport; status/shutdown CLI commands operate through state-dir metadata unless a caller sends the protocol method over an existing daemon stdio channel.
Two live transports are supported: **stdio** (the default; its lifetime is bound to the launching process's pipes) and a **loopback TCP socket** (`liveshell daemon serve`/`daemon start`, attached from a fresh client via `LiveShellClient.connect(state_dir)`) that keeps running after clients disconnect. Named-pipe and Unix-domain-socket transports are not used — the socket transport is loopback TCP. `status`/`shutdown` CLI commands operate through state-dir metadata unless a caller sends the protocol method over a live daemon channel.
Comment thread
wheresoli marked this conversation as resolved.

## Tests

Expand Down
14 changes: 11 additions & 3 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# LiveShell Protocol

LiveShell speaks newline-delimited JSON over local stdio. Each request and response is one JSON object plus `\n`.
LiveShell speaks newline-delimited JSON over a local transport. Each request and response is one JSON object plus `\n`. The same request/response protocol is served over two interchangeable transports (see [Transports](#transports)): the stdio pipes of a launched daemon, or a loopback TCP socket.

Current protocol version: `1.0`

Expand Down Expand Up @@ -54,8 +54,16 @@ Stable error codes:

`command.events` returns events with stable per-command sequence numbers greater than `since_seq`.

`daemon.shutdown` is reliable over the live stdio channel. CLI shutdown writes a state-dir marker for local operators; it is not a network control plane.
`daemon.shutdown` is reliable over a live channel (stdio or socket). The `liveshell daemon shutdown`/`stop` CLI paths also write a state-dir marker for operators when no live channel is held; the marker is not a network control plane.

## Transports

The protocol is transport-agnostic. Two transports are supported, both local-only:

- **stdio** — `liveshell daemon stdio` serves the protocol over the process's stdin/stdout. The daemon's lifetime is bound to the launching process's pipes. This is the default, used by `LiveShellClient.stdio(...)` and `liveshell run`.
- **socket** — `liveshell daemon serve`/`daemon start` serves the protocol over a **loopback IPv4 TCP socket** (default `127.0.0.1`, an ephemeral port unless one is given). The bound address is published to `daemon.json` in the state dir so a fresh client can attach with `LiveShellClient.connect(state_dir)`. Unlike stdio, a socket daemon keeps running — and its commands keep executing — after any client disconnects.
The host is validated as loopback-only: non-loopback and non-IPv4 hosts are rejected. There is no remote transport and no auto-started network listener; the socket daemon is opt-in.

## Security

The protocol is local-only stdio in this slice. LiveShell does not install URL handlers, expose a default network server, or execute commands from links.
The protocol is local-only. LiveShell does not install URL handlers, expose a *remote* network server, or execute commands from links. The optional socket transport binds to loopback (`127.0.0.1`) only and is never started implicitly.
10 changes: 9 additions & 1 deletion src/liveshell/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ def discover_capabilities() -> list[Capability]:
Capability("command.poll", True),
Capability("command.timeout", True),
Capability("command.exit_code.native", True),
Capability("daemon.protocol", True, {"transport": "stdio", "network": False}),
Capability(
"daemon.protocol",
True,
{
"transports": ["stdio", "socket"],
"socket_scope": "loopback",
"remote_network": False,
},
),
Capability("command.events.replay", True),
Capability("command.events.chunking", True),
Capability("command.stdout.streaming", True, {"scope": "process_backed"}),
Expand Down
14 changes: 14 additions & 0 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ def test_discovery_returns_json_serializable_capabilities(self) -> None:
self.assertTrue(streaming["details"]["hosted_powershell_native_exit_code"])
self.assertIsInstance(encoded, str)

def test_daemon_protocol_advertises_both_transports(self) -> None:
payload = [capability.to_dict() for capability in discover_capabilities()]
daemon_protocol = next(
item for item in payload if item["name"] == "daemon.protocol"
)
details = daemon_protocol["details"]
# The package ships both a stdio and a loopback-socket transport
# (serve_socket / daemon serve / LiveShellClient.connect), so discovery
# must advertise both rather than claiming stdio-only.
self.assertEqual(details["transports"], ["stdio", "socket"])
self.assertEqual(details["socket_scope"], "loopback")
# There is still no remote/auto-started network server.
self.assertFalse(details["remote_network"])


if __name__ == "__main__":
unittest.main()
18 changes: 9 additions & 9 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def test_capability_discover_succeeds_as_json(self) -> None:
self.assertIn("command.poll", capability_names)

def test_daemon_stdio_once_prints_protocol_response(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
output = io.StringIO()
request = json.dumps(
{"id": "req_1", "method": "capability.discover", "params": {}}
Expand All @@ -64,7 +64,7 @@ def test_daemon_stdio_once_prints_protocol_response(self) -> None:
self.assertIn("capabilities", response["result"])

def test_session_list_and_snapshot_succeed_as_json(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
store = Store.from_state_dir(temp_dir)
session = store.create_session(
SessionSpec(kind="cmd", cwd=temp_dir, metadata={"purpose": "cli-test"}),
Expand Down Expand Up @@ -97,7 +97,7 @@ def test_session_list_and_snapshot_succeed_as_json(self) -> None:
self.assertEqual(snapshot_payload["result"]["metadata"]["purpose"], "cli-test")

def test_command_read_commands_succeed_as_json_for_terminal_records(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
store = Store.from_state_dir(temp_dir)
session = store.create_session(SessionSpec(kind="cmd"), status="closed")
command = store.create_command(
Expand Down Expand Up @@ -175,7 +175,7 @@ def test_run_executes_command_through_stdio_daemon_client(self) -> None:
self.skipTest("No process-backed shell is available")
kind, command = shell

with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
output = io.StringIO()
with contextlib.redirect_stdout(output):
status = main(
Expand Down Expand Up @@ -203,7 +203,7 @@ def test_run_executes_command_through_stdio_daemon_client(self) -> None:
self.assertEqual(payload["result"]["closed_session"]["status"], "closed")

def test_session_create_without_live_daemon_fails_as_json(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
output = io.StringIO()
with contextlib.redirect_stdout(output):
status = main(
Expand All @@ -223,7 +223,7 @@ def test_session_create_without_live_daemon_fails_as_json(self) -> None:
self.assertEqual(payload["error"]["type"], "RuntimeError")

def test_command_start_without_live_daemon_fails_as_json(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
output = io.StringIO()
with contextlib.redirect_stdout(output):
status = main(
Expand All @@ -245,7 +245,7 @@ def test_command_start_without_live_daemon_fails_as_json(self) -> None:
self.assertEqual(payload["error"]["type"], "RuntimeError")

def test_command_cancel_without_live_daemon_fails_as_json(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
store = Store.from_state_dir(temp_dir)
session = store.create_session(SessionSpec(kind="cmd"), status="running")
command = store.create_command(
Expand Down Expand Up @@ -273,7 +273,7 @@ def test_command_cancel_without_live_daemon_fails_as_json(self) -> None:
self.assertEqual(store.get_command(command.id).status, "running")

def test_command_events_for_unknown_command_fails_as_json(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
output = io.StringIO()
with contextlib.redirect_stdout(output):
status = main(
Expand All @@ -297,7 +297,7 @@ class FailedProcess:
def poll(self) -> int:
return 23

with tempfile.TemporaryDirectory(prefix="liveshell-cli-daemon-") as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, prefix="liveshell-cli-daemon-") as temp_dir:
args = mock.Mock()
args.state_dir = temp_dir
args.host = "127.0.0.1"
Expand Down
8 changes: 4 additions & 4 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def available_process_shell() -> tuple[str, str] | None:

class LiveShellClientTests(unittest.TestCase):
def test_stdio_client_discovers_capabilities(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
with LiveShellClient.stdio(temp_dir) as client:
capabilities = client.discover_capabilities()

Expand All @@ -38,7 +38,7 @@ def test_stdio_client_session_handle_runs_command_and_closes(self) -> None:
self.skipTest("No process-backed shell is available")
kind, command = shell

with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
with LiveShellClient.stdio(temp_dir) as client:
session = client.create_session(kind)
result = session.run(command, timeout_seconds=5, poll_interval=0.05)
Expand All @@ -49,7 +49,7 @@ def test_stdio_client_session_handle_runs_command_and_closes(self) -> None:
self.assertEqual(closed.status, "closed")

def test_stdio_client_raises_response_error_for_daemon_errors(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
with LiveShellClient.stdio(temp_dir) as client:
with self.assertRaises(LiveShellResponseError) as context:
client.session_snapshot("sess_missing")
Expand All @@ -60,7 +60,7 @@ def test_stdio_client_raises_response_error_for_daemon_errors(self) -> None:
class AsyncLiveShellClientTests(unittest.IsolatedAsyncioTestCase):
async def test_async_client_methods_wrap_sync_protocol_operations(self) -> None:
asyncio.get_running_loop().slow_callback_duration = 2.0
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
client = LiveShellClient.stdio(temp_dir)
try:
capabilities = await client.discover_capabilities_async()
Expand Down
Loading
Loading