soothe-client-python 0.9.4__tar.gz → 0.9.7__tar.gz

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.
Files changed (33) hide show
  1. soothe_client_python-0.9.7/PKG-INFO +85 -0
  2. soothe_client_python-0.9.7/README.md +52 -0
  3. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/pyproject.toml +12 -5
  4. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/__init__.py +27 -4
  5. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/__init__.py +7 -10
  6. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/attachments.py +4 -7
  7. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/broadcaster.py +1 -1
  8. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/classifier.py +17 -13
  9. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/daemon_session.py +47 -15
  10. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/managed_client.py +75 -7
  11. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/pool.py +1 -1
  12. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/query_gate.py +1 -1
  13. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/session_store.py +1 -1
  14. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/thinking_step.py +1 -1
  15. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/turn_runner.py +24 -21
  16. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/helpers.py +104 -27
  17. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/protocol_params.py +131 -67
  18. soothe_client_python-0.9.7/src/soothe_client/py.typed +0 -0
  19. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/schemas.py +3 -9
  20. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/session.py +6 -7
  21. soothe_client_python-0.9.7/src/soothe_client/stream_terminal.py +73 -0
  22. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/websocket.py +65 -94
  23. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/ws_command_client.py +80 -60
  24. soothe_client_python-0.9.4/PKG-INFO +0 -131
  25. soothe_client_python-0.9.4/README.md +0 -97
  26. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/.gitignore +0 -0
  27. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/LICENSE +0 -0
  28. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/chunk_filter.py +0 -0
  29. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/events.py +0 -0
  30. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/observability.py +0 -0
  31. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/appkit/turn.py +0 -0
  32. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/errors.py +0 -0
  33. {soothe_client_python-0.9.4 → soothe_client_python-0.9.7}/src/soothe_client/intent_hints.py +0 -0
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: soothe-client-python
3
+ Version: 0.9.7
4
+ Summary: WebSocket client + appkit for soothe-daemon (Python)
5
+ Project-URL: Homepage, https://github.com/mirasoth/soothe-client-python
6
+ Project-URL: Documentation, https://soothe.readthedocs.io
7
+ Project-URL: Repository, https://github.com/mirasoth/soothe-client-python
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,ai,client,soothe,websocket
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: <3.15,>=3.11
22
+ Requires-Dist: pillow<12.0.0,>=10.0.0
23
+ Requires-Dist: pydantic<3.0.0,>=2.0.0
24
+ Requires-Dist: soothe-sdk<1.0.0,>=0.8.1
25
+ Requires-Dist: websockets>=12.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
29
+ Requires-Dist: pytest-cov; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.12.0; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # soothe-client-python
35
+
36
+ Talk to a running **soothe-daemon** over WebSocket — send prompts, stream agent
37
+ turns, run jobs.
38
+
39
+ ```bash
40
+ pip install soothe-client-python
41
+ ```
42
+
43
+ Requires a local daemon (default `ws://127.0.0.1:8765`).
44
+
45
+ ## Quick start
46
+
47
+ ```python
48
+ import asyncio
49
+ from soothe_client.appkit import DaemonSession
50
+
51
+ async def main() -> None:
52
+ session = DaemonSession("ws://127.0.0.1:8765")
53
+ await session.connect()
54
+ await session.send_turn("Summarize this in one sentence: agents need tools.")
55
+ async for _namespace, mode, data in session.iter_turn_chunks():
56
+ if mode == "custom" and isinstance(data, dict):
57
+ text = data.get("content") or data.get("text")
58
+ if text:
59
+ print(text, end="", flush=True)
60
+ print()
61
+ await session.close()
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ More patterns: [`examples/`](examples/) (hello → streaming → multi-turn → pool → jobs).
67
+
68
+ ## What you get
69
+
70
+ | Need | Use |
71
+ |------|-----|
72
+ | One conversation, stream replies | `DaemonSession` |
73
+ | Raw WebSocket / custom RPCs | `WebSocketClient` |
74
+ | Many users / HTTP backend | `ConnectionPool` + `TurnRunner` |
75
+ | Jobs / autopilot / cron | `WsCommandClient` |
76
+
77
+ ## Develop
78
+
79
+ ```bash
80
+ make sync-dev
81
+ make check # lint + unit tests
82
+ make test-examples-offline # offline appkit examples
83
+ make test-examples # live 01–06 (needs soothed)
84
+ make test-integration # live integration suite (needs soothed)
85
+ ```
@@ -0,0 +1,52 @@
1
+ # soothe-client-python
2
+
3
+ Talk to a running **soothe-daemon** over WebSocket — send prompts, stream agent
4
+ turns, run jobs.
5
+
6
+ ```bash
7
+ pip install soothe-client-python
8
+ ```
9
+
10
+ Requires a local daemon (default `ws://127.0.0.1:8765`).
11
+
12
+ ## Quick start
13
+
14
+ ```python
15
+ import asyncio
16
+ from soothe_client.appkit import DaemonSession
17
+
18
+ async def main() -> None:
19
+ session = DaemonSession("ws://127.0.0.1:8765")
20
+ await session.connect()
21
+ await session.send_turn("Summarize this in one sentence: agents need tools.")
22
+ async for _namespace, mode, data in session.iter_turn_chunks():
23
+ if mode == "custom" and isinstance(data, dict):
24
+ text = data.get("content") or data.get("text")
25
+ if text:
26
+ print(text, end="", flush=True)
27
+ print()
28
+ await session.close()
29
+
30
+ asyncio.run(main())
31
+ ```
32
+
33
+ More patterns: [`examples/`](examples/) (hello → streaming → multi-turn → pool → jobs).
34
+
35
+ ## What you get
36
+
37
+ | Need | Use |
38
+ |------|-----|
39
+ | One conversation, stream replies | `DaemonSession` |
40
+ | Raw WebSocket / custom RPCs | `WebSocketClient` |
41
+ | Many users / HTTP backend | `ConnectionPool` + `TurnRunner` |
42
+ | Jobs / autopilot / cron | `WsCommandClient` |
43
+
44
+ ## Develop
45
+
46
+ ```bash
47
+ make sync-dev
48
+ make check # lint + unit tests
49
+ make test-examples-offline # offline appkit examples
50
+ make test-examples # live 01–06 (needs soothed)
51
+ make test-integration # live integration suite (needs soothed)
52
+ ```
@@ -23,15 +23,13 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
24
  ]
