soothe-client-python 0.9.4__py3-none-any.whl → 0.9.6__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.
soothe_client/__init__.py CHANGED
@@ -1,8 +1,7 @@
1
- """Soothe WebSocket client for soothe-daemon.
1
+ """Soothe WebSocket client for talking to a running soothe-daemon.
2
2
 
3
- Layer 0 transport and session helpers. Layer 1 application mechanics live in
4
- ``soothe_client.appkit``. Shared wire codec and path constants live in
5
- soothe-sdk (`soothe_sdk.wire`, `soothe_sdk.paths`).
3
+ Transport helpers (connect, loops, RPCs) live here. Higher-level session
4
+ and multi-user turn tools live in ``soothe_client.appkit``.
6
5
  """
7
6
 
8
7
  from __future__ import annotations
@@ -21,12 +20,14 @@ from soothe_client.errors import (
21
20
  )
22
21
  from soothe_client.helpers import (
23
22
  check_daemon_status,
23
+ connected_websocket,
24
24
  fetch_config_section,
25
25
  fetch_loop_cards,
26
26
  fetch_loop_history,
27
27
  fetch_loop_messages,
28
28
  fetch_skills_catalog,
29
29
  is_daemon_live,
30
+ protocol1_rpc,
30
31
  request_daemon_config_reload,
31
32
  request_daemon_shutdown,
32
33
  websocket_url_from_config,
@@ -42,7 +43,17 @@ from soothe_client.intent_hints import (
42
43
  from soothe_client.protocol_params import (
43
44
  AuthParams,
44
45
  AuthRefreshParams,
46
+ AutopilotCancelGoalParams,
47
+ AutopilotDreamParams,
48
+ AutopilotGetGoalParams,
49
+ AutopilotGetJobParams,
50
+ AutopilotListGoalsParams,
51
+ AutopilotListJobsParams,
52
+ AutopilotResumeParams,
53
+ AutopilotStatusParams,
54
+ AutopilotSubmitParams,
45
55
  AutopilotSubscribeParams,
56
+ AutopilotWakeParams,
46
57
  ConfigGetParams,
47
58
  ConfigReloadParams,
48
59
  CronAddParams,
@@ -114,6 +125,8 @@ __all__ = [
114
125
  "bootstrap_loop_session",
115
126
  "connect_websocket_with_retries",
116
127
  "websocket_url_from_config",
128
+ "connected_websocket",
129
+ "protocol1_rpc",
117
130
  "check_daemon_status",
118
131
  "is_daemon_live",
119
132
  "request_daemon_config_reload",
@@ -144,6 +157,16 @@ __all__ = [
144
157
  "LoopDetachParams",
145
158
  "SubscribeParams",
146
159
  "AutopilotSubscribeParams",
160
+ "AutopilotStatusParams",
161
+ "AutopilotSubmitParams",
162
+ "AutopilotListGoalsParams",
163
+ "AutopilotGetGoalParams",
164
+ "AutopilotCancelGoalParams",
165
+ "AutopilotWakeParams",
166
+ "AutopilotDreamParams",
167
+ "AutopilotResumeParams",
168
+ "AutopilotListJobsParams",
169
+ "AutopilotGetJobParams",
147
170
  "JobCreateParams",
148
171
  "JobStatusParams",
149
172
  "JobPauseParams",
@@ -1,15 +1,12 @@
1
- """Reusable application-architecture layer over Layer 0 (RFC-629).
1
+ """Application helpers built on WebSocketClient.
2
2
 
3
- appkit is product-agnostic. Deliverable phases, persistence, and UI copy stay
4
- in the application (e.g. soothe-cli). Building blocks:
3
+ Product-agnostic building blocks for agent UIs and backends:
5
4
 
6
- - ``unwrap_next`` / ``is_loop_scoped_event`` protocol-1 stream helpers
7
- - ``QueryGate`` — single-flight cancel-before-context gating
8
- - ``TurnEventPipeline`` — reader / processor / applier concurrency
9
- - ``DaemonSession`` — dual-socket loop session + ``iter_turn_chunks``
10
- - ``EventClassifier`` / ``extract_thinking_step`` deliverable terminal mapping
11
- - ``SSEBroadcaster`` — drop-on-full SSE-style fan-out
12
- - ``ConnectionPool`` / ``TurnRunner`` — pooled multi-session turn execution
5
+ - ``DaemonSession`` — dual-socket loop session + streamed turns
6
+ - ``ConnectionPool`` / ``TurnRunner`` multi-session turn execution
7
+ - ``QueryGate`` — one-in-flight query per session
8
+ - ``EventClassifier`` / ``extract_thinking_step`` stream deliverable mapping
9
+ - ``SSEBroadcaster`` drop-on-full fan-out to subscribers
13
10
  - ``SessionStore`` — persistence seam (Protocol)
14
11
  """
