soothe-cli 0.5.6__tar.gz → 0.5.8__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 (139) hide show
  1. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/PKG-INFO +1 -1
  2. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/execution/daemon.py +69 -28
  3. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/stream/formatter.py +1 -1
  4. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/stream/pipeline.py +4 -8
  5. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/config/cli_config.py +11 -0
  6. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/commands/command_router.py +6 -6
  7. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/commands/slash_commands.py +2 -14
  8. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/commands/subagent_routing.py +13 -12
  9. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/core/event_processor.py +13 -6
  10. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/core/presentation_engine.py +1 -1
  11. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/core/processor_state.py +8 -2
  12. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/core/renderer_protocol.py +1 -1
  13. soothe_cli-0.5.8/src/soothe_cli/shared/daemon_errors.py +27 -0
  14. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/rendering/async_renderer_protocol.py +1 -1
  15. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/__init__.py +0 -2
  16. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/message_processing.py +224 -8
  17. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/rendering.py +4 -1
  18. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_call_resolution.py +169 -37
  19. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_card_payload.py +8 -4
  20. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/_env_vars.py +7 -0
  21. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_app.py +7 -1
  22. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_commands.py +3 -3
  23. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_execution.py +10 -6
  24. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_messages_mixin.py +28 -17
  25. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_module_init.py +2 -2
  26. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_ui.py +1 -1
  27. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/command_registry.py +2 -13
  28. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/config.py +1 -1
  29. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/message_display_filter.py +4 -7
  30. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/preview_limits.py +3 -4
  31. soothe_cli-0.5.8/src/soothe_cli/tui/step_task_routing.py +401 -0
  32. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/textual_adapter/__init__.py +0 -8
  33. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/textual_adapter/_adapter.py +6 -10
  34. soothe_cli-0.5.8/src/soothe_cli/tui/textual_adapter/_stream_formatting.py +1000 -0
  35. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/textual_adapter/_stream_messages.py +8 -42
  36. soothe_cli-0.5.8/src/soothe_cli/tui/textual_adapter/_stream_tool_wire.py +167 -0
  37. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/textual_adapter/_turn.py +592 -222
  38. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/textual_adapter/_turn_helpers.py +44 -14
  39. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/tool_display.py +42 -3
  40. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/clipboard.py +64 -25
  41. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/message_store.py +1 -1
  42. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/messages.py +356 -175
  43. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/status.py +3 -3
  44. soothe_cli-0.5.6/src/soothe_cli/tui/textual_adapter/_stream_formatting.py +0 -285
  45. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/.gitignore +0 -0
  46. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/README.md +0 -0
  47. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/pyproject.toml +0 -0
  48. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/__init__.py +0 -0
  49. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/__init__.py +0 -0
  50. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/commands/__init__.py +0 -0
  51. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  52. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  53. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  54. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/execution/__init__.py +0 -0
  55. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/execution/headless.py +0 -0
  56. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  57. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/execution/launcher.py +0 -0
  58. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/main.py +0 -0
  59. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/stream/__init__.py +0 -0
  60. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/stream/context.py +0 -0
  61. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/stream/display_line.py +0 -0
  62. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/cli/stream/task_scope.py +0 -0
  63. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/config/__init__.py +0 -0
  64. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/plan/__init__.py +0 -0
  65. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/plan/rich_tree.py +0 -0
  66. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/__init__.py +0 -0
  67. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/commands/__init__.py +0 -0
  68. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/config_loader.py +0 -0
  69. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/core/__init__.py +0 -0
  70. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/duration_format.py +0 -0
  71. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/events/__init__.py +0 -0
  72. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/events/display_policy.py +0 -0
  73. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/events/essential_events.py +0 -0
  74. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/events/explore_task_display.py +0 -0
  75. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/events/stream_accumulator.py +0 -0
  76. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/events/tui_trace_log.py +0 -0
  77. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/rendering/__init__.py +0 -0
  78. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/rendering/renderer_base.py +0 -0
  79. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/_utils.py +0 -0
  80. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_card_visibility.py +0 -0
  81. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/__init__.py +0 -0
  82. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/base.py +0 -0
  83. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/execution.py +0 -0
  84. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/fallback.py +0 -0
  85. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/file_ops.py +0 -0
  86. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/goal_formatter.py +0 -0
  87. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/media.py +0 -0
  88. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/structured.py +0 -0
  89. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/subagent.py +0 -0
  90. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_formatters/web.py +0 -0
  91. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_message_format.py +0 -0
  92. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/shared/tools/tool_output_formatter.py +0 -0
  93. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/__init__.py +0 -0
  94. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/_ask_user_types.py +0 -0
  95. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/_cli_context.py +0 -0
  96. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/_session_stats.py +0 -0
  97. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/_version.py +0 -0
  98. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/__init__.py +0 -0
  99. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_history.py +0 -0
  100. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_model.py +0 -0
  101. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/_startup.py +0 -0
  102. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/app/app.tcss +0 -0
  103. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/daemon_session.py +0 -0
  104. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/file_ops.py +0 -0
  105. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/formatting.py +0 -0
  106. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/hooks.py +0 -0
  107. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/input.py +0 -0
  108. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/media_utils.py +0 -0
  109. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/model_config.py +0 -0
  110. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/output.py +0 -0
  111. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/project_utils.py +0 -0
  112. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/sessions.py +0 -0
  113. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/skills/__init__.py +0 -0
  114. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/skills/invocation.py +0 -0
  115. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/skills/load.py +0 -0
  116. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/theme.py +0 -0
  117. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/unicode_security.py +0 -0
  118. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/update_check.py +0 -0
  119. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  120. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/_links.py +0 -0
  121. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/approval.py +0 -0
  122. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/ask_user.py +0 -0
  123. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  124. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  125. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  126. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  127. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/diff.py +0 -0
  128. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/editor.py +0 -0
  129. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/history.py +0 -0
  130. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/loading.py +0 -0
  131. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  132. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  133. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  134. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  135. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  136. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/tool_renderers.py +0 -0
  137. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/tool_widgets.py +0 -0
  138. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/tools.py +0 -0
  139. {soothe_cli-0.5.6 → soothe_cli-0.5.8}/src/soothe_cli/tui/widgets/welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.5.6