25
25
  dependencies = [
26
- "soothe-sdk>=0.5.25,<1.0.0",
26
+ "soothe-sdk>=0.8.1,<1.0.0",
27
27
  "pydantic>=2.0.0,<3.0.0",
28
28
  "websockets>=12.0",
29
+ "pillow>=10.0.0,<12.0.0",
29
30
  ]
30
31
 
31
32
  [project.optional-dependencies]
32
- image = [
33
- "pillow>=10.0.0",
34
- ]
35
33
  dev = [
36
34
  "pytest>=8.0.0",
37
35
  "pytest-asyncio>=1.3.0",
@@ -76,8 +74,17 @@ python_version = "3.12"
76
74
  warn_return_any = true
77
75
  warn_unused_configs = true
78
76
  disallow_untyped_defs = true
77
+ packages = ["soothe_client"]
78
+ mypy_path = "src"
79
+
80
+ [[tool.mypy.overrides]]
81
+ module = ["soothe_sdk", "soothe_sdk.*"]
82
+ ignore_missing_imports = true
79
83
 
80
84
  [tool.pytest.ini_options]
81
85
  asyncio_mode = "auto"
82
- testpaths = ["tests", "examples"]
86
+ testpaths = ["tests/unit", "examples/appkit"]
83
87
  addopts = "--import-mode=importlib"
88
+ markers = [
89
+ "integration: live soothe-daemon tests (skipped when unreachable)",
90
+ ]
@@ -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
@@ -155,13 +155,12 @@ class EventClassifier:
155
155
  return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
156
156
 
157
157
  if typ == "error":
158
- err_obj = msg.get("error") if isinstance(msg.get("error"), dict) else {}
159
- code = err_obj.get("code") if isinstance(err_obj.get("code"), int) else -32603
160
- message = (
161
- err_obj.get("message")
162
- if isinstance(err_obj.get("message"), str)
163
- else "daemon error"
164
- )
158
+ raw_err = msg.get("error")
159
+ err_obj: dict[str, Any] = raw_err if isinstance(raw_err, dict) else {}
160
+ raw_code = err_obj.get("code")
161
+ code = raw_code if isinstance(raw_code, int) else -32603
162
+ raw_message = err_obj.get("message")
163
+ message = raw_message if isinstance(raw_message, str) else "daemon error"
165
164
  return self._failed_result(DaemonError(code, message, err_obj.get("data")))
166
165
 
167
166
  if typ == "event":
@@ -174,8 +173,10 @@ class EventClassifier:
174
173
  return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
175
174
 
176
175
  def _classify_next_envelope(self, env: dict[str, Any]) -> ChatEventResult:
177
- payload = env.get("payload") if isinstance(env.get("payload"), dict) else {}
178
- inner_data = payload.get("data") if isinstance(payload.get("data"), dict) else None
176
+ raw_payload = env.get("payload")
177
+ payload: dict[str, Any] = raw_payload if isinstance(raw_payload, dict) else {}
178
+ raw_inner = payload.get("data")
179
+ inner_data: dict[str, Any] | None = raw_inner if isinstance(raw_inner, dict) else None
179
180
  if inner_data is not None:
180
181
  inner_mode = str(inner_data.get("mode") or "")
181
182
  if inner_mode:
@@ -372,8 +373,10 @@ def _first_message_payload(data: Any) -> tuple[str, str, str, bool]:
372
373
  msg_map = data[0]
373
374
  if not isinstance(msg_map, dict):
374
375
  return "", "", "", False
375
- msg_type = msg_map.get("type") if isinstance(msg_map.get("type"), str) else ""
376
- phase = msg_map.get("phase") if isinstance(msg_map.get("phase"), str) else ""
376
+ type_raw = msg_map.get("type")
377
+ msg_type = type_raw if isinstance(type_raw, str) else ""
378
+ phase_raw = msg_map.get("phase")
379
+ phase = phase_raw if isinstance(phase_raw, str) else ""
377
380
  content = _extract_content_from_message(msg_map)
378
381
  return msg_type, content, phase, True
379
382
 
@@ -388,7 +391,8 @@ def _loop_ai_message(data: Any) -> dict[str, str] | None:
388
391
  phase = phase_raw.strip() if isinstance(phase_raw, str) else ""
389
392
  if not phase:
390
393
  return None
391
- msg_type = msg_map.get("type") if isinstance(msg_map.get("type"), str) else ""
394
+ type_raw = msg_map.get("type")
395
+ msg_type = type_raw if isinstance(type_raw, str) else ""
392
396
  content = _extract_content_from_message(msg_map)
393
397
  return {"type": msg_type, "content": content, "phase": phase}
394
398
 
@@ -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
@@ -19,13 +18,13 @@ from soothe_client.appkit.chunk_filter import should_drop_stream_chunk_early
19
18
  from soothe_client.appkit.events import unwrap_next
20
19
  from soothe_client.appkit.observability import TurnEventStats
21
20
  from soothe_client.session import bootstrap_loop_session, connect_websocket_with_retries
21
+ from soothe_client.stream_terminal import STREAM_END, is_turn_end_custom_data
22
22
  from soothe_client.websocket import WebSocketClient
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
26
  DEFAULT_POST_IDLE_DRAIN_S = 0.5
27
27
  _RPC_HANDSHAKE_TIMEOUT_S = 20.0
28
- _TURN_END_CUSTOM_TYPES = frozenset({"soothe.cognition.strange_loop.completed"})
29
28
 
30
29
  EarlyDropFn = Callable[[tuple[Any, ...], str, Any], bool]
31
30
  StatsFactory = Callable[[], Any]
@@ -210,6 +209,7 @@ class DaemonSession:
210
209
  clarification_mode: str | None = None,
211
210
  clarification_answer: bool = False,
212
211
  clarification_answers: list[str] | None = None,
212
+ intent_hint: str | None = None,
213
213
  ) -> None:
