muxplex-client 0.19.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ """muxplex_client -- typed sync/async HTTP client for the muxplex API.
2
+
3
+ Basic usage:
4
+
5
+ >>> from muxplex_client import MuxplexClient
6
+ >>> with MuxplexClient("https://your-server:8088", "federation-key") as client:
7
+ ... sessions = client.sessions()
8
+
9
+ Async usage:
10
+
11
+ >>> from muxplex_client import AsyncMuxplexClient
12
+ >>> async with AsyncMuxplexClient("https://your-server:8088", "federation-key") as client:
13
+ ... sessions = await client.sessions()
14
+
15
+ See ../README.md and ../../muxplex-client-design.md for the full design
16
+ rationale (why this is a second distribution, version-alignment policy, the
17
+ sentinel's digit-anchor rule, and what was deliberately excluded).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib.metadata
23
+
24
+ from .async_client import AsyncMuxplexClient
25
+ from .constants import (
26
+ DEFAULT_CAPTURE_LINES,
27
+ KNOWN_KEYS,
28
+ MAX_CAPTURE_LINES,
29
+ MAX_KEYS,
30
+ MIN_SERVER_VERSION,
31
+ )
32
+ from .errors import (
33
+ ApiError,
34
+ AuthError,
35
+ CommandTimeout,
36
+ InputForbidden,
37
+ MuxplexError,
38
+ SessionNotFound,
39
+ UnreachableError,
40
+ )
41
+ from .models import (
42
+ Bell,
43
+ CommandResult,
44
+ ConnectResult,
45
+ InputResult,
46
+ InstanceInfo,
47
+ ServerState,
48
+ Session,
49
+ SessionSnapshot,
50
+ Settings,
51
+ View,
52
+ ViewResult,
53
+ ViewSession,
54
+ )
55
+ from .sentinel import Sentinel, make_sentinel
56
+ from .sync_client import MuxplexClient
57
+
58
+ try:
59
+ __version__ = importlib.metadata.version("muxplex-client")
60
+ except importlib.metadata.PackageNotFoundError:
61
+ # Editable/workspace checkout with no build metadata yet (e.g. running
62
+ # straight from a source tree before an initial `uv sync`).
63
+ __version__ = "0.0.0.dev0"
64
+
65
+ __all__ = [
66
+ "__version__",
67
+ "ApiError",
68
+ "AsyncMuxplexClient",
69
+ "AuthError",
70
+ "Bell",
71
+ "CommandResult",
72
+ "CommandTimeout",
73
+ "ConnectResult",
74
+ "DEFAULT_CAPTURE_LINES",
75
+ "InputForbidden",
76
+ "InputResult",
77
+ "InstanceInfo",
78
+ "KNOWN_KEYS",
79
+ "MAX_CAPTURE_LINES",
80
+ "MAX_KEYS",
81
+ "MIN_SERVER_VERSION",
82
+ "MuxplexClient",
83
+ "MuxplexError",
84
+ "Sentinel",
85
+ "ServerState",
86
+ "Session",
87
+ "SessionNotFound",
88
+ "SessionSnapshot",
89
+ "Settings",
90
+ "UnreachableError",
91
+ "View",
92
+ "ViewResult",
93
+ "ViewSession",
94
+ "make_sentinel",
95
+ ]
@@ -0,0 +1,215 @@
1
+ """Pure protocol logic: response parsing and error mapping.
2
+
3
+ No I/O, no httpx import -- tested once in `tests/test_protocol.py` with no
4
+ network. `sync_client.py` and `async_client.py` are each a thin, ~120-line
5
+ await-shaped (or not) shell that calls into this module; duplication is
6
+ confined to that shell, where it is honest and cheap (this is what httpx
7
+ itself does for its own sync/async split).
8
+
9
+ `.get()`-based parsing throughout, deliberately: AGENTS.md requires clients
10
+ to tolerate unknown fields and the server to tolerate their absence. Missing
11
+ keys fall back to a sane default rather than raising KeyError.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Mapping, Sequence
17
+
18
+ from .constants import DEFAULT_CAPTURE_LINES
19
+ from .errors import ApiError, AuthError, InputForbidden, MuxplexError, SessionNotFound
20
+ from .models import (
21
+ Bell,
22
+ ConnectResult,
23
+ InputResult,
24
+ InstanceInfo,
25
+ ServerState,
26
+ Session,
27
+ SessionSnapshot,
28
+ Settings,
29
+ View,
30
+ ViewResult,
31
+ ViewSession,
32
+ )
33
+
34
+ __all__ = [
35
+ "map_status_error",
36
+ "parse_bell",
37
+ "parse_session",
38
+ "parse_sessions",
39
+ "parse_session_snapshot",
40
+ "parse_view_session",
41
+ "parse_view_result",
42
+ "parse_server_state",
43
+ "parse_view",
44
+ "parse_settings",
45
+ "parse_instance_info",
46
+ "parse_connect_result",
47
+ "parse_input_result",
48
+ "version_tuple",
49
+ ]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Response parsing
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def parse_bell(raw: Mapping[str, Any]) -> Bell:
58
+ return Bell(
59
+ last_fired_at=raw.get("last_fired_at"),
60
+ seen_at=raw.get("seen_at"),
61
+ unseen_count=int(raw.get("unseen_count", 0)),
62
+ )
63
+
64
+
65
+ def parse_session(raw: Mapping[str, Any]) -> Session:
66
+ return Session(
67
+ name=raw["name"],
68
+ snapshot=raw.get("snapshot", ""),
69
+ bell=parse_bell(raw.get("bell") or {}),
70
+ last_activity_at=raw.get("last_activity_at"),
71
+ )
72
+
73
+
74
+ def parse_sessions(raw: Sequence[Mapping[str, Any]]) -> list[Session]:
75
+ return [parse_session(item) for item in raw]
76
+
77
+
78
+ def parse_session_snapshot(raw: Mapping[str, Any]) -> SessionSnapshot:
79
+ return SessionSnapshot(
80
+ name=raw["name"],
81
+ snapshot=raw.get("snapshot", ""),
82
+ lines=int(raw.get("lines", DEFAULT_CAPTURE_LINES)),
83
+ bell=parse_bell(raw.get("bell") or {}),
84
+ last_activity_at=raw.get("last_activity_at"),
85
+ )
86
+
87
+
88
+ def parse_view_session(raw: Mapping[str, Any]) -> ViewSession:
89
+ return ViewSession(
90
+ name=raw["name"],
91
+ active=bool(raw.get("active", False)),
92
+ needs_attention=bool(raw.get("needs_attention", False)),
93
+ bell=parse_bell(raw.get("bell") or {}),
94
+ last_activity_at=raw.get("last_activity_at"),
95
+ )
96
+
97
+
98
+ def parse_view_result(raw: Mapping[str, Any]) -> ViewResult:
99
+ return ViewResult(
100
+ view=raw.get("view", "all"),
101
+ views=tuple(raw.get("views") or ()),
102
+ sort=raw.get("sort", "server"),
103
+ sessions=tuple(parse_view_session(s) for s in (raw.get("sessions") or ())),
104
+ )
105
+
106
+
107
+ def parse_server_state(raw: Mapping[str, Any]) -> ServerState:
108
+ return ServerState(
109
+ active_session=raw.get("active_session"),
110
+ active_view=raw.get("active_view") or "all",
111
+ settings_updated_at=raw.get("settings_updated_at"),
112
+ raw=raw,
113
+ )
114
+
115
+
116
+ def parse_view(raw: Mapping[str, Any]) -> View:
117
+ return View(
118
+ name=raw.get("name", ""),
119
+ sessions=frozenset(raw.get("sessions") or ()),
120
+ )
121
+
122
+
123
+ def parse_settings(raw: Mapping[str, Any]) -> Settings:
124
+ return Settings(
125
+ views=tuple(parse_view(v) for v in (raw.get("views") or ())),
126
+ hidden_sessions=frozenset(raw.get("hidden_sessions") or ()),
127
+ sort_order=raw.get("sort_order", "manual"),
128
+ raw=raw,
129
+ )
130
+
131
+
132
+ def parse_instance_info(raw: Mapping[str, Any]) -> InstanceInfo:
133
+ return InstanceInfo(
134
+ name=raw.get("name", ""),
135
+ device_id=raw.get("device_id", ""),
136
+ version=raw.get("version", ""),
137
+ federation_enabled=bool(raw.get("federation_enabled", False)),
138
+ tmux_socket_dir=raw.get("tmux_socket_dir"),
139
+ bell_hook_armed=raw.get("bell_hook_armed"),
140
+ raw=raw,
141
+ )
142
+
143
+
144
+ def parse_connect_result(raw: Mapping[str, Any]) -> ConnectResult:
145
+ return ConnectResult(
146
+ active_session=raw["active_session"],
147
+ ttyd_port=int(raw["ttyd_port"]),
148
+ )
149
+
150
+
151
+ def parse_input_result(raw: Mapping[str, Any]) -> InputResult:
152
+ return InputResult(
153
+ session=raw["session"],
154
+ snapshot=raw.get("snapshot", ""),
155
+ )
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Error mapping
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def map_status_error(
164
+ status: int,
165
+ path: str,
166
+ detail: str,
167
+ *,
168
+ session_name: str | None = None,
169
+ ) -> MuxplexError:
170
+ """Map an HTTP error status + path to the matching MuxplexError subclass.
171
+
172
+ Rules (applied in order):
173
+ - 401 -> AuthError (credential rejected).
174
+ - 403 from a path ending in "/input" -> InputForbidden (the operator's
175
+ allowlist fence, NOT a bad credential).
176
+ - 403 from any other path -> AuthError.
177
+ - 404 -> SessionNotFound (session_name is whatever the caller was
178
+ targeting; right after create_session() this can be the read-model
179
+ poll cache rather than a real failure).
180
+ - Anything else -> ApiError(status, detail).
181
+ """
182
+ if status == 401:
183
+ return AuthError(detail)
184
+ if status == 403:
185
+ if path.endswith("/input"):
186
+ return InputForbidden(session_name or "", detail)
187
+ return AuthError(detail)
188
+ if status == 404:
189
+ return SessionNotFound(session_name or "", detail)
190
+ return ApiError(status, detail)
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Version comparison (backs the opt-in check_server() helper)
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def version_tuple(version: str) -> tuple[int, ...]:
199
+ """Parse a dotted version string into a comparable tuple of ints.
200
+
201
+ Deliberately lenient: non-numeric suffixes (e.g. "1.2.3rc1") are reduced
202
+ to their leading digits, and an entirely non-numeric segment becomes 0
203
+ rather than raising -- this is a convenience comparison for an opt-in
204
+ caller, not a strict semver parser.
205
+ """
206
+ parts: list[int] = []
207
+ for piece in version.split("."):
208
+ digits = ""
209
+ for ch in piece:
210
+ if ch.isdigit():
211
+ digits += ch
212
+ else:
213
+ break
214
+ parts.append(int(digits) if digits else 0)
215
+ return tuple(parts)
@@ -0,0 +1,262 @@
1
+ """Asynchronous muxplex API client (httpx.AsyncClient transport).
2
+
3
+ Signature-identical to `sync_client.MuxplexClient` with `await` and an
4
+ `Async` prefix. Required for Amplifier tool modules: `async def mount(...)`
5
+ tool execution is awaited, and a synchronous httpx call inside an async tool
6
+ would block the event loop for its duration -- for `run_shell_command`
7
+ polling a ten-minute build, that blocks the provider stream, every other
8
+ tool, and every hook. See _protocol.py's docstring for the shared logic this
9
+ wraps.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any, Self, Sequence
18
+
19
+ import httpx
20
+
21
+ from . import _protocol as protocol
22
+ from .constants import MIN_SERVER_VERSION
23
+ from .errors import CommandTimeout, MuxplexError, UnreachableError
24
+ from .models import (
25
+ CommandResult,
26
+ ConnectResult,
27
+ InputResult,
28
+ InstanceInfo,
29
+ ServerState,
30
+ Session,
31
+ SessionSnapshot,
32
+ Settings,
33
+ ViewResult,
34
+ )
35
+ from .sentinel import make_sentinel
36
+ from .sync_client import _extract_detail
37
+
38
+
39
+ class AsyncMuxplexClient:
40
+ """Thin, typed, asynchronous HTTP client for the muxplex API.
41
+
42
+ See `sync_client.MuxplexClient` for the full rationale behind every
43
+ behavior here (Accept header, follow_redirects, ca_file, federation_key)
44
+ -- this class mirrors it exactly, `await`-shaped.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ server_url: str,
50
+ federation_key: str | None = None,
51
+ *,
52
+ ca_file: Path | str | None = None,
53
+ timeout: float = 5.0,
54
+ client: httpx.AsyncClient | None = None,
55
+ ) -> None:
56
+ if client is not None:
57
+ self._client = client
58
+ self._owns_client = False
59
+ return
60
+ headers = {"Accept": "application/json"}
61
+ if federation_key:
62
+ headers["Authorization"] = f"Bearer {federation_key}"
63
+ verify: bool | str = str(ca_file) if ca_file else True
64
+ self._client = httpx.AsyncClient(
65
+ base_url=server_url,
66
+ headers=headers,
67
+ verify=verify,
68
+ timeout=timeout,
69
+ follow_redirects=False,
70
+ )
71
+ self._owns_client = True
72
+
73
+ async def aclose(self) -> None:
74
+ if self._owns_client:
75
+ await self._client.aclose()
76
+
77
+ async def __aenter__(self) -> Self:
78
+ return self
79
+
80
+ async def __aexit__(self, *exc: object) -> None:
81
+ await self.aclose()
82
+
83
+ async def _request(
84
+ self,
85
+ method: str,
86
+ path: str,
87
+ *,
88
+ params: dict[str, Any] | None = None,
89
+ json: dict[str, Any] | None = None,
90
+ session_name: str | None = None,
91
+ ) -> Any:
92
+ try:
93
+ response = await self._client.request(
94
+ method, path, params=params, json=json
95
+ )
96
+ except httpx.HTTPError as exc:
97
+ raise UnreachableError(f"{method} {path} failed: {exc}") from exc
98
+ if response.status_code >= 400:
99
+ raise protocol.map_status_error(
100
+ response.status_code,
101
+ path,
102
+ _extract_detail(response),
103
+ session_name=session_name,
104
+ )
105
+ return response.json()
106
+
107
+ # ---- read ----
108
+
109
+ async def instance_info(self) -> InstanceInfo:
110
+ return protocol.parse_instance_info(
111
+ await self._request("GET", "/api/instance-info")
112
+ )
113
+
114
+ async def sessions(self) -> list[Session]:
115
+ return protocol.parse_sessions(await self._request("GET", "/api/sessions"))
116
+
117
+ async def session(self, name: str, *, lines: int | None = None) -> SessionSnapshot:
118
+ params = {"lines": lines} if lines is not None else None
119
+ return protocol.parse_session_snapshot(
120
+ await self._request(
121
+ "GET", f"/api/sessions/{name}", params=params, session_name=name
122
+ )
123
+ )
124
+
125
+ async def view(self, *, sort: str | None = None) -> ViewResult:
126
+ params = {"sort": sort} if sort is not None else None
127
+ return protocol.parse_view_result(
128
+ await self._request("GET", "/api/view", params=params)
129
+ )
130
+
131
+ async def state(self) -> ServerState:
132
+ return protocol.parse_server_state(await self._request("GET", "/api/state"))
133
+
134
+ async def settings(self) -> Settings:
135
+ return protocol.parse_settings(await self._request("GET", "/api/settings"))
136
+
137
+ # ---- lifecycle ----
138
+
139
+ async def create_session(
140
+ self,
141
+ name: str,
142
+ *,
143
+ wait: bool = True,
144
+ timeout: float = 6.0,
145
+ interval: float = 0.3,
146
+ ) -> None:
147
+ await self._request(
148
+ "POST", "/api/sessions", json={"name": name}, session_name=name
149
+ )
150
+ if wait and not await self.wait_for_session(
151
+ name, timeout=timeout, interval=interval
152
+ ):
153
+ raise TimeoutError(
154
+ f"session {name!r} did not appear in the read cache within {timeout}s"
155
+ )
156
+
157
+ async def delete_session(self, name: str) -> None:
158
+ await self._request("DELETE", f"/api/sessions/{name}", session_name=name)
159
+
160
+ async def wait_for_session(
161
+ self, name: str, *, timeout: float = 6.0, interval: float = 0.3
162
+ ) -> bool:
163
+ deadline = time.monotonic() + timeout
164
+ while True:
165
+ sessions = await self.sessions()
166
+ if any(s.name == name for s in sessions):
167
+ return True
168
+ if time.monotonic() >= deadline:
169
+ return False
170
+ await asyncio.sleep(interval)
171
+
172
+ async def connect(self, name: str) -> ConnectResult:
173
+ return protocol.parse_connect_result(
174
+ await self._request(
175
+ "POST", f"/api/sessions/{name}/connect", session_name=name
176
+ )
177
+ )
178
+
179
+ async def set_active_view(self, view: str) -> None:
180
+ await self._request("PATCH", "/api/state", json={"active_view": view})
181
+
182
+ # ---- input ----
183
+
184
+ async def send_input(
185
+ self,
186
+ name: str,
187
+ *,
188
+ text: str = "",
189
+ keys: Sequence[str] = (),
190
+ enter: bool = False,
191
+ lines: int | None = None,
192
+ ) -> InputResult:
193
+ body: dict[str, Any] = {"text": text, "keys": list(keys), "enter": enter}
194
+ if lines is not None:
195
+ body["lines"] = lines
196
+ return protocol.parse_input_result(
197
+ await self._request(
198
+ "POST", f"/api/sessions/{name}/input", json=body, session_name=name
199
+ )
200
+ )
201
+
202
+ async def run_shell_command(
203
+ self,
204
+ name: str,
205
+ command: str,
206
+ *,
207
+ timeout: float = 600.0,
208
+ poll_interval: float = 2.0,
209
+ lines: int = 500,
210
+ bell_on_failure: bool = True,
211
+ exit_expr: str = "$?",
212
+ token: str | None = None,
213
+ ) -> CommandResult:
214
+ """Run *command* to completion and return its real exit code.
215
+
216
+ See `sync_client.MuxplexClient.run_shell_command` for the full
217
+ assumptions and rationale -- identical here, `await`-shaped. This is
218
+ the operation that matters most for the async client: it never
219
+ blocks the event loop while polling, unlike a synchronous call
220
+ parked inside an async tool.
221
+ """
222
+ sentinel = make_sentinel(token)
223
+ wrapped = sentinel.wrap(
224
+ command, bell_on_failure=bell_on_failure, exit_expr=exit_expr
225
+ )
226
+ await self.send_input(name, text=wrapped, enter=True)
227
+
228
+ start = time.monotonic()
229
+ deadline = start + timeout
230
+ while True:
231
+ snap = await self.session(name, lines=lines)
232
+ exit_code = sentinel.search(snap.snapshot)
233
+ if exit_code is not None:
234
+ return CommandResult(
235
+ session=name,
236
+ exit_code=exit_code,
237
+ snapshot=snap.snapshot,
238
+ elapsed=time.monotonic() - start,
239
+ token=sentinel.token,
240
+ )
241
+ if time.monotonic() >= deadline:
242
+ raise CommandTimeout(
243
+ session=name,
244
+ token=sentinel.token,
245
+ elapsed=time.monotonic() - start,
246
+ snapshot=snap.snapshot,
247
+ )
248
+ await asyncio.sleep(poll_interval)
249
+
250
+ # ---- opt-in version check ----
251
+
252
+ async def check_server(self, min_version: str = MIN_SERVER_VERSION) -> InstanceInfo:
253
+ """Fetch instance-info and raise MuxplexError if the server is older.
254
+
255
+ Never called automatically.
256
+ """
257
+ info = await self.instance_info()
258
+ if protocol.version_tuple(info.version) < protocol.version_tuple(min_version):
259
+ raise MuxplexError(
260
+ f"server version {info.version!r} is older than required {min_version!r}"
261
+ )
262
+ return info
@@ -0,0 +1,46 @@
1
+ """Mirrored server constants.
2
+
3
+ These are convenience mirrors of values defined authoritatively on the
4
+ muxplex server (`muxplex.sessions`, `muxplex.terminal_input`). The client
5
+ does NOT pre-validate against them -- the server is authoritative and fails
6
+ closed; a client-side copy that silently disagreed with the server would be
7
+ exactly the drift `AGENTS.md` warns about. They exist so a caller can build a
8
+ tool schema enum or a UI without a round trip.
9
+
10
+ `muxplex/tests/test_client_contract.py` (in the server's own test suite)
11
+ asserts each of these equals its server original, converting what would
12
+ otherwise be a silent drift hazard into a CI-enforced invariant.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # Cut-against server version. Provenance, not a runtime requirement -- see
18
+ # MuxplexClient.check_server()/AsyncMuxplexClient.check_server(), which is
19
+ # opt-in and never called automatically.
20
+ MIN_SERVER_VERSION = "0.18.0"
21
+
22
+ # Mirrors muxplex.sessions.DEFAULT_CAPTURE_LINES.
23
+ DEFAULT_CAPTURE_LINES = 30
24
+
25
+ # Mirrors muxplex.sessions.MAX_CAPTURE_LINES.
26
+ MAX_CAPTURE_LINES = 2000
27
+
28
+ # Mirrors muxplex.terminal_input.ALLOWED_KEYS.
29
+ KNOWN_KEYS: frozenset[str] = frozenset(
30
+ {
31
+ "Enter",
32
+ "Escape",
33
+ "Tab",
34
+ "C-c",
35
+ "C-d",
36
+ "Up",
37
+ "Down",
38
+ "Left",
39
+ "Right",
40
+ "PageUp",
41
+ "PageDown",
42
+ }
43
+ )
44
+
45
+ # Mirrors muxplex.terminal_input.MAX_KEYS.
46
+ MAX_KEYS = 64
@@ -0,0 +1,80 @@
1
+ """Exception hierarchy for the muxplex client.
2
+
3
+ Mapping from HTTP status to these types happens in `_protocol.map_status_error`
4
+ and is applied by both transport classes (`sync_client.MuxplexClient`,
5
+ `async_client.AsyncMuxplexClient`). See that function's docstring for the
6
+ exact rules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class MuxplexError(Exception):
13
+ """Base class for all muxplex client errors."""
14
+
15
+
16
+ class UnreachableError(MuxplexError):
17
+ """Transport failure -- connection refused, timeout, DNS, TLS.
18
+
19
+ Raised when the request never got a response from the server at all
20
+ (an `httpx.HTTPError` other than an HTTP status response).
21
+ """
22
+
23
+
24
+ class AuthError(MuxplexError):
25
+ """Credential rejected (401, or 403 from any endpoint except /input)."""
26
+
27
+
28
+ class SessionNotFound(MuxplexError):
29
+ """404 for a session-scoped endpoint.
30
+
31
+ Right after a `create_session()` call, this can be the ~2s read-model
32
+ poll cache rather than a genuine failure -- see AGENT_GUIDE.md's "read
33
+ model is eventually consistent" section, and `wait_for_session()` /
34
+ `create_session(wait=True)`, which poll past this window.
35
+ """
36
+
37
+ def __init__(self, name: str, detail: str = "") -> None:
38
+ self.name = name
39
+ self.detail = detail
40
+ super().__init__(detail or f"Session {name!r} not found")
41
+
42
+
43
+ class InputForbidden(MuxplexError):
44
+ """403 from POST /api/sessions/{name}/input -- the operator's fence.
45
+
46
+ Deliberately does NOT subclass `AuthError`: this is not a rejected
47
+ credential, it means the session is not allowlisted for input (see
48
+ `settings.input_enabled` / `settings.input_allowed_sessions` in the
49
+ muxplex server). The correct caller response is to stop and tell the
50
+ human what to add to `settings.json` -- a different action from
51
+ rotating a key (AGENT_GUIDE.md §7).
52
+ """
53
+
54
+ def __init__(self, name: str, detail: str) -> None:
55
+ self.name = name
56
+ self.detail = detail
57
+ super().__init__(detail)
58
+
59
+
60
+ class ApiError(MuxplexError):
61
+ """Any other non-2xx response not covered by a more specific error."""
62
+
63
+ def __init__(self, status: int, detail: str) -> None:
64
+ self.status = status
65
+ self.detail = detail
66
+ super().__init__(f"HTTP {status}: {detail}")
67
+
68
+
69
+ class CommandTimeout(MuxplexError):
70
+ """`run_shell_command()` did not observe the completion sentinel in time."""
71
+
72
+ def __init__(self, session: str, token: str, elapsed: float, snapshot: str) -> None:
73
+ self.session = session
74
+ self.token = token
75
+ self.elapsed = elapsed
76
+ self.snapshot = snapshot
77
+ super().__init__(
78
+ f"command in session {session!r} did not complete within "
79
+ f"{elapsed:.1f}s (sentinel token={token!r})"
80
+ )
@@ -0,0 +1,164 @@
1
+ """Response shapes for the muxplex client.
2
+
3
+ All frozen dataclasses, deliberately not pydantic -- see
4
+ ../muxplex-client-design.md §8 ("Rejected: pydantic models"): heavy for six
5
+ shapes, and actively wrong here, since AGENTS.md requires clients to
6
+ tolerate unknown fields. `.get()`-based parsing in `_protocol.py` satisfies
7
+ that by construction; a strict model would have to be deliberately loosened
8
+ to avoid violating the contract.
9
+
10
+ Where a `raw` field appears (`ServerState`, `Settings`, `InstanceInfo` -- the
11
+ single-object, evolution-likely shapes) it is excluded from `__eq__`/`__hash__`
12
+ via `compare=False` so a frozen dataclass carrying a dict stays hashable, and
13
+ it is how "clients tolerate unknown fields" becomes usable rather than lossy:
14
+ a caller needing a field the model doesn't expose yet can still reach it.
15
+ `Session`/`Bell`/etc. are hot-path list items with a stable shape and omit it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Mapping
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Bell:
26
+ """A session's bell-alert sub-state, as returned by GET /api/sessions."""
27
+
28
+ last_fired_at: float | None
29
+ seen_at: float | None
30
+ unseen_count: int
31
+
32
+ @property
33
+ def needs_attention(self) -> bool:
34
+ """unseen_count > 0 and (seen_at is None or last_fired_at > seen_at).
35
+
36
+ Mirrors the server's `muxplex.bells.needs_attention` exactly --
37
+ contract-tested for agreement in `test_client_contract.py` across a
38
+ truth table of (unseen_count, seen_at, last_fired_at). Defensive on
39
+ `last_fired_at is None` combined with a non-None `seen_at` (should
40
+ not occur in practice, but treated as "not newer than seen_at"
41
+ rather than raising, matching the server).
42
+ """
43
+ if self.unseen_count <= 0:
44
+ return False
45
+ if self.seen_at is None:
46
+ return True
47
+ if self.last_fired_at is None:
48
+ return False
49
+ return self.last_fired_at > self.seen_at
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Session:
54
+ """One tmux session, as returned by GET /api/sessions."""
55
+
56
+ name: str
57
+ snapshot: str
58
+ bell: Bell
59
+ last_activity_at: float | None = None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class SessionSnapshot:
64
+ """A single session's pane content at a caller-chosen depth.
65
+
66
+ Returned by GET /api/sessions/{name}. Unlike `Session` (the shared,
67
+ ~2s-cycle poll cache), this is one fresh `capture-pane` call scoped to
68
+ a single session.
69
+ """
70
+
71
+ name: str
72
+ snapshot: str
73
+ lines: int
74
+ bell: Bell
75
+ last_activity_at: float | None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ViewSession:
80
+ """One entry of GET /api/view's `sessions` list."""
81
+
82
+ name: str
83
+ active: bool
84
+ needs_attention: bool
85
+ bell: Bell
86
+ last_activity_at: float | None
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ViewResult:
91
+ """The server-resolved current view: GET /api/view."""
92
+
93
+ view: str
94
+ views: tuple[str, ...]
95
+ sort: str # "server" | "alphabetical" | "attention"
96
+ sessions: tuple[ViewSession, ...]
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ServerState:
101
+ """GET /api/state."""
102
+
103
+ active_session: str | None
104
+ active_view: str # defaults to "all" when absent/empty
105
+ settings_updated_at: float | None = None
106
+ raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class View:
111
+ """One user-defined view, as returned by GET /api/settings."""
112
+
113
+ name: str
114
+ sessions: frozenset[str]
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Settings:
119
+ """GET /api/settings."""
120
+
121
+ views: tuple[View, ...]
122
+ hidden_sessions: frozenset[str]
123
+ sort_order: str # default "manual"
124
+ raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class InstanceInfo:
129
+ """GET /api/instance-info."""
130
+
131
+ name: str
132
+ device_id: str
133
+ version: str
134
+ federation_enabled: bool
135
+ tmux_socket_dir: str | None
136
+ bell_hook_armed: bool | None # None on servers < 0.18.0
137
+ raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class ConnectResult:
142
+ """POST /api/sessions/{name}/connect."""
143
+
144
+ active_session: str
145
+ ttyd_port: int
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class InputResult:
150
+ """POST /api/sessions/{name}/input."""
151
+
152
+ session: str
153
+ snapshot: str
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class CommandResult:
158
+ """Result of `run_shell_command()`."""
159
+
160
+ session: str
161
+ exit_code: int
162
+ snapshot: str
163
+ elapsed: float
164
+ token: str
@@ -0,0 +1,80 @@
1
+ """Completion sentinel for driving a shell command to completion over the API.
2
+
3
+ Pure, no I/O -- fully testable without a server, and directly reusable by a
4
+ caller who wants to run their own poll loop instead of `run_shell_command()`.
5
+
6
+ ASSUMES the target pane is an idle POSIX-ish shell prompt. None of this holds
7
+ for a pane running vim, a REPL, `less`, a TUI, an ssh session, or fish/nushell
8
+ (`$status`, `$env.LAST_EXIT_CODE`) unless the caller supplies a matching
9
+ `exit_expr`. See AGENT_GUIDE.md §6.2/§6.4 for the operational convention this
10
+ implements.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import secrets
17
+ from dataclasses import dataclass
18
+
19
+ # Length (in random bytes, before urlsafe-base64 encoding) of an
20
+ # auto-generated token. Short enough to keep the composed shell line
21
+ # readable, long enough that two concurrent commands in the same session
22
+ # don't collide.
23
+ _TOKEN_BYTES = 6
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Sentinel:
28
+ """A single-use completion marker: a token plus its digit-anchored regex."""
29
+
30
+ token: str
31
+ pattern: re.Pattern[str] # MUXPLEX_DONE_<token>_EXIT_(\d+)
32
+
33
+ def wrap(
34
+ self, command: str, *, bell_on_failure: bool = True, exit_expr: str = "$?"
35
+ ) -> str:
36
+ """Compose the one-liner from AGENT_GUIDE.md §6.2/§6.4:
37
+
38
+ <cmd>; rc=<exit_expr>; [ $rc -ne 0 ] && printf '\\a'; \\
39
+ echo "MUXPLEX_DONE_<token>_EXIT_$rc"
40
+
41
+ With bell_on_failure=False, the `printf '\\a'` clause is omitted
42
+ entirely (never fire the bell for a routine, expected-to-fail
43
+ check, per AGENT_GUIDE.md §6.4's recommended convention: ring on
44
+ nonzero exit only, never on routine success).
45
+ """
46
+ parts = [command, f"rc={exit_expr}"]
47
+ if bell_on_failure:
48
+ parts.append("[ $rc -ne 0 ] && printf '\\a'")
49
+ parts.append(f'echo "MUXPLEX_DONE_{self.token}_EXIT_$rc"')
50
+ return "; ".join(parts)
51
+
52
+ def search(self, snapshot: str) -> int | None:
53
+ """Digit-anchored match. Returns the real exit code, or None.
54
+
55
+ The digit anchor is load-bearing: tmux echoes your input line into
56
+ the pane BEFORE the shell runs it, so the literal, unexpanded
57
+ "...EXIT_$?" text is visible in the pane immediately -- before the
58
+ command has even started, let alone finished. A bare-token
59
+ substring check (`"MUXPLEX_DONE_<token>" in snapshot`) false-matches
60
+ on that echo and reports "done" with a bogus exit code instantly.
61
+ Shell expansion never happens inside that echoed input line, so
62
+ `\\d+` only ever matches the version the shell actually wrote back
63
+ out after running the command to completion.
64
+
65
+ Do NOT "simplify" this to a bare-token check -- see
66
+ `tests/test_sentinel.py::test_digit_anchor_regression` for the
67
+ regression this guards against.
68
+ """
69
+ match = self.pattern.search(snapshot)
70
+ if match is None:
71
+ return None
72
+ return int(match.group(1))
73
+
74
+
75
+ def make_sentinel(token: str | None = None) -> Sentinel:
76
+ """Create a new Sentinel. `token` defaults to a short random urlsafe string."""
77
+ if token is None:
78
+ token = secrets.token_urlsafe(_TOKEN_BYTES)
79
+ pattern = re.compile(rf"MUXPLEX_DONE_{re.escape(token)}_EXIT_(\d+)")
80
+ return Sentinel(token=token, pattern=pattern)
@@ -0,0 +1,295 @@
1
+ """Synchronous muxplex API client (httpx.Client transport).
2
+
3
+ Thin await-free shell over `_protocol.py` -- see that module's docstring.
4
+ Signature-identical to `async_client.AsyncMuxplexClient` with `await` and an
5
+ `Async` prefix.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any, Self, Sequence
13
+
14
+ import httpx
15
+
16
+ from . import _protocol as protocol
17
+ from .constants import MIN_SERVER_VERSION
18
+ from .errors import CommandTimeout, MuxplexError, UnreachableError
19
+ from .models import (
20
+ CommandResult,
21
+ ConnectResult,
22
+ InputResult,
23
+ InstanceInfo,
24
+ ServerState,
25
+ Session,
26
+ SessionSnapshot,
27
+ Settings,
28
+ ViewResult,
29
+ )
30
+ from .sentinel import make_sentinel
31
+
32
+
33
+ class MuxplexClient:
34
+ """Thin, typed, synchronous HTTP client for the muxplex API.
35
+
36
+ Always sets `Accept: application/json` and `follow_redirects=False` in
37
+ the constructor -- not left to callers. Without the header the auth
38
+ middleware 307s an unauthenticated request to `/login`; a client that
39
+ follows redirects would get a `200 OK` full of login-page HTML instead
40
+ of the `401` it should see (AGENT_GUIDE.md §1).
41
+
42
+ `federation_key=None` means "no credential" -- a localhost caller needs
43
+ none (AGENT_GUIDE.md §1.1); an agent running on the same host as the
44
+ server should not be forced to invent one.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ server_url: str,
50
+ federation_key: str | None = None,
51
+ *,
52
+ ca_file: Path | str | None = None,
53
+ timeout: float = 5.0,
54
+ client: httpx.Client | None = None,
55
+ ) -> None:
56
+ if client is not None:
57
+ self._client = client
58
+ self._owns_client = False
59
+ return
60
+ headers = {"Accept": "application/json"}
61
+ if federation_key:
62
+ headers["Authorization"] = f"Bearer {federation_key}"
63
+ # ca_file must be the CA, never the leaf -- a documented, expensive
64
+ # footgun in this project (see AGENTS.md's "GET /api/ca" section).
65
+ verify: bool | str = str(ca_file) if ca_file else True
66
+ self._client = httpx.Client(
67
+ base_url=server_url,
68
+ headers=headers,
69
+ verify=verify,
70
+ timeout=timeout,
71
+ follow_redirects=False,
72
+ )
73
+ self._owns_client = True
74
+
75
+ def close(self) -> None:
76
+ if self._owns_client:
77
+ self._client.close()
78
+
79
+ def __enter__(self) -> Self:
80
+ return self
81
+
82
+ def __exit__(self, *exc: object) -> None:
83
+ self.close()
84
+
85
+ def _request(
86
+ self,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ params: dict[str, Any] | None = None,
91
+ json: dict[str, Any] | None = None,
92
+ session_name: str | None = None,
93
+ ) -> Any:
94
+ try:
95
+ response = self._client.request(method, path, params=params, json=json)
96
+ except httpx.HTTPError as exc:
97
+ raise UnreachableError(f"{method} {path} failed: {exc}") from exc
98
+ if response.status_code >= 400:
99
+ raise protocol.map_status_error(
100
+ response.status_code,
101
+ path,
102
+ _extract_detail(response),
103
+ session_name=session_name,
104
+ )
105
+ return response.json()
106
+
107
+ # ---- read ----
108
+
109
+ def instance_info(self) -> InstanceInfo:
110
+ """GET /api/instance-info -- public, no auth required."""
111
+ return protocol.parse_instance_info(self._request("GET", "/api/instance-info"))
112
+
113
+ def sessions(self) -> list[Session]:
114
+ """GET /api/sessions -- the shared, ~2s-cycle poll cache."""
115
+ return protocol.parse_sessions(self._request("GET", "/api/sessions"))
116
+
117
+ def session(self, name: str, *, lines: int | None = None) -> SessionSnapshot:
118
+ """GET /api/sessions/{name} -- one fresh capture-pane at a chosen depth."""
119
+ params = {"lines": lines} if lines is not None else None
120
+ return protocol.parse_session_snapshot(
121
+ self._request(
122
+ "GET", f"/api/sessions/{name}", params=params, session_name=name
123
+ )
124
+ )
125
+
126
+ def view(self, *, sort: str | None = None) -> ViewResult:
127
+ """GET /api/view -- the server-resolved current view."""
128
+ params = {"sort": sort} if sort is not None else None
129
+ return protocol.parse_view_result(
130
+ self._request("GET", "/api/view", params=params)
131
+ )
132
+
133
+ def state(self) -> ServerState:
134
+ """GET /api/state."""
135
+ return protocol.parse_server_state(self._request("GET", "/api/state"))
136
+
137
+ def settings(self) -> Settings:
138
+ """GET /api/settings."""
139
+ return protocol.parse_settings(self._request("GET", "/api/settings"))
140
+
141
+ # ---- lifecycle ----
142
+
143
+ def create_session(
144
+ self,
145
+ name: str,
146
+ *,
147
+ wait: bool = True,
148
+ timeout: float = 6.0,
149
+ interval: float = 0.3,
150
+ ) -> None:
151
+ """POST /api/sessions. With wait=True, polls until the session is
152
+ visible in the ~2s read cache -- 0.3s interval, 6s ceiling, the
153
+ measured schedule from AGENT_GUIDE.md §4. Raises TimeoutError if
154
+ it never appears.
155
+ """
156
+ self._request("POST", "/api/sessions", json={"name": name}, session_name=name)
157
+ if wait and not self.wait_for_session(name, timeout=timeout, interval=interval):
158
+ raise TimeoutError(
159
+ f"session {name!r} did not appear in the read cache within {timeout}s"
160
+ )
161
+
162
+ def delete_session(self, name: str) -> None:
163
+ """DELETE /api/sessions/{name}."""
164
+ self._request("DELETE", f"/api/sessions/{name}", session_name=name)
165
+
166
+ def wait_for_session(
167
+ self, name: str, *, timeout: float = 6.0, interval: float = 0.3
168
+ ) -> bool:
169
+ """Poll GET /api/sessions until *name* appears (poll-cache race)."""
170
+ deadline = time.monotonic() + timeout
171
+ while True:
172
+ if any(s.name == name for s in self.sessions()):
173
+ return True
174
+ if time.monotonic() >= deadline:
175
+ return False
176
+ time.sleep(interval)
177
+
178
+ def connect(self, name: str) -> ConnectResult:
179
+ """POST /api/sessions/{name}/connect.
180
+
181
+ WARNING: active_session is server-global. This moves the human's
182
+ browser view too.
183
+ """
184
+ return protocol.parse_connect_result(
185
+ self._request("POST", f"/api/sessions/{name}/connect", session_name=name)
186
+ )
187
+
188
+ def set_active_view(self, view: str) -> None:
189
+ """PATCH /api/state {"active_view": view}.
190
+
191
+ WARNING: active_view is server-global, last-writer-wins.
192
+ """
193
+ self._request("PATCH", "/api/state", json={"active_view": view})
194
+
195
+ # ---- input ----
196
+
197
+ def send_input(
198
+ self,
199
+ name: str,
200
+ *,
201
+ text: str = "",
202
+ keys: Sequence[str] = (),
203
+ enter: bool = False,
204
+ lines: int | None = None,
205
+ ) -> InputResult:
206
+ """POST /api/sessions/{name}/input.
207
+
208
+ At least one of text/keys/enter must be non-empty (else the server
209
+ 400s). Send order is text -> keys -> enter. Note that
210
+ keys=["Enter"] with enter=True sends TWO Enters.
211
+ """
212
+ body: dict[str, Any] = {"text": text, "keys": list(keys), "enter": enter}
213
+ if lines is not None:
214
+ body["lines"] = lines
215
+ return protocol.parse_input_result(
216
+ self._request(
217
+ "POST", f"/api/sessions/{name}/input", json=body, session_name=name
218
+ )
219
+ )
220
+
221
+ def run_shell_command(
222
+ self,
223
+ name: str,
224
+ command: str,
225
+ *,
226
+ timeout: float = 600.0,
227
+ poll_interval: float = 2.0,
228
+ lines: int = 500,
229
+ bell_on_failure: bool = True,
230
+ exit_expr: str = "$?",
231
+ token: str | None = None,
232
+ ) -> CommandResult:
233
+ """Run *command* to completion and return its real exit code.
234
+
235
+ ASSUMES the target pane is an idle POSIX shell prompt. If it is not
236
+ (vim, a REPL, less, a TUI, an ssh session), this types a line into
237
+ whatever is running and raises CommandTimeout.
238
+
239
+ Composed entirely from send_input() + session() + Sentinel; rebuild
240
+ it yourself from those if this shape does not fit.
241
+
242
+ Raises CommandTimeout, InputForbidden, SessionNotFound.
243
+ """
244
+ sentinel = make_sentinel(token)
245
+ wrapped = sentinel.wrap(
246
+ command, bell_on_failure=bell_on_failure, exit_expr=exit_expr
247
+ )
248
+ self.send_input(name, text=wrapped, enter=True)
249
+
250
+ start = time.monotonic()
251
+ deadline = start + timeout
252
+ while True:
253
+ snap = self.session(name, lines=lines)
254
+ exit_code = sentinel.search(snap.snapshot)
255
+ if exit_code is not None:
256
+ return CommandResult(
257
+ session=name,
258
+ exit_code=exit_code,
259
+ snapshot=snap.snapshot,
260
+ elapsed=time.monotonic() - start,
261
+ token=sentinel.token,
262
+ )
263
+ if time.monotonic() >= deadline:
264
+ raise CommandTimeout(
265
+ session=name,
266
+ token=sentinel.token,
267
+ elapsed=time.monotonic() - start,
268
+ snapshot=snap.snapshot,
269
+ )
270
+ time.sleep(poll_interval)
271
+
272
+ # ---- opt-in version check ----
273
+
274
+ def check_server(self, min_version: str = MIN_SERVER_VERSION) -> InstanceInfo:
275
+ """Fetch instance-info and raise MuxplexError if the server is older.
276
+
277
+ Never called automatically.
278
+ """
279
+ info = self.instance_info()
280
+ if protocol.version_tuple(info.version) < protocol.version_tuple(min_version):
281
+ raise MuxplexError(
282
+ f"server version {info.version!r} is older than required {min_version!r}"
283
+ )
284
+ return info
285
+
286
+
287
+ def _extract_detail(response: httpx.Response) -> str:
288
+ """Best-effort extraction of a FastAPI-style {"detail": ...} error body."""
289
+ try:
290
+ body = response.json()
291
+ if isinstance(body, dict) and "detail" in body:
292
+ return str(body["detail"])
293
+ except Exception:
294
+ pass
295
+ return response.text
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: muxplex-client
3
+ Version: 0.19.0
4
+ Summary: Typed sync/async HTTP client for the muxplex API
5
+ Project-URL: Repository, https://github.com/bkrabach/muxplex
6
+ Project-URL: Issues, https://github.com/bkrabach/muxplex/issues
7
+ Author-email: Brian Krabach <brian@krabach.com>
8
+ License: MIT
9
+ Keywords: api,client,http,muxplex,tmux
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # muxplex-client
26
+
27
+ Typed sync/async HTTP client for the [muxplex](https://github.com/bkrabach/muxplex)
28
+ tmux-session-dashboard API.
29
+
30
+ Ships as its own PyPI distribution (import name `muxplex_client`) so a
31
+ consumer that only needs to make ~12 HTTP calls -- a Stream Deck sidecar, an
32
+ Amplifier tool module -- never has to install the server's dependencies
33
+ (fastapi, uvicorn, python-pam, ...) or its `muxplex` server console script.
34
+ See [`../muxplex-client-design.md`](../muxplex-client-design.md) for the full
35
+ design rationale.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install muxplex-client
41
+ ```
42
+
43
+ Runtime dependency: `httpx>=0.27.0`. Nothing else -- no `muxplex` server
44
+ dependency, no console script installed.
45
+
46
+ ## Usage
47
+
48
+ Synchronous:
49
+
50
+ ```python
51
+ from muxplex_client import MuxplexClient
52
+
53
+ with MuxplexClient("https://your-server:8088", "federation-key") as client:
54
+ for session in client.sessions():
55
+ if session.bell.needs_attention:
56
+ print(f"{session.name} needs attention")
57
+ ```
58
+
59
+ Asynchronous (Amplifier tool modules, or any asyncio caller):
60
+
61
+ ```python
62
+ from muxplex_client import AsyncMuxplexClient
63
+
64
+ async with AsyncMuxplexClient("https://your-server:8088", "federation-key") as client:
65
+ result = await client.run_shell_command("my-session", "pytest -q")
66
+ print(result.exit_code, result.elapsed)
67
+ ```
68
+
69
+ A localhost caller needs no credential -- `federation_key=None` is the
70
+ default and is fine when running on the same host as the server.
71
+
72
+ ## What's in here vs. what isn't
73
+
74
+ See `muxplex-client-design.md` §3 for the full included/excluded endpoint
75
+ table and rationale. Notably excluded: `PATCH /api/settings` (highest
76
+ blast-radius operation in the API; a v2 concern requiring CAS + 409 retry +
77
+ backstop discrimination), all federation endpoints (server-to-server
78
+ protocol, no client consumer), and `/api/internal/setup-hooks` (internal,
79
+ self-healing as of server v0.18.0).
80
+
81
+ ## Version alignment
82
+
83
+ `muxplex_client.__version__` is cut in lockstep with the muxplex server
84
+ version -- one `vX.Y.Z` tag publishes both wheels. This is provenance
85
+ ("cut against server X"), not a runtime requirement: the client declares no
86
+ dependency on the `muxplex` package and enforces no version at runtime.
87
+ `MIN_SERVER_VERSION` backs an opt-in `check_server()` helper the caller may
88
+ call; it is never invoked automatically.
89
+
90
+ The load-bearing correctness mechanism is
91
+ `muxplex/tests/test_client_contract.py`, living in the **server's** own test
92
+ suite: it drives this client over `httpx.ASGITransport` against the real
93
+ FastAPI app and asserts every field the client parses actually exists on the
94
+ real response, that mirrored constants (`KNOWN_KEYS`, `MAX_CAPTURE_LINES`,
95
+ `DEFAULT_CAPTURE_LINES`) equal their server originals, and that
96
+ `Bell.needs_attention` agrees with the server's own predicate across a truth
97
+ table.
98
+
99
+ ## The shell-command sentinel
100
+
101
+ `run_shell_command()` (and the lower-level `muxplex_client.sentinel` module)
102
+ implements the completion-detection convention from `AGENT_GUIDE.md` §6.2/§6.4:
103
+ wrap a command with `; rc=$?; ... ; echo "MUXPLEX_DONE_<token>_EXIT_$rc"` and
104
+ poll the pane for the marker.
105
+
106
+ **This assumes the target pane is an idle POSIX shell prompt.** It is *not* a
107
+ general HTTP contract -- it fails (types a garbage line into whatever is
108
+ actually running, then hangs until timeout) against vim, a REPL, `less`, a
109
+ TUI, an ssh session, or a non-POSIX shell without a matching `exit_expr`.
110
+
111
+ The one correctness rule worth calling out explicitly: matching must be
112
+ **digit-anchored** (`MUXPLEX_DONE_<token>_EXIT_(\d+)`), never a bare-token
113
+ substring check. tmux echoes the literal, unexpanded `...EXIT_$?` into the
114
+ pane the instant you send the line -- before the shell has even run it -- so
115
+ a bare-token match reports "done" with a bogus exit code immediately. See
116
+ `muxplex_client/sentinel.py`'s docstring and `tests/test_sentinel.py`'s
117
+ digit-anchor regression test.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ uv sync --extra dev # from this directory, or from the repo root via the
123
+ # [tool.uv.workspace] declaration
124
+ uv run pytest
125
+ ```
126
+
127
+ `tests/` is pure -- no network, no server import, and passes with `muxplex`
128
+ not installed at all.
@@ -0,0 +1,11 @@
1
+ muxplex_client/__init__.py,sha256=soQxrx4jsGdQWk4VLCI1-rdIfPE7Z7vGtjx2_Tg7EpY,2263
2
+ muxplex_client/_protocol.py,sha256=vazzprjvzLdJKjGm0uPQRe4_9GPZd0zK34wqFF7kgO8,6721
3
+ muxplex_client/async_client.py,sha256=OgOP6SlnUIHcLmm5XCOTPaI-j9pBglCkZQ5J5cOdPe0,8765
4
+ muxplex_client/constants.py,sha256=9ozPCO-RATK0rGaW3SMkdhzepToRGONUWIQcleYEfxI,1417
5
+ muxplex_client/errors.py,sha256=JMll3s1XRXtC9fCWr5D-YbUXDhxJVK072ZoMmCxLJqQ,2759
6
+ muxplex_client/models.py,sha256=b4xfBKKE9lmfiDVYw15oWiw-5tbUMQ8_9PX4UO9oKTg,4516
7
+ muxplex_client/sentinel.py,sha256=Dl-omK35y4yHfbSYx26ARnvJBWcO-rQ_L3jlnZxs9EQ,3333
8
+ muxplex_client/sync_client.py,sha256=Y4pfJpTdUWr5yPNgaCT0TXtR156RFCrfofIeWiyZ-5M,10168
9
+ muxplex_client-0.19.0.dist-info/METADATA,sha256=94pqY8ebVaZZx8hJo2VIhWDO9QJRMXRwvPOSMDtussE,5073
10
+ muxplex_client-0.19.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ muxplex_client-0.19.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any