15
12
 
@@ -1,8 +1,7 @@
1
- """Image attachment compaction for appkit (RFC-629 Layer 1).
1
+ """Image attachment compaction for appkit.
2
2
 
3
3
  Downscales ``image/*`` payloads when either dimension exceeds a max size.
4
- Non-images and decode failures pass through unchanged. Requires Pillow when
5
- compaction is requested; without Pillow, inputs are returned unchanged.
4
+ Non-images and decode failures pass through unchanged.
6
5
  """
7
6
 
8
7
  from __future__ import annotations
@@ -12,6 +11,8 @@ import io
12
11
  from dataclasses import dataclass
13
12
  from typing import Any
14
13
 
14
+ from PIL import Image
15
+
15
16
 
16
17
  @dataclass(slots=True)
17
18
  class CompactImageOptions:
@@ -49,10 +50,6 @@ def compact_image_attachment(
49
50
  """
50
51
  if not data_b64 or not mime_type.startswith("image/"):
51
52
  return mime_type, data_b64
52
- try:
53
- from PIL import Image
54
- except ImportError:
55
- return mime_type, data_b64
56
53
 
57
54
  try:
58
55
  raw = base64.b64decode(data_b64, validate=False)
@@ -1,4 +1,4 @@
1
- """SSE-style pub/sub fan-out for appkit (RFC-629 Layer 1).
1
+ """SSE-style pub/sub fan-out for appkit.
2
2
 
3
3
  Generic, string-keyed pub/sub for SSE-style event delivery. Slow consumers do
4
4
  not stall the broadcaster: each subscriber has a bounded queue and overflowing
@@ -1,4 +1,4 @@
1
- """Event classifier for appkit (RFC-629 Layer 1).
1
+ """Event classifier for appkit.
2
2
 
3
3
  Maps a stream of decoded daemon events into deliverable/streaming/terminal
4
4
  outcomes, keyed on (namespace, mode, phase). Product apps pass their own
@@ -1,9 +1,8 @@
1
- """Dual-socket daemon loop session with turn streaming (CLI-grade mechanics).
1
+ """Dual-socket daemon loop session with turn streaming.
2
2
 
3
3
  Owns a subscribed stream WebSocket plus an RPC sidecar so metadata calls do not
4
- starve ``loop_events``. ``iter_turn_chunks`` implements stale-idle guarding,
5
- post-idle drain, loop scoping, and connection-loss detection — behavior that
6
- originated in soothe-cli and is not yet mirrored in Go/TS TurnRunner.
4
+ starve loop events. ``iter_turn_chunks`` handles idle timeout, post-idle drain,
5
+ loop scoping, and connection-loss detection.
7
6
  """
8
7
 
9
8
  from __future__ import annotations
@@ -25,7 +24,12 @@ logger = logging.getLogger(__name__)
25
24
 
26
25
  DEFAULT_POST_IDLE_DRAIN_S = 0.5
27
26
  _RPC_HANDSHAKE_TIMEOUT_S = 20.0
28
- _TURN_END_CUSTOM_TYPES = frozenset({"soothe.cognition.strange_loop.completed"})
27
+ _TURN_END_CUSTOM_TYPES = frozenset(
28
+ {
29
+ "soothe.cognition.strange_loop.completed",
30
+ "soothe.stream.end",
31
+ }
32
+ )
29
33
 
30
34
  EarlyDropFn = Callable[[tuple[Any, ...], str, Any], bool]
31
35
  StatsFactory = Callable[[], Any]
@@ -210,6 +214,7 @@ class DaemonSession:
210
214
  clarification_mode: str | None = None,
211
215
  clarification_answer: bool = False,
212
216
  clarification_answers: list[str] | None = None,
217
+ intent_hint: str | None = None,
213
218
  ) -> None:
214
219
  """Send a new user turn to the daemon."""
215
220
  if not self._loop_id:
@@ -227,6 +232,7 @@ class DaemonSession:
227
232
  clarification_mode=clarification_mode,
228
233
  clarification_answer=clarification_answer,
229
234
  clarification_answers=clarification_answers,
235
+ intent_hint=intent_hint,
230
236
  )
231
237
 
232
238
  async def cancel_remote_query(self) -> None:
@@ -289,8 +295,19 @@ class DaemonSession:
289
295
  await self._ensure_rpc_connected()
290
296
  return await self._rpc_client.request("loop_list", {"limit": limit}, timeout=15.0)
291
297
 
292
- async def iter_turn_chunks(self) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
293
- """Yield ``(namespace, mode, data)`` chunks for the active daemon turn."""
298
+ async def iter_turn_chunks(
299
+ self,
300
+ *,
301
+ max_wait_s: float | None = None,
302
+ ) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
303
+ """Yield ``(namespace, mode, data)`` chunks for the active daemon turn.
304
+
305
+ Args:
306
+ max_wait_s: Optional absolute deadline for the whole turn. When set,
307
+ raises ``TimeoutError`` if the daemon never emits a turn-end
308
+ signal in time (idle after payload, stopped, stream.end, or
309
+ strange_loop.completed).
310
+ """
294
311
  self.turn_event_stats = self._new_turn_stats()
295
312
  self.last_turn_end_state = None
296
313
  self.last_turn_cancellation_seen = False
@@ -303,6 +320,9 @@ class DaemonSession:
303
320
  turn_read_started = time.monotonic()
304
321
  first_event_logged = False
305
322
  progress_seen = False
323
+ absolute_deadline = (
324
+ time.monotonic() + max_wait_s if max_wait_s is not None and max_wait_s > 0 else None
325
+ )
306
326
  peel = getattr(self._client, "peel_stale_pending_control_events", None)
307
327
  stale_pending = peel() if callable(peel) else []
308
328
  if stale_pending:
@@ -315,6 +335,11 @@ class DaemonSession:
315
335
  async with self._read_lock:
316
336
  try:
317
337
  while True:
338
+ if absolute_deadline is not None and time.monotonic() >= absolute_deadline:
339
+ raise TimeoutError(
340
+ f"Turn timed out after {max_wait_s:.0f}s "
341
+ f"(loop={expected_loop_id or '?'})"
342
+ )
318
343
  if not progress_seen and time.monotonic() - turn_read_started > 30.0:
319
344
  logger.warning(
320
345
  "No daemon stream progress after %.0fs (loop=%s, "
@@ -407,7 +432,14 @@ class DaemonSession:
407
432
  if mode == "custom" and isinstance(data, dict):
408
433
  custom_type = str(data.get("type", "")).strip()
409
434
  if custom_type in _TURN_END_CUSTOM_TYPES:
410
- self.last_turn_end_state = "completed"
435
+ # stream.end may be scope-tagged; treat blank / turn as end.
436
+ if custom_type == "soothe.stream.end":
437
+ scope = str(data.get("scope") or "turn").strip().lower()
438
+ if scope not in {"", "turn"}:
439
+ continue
440
+ self.last_turn_end_state = (
441
+ "stream_end" if custom_type == "soothe.stream.end" else "completed"
442
+ )
411
443
  async for chunk in self._drain_stream_events_after_idle(
412
444
  expected_loop_id=expected_loop_id,
413
445
  ):
@@ -1,7 +1,7 @@
1
- """ManagedClient seam for appkit ConnectionPool / TurnRunner (RFC-629).
1
+ """ManagedClient adapter used by ConnectionPool and TurnRunner.
2
2
 
3
- The concrete ``WebSocketClient`` satisfies it via ``WebSocketManagedClient``;
4
- tests supply fakes.
3
+ Wraps ``WebSocketClient`` and upgrades flat appkit payloads
4
+ (``loop_input``, ``command_request``, …) to protocol-1 envelopes before send.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -18,10 +18,72 @@ BootstrapFunc = Callable[
18
18
  Awaitable[str],
19
19
  ]
20
20
 
21
+ # Flat appkit payloads still use legacy ``{"type": "<op>", ...}`` maps (parity
22
+ # with Go InputMessageForLoop). The live daemon accepts protocol-1 envelopes
23
+ # only — coerce at the ManagedClient boundary before send.
24
+ _ALREADY_ENVELOPE_TYPES = frozenset(
25
+ {
26
+ "request",
27
+ "response",
28
+ "notification",
29
+ "subscribe",
30
+ "next",
31
+ "error",
32
+ "complete",
33
+ "unsubscribe",
34
+ "connection_init",
35
+ "connection_ack",
36
+ "ping",
37
+ "pong",
38
+ "receipt_response",
39
+ }
40
+ )
41
+ _NOTIFICATION_METHODS = frozenset({"loop_input", "slash_command", "disconnect", "delivery_ack"})
42
+ _REQUEST_METHOD_ALIASES = {
43
+ # TurnRunner cancel uses flat command_request; wire method is rpc_command.
44
+ "command_request": "rpc_command",
45
+ }
46
+
47
+
48
+ def _coerce_appkit_wire_message(msg: dict[str, Any], client: WebSocketClient) -> dict[str, Any]:
49
+ """Upgrade flat appkit dicts to protocol-1 envelopes when needed."""
50
+ from soothe_sdk.wire.codec import MessageType, WireEnvelope
51
+
52
+ msg_type = str(msg.get("type") or "")
53
+ if msg.get("proto") == "1" or msg_type in _ALREADY_ENVELOPE_TYPES:
54
+ return msg
55
+
56
+ params = {
57
+ key: value
58
+ for key, value in msg.items()
59
+ if key not in {"type", "proto", "method", "params", "id", "request_id"}
60
+ }
61
+
62
+ if msg_type in _NOTIFICATION_METHODS:
63
+ return WireEnvelope(
64
+ proto="1",
65
+ type=MessageType.NOTIFICATION.value,
66
+ method=msg_type,
67
+ params=params,
68
+ ).to_wire_dict()
69
+
70
+ if msg_type in _REQUEST_METHOD_ALIASES or msg_type:
71
+ request_method = _REQUEST_METHOD_ALIASES.get(msg_type, msg_type)
72
+ req_id = str(msg.get("request_id") or msg.get("id") or client._next_request_id())
73
+ return WireEnvelope(
74
+ proto="1",
75
+ type=MessageType.REQUEST.value,
76
+ method=request_method,
77
+ params=params,
78
+ id=req_id,
79
+ ).to_wire_dict()
80
+
81
+ return msg
82
+
21
83
 
22
84
  @runtime_checkable
23
85
  class ManagedClient(Protocol):
24
- """Subset of Layer 0 that ConnectionPool and TurnRunner depend on."""
86
+ """Subset of WebSocketClient that ConnectionPool and TurnRunner depend on."""
25
87
 
26
88
  async def connect(self) -> None:
27
89
  """Dial and handshake."""
@@ -72,7 +134,7 @@ class WebSocketManagedClient:
72
134
 
73
135
  @property
74
136
  def underlying(self) -> WebSocketClient:
75
- """Return the wrapped Layer 0 client."""
137
+ """Return the wrapped WebSocketClient."""
76
138
  return self._client
77
139
 
78
140
  async def connect(self) -> None:
@@ -89,7 +151,7 @@ class WebSocketManagedClient:
89
151
  async def send_message(self, msg: Any) -> None:
90
152
  if not isinstance(msg, dict):
91
153
  raise TypeError("send_message expects a dict payload")
92
- await self._client.send(msg)
154
+ await self._client.send(_coerce_appkit_wire_message(msg, self._client))
93
155
 
94
156
  async def receive_messages(
95
157
  self,
@@ -1,4 +1,4 @@
1
- """Per-session connection pool for appkit (RFC-629 Layer 1).
1
+ """Per-session connection pool for appkit.
2
2
 
3
3
  Manages a pool of daemon connections, one active per session. Reuses an
4
4
  active connection when still live, otherwise bootstraps a fresh loop or
@@ -1,4 +1,4 @@
1
- """Single-flight query gate (RFC-629 Layer 1)."""
1
+ """Single-flight query gate."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -1,4 +1,4 @@
1
- """Persistence seam for appkit (RFC-629 Layer 1)."""
1
+ """Persistence seam for appkit."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -1,4 +1,4 @@
1
- """Thinking-step extraction for appkit (RFC-629 Layer 1).
1
+ """Thinking-step extraction for appkit.
2
2
 
3
3
  Maps an allowlisted progress event to one structured UI line. Free-form
4
4
  streams (tokens, reports, reasoning) are excluded. Ported from Go/TS appkit.
@@ -1,11 +1,11 @@
1
- """Turn runner for appkit (RFC-629 Layer 1).
1
+ """Turn runner for appkit.
2
2
 
3
3
  Executes one query turn end-to-end: acquire a pooled connection, enforce
4
4
  single-flight, send loop_input, consume the event stream, classify events,
5
5
  resolve the deliverable, persist the reply, and broadcast completion.
6
6
 
7
7
  Supports absolute query timeout, optional idle silence watchdog, soft-complete
8
- policies (IG-651), and optional attachment compaction before send.
8
+ policies, and optional attachment compaction before send.
9
9
  """
10
10
 
11
11
  from __future__ import annotations
soothe_client/helpers.py CHANGED
@@ -5,36 +5,35 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import contextlib
7
7
  import logging
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
8
10
  from typing import Any
9
11
 
12
+ from soothe_sdk.wire.codec import ProtocolError
13
+
10
14
  from soothe_client.websocket import WebSocketClient
11
15
 
12
16
  logger = logging.getLogger(__name__)
13
17
 
14
18
 
15
19
  def websocket_url_from_config(cfg: Any) -> str:
16
- """Construct WebSocket URL from a config-like object.
17
-
18
- Duck-typed across the three workspace configs:
20
+ """Build a WebSocket URL from a config-like object.
19
21
 
20
- * ``CLIConfig`` (`soothe-cli`) exposes ``daemon_host`` / ``daemon_port``
21
- and a ``websocket_url()`` helper.
22
- * ``SootheDaemonConfig`` (`soothe-daemon`) exposes
23
- ``transports.websocket.host`` / ``.port``.
24
- * Legacy callers may still pass an object with
25
- ``daemon.transports.websocket.host`` / ``.port``.
22
+ Accepts any object that provides one of:
26
23
 
27
- The SDK deliberately does not import any of those classes — keeping
28
- `soothe_sdk` independent of `soothe`, `soothe_cli`, and `soothe_daemon`.
24
+ * ``websocket_url()`` callable
25
+ * ``daemon_host`` / ``daemon_port``
26
+ * ``transports.websocket.host`` / ``.port``
27
+ * ``daemon.transports.websocket.host`` / ``.port``
29
28
 
30
29
  Args:
31
- cfg: Any object exposing one of the shapes above.
30
+ cfg: Config-like object.
32
31
 
33
32
  Returns:
34
- WebSocket URL string (e.g., ``"ws://127.0.0.1:8765"``).
33
+ WebSocket URL (e.g. ``"ws://127.0.0.1:8765"``).
35
34
 
36
35
  Raises:
37
- AttributeError: If ``cfg`` exposes none of the supported shapes.
36
+ AttributeError: If none of the supported shapes are present.
38
37
  """
39
38
  if hasattr(cfg, "websocket_url") and callable(cfg.websocket_url):
40
39
  url = cfg.websocket_url()
@@ -62,7 +61,7 @@ def websocket_url_from_config(cfg: Any) -> str:
62
61
 
63
62
 
64
63
  async def _ensure_handshake(client: WebSocketClient, *, timeout: float) -> None:
65
- """Complete the protocol-1 readiness handshake when not already done (RFC-450 §8.2).
64
+ """Complete the protocol-1 readiness handshake when not already done.
66
65
 
67
66
  Args:
68
67
  client: Connected WebSocketClient.
@@ -79,6 +78,80 @@ async def _ensure_handshake(client: WebSocketClient, *, timeout: float) -> None:
79
78
  await client.wait_for_connection_ack(ack_timeout_s=timeout)
80
79
 
81
80
 
81
+ @asynccontextmanager
82
+ async def connected_websocket(
83
+ ws_url: str,
84
+ *,
85
+ timeout: float = 30.0,
86
+ ) -> AsyncIterator[WebSocketClient]:
87
+ """Connect, handshake, and yield a ready ``WebSocketClient``.
88
+
89
+ Args:
90
+ ws_url: Daemon WebSocket URL.
91
+ timeout: Handshake / overall connect budget in seconds.
92
+
93
+ Yields:
94
+ A connected client with protocol-1 handshake complete.
95
+ """
96
+ client = WebSocketClient(url=ws_url)
97
+ try:
98
+ await client.connect()
99
+ await asyncio.wait_for(_ensure_handshake(client, timeout=timeout), timeout=timeout)
100
+ yield client
101
+ finally:
102
+ await client.close()
103
+
104
+
105
+ async def protocol1_rpc(
106
+ ws_url: str,
107
+ method: str,
108
+ params: dict[str, Any] | None = None,
109
+ *,
110
+ mode: str = "request",
111
+ timeout: float = 30.0,
112
+ ) -> dict[str, Any]:
113
+ """One-shot protocol-1 RPC / notify / subscribe with dict error contract.
114
+
115
+ Callers check ``if "error" in response``. Used by Typer commands that do not
116
+ hold a long-lived session.
117
+
118
+ Args:
119
+ ws_url: WebSocket URL.
120
+ method: RPC method, notify target, or subscribe channel.
121
+ params: Structured parameters.
122
+ mode: ``request``, ``notify``, or ``subscribe``.
123
+ timeout: Seconds for handshake and the RPC itself.
124
+
125
+ Returns:
126
+ Result dict, ``{}`` for notify, ``{"subscription_id": ...}`` for
127
+ subscribe, or ``{"error": "..."}`` on failure.
128
+ """
129
+ try:
130
+ async with connected_websocket(ws_url, timeout=timeout) as client:
131
+ if mode == "notify":
132
+ await asyncio.wait_for(client.notify(method, params or {}), timeout=timeout)
133
+ return {}
134
+ if mode == "subscribe":
135
+ sub_id = await asyncio.wait_for(
136
+ client.subscribe(method, params or {}, timeout=timeout),
137
+ timeout=timeout,
138
+ )
139
+ return {"subscription_id": sub_id}
140
+ result = await asyncio.wait_for(
141
+ client.request(method, params or {}, timeout=timeout),
142
+ timeout=timeout,
143
+ )
144
+ return result if isinstance(result, dict) else {"result": result}
145
+ except TimeoutError:
146
+ return {"error": "Timed out waiting for daemon response"}
147
+ except ProtocolError as exc:
148
+ return {"error": str(exc)}
149
+ except (ConnectionError, OSError) as exc:
150
+ return {"error": f"Connection error: {exc}"}
151
+ except Exception as exc:
152
+ return {"error": str(exc)}
153
+
154
+
82
155
  async def check_daemon_status(
83
156
  client: WebSocketClient,
84
157
  timeout: float = 5.0,
@@ -115,7 +188,7 @@ async def check_daemon_status(
115
188
  def _daemon_status_indicates_live(status: dict) -> bool:
116
189
  """Infer liveness from a ``daemon_status`` response payload.
117
190
 
118
- Uses ``readiness_state`` (RFC-450 §8.2): transitional states (``starting``,
191
+ Uses ``readiness_state``: transitional states (``starting``,
119
192
  ``warming``) mean the daemon is not yet ready for loops; terminal states
120
193
  (``error``, ``degraded``, ``stopped``) mean it cannot serve loops; only
121
194
  ``ready`` is live for loop operations.
@@ -142,7 +215,7 @@ async def is_daemon_live(
142
215
  ) -> bool:
143
216
  """Composite health check: connection + status RPC.
144
217
 
145
- Optionally waits for daemon to reach "ready" state (IG-489), polling during
218
+ Optionally waits for daemon to reach "ready" state, polling during
146
219
  transitional states like "starting" and "warming".
147
220
 
148
221
  Args:
@@ -317,7 +390,7 @@ async def fetch_config_section(client: WebSocketClient, section: str, timeout: f
317
390
  """Fetch daemon config section via RPC.
318
391
 
319
392
  Performs ``connection_init`` / ``connection_ack`` handshake when needed
320
- before sending the request (RFC-450 §8.2).
393
+ before sending the request.
321
394
 
322
395
  Args:
323
396
  client: Connected WebSocketClient
@@ -392,6 +465,8 @@ async def fetch_loop_messages(
392
465
 
393
466
  __all__ = [
394
467
  "websocket_url_from_config",
468
+ "connected_websocket",
469
+ "protocol1_rpc",
395
470
  "check_daemon_status",
396
471
  "is_daemon_live",
397
472
  "request_daemon_shutdown",