soothe-cli 0.7.1__tar.gz → 0.7.2__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.1 → soothe_cli-0.7.2}/.gitignore +9 -3
  2. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/PKG-INFO +1 -1
  3. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/main.py +27 -0
  4. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/config/cli_config.py +3 -0
  5. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/policy/essential_events.py +3 -1
  6. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/transport/session.py +0 -8
  7. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/turn/pipeline.py +99 -4
  8. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/turn/prepare.py +16 -1
  9. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_app.py +3 -1
  10. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_execution.py +0 -1
  11. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_model.py +0 -9
  12. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_startup.py +89 -37
  13. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_ui.py +14 -1
  14. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/binding.py +0 -2
  15. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/commands/slash_commands.py +0 -7
  16. soothe_cli-0.7.2/src/soothe_cli/tui/markdown_theme.py +358 -0
  17. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/model_config.py +8 -10
  18. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/sessions.py +8 -0
  19. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/textual_adapter.py +29 -8
  20. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +3 -8
  21. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/loading.py +30 -7
  22. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/loop_selector.py +22 -131
  23. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -2
  24. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -42
  25. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/assistant.py +4 -5
  26. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +3 -10
  27. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/skill.py +2 -4
  28. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/README.md +0 -0
  29. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/pyproject.toml +0 -0
  30. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/__init__.py +0 -0
  31. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/__init__.py +0 -0
  32. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/__init__.py +0 -0
  33. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  34. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  35. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  36. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  37. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  38. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/__init__.py +0 -0
  39. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/daemon.py +0 -0
  40. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  41. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/headless.py +0 -0
  42. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  43. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/cli/execution/launcher.py +0 -0
  44. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/config/__init__.py +0 -0
  45. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/config/loader.py +0 -0
  46. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/config/logging_setup.py +0 -0
  47. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/__init__.py +0 -0
  48. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/headless/processor.py +0 -0
  49. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  50. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  51. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  52. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  53. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  54. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  55. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  56. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  57. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  58. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  59. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  60. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  61. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  62. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/step_router.py +0 -0
  63. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  64. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/state/transcript.py +0 -0
  65. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  66. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  67. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  68. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/runtime/wire/messages.py +0 -0
  69. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/__init__.py +0 -0
  70. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/_cli_context.py +0 -0
  71. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/_env_vars.py +0 -0
  72. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/_version.py +0 -0
  73. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/__init__.py +0 -0
  74. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_commands.py +0 -0
  75. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_history.py +0 -0
  76. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  77. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/_module_init.py +0 -0
  78. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/app/app.tcss +0 -0
  79. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/command_registry.py +0 -0
  80. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/commands/__init__.py +0 -0
  81. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/commands/command_router.py +0 -0
  82. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  83. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/config.py +0 -0
  84. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/file_change_notify.py +0 -0
  85. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  86. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/hooks.py +0 -0
  87. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/input.py +0 -0
  88. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/media_utils.py +0 -0
  89. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/path_utils.py +0 -0
  90. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/preview_limits.py +0 -0
  91. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/project_utils.py +0 -0
  92. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/skills/__init__.py +0 -0
  93. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/skills/invocation.py +0 -0
  94. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/skills/load.py +0 -0
  95. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/theme.py +0 -0
  96. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/tips.py +0 -0
  97. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/tool_display.py +0 -0
  98. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/unicode_security.py +0 -0
  99. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/update_check.py +0 -0
  100. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  101. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/_links.py +0 -0
  102. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  103. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  104. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  105. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  106. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  107. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/diff.py +0 -0
  108. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/editor.py +0 -0
  109. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  110. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/history.py +0 -0
  111. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  112. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  113. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  114. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  115. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  116. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
  117. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  118. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  119. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  120. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  121. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  122. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  123. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  124. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/status.py +0 -0
  125. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  126. {soothe_cli-0.7.1 → soothe_cli-0.7.2}/src/soothe_cli/tui/widgets/welcome.py +0 -0
@@ -191,6 +191,15 @@ cython_debug/
191
191
  # PyPI configuration file
