soothe-cli 0.7.0__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.7.0 → soothe_cli-0.7.1}/PKG-INFO +1 -1
  2. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/daemon.py +1 -1
  3. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/daemon_errors.py +18 -0
  4. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/session_stats.py +60 -0
  5. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/transport/session.py +30 -1
  6. soothe_cli-0.7.1/src/soothe_cli/runtime/turn/pipeline.py +309 -0
  7. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_app.py +8 -1
  8. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_execution.py +37 -0
  9. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_messages_mixin.py +20 -12
  10. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_model.py +23 -30
  11. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_startup.py +51 -26
  12. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_ui.py +26 -4
  13. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/app.tcss +6 -6
  14. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/model_config.py +70 -14
  15. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/textual_adapter.py +275 -30
  16. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/tool_display.py +54 -1
  17. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/context_viewer.py +1 -1
  18. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/loading.py +30 -8
  19. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_step.py +5 -4
  20. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +1 -0
  21. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/user.py +20 -12
  22. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/welcome.py +1 -15
  23. soothe_cli-0.7.0/src/soothe_cli/runtime/turn/pipeline.py +0 -166
  24. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/.gitignore +0 -0
  25. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/README.md +0 -0
  26. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/pyproject.toml +0 -0
  27. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/__init__.py +0 -0
  28. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/__init__.py +0 -0
  29. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/__init__.py +0 -0
  30. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  31. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  32. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  33. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  34. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  35. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/__init__.py +0 -0
  36. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/headless.py +0 -0
  37. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  38. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/launcher.py +0 -0
  39. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/main.py +0 -0
  40. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/config/__init__.py +0 -0
  41. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/config/cli_config.py +0 -0
  42. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/config/loader.py +0 -0
  43. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/config/logging_setup.py +0 -0
  44. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/__init__.py +0 -0
  45. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/headless/processor.py +0 -0
  46. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  47. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  48. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  49. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  50. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  51. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  52. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  53. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  54. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  55. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  56. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  57. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  58. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  59. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/step_router.py +0 -0
  60. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  61. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/transcript.py +0 -0
  62. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  63. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  64. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  65. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  66. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/messages.py +0 -0
  67. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/__init__.py +0 -0
  68. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/_cli_context.py +0 -0
  69. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/_env_vars.py +0 -0
  70. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/_version.py +0 -0
  71. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/__init__.py +0 -0
  72. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_commands.py +0 -0
  73. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_history.py +0 -0
  74. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_module_init.py +0 -0
  75. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/binding.py +0 -0
  76. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/command_registry.py +0 -0
  77. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/__init__.py +0 -0
  78. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/command_router.py +0 -0
  79. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  80. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  81. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/config.py +0 -0
  82. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/file_change_notify.py +0 -0
  83. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  84. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/hooks.py +0 -0
  85. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/input.py +0 -0
  86. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/media_utils.py +0 -0
  87. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/path_utils.py +0 -0
  88. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/preview_limits.py +0 -0
  89. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/project_utils.py +0 -0
  90. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/sessions.py +0 -0
  91. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/__init__.py +0 -0
  92. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/invocation.py +0 -0
  93. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/load.py +0 -0
  94. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/theme.py +0 -0
  95. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/tips.py +0 -0
  96. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/unicode_security.py +0 -0
  97. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/update_check.py +0 -0
  98. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  99. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/_links.py +0 -0
  100. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  101. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  102. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  103. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  104. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  105. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/diff.py +0 -0
  106. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/editor.py +0 -0
  107. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  108. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/history.py +0 -0
  109. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  110. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  111. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  112. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  113. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  114. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  115. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  116. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  117. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  118. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  119. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  120. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  121. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  122. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  123. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  124. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  125. {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/status.py +0 -0
  126. {soothe_cli-0.7.0 → 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.7.0
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
@@ -280,7 +280,7 @@ async def _run_headless_session_once(
280
280
  query_started = True
281
281
  elif (state == "idle" and query_started) or state == "stopped":
282
282
  loop_clock = asyncio.get_event_loop()
283
- drain_deadline = loop_clock.time() + 2.5
283
+ drain_deadline = loop_clock.time() + 30.0
284
284
  while loop_clock.time() < drain_deadline:
285
285
  try:
286
286
  nxt = await asyncio.wait_for(client.read_event(), timeout=0.25)
@@ -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
@@ -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
 
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
26
26
 
27
27
  # Match headless daemon client: brief read window after ``idle`` so stream events
28
28
  # that arrive slightly after status are not dropped (``cli/execution/daemon.py``).
29
- _POST_IDLE_DRAIN_DEADLINE_S = 2.5
29
+ _POST_IDLE_DRAIN_DEADLINE_S = 30.0
30
30
 
31
31
  # Align with ``bootstrap_loop_session`` daemon-ready wait (RFC-450 §8.2).
32
32
  _RPC_HANDSHAKE_TIMEOUT_S = 20.0
@@ -91,6 +91,7 @@ class TuiDaemonSession:
91
91
  self.turn_event_stats = TurnEventStats()
92
92
  self.last_turn_end_state: str | None = None
93
93
  self.last_turn_cancellation_seen: bool = False
94
+ self.last_turn_error_message: str | None = None
94
95
 
95
96
  @property
96
97
  def loop_id(self) -> str | None:
@@ -248,6 +249,14 @@ class TuiDaemonSession:
248
249
  """Ask the daemon to cancel the in-flight query (same wire path as ``/cancel``)."""
249
250
  await self._client.notify("slash_command", {"cmd": "/cancel"})
250
251
 
252
+ async def cancel_active_turn(self) -> None:
253
+ """Cancel the in-flight query on the active loop (IG-533 ordering contract).
254
+
255
+ Call before switching ``loop_id`` (e.g. ``/clear``) so synthesis on the
256
+ prior loop is torn down server-side instead of filtered client-side.
257
+ """
258
+ await self.cancel_remote_query()
259
+
251
260
  async def _drain_stream_events_after_idle(
252
261
  self,
253
262
  *,
@@ -316,6 +325,8 @@ class TuiDaemonSession:
316
325
  self.turn_event_stats = TurnEventStats()
317
326
  self.last_turn_end_state = None
318
327
  self.last_turn_cancellation_seen = False
328
+ self.last_turn_error_message = None
329
+ inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
319
330
  query_started = False
320
331
  expected_loop_id = self._loop_id
321
332
  self._streaming = True
@@ -431,8 +442,15 @@ class TuiDaemonSession:
431
442
  # Graph auto-resumes LangGraph interrupts server-side; keep consuming events.
432
443
  if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
433
444
  continue
445
+ except Exception as exc:
446
+ self.last_turn_error_message = str(exc)
447
+ raise
434
448
  finally:
435
449
  self._streaming = False
450
+ self.turn_event_stats.inbound_dropped = max(
451
+ 0,
452
+ getattr(self._client, "inbound_dropped", 0) - inbound_dropped_baseline,
453
+ )
436
454
 
437
455
  async def list_skills(self) -> list[dict[str, Any]]:
438
456
  """Return skill rows from the daemon catalog (no filesystem paths)."""
@@ -651,5 +669,16 @@ class TuiDaemonSession:
651
669
  return []
652
670
  return [m for m in raw if isinstance(m, dict)]
653
671
 
672
+ async def fetch_goal_completion_text(self, loop_id: str) -> str | None:
673
+ """Return the latest persisted ``goal_completion`` body for a loop, if any."""
674
+ rows = await self.fetch_conversation_log(loop_id, limit=200, include_events=False)
675
+ for row in reversed(rows):
676
+ if row.get("phase") != "goal_completion":
677
+ continue
678
+ text = row.get("text") or row.get("content") or ""
679
+ if isinstance(text, str) and text.strip():
680
+ return text.strip()
681
+ return None
682
+
654
683
 
655
684
  DaemonSession = TuiDaemonSession
@@ -0,0 +1,309 @@
1
+ """Decouple daemon stream ingestion from chunk processing and UI application.
2
+
3
+ Three stages run concurrently during a TUI turn:
4
+
5
+ 1. **Reader** (async, main event loop): pulls ``(namespace, mode, data)`` tuples from the
6
+ daemon and enqueues them.
7
+ 2. **Processor** (dedicated thread): runs CPU-heavy parsing / routing without blocking
8
+ Textual rendering.
9
+ 3. **Applier** (async, main event loop): consumes prepared plans and performs widget
10
+ updates.
11
+
12
+ The websocket client stays on the main asyncio loop; only synchronous work moves off it.
13
+ High-priority chunks (tool wire, loop step events) are applied before low-priority text.
14
+
15
+ IG-535 Optimization 2: Added batched apply mode to reduce DOM mutations per chunk.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import queue
23
+ import threading
24
+ import time
25
+ from collections.abc import AsyncIterator, Callable
26
+ from typing import Any, Generic, TypeVar
27
+
28
+ from soothe_sdk.ux.loop_stream import assistant_output_phase
29
+
30
+ from soothe_cli.runtime.state.session_stats import TurnLatencyStats
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ T = TypeVar("T")
35
+ _SENTINEL = object()
36
+
37
+ # Lower number = higher priority (matches asyncio.PriorityQueue ordering).
38
+ PRIORITY_HIGH = 0
39
+ PRIORITY_NORMAL = 1
40
+ PRIORITY_LOW = 2
41
+
42
+ # IG-535: Batching defaults for TUI apply path optimization
43
+ _DEFAULT_BATCH_SIZE = 10
44
+ _DEFAULT_BATCH_DELAY_MS = 50
45
+
46
+ # IG-535: Queue sizes tuned for 32 concurrent loops with dense streaming
47
+ # Inbound: daemon→TUI chunks; Outbound: processed→UI apply plans
48
+ _DEFAULT_INBOUND_MAXSIZE = 2048 # IG-535: Increased from 1024 for multi-loop throughput
49
+ _DEFAULT_OUTBOUND_MAXSIZE = 1024 # IG-535: Increased from 512 for batched apply backlog
50
+
51
+
52
+ class TurnApplyBatcher(Generic[T]):
53
+ """IG-535 Optimization 2: Accumulate prepared chunks for batched apply.
54
+
55
+ Reduces DOM mutations per chunk by batching up to N chunks before applying.
56
+ HIGH priority items trigger immediate flush for responsiveness.
57
+
58
+ Args:
59
+ max_batch_size: Maximum chunks per batch (default 10).
60
+ max_batch_delay_ms: Maximum delay before forced flush (default 50ms).
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ max_batch_size: int = _DEFAULT_BATCH_SIZE,
67
+ max_batch_delay_ms: int = _DEFAULT_BATCH_DELAY_MS,
68
+ ) -> None:
69
+ self._max_batch_size = max_batch_size
70
+ self._max_batch_delay_s = max_batch_delay_ms / 1000.0
71
+ self._pending: list[T] = []
72
+ self._high_priority_count: int = 0
73
+ self._last_flush_monotonic: float = time.monotonic()
74
+
75
+ def add(self, prepared: T) -> bool:
76
+ """Add chunk to batch. Returns True if batch should flush now.
77
+
78
+ Flush triggers:
79
+ 1. Batch size threshold reached
80
+ 2. HIGH priority item present (immediate for responsiveness)
81
+ 3. Time threshold elapsed
82
+ """
83
+ self._pending.append(prepared)
84
+ priority = getattr(prepared, "priority", PRIORITY_LOW)
85
+ if priority == PRIORITY_HIGH:
86
+ self._high_priority_count += 1
87
+
88
+ now = time.monotonic()
89
+ if len(self._pending) >= self._max_batch_size:
90
+ return True
91
+ if self._high_priority_count > 0:
92
+ return True
93
+ if now - self._last_flush_monotonic >= self._max_batch_delay_s:
94
+ return True
95
+ return False
96
+
97
+ def flush(self) -> list[T]:
98
+ """Return accumulated batch and reset state."""
99
+ batch = list(self._pending)
100
+ self._pending.clear()
101
+ self._high_priority_count = 0
102
+ self._last_flush_monotonic = time.monotonic()
103
+ return batch
104
+
105
+ def has_pending(self) -> bool:
106
+ """Return True if there are pending items."""
107
+ return bool(self._pending)
108
+
109
+ @property
110
+ def pending_count(self) -> int:
111
+ """Return number of pending items."""
112
+ return len(self._pending)
113
+
114
+
115
+ class TurnEventPipeline(Generic[T]):
116
+ """Bridge daemon chunk ingestion, background processing, and UI application."""
117
+
118
+ def __init__(
119
+ self,
120
+ loop: asyncio.AbstractEventLoop,
121
+ *,
122
+ inbound_maxsize: int = _DEFAULT_INBOUND_MAXSIZE,
123
+ outbound_maxsize: int = _DEFAULT_OUTBOUND_MAXSIZE,
124
+ ) -> None:
125
+ self._loop = loop
126
+ self._inbound: queue.Queue[Any] = queue.Queue(maxsize=inbound_maxsize)
127
+ # Thread-safe outbound bridge: processor puts without blocking the event loop.
128
+ self._outbound: queue.PriorityQueue[tuple[int, int, Any]] = queue.PriorityQueue(
129
+ maxsize=outbound_maxsize
130
+ )
131
+ self._outbound_seq = 0
132
+ self._stop = threading.Event()
133
+ self._thread: threading.Thread | None = None
134
+ self._processor_error: BaseException | None = None
135
+
136
+ async def feed_chunks(self, chunk_source: AsyncIterator[Any]) -> None:
137
+ """Read all chunks from *chunk_source* into the inbound queue."""
138
+ try:
139
+ async for chunk in chunk_source:
140
+ if self._stop.is_set():
141
+ break
142
+ await asyncio.to_thread(self._inbound.put, chunk)
143
+ finally:
144
+ await asyncio.to_thread(self._inbound.put, _SENTINEL)
145
+
146
+ def start_processor(
147
+ self,
148
+ process_fn: Callable[[Any], T],
149
+ ) -> None:
150
+ """Start the background processor thread.
151
+
152
+ Args:
153
+ process_fn: Synchronous function mapping one raw chunk to a prepared plan.
154
+ """
155
+
156
+ def _worker() -> None:
157
+ while not self._stop.is_set():
158
+ try:
159
+ item = self._inbound.get(timeout=0.25)
160
+ except queue.Empty:
161
+ continue
162
+ if item is _SENTINEL:
163
+ self._put_outbound(PRIORITY_LOW, _SENTINEL)
164
+ break
165
+ try:
166
+ prepared = process_fn(item)
167
+ except Exception as exc:
168
+ logger.exception("Turn chunk processor failed")
169
+ self._processor_error = exc
170
+ self._put_outbound(PRIORITY_LOW, _SENTINEL)
171
+ break
172
+ if prepared is None:
173
+ continue
174
+ priority = getattr(prepared, "priority", PRIORITY_LOW)
175
+ self._put_outbound(int(priority), prepared)
176
+
177
+ self._thread = threading.Thread(
178
+ target=_worker,
179
+ name="soothe-tui-turn-processor",
180
+ daemon=True,
181
+ )
182
+ self._thread.start()
183
+
184
+ def _put_outbound(self, priority: int, item: Any) -> None:
185
+ """Enqueue a prepared chunk from the processor thread (thread-safe, non-blocking)."""
186
+ seq = self._outbound_seq
187
+ self._outbound_seq += 1
188
+ try:
189
+ self._outbound.put_nowait((priority, seq, item))
190
+ except queue.Full:
191
+ logger.warning("Turn outbound queue full; dropping prepared chunk")
192
+
193
+ async def iter_prepared(self) -> AsyncIterator[T]:
194
+ """Yield prepared chunk plans until the stream ends."""
195
+ while True:
196
+ _priority, _seq, item = await asyncio.to_thread(self._outbound.get)
197
+ if item is _SENTINEL:
198
+ if self._processor_error is not None:
199
+ raise self._processor_error
200
+ break
201
+ yield item
202
+
203
+ def shutdown(self) -> None:
204
+ """Signal the processor thread to stop (best-effort)."""
205
+ self._stop.set()
206
+ if self._thread is not None and self._thread.is_alive():
207
+ self._thread.join(timeout=2.0)
208
+
209
+
210
+ def _record_apply_latency(prepared: Any, latency: TurnLatencyStats | None) -> None:
211
+ """IG-534 Phase 3: record first-chunk and synthesis-visible timings."""
212
+ if latency is None or getattr(prepared, "skip", False):
213
+ return
214
+ latency.record_first_chunk()
215
+ if getattr(prepared, "mode", None) == "messages":
216
+ message = getattr(prepared, "normalized_message", None)
217
+ if message is not None and assistant_output_phase(message) == "goal_completion":
218
+ latency.record_goal_completion()
219
+
220
+
221
+ async def run_turn_pipeline(
222
+ chunk_source: AsyncIterator[Any],
223
+ process_fn: Callable[[Any], T],
224
+ apply_fn: Callable[[T], Any],
225
+ *,
226
+ batch_size: int = _DEFAULT_BATCH_SIZE,
227
+ batch_delay_ms: int = _DEFAULT_BATCH_DELAY_MS,
228
+ batching_enabled: bool = True,
229
+ latency_stats: TurnLatencyStats | None = None,
230
+ ) -> None:
231
+ """Run reader, processor thread, and applier coroutine to completion.
232
+
233
+ IG-535: Added batched apply mode to reduce DOM mutations per chunk.
234
+
235
+ Args:
236
+ chunk_source: Async iterator of daemon stream chunks.
237
+ process_fn: Sync chunk processor (runs in background thread).
238
+ apply_fn: Async callable applied on the main loop for each prepared plan.
239
+ batch_size: Maximum chunks per batch when batching enabled (default 10).
240
+ batch_delay_ms: Maximum delay before forced flush (default 50ms).
241
+ batching_enabled: Enable batching for performance (default True; disable for debug).
242
+ latency_stats: Optional per-turn latency recorder (IG-534 Phase 3).
243
+ """
244
+ loop = asyncio.get_running_loop()
245
+ if latency_stats is not None and latency_stats.turn_start_monotonic <= 0:
246
+ latency_stats.turn_start_monotonic = time.monotonic()
247
+ pipeline: TurnEventPipeline[T] = TurnEventPipeline(loop)
248
+ pipeline.start_processor(process_fn)
249
+
250
+ async def _apply_with_latency(prepared: T) -> None:
251
+ _record_apply_latency(prepared, latency_stats)
252
+ await apply_fn(prepared)
253
+
254
+ if batching_enabled:
255
+ batcher: TurnApplyBatcher[T] = TurnApplyBatcher(
256
+ max_batch_size=batch_size,
257
+ max_batch_delay_ms=batch_delay_ms,
258
+ )
259
+
260
+ async def _apply_batched() -> None:
261
+ """Apply chunks in batches with single yield per batch."""
262
+ async for prepared in pipeline.iter_prepared():
263
+ if batcher.add(prepared):
264
+ batch = batcher.flush()
265
+ # Apply all items in batch
266
+ for p in batch:
267
+ await _apply_with_latency(p)
268
+ # Single yield to event loop per batch (IG-535)
269
+ await asyncio.sleep(0)
270
+
271
+ # Final flush for remaining items
272
+ if batcher.has_pending():
273
+ for p in batcher.flush():
274
+ await _apply_with_latency(p)
275
+
276
+ try:
277
+ await asyncio.gather(
278
+ pipeline.feed_chunks(chunk_source),
279
+ _apply_batched(),
280
+ )
281
+ finally:
282
+ pipeline.shutdown()
283
+ else:
284
+ # Original non-batched path (for debugging or high-frequency streams)
285
+ async def _apply_all() -> None:
286
+ async for prepared in pipeline.iter_prepared():
287
+ await _apply_with_latency(prepared)
288
+
289
+ try:
290
+ await asyncio.gather(
291
+ pipeline.feed_chunks(chunk_source),
292
+ _apply_all(),
293
+ )
294
+ finally:
295
+ pipeline.shutdown()
296
+
297
+
298
+ __all__ = [
299
+ "PRIORITY_HIGH",
300
+ "PRIORITY_LOW",
301
+ "PRIORITY_NORMAL",
302
+ "TurnApplyBatcher",
303
+ "TurnEventPipeline",
304
+ "run_turn_pipeline",
305
+ "_DEFAULT_BATCH_SIZE",
306
+ "_DEFAULT_BATCH_DELAY_MS",
307
+ "_DEFAULT_INBOUND_MAXSIZE",
308
+ "_DEFAULT_OUTBOUND_MAXSIZE",
309
+ ]
@@ -192,6 +192,14 @@ class SootheApp(
192
192
  self._daemon_skills_wire: list[dict[str, Any]] = []
193
193
  """Cached ``skills_list_response`` rows when the TUI uses ``TuiDaemonSession``."""
194
194
 
195
+ self._preloaded_model_data: (
196
+ tuple[list[tuple[str, str]], str | None, dict[str, dict[str, Any]]] | None
197
+ ) = None
198
+ """Cached ``/model`` selector data from startup ``models_list`` prewarm."""
199
+
200
+ self._wire_credential_map: dict[str, bool | None] | None = None
201
+ """Provider credential flags paired with ``_preloaded_model_data``."""
202
+
195
203
  self._connecting = True
196
204
 
197
205
  self._sandbox_type: str | None = None
@@ -360,7 +368,6 @@ class SootheApp(
360
368
  loop_id=self._lc_loop_id,
361
369
  mcp_tool_count=self._mcp_tool_count,
362
370
  workspace_path=self._cwd,
363
- connecting=self._connecting,
364
371
  id="welcome-banner",
365
372
  )
366
373
  yield Container(id="messages")
@@ -565,6 +565,13 @@ class _ExecutionMixin:
565
565
  AppMessage("Not connected to the daemon; cannot start a new loop.")
566
566
  )
567
567
  else:
568
+ if self._agent_running:
569
+ await self._interrupt_daemon_agent_turn()
570
+ worker = self._agent_worker
571
+ if worker is not None:
572
+ with suppress(Exception):
573
+ await asyncio.wait_for(worker.wait(), timeout=5.0)
574
+ await self._cleanup_agent_task()
568
575
  status_event = await self._daemon_session.new_loop()
569
576
  new_loop_id = (
570
577
  str(status_event.get("loop_id", "")) or self._session_state.reset_loop()
@@ -937,6 +944,7 @@ class _ExecutionMixin:
937
944
  if self._ui_adapter:
938
945
  self._ui_adapter.finalize_pending_tools_with_error(f"{error_title}: {display_err}")
939
946
  self._ui_adapter.finalize_pending_steps_with_error(f"{error_title}: {display_err}")
947
+ await self._try_recover_goal_completion_from_ledger()
940
948
  try:
941
949
  await self._mount_message(ErrorMessage(f"{error_title}. {display_err}"))
942
950
  except Exception:
@@ -990,6 +998,35 @@ class _ExecutionMixin:
990
998
  if not busy and self._pending_messages:
991
999
  await self._process_next_from_queue()
992
1000
 
1001
+ async def _try_recover_goal_completion_from_ledger(self) -> None:
1002
+ """Mount persisted goal_completion when the stream aborted before the report card."""
1003
+ adapter = self._ui_adapter
1004
+ session = self._daemon_session
1005
+ if adapter is None or session is None or self._session_state is None:
1006
+ return
1007
+ if getattr(adapter, "_goal_completion_mounted_this_turn", False):
1008
+ return
1009
+ loop_id = self._session_state.loop_id
1010
+ if not loop_id:
1011
+ return
1012
+ try:
1013
+ from soothe_cli.tui.widgets.messages.assistant import AssistantMessage
1014
+
1015
+ text = await session.fetch_goal_completion_text(loop_id)
1016
+ if not text:
1017
+ return
1018
+ msg = AssistantMessage(text, id=f"asst-recovered-{loop_id[:8]}")
1019
+ await self._mount_message(msg)
1020
+ await msg.write_initial_content()
1021
+ adapter._goal_completion_mounted_this_turn = True
1022
+ logger.info(
1023
+ "Recovered goal_completion from ledger for loop %s (%d chars)",
1024
+ loop_id[:8],
1025
+ len(text),
1026
+ )
1027
+ except Exception:
1028
+ logger.debug("goal_completion ledger recovery failed", exc_info=True)
1029
+
993
1030
  async def _cleanup_agent_task(self) -> None:
994
1031
  """Clean up after agent task completes or is cancelled."""
995
1032
  self._agent_running = False