soothe-cli 0.7.18__tar.gz → 0.8.0__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 (132) hide show
  1. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/PKG-INFO +1 -1
  2. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/loop_cmd.py +12 -2
  3. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/step_router.py +52 -1
  4. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/transport/session.py +16 -0
  5. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_app.py +54 -2
  6. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_execution.py +2 -13
  7. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_messages_mixin.py +8 -34
  8. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_startup.py +2 -2
  9. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/command_registry.py +6 -0
  10. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/commands/__init__.py +2 -2
  11. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/commands/command_router.py +7 -6
  12. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/commands/slash_commands.py +9 -3
  13. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/commands/subagent_routing.py +18 -6
  14. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/spinner_labels.py +8 -1
  15. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/textual_adapter.py +246 -16
  16. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/tips.py +1 -1
  17. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/help_screen.py +3 -3
  18. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +10 -3
  19. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/status.py +25 -4
  20. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/.gitignore +0 -0
  21. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/README.md +0 -0
  22. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/pyproject.toml +0 -0
  23. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/__init__.py +0 -0
  24. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/__init__.py +0 -0
  25. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/__init__.py +0 -0
  26. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  27. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  28. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  29. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  30. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  31. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/__init__.py +0 -0
  32. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/daemon.py +0 -0
  33. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  34. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/headless.py +0 -0
  35. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  36. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/execution/launcher.py +0 -0
  37. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/cli/main.py +0 -0
  38. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/config/__init__.py +0 -0
  39. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/config/cli_config.py +0 -0
  40. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/config/loader.py +0 -0
  41. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/config/logging_setup.py +0 -0
  42. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/__init__.py +0 -0
  43. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/headless/processor.py +0 -0
  44. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  45. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  46. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  47. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  48. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  49. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  50. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  51. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  52. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  53. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
  54. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  55. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  56. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  57. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  58. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  59. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  60. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/state/transcript.py +0 -0
  61. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  62. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/token_usage.py +0 -0
  63. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  64. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  65. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  66. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  67. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/runtime/wire/messages.py +0 -0
  68. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/__init__.py +0 -0
  69. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/_cli_context.py +0 -0
  70. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/_env_vars.py +0 -0
  71. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/_version.py +0 -0
  72. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/__init__.py +0 -0
  73. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_history.py +0 -0
  74. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_model.py +0 -0
  75. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_module_init.py +0 -0
  76. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/_ui.py +0 -0
  77. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/app/app.tcss +0 -0
  78. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/binding.py +0 -0
  79. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/config.py +0 -0
  80. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/file_change_notify.py +0 -0
  81. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  82. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/hooks.py +0 -0
  83. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/input.py +0 -0
  84. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/markdown_theme.py +0 -0
  85. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/media_utils.py +0 -0
  86. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/model_config.py +0 -0
  87. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/path_utils.py +0 -0
  88. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/preview_limits.py +0 -0
  89. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/project_utils.py +0 -0
  90. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/sessions.py +0 -0
  91. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/shell_color.py +0 -0
  92. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/skills/__init__.py +0 -0
  93. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/skills/invocation.py +0 -0
  94. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/skills/load.py +0 -0
  95. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/theme.py +0 -0
  96. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/tool_display.py +0 -0
  97. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/unicode_security.py +0 -0
  98. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/update_check.py +0 -0
  99. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  100. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/_links.py +0 -0
  101. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  102. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  103. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  104. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  105. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  106. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/diff.py +0 -0
  107. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/editor.py +0 -0
  108. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  109. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/history.py +0 -0
  110. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/loading.py +0 -0
  111. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  112. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  113. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  114. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  115. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  116. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  117. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  118. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  119. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  120. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  121. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
  122. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  123. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  124. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  125. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  126. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  127. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  128. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  129. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  130. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
  131. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  132. {soothe_cli-0.7.18 → soothe_cli-0.8.0}/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.7.18