192
192
  .pypirc
193
193
 
194
+ # Soothe local runtime data
195
+ .soothe/
196
+
197
+ # Tool backup files (created by file editing tools)
198
+ .backups/
199
+
200
+ # Large tool result cache
201
+ large_tool_results/
202
+
194
203
  # Cursor
195
204
  # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
196
205
  # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
@@ -206,7 +215,6 @@ __marimo__/
206
215
  .python-version
207
216
  thirdparty/
208
217
  .cursor/
209
- large_tool_results/
210
218
  plot_*
211
219
  .claude/
212
220
  .DS_Store
@@ -216,5 +224,3 @@ manifest.json
216
224
  _bmad
217
225
  __MACOSX
218
226
  .qoder
219
- .soothe
220
- .backups/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.7.1
3
+ Version: 0.7.2
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
@@ -15,6 +15,12 @@ from soothe_sdk.client.config import SOOTHE_HOME # noqa: E402
15
15
 
16
16
  from soothe_cli.config.cli_config import CLIConfig # noqa: E402
17
17
  from soothe_cli.config.loader import set_runtime_config # noqa: E402
18
+ from soothe_cli.tui.markdown_theme import ( # noqa: E402
19
+ DEFAULT_MARKDOWN_THEME,
20
+ REGISTRY,
21
+ load_markdown_theme_preference,
22
+ markdown_theme_help,
23
+ )
18
24
 
19
25
  app = typer.Typer(
20
26
  name="soothe",
@@ -101,6 +107,16 @@ def main(
101
107
  help="Render assistant messages as Markdown in the TUI.",
102
108
  ),
103
109
  ] = True,