214
214
  """Send a new user turn to the daemon."""
215
215
  if not self._loop_id:
@@ -227,6 +227,7 @@ class DaemonSession:
227
227
  clarification_mode=clarification_mode,
228
228
  clarification_answer=clarification_answer,
229
229
  clarification_answers=clarification_answers,
230
+ intent_hint=intent_hint,
230
231
  )
231
232
 
232
233
  async def cancel_remote_query(self) -> None:
@@ -289,8 +290,19 @@ class DaemonSession:
289
290
  await self._ensure_rpc_connected()
290
291
  return await self._rpc_client.request("loop_list", {"limit": limit}, timeout=15.0)
291
292
 
292
- async def iter_turn_chunks(self) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
293
- """Yield ``(namespace, mode, data)`` chunks for the active daemon turn."""
293
+ async def iter_turn_chunks(
294
+ self,
295
+ *,
296
+ max_wait_s: float | None = None,
297
+ ) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
298
+ """Yield ``(namespace, mode, data)`` chunks for the active daemon turn.
299
+
300
+ Args:
301
+ max_wait_s: Optional absolute deadline for the whole turn. When set,
302
+ raises ``TimeoutError`` if the daemon never emits a turn-end
303
+ signal in time (idle after payload, stopped, stream.end, or
304
+ strange_loop.completed).
305
+ """
294
306
  self.turn_event_stats = self._new_turn_stats()
295
307
  self.last_turn_end_state = None
296
308
  self.last_turn_cancellation_seen = False
@@ -303,6 +315,9 @@ class DaemonSession:
303
315
  turn_read_started = time.monotonic()
304
316
  first_event_logged = False
305
317
  progress_seen = False
318
+ absolute_deadline = (
319
+ time.monotonic() + max_wait_s if max_wait_s is not None and max_wait_s > 0 else None
320
+ )
306
321
  peel = getattr(self._client, "peel_stale_pending_control_events", None)
307
322
  stale_pending = peel() if callable(peel) else []
