soothe-cli 0.6.17__tar.gz → 0.7.1__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 (126) hide show
  1. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/PKG-INFO +1 -1
  2. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/loop_cmd.py +59 -47
  3. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/status_cmd.py +26 -9
  4. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/daemon.py +55 -4
  5. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/daemon_errors.py +18 -0
  6. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/headless/processor.py +14 -3
  7. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/session_stats.py +60 -0
  8. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/transport/session.py +154 -34
  9. soothe_cli-0.7.1/src/soothe_cli/runtime/turn/pipeline.py +309 -0
  10. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_app.py +23 -1
  11. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_execution.py +48 -6
  12. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_history.py +0 -2
  13. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_messages_mixin.py +58 -41
  14. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_model.py +28 -41
  15. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_module_init.py +3 -1
  16. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_startup.py +57 -65
  17. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_ui.py +26 -5
  18. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/app.tcss +6 -6
  19. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/binding.py +2 -1
  20. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/command_registry.py +6 -6
  21. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/__init__.py +0 -6
  22. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/command_router.py +7 -13
  23. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/slash_commands.py +0 -33
  24. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/subagent_routing.py +7 -1
  25. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/media_utils.py +1 -5
  26. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/model_config.py +75 -18
  27. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/sessions.py +24 -1
  28. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/invocation.py +0 -13
  29. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/textual_adapter.py +405 -85
  30. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/theme.py +0 -5
  31. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/tool_display.py +54 -1
  32. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/chat_input.py +3 -3
  33. soothe_cli-0.7.1/src/soothe_cli/tui/widgets/context_viewer.py +298 -0
  34. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/loading.py +30 -8
  35. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/loop_selector.py +126 -112
  36. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_step.py +64 -12
  37. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +14 -5
  38. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/user.py +20 -12
  39. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/status.py +2 -2
  40. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/welcome.py +1 -15
  41. soothe_cli-0.6.17/src/soothe_cli/runtime/turn/pipeline.py +0 -166
  42. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/.gitignore +0 -0
  43. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/README.md +0 -0
  44. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/pyproject.toml +0 -0
  45. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/__init__.py +0 -0
  46. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/__init__.py +0 -0
  47. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/__init__.py +0 -0
  48. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  49. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  50. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  51. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/__init__.py +0 -0
  52. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/headless.py +0 -0
  53. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  54. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/launcher.py +0 -0
  55. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/cli/main.py +0 -0
  56. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/config/__init__.py +0 -0
  57. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/config/cli_config.py +0 -0
  58. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/config/loader.py +0 -0
  59. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/config/logging_setup.py +0 -0
  60. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/__init__.py +0 -0
  61. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  62. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  63. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  64. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  65. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  66. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  67. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  68. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  69. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  70. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  71. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  72. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  73. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  74. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/step_router.py +0 -0
  75. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  76. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/transcript.py +0 -0
  77. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  78. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  79. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  80. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  81. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/messages.py +0 -0
  82. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/__init__.py +0 -0
  83. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/_cli_context.py +0 -0
  84. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/_env_vars.py +0 -0
  85. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/_version.py +0 -0
  86. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/__init__.py +0 -0
  87. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_commands.py +0 -0
  88. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/config.py +0 -0
  89. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/file_change_notify.py +0 -0
  90. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  91. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/hooks.py +0 -0
  92. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/input.py +0 -0
  93. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/path_utils.py +0 -0
  94. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/preview_limits.py +0 -0
  95. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/project_utils.py +0 -0
  96. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/__init__.py +0 -0
  97. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/load.py +0 -0
  98. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/tips.py +0 -0
  99. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/unicode_security.py +0 -0
  100. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/update_check.py +0 -0
  101. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  102. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/_links.py +0 -0
  103. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  104. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  105. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  106. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  107. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/diff.py +0 -0
  108. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/editor.py +0 -0
  109. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  110. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/history.py +0 -0
  111. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  112. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  113. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  114. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  115. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  116. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  117. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  118. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  119. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  120. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  121. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  122. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  123. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  124. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  125. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  126. {soothe_cli-0.6.17 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.6.17
3
+ Version: 0.7.1
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/mirasoth/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -17,7 +17,12 @@ from rich.console import Console
17
17
  from rich.panel import Panel
18
18
  from rich.prompt import Prompt
19
19
  from rich.table import Table
20
- from soothe_sdk.client import WebSocketClient, is_daemon_live, websocket_url_from_config
20
+ from soothe_sdk.client import (
21
+ ProtocolError,
22
+ WebSocketClient,
23
+ is_daemon_live,
24
+ websocket_url_from_config,
25
+ )
21
26
 
22
27
  from soothe_cli.runtime import load_config
23
28
 
@@ -44,37 +49,50 @@ async def _check_daemon(ws_url: str) -> bool:
44
49
 
45
50
  async def _rpc(
46
51
  ws_url: str,
47
- send_fn: str,
48
- send_args: dict[str, Any],
49
- response_type: str,
52
+ method: str,
53
+ params: dict[str, Any] | None = None,
54
+ *,
55
+ mode: str = "request",
50
56
  timeout: float = 30.0,
51
57
  ) -> dict[str, Any]:
52
- """Send an RPC request and wait for a matching response.
58
+ """Send a protocol-1 RPC to the daemon and return the response.
59
+
60
+ Uses the protocol-1 client API (RFC-450): ``request()`` for RPC,
61
+ ``notify()`` for fire-and-forget, ``subscribe()`` for event streams.
62
+ The wrapper preserves the dict-based error contract used by command
63
+ handlers — callers check ``if "error" in response``.
53
64
 
54
65
  Args:
55
66
  ws_url: WebSocket URL.
56
- send_fn: Name of the WebSocketClient method to call.
57
- send_args: Keyword arguments for the send method.
58
- response_type: Expected response message type.
67
+ method: RPC method / notification target / subscription target
68
+ (e.g. ``"loop_get"``, ``"loop_input"``, ``"loop_events"``).
69
+ params: Structured parameters object.
70
+ mode: One of ``"request"`` (blocking RPC), ``"notify"``
71
+ (fire-and-forget), or ``"subscribe"`` (start a stream).
59
72
  timeout: Maximum seconds to wait.
60
73
 
61
74
  Returns:
62
- Response dict from daemon.
75
+ Response dict from daemon, or ``{"error": ...}`` on failure. For
76
+ ``notify`` mode returns ``{}``; for ``subscribe`` mode returns
77
+ ``{"subscription_id": <id>}``.
63
78
  """
64
79
  client = WebSocketClient(url=ws_url)
65
80
  try:
66
81
  await client.connect()
67
- method = getattr(client, send_fn)
68
- await method(**send_args)
69
- async with asyncio.timeout(timeout):
70
- while True:
71
- event = await client.read_event()
72
- if not event:
73
- return {"error": "Connection closed"}
74
- if event.get("type") == response_type:
75
- return event
82
+ if mode == "notify":
83
+ await asyncio.wait_for(client.notify(method, params or {}), timeout=timeout)
84
+ return {}
85
+ if mode == "subscribe":
86
+ sub_id = await asyncio.wait_for(client.subscribe(method, params or {}), timeout=timeout)
87
+ return {"subscription_id": sub_id}
88
+ result = await asyncio.wait_for(client.request(method, params or {}), timeout=timeout)
89
+ return result if isinstance(result, dict) else {"result": result}
76
90
  except TimeoutError:
77
91
  return {"error": "Timed out waiting for daemon response"}
92
+ except ProtocolError as exc:
93
+ return {"error": str(exc)}
94
+ except (ConnectionError, OSError) as exc:
95
+ return {"error": f"Connection error: {exc}"}
78
96
  finally:
79
97
  await client.close()
80
98
 
@@ -91,9 +109,8 @@ def _resolve_continue_loop_id(ws_url: str, loop_id: str | None) -> str:
91
109
  response = asyncio.run(
92
110
  _rpc(
93
111
  ws_url,
94
- "send_loop_list",
95
- {"filter_dict": None, "limit": 20},
96
- "loop_list_response",
112
+ "loop_list",
113
+ {"limit": 20},
97
114
  )
98
115
  )
99
116
  if "error" in response:
@@ -153,9 +170,8 @@ def list_loops(
153
170
  response = asyncio.run(
154
171
  _rpc(
155
172
  ws_url,
156
- "send_loop_list",
157
- {"filter_dict": {"status": status} if status else None, "limit": limit},
158
- "loop_list_response",
173
+ "loop_list",
174
+ {"filter": {"status": status} if status else None, "limit": limit},
159
175
  )
160
176
  )
161
177
 
@@ -211,9 +227,8 @@ def describe_loop(
211
227
  response = asyncio.run(
212
228
  _rpc(
213
229
  ws_url,
214
- "send_loop_get",
230
+ "loop_get",
215
231
  {"loop_id": loop_id, "verbose": verbose},
216
- "loop_get_response",
217
232
  )
218
233
  )
219
234
 
@@ -327,9 +342,8 @@ def visualize_loop_tree(
327
342
  response = asyncio.run(
328
343
  _rpc(
329
344
  ws_url,
330
- "send_loop_tree",
345
+ "loop_tree",
331
346
  {"loop_id": loop_id, "format": format},
332
- "loop_tree_response",
333
347
  )
334
348
  )
335
349
 
@@ -384,9 +398,8 @@ def prune_loop_branches(
384
398
  response = asyncio.run(
385
399
  _rpc(
386
400
  ws_url,
387
- "send_loop_prune",
401
+ "loop_prune",
388
402
  {"loop_id": loop_id, "retention_days": retention_days, "dry_run": dry_run},
389
- "loop_prune_response",
390
403
  )
391
404
  )
392
405
 
@@ -394,10 +407,13 @@ def prune_loop_branches(
394
407
  typer.echo(f"Error: {response['error']}", err=True)
395
408
  sys.exit(1)
396
409
 
397
- result = response.get("result", {})
410
+ # Protocol-1: request() returns the result dict directly (e.g.
411
+ # {"pruned": N, "remaining": N, "dry_run": bool}), not wrapped in {"result": ...}.
412
+ pruned = response.get("pruned", 0)
413
+ remaining = response.get("remaining", 0)
398
414
  console.print("[green]Summary:[/green]")
399
- console.print(f" Branches pruned: {result.get('pruned', 0)}")
400
- console.print(f" Remaining: {result.get('remaining', 0)}")
415
+ console.print(f" Branches pruned: {pruned}")
416
+ console.print(f" Remaining: {remaining}")
401
417
 
402
418
 
403
419
  @loop_app.command("delete")
@@ -424,9 +440,8 @@ def delete_loop(
424
440
  response = asyncio.run(
425
441
  _rpc(
426
442
  ws_url,
427
- "send_loop_get",
443
+ "loop_get",
428
444
  {"loop_id": loop_id, "verbose": False},
429
- "loop_get_response",
430
445
  )
431
446
  )
432
447
 
@@ -456,9 +471,8 @@ def delete_loop(
456
471
  delete_response = asyncio.run(
457
472
  _rpc(
458
473
  ws_url,
459
- "send_loop_delete",
474
+ "loop_delete",
460
475
  {"loop_id": loop_id},
461
- "loop_delete_response",
462
476
  )
463
477
  )
464
478
 
@@ -728,9 +742,9 @@ def detach_loop(
728
742
  response = asyncio.run(
729
743
  _rpc(
730
744
  ws_url,
731
- "send_loop_detach",
745
+ "loop_detach",
732
746
  {"loop_id": loop_id},
733
- "loop_detach_response",
747
+ mode="notify",
734
748
  )
735
749
  )
736
750
 
@@ -766,9 +780,9 @@ def attach_loop(
766
780
  response = asyncio.run(
767
781
  _rpc(
768
782
  ws_url,
769
- "send_loop_subscribe",
783
+ "loop_events",
770
784
  {"loop_id": loop_id},
771
- "loop_subscribe_response",
785
+ mode="subscribe",
772
786
  )
773
787
  )
774
788
 
@@ -782,9 +796,8 @@ def attach_loop(
782
796
  status_response = asyncio.run(
783
797
  _rpc(
784
798
  ws_url,
785
- "send_loop_get",
799
+ "loop_get",
786
800
  {"loop_id": loop_id, "verbose": False},
787
- "loop_get_response",
788
801
  )
789
802
  )
790
803
 
@@ -824,9 +837,8 @@ def new_loop(
824
837
  response = asyncio.run(
825
838
  _rpc(
826
839
  ws_url,
827
- "send_loop_new",
840
+ "loop_new",
828
841
  {},
829
- "loop_new_response",
830
842
  )
831
843
  )
832
844
 
@@ -842,9 +854,9 @@ def new_loop(
842
854
  input_response = asyncio.run(
843
855
  _rpc(
844
856
  ws_url,
845
- "send_loop_input",
857
+ "loop_input",
846
858
  {"loop_id": loop_id, "content": prompt},
847
- "loop_input_response",
859
+ mode="notify",
848
860
  )
849
861
  )
850
862
  if "error" in input_response:
@@ -15,7 +15,12 @@ import typer
15
15
  from rich.console import Console
16
16
  from rich.panel import Panel
17
17
  from rich.table import Table
18
- from soothe_sdk.client import WebSocketClient, is_daemon_live, websocket_url_from_config
18
+ from soothe_sdk.client import (
19
+ WebSocketClient,
20
+ check_daemon_status,
21
+ is_daemon_live,
22
+ websocket_url_from_config,
23
+ )
19
24
 
20
25
  from soothe_cli.config.loader import load_config
21
26
 
@@ -38,8 +43,7 @@ async def _fetch_status(ws_url: str, timeout: float = 5.0) -> dict[str, Any]:
38
43
  client = WebSocketClient(url=ws_url)
39
44
  try:
40
45
  await client.connect()
41
- status = await client.fetch_daemon_status(timeout=timeout)
42
- return status
46
+ return await check_daemon_status(client, timeout=timeout)
43
47
  except Exception as e:
44
48
  return {"error": str(e)}
45
49
  finally:
@@ -47,28 +51,41 @@ async def _fetch_status(ws_url: str, timeout: float = 5.0) -> dict[str, Any]:
47
51
 
48
52
 
49
53
  async def _fetch_ready_state(ws_url: str, timeout: float = 5.0) -> dict[str, Any] | None:
50
- """Fetch daemon readiness state via WebSocket handshake.
54
+ """Fetch daemon readiness state via WebSocket connection_init/ack handshake.
51
55
 
52
- The daemon sends a daemon_ready message on connect with its state.
56
+ Sends ``connection_init`` and waits for ``connection_ack`` which carries
57
+ the daemon's ``readiness_state``.
53
58
 
54
59
  Args:
55
60
  ws_url: WebSocket URL.
56
61
  timeout: Timeout for handshake.
57
62
 
58
63
  Returns:
59
- daemon_ready message dict or None.
64
+ ``connection_ack`` result dict (containing ``readiness_state``) or None.
60
65
  """
61
66
  import websockets
62
67
 
68
+ init_msg = json.dumps(
69
+ {
70
+ "proto": "1",
71
+ "type": "connection_init",
72
+ "params": {
73
+ "client_version": "0.5.0",
74
+ "accept_proto": ["1"],
75
+ "capabilities": ["streaming"],
76
+ },
77
+ }
78
+ )
79
+
63
80
  try:
64
81
  async with asyncio.timeout(timeout):
65
82
  async with websockets.connect(ws_url) as ws:
66
- # Read initial messages - daemon sends status then daemon_ready
83
+ await ws.send(init_msg)
67
84
  for _ in range(3):
68
85
  msg = await ws.recv()
69
86
  data = json.loads(msg)
70
- if data.get("type") == "daemon_ready":
71
- return data
87
+ if data.get("type") == "connection_ack":
88
+ return data.get("result", data)
72
89
  except Exception:
73
90
  pass
74
91
  return None
@@ -39,9 +39,41 @@ _HEADLESS_WORKER_LOST_RETRIES = 1
39
39
  _CANCEL_SEND_TIMEOUT_S = 3.0
40
40
 
41
41
 
42
+ def _unwrap_next(event: dict[str, Any] | None) -> dict[str, Any] | None:
43
+ """Unwrap a protocol-1 ``next`` envelope to its inner streaming frame.
44
+
45
+ Under protocol-1 (RFC-450 §9.3) the daemon wraps free-form streaming
46
+ frames (``event``/``command_response``/card replay) in a
47
+ ``{proto, type:"next", payload:{namespace, mode, data}}`` envelope. This
48
+ helper returns the inner ``data`` dict (the legacy frame) so the headless
49
+ loop and EventProcessor can branch on the same fields as before.
50
+ ``status``/``error``/``response``/``complete`` are sent raw and pass
51
+ through unchanged.
52
+ """
53
+ if not isinstance(event, dict):
54
+ return event
55
+ if event.get("type") != "next":
56
+ return event
57
+ payload = event.get("payload")
58
+ if not isinstance(payload, dict):
59
+ return event
60
+ data = payload.get("data")
61
+ return data if isinstance(data, dict) else event
62
+
63
+
42
64
  def _is_loop_scoped_event(event: dict[str, Any], *, active_loop_id: str) -> bool:
43
- """Return whether a daemon frame belongs to the active StrangeLoop session."""
65
+ """Return whether a daemon frame belongs to the active StrangeLoop session.
66
+
67
+ Unwraps protocol-1 ``next`` envelopes first, then checks ``loop_id`` on the
68
+ inner streaming frame. Non-scoped types (``response``, ``error``,
69
+ ``complete``, etc.) are always considered in-scope.
70
+ """
44
71
  event_type = event.get("type", "")
72
+ if event_type == "next":
73
+ inner = _unwrap_next(event)
74
+ if isinstance(inner, dict):
75
+ event_type = inner.get("type", "")
76
+ return event_type not in {"status", "event"} or (inner.get("loop_id") == active_loop_id)
45
77
  if event_type not in {"status", "event"}:
46
78
  return True
47
79
  return event.get("loop_id") == active_loop_id
@@ -55,7 +87,10 @@ def _emit_headless_error(message: str) -> None:
55
87
  async def _send_cancel_to_daemon(client: WebSocketClient) -> None:
56
88
  """Send /cancel to the daemon with a short timeout."""
57
89
  try:
58
- await asyncio.wait_for(client.send_command("/cancel"), timeout=_CANCEL_SEND_TIMEOUT_S)
90
+ await asyncio.wait_for(
91
+ client.notify("slash_command", {"cmd": "/cancel"}),
92
+ timeout=_CANCEL_SEND_TIMEOUT_S,
93
+ )
59
94
  except Exception:
60
95
  logger.warning("Failed to send /cancel to daemon", exc_info=True)
61
96
 
@@ -216,8 +251,18 @@ async def _run_headless_session_once(
216
251
  if not _is_loop_scoped_event(event, active_loop_id=active_loop_id):
217
252
  continue
218
253
 
254
+ # Unwrap protocol-1 ``next`` envelopes to the inner streaming frame
255
+ # (RFC-450 §9.3). ``status``/``error`` arrive raw and pass through.
256
+ if event_type == "next":
257
+ inner = _unwrap_next(event)
258
+ if isinstance(inner, dict):
259
+ event = inner
260
+ event_type = event.get("type", "")
261
+
219
262
  if event_type == "error":
220
- raw = str(event.get("message", "unknown"))
263
+ # Protocol-1 error envelope: {type:'error', error:{code, message, data}}
264
+ err_obj = event.get("error") or {}
265
+ raw = str(err_obj.get("message") or event.get("message") or "unknown")
221
266
  _emit_headless_error(friendly_daemon_execution_error(raw))
222
267
  return 1, is_daemon_worker_subprocess_lost(raw)
223
268
 
@@ -235,7 +280,7 @@ async def _run_headless_session_once(
235
280
  query_started = True
236
281
  elif (state == "idle" and query_started) or state == "stopped":
237
282
  loop_clock = asyncio.get_event_loop()
238
- drain_deadline = loop_clock.time() + 2.5
283
+ drain_deadline = loop_clock.time() + 30.0
239
284
  while loop_clock.time() < drain_deadline:
240
285
  try:
241
286
  nxt = await asyncio.wait_for(client.read_event(), timeout=0.25)
@@ -245,6 +290,12 @@ async def _run_headless_session_once(
245
290
  break
246
291
  if not _is_loop_scoped_event(nxt, active_loop_id=active_loop_id):
247
292
  continue
293
+ # Unwrap ``next`` envelopes before handing to the
294
+ # processor so it sees the legacy frame shape.
295
+ if nxt.get("type") == "next":
296
+ inner = _unwrap_next(nxt)
297
+ if isinstance(inner, dict):
298
+ nxt = inner
248
299
  processor.process_event(nxt)
249
300
 
250
301
  processor.process_event(event)
@@ -5,11 +5,19 @@ from __future__ import annotations
5
5
  # Partial match for pool_runner RuntimeError when an OS worker exits mid-turn.
6
6
  DAEMON_WORKER_SUBPROCESS_LOST = "Worker subprocess exited unexpectedly during query execution"
7
7
 
8
+ # Partial match for thread_runner RuntimeError when a worker thread dies mid-turn.
9
+ DAEMON_WORKER_THREAD_LOST = "Worker thread exited unexpectedly during query execution"
10
+
8
11
  _FRIENDLY_WORKER_SUBPROCESS_LOST = (
9
12
  "The daemon execution worker stopped unexpectedly (for example after the pool "
10
13
  "recycled an idle subprocess). Send your message again."
11
14
  )
12
15
 
16
+ _FRIENDLY_WORKER_THREAD_LOST = (
17
+ "The daemon execution worker stopped unexpectedly during your request. "
18
+ "Send your message again, or use /resume to recover the loop."
19
+ )
20
+
13
21
  _FRIENDLY_DAEMON_CONNECTION_LOST = (
14
22
  "Daemon connection lost (the daemon may have restarted). "
15
23
  "Send your message again to reconnect and continue this loop."
@@ -45,9 +53,13 @@ def friendly_daemon_execution_error(exc: BaseException | str) -> str:
45
53
  return _FRIENDLY_DAEMON_CONNECTION_LOST
46
54
  if isinstance(exc, RuntimeError) and DAEMON_WORKER_SUBPROCESS_LOST in str(exc):
47
55
  return _FRIENDLY_WORKER_SUBPROCESS_LOST
56
+ if isinstance(exc, RuntimeError) and DAEMON_WORKER_THREAD_LOST in str(exc):
57
+ return _FRIENDLY_WORKER_THREAD_LOST
48
58
  text = str(exc)
49
59
  if DAEMON_WORKER_SUBPROCESS_LOST in text:
50
60
  return _FRIENDLY_WORKER_SUBPROCESS_LOST
61
+ if DAEMON_WORKER_THREAD_LOST in text:
62
+ return _FRIENDLY_WORKER_THREAD_LOST
51
63
  return text if isinstance(exc, str) else str(exc)
52
64
 
53
65
 
@@ -55,3 +67,9 @@ def is_daemon_worker_subprocess_lost(exc: BaseException | str) -> bool:
55
67
  """Return whether an error indicates a pool worker process exited mid-query."""
56
68
  text = str(exc)
57
69
  return DAEMON_WORKER_SUBPROCESS_LOST in text
70
+
71
+
72
+ def is_daemon_worker_thread_lost(exc: BaseException | str) -> bool:
73
+ """Return whether an error indicates a pool worker thread exited mid-query."""
74
+ text = str(exc)
75
+ return DAEMON_WORKER_THREAD_LOST in text
@@ -465,9 +465,20 @@ class EventProcessor:
465
465
  self._handle_custom_event(data, namespace)
466
466
 
467
467
  def _handle_error_event(self, event: dict[str, Any]) -> None:
468
- """Handle error events."""
469
- error = event.get("message", event.get("error", "Unknown error"))
470
- context = event.get("code")
468
+ """Handle error events.
469
+
470
+ Supports both legacy flat errors (``{type:'error', message:...}``) and
471
+ protocol-1 error envelopes (``{type:'error', error:{code, message, data}}``).
472
+ """
473
+ err_obj = event.get("error")
474
+ if isinstance(err_obj, dict):
475
+ # Protocol-1 error envelope.
476
+ error = str(err_obj.get("message", "Unknown error"))
477
+ context = err_obj.get("code")
478
+ else:
479
+ # Legacy flat error or bare string.
480
+ error = str(event.get("message", err_obj or "Unknown error"))
481
+ context = event.get("code")
471
482
  self._renderer.on_error(error, context=context)
472
483
 
473
484
  def _handle_messages(
@@ -8,6 +8,7 @@ config, no widget imports) so that `app.py` can import `SessionStats` and
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import time
11
12
  from dataclasses import dataclass, field
12
13
 
13
14
  SpinnerStatus = str | None
@@ -106,6 +107,52 @@ class SessionStats:
106
107
  self.event_stats.merge(other.event_stats)
107
108
 
108
109
 
110
+ @dataclass
111
+ class TurnLatencyStats:
112
+ """IG-534 Phase 3: end-to-end turn latency observability.
113
+
114
+ Attributes:
115
+ turn_start_monotonic: Monotonic timestamp when the turn began.
116
+ time_to_first_chunk_ms: Milliseconds from turn start to first applied chunk.
117
+ synthesis_visible_ms: Milliseconds from turn start to first goal_completion apply.
118
+ goal_completion_applied: Whether a goal_completion chunk was applied.
119
+ """
120
+
121
+ turn_start_monotonic: float = 0.0
122
+ time_to_first_chunk_ms: float | None = None
123
+ synthesis_visible_ms: float | None = None
124
+ goal_completion_applied: bool = False
125
+
126
+ def record_first_chunk(self) -> None:
127
+ """Record time-to-first-chunk once per turn."""
128
+ if self.time_to_first_chunk_ms is not None or self.turn_start_monotonic <= 0:
129
+ return
130
+ self.time_to_first_chunk_ms = (time.monotonic() - self.turn_start_monotonic) * 1000.0
131
+
132
+ def record_goal_completion(self) -> None:
133
+ """Record synthesis-visible latency once per turn."""
134
+ if self.goal_completion_applied or self.turn_start_monotonic <= 0:
135
+ return
136
+ self.goal_completion_applied = True
137
+ self.synthesis_visible_ms = (time.monotonic() - self.turn_start_monotonic) * 1000.0
138
+
139
+ def merge(self, other: TurnLatencyStats) -> None:
140
+ """Merge another stats object (keeps earliest first-chunk / synthesis times)."""
141
+ if other.time_to_first_chunk_ms is not None:
142
+ if (
143
+ self.time_to_first_chunk_ms is None
144
+ or other.time_to_first_chunk_ms < self.time_to_first_chunk_ms
145
+ ):
146
+ self.time_to_first_chunk_ms = other.time_to_first_chunk_ms
147
+ if other.synthesis_visible_ms is not None:
148
+ if (
149
+ self.synthesis_visible_ms is None
150
+ or other.synthesis_visible_ms < self.synthesis_visible_ms
151
+ ):
152
+ self.synthesis_visible_ms = other.synthesis_visible_ms
153
+ self.goal_completion_applied = self.goal_completion_applied or other.goal_completion_applied
154
+
155
+
109
156
  @dataclass
110
157
  class TurnEventStats:
111
158
  """Event counts accumulated over a single daemon turn.
@@ -139,6 +186,8 @@ class TurnEventStats:
139
186
  text_chunks: int = 0
140
187
  heartbeats_dropped: int = 0
141
188
  post_idle_drained: int = 0
189
+ inbound_dropped: int = 0
190
+ latency: TurnLatencyStats | None = None
142
191
 
143
192
  def record(
144
193
  self,
@@ -187,6 +236,11 @@ class TurnEventStats:
187
236
  self.heartbeats_dropped += other.heartbeats_dropped
188
237
  self.post_idle_drained += other.post_idle_drained
189
238
  self.filtered_early += other.filtered_early
239
+ self.inbound_dropped += other.inbound_dropped
240
+ if other.latency is not None:
241
+ if self.latency is None:
242
+ self.latency = TurnLatencyStats()
243
+ self.latency.merge(other.latency)
190
244
 
191
245
  def summary_line(self) -> str:
192
246
  """Return a one-line summary suitable for structured logging.
@@ -220,6 +274,12 @@ class TurnEventStats:
220
274
  detail_parts.append(f"{self.heartbeats_dropped} hb-drop")
221
275
  if self.post_idle_drained:
222
276
  detail_parts.append(f"{self.post_idle_drained} post-idle")
277
+ if self.inbound_dropped:
278
+ detail_parts.append(f"{self.inbound_dropped} inbound-drop")
279
+ if self.latency is not None and self.latency.time_to_first_chunk_ms is not None:
280
+ detail_parts.append(f"ttfc={self.latency.time_to_first_chunk_ms:.0f}ms")
281
+ if self.latency is not None and self.latency.synthesis_visible_ms is not None:
282
+ detail_parts.append(f"synth={self.latency.synthesis_visible_ms:.0f}ms")
223
283
  if detail_parts:
224
284
  parts.append("; ".join(detail_parts))
225
285