3
+ Version: 0.5.8
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/OpenSoothe/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -22,12 +22,17 @@ from soothe_cli.cli.execution.headless_renderer import HeadlessCliRenderer
22
22
  from soothe_cli.shared import EventProcessor
23
23
  from soothe_cli.shared.commands.subagent_routing import parse_subagent_from_input
24
24
  from soothe_cli.shared.core.presentation_engine import PresentationEngine
25
+ from soothe_cli.shared.daemon_errors import (
26
+ friendly_daemon_execution_error,
27
+ is_daemon_worker_subprocess_lost,
28
+ )
25
29
 
26
30
  logger = logging.getLogger(__name__)
27
31
 
28
32
  _DAEMON_FALLBACK_EXIT_CODE = 42
29
33
  _SESSION_BOOTSTRAP_TIMEOUT_S = 30.0
30
34
  _QUERY_START_TIMEOUT_S = 20.0
35
+ _HEADLESS_WORKER_LOST_RETRIES = 1
31
36
 
32
37
 
33
38
  def _is_loop_scoped_event(event: dict[str, Any], *, active_loop_id: str) -> bool:
@@ -38,19 +43,20 @@ def _is_loop_scoped_event(event: dict[str, Any], *, active_loop_id: str) -> bool
38
43
  return event.get("loop_id") == active_loop_id
39
44
 
40
45
 
41
- async def run_headless_via_daemon(
46
+ def _emit_headless_error(message: str) -> None:
47
+ """Write a user-facing error line to stderr (headless renderer convention)."""
48
+ typer.echo(f"ERROR: {message}", err=True)
49
+
50
+
51
+ async def _run_headless_session_once(
42
52
  cfg: Any,
43
53
  prompt: str,
44
54
  *,
45
55
  resume_loop_id: str | None = None,
46
56
  autonomous: bool = False,
47
57
  max_iterations: int | None = None,
48
- ) -> int:
49
- """Run a single prompt by connecting to a running daemon.
50
-
51
- Uses WebSocket transport for all connections (RFC-0013).
52
- Headless output is RFC-614 loop-tagged main-graph assistant text only (IG-343).
53
- """
58
+ ) -> tuple[int, bool]:
59
+ """Run one headless daemon session; return ``(exit_code, retry_on_worker_loss)``."""
54
60
  from soothe_sdk.client import WebSocketClient