308
323
  if stale_pending:
@@ -315,6 +330,11 @@ class DaemonSession:
315
330
  async with self._read_lock:
316
331
  try:
317
332
  while True:
333
+ if absolute_deadline is not None and time.monotonic() >= absolute_deadline:
334
+ raise TimeoutError(
335
+ f"Turn timed out after {max_wait_s:.0f}s "
336
+ f"(loop={expected_loop_id or '?'})"
337
+ )
318
338
  if not progress_seen and time.monotonic() - turn_read_started > 30.0:
319
339
  logger.warning(
320
340
  "No daemon stream progress after %.0fs (loop=%s, "
@@ -401,18 +421,30 @@ class DaemonSession:
401
421
  if self._should_drop(namespace, mode, data):
402
422
  self.turn_event_stats.filtered_early += 1
403
423
  continue
424
+
425
+ # Prior-goal terminal frames can arrive before status=running
426
+ # for the new turn; ignoring them prevents a blank second query.
427
+ if mode == "custom" and is_turn_end_custom_data(data) and not query_started:
428
+ logger.debug(
429
+ "Ignoring pre-start turn-end frame %s (loop=%s)",
430
+ str(data.get("type", "")).strip(),
431
+ (expected_loop_id or "?")[:16],
432
+ )
433
+ continue
434
+
404
435
  progress_seen = True
405
436
  stream_payload_seen = True
406
437
  yield (namespace, mode, data)
407
- if mode == "custom" and isinstance(data, dict):
438
+ if mode == "custom" and is_turn_end_custom_data(data):
408
439
  custom_type = str(data.get("type", "")).strip()
409
- if custom_type in _TURN_END_CUSTOM_TYPES:
410
- self.last_turn_end_state = "completed"
411
- async for chunk in self._drain_stream_events_after_idle(
412
- expected_loop_id=expected_loop_id,
413
- ):
414
- yield chunk
415
- break
440
+ self.last_turn_end_state = (
441
+ "stream_end" if custom_type == STREAM_END else "completed"
442
+ )
443
+ async for chunk in self._drain_stream_events_after_idle(
444
+ expected_loop_id=expected_loop_id,
445
+ ):
446
+ yield chunk
447
+ break
416
448
  if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
417
449
  continue
418
450
  except Exception as exc:
@@ -1,13 +1,13 @@
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
8
8
 
9
9
  from collections.abc import AsyncIterator, Awaitable, Callable
10
- from typing import Any, Protocol, runtime_checkable
10
+ from typing import Any, Protocol, cast, runtime_checkable
11
11
 
12
12
  from soothe_client.errors import DisconnectCause
13
13
  from soothe_client.websocket import WebSocketClient
@@ -18,10 +18,78 @@ 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 cast(
64
+ dict[str, Any],
65
+ WireEnvelope(
66
+ proto="1",
67
+ type=MessageType.NOTIFICATION.value,
68
+ method=msg_type,
69
+ params=params,
70
+ ).to_wire_dict(),
71
+ )
72
+
73
+ if msg_type in _REQUEST_METHOD_ALIASES or msg_type:
74
+ request_method = _REQUEST_METHOD_ALIASES.get(msg_type, msg_type)
75
+ req_id = str(msg.get("request_id") or msg.get("id") or client._next_request_id())
76
+ return cast(
77
+ dict[str, Any],
78
+ WireEnvelope(
79
+ proto="1",
80
+ type=MessageType.REQUEST.value,
81
+ method=request_method,
82
+ params=params,
83
+ id=req_id,
84
+ ).to_wire_dict(),
85
+ )
86
+
87
+ return msg
88
+
21
89
 
22
90
  @runtime_checkable
23
91
  class ManagedClient(Protocol):
24
- """Subset of Layer 0 that ConnectionPool and TurnRunner depend on."""
92
+ """Subset of WebSocketClient that ConnectionPool and TurnRunner depend on."""
25
93
 
26
94
  async def connect(self) -> None:
27
95
  """Dial and handshake."""
@@ -72,7 +140,7 @@ class WebSocketManagedClient:
72
140
 
73
141
  @property
74
142
  def underlying(self) -> WebSocketClient:
75
- """Return the wrapped Layer 0 client."""
143
+ """Return the wrapped WebSocketClient."""
76
144
  return self._client
77
145
 
78
146
  async def connect(self) -> None:
@@ -89,7 +157,7 @@ class WebSocketManagedClient:
89
157
  async def send_message(self, msg: Any) -> None:
90
158
  if not isinstance(msg, dict):
91
159
  raise TypeError("send_message expects a dict payload")
92
- await self._client.send(msg)
160
+ await self._client.send(_coerce_appkit_wire_message(msg, self._client))
93
161
 
94
162
  async def receive_messages(
95
163
  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.