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,633 @@
1
+ """WebSocket command client for daemon command endpoints.
2
+
3
+ Provides synchronous and async clients for sending commands over WebSocket
4
+ and receiving responses. Replaces HTTP REST clients for autopilot, cron,
5
+ and memory profiling operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from typing import Any
13
+ from uuid import uuid4
14
+
15
+ from soothe_sdk.wire.codec import (
16
+ ConnectionInitEnvelope,
17
+ ConnectionInitParams,
18
+ ErrorEnvelope,
19
+ MessageType,
20
+ WireEnvelope,
21
+ decode_envelope,
22
+ encode_envelope,
23
+ )
24
+
25
+ from soothe_client.helpers import websocket_url_from_config
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _TRANSITIONAL_DAEMON_READY_STATES = frozenset({"starting", "warming"})
30
+ _DAEMON_READY_POLL_INTERVAL_S = 0.05
31
+
32
+ try:
33
+ from soothe_sdk import __version__ as _client_version
34
+ except Exception: # pragma: no cover
35
+ _client_version = "0.0.0"
36
+
37
+
38
+ def _normalize_cron_add_result(result: dict[str, Any]) -> dict[str, Any]:
39
+ """Normalize daemon cron_add payloads to ``{"job": {...}}`` for CLI callers."""
40
+ if "job" in result:
41
+ out = dict(result)
42
+ elif result.get("job_id") or result.get("id"):
43
+ job_id = result.get("job_id") or result.get("id")
44
+ job = dict(result)
45
+ job["id"] = job_id
46
+ job.pop("job_id", None)
47
+ out = {"job": job}
48
+ else:
49
+ return result
50
+ if result.get("duplicate"):
51
+ out["duplicate"] = True
52
+ return out
53
+
54
+
55
+ def _normalize_cron_show_result(result: dict[str, Any]) -> dict[str, Any]:
56
+ """Normalize daemon cron_show payloads to ``{"job": {...}}`` for CLI callers."""
57
+ if "job" in result:
58
+ return result
59
+ job_id = result.get("job_id") or result.get("id")
60
+ if not job_id:
61
+ return {"job": None}
62
+ job = dict(result)
63
+ job["id"] = job_id
64
+ job.pop("job_id", None)
65
+ return {"job": job}
66
+
67
+
68
+ async def _perform_handshake(ws: Any, *, timeout: float) -> None:
69
+ """Complete protocol-1 ``connection_init`` / ``connection_ack`` (RFC-450 §8.2).
70
+
71
+ Args:
72
+ ws: Connected WebSocket.
73
+ timeout: Maximum seconds to wait for a ready ``connection_ack``.
74
+
75
+ Raises:
76
+ RuntimeError: If the handshake fails or times out.
77
+ """
78
+ init = ConnectionInitEnvelope(
79
+ params=ConnectionInitParams(
80
+ client_version=_client_version,
81
+ client_name="soothe-sdk",
82
+ accept_proto=["1"],
83
+ capabilities=["streaming", "batch", "heartbeat"],
84
+ )
85
+ )
86
+
87
+ async with asyncio.timeout(timeout):
88
+ await ws.send(encode_envelope(init))
89
+ while True:
90
+ response_str = await ws.recv()
91
+ response = decode_envelope(response_str)
92
+ if not isinstance(response, dict):
93
+ continue
94
+
95
+ msg_type = response.get("type")
96
+ if msg_type == "status":
97
+ continue
98
+
99
+ if msg_type == MessageType.ERROR.value:
100
+ err = ErrorEnvelope.from_wire_dict(response)
101
+ raise RuntimeError(
102
+ f"[{err.code}] {err.message}" + (f" ({err.data})" if err.data else "")
103
+ )
104
+
105
+ if msg_type != "connection_ack":
106
+ continue
107
+
108
+ result = response.get("result") or {}
109
+ state = result.get("readiness_state")
110
+ if state == "incompatible":
111
+ raise RuntimeError(
112
+ "Protocol version incompatible: "
113
+ f"daemon returned {result.get('protocol_version')!r}"
114
+ )
115
+ if state == "ready":
116
+ return
117
+ if state == "error":
118
+ raise RuntimeError("Daemon startup failed")
119
+ if state == "degraded":
120
+ raise RuntimeError("Daemon is degraded")
121
+ if state in _TRANSITIONAL_DAEMON_READY_STATES:
122
+ await asyncio.sleep(_DAEMON_READY_POLL_INTERVAL_S)
123
+ await ws.send(encode_envelope(init))
124
+ continue
125
+ raise RuntimeError(f"Daemon state is {state}")
126
+
127
+
128
+ # Command message types
129
+ _AUTOPilot_COMMANDS = {
130
+ "status": "autopilot_status",
131
+ "submit": "autopilot_submit",
132
+ "list_goals": "autopilot_list_goals",
133
+ "get_goal": "autopilot_get_goal",
134
+ "cancel_goal": "autopilot_cancel_goal",
135
+ "wake": "autopilot_wake",
136
+ "dream": "autopilot_dream",
137
+ "resume": "autopilot_resume",
138
+ "list_jobs": "autopilot_list_jobs",
139
+ "get_job": "autopilot_get_job",
140
+ }
141
+
142
+ _CRON_COMMANDS = {
143
+ "add": "cron_add",
144
+ "list": "cron_list",
145
+ "list_jobs": "cron_list",
146
+ "show": "cron_show",
147
+ "cancel": "cron_cancel",
148
+ }
149
+
150
+ _MEMORY_COMMANDS = {
151
+ "stats": "memory_stats",
152
+ }
153
+
154
+
155
+ class WsCommandClient:
156
+ """Async WebSocket client for daemon command endpoints.
157
+
158
+ Connects to daemon WebSocket, sends command messages, and waits for
159
+ response messages with a request/response pattern.
160
+
161
+ Usage:
162
+ client = WsCommandClient(ws_url)
163
+ result = await client.autopilot_status()
164
+ result = await client.cron_add("in 1 hour remind me to deploy")
165
+
166
+ Args:
167
+ ws_url: WebSocket URL (e.g. ``ws://127.0.0.1:8765``).
168
+ timeout: Command timeout in seconds.
169
+ """
170
+
171
+ def __init__(self, ws_url: str, *, timeout: float = 30.0) -> None:
172
+ self._ws_url = ws_url.rstrip("/")
173
+ self._timeout = timeout
174
+
175
+ async def _send_command(
176
+ self, command_type: str, payload: dict[str, Any] | None = None
177
+ ) -> dict[str, Any]:
178
+ """Send a protocol-1 request envelope and wait for the response.
179
+
180
+ Builds a ``WireEnvelope`` with ``type='request'``, ``method=command_type``,
181
+ ``params=payload``, and a UUID4 correlation ``id`` (RFC-450 §5, IG-522
182
+ Phase 6). The envelope is serialized with :func:`encode_envelope` and the
183
+ reply is parsed with :func:`decode_envelope`. Responses are matched by
184
+ ``id``; ``response`` envelopes return their ``result`` and ``error``
185
+ envelopes raise :class:`RuntimeError` carrying the daemon's code/message.
186
+
187
+ Args:
188
+ command_type: RPC method name (e.g. ``"autopilot_status"``).
189
+ payload: Structured parameters object for the method.
190
+
191
+ Returns:
192
+ The ``result`` dict from the matching ``response`` envelope.
193
+
194
+ Raises:
195
+ RuntimeError: If the daemon replies with an ``error`` envelope, the
196
+ connection fails, or the command times out.
197
+ """
198
+ import websockets
199
+
200
+ req_id = str(uuid4())
201
+ envelope = WireEnvelope(
202
+ type=MessageType.REQUEST.value,
203
+ method=command_type,
204
+ params=payload or {},
205
+ id=req_id,
206
+ )
207
+
208
+ try:
209
+ async with websockets.connect(self._ws_url, open_timeout=self._timeout) as ws:
210
+ await _perform_handshake(ws, timeout=self._timeout)
211
+
212
+ # Send the protocol-1 request envelope.
213
+ await ws.send(encode_envelope(envelope))
214
+
215
+ # Wait for the matching response/error envelope.
216
+ while True:
217
+ response_str = await asyncio.wait_for(ws.recv(), timeout=self._timeout)
218
+ response = decode_envelope(response_str)
219
+ if not isinstance(response, dict):
220
+ raise RuntimeError(f"Unexpected response: {response_str!r}")
221
+
222
+ msg_type = response.get("type")
223
+
224
+ # Error envelope: raise with the daemon's code/message.
225
+ if msg_type == MessageType.ERROR.value:
226
+ err = ErrorEnvelope.from_wire_dict(response)
227
+ raise RuntimeError(
228
+ f"[{err.code}] {err.message}" + (f" ({err.data})" if err.data else "")
229
+ )
230
+
231
+ # Success: return the result payload.
232
+ if msg_type == MessageType.RESPONSE.value:
233
+ if response.get("id") != req_id:
234
+ # Not our response; keep waiting for the match.
235
+ continue
236
+ return response.get("result") or {}
237
+
238
+ # Other message types (next/complete/etc.) are unexpected
239
+ # for a blocking request; keep reading.
240
+ continue
241
+
242
+ except TimeoutError:
243
+ raise RuntimeError(f"Command timeout after {self._timeout}s") from None
244
+ except websockets.exceptions.ConnectionClosedError as exc:
245
+ raise RuntimeError(f"WebSocket connection closed: {exc}") from exc
246
+ except RuntimeError:
247
+ raise
248
+ except Exception as exc:
249
+ raise RuntimeError(f"Command failed: {exc}") from exc
250
+
251
+ # Autopilot commands
252
+
253
+ async def autopilot_status(self) -> dict[str, Any]:
254
+ """Get autopilot status."""
255
+ return await self._send_command("autopilot_status")
256
+
257
+ async def autopilot_submit(
258
+ self, description: str, *, priority: int = 50, workspace: str | None = None
259
+ ) -> dict[str, Any]:
260
+ """Submit a new autopilot task."""
261
+ payload = {"description": description, "priority": priority}
262
+ if workspace:
263
+ payload["workspace"] = workspace
264
+ return await self._send_command("autopilot_submit", payload)
265
+
266
+ async def autopilot_list_goals(self) -> dict[str, Any]:
267
+ """List all goals."""
268
+ return await self._send_command("autopilot_list_goals")
269
+
270
+ async def autopilot_get_goal(self, goal_id: str) -> dict[str, Any]:
271
+ """Get goal details."""
272
+ return await self._send_command("autopilot_get_goal", {"goal_id": goal_id})
273
+
274
+ async def autopilot_cancel_goal(self, goal_id: str) -> dict[str, Any]:
275
+ """Cancel a goal."""
276
+ return await self._send_command("autopilot_cancel_goal", {"goal_id": goal_id})
277
+
278
+ async def autopilot_wake(self) -> dict[str, Any]:
279
+ """Exit dreaming mode."""
280
+ return await self._send_command("autopilot_wake")
281
+
282
+ async def autopilot_dream(self) -> dict[str, Any]:
283
+ """Force dreaming mode."""
284
+ return await self._send_command("autopilot_dream")
285
+
286
+ async def autopilot_resume(self, goal_id: str) -> dict[str, Any]:
287
+ """Resume a suspended/blocked goal."""
288
+ return await self._send_command("autopilot_resume", {"goal_id": goal_id})
289
+
290
+ async def autopilot_list_jobs(self) -> dict[str, Any]:
291
+ """List root goals (jobs) only."""
292
+ return await self._send_command("autopilot_list_jobs")
293
+
294
+ async def autopilot_get_job(self, job_id: str) -> dict[str, Any]:
295
+ """Get job status with DAG snapshot."""
296
+ return await self._send_command("autopilot_get_job", {"job_id": job_id})
297
+
298
+ async def autopilot_subscribe(self) -> dict[str, Any]:
299
+ """Subscribe to autopilot worker events."""
300
+ return await self._send_command("autopilot_subscribe")
301
+
302
+ async def autopilot_unsubscribe(self) -> dict[str, Any]:
303
+ """Unsubscribe from autopilot worker events."""
304
+ return await self._send_command("autopilot_unsubscribe")
305
+
306
+ # RFC-228 canonical job commands (recommended)
307
+
308
+ async def job_create(
309
+ self,
310
+ goal: str,
311
+ *,
312
+ workspace: str | None = None,
313
+ autonomous: bool = False,
314
+ max_iterations: int | None = None,
315
+ guidance: str | None = None,
316
+ ) -> dict[str, Any]:
317
+ """Create a new autopilot job (RFC-228 canonical method).
318
+
319
+ Args:
320
+ goal: Job goal text (required).
321
+ workspace: Optional workspace path.
322
+ autonomous: Whether to run autonomously.
323
+ max_iterations: Optional iteration limit.
324
+ guidance: Optional initial guidance.
325
+
326
+ Returns:
327
+ Dict with job_id and status.
328
+ """
329
+ payload = {"goal": goal}
330
+ if workspace:
331
+ payload["workspace"] = workspace
332
+ if autonomous:
333
+ payload["autonomous"] = autonomous
334
+ if max_iterations:
335
+ payload["max_iterations"] = max_iterations
336
+ if guidance:
337
+ payload["guidance"] = guidance
338
+ return await self._send_command("job_create", payload)
339
+
340
+ async def job_status(self, job_id: str) -> dict[str, Any]:
341
+ """Get job status with goal counts and workers (RFC-228 canonical method).
342
+
343
+ Args:
344
+ job_id: Job identifier.
345
+
346
+ Returns:
347
+ Dict with job_id, status, active_goals, completed_goals, workers.
348
+ """
349
+ return await self._send_command("job_status", {"job_id": job_id})
350
+
351
+ async def job_pause(self, job_id: str) -> dict[str, Any]:
352
+ """Pause a running job (RFC-228 canonical method).
353
+
354
+ Args:
355
+ job_id: Job identifier.
356
+
357
+ Returns:
358
+ Dict with job_id and status="suspended".
359
+ """
360
+ return await self._send_command("job_pause", {"job_id": job_id})
361
+
362
+ async def job_resume(self, job_id: str) -> dict[str, Any]:
363
+ """Resume a paused job (RFC-228 canonical method).
364
+
365
+ Args:
366
+ job_id: Job identifier.
367
+
368
+ Returns:
369
+ Dict with job_id and status="pending".
370
+ """
371
+ return await self._send_command("job_resume", {"job_id": job_id})
372
+
373
+ async def job_cancel(self, job_id: str) -> dict[str, Any]:
374
+ """Cancel a job (RFC-228 canonical method).
375
+
376
+ Args:
377
+ job_id: Job identifier.
378
+
379
+ Returns:
380
+ Dict with job_id and status="cancelled".
381
+ """
382
+ return await self._send_command("job_cancel", {"job_id": job_id})
383
+
384
+ async def job_dag(self, job_id: str) -> dict[str, Any]:
385
+ """Get job DAG snapshot for visualization (RFC-228 canonical method).
386
+
387
+ Args:
388
+ job_id: Job identifier.
389
+
390
+ Returns:
391
+ Dict with job_id and dag (nodes/edges).
392
+ """
393
+ return await self._send_command("job_dag", {"job_id": job_id})
394
+
395
+ async def job_guidance(
396
+ self, job_id: str, content: str, *, goal_id: str | None = None
397
+ ) -> dict[str, Any]:
398
+ """Send guidance to a job or specific goal (RFC-228 canonical method).
399
+
400
+ Args:
401
+ job_id: Job identifier.
402
+ content: Guidance text.
403
+ goal_id: Optional specific goal to target.
404
+
405
+ Returns:
406
+ Dict with job_id, goal_id, absorbed.
407
+ """
408
+ payload = {"job_id": job_id, "content": content}
409
+ if goal_id:
410
+ payload["goal_id"] = goal_id
411
+ return await self._send_command("job_guidance", payload)
412
+
413
+ # Cron commands
414
+
415
+ async def cron_add(self, text: str, *, priority: int | None = None) -> dict[str, Any]:
416
+ """Submit a natural-language scheduled job."""
417
+ payload = {"text": text}
418
+ if priority is not None:
419
+ payload["priority"] = priority
420
+ result = await self._send_command("cron_add", payload)
421
+ return _normalize_cron_add_result(result)
422
+
423
+ async def cron_list(self, *, status: str | None = None) -> dict[str, Any]:
424
+ """List scheduled jobs (RFC-229 canonical method).
425
+
426
+ Sends the ``cron_list`` method, which the daemon routes to
427
+ ``_handle_cron_list``. The former ``cron_list_jobs`` method name is
428
+ deprecated; it sent a method the daemon did not handle.
429
+
430
+ Args:
431
+ status: Optional status filter.
432
+
433
+ Returns:
434
+ Dict with jobs list.
435
+ """
436
+ payload = {}
437
+ if status:
438
+ payload["status"] = status
439
+ return await self._send_command("cron_list", payload)
440
+
441
+ async def cron_show(self, job_id: str) -> dict[str, Any]:
442
+ """Get job details."""
443
+ result = await self._send_command("cron_show", {"job_id": job_id})
444
+ return _normalize_cron_show_result(result)
445
+
446
+ async def cron_cancel(self, job_id: str) -> dict[str, Any]:
447
+ """Cancel a scheduled job."""
448
+ return await self._send_command("cron_cancel", {"job_id": job_id})
449
+
450
+ # Memory commands
451
+
452
+ async def memory_stats(self, mode: str = "daemon") -> dict[str, Any]:
453
+ """Query daemon memory profiling stats."""
454
+ return await self._send_command("memory_stats", {"mode": mode})
455
+
456
+
457
+ class SyncWsCommandClient:
458
+ """Synchronous wrapper for WsCommandClient.
459
+
460
+ Provides a synchronous interface for CLI commands that need to call
461
+ daemon endpoints without async context.
462
+
463
+ Args:
464
+ ws_url: WebSocket URL.
465
+ timeout: Command timeout in seconds.
466
+ """
467
+
468
+ def __init__(self, ws_url: str, *, timeout: float = 30.0) -> None:
469
+ self._client = WsCommandClient(ws_url, timeout=timeout)
470
+
471
+ def _run_async(self, coro: Any) -> Any:
472
+ """Run async coroutine in sync context."""
473
+ try:
474
+ loop = asyncio.get_running_loop()
475
+ except RuntimeError:
476
+ loop = None
477
+
478
+ if loop is not None:
479
+ # Already in async context - create task
480
+ return asyncio.ensure_future(coro)
481
+ else:
482
+ # Not in async context - run in new loop
483
+ return asyncio.run(coro)
484
+
485
+ def autopilot_status(self) -> dict[str, Any]:
486
+ """Get autopilot status (sync)."""
487
+ return self._run_async(self._client.autopilot_status())
488
+
489
+ def autopilot_submit(
490
+ self, description: str, *, priority: int = 50, workspace: str | None = None
491
+ ) -> dict[str, Any]:
492
+ """Submit a new autopilot task (sync)."""
493
+ return self._run_async(
494
+ self._client.autopilot_submit(description, priority=priority, workspace=workspace)
495
+ )
496
+
497
+ def autopilot_list_goals(self) -> dict[str, Any]:
498
+ """List all goals (sync)."""
499
+ return self._run_async(self._client.autopilot_list_goals())
500
+
501
+ def autopilot_get_goal(self, goal_id: str) -> dict[str, Any]:
502
+ """Get goal details (sync)."""
503
+ return self._run_async(self._client.autopilot_get_goal(goal_id))
504
+
505
+ def autopilot_cancel_goal(self, goal_id: str) -> dict[str, Any]:
506
+ """Cancel a goal (sync)."""
507
+ return self._run_async(self._client.autopilot_cancel_goal(goal_id))
508
+
509
+ def autopilot_wake(self) -> dict[str, Any]:
510
+ """Exit dreaming mode (sync)."""
511
+ return self._run_async(self._client.autopilot_wake())
512
+
513
+ def autopilot_dream(self) -> dict[str, Any]:
514
+ """Force dreaming mode (sync)."""
515
+ return self._run_async(self._client.autopilot_dream())
516
+
517
+ def autopilot_resume(self, goal_id: str) -> dict[str, Any]:
518
+ """Resume a suspended/blocked goal (sync)."""
519
+ return self._run_async(self._client.autopilot_resume(goal_id))
520
+
521
+ def autopilot_list_jobs(self) -> dict[str, Any]:
522
+ """List root goals (jobs) only (sync)."""
523
+ return self._run_async(self._client.autopilot_list_jobs())
524
+
525
+ def autopilot_get_job(self, job_id: str) -> dict[str, Any]:
526
+ """Get job status with DAG snapshot (sync)."""
527
+ return self._run_async(self._client.autopilot_get_job(job_id))
528
+
529
+ def job_pause(self, job_id: str) -> dict[str, Any]:
530
+ """Pause a running autopilot job (sync)."""
531
+ return self._run_async(self._client.job_pause(job_id))
532
+
533
+ def job_guidance(self, job_id: str, text: str, *, goal_id: str | None = None) -> dict[str, Any]:
534
+ """Send guidance to an autopilot job or specific goal (sync)."""
535
+ return self._run_async(self._client.job_guidance(job_id, text, goal_id=goal_id))
536
+
537
+ def job_create(
538
+ self,
539
+ goal: str,
540
+ *,
541
+ workspace: str | None = None,
542
+ autonomous: bool = False,
543
+ max_iterations: int | None = None,
544
+ guidance: str | None = None,
545
+ ) -> dict[str, Any]:
546
+ """Create a new autopilot job (sync, RFC-228 canonical)."""
547
+ return self._run_async(
548
+ self._client.job_create(
549
+ goal,
550
+ workspace=workspace,
551
+ autonomous=autonomous,
552
+ max_iterations=max_iterations,
553
+ guidance=guidance,
554
+ )
555
+ )
556
+
557
+ def job_status(self, job_id: str) -> dict[str, Any]:
558
+ """Get job status with goal counts and workers (sync, RFC-228 canonical)."""
559
+ return self._run_async(self._client.job_status(job_id))
560
+
561
+ def job_resume(self, job_id: str) -> dict[str, Any]:
562
+ """Resume a paused autopilot job (sync, RFC-228 canonical)."""
563
+ return self._run_async(self._client.job_resume(job_id))
564
+
565
+ def job_cancel(self, job_id: str) -> dict[str, Any]:
566
+ """Cancel an autopilot job (sync, RFC-228 canonical)."""
567
+ return self._run_async(self._client.job_cancel(job_id))
568
+
569
+ def job_dag(self, job_id: str) -> dict[str, Any]:
570
+ """Get job DAG snapshot for visualization (sync, RFC-228 canonical)."""
571
+ return self._run_async(self._client.job_dag(job_id))
572
+
573
+ def autopilot_subscribe(self) -> dict[str, Any]:
574
+ """Subscribe to autopilot worker events (sync)."""
575
+ return self._run_async(self._client.autopilot_subscribe())
576
+
577
+ def autopilot_unsubscribe(self) -> dict[str, Any]:
578
+ """Unsubscribe from autopilot worker events (sync)."""
579
+ return self._run_async(self._client.autopilot_unsubscribe())
580
+
581
+ def cron_add(self, text: str, *, priority: int | None = None) -> dict[str, Any]:
582
+ """Submit a natural-language scheduled job (sync)."""
583
+ return self._run_async(self._client.cron_add(text, priority=priority))
584
+
585
+ def cron_list(self, *, status: str | None = None) -> dict[str, Any]:
586
+ """List scheduled jobs (sync)."""
587
+ return self._run_async(self._client.cron_list(status=status))
588
+
589
+ def cron_show(self, job_id: str) -> dict[str, Any]:
590
+ """Get job details (sync)."""
591
+ return self._run_async(self._client.cron_show(job_id))
592
+
593
+ def cron_cancel(self, job_id: str) -> dict[str, Any]:
594
+ """Cancel a scheduled job (sync)."""
595
+ return self._run_async(self._client.cron_cancel(job_id))
596
+
597
+ def memory_stats(self, mode: str = "daemon") -> dict[str, Any]:
598
+ """Query daemon memory profiling stats (sync)."""
599
+ return self._run_async(self._client.memory_stats(mode))
600
+
601
+
602
+ def ws_command_client_from_config(cfg: Any) -> SyncWsCommandClient:
603
+ """Build a WebSocket command client from CLI or soothe config.
604
+
605
+ Args:
606
+ cfg: CLI, daemon, or soothe config exposing websocket host/port.
607
+
608
+ Returns:
609
+ SyncWsCommandClient instance.
610
+ """
611
+ ws_url = websocket_url_from_config(cfg)
612
+ return SyncWsCommandClient(ws_url)
613
+
614
+
615
+ def async_ws_command_client_from_config(cfg: Any) -> WsCommandClient:
616
+ """Build an async WebSocket command client from config.
617
+
618
+ Args:
619
+ cfg: CLI, daemon, or soothe config exposing websocket host/port.
620
+
621
+ Returns:
622
+ WsCommandClient instance.
623
+ """
624
+ ws_url = websocket_url_from_config(cfg)
625
+ return WsCommandClient(ws_url)
626
+
627
+
628
+ __all__ = [
629
+ "WsCommandClient",
630
+ "SyncWsCommandClient",
631
+ "ws_command_client_from_config",
632
+ "async_ws_command_client_from_config",
633
+ ]