3
+ Version: 0.8.0
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
@@ -79,13 +79,23 @@ async def _rpc(
79
79
  client = WebSocketClient(url=ws_url)
80
80
  try:
81
81
  await client.connect()
82
+ await asyncio.wait_for(client.request_connection_init(), timeout=timeout)
83
+ await asyncio.wait_for(
84
+ client.wait_for_connection_ack(ack_timeout_s=timeout), timeout=timeout
85
+ )
82
86
  if mode == "notify":
83
87
  await asyncio.wait_for(client.notify(method, params or {}), timeout=timeout)
84
88
  return {}
85
89
  if mode == "subscribe":
86
- sub_id = await asyncio.wait_for(client.subscribe(method, params or {}), timeout=timeout)
90
+ sub_id = await asyncio.wait_for(
91
+ client.subscribe(method, params or {}, timeout=timeout),
92
+ timeout=timeout,
93
+ )
87
94
  return {"subscription_id": sub_id}
88
- result = await asyncio.wait_for(client.request(method, params or {}), timeout=timeout)
95
+ result = await asyncio.wait_for(
96
+ client.request(method, params or {}, timeout=timeout),
97
+ timeout=timeout,
98
+ )
89
99
  return result if isinstance(result, dict) else {"result": result}
90
100
  except TimeoutError:
91
101
  return {"error": "Timed out waiting for daemon response"}
@@ -24,6 +24,7 @@ from soothe_sdk.ux.task_namespace import (
24
24
  resolve_task_parent_lookup,
25
25
  resolve_task_scope_for_namespace,
26
26
  row_key_for_subgraph_tool,
27
+ step_level_parent_task_call_id,
27
28
  task_scope_step_id,
28
29
  try_bind_namespace_from_tool_call_id,
29
30
  )
@@ -93,6 +94,10 @@ class StepTaskRouter:
93
94
  _spawns_by_task_id: dict[str, TaskScope] = field(default_factory=dict, repr=False)
94
95
  _pending_unscoped_namespaces: deque[tuple[str, ...]] = field(default_factory=deque, repr=False)
95
96
  _spawn_recorded: set[tuple[str, str]] = field(default_factory=set, repr=False)
97
+ _task_index_by_step_and_call: dict[tuple[str, str], int] = field(
98
+ default_factory=dict, repr=False
99
+ )
100
+ _next_task_index_by_step: dict[str, int] = field(default_factory=dict, repr=False)
96
101
  _pending_main_tools: dict[str, PendingMainTool] = field(default_factory=dict, repr=False)
97
102
  _pending_subgraph_tools: dict[tuple[tuple[str, ...], str], PendingSubgraphTool] = field(
98
103
  default_factory=dict,
@@ -108,6 +113,8 @@ class StepTaskRouter:
108
113
  self._spawns_by_task_id.clear()
109
114
  self._pending_unscoped_namespaces.clear()
110
115
  self._spawn_recorded.clear()
116
+ self._task_index_by_step_and_call.clear()
117
+ self._next_task_index_by_step.clear()
111
118
  self._pending_main_tools.clear()
112
119
  self._pending_subgraph_tools.clear()
113
120
 
@@ -220,6 +227,11 @@ class StepTaskRouter:
220
227
  ]
221
228
  for tcid in drop_tcid:
222
229
  self._spawns_by_task_id.pop(tcid, None)
230
+ # Cleanse per-step synthetic task-index state once the step is done.
231
+ self._next_task_index_by_step.pop(sid, None)
232
+ stale_index_keys = [key for key in self._task_index_by_step_and_call if key[0] == sid]
233
+ for key in stale_index_keys:
234
+ self._task_index_by_step_and_call.pop(key, None)
223
235
 
224
236
  def step_id_for_tool(self, tool_call_id: str) -> str:
225
237
  """Return execute step id encoded in a unified root tool_call_id."""
@@ -287,7 +299,10 @@ class StepTaskRouter:
287
299
  sid = str(step_id).strip()
288
300
  if not sid:
289
301
  return False
290
- normalized_tcid = normalize_main_task_delegation_id(sid, tcid, tool_name="task")
302
+ normalized_tcid = self.normalize_task_delegation_id(
303
+ step_id=sid,
304
+ tool_call_id=tcid,
305
+ )
291
306
  spawn_key = (sid, normalized_tcid)
292
307
  if spawn_key in self._spawn_recorded:
293
308
  return False
@@ -311,6 +326,42 @@ class StepTaskRouter:
311
326
  )
312
327
  return True
313
328
 
