soothe-client-python 0.9.4__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,1626 @@
1
+ """WebSocket client for daemon connections (RFC-0013)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from collections import deque
11
+ from collections.abc import AsyncGenerator, Callable
12
+ from typing import Any
13
+
14
+ import websockets.asyncio.client
15
+ import websockets.exceptions
16
+ from soothe_sdk.ux.loop_stream import is_stream_terminal_wire_dict
17
+ from soothe_sdk.wire.protocol import decode_websocket_text, encode_websocket_text
18
+
19
+ from soothe_client.errors import DisconnectCause, ReconnectError, StaleLoopError
20
+ from soothe_client.intent_hints import validate_loop_input_intent_hint
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # IG-535 Optimization 1: Priority-aware drop policy for inbound queue.
25
+ # Lower values = keep, higher values = drop candidate.
26
+ _DROP_PRIORITY_CRITICAL = 0 # Never drop: terminal frames, goal_completion, status, errors
27
+ _DROP_PRIORITY_HIGH = 1 # Prefer keep: tool call updates, step events
28
+ _DROP_PRIORITY_NORMAL = 2 # Default drop candidate: streaming text, updates
29
+
30
+
31
+ def _wire_message_dict_from_event_data(data: Any) -> dict[str, Any] | None:
32
+ """Extract the first message wire dict from an event ``data`` payload."""
33
+ if isinstance(data, dict):
34
+ return data
35
+ if isinstance(data, (tuple, list)) and data:
36
+ first = data[0]
37
+ return first if isinstance(first, dict) else None
38
+ return None
39
+
40
+
41
+ def _event_messages_wire_terminal(data: Any) -> bool:
42
+ """Return True when a messages-mode payload is a stream terminal frame."""
43
+ body = _wire_message_dict_from_event_data(data)
44
+ return is_stream_terminal_wire_dict(body) if body is not None else False
45
+
46
+
47
+ def _inbound_frame_drop_priority(event: dict[str, Any] | None) -> int:
48
+ """Return priority for drop decision (lower = keep, higher = drop candidate).
49
+
50
+ IG-535: Ensures terminal frames and goal_completion are never dropped
51
+ even when inbound queue is full.
52
+
53
+ Args:
54
+ event: Wire frame dict or None sentinel.
55
+
56
+ Returns:
57
+ Priority level: 0 (never drop) to 2 (drop candidate).
58
+ """
59
+ if event is None:
60
+ return _DROP_PRIORITY_CRITICAL # Sentinel - never drop
61
+
62
+ event_type = event.get("type", "")
63
+
64
+ # Batched transport frames bundle user-visible events — prefer keep (IG-546).
65
+ if event_type == "event_batch":
66
+ return _DROP_PRIORITY_HIGH
67
+ if event_type == "tool_call_updates_batch":
68
+ return _DROP_PRIORITY_HIGH
69
+
70
+ # Unwrap protocol-1 next envelope (RFC-450 §9.3)
71
+ if event_type == "next":
72
+ payload = event.get("payload")
73
+ if isinstance(payload, dict):
74
+ inner_type = payload.get("type", "")
75
+ inner_mode = payload.get("mode", "")
76
+ inner_data = payload.get("data")
77
+
78
+ # Check for goal_completion in inner messages frame
79
+ if inner_mode == "messages" and isinstance(inner_data, (tuple, list)):
80
+ if _event_messages_wire_terminal(inner_data):
81
+ return _DROP_PRIORITY_CRITICAL
82
+ if inner_data and isinstance(inner_data[0], dict):
83
+ if inner_data[0].get("phase") == "goal_completion":
84
+ return _DROP_PRIORITY_CRITICAL
85
+
86
+ # Protocol-1 complete envelope
87
+ if inner_type == "complete":
88
+ return _DROP_PRIORITY_CRITICAL
89
+
90
+ # Recurse with inner frame type
91
+ event_type = inner_type
92
+
93
+ # Subscription complete — never drop
94
+ if event_type == "complete":
95
+ return _DROP_PRIORITY_CRITICAL
96
+
97
+ # Terminal frames - never drop
98
+ if event_type == "status":
99
+ state = event.get("state", "")
100
+ if state in ("idle", "running", "stopped", "detached"):
101
+ return _DROP_PRIORITY_CRITICAL
102
+
103
+ # Error frames - never drop
104
+ if event_type == "error":
105
+ return _DROP_PRIORITY_CRITICAL
106
+
107
+ # Connection ack - never drop
108
+ if event_type == "connection_ack":
109
+ return _DROP_PRIORITY_CRITICAL
110
+
111
+ # Custom events with specific types - prefer keep
112
+ if event_type == "event" or event.get("type") == "event":
113
+ mode = event.get("mode", "")
114
+ if mode == "custom":
115
+ data = event.get("data")
116
+ if isinstance(data, dict):
117
+ custom_type = data.get("type", "")
118
+ if custom_type == "soothe.stream.end":
119
+ return _DROP_PRIORITY_CRITICAL
120
+ if custom_type == "soothe.cognition.strange_loop.completed":
121
+ return _DROP_PRIORITY_CRITICAL
122
+ # Cognition events (step started/completed) - prefer keep
123
+ if custom_type.startswith("soothe.cognition."):
124
+ return _DROP_PRIORITY_HIGH
125
+ # Error events - never drop
126
+ if custom_type.startswith("soothe.error."):
127
+ return _DROP_PRIORITY_CRITICAL
128
+ # Stream degraded signal - never drop
129
+ if custom_type == "stream_degraded":
130
+ return _DROP_PRIORITY_CRITICAL
131
+ # Tool call updates batch - prefer keep
132
+ if custom_type == "soothe.ux.stream_tool_wire.tool_call_updates_batch":
133
+ return _DROP_PRIORITY_HIGH
134
+
135
+ # Messages mode - terminal or goal_completion phase
136
+ if mode == "messages":
137
+ data = event.get("data")
138
+ if _event_messages_wire_terminal(data):
139
+ return _DROP_PRIORITY_CRITICAL
140
+ if isinstance(data, (tuple, list)) and data:
141
+ first = data[0]
142
+ if isinstance(first, dict) and first.get("phase") == "goal_completion":
143
+ return _DROP_PRIORITY_CRITICAL
144
+
145
+ # Default - acceptable drop candidate
146
+ return _DROP_PRIORITY_NORMAL
147
+
148
+
149
+ def _extract_loop_id_from_inbound(event: dict[str, Any]) -> str | None:
150
+ """Return ``loop_id`` from a protocol-1 or legacy inbound frame."""
151
+ lid = event.get("loop_id")
152
+ if isinstance(lid, str) and lid.strip():
153
+ return lid.strip()
154
+ if event.get("type") == "next":
155
+ payload = event.get("payload")
156
+ if isinstance(payload, dict):
157
+ data = payload.get("data")
158
+ if isinstance(data, dict):
159
+ inner = data.get("loop_id")
160
+ if isinstance(inner, str) and inner.strip():
161
+ return inner.strip()
162
+ return None
163
+
164
+
165
+ def _inbound_needs_delivery_ack(event: dict[str, Any]) -> bool:
166
+ """True when the client should bump delivery ack sequence for ``event``."""
167
+ if event.get("type") == "complete":
168
+ return True
169
+ if event.get("type") == "next":
170
+ payload = event.get("payload")
171
+ if not isinstance(payload, dict):
172
+ return False
173
+ inner = payload.get("data")
174
+ if not isinstance(inner, dict):
175
+ return False
176
+ mode = payload.get("mode", "")
177
+ if mode == "event":
178
+ inner_mode = inner.get("mode", "")
179
+ data = inner.get("data")
180
+ if inner_mode == "messages" and isinstance(data, (tuple, list)) and data:
181
+ body = data[0]
182
+ return isinstance(body, dict) and is_stream_terminal_wire_dict(body)
183
+ if inner_mode == "custom" and isinstance(data, dict):
184
+ ctype = data.get("type", "")
185
+ return ctype in (
186
+ "soothe.cognition.strange_loop.completed",
187
+ "soothe.stream.end",
188
+ )
189
+ if event.get("type") == "event":
190
+ mode = event.get("mode", "")
191
+ data = event.get("data")
192
+ if mode == "messages" and isinstance(data, (tuple, list)) and data:
193
+ body = data[0]
194
+ return isinstance(body, dict) and is_stream_terminal_wire_dict(body)
195
+ if mode == "custom" and isinstance(data, dict):
196
+ ctype = data.get("type", "")
197
+ return ctype in (
198
+ "soothe.cognition.strange_loop.completed",
199
+ "soothe.stream.end",
200
+ )
201
+ return False
202
+
203
+
204
+ # Align with soothe_daemon.config.models.WebSocketConfig.max_frame_size (default 10 MiB).
205
+ # The websockets library defaults max_size to 1 MiB, which closes the connection (1009)
206
+ # when the daemon streams larger JSON events to the client.
207
+ _DEFAULT_MAX_FRAME_SIZE = 10 * 1024 * 1024
208
+
209
+ # RFC-450: clients must wait (bounded) while the daemon is still starting; it does not
210
+ # necessarily push another ``daemon_ready`` when transitioning to ready, so we re-request.
211
+ _TRANSITIONAL_DAEMON_READY_STATES = frozenset({"starting", "warming"})
212
+ _DAEMON_READY_POLL_INTERVAL_S = 0.05
213
+
214
+ # Client version reported in the connection_init handshake (RFC-450 §8.2).
215
+ try:
216
+ from soothe_sdk import __version__ as client_version # noqa: N812
217
+ except Exception: # pragma: no cover
218
+ client_version = "0.0.0" # noqa: N812
219
+
220
+
221
+ class WebSocketClient:
222
+ """WebSocket client for communicating with Soothe daemon.
223
+
224
+ This client connects to the daemon via WebSocket and provides
225
+ streaming event access and bidirectional message passing.
226
+
227
+ Args:
228
+ url: WebSocket URL (e.g., ``ws://localhost:8765``).
229
+ client_id: Optional client identifier for log differentiation. If not
230
+ provided, a short random ID is generated (8 hex chars).
231
+ max_frame_size: Maximum incoming WebSocket message size in bytes. Should be
232
+ at least the daemon's ``transport.websocket.max_frame_size`` when that
233
+ is customized.
234
+ """
235
+
236
+ def __init__(
237
+ self,
238
+ url: str = "ws://localhost:8765",
239
+ *,
240
+ client_id: str | None = None,
241
+ max_frame_size: int = _DEFAULT_MAX_FRAME_SIZE,
242
+ ) -> None:
243
+ """Initialize WebSocket client.
244
+
245
+ Args:
246
+ url: WebSocket URL.
247
+ client_id: Optional client identifier for log differentiation.
248
+ max_frame_size: Max size for frames received from the daemon.
249
+ """
250
+ self._url = url
251
+ self._client_id = client_id or uuid.uuid4().hex[:8]
252
+ self._max_frame_size = max_frame_size
253
+ self._ws: websockets.asyncio.client.ClientConnection | None = None
254
+ self._connected = False
255
+ self._pending_events: deque[dict[str, Any]] = deque()
256
+ # Background reader drains the socket so daemon sends are not blocked by a
257
+ # stalled consumer (e.g. heavy Textual UI work on the same event loop).
258
+ # IG-535: Increased from 10_000 to 20_000 for 32 concurrent loops with dense streaming
259
+ self._inbound_maxsize = 20_000
260
+ self._inbound_queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(
261
+ maxsize=self._inbound_maxsize
262
+ )
263
+ self._inbound_dropped = 0
264
+ # IG-534: Optional callback for stream degradation events
265
+ self._on_stream_degraded: Callable[[int, str], None] | None = None
266
+ self._reader_task: asyncio.Task[None] | None = None
267
+ # Coalesce high-frequency daemon_status polls on a long-lived connection.
268
+ self._daemon_status_cache: tuple[float, dict[str, Any]] | None = None
269
+ self._daemon_status_lock = asyncio.Lock()
270
+ self._daemon_status_inflight: asyncio.Task[dict[str, Any]] | None = None
271
+ # Protocol-1 handshake state (RFC-450 §8.2)
272
+ self._negotiated_capabilities: set[str] = set()
273
+ self._protocol_version: str | None = None
274
+ self._handshake_complete: bool = False
275
+ self._heartbeat_interval_ms: int = 0
276
+ self._heartbeat_timeout_ms: int = 10000
277
+ self._heartbeat_task: asyncio.Task[None] | None = None
278
+ self._last_pong_monotonic: float = 0.0
279
+ # IG-556 P1.3: delivery ack sequence per loop for daemon drain gating.
280
+ self._delivery_recv_seq: dict[str, int] = {}
281
+ self._delivery_acked_seq: dict[str, int] = {}
282
+ # Mid-session drop signal (RFC-450 §8.3 / RFC-629 Layer 0).
283
+ self._disconn_event = asyncio.Event()
284
+ self._disconn_cause: DisconnectCause | None = None
285
+ self._disconn_fired = False
286
+ self._on_disconnected: Callable[[DisconnectCause], None] | None = None
287
+
288
+ async def connect(self) -> None:
289
+ """Connect to the daemon.
290
+
291
+ Raises:
292
+ ConnectionError: If connection fails.
293
+ """
294
+ if self._ws is not None:
295
+ await self.close()
296
+
297
+ # Fresh transport — do not inherit liveness timestamps from a prior socket.
298
+ self._last_pong_monotonic = time.monotonic()
299
+ self._handshake_complete = False
300
+ self._negotiated_capabilities = set()
301
+ self._heartbeat_interval_ms = 0
302
+ self._reset_disconnect_signal()
303
+
304
+ try:
305
+ # Transport-level keepalive: daemon heartbeats are loop-scoped and only
306
+ # while a query runs; without client pings, long idle TCP can be dropped.
307
+ self._ws = await websockets.asyncio.client.connect(
308
+ self._url,
309
+ ping_interval=30,
310
+ ping_timeout=60,
311
+ max_size=self._max_frame_size,
312
+ )
313
+ self._connected = True
314
+ self._reader_task = asyncio.create_task(
315
+ self._socket_reader_loop(),
316
+ name=f"soothe-ws-reader-{self._client_id}",
317
+ )
318
+
319
+ logger.info("[Client:%s] Connected to daemon at %s", self._client_id, self._url)
320
+ except Exception as e:
321
+ self._connected = False
322
+ msg = f"Failed to connect to daemon: {e}"
323
+ raise ConnectionError(msg) from e
324
+
325
+ async def _stop_reader(self) -> None:
326
+ """Cancel the background reader and clear queued inbound events."""
327
+ task = self._reader_task
328
+ self._reader_task = None
329
+ if task is not None and not task.done():
330
+ task.cancel()
331
+ with contextlib.suppress(asyncio.CancelledError):
332
+ await task
333
+ while True:
334
+ try:
335
+ self._inbound_queue.get_nowait()
336
+ except asyncio.QueueEmpty:
337
+ break
338
+
339
+ async def _socket_reader_loop(self) -> None:
340
+ """Continuously read frames from the transport into ``_inbound_queue``."""
341
+ try:
342
+ while self._connected and self._ws is not None:
343
+ event = await self._read_from_socket()
344
+ if event is None:
345
+ await self._enqueue_inbound(None)
346
+ break
347
+ # Intercept heartbeat frames (RFC-450 §8.3): respond to ping
348
+ # with pong and swallow pong (liveness tracked by heartbeat loop).
349
+ etype = event.get("type")
350
+ if etype == "ping":
351
+ await self._respond_pong()
352
+ continue
353
+ if etype == "pong":
354
+ self._handle_pong()
355
+ continue
356
+ await self._enqueue_inbound(event)
357
+ except asyncio.CancelledError:
358
+ raise
359
+ except Exception:
360
+ logger.exception(
361
+ "[Client:%s] WebSocket reader loop failed",
362
+ self._client_id,
363
+ )
364
+ with contextlib.suppress(asyncio.QueueFull):
365
+ await self._enqueue_inbound(None)
366
+ finally:
367
+ was_connected = self._connected
368
+ self._connected = False
369
+ if was_connected:
370
+ self._signal_disconnect(DisconnectCause.UNCLEAN)
371
+
372
+ async def _respond_pong(self) -> None:
373
+ """Respond to a daemon ``ping`` with ``pong`` (RFC-450 §8.3)."""
374
+ if not self._ws or not self._connected:
375
+ return
376
+ try:
377
+ await self._ws.send(encode_websocket_text({"proto": "1", "type": "pong"}))
378
+ except websockets.exceptions.ConnectionClosed:
379
+ self._connected = False
380
+ except Exception:
381
+ logger.debug("[Client:%s] Failed to send pong", self._client_id)
382
+
383
+ async def _enqueue_inbound(self, event: dict[str, Any] | None) -> None:
384
+ """Queue one or more inbound wire messages (expands ``event_batch``)."""
385
+ if event is None:
386
+ await self._put_inbound_queue(None)
387
+ return
388
+ if event.get("type") == "event_batch":
389
+ sub_events = event.get("events")
390
+ if isinstance(sub_events, list):
391
+ for sub in sub_events:
392
+ if isinstance(sub, dict):
393
+ await self._put_inbound_queue(sub)
394
+ return
395
+ await self._put_inbound_queue(event)
396
+
397
+ async def _put_inbound_queue(self, event: dict[str, Any] | None) -> None:
398
+ """Put event into inbound queue with priority-aware drop policy.
399
+
400
+ IG-535: When queue is full, find and drop a NORMAL priority frame
401
+ (streaming text/updates) instead of blindly dropping oldest, which
402
+ could lose terminal frames (goal_completion, status:idle).
403
+ """
404
+ event_priority = _inbound_frame_drop_priority(event)
405
+
406
+ if self._inbound_queue.full():
407
+ # Priority-aware drop: find best drop candidate (highest priority value)
408
+ # by temporarily draining and scanning the queue.
409
+ temp_items: list[dict[str, Any] | None] = []
410
+ drop_candidate: dict[str, Any] | None = None
411
+ drop_priority = -1 # Will only drop if we find priority >= _DROP_PRIORITY_NORMAL
412
+
413
+ # Drain queue to scan for drop candidate
414
+ while True:
415
+ try:
416
+ item = self._inbound_queue.get_nowait()
417
+ temp_items.append(item)
418
+ except asyncio.QueueEmpty:
419
+ break
420
+
421
+ # Find highest-priority drop candidate (NORMAL = acceptable to drop)
422
+ for item in temp_items:
423
+ p = _inbound_frame_drop_priority(item)
424
+ if p > drop_priority:
425
+ drop_priority = p
426
+ drop_candidate = item
427
+
428
+ # Requeue all except drop candidate (if found with acceptable priority)
429
+ requeued_critical_or_high = False
430
+ for item in temp_items:
431
+ if item is drop_candidate and drop_priority >= _DROP_PRIORITY_NORMAL:
432
+ # Skip - this is our drop target
433
+ continue
434
+ self._inbound_queue.put_nowait(item)
435
+ p = _inbound_frame_drop_priority(item)
436
+ if p <= _DROP_PRIORITY_HIGH:
437
+ requeued_critical_or_high = True
438
+
439
+ if drop_candidate is not None and drop_priority >= _DROP_PRIORITY_NORMAL:
440
+ # Successfully found and dropped a NORMAL frame
441
+ self._inbound_dropped += 1
442
+ # IG-534: Emit stream_degraded callback on first drop
443
+ if self._on_stream_degraded and self._inbound_dropped == 1:
444
+ try:
445
+ self._on_stream_degraded(1, "inbound_queue_overflow")
446
+ except Exception:
447
+ logger.debug("Stream degraded callback error", exc_info=True)
448
+ if self._inbound_dropped == 1 or self._inbound_dropped % 1000 == 0:
449
+ logger.warning(
450
+ "[Client:%s] Stream degraded: inbound queue overflow "
451
+ "(dropped NORMAL frame, dropped=%d)",
452
+ self._client_id,
453
+ self._inbound_dropped,
454
+ )
455
+ else:
456
+ # No acceptable drop candidate - queue contains only CRITICAL/HIGH frames
457
+ # Briefly yield and retry, or if incoming frame is also CRITICAL/HIGH,
458
+ # force a slot by dropping oldest (rare edge case)
459
+ if event_priority >= _DROP_PRIORITY_NORMAL and requeued_critical_or_high:
460
+ # Incoming is NORMAL, but queue is full of CRITICAL/HIGH
461
+ # This shouldn't happen under normal operation; log and yield
462
+ logger.debug(
463
+ "[Client:%s] Inbound queue full of critical frames, "
464
+ "yielding for drain (incoming_priority=%d)",
465
+ self._client_id,
466
+ event_priority,
467
+ )
468
+ await asyncio.sleep(0.001)
469
+ elif event_priority < _DROP_PRIORITY_NORMAL:
470
+ # Incoming is CRITICAL/HIGH, force a slot by dropping oldest
471
+ # even if it's also critical (rare, but necessary to avoid deadlock)
472
+ if temp_items:
473
+ # Drop the first item we drained (oldest)
474
+ self._inbound_dropped += 1
475
+ logger.warning(
476
+ "[Client:%s] Force drop oldest to admit CRITICAL/HIGH frame "
477
+ "(queue full of critical, dropped=%d)",
478
+ self._client_id,
479
+ self._inbound_dropped,
480
+ )
481
+ # Requeue all but the first (oldest)
482
+ for item in temp_items[1:]:
483
+ self._inbound_queue.put_nowait(item)
484
+
485
+ await self._inbound_queue.put(event)
486
+ if isinstance(event, dict):
487
+ self._track_inbound_delivery_ack(event)
488
+
489
+ def _track_inbound_delivery_ack(self, event: dict[str, Any]) -> None:
490
+ """Bump recv seq and schedule a delivery ack for terminal stream frames."""
491
+ if event.get("type") == "event_batch":
492
+ sub_events = event.get("events")
493
+ if isinstance(sub_events, list):
494
+ for sub in sub_events:
495
+ if isinstance(sub, dict):
496
+ self._track_inbound_delivery_ack(sub)
497
+ return
498
+ if not _inbound_needs_delivery_ack(event):
499
+ return
500
+ loop_id = _extract_loop_id_from_inbound(event)
501
+ if not loop_id:
502
+ return
503
+ self._delivery_recv_seq[loop_id] = self._delivery_recv_seq.get(loop_id, 0) + 1
504
+ try:
505
+ loop = asyncio.get_running_loop()
506
+ except RuntimeError:
507
+ return
508
+ loop.create_task(self._send_delivery_ack(loop_id))
509
+
510
+ async def _send_delivery_ack(self, loop_id: str) -> None:
511
+ """Notify daemon that terminal frames through ``seq`` were received."""
512
+ seq = self._delivery_recv_seq.get(loop_id, 0)
513
+ if seq <= self._delivery_acked_seq.get(loop_id, 0):
514
+ return
515
+ self._delivery_acked_seq[loop_id] = seq
516
+ if not self._connected or self._ws is None:
517
+ return
518
+ try:
519
+ await self.send(
520
+ {
521
+ "proto": "1",
522
+ "type": "notification",
523
+ "method": "delivery_ack",
524
+ "params": {"loop_id": loop_id, "seq": seq},
525
+ }
526
+ )
527
+ except Exception:
528
+ logger.debug(
529
+ "[Client:%s] delivery_ack failed for loop %s",
530
+ self._client_id,
531
+ loop_id[:16],
532
+ exc_info=True,
533
+ )
534
+
535
+ async def close(self, *, handshake_timeout: float = 2.0) -> None:
536
+ """Close the connection with timeout to prevent exit hangs.
537
+
538
+ Args:
539
+ handshake_timeout: Seconds to wait for the WebSocket close handshake.
540
+ Use a small value (e.g. 0.3) on interactive client exit.
541
+ """
542
+ # Intentional teardown is a clean drop (loops keep running server-side).
543
+ if self._connected or self._ws is not None:
544
+ self._signal_disconnect(DisconnectCause.CLEAN)
545
+
546
+ # Cancel heartbeat task
547
+ hb_task = self._heartbeat_task
548
+ self._heartbeat_task = None
549
+ if hb_task is not None and not hb_task.done():
550
+ hb_task.cancel()
551
+ with contextlib.suppress(asyncio.CancelledError):
552
+ await hb_task
553
+
554
+ await self._stop_reader()
555
+ inflight: asyncio.Task[dict[str, Any]] | None = None
556
+ async with self._daemon_status_lock:
557
+ inflight = self._daemon_status_inflight
558
+ self._daemon_status_inflight = None
559
+ self._daemon_status_cache = None
560
+
561
+ if inflight is not None and not inflight.done():
562
+ inflight.cancel()
563
+ with contextlib.suppress(asyncio.CancelledError, Exception):
564
+ await inflight
565
+
566
+ if self._ws:
567
+ try:
568
+ await asyncio.wait_for(self._ws.close(), timeout=handshake_timeout)
569
+ except TimeoutError:
570
+ # Force close on timeout - daemon will handle graceful cleanup
571
+ logger.debug(
572
+ "WebSocket close timed out after %.1fs, forcing closure",
573
+ handshake_timeout,
574
+ )
575
+ except Exception:
576
+ # Suppress other errors (connection closed, network issues)
577
+ logger.debug("WebSocket close error (connection likely already closed)")
578
+ self._ws = None
579
+ self._connected = False
580
+ self._pending_events.clear()
581
+ self._handshake_complete = False
582
+ self._negotiated_capabilities = set()
583
+ self._heartbeat_interval_ms = 0
584
+ self._last_pong_monotonic = 0.0
585
+
586
+ async def send(self, message: dict[str, Any]) -> None:
587
+ """Send a message to the daemon.
588
+
589
+ Args:
590
+ message: Message dict to send.
591
+
592
+ Raises:
593
+ ConnectionError: If not connected or send fails.
594
+ """
595
+ if not self._ws or not self._connected:
596
+ raise ConnectionError("Not connected to daemon")
597
+
598
+ if not self.is_connection_alive():
599
+ self._connected = False
600
+ raise ConnectionError("Connection closed")
601
+
602
+ try:
603
+ await self._ws.send(encode_websocket_text(message))
604
+ except websockets.exceptions.ConnectionClosed as e:
605
+ self._connected = False
606
+ raise ConnectionError("Connection closed") from e
607
+ except Exception as e:
608
+ msg = f"Failed to send message: {e}"
609
+ raise ConnectionError(msg) from e
610
+
611
+ async def receive(self) -> AsyncGenerator[dict[str, Any]]:
612
+ """Receive messages from the daemon.
613
+
614
+ Yields:
615
+ Message dicts received from the daemon.
616
+
617
+ Raises:
618
+ ConnectionError: If not connected or receive fails.
619
+ """
620
+ if not self._ws or not self._connected:
621
+ raise ConnectionError("Not connected to daemon")
622
+
623
+ try:
624
+ async for message in self._ws:
625
+ try:
626
+ message_str = message.decode("utf-8") if isinstance(message, bytes) else message
627
+ msg_dict = decode_websocket_text(message_str)
628
+ if msg_dict:
629
+ yield msg_dict
630
+ except Exception:
631
+ logger.exception("Error parsing message")
632
+ continue
633
+ except websockets.exceptions.ConnectionClosed:
634
+ self._connected = False
635
+ except Exception as e:
636
+ self._connected = False
637
+ msg = f"Connection error: {e}"
638
+ raise ConnectionError(msg) from e
639
+
640
+ @property
641
+ def client_id(self) -> str:
642
+ """Get the client identifier.
643
+
644
+ Returns:
645
+ Client identifier string (8 hex chars).
646
+ """
647
+ return self._client_id
648
+
649
+ @property
650
+ def is_connected(self) -> bool:
651
+ """Check if connected to the daemon.
652
+
653
+ Returns:
654
+ True if connected, False otherwise.
655
+ """
656
+ return self._connected
657
+
658
+ @property
659
+ def inbound_dropped(self) -> int:
660
+ """Cumulative inbound frames evicted due to queue overflow."""
661
+ return self._inbound_dropped
662
+
663
+ def set_stream_degraded_callback(self, callback: Callable[[int, str], None] | None) -> None:
664
+ """Set callback for stream degradation notifications (IG-534).
665
+
666
+ Args:
667
+ callback: Function called with (dropped_count, reason) when frames
668
+ are dropped due to inbound queue overflow. Pass None to disable.
669
+ """
670
+ self._on_stream_degraded = callback
671
+
672
+ def is_connection_alive(self) -> bool:
673
+ """Check if WebSocket connection is actually alive (not closed).
674
+
675
+ This is a deeper check than is_connected - it verifies the actual
676
+ WebSocket state, not just the client-side flag.
677
+
678
+ Returns:
679
+ True if WebSocket is open and not closed, False otherwise.
680
+ """
681
+ from websockets.asyncio.connection import State
682
+
683
+ return self._ws is not None and self._ws.state == State.OPEN
684
+
685
+ # -----------------------------------------------------------------------
686
+ # Protocol-1 client API (RFC-450 §5/§9, IG-522 Phase 5)
687
+ # -----------------------------------------------------------------------
688
+
689
+ def _next_request_id(self) -> str:
690
+ """Generate a unique request correlation ID (RFC-450 §5.2).
691
+
692
+ Uses :func:`uuid.uuid4` to produce a globally unique hex string. The
693
+ same ID space serves ``request`` and ``subscribe`` operations.
694
+
695
+ Returns:
696
+ 32-character hex string.
697
+ """
698
+ return uuid.uuid4().hex
699
+
700
+ async def request(
701
+ self,
702
+ method: str,
703
+ params: dict[str, Any] | None = None,
704
+ *,
705
+ timeout: float = 5.0,
706
+ proto: str = "1",
707
+ ) -> dict[str, Any]:
708
+ """Send a blocking RPC request and await the matching response (RFC-450 §5/§9).
709
+
710
+ Constructs a ``WireEnvelope`` with ``type='request'``, sends it over
711
+ the transport, and waits for a ``response`` or ``error`` message
712
+ whose ``id`` matches the generated correlation ID.
713
+
714
+ Args:
715
+ method: RPC method name (e.g. ``"loop_get"``, ``"daemon_status"``).
716
+ params: Structured parameters object for the method.
717
+ timeout: Maximum seconds to wait for a response.
718
+ proto: Protocol version string (default ``"1"``).
719
+
720
+ Returns:
721
+ The ``result`` dict from the matching ``response`` envelope.
722
+
723
+ Raises:
724
+ ConnectionError: If not connected or the connection closes while
725
+ waiting.
726
+ ProtocolError: If the daemon returns an ``error`` envelope with a
727
+ matching ``id``.
728
+ TimeoutError: If no matching response arrives within ``timeout``.
729
+ """
730
+ from soothe_sdk.wire.codec import MessageType, ProtocolError, WireEnvelope
731
+
732
+ req_id = self._next_request_id()
733
+ envelope = WireEnvelope(
734
+ proto=proto,
735
+ type=MessageType.REQUEST.value,
736
+ method=method,
737
+ params=params or {},
738
+ id=req_id,
739
+ )
740
+ await self.send(envelope.to_wire_dict())
741
+
742
+ try:
743
+ async with asyncio.timeout(timeout):
744
+ while True:
745
+ event = await self._read_inbound_event()
746
+ if not event:
747
+ if not self.is_connection_alive():
748
+ self._connected = False
749
+ raise ConnectionError("Connection closed")
750
+ raise TimeoutError(
751
+ f"WebSocket closed while waiting for response to {method} (id={req_id})"
752
+ )
753
+ event_id = event.get("id")
754
+ event_type = event.get("type")
755
+ # Route responses/errors by id; queue non-matching for later.
756
+ if event_id != req_id:
757
+ self._pending_events.append(event)
758
+ continue
759
+ if event_type == MessageType.ERROR.value:
760
+ err_obj = event.get("error") or {}
761
+ raise ProtocolError(
762
+ code=int(err_obj.get("code", -32603)),
763
+ message=str(err_obj.get("message", "daemon error")),
764
+ data=err_obj.get("data"),
765
+ )
766
+ if event_type == MessageType.RESPONSE.value:
767
+ return event.get("result") or {}
768
+ # complete/unsubscribe/etc with same id — unexpected for
769
+ # a blocking request; keep waiting.
770
+ continue
771
+ except TimeoutError:
772
+ raise TimeoutError(
773
+ f"Daemon did not respond to {method} within {timeout}s (id={req_id})"
774
+ ) from None
775
+
776
+ async def notify(
777
+ self,
778
+ method: str,
779
+ params: dict[str, Any] | None = None,
780
+ *,
781
+ proto: str = "1",
782
+ ) -> None:
783
+ """Send a fire-and-forget notification (RFC-450 §5/§9).
784
+
785
+ Notifications carry no ``id`` and the daemon does not reply. Use for
786
+ operations like ``loop_input`` (fire-and-forget) and ``disconnect``.
787
+
788
+ Args:
789
+ method: Notification method name (e.g. ``"loop_input"``).
790
+ params: Structured parameters object.
791
+ proto: Protocol version string (default ``"1"``).
792
+
793
+ Raises:
794
+ ConnectionError: If not connected or send fails.
795
+ """
796
+ from soothe_sdk.wire.codec import MessageType, WireEnvelope
797
+
798
+ envelope = WireEnvelope(
799
+ proto=proto,
800
+ type=MessageType.NOTIFICATION.value,
801
+ method=method,
802
+ params=params or {},
803
+ )
804
+ await self.send(envelope.to_wire_dict())
805
+
806
+ async def subscribe(
807
+ self,
808
+ method: str,
809
+ params: dict[str, Any] | None = None,
810
+ *,
811
+ timeout: float = 5.0,
812
+ proto: str = "1",
813
+ ) -> str:
814
+ """Start a subscription stream (RFC-450 §5/§9).
815
+
816
+ Sends a ``subscribe`` envelope with a generated correlation ``id``.
817
+ The daemon delivers stream events as ``next`` messages carrying the
818
+ same ``id``; the stream terminates with ``complete`` or ``error``.
819
+ Subscription confirmation is implicit — if the daemon cannot honour
820
+ the subscription it sends an ``error`` with the subscription ``id``.
821
+
822
+ Args:
823
+ method: Subscription target (e.g. ``"loop_events"``).
824
+ params: Subscription parameters (e.g. ``{"loop_id": "abc"}``).
825
+ timeout: Maximum seconds to wait for an initial error if the
826
+ daemon rejects the subscription. If no error arrives within
827
+ this window the subscription is assumed accepted.
828
+ proto: Protocol version string (default ``"1"``).
829
+
830
+ Returns:
831
+ The subscription ``id`` for later correlation and ``unsubscribe()``.
832
+
833
+ Raises:
834
+ ConnectionError: If not connected.
835
+ ProtocolError: If the daemon sends an ``error`` with the matching
836
+ ``id`` within the ``timeout`` window.
837
+ """
838
+ from soothe_sdk.wire.codec import MessageType, ProtocolError, WireEnvelope
839
+
840
+ sub_id = self._next_request_id()
841
+ envelope = WireEnvelope(
842
+ proto=proto,
843
+ type=MessageType.SUBSCRIBE.value,
844
+ method=method,
845
+ params=params or {},
846
+ id=sub_id,
847
+ )
848
+ await self.send(envelope.to_wire_dict())
849
+
850
+ # Check for an immediate rejection (error with matching id). We poll
851
+ # inbound events with a short timeout; if no error arrives we assume
852
+ # the subscription was accepted and return the id for stream reading.
853
+ try:
854
+ async with asyncio.timeout(timeout):
855
+ while True:
856
+ event = await self._read_inbound_event()
857
+ if not event:
858
+ # Connection closed before any response — assume
859
+ # accepted; caller will discover closure via next().
860
+ break
861
+ event_id = event.get("id")
862
+ event_type = event.get("type")
863
+ if event_id == sub_id and event_type == MessageType.ERROR.value:
864
+ err_obj = event.get("error") or {}
865
+ raise ProtocolError(
866
+ code=int(err_obj.get("code", -32603)),
867
+ message=str(err_obj.get("message", "subscription rejected")),
868
+ data=err_obj.get("data"),
869
+ )
870
+ # Re-queue: a next/complete or unrelated event.
871
+ self._pending_events.append(event)
872
+ # If we already got a next or complete, subscription is live.
873
+ if event_id == sub_id and event_type in (
874
+ MessageType.NEXT.value,
875
+ MessageType.COMPLETE.value,
876
+ ):
877
+ break
878
+ except TimeoutError:
879
+ pass # No error within timeout — subscription assumed accepted.
880
+
881
+ return sub_id
882
+
883
+ async def unsubscribe(
884
+ self,
885
+ subscription_id: str,
886
+ *,
887
+ proto: str = "1",
888
+ ) -> None:
889
+ """Cancel an active subscription (RFC-450 §5/§9).
890
+
891
+ Sends an ``unsubscribe`` envelope carrying the subscription ``id``.
892
+ The daemon stops delivering ``next`` events for that ``id`` and may
893
+ send a final ``complete``.
894
+
895
+ Args:
896
+ subscription_id: The ``id`` returned by :meth:`subscribe`.
897
+ proto: Protocol version string (default ``"1"``).
898
+
899
+ Raises:
900
+ ConnectionError: If not connected.
901
+ """
902
+ from soothe_sdk.wire.codec import MessageType, WireEnvelope
903
+
904
+ envelope = WireEnvelope(
905
+ proto=proto,
906
+ type=MessageType.UNSUBSCRIBE.value,
907
+ id=subscription_id,
908
+ )
909
+ await self.send(envelope.to_wire_dict())
910
+
911
+ async def next(self) -> dict[str, Any] | None:
912
+ """Read the next protocol-1 stream event from the daemon (RFC-450 §5/§9).
913
+
914
+ This is the primary stream-event reader for protocol-1 messages. It
915
+ replaces :meth:`read_event` for protocol-1 consumers. For ``next``
916
+ messages (subscription stream events) the ``payload`` is returned.
917
+ For ``complete`` and ``error`` messages the full envelope is returned
918
+ so the caller can inspect the ``id`` and error details. For all other
919
+ messages (e.g. ``response``, ``connection_ack``) the full envelope
920
+ is returned.
921
+
922
+ Returns:
923
+ The event ``payload`` for ``next`` messages, or the full envelope
924
+ dict for other message types. Returns ``None`` on EOF (connection
925
+ closed).
926
+
927
+ Raises:
928
+ ConnectionError: If not connected (only when no reader task is
929
+ active; the background reader returns ``None`` on EOF).
930
+ """
931
+ from soothe_sdk.wire.codec import MessageType
932
+
933
+ if self._pending_events:
934
+ event = self._pending_events.popleft()
935
+ else:
936
+ event = await self._read_inbound_event()
937
+
938
+ if not event:
939
+ return None
940
+
941
+ event_type = event.get("type")
942
+ if event_type == MessageType.NEXT.value:
943
+ return event.get("payload") or {}
944
+ return event
945
+
946
+ async def send_input(
947
+ self,
948
+ loop_id: str,
949
+ text: str,
950
+ *,
951
+ autonomous: bool = False,
952
+ max_iterations: int | None = None,
953
+ preferred_subagent: str | None = None,
954
+ model: str | None = None,
955
+ model_params: dict[str, Any] | None = None,
956
+ router_profile: str | None = None,
957
+ attachments: list[dict[str, str]] | None = None,
958
+ intent_hint: str | None = None,
959
+ response_schema: dict[str, Any] | None = None,
960
+ response_schema_name: str | None = None,
961
+ response_schema_strict: bool | None = None,
962
+ clarification_mode: str | None = None,
963
+ clarification_answer: bool = False,
964
+ clarification_answers: list[str] | None = None,
965
+ ) -> None:
966
+ """Send user input to the daemon for a subscribed loop (``loop_input``).
967
+
968
+ Args:
969
+ loop_id: Loop identifier for the subscribed loop.
970
+ text: User input text.
971
+ autonomous: Enable autonomous iteration mode.
972
+ max_iterations: Maximum iterations for autonomous mode.
973
+ preferred_subagent: Preferred subagent hint for routing.
974
+ model: Provider:model override string.
975
+ model_params: Additional model parameters.
976
+ router_profile: Named router profile for chat-role overlay this turn.
977
+ attachments: Image attachments (mime_type + base64 data).
978
+ intent_hint: Daemon-only direct model hint. Supported values:
979
+ ``text_completion`` (``default`` role, text-only),
980
+ ``image_to_text`` (``image`` role, attachments required),
981
+ ``ocr`` (``ocr`` role, attachments required),
982
+ ``embed`` (``embedding`` role, text-only; returns JSON vector).
983
+ ``response_schema`` is supported for ``text_completion`` and
984
+ ``image_to_text``. Agent-path pass-through hints (e.g.
985
+ ``resume_clarification``, ``skill:foo``) are forwarded unchanged.
986
+ Legacy ``direct_llm`` and ``quiz`` are rejected before send.
987
+
988
+ Raises:
989
+ ValueError: When ``intent_hint`` is a removed legacy value.
990
+ clarification_mode: RFC-622 clarification relay mode for this turn
991
+ (``"auto"`` / ``"manual"``). ``None`` lets the daemon fall back
992
+ to its configured default.
993
+ clarification_answer: When True, the daemon treats this input as the
994
+ answer to the loop's currently pending clarification interrupt
995
+ (RFC-622) and resumes the graph via ``Command(resume=...)``
996
+ instead of starting a new turn. Hint only — daemon verifies via
997
+ the loop's persisted state and falls back to a normal turn when
998
+ no clarification is pending.
999
+ clarification_answers: Optional per-question answer list paired with
1000
+ ``clarification_answer=True`` for multi-question clarifications.
1001
+ When set, the daemon resumes with one answer per question (no
1002
+ broadcast), so the step card and working memory carry exactly
1003
+ what the user typed — instead of a single concatenated string
1004
+ being broadcast to every question.
1005
+ """
1006
+ params: dict[str, Any] = {
1007
+ "loop_id": loop_id,
1008
+ "content": text,
1009
+ }
1010
+ if autonomous:
1011
+ params["autonomous"] = True
1012
+ if max_iterations is not None:
1013
+ params["max_iterations"] = max_iterations
1014
+ if preferred_subagent is not None:
1015
+ params["preferred_subagent"] = preferred_subagent
1016
+ if model:
1017
+ params["model"] = model
1018
+ if model_params:
1019
+ params["model_params"] = model_params
1020
+ if router_profile:
1021
+ params["router_profile"] = router_profile
1022
+ if attachments:
1023
+ params["attachments"] = attachments
1024
+ if intent_hint:
1025
+ hint_error = validate_loop_input_intent_hint(intent_hint)
1026
+ if hint_error is not None:
1027
+ raise ValueError(hint_error)
1028
+ params["intent_hint"] = intent_hint
1029
+ if response_schema:
1030
+ params["response_schema"] = response_schema
1031
+ if response_schema_name:
1032
+ params["response_schema_name"] = response_schema_name
1033
+ if response_schema_strict is not None:
1034
+ params["response_schema_strict"] = response_schema_strict
1035
+ if clarification_mode is not None:
1036
+ params["clarification_mode"] = clarification_mode
1037
+ if clarification_answer:
1038
+ params["clarification_answer"] = True
1039
+ if clarification_answers is not None:
1040
+ params["clarification_answers"] = list(clarification_answers)
1041
+ await self.notify("loop_input", params)
1042
+
1043
+ async def loop_list(
1044
+ self,
1045
+ *,
1046
+ limit: int = 20,
1047
+ filter: dict[str, Any] | None = None,
1048
+ timeout: float = 15.0,
1049
+ ) -> dict[str, Any]:
1050
+ """List loops via ``loop_list`` RPC."""
1051
+ params: dict[str, Any] = {"limit": limit}
1052
+ if filter:
1053
+ params["filter"] = filter
1054
+ return await self.request("loop_list", params, timeout=timeout)
1055
+
1056
+ async def loop_get(
1057
+ self,
1058
+ loop_id: str,
1059
+ *,
1060
+ verbose: bool = False,
1061
+ timeout: float = 15.0,
1062
+ ) -> dict[str, Any]:
1063
+ """Fetch one loop via ``loop_get`` RPC."""
1064
+ return await self.request(
1065
+ "loop_get",
1066
+ {"loop_id": loop_id, "verbose": verbose},
1067
+ timeout=timeout,
1068
+ )
1069
+
1070
+ async def loop_history_fetch(self, loop_id: str, *, timeout: float = 30.0) -> dict[str, Any]:
1071
+ """Fetch goal display snapshots + live card tail (``loop_history_fetch``)."""
1072
+ return await self.request(
1073
+ "loop_history_fetch",
1074
+ {"loop_id": loop_id},
1075
+ timeout=timeout,
1076
+ )
1077
+
1078
+ async def loop_cards_fetch(self, loop_id: str, *, timeout: float = 30.0) -> dict[str, Any]:
1079
+ """Fetch bound display-card snapshot (``loop_cards_fetch``)."""
1080
+ return await self.request(
1081
+ "loop_cards_fetch",
1082
+ {"loop_id": loop_id},
1083
+ timeout=timeout,
1084
+ )
1085
+
1086
+ async def loop_messages(
1087
+ self,
1088
+ loop_id: str,
1089
+ *,
1090
+ limit: int = 100,
1091
+ offset: int = 0,
1092
+ include_events: bool = False,
1093
+ timeout: float = 10.0,
1094
+ ) -> dict[str, Any]:
1095
+ """Load persisted conversation rows (``loop_messages``)."""
1096
+ return await self.request(
1097
+ "loop_messages",
1098
+ {
1099
+ "loop_id": loop_id,
1100
+ "limit": limit,
1101
+ "offset": offset,
1102
+ "include_events": include_events,
1103
+ },
1104
+ timeout=timeout,
1105
+ )
1106
+
1107
+ async def loop_state_get(self, loop_id: str, *, timeout: float = 30.0) -> dict[str, Any]:
1108
+ """Load StrangeLoop state channels (``loop_state_get``)."""
1109
+ return await self.request(
1110
+ "loop_state_get",
1111
+ {"loop_id": loop_id},
1112
+ timeout=timeout,
1113
+ )
1114
+
1115
+ async def loop_state_update(
1116
+ self,
1117
+ loop_id: str,
1118
+ values: dict[str, Any],
1119
+ *,
1120
+ as_node: str | None = None,
1121
+ timeout: float = 10.0,
1122
+ ) -> dict[str, Any]:
1123
+ """Merge partial state into a loop (``loop_state_update``)."""
1124
+ from soothe_sdk.wire.protocol import _serialize_for_json
1125
+
1126
+ payload_values = _serialize_for_json(values)
1127
+ if not isinstance(payload_values, dict):
1128
+ return {}
1129
+ params: dict[str, Any] = {"loop_id": loop_id, "values": payload_values}
1130
+ if as_node:
1131
+ params["as_node"] = as_node
1132
+ return await self.request("loop_state_update", params, timeout=timeout)
1133
+
1134
+ def _reset_disconnect_signal(self) -> None:
1135
+ """Clear the mid-session drop signal (called from ``connect``)."""
1136
+ self._disconn_event = asyncio.Event()
1137
+ self._disconn_cause = None
1138
+ self._disconn_fired = False
1139
+
1140
+ def _signal_disconnect(self, cause: DisconnectCause) -> None:
1141
+ """Fire the disconnect signal exactly once."""
1142
+ if self._disconn_fired:
1143
+ return
1144
+ self._disconn_fired = True
1145
+ self._disconn_cause = cause
1146
+ self._disconn_event.set()
1147
+ callback = self._on_disconnected
1148
+ if callback is not None:
1149
+ with contextlib.suppress(Exception):
1150
+ callback(cause)
1151
+
1152
+ def set_disconnected_callback(
1153
+ self,
1154
+ callback: Callable[[DisconnectCause], None] | None,
1155
+ ) -> None:
1156
+ """Register a sync callback invoked once when the connection drops."""
1157
+ self._on_disconnected = callback
1158
+
1159
+ def is_disconnected(self) -> bool:
1160
+ """Return whether the disconnect signal has fired."""
1161
+ return self._disconn_fired
1162
+
1163
+ def disconnect_cause(self) -> DisconnectCause | None:
1164
+ """Return the disconnect cause, or None if still connected."""
1165
+ if not self._disconn_fired:
1166
+ return None
1167
+ return self._disconn_cause if self._disconn_cause is not None else DisconnectCause.UNCLEAN
1168
+
1169
+ async def wait_disconnected(self) -> DisconnectCause:
1170
+ """Wait until the disconnect signal fires and return its cause."""
1171
+ await self._disconn_event.wait()
1172
+ cause = self.disconnect_cause()
1173
+ return cause if cause is not None else DisconnectCause.UNCLEAN
1174
+
1175
+ async def reconnect(
1176
+ self,
1177
+ *,
1178
+ max_attempts: int = 10,
1179
+ initial_delay_s: float = 0.5,
1180
+ max_delay_s: float = 10.0,
1181
+ ) -> None:
1182
+ """Re-dial and re-handshake after a drop (does not re-subscribe loops)."""
1183
+ last_err: BaseException | None = None
1184
+ delay = initial_delay_s
1185
+ for attempt in range(1, max_attempts + 1):
1186
+ try:
1187
+ await self.connect()
1188
+ await self.request_connection_init()
1189
+ await self.wait_for_connection_ack()
1190
+ return
1191
+ except Exception as exc:
1192
+ last_err = exc
1193
+ if attempt < max_attempts:
1194
+ await asyncio.sleep(delay)
1195
+ delay = min(delay * 2, max_delay_s)
1196
+ raise ReconnectError(self._url, max_attempts, last_err)
1197
+
1198
+ async def reattach_and_probe(
1199
+ self,
1200
+ loop_id: str,
1201
+ *,
1202
+ reattach_timeout_s: float = 15.0,
1203
+ subscribe_timeout_s: float = 10.0,
1204
+ probe_timeout_s: float = 5.0,
1205
+ stream_delivery: str = "adaptive",
1206
+ wire_tier: str = "full",
1207
+ ) -> None:
1208
+ """Resume a loop after reconnect: reattach, subscribe, ``loop_get`` probe.
1209
+
1210
+ Raises:
1211
+ ValueError: Empty ``loop_id``.
1212
+ StaleLoopError: Reattach accepted but liveness probe failed.
1213
+ ProtocolError / ConnectionError: Transport or RPC failures before probe.
1214
+ """
1215
+ from soothe_sdk.wire.codec import ProtocolError
1216
+
1217
+ lid = str(loop_id or "").strip()
1218
+ if not lid:
1219
+ raise ValueError("reattach_and_probe requires a loop id")
1220
+
1221
+ try:
1222
+ await self.request(
1223
+ "loop_reattach",
1224
+ {"loop_id": lid},
1225
+ timeout=reattach_timeout_s,
1226
+ )
1227
+ except ProtocolError as exc:
1228
+ raise StaleLoopError(lid, exc) from exc
1229
+
1230
+ await self.subscribe(
1231
+ "loop_events",
1232
+ {
1233
+ "loop_id": lid,
1234
+ "stream_delivery": stream_delivery,
1235
+ "wire_tier": wire_tier,
1236
+ },
1237
+ timeout=subscribe_timeout_s,
1238
+ )
1239
+
1240
+ try:
1241
+ await self.loop_get(lid, verbose=False, timeout=probe_timeout_s)
1242
+ except ProtocolError as exc:
1243
+ if getattr(exc, "code", None) == -32200:
1244
+ raise StaleLoopError(lid, exc) from exc
1245
+ raise StaleLoopError(lid, exc) from exc
1246
+ except Exception as exc:
1247
+ raise StaleLoopError(lid, exc) from exc
1248
+
1249
+ async def list_skills(self, *, timeout: float = 15.0) -> dict[str, Any]:
1250
+ """Request wire-safe skill metadata from the daemon (RFC-400 ``skills_list``)."""
1251
+ return await self.request("skills_list", {}, timeout=timeout)
1252
+
1253
+ async def list_models(self, *, timeout: float = 15.0) -> dict[str, Any]:
1254
+ """Request model catalog rows from the daemon host ``SootheConfig`` (RFC-400 ``models_list``)."""
1255
+ return await self.request("models_list", {}, timeout=timeout)
1256
+
1257
+ async def get_mcp_status(self, *, timeout: float = 15.0) -> dict[str, Any]:
1258
+ """Request MCP server status from the daemon."""
1259
+ return await self.request("mcp_status", {}, timeout=timeout)
1260
+
1261
+ async def invoke_skill(
1262
+ self,
1263
+ skill: str,
1264
+ args: str = "",
1265
+ *,
1266
+ timeout: float = 120.0,
1267
+ clarification_mode: str | None = None,
1268
+ ) -> dict[str, Any]:
1269
+ """Resolve a skill on the daemon host and receive echo before streaming (RFC-400).
1270
+
1271
+ Args:
1272
+ skill: Skill identifier (e.g. ``"my-plugin:my-skill"``).
1273
+ args: Free-form argument string appended after the skill name.
1274
+ timeout: RPC timeout in seconds.
1275
+ clarification_mode: RFC-622 clarification relay mode for the
1276
+ synthetic turn the daemon will enqueue (``"auto"`` /
1277
+ ``"manual"``). ``None`` lets the daemon fall back to its
1278
+ configured default. Without this, slash-skill turns ignore
1279
+ the client's Manual badge and always defer to the config
1280
+ default — typically ``"auto"`` (veritas).
1281
+ """
1282
+ params: dict[str, Any] = {"skill": skill, "args": args}
1283
+ if clarification_mode is not None:
1284
+ params["clarification_mode"] = clarification_mode
1285
+ return await self.request("invoke_skill", params, timeout=timeout)
1286
+
1287
+ async def fetch_daemon_status(
1288
+ self,
1289
+ *,
1290
+ timeout: float = 5.0,
1291
+ min_interval_s: float = 1.0,
1292
+ ) -> dict[str, Any]:
1293
+ """Fetch ``daemon_status_response`` with TTL cache and in-flight coalescing.
1294
+
1295
+ Pollers that call this several times per second only trigger one RPC per
1296
+ ``min_interval_s`` window; concurrent callers share a single in-flight
1297
+ request.
1298
+
1299
+ Args:
1300
+ timeout: Per-request timeout passed to :meth:`request`.
1301
+ min_interval_s: Minimum seconds between real RPCs. Use ``0`` to
1302
+ disable caching and always hit the daemon.
1303
+
1304
+ Returns:
1305
+ Parsed daemon status response dict.
1306
+
1307
+ Raises:
1308
+ Same as :meth:`request` (timeout, connection errors, etc.).
1309
+ """
1310
+ if min_interval_s <= 0:
1311
+ return await self.request("daemon_status", {}, timeout=timeout)
1312
+
1313
+ async with self._daemon_status_lock:
1314
+ now = time.monotonic()
1315
+ if self._daemon_status_cache is not None:
1316
+ ts, cached = self._daemon_status_cache
1317
+ if now - ts < min_interval_s:
1318
+ return dict(cached)
1319
+
1320
+ if self._daemon_status_inflight is None:
1321
+ self._daemon_status_inflight = asyncio.create_task(
1322
+ self.request("daemon_status", {}, timeout=timeout)
1323
+ )
1324
+ inflight = self._daemon_status_inflight
1325
+
1326
+ assert inflight is not None
1327
+ try:
1328
+ result = await inflight
1329
+ except BaseException:
1330
+ async with self._daemon_status_lock:
1331
+ if self._daemon_status_inflight is inflight:
1332
+ self._daemon_status_inflight = None
1333
+ raise
1334
+
1335
+ async with self._daemon_status_lock:
1336
+ if self._daemon_status_inflight is inflight:
1337
+ self._daemon_status_inflight = None
1338
+ self._daemon_status_cache = (time.monotonic(), dict(result))
1339
+ return dict(result)
1340
+
1341
+ async def request_connection_init(self) -> None:
1342
+ """Send ``connection_init`` to the daemon (RFC-450 §8.2).
1343
+
1344
+ This is the first message the client sends after the WebSocket upgrade.
1345
+ The daemon responds with ``connection_ack`` containing readiness state,
1346
+ negotiated protocol version, and capabilities.
1347
+
1348
+ This method is safe to call even if the connection may be closed.
1349
+ If the connection is closed, this method silently succeeds —
1350
+ ``wait_for_connection_ack()`` will either find a pending ack or timeout.
1351
+ """
1352
+ from soothe_sdk.wire.codec import ConnectionInitEnvelope, ConnectionInitParams
1353
+
1354
+ envelope = ConnectionInitEnvelope(
1355
+ params=ConnectionInitParams(
1356
+ client_version=client_version,
1357
+ client_name="soothe-sdk",
1358
+ accept_proto=["1"],
1359
+ capabilities=["streaming", "batch", "heartbeat"],
1360
+ )
1361
+ )
1362
+ try:
1363
+ await self.send(envelope.to_wire_dict())
1364
+ except ConnectionError:
1365
+ logger.debug(
1366
+ "[Client:%s] request_connection_init failed (connection closed), "
1367
+ "will check pending events",
1368
+ self._client_id,
1369
+ )
1370
+
1371
+ async def wait_for_connection_ack(self, ack_timeout_s: float = 10.0) -> dict[str, Any]:
1372
+ """Wait for ``connection_ack`` and require ready state (RFC-450 §8.2).
1373
+
1374
+ Args:
1375
+ ack_timeout_s: Maximum seconds to wait for the ack.
1376
+
1377
+ Returns:
1378
+ The ``connection_ack`` result dict on success.
1379
+
1380
+ Raises:
1381
+ ConnectionError: If protocol version is incompatible.
1382
+ RuntimeError: If daemon reports ``error``, ``degraded``, or another
1383
+ non-ready terminal state.
1384
+ TimeoutError: If timeout expires.
1385
+ """
1386
+ async with asyncio.timeout(ack_timeout_s):
1387
+ while True:
1388
+ event = self._pop_pending_event_by_type("connection_ack")
1389
+ if event is None:
1390
+ event = await self._read_inbound_event()
1391
+ if not event:
1392
+ if not self.is_connection_alive():
1393
+ self._connected = False
1394
+ raise ConnectionError("Connection closed")
1395
+ raise TimeoutError("No connection_ack received")
1396
+ if event.get("type") != "connection_ack":
1397
+ # Discard the initial ``status`` frame — keeping it in
1398
+ # ``_pending_events`` would block ``connection_ack`` in
1399
+ # the inbound queue.
1400
+ if event.get("type") != "status":
1401
+ self._pending_events.append(event)
1402
+ continue
1403
+
1404
+ result = event.get("result") or {}
1405
+ state = result.get("readiness_state")
1406
+ proto_ver = result.get("protocol_version")
1407
+ caps = result.get("capabilities", [])
1408
+ hb_interval = result.get("heartbeat_interval_ms", 0)
1409
+
1410
+ # Store negotiated values
1411
+ self._protocol_version = proto_ver
1412
+ self._negotiated_capabilities = set(caps)
1413
+ self._heartbeat_interval_ms = int(hb_interval) if hb_interval else 0
1414
+ self._handshake_complete = True
1415
+
1416
+ if state == "incompatible":
1417
+ raise ConnectionError(
1418
+ f"Protocol version incompatible: daemon returned {proto_ver!r}"
1419
+ )
1420
+ if state == "ready":
1421
+ # Start heartbeat if negotiated
1422
+ if (
1423
+ "heartbeat" in self._negotiated_capabilities
1424
+ and self._heartbeat_interval_ms > 0
1425
+ ):
1426
+ self._start_heartbeat()
1427
+ return event
1428
+ if state == "error":
1429
+ raise RuntimeError("Daemon startup failed")
1430
+ if state == "degraded":
1431
+ raise RuntimeError("Daemon is degraded")
1432
+ if state in _TRANSITIONAL_DAEMON_READY_STATES:
1433
+ await asyncio.sleep(_DAEMON_READY_POLL_INTERVAL_S)
1434
+ await self.request_connection_init()
1435
+ continue
1436
+ raise RuntimeError(f"Daemon state is {state}")
1437
+
1438
+ def _start_heartbeat(self) -> None:
1439
+ """Start the client-side heartbeat ping sender (RFC-450 §8.3).
1440
+
1441
+ Sends ``ping`` frames at the negotiated interval. If no ``pong`` is
1442
+ received within the timeout window, the connection is considered dead.
1443
+ """
1444
+ if self._heartbeat_task is not None and not self._heartbeat_task.done():
1445
+ return
1446
+ interval_s = self._heartbeat_interval_ms / 1000.0
1447
+ if interval_s <= 0:
1448
+ return
1449
+ # Baseline liveness for this socket — ignore pre-reconnect pong timestamps.
1450
+ self._last_pong_monotonic = time.monotonic()
1451
+ self._heartbeat_task = asyncio.create_task(
1452
+ self._heartbeat_loop(interval_s),
1453
+ name=f"soothe-ws-heartbeat-{self._client_id}",
1454
+ )
1455
+
1456
+ async def _heartbeat_loop(self, interval_s: float) -> None:
1457
+ """Send periodic ping frames and detect dead connections (RFC-450 §8.3).
1458
+
1459
+ Args:
1460
+ interval_s: Ping interval in seconds.
1461
+ """
1462
+ import time
1463
+
1464
+ timeout_s = max(self._heartbeat_timeout_ms / 1000.0, interval_s * 2)
1465
+ try:
1466
+ while self._connected and self._ws is not None:
1467
+ await asyncio.sleep(interval_s)
1468
+ if not self._connected or self._ws is None:
1469
+ break
1470
+ now = time.monotonic()
1471
+ last_pong = self._last_pong_monotonic or now
1472
+ if now - last_pong > interval_s + timeout_s:
1473
+ logger.warning(
1474
+ "[Client:%s] Heartbeat timeout (no pong in %.1fs), closing",
1475
+ self._client_id,
1476
+ now - last_pong,
1477
+ )
1478
+ self._connected = False
1479
+ with contextlib.suppress(Exception):
1480
+ await self._ws.close()
1481
+ return
1482
+ try:
1483
+ await self._ws.send(encode_websocket_text({"proto": "1", "type": "ping"}))
1484
+ except websockets.exceptions.ConnectionClosed:
1485
+ self._connected = False
1486
+ return
1487
+ except Exception:
1488
+ logger.debug("[Client:%s] Failed to send heartbeat ping", self._client_id)
1489
+ return
1490
+ except asyncio.CancelledError:
1491
+ raise
1492
+
1493
+ def _handle_pong(self) -> None:
1494
+ """Mark that a pong was received from the daemon (heartbeat liveness)."""
1495
+ import time
1496
+
1497
+ self._last_pong_monotonic = time.monotonic()
1498
+
1499
+ def _pop_pending_event_by_type(self, event_type: str) -> dict[str, Any] | None:
1500
+ """Pop the first pending event of ``event_type`` while preserving queue order."""
1501
+ if not self._pending_events:
1502
+ return None
1503
+
1504
+ kept_events: deque[dict[str, Any]] = deque()
1505
+ matched: dict[str, Any] | None = None
1506
+
1507
+ while self._pending_events:
1508
+ event = self._pending_events.popleft()
1509
+ if matched is None and event.get("type") == event_type:
1510
+ matched = event
1511
+ continue
1512
+ kept_events.append(event)
1513
+
1514
+ self._pending_events = kept_events
1515
+ return matched
1516
+
1517
+ async def _read_inbound_event(self) -> dict[str, Any] | None:
1518
+ """Read the next frame from the transport queue, ignoring ``_pending_events``.
1519
+
1520
+ Used for RPC/handshake waits so a stray ``status`` frame cannot block matching
1521
+ responses that arrive later on the inbound queue.
1522
+ """
1523
+ if self._reader_task is not None:
1524
+ return await self._inbound_queue.get()
1525
+
1526
+ return await self._read_from_socket()
1527
+
1528
+ async def read_event(self) -> dict[str, Any] | None:
1529
+ """Read the next event from the daemon.
1530
+
1531
+ Returns:
1532
+ Parsed event dict, or ``None`` on EOF.
1533
+ """
1534
+ if self._pending_events:
1535
+ return self._pending_events.popleft()
1536
+
1537
+ return await self._read_inbound_event()
1538
+
1539
+ def clear_pending_events(self) -> None:
1540
+ """Clear all pending events from the internal queue.
1541
+
1542
+ Useful in tests to discard setup-phase events that should not
1543
+ affect isolation verification.
1544
+ """
1545
+ self._pending_events.clear()
1546
+
1547
+ # Handshake / RPC responses that must not count as turn progress (TUI stall detection).
1548
+ # ``card.replay_*`` / ``card.created`` are emitted by the daemon during
1549
+ # ``loop_subscribe`` for non-TUI clients (RFC-413). The TUI consumes its
1550
+ # cards via the synchronous ``loop_cards_fetch`` RPC so these frames are
1551
+ # peeled silently here. Under protocol-1, RPC responses arrive as
1552
+ # ``type:"response"`` correlated by ``id`` (peeled by the reader loop), so
1553
+ # the legacy ``*_response`` type entries are gone. Under protocol-1 (RFC-450
1554
+ # §9.3) card replay frames arrive wrapped in ``next`` envelopes with
1555
+ # ``payload.mode`` set to the originating frame type; the peel logic below
1556
+ # inspects both the raw ``type`` and the wrapped ``payload.mode``.
1557
+ _STALE_TURN_PENDING_TYPES = frozenset(
1558
+ {
1559
+ "connection_ack",
1560
+ "card.replay_begin",
1561
+ "card.replay_end",
1562
+ "card.created",
1563
+ }
1564
+ )
1565
+
1566
+ def peel_stale_pending_control_events(self) -> list[str]:
1567
+ """Remove stale handshake/RPC frames left in ``_pending_events`` before a turn.
1568
+
1569
+ ``request`` queues unrelated inbound frames while waiting for a matching
1570
+ ``id``. If a handshake/control frame remains at turn start, the TUI can
1571
+ mistake it for live progress and never log a stalled stream.
1572
+
1573
+ Returns:
1574
+ List of removed frame types (in order).
1575
+ """
1576
+ if not self._pending_events:
1577
+ return []
1578
+
1579
+ kept: deque[dict[str, Any]] = deque()
1580
+ removed: list[str] = []
1581
+ while self._pending_events:
1582
+ event = self._pending_events.popleft()
1583
+ event_type = str(event.get("type") or "")
1584
+ # Protocol-1 wraps card replay frames in ``next`` envelopes; peel
1585
+ # them by inspecting ``payload.mode`` so they don't masquerade as
1586
+ # live progress at turn start.
1587
+ stale_mode = ""
1588
+ if event_type == "next":
1589
+ payload = event.get("payload")
1590
+ if isinstance(payload, dict):
1591
+ stale_mode = str(payload.get("mode") or "")
1592
+ if event_type in self._STALE_TURN_PENDING_TYPES:
1593
+ removed.append(event_type)
1594
+ continue
1595
+ if stale_mode and stale_mode in self._STALE_TURN_PENDING_TYPES:
1596
+ removed.append(stale_mode)
1597
+ continue
1598
+ kept.append(event)
1599
+ self._pending_events = kept
1600
+ return removed
1601
+
1602
+ async def _read_from_socket(self) -> dict[str, Any] | None:
1603
+ """Read one event directly from the websocket transport."""
1604
+
1605
+ if not self._ws or not self._connected:
1606
+ return None
1607
+
1608
+ try:
1609
+ message = await self._ws.recv()
1610
+ if isinstance(message, bytes):
1611
+ message = message.decode("utf-8")
1612
+ return decode_websocket_text(message)
1613
+ except websockets.exceptions.ConnectionClosed:
1614
+ return None
1615
+ except Exception:
1616
+ logger.exception("Error reading event")
1617
+ return None
1618
+
1619
+
1620
+ __all__ = [
1621
+ "WebSocketClient",
1622
+ "_inbound_frame_drop_priority", # IG-535: Exported for testing
1623
+ "_DROP_PRIORITY_CRITICAL",
1624
+ "_DROP_PRIORITY_HIGH",
1625
+ "_DROP_PRIORITY_NORMAL",
1626
+ ]