110
+ markdown_theme: Annotated[
111
+ str | None,
112
+ typer.Option(
113
+ "--markdown-theme",
114
+ help=(
115
+ "Markdown appearance preset for TUI cards "
116
+ f"({markdown_theme_help()}). Default: {DEFAULT_MARKDOWN_THEME}."
117
+ ),
118
+ ),
119
+ ] = None,
104
120
  soothe_home: Annotated[
105
121
  str | None,
106
122
  typer.Option("--soothe-home", help="Soothe home directory (default: ~/.soothe)."),
@@ -168,11 +184,22 @@ def main(
168
184
  if mode is not None and mode not in ("manual", "auto"):
169
185
  typer.echo(f"Invalid --mode {mode!r}; expected 'manual' or 'auto'.", err=True)
170
186
  raise typer.Exit(code=2)
187
+ if markdown_theme is not None and markdown_theme not in REGISTRY:
188
+ typer.echo(
189
+ f"Invalid --markdown-theme {markdown_theme!r}; "
190
+ f"expected one of: {markdown_theme_help()}.",
191
+ err=True,
192
+ )
193
+ raise typer.Exit(code=2)
194
+ resolved_markdown_theme = (
195
+ markdown_theme if markdown_theme is not None else load_markdown_theme_preference()
196
+ )
171
197
  cli_cfg = CLIConfig(
172
198
  daemon_host=daemon_host,
173
199
  daemon_port=daemon_port,
174
200
  logging_level=log_level,
175
201
  render_markdown=render_markdown,
202
+ markdown_theme=resolved_markdown_theme,
176
203
  output_streaming_enabled=streaming,
177
204
  output_streaming_mode=streaming_mode,
178
205
  clarification_mode=mode,
@@ -29,6 +29,9 @@ class CLIConfig:
29
29
  render_markdown: bool = True
30
30
  """Render assistant messages as Markdown in TUI (default True)."""
31
31
 
32
+ markdown_theme: str = "match-app"
33
+ """Markdown appearance preset (``match-app``, ``langchain``, ``standard``, …)."""
34
+
32
35
  # Output streaming overrides (RFC-614)
33
36
  output_streaming_enabled: bool | None = None
34
37
  """Override daemon streaming enabled setting."""
@@ -24,12 +24,13 @@ STEP_COMPLETE_EVENT_TYPES: Final[frozenset[str]] = frozenset(
24
24
  )
25
25
 
26
26
  LOOP_REASON_EVENT_TYPE: Final[str] = "soothe.cognition.strange_loop.reasoned"
27
+ INTENT_CLASSIFIED_EVENT_TYPE: Final[str] = "soothe.cognition.intent.classified"
27
28
 
28
29
  ESSENTIAL_PROGRESS_EVENT_TYPES: Final[frozenset[str]] = frozenset(
29
30
  set(GOAL_START_EVENT_TYPES)
30
31
  | set(STEP_START_EVENT_TYPES)
31
32
  | set(STEP_COMPLETE_EVENT_TYPES)
32
- | {LOOP_REASON_EVENT_TYPE}
33
+ | {LOOP_REASON_EVENT_TYPE, INTENT_CLASSIFIED_EVENT_TYPE}
33
34
  )
34
35
 
35
36
 
@@ -56,6 +57,7 @@ def is_step_complete_event_type(event_type: str) -> bool:
56
57
  __all__ = [
57
58
  "ESSENTIAL_PROGRESS_EVENT_TYPES",
58
59
  "GOAL_START_EVENT_TYPES",
60
+ "INTENT_CLASSIFIED_EVENT_TYPE",
59
61
  "LOOP_REASON_EVENT_TYPE",
60
62
  "STEP_COMPLETE_EVENT_TYPES",
61
63
  "STEP_START_EVENT_TYPES",
@@ -81,7 +81,6 @@ class TuiDaemonSession:
81
81
  self._client = WebSocketClient(url=ws_url)
82
82
  self._rpc_client = WebSocketClient(url=ws_url)
83
83
  self._loop_id: str | None = None
84
- self._autopilot_mode: str | None = None
85
84
  self._read_lock = asyncio.Lock()
86
85
  self._rpc_lock = asyncio.Lock()
87
86
  self._rpc_connected = False
@@ -98,11 +97,6 @@ class TuiDaemonSession:
98
97
  """Active StrangeLoop id for this WebSocket session."""
99
98
  return self._loop_id
100
99
 
101
- @property
102
- def autopilot_mode(self) -> str | None:
103
- """Active loop Solo/Autopilot mode when known."""
104
- return self._autopilot_mode
105
-
106
100
  async def connect(self, *, resume_loop_id: str | None = None) -> dict[str, Any]:
107
101
  """Connect and bootstrap a daemon loop session."""
108
102
  await connect_websocket_with_retries(self._client)
@@ -123,8 +117,6 @@ class TuiDaemonSession:
123
117
  if status_event.get("type") == "error":
124
118
  raise RuntimeError(str(status_event.get("message", "daemon bootstrap failed")))
125
119
  self._loop_id = status_event.get("loop_id")
126
- mode = status_event.get("autopilot_mode")
127
- self._autopilot_mode = str(mode) if mode in ("solo", "autopilot") else None
128
120
  return status_event
129
121
 
130
122
  def _resolve_stream_delivery_mode(self) -> str:
@@ -35,6 +35,7 @@ T = TypeVar("T")
35
35
  _SENTINEL = object()
36
36
 
37
37
  # Lower number = higher priority (matches asyncio.PriorityQueue ordering).
38
+ PRIORITY_CRITICAL = -1 # step lifecycle / plan progress — never evict from queue
38
39
  PRIORITY_HIGH = 0
39
40
  PRIORITY_NORMAL = 1
40
41
  PRIORITY_LOW = 2
@@ -82,7 +83,7 @@ class TurnApplyBatcher(Generic[T]):
82
83
  """
83
84
  self._pending.append(prepared)
84
85
  priority = getattr(prepared, "priority", PRIORITY_LOW)
85
- if priority == PRIORITY_HIGH:
86
+ if priority <= PRIORITY_HIGH:
86
87
  self._high_priority_count += 1
87
88
 
88
89
  now = time.monotonic()
@@ -129,6 +130,7 @@ class TurnEventPipeline(Generic[T]):
129
130
  maxsize=outbound_maxsize
130
131
  )
131
132
  self._outbound_seq = 0
133
+ self._outbound_dropped = 0
132
134
  self._stop = threading.Event()
133
135
  self._thread: threading.Thread | None = None
134
136
  self._processor_error: BaseException | None = None
@@ -182,13 +184,105 @@ class TurnEventPipeline(Generic[T]):
182
184
  self._thread.start()
183
185
 
184
186
  def _put_outbound(self, priority: int, item: Any) -> None:
185
- """Enqueue a prepared chunk from the processor thread (thread-safe, non-blocking)."""
187
+ """Enqueue a prepared chunk from the processor thread (thread-safe).
188
+
189
+ When the outbound queue is full, evict the lowest-priority buffered chunk
190
+ (streaming text) instead of dropping the incoming chunk. Step lifecycle
191
+ events use ``PRIORITY_CRITICAL`` and are never evicted.
192
+ """
186
193
  seq = self._outbound_seq
187
194
  self._outbound_seq += 1
195
+ entry = (priority, seq, item)
196
+ try:
197
+ self._outbound.put_nowait(entry)
198
+ return
199
+ except queue.Full:
200
+ pass
201
+
202
+ if not self._evict_outbound_drop_candidate(incoming_priority=priority):
203
+ if priority <= PRIORITY_HIGH:
204
+ # Block briefly so the applier can drain HIGH/CRITICAL backlog.
205
+ try:
206
+ self._outbound.put(entry, block=True, timeout=5.0)
207
+ return
208
+ except queue.Full:
209
+ logger.warning(
210
+ "Turn outbound queue still full after wait; dropping prepared chunk "
211
+ "(priority=%d)",
212
+ priority,
213
+ )
214
+ self._outbound_dropped += 1
215
+ return
216
+ logger.warning(
217
+ "Turn outbound queue full; dropping low-priority prepared chunk (priority=%d)",
218
+ priority,
219
+ )
220
+ self._outbound_dropped += 1
221
+ return
222
+
188
223
  try:
189
- self._outbound.put_nowait((priority, seq, item))
224
+ self._outbound.put_nowait(entry)
190
225
  except queue.Full:
191
- logger.warning("Turn outbound queue full; dropping prepared chunk")
226
+ if priority <= PRIORITY_HIGH:
227
+ try:
228
+ self._outbound.put(entry, block=True, timeout=5.0)
229
+ except queue.Full:
230
+ logger.warning(
231
+ "Turn outbound queue full after eviction; dropping prepared chunk "
232
+ "(priority=%d)",
233
+ priority,
234
+ )
235
+ self._outbound_dropped += 1
236
+ else:
237
+ logger.warning(
238
+ "Turn outbound queue full after eviction; dropping prepared chunk "
239
+ "(priority=%d)",
240
+ priority,
241
+ )
242
+ self._outbound_dropped += 1
243
+
244
+ def _evict_outbound_drop_candidate(self, *, incoming_priority: int) -> bool:
245
+ """Drop one evictable chunk from the outbound queue to make room.
246
+
247
+ Returns:
248
+ True when a chunk was evicted and the caller should retry ``put_nowait``.
249
+ """
250
+ temp: list[tuple[int, int, Any]] = []
251
+ drop_target: tuple[int, int, Any] | None = None
252
+ drop_priority = PRIORITY_CRITICAL - 1
253
+
254
+ while True:
255
+ try:
256
+ temp.append(self._outbound.get_nowait())
257
+ except queue.Empty:
258
+ break
259
+
260
+ for queued in temp:
261
+ queued_priority = queued[0]
262
+ if queued_priority > drop_priority:
263
+ drop_priority = queued_priority
264
+ drop_target = queued
265
+
266
+ # Never evict CRITICAL/HIGH; only drop NORMAL/LOW when admitting progress events.
267
+ min_evictable = PRIORITY_NORMAL if incoming_priority <= PRIORITY_HIGH else PRIORITY_LOW
268
+ if drop_target is None or drop_priority < min_evictable:
269
+ for queued in temp:
270
+ self._outbound.put_nowait(queued)
271
+ return False
272
+
273
+ self._outbound_dropped += 1
274
+ if self._outbound_dropped == 1 or self._outbound_dropped % 500 == 0:
275
+ logger.warning(
276
+ "Turn outbound queue overflow: evicted buffered chunk (priority=%d, dropped=%d)",
277
+ drop_priority,
278
+ self._outbound_dropped,
279
+ )
280
+
281
+ for queued in temp:
282
+ if queued is drop_target:
283
+ continue
284
+ self._outbound.put_nowait(queued)
285
+ return True
192
286
 
193
287
  async def iter_prepared(self) -> AsyncIterator[T]:
194
288
  """Yield prepared chunk plans until the stream ends."""
@@ -296,6 +390,7 @@ async def run_turn_pipeline(
296
390
 
297
391
 
298
392
  __all__ = [
393
+ "PRIORITY_CRITICAL",
299
394
  "PRIORITY_HIGH",
300
395
  "PRIORITY_LOW",
301
396
  "PRIORITY_NORMAL",
@@ -24,7 +24,12 @@ from soothe_sdk.ux.stream_tool_wire import STREAM_TOOL_CALL_UPDATE, TOOL_CALL_UP
24
24
 
25
25
  from soothe_cli.runtime.presentation.engine import PresentationEngine
26
26
  from soothe_cli.runtime.state.session_stats import TurnEventStats
27
- from soothe_cli.runtime.turn.pipeline import PRIORITY_HIGH, PRIORITY_LOW, PRIORITY_NORMAL
27
+ from soothe_cli.runtime.turn.pipeline import (
28
+ PRIORITY_CRITICAL,
29
+ PRIORITY_HIGH,
30
+ PRIORITY_LOW,
31
+ PRIORITY_NORMAL,
32
+ )
28
33
  from soothe_cli.runtime.wire.chunk_filter import (
29
34
  message_has_tool_invocation_metadata,
30
35
  updates_chunk_is_noop,
@@ -154,6 +159,16 @@ def _prepare_custom_chunk(
154
159
  prepared.priority = PRIORITY_HIGH
155
160
  return prepared
156
161
 
162
+ if event_type in (
163
+ STRANGE_LOOP_STEP_STARTED,
164
+ STRANGE_LOOP_STEP_QUEUED,
165
+ STRANGE_LOOP_STEP_COMPLETED,
166
+ STRANGE_LOOP_PLAN_DECISION,
167
+ ):
168
+ prepared.priority = PRIORITY_CRITICAL
169
+ prepared.skip_custom_progress = True
170
+ return prepared
171
+
157
172
  if event_type in _MAIN_LOOP_CUSTOM_TYPES or event_type == TOOL_CALL_UPDATES_BATCH:
158
173
  prepared.priority = PRIORITY_HIGH
159
174
  prepared.skip_custom_progress = True
@@ -173,7 +173,6 @@ class SootheApp(
173
173
  # Active StrangeLoop id; LangGraph stores it as configurable.thread_id.
174
174
  # Named `_lc_loop_id` to avoid colliding with Textual's App._thread_id.
175
175
  self._lc_loop_id = resume_loop_id
176
- self._loop_autopilot_mode: str = "solo"
177
176
 
178
177
  self._initial_prompt = initial_prompt
179
178
 
@@ -244,6 +243,9 @@ class SootheApp(
244
243
 
245
244
  self._loading_widget: LoadingWidget | None = None
246
245
 
246
+ self._connect_spinner_start_mono: float | None = None
247
+ """Monotonic anchor for startup daemon-connect elapsed time in the thinking row."""
248
+
247
249
  self._context_tokens: int = 0
248
250
  """Local cache of the last total-context token count.
249
251
 
@@ -578,7 +578,6 @@ class _ExecutionMixin:
578
578
  )
579
579
  self._session_state.loop_id = new_loop_id
580
580
  self._lc_loop_id = new_loop_id
581
- self._apply_loop_autopilot_mode(status_event.get("autopilot_mode"))
582
581
  try:
583
582
  banner = self.query_one("#welcome-banner", WelcomeBanner)
584
583
  banner.update_loop_id(new_loop_id)
@@ -259,15 +259,6 @@ class _ModelMixin:
259
259
  return None
260
260
  return resp.get("servers")
261
261
 
262
- def _apply_loop_autopilot_mode(self, mode: str | None) -> None:
263
- """Sync local Solo/Autopilot mode from daemon bootstrap or toggle events."""
264
- if mode not in ("solo", "autopilot"):
265
- return
266
- self._loop_autopilot_mode = mode
267
- if self._status_bar is not None:
268
- label = "Autopilot" if mode == "autopilot" else "Solo"
269
- self._status_bar.set_session_tip(f"Mode: {label}")
270
-
271
262
  async def _show_context_viewer(self) -> None:
272
263
  """Show context engine goal DAG and status as a modal screen."""
273
264
  from soothe_cli.tui.widgets.context_viewer import ContextViewerScreen
@@ -35,6 +35,11 @@ from soothe_cli.tui.widgets.welcome import WelcomeBanner
35
35
 
36
36
  logger = logging.getLogger(__name__)
37
37
 
38
+ # Cold-start: daemon may bind WebSocket ~30s after PID file (CoreAgent pool warmup).
39
+ _DAEMON_READY_TIMEOUT_S = 60.0
40
+ _DAEMON_CONNECT_MAX_ATTEMPTS = 3
41
+ _DAEMON_CONNECT_RETRY_DELAY_S = 2.0
42
+
38
43
 
39
44
  class _StartupMixin:
40
45
  """Startup, server/daemon workers, skills discovery, prewarm and update methods."""
@@ -405,51 +410,105 @@ class _StartupMixin:
405
410
 
406
411
  return await discover_skills_async(daemon_config=self._daemon_config)
407
412
 
413
+ @staticmethod
414
+ def _daemon_connect_attempt_label(*, phase: str, attempt: int, max_attempts: int) -> str:
415
+ """Format a daemon-connect spinner label with attempt progress.
416
+
417
+ The first try shows only ``phase``; attempt suffixes appear after a
418
+ timeout or failure triggers a retry.
419
+ """
420
+ if max_attempts <= 1 or attempt <= 1:
421
+ return phase
422
+ return f"{phase} (attempt {attempt}/{max_attempts})"
423
+
408
424
  async def _connect_daemon_background(self) -> None:
409
425
  """Background worker: connect the TUI directly to the daemon."""
410
426
  from soothe_cli.tui.textual_adapter import (
411
- SPINNER_LABEL_CONNECTING_DAEMON,
412
427
  SPINNER_LABEL_WAITING_AGENT_READY,
413
428
  )
414
429
 
415
- await self._set_spinner(SPINNER_LABEL_WAITING_AGENT_READY, show_interrupt_hint=False)
416
- try:
417
- from soothe_sdk.client import (
418
- is_daemon_live,
419
- websocket_url_from_config,
430
+ for attempt in range(1, _DAEMON_CONNECT_MAX_ATTEMPTS + 1):
431
+ waiting_label = self._daemon_connect_attempt_label(
432
+ phase=SPINNER_LABEL_WAITING_AGENT_READY,
433
+ attempt=attempt,
434
+ max_attempts=_DAEMON_CONNECT_MAX_ATTEMPTS,
420
435
  )
436
+ await self._set_spinner(waiting_label, show_interrupt_hint=False)
437
+ try:
438
+ session, status_event = await self._connect_daemon_once(attempt=attempt)
439
+ except Exception as exc:
440
+ if attempt < _DAEMON_CONNECT_MAX_ATTEMPTS:
441
+ logger.warning(
442
+ "Daemon connect attempt %d/%d failed: %s",
443
+ attempt,
444
+ _DAEMON_CONNECT_MAX_ATTEMPTS,
445
+ exc,
446
+ )
447
+ await asyncio.sleep(_DAEMON_CONNECT_RETRY_DELAY_S)
448
+ continue
449
+ self.post_message(self.ServerStartFailed(error=exc))
450
+ return
421
451
 
422
- from soothe_cli.runtime.transport.session import TuiDaemonSession
452
+ self.post_message(self.DaemonReady(session=session, status_event=status_event))
453
+ return
423
454
 
424
- ws_url = websocket_url_from_config(self._daemon_config)
455
+ async def _connect_daemon_once(
456
+ self,
457
+ *,
458
+ attempt: int,
459
+ ) -> tuple[Any, dict[str, Any]]:
460
+ """Wait for daemon readiness and bootstrap one TUI session.
425
461
 
426
- # Check daemon status via WebSocket RPC (IG-174 Phase 1)
427
- # Wait for daemon to be fully ready, not just port-live (IG-489)
428
- daemon_live = await is_daemon_live(
429
- ws_url, timeout=5.0, wait_for_ready=True, ready_timeout=30.0
430
- )
462
+ Args:
463
+ attempt: Current connect attempt (1-based).
431
464
 
432
- if not daemon_live:
433
- # CLI does NOT control daemon start/stop per architectural separation (IG-174/IG-175)
434
- # Show helpful error message instead
435
- raise ConnectionError(
436
- f"Soothe daemon not running at {ws_url}. "
437
- f"Please start the daemon with: soothed start"
438
- )
465
+ Returns:
466
+ Tuple of ``(TuiDaemonSession, status_event)``.
439
467
 
440
- await self._set_spinner(SPINNER_LABEL_CONNECTING_DAEMON, show_interrupt_hint=False)
468
+ Raises:
469
+ ConnectionError: If the daemon is not reachable or ready in time.
470
+ Exception: If loop bootstrap fails.
471
+ """
472
+ from soothe_sdk.client import (
473
+ is_daemon_live,
474
+ websocket_url_from_config,
475
+ )
476
+
477
+ from soothe_cli.runtime.transport.session import TuiDaemonSession
478
+ from soothe_cli.tui.textual_adapter import SPINNER_LABEL_CONNECTING_DAEMON
479
+
480
+ ws_url = websocket_url_from_config(self._daemon_config)
441
481
 
442
- session = TuiDaemonSession(
443
- self._daemon_config,
444
- workspace=self._cwd,
445
- post_idle_drain_deadline=0.3,
482
+ # Check daemon status via WebSocket RPC (IG-174 Phase 1)
483
+ # Wait for daemon to be fully ready, not just port-live (IG-489)
484
+ daemon_live = await is_daemon_live(
485
+ ws_url,
486
+ timeout=5.0,
487
+ wait_for_ready=True,
488
+ ready_timeout=_DAEMON_READY_TIMEOUT_S,
489
+ )
490
+
491
+ if not daemon_live:
492
+ # CLI does NOT control daemon start/stop per architectural separation (IG-174/IG-175)
493
+ raise ConnectionError(
494
+ f"Soothe daemon not running at {ws_url}. "
495
+ f"Please start the daemon with: soothed start"
446
496
  )
447
- status_event = await session.connect(resume_loop_id=self._lc_loop_id)
448
- except Exception as exc:
449
- self.post_message(self.ServerStartFailed(error=exc))
450
- return
451
497
 
452
- self.post_message(self.DaemonReady(session=session, status_event=status_event))
498
+ connecting_label = self._daemon_connect_attempt_label(
499
+ phase=SPINNER_LABEL_CONNECTING_DAEMON,
500
+ attempt=attempt,
501
+ max_attempts=_DAEMON_CONNECT_MAX_ATTEMPTS,
502
+ )
503
+ await self._set_spinner(connecting_label, show_interrupt_hint=False)
504
+
505
+ session = TuiDaemonSession(
506
+ self._daemon_config,
507
+ workspace=self._cwd,
508
+ post_idle_drain_deadline=0.3,
509
+ )
510
+ status_event = await session.connect(resume_loop_id=self._lc_loop_id)
511
+ return session, status_event
453
512
 
454
513
  def on_soothe_app_daemon_ready(self, event: DaemonReady) -> None:
455
514
  """Handle successful daemon bootstrap for the TUI."""
@@ -463,13 +522,6 @@ class _StartupMixin:
463
522
  if self._session_state is not None:
464
523
  self._session_state.loop_id = status_loop_id
465
524
 
466
- autopilot_mode = event.status_event.get("autopilot_mode")
467
- if autopilot_mode not in ("solo", "autopilot") and event.session is not None:
468
- autopilot_mode = getattr(event.session, "autopilot_mode", None)
469
- self._apply_loop_autopilot_mode(
470
- str(autopilot_mode) if autopilot_mode in ("solo", "autopilot") else None
471
- )
472
-
473
525
  try:
474
526
  banner = self.query_one("#welcome-banner", WelcomeBanner)
475
527
  banner.set_connected(self._mcp_tool_count)
@@ -313,13 +313,20 @@ class _UIMixin:
313
313
  if self._loading_widget:
314
314
  await self._loading_widget.remove()
315
315
  self._loading_widget = None
316
+ self._connect_spinner_start_mono = None
316
317
  return
317
318
 
318
319
  thinking_status = self.query_one("#thinking-status", Container)
319
320
 
321
+ connect_start_mono: float | None = None
322
+ if not self._agent_running:
323
+ if self._connect_spinner_start_mono is None:
324
+ self._connect_spinner_start_mono = _monotonic()
325
+ connect_start_mono = self._connect_spinner_start_mono
326
+
320
327
  if self._loading_widget is None:
321
328
  # Create new
322
- turn_mono = self._inflight_turn_start if self._agent_running else None
329
+ turn_mono = self._inflight_turn_start if self._agent_running else connect_start_mono
323
330
  self._loading_widget = LoadingWidget(
324
331
  status,
325
332
  turn_start_mono=turn_mono,
@@ -329,6 +336,12 @@ class _UIMixin:
329
336
  else:
330
337
  if self._agent_running:
331
338
  self._loading_widget.set_turn_start_mono(self._inflight_turn_start)
339
+ elif connect_start_mono is not None:
340
+ self._loading_widget.set_turn_start_mono(connect_start_mono)
341
+ if self._loading_widget.parent is not thinking_status:
342
+ if self._loading_widget.is_attached:
343
+ await self._loading_widget.remove()
344
+ await thinking_status.mount(self._loading_widget)
332
345
  # Update existing (also clears a clarification pause)
333
346
  self._loading_widget.activate_status(status, show_interrupt_hint=show_interrupt_hint)
334
347
  # NOTE: Don't call anchor() here - it would re-anchor and drag user back
@@ -111,7 +111,6 @@ def message_to_widget(data: MessageData) -> Widget:
111
111
 
112
112
  case MessageType.COGNITION_REASON:
113
113
  return CognitionReasonMessage(
114
- next_action=data.cognition_plan_next_action or "",
115
114
  status=data.cognition_plan_status or "",
116
115
  iteration=int(data.cognition_plan_iteration or 0),
117
116
  plan_action=data.cognition_plan_action or "",
@@ -198,7 +197,6 @@ def message_from_widget(widget: Widget) -> MessageData:
198
197
  type=MessageType.COGNITION_REASON,
199
198
  content="",
200
199
  id=widget_id,
201
- cognition_plan_next_action=widget._next_action,
202
200
  cognition_plan_status=widget._status,
203
201
  cognition_plan_iteration=widget._iteration,
204
202
  cognition_plan_action=widget._plan_action,
@@ -240,13 +240,6 @@ COMMANDS: dict[str, dict[str, Any]] = {
240
240
  "description": "Submit autopilot job (usage: /autopilot <task>)",
241
241
  "requires_query": True,
242
242
  },
243
- "/autopilot-toggle": {
244
- "location": "daemon",
245
- "type": "rpc",
246
- "daemon_command": "autopilot_toggle",
247
- "description": "Toggle autopilot mode (solo ↔ autopilot)",
248
- "requires_loop": True,
249
- },
250
243
  # Daemon routing commands (3)
251
244
  "/plan": {"location": "daemon", "type": "routing", "description": "Trigger plan mode"},
252
245
  "/tacitus": {