329
+ def normalize_task_delegation_id(self, *, step_id: str, tool_call_id: str) -> str:
330
+ """Return canonical ``{step}:s:task:{idx}`` for main ``task`` delegations.
331
+
332
+ Some providers emit unified ids like ``{step}:s:call_<uuid>`` that do not
333
+ encode a task index. In that case allocate a stable per-step synthetic
334
+ index so task activity rows and standalone SubAgent cards stay 1:1.
335
+ """
336
+ sid = str(step_id).strip()
337
+ tcid = str(tool_call_id).strip()
338
+ if not sid or not tcid:
339
+ return tcid
340
+
341
+ normalized = normalize_main_task_delegation_id(sid, tcid, tool_name="task")
342
+ _, type_code, _, tool_info = parse_unified_tool_call_id(normalized)
343
+ if type_code == "s" and isinstance(tool_info, str):
344
+ parts = tool_info.split(":")
345
+ if parts and parts[0] == "task":
346
+ tail = parts[-1] if parts else ""
347
+ if tail.isdigit():
348
+ idx = int(tail)
349
+ key = (sid, tcid)
350
+ self._task_index_by_step_and_call[key] = idx
351
+ next_idx = self._next_task_index_by_step.get(sid, 0)
352
+ if idx >= next_idx:
353
+ self._next_task_index_by_step[sid] = idx + 1
354
+ return normalized
355
+
356
+ key = (sid, tcid)
357
+ if key in self._task_index_by_step_and_call:
358
+ idx = self._task_index_by_step_and_call[key]
359
+ else:
360
+ idx = self._next_task_index_by_step.get(sid, 0)
361
+ self._task_index_by_step_and_call[key] = idx
362
+ self._next_task_index_by_step[sid] = idx + 1
363
+ return step_level_parent_task_call_id(sid, idx)
364
+
314
365
  def resolve_task_scope(self, namespace: tuple[str, ...]) -> TaskScope | None:
315
366
  """Task scope for a stream namespace, if bound."""
316
367
  return resolve_task_scope_for_namespace(self._namespace_bindings, namespace)
@@ -30,6 +30,9 @@ _POST_IDLE_DRAIN_DEADLINE_S = 0.5
30
30
 
31
31
  # Align with ``bootstrap_loop_session`` daemon-ready wait (RFC-450 §8.2).
32
32
  _RPC_HANDSHAKE_TIMEOUT_S = 20.0
33
+ _TURN_END_CUSTOM_TYPES = {
34
+ "soothe.cognition.strange_loop.completed",
35
+ }
33
36
 
34
37
  # Brief close handshake on TUI exit — the daemon cleans up on disconnect anyway.
35
38
  TUI_EXIT_HANDSHAKE_TIMEOUT_S = 0.3
@@ -448,6 +451,19 @@ class TuiDaemonSession:
448
451
  progress_seen = True
449
452
  stream_payload_seen = True
450
453
  yield (namespace, mode, data)
454
+ if mode == "custom" and isinstance(data, dict):
455
+ custom_type = str(data.get("type", "")).strip()
456
+ if custom_type in _TURN_END_CUSTOM_TYPES:
457
+ # Some turns can miss the trailing status=idle/stopped
458
+ # despite emitting terminal custom events. Treat these
459
+ # as explicit turn completion and briefly drain late
460
+ # frames to preserve post-terminal payloads.
461
+ self.last_turn_end_state = "completed"
462
+ async for chunk in self._drain_stream_events_after_idle(
463
+ expected_loop_id=expected_loop_id,
464
+ ):
465
+ yield chunk
466
+ break
451
467
  # Graph auto-resumes LangGraph interrupts server-side; keep consuming events.
452
468
  if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
453
469
  continue
@@ -6,11 +6,13 @@ import asyncio
6
6
  import logging
7
7
  import time
8
8
  from collections import deque
9
+ from contextlib import suppress
9
10
  from pathlib import Path
10
11
  from typing import TYPE_CHECKING, Any, ClassVar
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from textual.app import ComposeResult
15
+ from textual.timer import Timer
14
16
  from textual.worker import Worker
15
17
 
16
18
  from soothe_cli.tui.skills.load import ExtendedSkillMetadata