55
61
 
56
62
  ws_url = websocket_url_from_config(cfg)
@@ -67,20 +73,22 @@ async def run_headless_via_daemon(
67
73
  subscribe_timeout_s=_SESSION_BOOTSTRAP_TIMEOUT_S,
68
74
  )
69
75
  if status_event.get("type") == "error":
70
- typer.echo(f"Daemon error: {status_event.get('message', 'unknown')}", err=True)
71
- return 1
76
+ raw = str(status_event.get("message", "unknown"))
77
+ _emit_headless_error(friendly_daemon_execution_error(raw))
78
+ return 1, is_daemon_worker_subprocess_lost(raw)
72
79
 
73
80
  active_loop_id = status_event.get("loop_id")
74
81
  if not active_loop_id:
75
- typer.echo("Error: No loop_id after session bootstrap", err=True)
76
- return 1
82
+ _emit_headless_error("No loop_id after session bootstrap")
83
+ return 1, False
77
84
 
78
85
  subagent_name, cleaned_prompt = parse_subagent_from_input(prompt)
86
+ effective_prompt = cleaned_prompt if subagent_name else prompt
79
87
 
80
88
  await asyncio.wait_for(
81
89
  client.send_input(
82
90
  active_loop_id,
83
- cleaned_prompt if subagent_name else prompt,
91
+ effective_prompt,
84
92
  autonomous=autonomous,
85
93
  max_iterations=max_iterations,
86
94
  preferred_subagent=subagent_name,
@@ -107,7 +115,7 @@ async def run_headless_via_daemon(
107
115
  client.read_event(), timeout=_QUERY_START_TIMEOUT_S
108
116
  )
109
117
  except TimeoutError:
110
- return _DAEMON_FALLBACK_EXIT_CODE
118
+ return _DAEMON_FALLBACK_EXIT_CODE, False
111
119
  if not event:
112
120
  break
113
121
 
@@ -116,17 +124,17 @@ async def run_headless_via_daemon(
116
124
  continue
117
125
 
118
126
  if event_type == "error":
119
- typer.echo(f"Daemon error: {event.get('message', 'unknown')}", err=True)
120
- return 1
127
+ raw = str(event.get("message", "unknown"))
128
+ _emit_headless_error(friendly_daemon_execution_error(raw))
129
+ return 1, is_daemon_worker_subprocess_lost(raw)
121
130
 
122
131
  ev_data = event.get("data")
123
- if (
124
- not query_started
125
- and isinstance(ev_data, dict)
126
- and str(ev_data.get("type", "")).startswith("soothe.error")
132
+ if isinstance(ev_data, dict) and str(ev_data.get("type", "")).startswith(
133
+ "soothe.error"
127
134
  ):
128
- typer.echo(f"Daemon error: {ev_data.get('error', 'unknown')}", err=True)
129
- return 1
135
+ raw = str(ev_data.get("error", "unknown"))
136
+ _emit_headless_error(friendly_daemon_execution_error(raw))
137
+ return 1, is_daemon_worker_subprocess_lost(raw)
130
138
 
131
139
  if event_type == "status":
132
140
  state = event.get("state", "")
@@ -155,15 +163,48 @@ async def run_headless_via_daemon(
155
163
  logger.exception("Daemon connection failed")
156
164
  from soothe_sdk.utils import format_cli_error
157
165
 
158
- typer.echo(f"Error: {format_cli_error(e)}", err=True)
159
- return _DAEMON_FALLBACK_EXIT_CODE
166
+ _emit_headless_error(format_cli_error(e))
167
+ return _DAEMON_FALLBACK_EXIT_CODE, False
160
168
  except Exception as e:
161
169
  logger.exception("Failed to run via daemon")
162
- from soothe_sdk.utils import format_cli_error
163
-
164
- typer.echo(f"Error: {format_cli_error(e)}", err=True)
165
- return 1
170
+ friendly = friendly_daemon_execution_error(e)
171
+ _emit_headless_error(friendly)
172
+ return 1, is_daemon_worker_subprocess_lost(e)
166
173
  else:
167
- return 0
174
+ return 0, False
168
175
  finally:
169
176
  await client.close()
177
+
178
+
179
+ async def run_headless_via_daemon(
180
+ cfg: Any,
181
+ prompt: str,
182
+ *,
183
+ resume_loop_id: str | None = None,
184
+ autonomous: bool = False,
185
+ max_iterations: int | None = None,
186
+ ) -> int:
187
+ """Run a single prompt by connecting to a running daemon.
188
+
189
+ Uses WebSocket transport for all connections (RFC-0013).
190
+ Headless output is RFC-614 loop-tagged main-graph assistant text only (IG-343).
191
+
192
+ Retries once when the worker pool loses a subprocess mid-query (common idle-timeout
193
+ race or transient worker recycle).
194
+ """
195
+ last_code = 1
196
+ for attempt in range(_HEADLESS_WORKER_LOST_RETRIES + 1):
197
+ last_code, retryable = await _run_headless_session_once(
198
+ cfg,
199
+ prompt,
200
+ resume_loop_id=resume_loop_id,
201
+ autonomous=autonomous,
202
+ max_iterations=max_iterations,
203
+ )
204
+ if last_code == 0:
205
+ return 0
206
+ if retryable and attempt < _HEADLESS_WORKER_LOST_RETRIES:
207
+ logger.warning("Headless query failed after worker subprocess exit; retrying once")
208
+ continue
209
+ return last_code
210
+ return last_code
@@ -111,7 +111,7 @@ def format_subagent_done(
111
111
  duration_s: Duration in seconds.
112
112
  task_scope: Optional ``(task_tool_call_id, subagent_type)`` for delegated rows.
113
113
  task_description: Original brief (e.g. explore ``search_target``) when available.
114
- task_done_success: False for delegated failures (e.g. Claude subagent error).
114
+ task_done_success: False for delegated failures (e.g. optional plugin delegate error).
115
115
  answer_summary: Optional one-line answer tail after metrics (IG-344).
116
116
 
117
117
  Returns:
@@ -301,11 +301,7 @@ class StreamDisplayPipeline:
301
301
 
302
302
  answer_tail: str | None = None
303
303
  raw_summary = event.get("summary")
304
- if (
305
- isinstance(raw_summary, str)
306
- and raw_summary.strip()
307
- and subagent_name in ("claude", "browser", "research")
308
- ):
304
+ if isinstance(raw_summary, str) and raw_summary.strip() and subagent_name:
309
305
  answer_tail = preview_first(raw_summary.strip(), 120)
310
306
 
311
307
  return [
@@ -323,7 +319,7 @@ class StreamDisplayPipeline:
323
319
 
324
320
  Args:
325
321
  event: Event dictionary.
326
- subagent_name: Subagent name (explore, browser, claude, research).
322
+ subagent_name: Subagent id (e.g. explore, plan, research, or a plugin id).
327
323
 
328
324
  Returns:
329
325
  Formatted summary string with key metrics.
@@ -342,7 +338,7 @@ class StreamDisplayPipeline:
342
338
  return summary
343
339
  return "done"
344
340
 
345
- # Claude: cost_usd, claude_session_id
341
+ # Optional plugin session: cost_usd + opaque session id field
346
342
  if subagent_name == "claude":
347
343
  cost = event.get("cost_usd", 0.0)
348
344
  session_id = event.get("claude_session_id")
@@ -353,7 +349,7 @@ class StreamDisplayPipeline:
353
349
  return summary
354
350
  return "done"
355
351
 
356
- # Browser: success status
352
+ # Optional web automation plugin: boolean success in payload
357
353
  if subagent_name == "browser":
358
354
  success = event.get("success", True)
359
355
  return "✓ success" if success else "✗ failed"
@@ -27,6 +27,10 @@ class CLIConfig:
27
27
  # logging_level: DEBUG/INFO/… for ~/.soothe/logs/cli.log; None = default INFO.
28
28
  logging_level: str | None = None
29
29
 
30
+ # TUI rendering options
31
+ render_markdown: bool = True
32
+ """Render assistant messages as Markdown in TUI (default True)."""
33
+
30
34
  # Output streaming overrides (RFC-614)
31
35
  output_streaming_enabled: bool | None = None
32
36
  """Override daemon streaming enabled setting."""
@@ -110,10 +114,17 @@ class CLIConfig:
110
114
  if raw_level is not None and not isinstance(raw_level, str):
111
115
  raw_level = None
112
116
 
117
+ # TUI options from 'tui' section
118
+ tui_section = data.get("tui", {})
119
+ render_markdown = tui_section.get("render_markdown", True)
120
+ if not isinstance(render_markdown, bool):
121
+ render_markdown = True
122
+
113
123
  return cls(
114
124
  daemon_host=websocket.get("host", websocket_legacy.get("host", "127.0.0.1")),
115
125
  daemon_port=websocket.get("port", websocket_legacy.get("port", 8765)),
116
126
  logging_level=raw_level,
127
+ render_markdown=render_markdown,
117
128
  soothe_home=Path(data.get("home", str(Path.home() / ".soothe"))),
118
129
  )
119
130
 
@@ -25,7 +25,7 @@ def parse_slash_command(input_text: str) -> tuple[str, str | None]:
25
25
  """Parse slash command and extract command + query.
26
26
 
27
27
  Args:
28
- input_text: Full user input (e.g., "/browser AI trends")
28
+ input_text: Full user input (e.g., "/research topic summary")
29
29
 
30
30
  Returns:
31
31
  Tuple of (command, query) where query may be None
@@ -119,7 +119,7 @@ async def route_slash_command(
119
119
  """Route slash command based on registry metadata (RFC-404).
120
120
 
121
121
  Args:
122
- cmd_input: Full command input (e.g., "/memory", "/browser AI trends")
122
+ cmd_input: Full command input (e.g., "/memory", "/research topic")
123
123
  console: Rich console for rendering
124
124
  client: WebSocket client for daemon communication
125
125
 
@@ -243,12 +243,12 @@ async def handle_routing_command(
243
243
  ) -> None:
244
244
  """Handle daemon routing command by sending input with optional subagent (RFC-404).
245
245
 
246
- For ``/browser``, ``/claude``, ``/research``, and ``/explore``, sets the WebSocket
247
- ``preferred_subagent`` field so the daemon merges a subagent hint into AgentLoop (IG-349).
248
- Other routing commands (e.g. ``/plan``) are sent as plain text unchanged.
246
+ For routing commands that map to a configured subagent id (e.g. ``/research``, ``/explore``),
247
+ sets the WebSocket ``preferred_subagent`` field so the daemon merges a subagent hint into
248
+ AgentLoop (IG-349). Other routing commands (e.g. ``/plan``) are sent as plain text unchanged.
249
249
 
250
250
  Args:
251
- cmd_input: Full command input (e.g., "/browser AI trends")
251
+ cmd_input: Full command input (e.g., "/research topic summary")
252
252
  console: Rich console
253
253
  client: WebSocket client
254
254
  loop_id: Subscribed loop to target (required for ``loop_input``)
@@ -160,7 +160,7 @@ KEYBOARD_SHORTCUTS: dict[str, str] = {
160
160
  "Ctrl+D": "Detach TUI: Leave the loop running (confirm) and exit client",
161
161
  "Ctrl+C": "Cancel running job, press twice within 1s to quit",
162
162
  "Ctrl+E": "Focus chat input",
163
- "Ctrl+Y": "Copy last message to clipboard",
163
+ "Ctrl+Y": "Copy selected text to clipboard (or show hint if none)",
164
164
  }
165
165
 
166
166
 
@@ -266,20 +266,8 @@ COMMANDS: dict[str, dict[str, Any]] = {
266
266
  "requires_loop": True,
267
267
  "handler": show_autopilot_dashboard,
268
268
  },
269
- # Daemon routing commands (5)
269
+ # Daemon routing commands (3)
270
270
  "/plan": {"location": "daemon", "type": "routing", "description": "Trigger plan mode"},
271
- "/browser": {
272
- "location": "daemon",
273
- "type": "routing",
274
- "description": "Route query to Browser subagent",
275
- "requires_query": True,
276
- },
277
- "/claude": {
278
- "location": "daemon",
279
- "type": "routing",
280
- "description": "Route query to Claude subagent",
281
- "requires_query": True,
282
- },
283
271
  "/research": {
284
272
  "location": "daemon",
285
273
  "type": "routing",
@@ -3,13 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  SUBAGENT_DISPLAY_NAMES: dict[str, str] = {
6
- "browser": "Browser",
7
- "claude": "Claude",
8
6
  "research": "Research",
9
7
  "explore": "Explore",
10
8
  }
11
9
 
12
- BUILTIN_SUBAGENT_NAMES: list[str] = list(SUBAGENT_DISPLAY_NAMES.keys())
10
+ # Lowercase ids matched after ``/`` for preferred_subagent routing (core only).
11
+ SUBAGENT_SLASH_ROUTE_IDS: tuple[str, ...] = ("research", "explore")
12
+
13
+ BUILTIN_SUBAGENT_NAMES: list[str] = list(SUBAGENT_SLASH_ROUTE_IDS)
13
14
 
14
15
 
15
16
  def get_subagent_display_name(technical_name: str) -> str:
@@ -19,18 +20,18 @@ def get_subagent_display_name(technical_name: str) -> str:
19
20
  technical_name: Internal subagent name.
20
21
 
21
22
  Returns:
22
- PascalCase display name.
23
+ Title-cased label for first-party ids; otherwise the raw id string.
23
24
  """
24
- return SUBAGENT_DISPLAY_NAMES.get(
25
- technical_name,
26
- technical_name.replace("_", " ").title().replace(" ", ""),
27
- )
25
+ key = (technical_name or "").strip()
26
+ if key.lower() in SUBAGENT_DISPLAY_NAMES:
27
+ return SUBAGENT_DISPLAY_NAMES[key.lower()]
28
+ return key
28
29
 
29
30
 
30
31
  def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
31
32
  """Parse subagent subcommand from user input.
32
33
 
33
- Detects subagent subcommands (e.g., /browser, /claude) anywhere in the text
34
+ Detects subagent routing commands (e.g. ``/research``, ``/explore``)
34
35
  and extracts the subagent name along with the cleaned input text.
35
36
 
36
37
  Args:
@@ -42,13 +43,13 @@ def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
42
43
  The subcommand is removed from ``cleaned_text``.
43
44
 
44
45
  Examples:
45
- ``"/browser check this"`` -> ``("browser", "check this")``
46
- ``"Can you /claude analyze this"`` -> ``("claude", "Can you analyze this")``
46
+ ``"/research check this"`` -> ``("research", "check this")``
47
+ ``"/explore map the repo"`` -> ``("explore", "map the repo")``
47
48
  ``"hello world"`` -> ``(None, "hello world")``
48
49
  """
49
50
  first_match: tuple[int, str] | None = None
50
51
 
51
- for subagent_name in BUILTIN_SUBAGENT_NAMES:
52
+ for subagent_name in SUBAGENT_SLASH_ROUTE_IDS:
52
53
  subcommand = f"/{subagent_name}"
53
54
  idx = user_input.lower().find(subcommand)
54
55
  if idx != -1 and (first_match is None or idx < first_match[0]):
@@ -137,12 +137,17 @@ class EventProcessor:
137
137
  namespace,
138
138
  )
139
139
 
140
- def _resolve_task_scope(self, namespace: tuple[str, ...]) -> tuple[str, str] | None:
141
- """Return ``(task_tool_call_id, subagent_type)`` for this stream namespace."""
140
+ def _resolve_task_scope(self, namespace: tuple[str, ...]) -> tuple[str, str, str] | None:
141
+ """Return task scope ``(task_tool_call_id, subagent_type, step_id)`` for namespace."""
142
142
  return resolve_task_scope_for_namespace(self._state.namespace_task_bindings, namespace)
143
143
 
144
144
  def _enqueue_task_spawn_if_needed(
145
- self, name: str, args: dict[str, Any], tool_call_id: str, *, is_main: bool
145
+ self,
146
+ name: str,
147
+ args: dict[str, Any],
148
+ tool_call_id: str,
149
+ *,
150
+ is_main: bool,
146
151
  ) -> None:
147
152
  """Record main-graph ``task`` tool calls so subgraph streams can resolve labels."""
148
153
  enqueue_task_spawn(
@@ -229,7 +234,7 @@ class EventProcessor:
229
234
  """Headless stdout: only RFC-614 loop-tagged finals (IG-343 / IG-345).
230
235
 
231
236
  Suppresses unphased execute-wave narration on the main graph; ``goal_completion``,
232
- ``chitchat``, etc. are routed via ``assistant_output_phase`` before this path.
237
+ ``quiz``, etc. are routed via ``assistant_output_phase`` before this path.
233
238
 
234
239
  Subgraph streams without RFC-614 phases are skipped in ``_handle_ai_message`` before
235
240
  this runs; loop-tagged subgraph finals use ``_dispatch_loop_tagged_assistant_text``.
@@ -536,10 +541,11 @@ class EventProcessor:
536
541
 
537
542
  # Accumulate streaming tool args (IG-053)
538
543
  tool_call_chunks = getattr(msg, "tool_call_chunks", None) or []
539
- accumulate_tool_call_chunks(
544
+ self._state.last_active_tool_call_id = accumulate_tool_call_chunks(
540
545
  self._state.pending_tool_calls,
541
546
  tool_call_chunks,
542
547
  is_main=is_main,
548
+ last_active_id=self._state.last_active_tool_call_id,
543
549
  )
544
550
 
545
551
  # Emit pending tool calls with complete args
@@ -777,10 +783,11 @@ class EventProcessor:
777
783
  # Accumulate streaming tool args from tool_call_chunks (IG-053)
778
784
  tool_call_chunks = msg.get("tool_call_chunks", [])
779
785
  if isinstance(tool_call_chunks, list) and tool_call_chunks:
780
- accumulate_tool_call_chunks(
786
+ self._state.last_active_tool_call_id = accumulate_tool_call_chunks(
781
787
  self._state.pending_tool_calls,
782
788
  tool_call_chunks,
783
789
  is_main=is_main,
790
+ last_active_id=self._state.last_active_tool_call_id,
784
791
  )
785
792
 
786
793
  self._emit_pending_tool_calls(namespace)
@@ -49,7 +49,7 @@ class PresentationEngine:
49
49
 
50
50
  @property
51
51
  def final_answer_locked(self) -> bool:
52
- """True after a custom final/chitchat response was emitted for this turn."""
52
+ """True after a custom final or quiz-phase response was emitted for this turn."""
53
53
  return self._state.final_answer_locked
54
54
 
55
55
  def mark_final_answer_locked(self) -> None:
@@ -32,6 +32,9 @@ class ProcessorState:
32
32
  # Maps tool_call_id -> {'name': str, 'args_str': str, 'emitted': bool, 'is_main': bool}
33
33
  pending_tool_calls: dict[str, dict[str, Any]] = field(default_factory=dict)
34
34
 
35
+ # Last active tool_call_id for orphan chunk attachment (chunks without explicit id)
36
+ last_active_tool_call_id: str = ""
37
+
35
38
  # Namespace -> display name mapping for subagents
36
39
  name_map: dict[str, str] = field(default_factory=dict)
37
40
 
@@ -65,8 +68,10 @@ class ProcessorState:
65
68
  """(phase, namespace) pairs that already emitted final loop-tagged output this turn."""
66
69
 
67
70
  # Task tool spawn queue → bind first subgraph namespace (FIFO; IG-334)
68
- task_spawn_queue: deque[tuple[str, str]] = field(default_factory=deque)
69
- namespace_task_bindings: dict[tuple[str, ...], tuple[str, str]] = field(default_factory=dict)
71
+ task_spawn_queue: deque[tuple[str, str, str]] = field(default_factory=deque)
72
+ namespace_task_bindings: dict[tuple[str, ...], tuple[str, str, str]] = field(
73
+ default_factory=dict
74
+ )
70
75
 
71
76
  def reset_turn(self) -> None:
72
77
  """Reset per-turn state.
@@ -75,6 +80,7 @@ class ProcessorState:
75
80
  Clears streaming buffers but preserves session state.
76
81
  """
77
82
  self.pending_tool_calls.clear()
83
+ self.last_active_tool_call_id = ""
78
84
  self.tool_call_start_times.clear()
79
85
  self.emitted_tool_call_ids.clear()
80
86
  self.emitted_tool_result_ids.clear()
@@ -141,7 +141,7 @@ class RendererProtocol(Protocol):
141
141
  Catch-all for events not covered by specific callbacks.
142
142
 
143
143
  Args:
144
- event_type: Full event type string (e.g., ``soothe.subagent.browser.started``).
144
+ event_type: Full event type string (e.g., ``soothe.subagent.explore.started``).
145
145
  data: Event payload.
146
146
  namespace: Subagent namespace tuple (empty for main agent).
147
147
  task_scope: When subgraph streams are bound to a Task tool call (IG-334).
@@ -0,0 +1,27 @@
1
+ """Shared daemon execution error presentation for CLI and TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Partial match for pool_runner RuntimeError when an OS worker exits mid-turn.
6
+ DAEMON_WORKER_SUBPROCESS_LOST = "Worker subprocess exited unexpectedly during query execution"
7
+
8
+ _FRIENDLY_WORKER_SUBPROCESS_LOST = (
9
+ "The daemon execution worker stopped unexpectedly (for example after the pool "
10
+ "recycled an idle subprocess). Send your message again."
11
+ )
12
+
13
+
14
+ def friendly_daemon_execution_error(exc: BaseException | str) -> str:
15
+ """Map known daemon failures to concise, actionable copy."""
16
+ if isinstance(exc, RuntimeError) and DAEMON_WORKER_SUBPROCESS_LOST in str(exc):
17
+ return _FRIENDLY_WORKER_SUBPROCESS_LOST
18
+ text = str(exc)
19
+ if DAEMON_WORKER_SUBPROCESS_LOST in text:
20
+ return _FRIENDLY_WORKER_SUBPROCESS_LOST
21
+ return text if isinstance(exc, str) else str(exc)
22
+
23
+
24
+ def is_daemon_worker_subprocess_lost(exc: BaseException | str) -> bool:
25
+ """Return whether an error indicates a pool worker process exited mid-query."""
26
+ text = str(exc)
27
+ return DAEMON_WORKER_SUBPROCESS_LOST in text
@@ -140,7 +140,7 @@ class AsyncRendererProtocol(Protocol):
140
140
  Catch-all for events not covered by specific callbacks.
141
141
 
142
142
  Args:
143
- event_type: Full event type string (e.g., ``soothe.subagent.browser.started``).
143
+ event_type: Full event type string (e.g., ``soothe.subagent.explore.started``).
144
144
  data: Event payload.
145
145
  namespace: Subagent namespace tuple (empty for main agent).
146
146
  task_scope: When subgraph streams are bound to a Task tool call (IG-334).
@@ -15,7 +15,6 @@ from soothe_cli.shared.tools.message_processing import (
15
15
  from soothe_cli.shared.tools.rendering import update_name_map_from_tool_calls
16
16
  from soothe_cli.shared.tools.tool_call_resolution import (
17
17
  build_streaming_args_overlay,
18
- infer_tool_name_from_call_id,
19
18
  materialize_ai_blocks_with_resolved_tools,
20
19
  tool_args_meaningful,
21
20
  )
@@ -64,7 +63,6 @@ __all__ = [
64
63
  "update_name_map_from_tool_calls",
65
64
  # Tool call resolution
66
65
  "build_streaming_args_overlay",
67
- "infer_tool_name_from_call_id",
68
66
  "materialize_ai_blocks_with_resolved_tools",
69
67
  "tool_args_meaningful",
70
68
  # Tool card payload