@@ -222,12 +224,13 @@ class SootheApp(
222
224
  self._mcp_tool_count = sum(len(s.tools) for s in (mcp_server_info or []))
223
225
 
224
226
  self._status_bar: StatusBar | None = None
227
+ self._default_session_tip: str = ""
228
+ self._status_notification_timer: Timer | None = None
229
+ self._status_notification_active = False
225
230
 
226
231
  self._chat_input: ChatInput | None = None
227
232
  self._plan_quick_view_overlay: PlanQuickViewOverlay | None = None
228
233
 
229
- self._quit_pending = False
230
-
231
234
  self._session_state: TextualSessionState | None = None
232
235
 
233
236
  self._ui_adapter: TextualUIAdapter | None = None
@@ -361,6 +364,55 @@ class SootheApp(
361
364
  """Return whether the app has a connected daemon session."""
362
365
  return self._daemon_session is not None
363
366
 
367
+ def set_default_session_tip(self, tip: str) -> None:
368
+ """Persist and render the fallback tip shown when no notification is active."""
369
+ cleaned = (tip or "").strip()
370
+ self._default_session_tip = cleaned
371
+ if self._status_bar is not None and not self._status_notification_active:
372
+ self._status_bar.set_session_tip(cleaned)
373
+
374
+ def _set_status_notification(self, message: str, *, timeout: float | None = None) -> None:
375
+ """Render a transient notification in the status-tip area."""
376
+ text = (message or "").strip()
377
+ if not text:
378
+ return
379
+ if self._status_notification_timer is not None:
380
+ with suppress(Exception):
381
+ self._status_notification_timer.stop()
382
+ self._status_notification_timer = None
383
+ self._status_notification_active = True
384
+ if self._status_bar is not None:
385
+ self._status_bar.set_notification_message(text)
386
+
387
+ duration = timeout if timeout and timeout > 0 else 3.0
388
+ self._status_notification_timer = self.set_timer(duration, self._clear_status_notification)
389
+
390
+ def _clear_status_notification(self) -> None:
391
+ """Restore the default tip after transient notification timeout."""
392
+ self._status_notification_timer = None
393
+ self._status_notification_active = False
394
+ if self._status_bar is not None:
395
+ self._status_bar.set_session_tip(self._default_session_tip)
396
+
397
+ def notify( # type: ignore[override]
398
+ self,
399
+ message: Any,
400
+ *,
401
+ title: str = "",
402
+ severity: str = "information",
403
+ timeout: float | None = None,
404
+ markup: bool = True, # noqa: ARG002
405
+ ) -> None:
406
+ """Display notifications in the status-tip area instead of toast bubbles."""
407
+ text = str(message or "").strip()
408
+ if not text:
409
+ return
410
+ if title:
411
+ text = f"{title}: {text}"
412
+ if severity and severity.lower() in {"warning", "error"}:
413
+ text = f"{severity.title()}: {text}"
414
+ self._set_status_notification(text, timeout=timeout)
415
+
364
416
  def get_theme_variable_defaults(self) -> dict[str, str]:
365
417
  """Return custom CSS variable defaults for the current theme.
366
418
 
@@ -148,9 +148,6 @@ class _ExecutionMixin:
148
148
  value = event.value
149
149
  mode: InputMode = event.mode # type: ignore[assignment] # Textual event mode is str at type level but InputMode at runtime
150
150
 
151
- # Reset quit pending state on any input
152
- self._quit_pending = False
153
-
154
151
  await dispatch_hook("user.prompt", {})
155
152
 
156
153
  # /quit and /q always execute immediately, even mid-loop-switch.
@@ -465,10 +462,10 @@ class _ExecutionMixin:
465
462
  )
466
463
  from soothe_cli.tui.commands.slash_commands import COMMANDS as _RFC404_COMMANDS
467
464
 
468
- # RFC-454 daemon *routing* commands (/research, /plan, optional /«id»):
465
+ # RFC-454 daemon *routing* commands (/deep_research, /plan, /browser_use, ):
469
466
  # send the full line as a normal user turn so ``parse_subagent_from_input``
470
467
  # runs in the daemon adapter (same as headless CLI). Without this branch,
471
- # ``cmd == "/research …"`` never matches the bare single-token handlers below.
468
+ # ``cmd == "/deep_research …"`` never matches the bare single-token handlers below.
472
469
  full_stripped = command.strip()
473
470
  first_word, query = parse_slash_command(full_stripped)
474
471
  if first_word:
@@ -747,7 +744,6 @@ class _ExecutionMixin:
747
744
  self,
748
745
  message: str,
749
746
  *,
750
- message_kwargs: dict[str, Any] | None = None,
751
747
  skip_daemon_send_turn: bool = False,
752
748
  ) -> None:
753
749
  """Send a message to the agent and start execution.
@@ -758,8 +754,6 @@ class _ExecutionMixin:
758
754
 
759
755
  Args:
760
756
  message: The prompt to send to the agent.
761
- message_kwargs: Extra fields merged into the stream input message
762
- dict (e.g., `additional_kwargs` for skill metadata).
763
757
  skip_daemon_send_turn: When using a daemon session, only attach to
764
758
  the in-flight stream (prompt already queued on the daemon).
765
759
  """
@@ -792,7 +786,6 @@ class _ExecutionMixin:
792
786
  self._agent_worker = self.run_worker(
793
787
  self._run_agent_task(
794
788
  message,
795
- message_kwargs=message_kwargs,
796
789
  skip_daemon_send_turn=skip_daemon_send_turn,
797
790
  ),
798
791
  exclusive=False,
@@ -808,7 +801,6 @@ class _ExecutionMixin:
808
801
  self,
809
802
  message: str,
810
803
  *,
811
- message_kwargs: dict[str, Any] | None = None,
812
804
  skip_daemon_send_turn: bool = False,
813
805
  ) -> None:
814
806
  """Run the agent task in a background worker.
@@ -817,8 +809,6 @@ class _ExecutionMixin:
817
809
 
818
810
  Args:
819
811
  message: The prompt to send to the agent.
820
- message_kwargs: Extra fields merged into the stream input message
821
- dict (e.g., `additional_kwargs` for skill metadata).
822
812
  skip_daemon_send_turn: When ``True`` with a daemon session, only
823
813
  consume the daemon stream (prompt already queued server-side).
824
814
  """
@@ -847,7 +837,6 @@ class _ExecutionMixin:
847
837
  image_tracker=self._image_tracker,
848
838
  sandbox_type=self._sandbox_type,
849
839
  workspace=self._cwd,
850
- message_kwargs=message_kwargs,
851
840
  context=CLIContext(
852
841
  model=self._model_override,
853
842
  model_params=self._model_params_override or {},
@@ -104,7 +104,6 @@ class _MessagesMixin:
104
104
  group="interrupt-ui",
105
105
  )
106
106
  self._cancel_worker(self._agent_worker, discard_queue=False)
107
- self._quit_pending = False
108
107
  return True
109
108
 
110
109
  async def _load_loop_history(
@@ -554,18 +553,16 @@ class _MessagesMixin:
554
553
  copy_selection_to_clipboard(self, notify_if_empty=True)
555
554
 
556
555
  def action_quit_or_interrupt(self) -> None:
557
- """Handle Ctrl+C - clear input, interrupt agent, or quit on double press.
556
+ """Handle Ctrl+C - clear input or interrupt running work.
558
557
 
559
558
  Priority order when task is running (agent/shell):
560
559
  1. If input has pending text, clear it (first Ctrl+C)
561
- 2. If input is empty, interrupt the running task (second Ctrl+C)
560
+ 2. If input is empty, interrupt the running task
562
561
 
563
- Priority order when idle:
564
- 1. If double press (quit_pending), quit
565
- 2. Otherwise clear pending input and show quit hint
562
+ When idle, Ctrl+C never exits the TUI. Use `/quit` (or `/q`) to exit.
566
563
 
567
564
  Note: Copying selected text is bound to Ctrl+Y (`action_copy_selection`)
568
- so Ctrl+C is reserved for interrupt/quit only.
565
+ so Ctrl+C is reserved for interrupt behavior.
569
566
  """
570
567
  # Check if input has pending content (text, mode, or completion)
571
568
  has_pending_input = self._has_pending_chat_input()
@@ -574,39 +571,22 @@ class _MessagesMixin:
574
571
  if self._shell_running and self._shell_worker:
575
572
  if has_pending_input:
576
573
  self._chat_input.clear_input()
577
- self._quit_pending = False
578
574
  return
579
575
  self._cancel_worker(self._shell_worker, discard_queue=False)
580
- self._quit_pending = False
581
576
  return
582
577
 
583
578
  # If agent is running: clear input first, then interrupt
584
579
  if self._agent_running and self._agent_worker:
585
580
  if has_pending_input:
586
581
  self._chat_input.clear_input()
587
- self._quit_pending = False
588
582
  return
589
583
  self._interrupt_running_goal_preserving_queue()
590
584
  return
591
585
 
592
- # Double Ctrl+C to quit (same detach path as Ctrl+D and /quit)
593
- if self._quit_pending:
594
- self._detach_or_exit()
595
- else:
596
- self._arm_quit_pending("Ctrl+C")
597
-
598
- def _arm_quit_pending(self, shortcut: str) -> None:
599
- """Set the pending-quit flag, clear pending input, and show a matching hint.
600
-
601
- Args:
602
- shortcut: The key chord to show in the quit hint.
603
- """
586
+ # Idle path: clear any pending draft, but never quit via keyboard shortcut.
604
587
  if self._chat_input:
605
588
  self._chat_input.clear_input()
606
- self._quit_pending = True
607
- quit_timeout = 3
608
- self.notify(f"Press {shortcut} again to quit", timeout=quit_timeout, markup=False)
609
- self.set_timer(quit_timeout, lambda: setattr(self, "_quit_pending", False))
589
+ self.notify("Use /quit (or /q) to exit the TUI", timeout=2, markup=False)
610
590
 
611
591
  def action_dismiss_ui(self) -> None:
612
592
  """Handle Escape — dismiss overlays and optionally cancel queued goals.
@@ -661,14 +641,8 @@ class _MessagesMixin:
661
641
  self.action_dismiss_ui()
662
642
 
663
643
  def action_quit_app(self) -> None:
664
- """Handle quit action (Ctrl+D)."""
665
- from soothe_cli.tui.widgets.loop_selector import LoopSelectorScreen
666
-
667
- if isinstance(self.screen, LoopSelectorScreen):
668
- # Loop selector doesn't have delete confirmation - just detach
669
- self._detach_or_exit()
670
- return
671
- self._detach_or_exit()
644
+ """Handle Ctrl+D by hinting explicit slash-command exit."""
645
+ self.notify("Use /quit (or /q) to exit the TUI", timeout=2, markup=False)
672
646
 
673
647
  async def _detach_then_exit(self) -> None:
674
648
  """Detach from daemon, then exit the app."""
@@ -139,7 +139,7 @@ class _StartupMixin:
139
139
 
140
140
  with suppress(NoMatches):
141
141
  banner = self.query_one("#welcome-banner", WelcomeBanner)
142
- self._status_bar.set_session_tip(banner.session_tip)
142
+ self.set_default_session_tip(banner.session_tip)
143
143
 
144
144
  # Focus the input immediately so the cursor is visible on first paint
145
145
  self._chat_input.focus_input()
@@ -535,7 +535,7 @@ class _StartupMixin:
535
535
  if self._lc_loop_id:
536
536
  banner.update_loop_id(self._lc_loop_id)
537
537
  if self._status_bar is not None:
538
- self._status_bar.set_session_tip(banner.session_tip)
538
+ self.set_default_session_tip(banner.session_tip)
539
539
  except NoMatches:
540
540
  logger.warning("Welcome banner not found during daemon ready transition")
541
541
 
@@ -130,6 +130,12 @@ COMMANDS: tuple[SlashCommand, ...] = (
130
130
  description="Route prompt to academic_research subagent (usage: /academic_research <query>)",
131
131
  bypass_tier=BypassTier.QUEUED,
132
132
  ),
133
+ SlashCommand(
134
+ name="/browser_use",
135
+ description="Route prompt to browser_use subagent (usage: /browser_use <task>)",
136
+ bypass_tier=BypassTier.QUEUED,
137
+ hidden_keywords="browser automation web",
138
+ ),
133
139
  SlashCommand(
134
140
  name="/plan",
135
141
  description="Send input in plan mode (usage: /plan or /plan <prompt>)",
@@ -10,15 +10,15 @@ from soothe_cli.tui.commands.command_router import (
10
10
  validate_command,
11
11
  )
12
12
  from soothe_cli.tui.commands.subagent_routing import (
13
- BUILTIN_SUBAGENT_NAMES,
14
13
  SUBAGENT_DISPLAY_NAMES,
14
+ SUBAGENT_SLASH_ROUTE_IDS,
15
15
  get_subagent_display_name,
16
16
  parse_subagent_from_input,
17
17
  )
18
18
 
19
19
  __all__ = [
20
- "BUILTIN_SUBAGENT_NAMES",
21
20
  "SUBAGENT_DISPLAY_NAMES",
21
+ "SUBAGENT_SLASH_ROUTE_IDS",
22
22
  "find_command_by_daemon_command",
23
23
  "get_subagent_display_name",
24
24
  "handle_rpc_command",
@@ -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., "/research topic summary")
28
+ input_text: Full user input (e.g., "/deep_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-454).
120
120
 
121
121
  Args:
122
- cmd_input: Full command input (e.g., "/memory", "/research topic")
122
+ cmd_input: Full command input (e.g., "/memory", "/deep_research topic")
123
123
  console: Rich console for rendering
124
124
  client: WebSocket client for daemon communication
125
125
 
@@ -237,12 +237,13 @@ async def handle_routing_command(
237
237
  ) -> None:
238
238
  """Handle daemon routing command by sending input with optional subagent (RFC-454).
239
239
 
240
- For routing commands that map to a configured subagent id (e.g. ``/research``, ``/plan``),
241
- sets the WebSocket ``preferred_subagent`` field so the daemon merges a subagent hint into
242
- StrangeLoop (IG-349). Other routing commands (e.g. ``/plan``) are sent as plain text unchanged.
240
+ For routing commands that map to a configured subagent id (e.g. ``/deep_research``,
241
+ ``/browser_use``), sets the WebSocket ``preferred_subagent`` field so the daemon merges a
242
+ subagent hint into StrangeLoop (IG-349). Other routing commands (e.g. ``/plan``) are sent
243
+ as plain text unchanged.
243
244
 
244
245
  Args:
245
- cmd_input: Full command input (e.g., "/research topic summary")
246
+ cmd_input: Full command input (e.g., "/deep_research topic summary")
246
247
  console: Rich console
247
248
  client: WebSocket client
248
249
  loop_id: Subscribed loop to target (required for ``loop_input``)
@@ -154,8 +154,8 @@ def show_cron_add(console: Console, data: dict[str, Any]) -> None:
154
154
 
155
155
  KEYBOARD_SHORTCUTS: dict[str, str] = {
156
156
  "Esc": "Dismiss modal, plan overlay, or autocomplete",
157
- "Ctrl+D": "Quit and detach from the daemon",
158
- "Ctrl+C": "Clear input, interrupt agent/shell, or quit when pressed twice while idle",
157
+ "Ctrl+D": "Use /quit (or /q) to exit the TUI",
158
+ "Ctrl+C": "Clear input or interrupt running agent/shell",
159
159
  "Ctrl+X": "Open prompt in external editor ($VISUAL/$EDITOR)",
160
160
  "Ctrl+Y": "Copy selected text to clipboard (or show hint if none)",
161
161
  "Ctrl+T": "Toggle plan quick-view overlay",
@@ -272,7 +272,7 @@ COMMANDS: dict[str, dict[str, Any]] = {
272
272
  "requires_query": True,
273
273
  "handler": show_cron_add,
274
274
  },
275
- # Daemon routing commands (3)
275
+ # Daemon routing commands
276
276
  "/plan": {"location": "daemon", "type": "routing", "description": "Trigger plan mode"},
277
277
  "/deep_research": {
278
278
  "location": "daemon",
@@ -286,6 +286,12 @@ COMMANDS: dict[str, dict[str, Any]] = {
286
286
  "description": "Route query to Academic Research subagent",
287
287
  "requires_query": True,
288
288
  },
289
+ "/browser_use": {
290
+ "location": "daemon",
291
+ "type": "routing",
292
+ "description": "Route query to Browser Use subagent",
293
+ "requires_query": True,
294
+ },
289
295
  }
290
296
 
291
297
 
@@ -11,9 +11,13 @@ SUBAGENT_DISPLAY_NAMES: dict[str, str] = {
11
11
  }
12
12
 
13
13
  # Lowercase ids matched after ``/`` for preferred_subagent routing (core only).
14
- SUBAGENT_SLASH_ROUTE_IDS: tuple[str, ...] = ("deep_research", "academic_research")
15
-
16
- BUILTIN_SUBAGENT_NAMES: list[str] = list(SUBAGENT_SLASH_ROUTE_IDS)
14
+ # Longest-first so a shorter id never shadows a longer prefix match.
15
+ # Intake-only specialists (IG-600/601) must stay here so slash sets preferred_subagent.
16
+ SUBAGENT_SLASH_ROUTE_IDS: tuple[str, ...] = (
17
+ "academic_research",
18
+ "deep_research",
19
+ "browser_use",
20
+ )
17
21
 
18
22
 
19
23
  def get_subagent_display_name(technical_name: str) -> str:
@@ -34,7 +38,7 @@ def get_subagent_display_name(technical_name: str) -> str:
34
38
  def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
35
39
  """Parse subagent subcommand from user input.
36
40
 
37
- Detects subagent routing commands (e.g. ``/deep_research``)
41
+ Detects subagent routing commands (e.g. ``/deep_research``, ``/browser_use``)
38
42
  and extracts the subagent name along with the cleaned input text.
39
43
 
40
44
  Args:
@@ -47,14 +51,22 @@ def parse_subagent_from_input(user_input: str) -> tuple[str | None, str]:
47
51
 
48
52
  Examples:
49
53
  ``"/deep_research check this"`` -> ``("deep_research", "check this")``
54
+ ``"/browser_use open example.com"`` -> ``("browser_use", "open example.com")``
50
55
  ``"hello world"`` -> ``(None, "hello world")``
51
56
  """
52
57
  first_match: tuple[int, str] | None = None
58
+ lowered = user_input.lower()
53
59
 
54
60
  for subagent_name in SUBAGENT_SLASH_ROUTE_IDS:
55
61
  subcommand = f"/{subagent_name}"
56
- idx = user_input.lower().find(subcommand)
57
- if idx != -1 and (first_match is None or idx < first_match[0]):
62
+ idx = lowered.find(subcommand)
63
+ if idx == -1:
64
+ continue
65
+ end = idx + len(subcommand)
66
+ # Require end-of-string or whitespace so ``/browser`` does not match ``/browser_use``.
67
+ if end < len(lowered) and not lowered[end].isspace():
68
+ continue
69
+ if first_match is None or idx < first_match[0]:
58
70
  first_match = (idx, subagent_name)
59
71
 
60
72
  if first_match:
@@ -17,6 +17,7 @@ SPINNER_LABEL_RETRYING = "Retrying"
17
17
  SPINNER_LABEL_INPUT = "Input"
18
18
  SPINNER_LABEL_WAITING = "Waiting"
19
19
  SPINNER_LABEL_CONNECTING = "Connecting"
20
+ SPINNER_LABEL_DELEGATING = "Delegating"
20
21
 
21
22
  # Backend plan_phase_status labels (orchestrator nodes) → display word.
22
23
  _PLAN_PHASE_TO_SPINNER: dict[str, str] = {
@@ -38,7 +39,13 @@ def map_plan_phase_spinner_label(label: str) -> str:
38
39
  key = label.strip()
39
40
  if not key:
40
41
  return SPINNER_LABEL_THINKING
41
- return _PLAN_PHASE_TO_SPINNER.get(key, SPINNER_LABEL_THINKING)
42
+ mapped = _PLAN_PHASE_TO_SPINNER.get(key)
43
+ if mapped:
44
+ return mapped
45
+ # Intake-only wire: «Delegating to {subagent}» (RFC-630 / IG-602).
46
+ if key.lower().startswith("delegating"):
47
+ return SPINNER_LABEL_DELEGATING
48
+ return SPINNER_LABEL_THINKING
42
49
 
43
50
 
44
51
  def retry_spinner_hint(*, attempt: int, max_attempts: int) -> str | None: