soothe-cli 0.6.8__tar.gz → 0.6.10__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 (115) hide show
  1. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/PKG-INFO +1 -1
  2. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/commands/autopilot_cmd.py +74 -13
  3. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/commands/status_cmd.py +52 -67
  4. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/parse/tool_call_resolution.py +34 -2
  5. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/state/step_router.py +48 -9
  6. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/transport/session.py +8 -0
  7. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_app.py +1 -0
  8. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_execution.py +3 -0
  9. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_model.py +57 -1
  10. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_startup.py +7 -0
  11. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/commands/slash_commands.py +7 -0
  12. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/input.py +37 -26
  13. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/preview_limits.py +2 -2
  14. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/textual_adapter.py +89 -33
  15. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/tool_display.py +17 -3
  16. soothe_cli-0.6.10/src/soothe_cli/tui/widgets/autopilot_dashboard.py +614 -0
  17. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/autopilot_screen.py +4 -3
  18. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/chat_input.py +37 -5
  19. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/loading.py +39 -78
  20. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/messages.py +118 -8
  21. soothe_cli-0.6.8/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -318
  22. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/.gitignore +0 -0
  23. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/README.md +0 -0
  24. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/pyproject.toml +0 -0
  25. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/__init__.py +0 -0
  26. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/__init__.py +0 -0
  27. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/commands/__init__.py +0 -0
  28. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  29. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  30. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/execution/__init__.py +0 -0
  31. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/execution/daemon.py +0 -0
  32. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  33. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/execution/headless.py +0 -0
  34. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  35. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/execution/launcher.py +0 -0
  36. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/cli/main.py +0 -0
  37. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/config/__init__.py +0 -0
  38. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/config/cli_config.py +0 -0
  39. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/config/loader.py +0 -0
  40. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/config/logging_setup.py +0 -0
  41. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/__init__.py +0 -0
  42. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/headless/processor.py +0 -0
  43. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  44. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/parse/_utils.py +0 -0
  45. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  46. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/parse/tool_message_format.py +0 -0
  47. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  48. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  49. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  50. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  51. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/presentation/async_renderer_protocol.py +0 -0
  52. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  53. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  54. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  55. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  56. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  57. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  58. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  59. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  60. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/state/transcript.py +0 -0
  61. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/task_scope.py +0 -0
  62. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  63. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  64. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  65. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  66. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  67. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/runtime/wire/messages.py +0 -0
  68. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/__init__.py +0 -0
  69. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/_cli_context.py +0 -0
  70. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/_env_vars.py +0 -0
  71. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/_version.py +0 -0
  72. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/__init__.py +0 -0
  73. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_commands.py +0 -0
  74. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_history.py +0 -0
  75. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  76. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_module_init.py +0 -0
  77. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/_ui.py +0 -0
  78. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/app/app.tcss +0 -0
  79. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/binding.py +0 -0
  80. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/command_registry.py +0 -0
  81. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/commands/__init__.py +0 -0
  82. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/commands/command_router.py +0 -0
  83. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  84. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/config.py +0 -0
  85. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/file_change_notify.py +0 -0
  86. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  87. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/hooks.py +0 -0
  88. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/media_utils.py +0 -0
  89. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/model_config.py +0 -0
  90. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/path_utils.py +0 -0
  91. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/project_utils.py +0 -0
  92. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/sessions.py +0 -0
  93. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/skills/__init__.py +0 -0
  94. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/skills/invocation.py +0 -0
  95. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/skills/load.py +0 -0
  96. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/theme.py +0 -0
  97. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/tips.py +0 -0
  98. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/unicode_security.py +0 -0
  99. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/update_check.py +0 -0
  100. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  101. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/_links.py +0 -0
  102. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  103. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  104. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/diff.py +0 -0
  105. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/editor.py +0 -0
  106. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  107. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/history.py +0 -0
  108. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  109. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  110. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  111. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  112. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  113. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/status.py +0 -0
  114. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  115. {soothe_cli-0.6.8 → soothe_cli-0.6.10}/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.6.8
3
+ Version: 0.6.10
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
@@ -10,6 +10,7 @@ import asyncio
10
10
  import os
11
11
  import sys
12
12
  import time
13
+ from collections import Counter
13
14
  from pathlib import Path
14
15
 
15
16
  import typer
@@ -124,12 +125,40 @@ def submit(
124
125
 
125
126
  @app.command("status")
126
127
  def status() -> None:
127
- """Show overall autopilot state from the daemon."""
128
+ """Show overall autopilot state and goal DAG summary."""
128
129
  client = _require_daemon_http()
129
130
  data = client.status()
130
- typer.echo(f"Autopilot state: {data.get('state', data.get('status', 'unknown'))}")
131
- if "active_goals" in data:
132
- typer.echo(f"Active goals: {len(data['active_goals'])}")
131
+ state = data.get("state", data.get("status", "unknown"))
132
+ running = data.get("running", False)
133
+ dreaming = data.get("dreaming", False)
134
+ typer.echo(f"Autopilot state: {state}")
135
+ typer.echo(f"Scheduling loop: {'running' if running else 'stopped'}")
136
+ if dreaming:
137
+ typer.echo("Dreaming: yes")
138
+ loop_pool = data.get("loop_pool")
139
+ if isinstance(loop_pool, dict) and loop_pool:
140
+ typer.echo(f"Worker pool: {loop_pool}")
141
+
142
+ jobs = client.list_jobs().get("jobs") or []
143
+ goals = client.list_goals().get("goals") or []
144
+ typer.echo(f"\nJobs (root goals): {len(jobs)}")
145
+ if goals:
146
+ counts = Counter(str(g.get("status", "pending")) for g in goals)
147
+ typer.echo(f"Goals in DAG: {len(goals)}")
148
+ for stat, count in sorted(counts.items()):
149
+ typer.echo(f" {stat}: {count}")
150
+ elif not jobs:
151
+ typer.echo("Goals in DAG: 0")
152
+
153
+ if jobs:
154
+ typer.echo("\nJobs:")
155
+ for j in jobs:
156
+ jid = str(j.get("id", "?"))
157
+ sid = jid[:8]
158
+ sstat = j.get("status", "pending")
159
+ sdesc = preview_first(j.get("description", ""), 50)
160
+ typer.echo(f" [{sid}] {sstat:10s} {sdesc}")
161
+ typer.echo("\nFull DAG for a job: soothe autopilot job <job_id>")
133
162
 
134
163
 
135
164
  @app.command("list")
@@ -139,8 +168,20 @@ def list_jobs(
139
168
  """List jobs (root goals) from the daemon autopilot.
140
169
 
141
170
  Jobs are user-submitted tasks. Subgoals created during autonomous
142
- execution are not shown here; use 'goal <id>' to inspect subgoals.
171
+ execution are not shown here; use ``goals`` or ``goal <id>`` for details.
143
172
  """
173
+ _list_jobs_impl(status_filter)
174
+
175
+
176
+ @app.command("jobs")
177
+ def list_jobs_alias(
178
+ status_filter: str = typer.Option("", "--status", "-s", help="Filter by status."),
179
+ ) -> None:
180
+ """Alias for ``list`` — list root autopilot jobs."""
181
+ _list_jobs_impl(status_filter)
182
+
183
+
184
+ def _list_jobs_impl(status_filter: str) -> None:
144
185
  client = _require_daemon_http()
145
186
  payload = client.list_jobs()
146
187
  jobs = payload.get("jobs") or []
@@ -158,6 +199,29 @@ def list_jobs(
158
199
  typer.echo(f" [{sid}] {sstat:10s} pri={spri:3d} {sdesc}")
159
200
 
160
201
 
202
+ @app.command("goals")
203
+ def list_goals(
204
+ status_filter: str = typer.Option("", "--status", "-s", help="Filter by status."),
205
+ ) -> None:
206
+ """List all goals in the daemon autopilot DAG (including subgoals)."""
207
+ client = _require_daemon_http()
208
+ payload = client.list_goals()
209
+ goals = payload.get("goals") or []
210
+ if not goals:
211
+ typer.echo("No goals found.")
212
+ return
213
+
214
+ for g in goals:
215
+ if status_filter and g.get("status", "") != status_filter:
216
+ continue
217
+ gid = str(g.get("id", "?"))[:8]
218
+ parent = g.get("parent_id")
219
+ parent_s = f" parent={str(parent)[:8]}" if parent else ""
220
+ desc = preview_first(g.get("description", ""), 50)
221
+ stat = g.get("status", "pending")
222
+ typer.echo(f" [{gid}] {stat:10s}{parent_s} {desc}")
223
+
224
+
161
225
  def _render_dag_tree(dag: dict, root_id: str) -> None:
162
226
  """Render DAG as ASCII tree for job visualization."""
163
227
  nodes = {n["id"]: n for n in dag.get("nodes", [])}
@@ -199,9 +263,9 @@ def _render_dag_tree(dag: dict, root_id: str) -> None:
199
263
 
200
264
  @app.command("job")
201
265
  def show_job(
202
- job_id: str = typer.Argument(..., help="Job ID to show details and DAG."),
266
+ job_id: str = typer.Argument(..., help="Job ID to show details and goal DAG."),
203
267
  ) -> None:
204
- """Show job status and DAG tree visualization.
268
+ """Show job status and goal DAG tree visualization.
205
269
 
206
270
  A job is a root goal submitted by the user. This command shows
207
271
  the job's details and the complete goal DAG under it.
@@ -211,7 +275,7 @@ def show_job(
211
275
  payload = client.get_job(job_id)
212
276
  except RuntimeError as exc:
213
277
  typer.echo(str(exc), err=True)
214
- raise typer.Exit(1)
278
+ raise typer.Exit(1) from exc
215
279
 
216
280
  job = payload.get("job")
217
281
  dag = payload.get("dag")
@@ -228,11 +292,9 @@ def show_job(
228
292
  typer.echo(f"Workspace: {job['workspace']}")
229
293
  created = job.get("created_at", "")
230
294
  if created:
231
- # Truncate timestamp for readability
232
295
  created_short = created[:19] if len(created) > 19 else created
233
296
  typer.echo(f"Created: {created_short}")
234
297
 
235
- # DAG stats from response
236
298
  active = payload.get("active_goals", 0)
237
299
  completed = payload.get("completed_goals", 0)
238
300
  total = payload.get("total_goals", 0)
@@ -240,10 +302,9 @@ def show_job(
240
302
  typer.echo(f"Completed goals: {completed}")
241
303
  typer.echo(f"Total goals: {total}")
242
304
 
243
- # DAG tree
244
- typer.echo("\nDAG:")
305
+ typer.echo("\nGoal DAG:")
245
306
  if dag:
246
- _render_dag_tree(dag, job_id)
307
+ _render_dag_tree(dag, str(job.get("id") or job_id))
247
308
  else:
248
309
  typer.echo(" (no subgoals)")
249
310
 
@@ -74,39 +74,41 @@ async def _fetch_ready_state(ws_url: str, timeout: float = 5.0) -> dict[str, Any
74
74
  return None
75
75
 
76
76
 
77
- def _render_connection_table(config: Any, ws_url: str) -> Table:
78
- """Render connection settings table."""
79
- table = Table(title="Connection Settings")
80
- table.add_column("Setting", style="cyan")
81
- table.add_column("Value", style="green")
82
-
83
- table.add_row("WebSocket URL", ws_url)
84
- table.add_row("Daemon Host", config.daemon_host)
85
- table.add_row("Daemon Port", str(config.daemon_port))
86
- table.add_row("Soothe Home", str(config.soothe_home))
87
-
88
- return table
89
-
90
-
91
- def _render_daemon_table(
77
+ def _render_unified_status_table(
78
+ config: Any,
92
79
  ws_url: str,
93
- running: bool,
94
- port_live: bool,
95
- active_threads: int,
96
- daemon_pid: int | None,
80
+ running: bool | None = None,
81
+ port_live: bool | None = None,
82
+ active_threads: int | None = None,
83
+ daemon_pid: int | None = None,
97
84
  ready_state: dict[str, Any] | None = None,
85
+ daemon_live: bool = True,
98
86
  ) -> Table:
99
- """Render daemon status table."""
100
- table = Table(title="Daemon Status")
87
+ """Render unified status table without duplicated info.
88
+
89
+ Sections:
90
+ - Connection: WebSocket URL, Soothe Home
91
+ - Daemon: Running, Threads, PID (only when daemon is live)
92
+ """
93
+ table = Table(title="Soothe Status")
94
+ table.add_column("Section", style="dim", width=12)
101
95
  table.add_column("Setting", style="cyan")
102
96
  table.add_column("Value", style="green")
103
97
 
104
- table.add_row("WebSocket URL", ws_url)
105
- table.add_row("Running", "[green]Yes[/green]" if running else "[red]No[/red]")
106
- table.add_row("Port Live", "[green]Yes[/green]" if port_live else "[red]No[/red]")
107
- table.add_row("Active Threads", str(active_threads))
98
+ # Connection section
99
+ table.add_row("Connection", "WebSocket URL", ws_url)
100
+ table.add_row("", "Soothe Home", str(config.soothe_home))
101
+
102
+ # Daemon section
103
+ if not daemon_live:
104
+ table.add_row("Daemon", "Status", "[red]Not running[/red]")
105
+ return table
106
+
107
+ table.add_row("Daemon", "Status", "[green]Running[/green]")
108
108
  if daemon_pid:
109
- table.add_row("Daemon PID", str(daemon_pid))
109
+ table.add_row("", "PID", str(daemon_pid))
110
+ if active_threads is not None:
111
+ table.add_row("", "Active Threads", str(active_threads))
110
112
 
111
113
  if ready_state:
112
114
  state = ready_state.get("state", "unknown")
@@ -118,9 +120,9 @@ def _render_daemon_table(
118
120
  "warming": "blue",
119
121
  "stopped": "dim",
120
122
  }.get(state, "white")
121
- table.add_row("Readiness", f"[{state_color}]{state}[/{state_color}]")
123
+ table.add_row("", "Readiness", f"[{state_color}]{state}[/{state_color}]")
122
124
  if ready_state.get("message"):
123
- table.add_row("Message", ready_state["message"])
125
+ table.add_row("", "Message", ready_state["message"])
124
126
 
125
127
  return table
126
128
 
@@ -163,15 +165,9 @@ def daemon_status(
163
165
  )
164
166
  )
165
167
  else:
166
- console.print(
167
- Panel(
168
- f"WebSocket URL: {ws_url}\n"
169
- "Status: [red]Not running[/red]\n"
170
- "Hint: Start with 'soothed start'",
171
- title="Daemon Status",
172
- border_style="red",
173
- )
174
- )
168
+ table = _render_unified_status_table(config, ws_url, daemon_live=False)
169
+ console.print(table)
170
+ console.print("\n[dim]Hint: Start with 'soothed start'[/dim]")
175
171
  sys.exit(1)
176
172
 
177
173
  # Fetch detailed status
@@ -218,14 +214,14 @@ def daemon_status(
218
214
  console.print_json(json.dumps(output))
219
215
  return
220
216
 
221
- # Render daemon status table
217
+ # Render unified daemon status table
222
218
  running = status.get("running", True)
223
219
  port_live = status.get("port_live", True)
224
220
  active_threads = status.get("active_threads", 0)
225
221
  daemon_pid = status.get("daemon_pid")
226
222
 
227
- table = _render_daemon_table(
228
- ws_url, running, port_live, active_threads, daemon_pid, ready_state
223
+ table = _render_unified_status_table(
224
+ config, ws_url, running, port_live, active_threads, daemon_pid, ready_state
229
225
  )
230
226
  console.print(table)
231
227
 
@@ -261,7 +257,12 @@ def connection_status(
261
257
  )
262
258
  return
263
259
 
264
- table = _render_connection_table(config, ws_url)
260
+ # Simple connection table
261
+ table = Table(title="Connection Settings")
262
+ table.add_column("Setting", style="cyan")
263
+ table.add_column("Value", style="green")
264
+ table.add_row("WebSocket URL", ws_url)
265
+ table.add_row("Soothe Home", str(config.soothe_home))
265
266
  console.print(table)
266
267
 
267
268
 
@@ -313,20 +314,11 @@ def status_main(
313
314
  console.print_json(json.dumps(output))
314
315
  return
315
316
 
316
- # Render combined status with tables
317
+ # Render unified status table
317
318
  if not live:
318
- console.print(
319
- Panel(
320
- f"WebSocket URL: {ws_url}\n"
321
- f"Daemon Host: {config.daemon_host}\n"
322
- f"Daemon Port: {config.daemon_port}\n"
323
- f"Soothe Home: {config.soothe_home}\n\n"
324
- "Daemon Status: [red]Not running[/red]\n"
325
- "Hint: Start with 'soothed start'",
326
- title="Soothe Status",
327
- border_style="red",
328
- )
329
- )
319
+ table = _render_unified_status_table(config, ws_url, daemon_live=False)
320
+ console.print(table)
321
+ console.print("\n[dim]Hint: Start with 'soothed start'[/dim]")
330
322
  sys.exit(1)
331
323
 
332
324
  # Fetch detailed daemon status
@@ -335,29 +327,22 @@ def status_main(
335
327
  if "error" in status:
336
328
  console.print(
337
329
  Panel(
338
- f"WebSocket URL: {ws_url}\n"
339
- f"Daemon Host: {config.daemon_host}\n"
340
- f"Daemon Port: {config.daemon_port}\n"
341
- f"Soothe Home: {config.soothe_home}\n\n"
342
- f"Daemon Status: [red]Error[/red]\n"
343
- f"Error: {status['error']}",
344
- title="Soothe Status",
330
+ f"WebSocket URL: {ws_url}\nError: [red]{status['error']}[/red]",
331
+ title="Daemon Status",
345
332
  border_style="red",
346
333
  )
347
334
  )
348
335
  sys.exit(1)
349
336
 
350
- # Render both tables
351
- connection_table = _render_connection_table(config, ws_url)
352
- console.print(connection_table)
353
-
354
337
  running = status.get("running", True)
355
338
  port_live = status.get("port_live", True)
356
339
  active_threads = status.get("active_threads", 0)
357
340
  daemon_pid = status.get("daemon_pid")
358
341
 
359
- daemon_table = _render_daemon_table(ws_url, running, port_live, active_threads, daemon_pid)
360
- console.print(daemon_table)
342
+ table = _render_unified_status_table(
343
+ config, ws_url, running, port_live, active_threads, daemon_pid
344
+ )
345
+ console.print(table)
361
346
 
362
347
 
363
348
  __all__ = [
@@ -39,6 +39,18 @@ def tool_args_meaningful(raw: Any) -> bool:
39
39
  return True
40
40
 
41
41
 
42
+ def is_execute_step_namespace(ns_key: tuple[str, ...]) -> bool:
43
+ """True for CoreAgent execute root namespace (``execute:{run_id}``), not nested ``tools:`` subgraphs."""
44
+ if len(ns_key) != 1:
45
+ return False
46
+ return str(ns_key[0] or "").startswith("execute:")
47
+
48
+
49
+ def is_step_card_tool_scope(*, ns_key: tuple[str, ...]) -> bool:
50
+ """True when tool activity belongs on the step card as main execute-graph tools."""
51
+ return ns_key == () or is_execute_step_namespace(ns_key)
52
+
53
+
42
54
  def is_main_step_level_tool_call_id(tool_call_id: str) -> bool:
43
55
  """True for unified main-graph step tools (``{step}:s:{tool}:{n}``), not ``task`` rows."""
44
56
  from soothe_sdk.ux.task_namespace import is_step_level_task_tool_id, parse_unified_tool_call_id
@@ -52,9 +64,23 @@ def is_main_step_level_tool_call_id(tool_call_id: str) -> bool:
52
64
  return not is_step_level_task_tool_id(tcid)
53
65
 
54
66
 
67
+ def is_task_level_subgraph_tool_call_id(tool_call_id: str) -> bool:
68
+ """True for unified subgraph tools (``{step}:t{n}:{tool}:{seq}``), not nested ``task`` rows."""
69
+ from soothe_sdk.ux.task_namespace import (
70
+ is_inner_subgraph_task_tool_id,
71
+ parse_unified_tool_call_id,
72
+ )
73
+
74
+ tcid = str(tool_call_id or "").strip()
75
+ if not tcid or is_inner_subgraph_task_tool_id(tcid):
76
+ return False
77
+ _, type_code, _, _ = parse_unified_tool_call_id(tcid)
78
+ return type_code == "t"
79
+
80
+
55
81
  def should_ingest_tool_for_step_stats(
56
82
  *,
57
- is_main_agent: bool,
83
+ is_step_card_scope: bool,
58
84
  tool_name: str,
59
85
  tool_call_id: str,
60
86
  args_meaningful: bool,
@@ -70,7 +96,10 @@ def should_ingest_tool_for_step_stats(
70
96
  return False
71
97
  if args_meaningful:
72
98
  return True
73
- return is_main_agent and is_main_step_level_tool_call_id(tcid)
99
+ if is_step_card_scope and is_main_step_level_tool_call_id(tcid):
100
+ return True
101
+ # Subgraph explore tools often arrive with ``{"_subgraph_tool": true}`` before real args.
102
+ return not is_step_card_scope and is_task_level_subgraph_tool_call_id(tcid)
74
103
 
75
104
 
76
105
  def _args_from_toolish_block(block: dict[str, Any]) -> dict[str, Any]:
@@ -465,7 +494,10 @@ def build_streaming_args_overlay(
465
494
  __all__ = [
466
495
  "ResolvedToolInvocation",
467
496
  "build_streaming_args_overlay",
497
+ "is_execute_step_namespace",
468
498
  "is_main_step_level_tool_call_id",
499
+ "is_step_card_tool_scope",
500
+ "is_task_level_subgraph_tool_call_id",
469
501
  "is_toolish_display_block",
470
502
  "materialize_ai_blocks_with_resolved_tools",
471
503
  "merge_tool_display_args",
@@ -17,7 +17,7 @@ from typing import Any, TypeAlias
17
17
  from soothe_sdk.ux.task_namespace import (
18
18
  TaskScope,
19
19
  is_inner_subgraph_task_tool_id,
20
- normalize_step_task_tool_call_id,
20
+ normalize_main_task_delegation_id,
21
21
  parse_unified_tool_call_id,
22
22
  prune_bound_pending_namespaces,
23
23
  register_task_spawn_for_step,
@@ -62,6 +62,21 @@ def _subgraph_pending_key(ns_key: tuple[str, ...], lookup_id: str) -> tuple[tupl
62
62
  return (ns_key, str(lookup_id).strip())
63
63
 
64
64
 
65
+ def _is_task_metadata_subgraph_tool(item: PendingSubgraphTool) -> bool:
66
+ """True when a buffered subgraph item is task metadata, not a user-facing tool row."""
67
+ if (item.tool_name or "").strip() == "task":
68
+ return True
69
+ for candidate in (item.lookup_id, item.display_key):
70
+ cid = str(candidate or "").strip()
71
+ if cid and is_inner_subgraph_task_tool_id(cid):
72
+ return True
73
+ args = item.args if isinstance(item.args, dict) else {}
74
+ subagent_type = str(args.get("subagent_type") or "").strip()
75
+ prompt = str(args.get("description") or args.get("prompt") or "").strip()
76
+ # Some providers emit opaque names (e.g. "tool-<id>") for task chunks.
77
+ return bool(subagent_type and prompt)
78
+
79
+
65
80
  @dataclass
66
81
  class StepTaskRouter:
67
82
  """High-performance per-turn router for steps, tools, and task namespaces.
@@ -123,7 +138,15 @@ class StepTaskRouter:
123
138
  if existing is None:
124
139
  self._pending_subgraph_tools[key] = item
125
140
  return
126
- args = item.args if len(item.args) >= len(existing.args) else existing.args
141
+ # Prefer meaningful args over placeholder metadata like {"_subgraph_tool": true}.
142
+ from soothe_cli.runtime.parse.message_processing import extract_tool_args_dict
143
+
144
+ item_meaningful = extract_tool_args_dict(item.args or {})
145
+ existing_meaningful = extract_tool_args_dict(existing.args or {})
146
+ if len(item_meaningful) >= len(existing_meaningful):
147
+ args = item.args
148
+ else:
149
+ args = existing.args
127
150
  raw = item.raw_args if len(item.raw_args) >= len(existing.raw_args) else existing.raw_args
128
151
  self._pending_subgraph_tools[key] = PendingSubgraphTool(
129
152
  ns_key=item.ns_key,
@@ -219,14 +242,12 @@ class StepTaskRouter:
219
242
  if not tcid or is_inner_subgraph_task_tool_id(tcid):
220
243
  return False
221
244
  parsed_sid, type_code, _, _ = parse_unified_tool_call_id(tcid)
222
- if type_code == "t":
223
- return False
224
- sid = parsed_sid if (parsed_sid and type_code == "s") else ""
245
+ sid = parsed_sid if (parsed_sid and type_code in ("s", "t")) else ""
225
246
  if not sid:
226
247
  sid = str(step_id).strip()
227
248
  if not sid:
228
249
  return False
229
- normalized_tcid = normalize_step_task_tool_call_id(sid, tcid)
250
+ normalized_tcid = normalize_main_task_delegation_id(sid, tcid, tool_name="task")
230
251
  spawn_key = (sid, normalized_tcid)
231
252
  if spawn_key in self._spawn_recorded:
232
253
  return False
@@ -375,7 +396,7 @@ class StepTaskRouter:
375
396
  tool_to_step: dict[str, ParentWidget],
376
397
  ) -> bool:
377
398
  """Register one subgraph tool row on an already-resolved parent step card."""
378
- if (item.tool_name or "").strip() == "task":
399
+ if _is_task_metadata_subgraph_tool(item):
379
400
  # Inner explore ``task`` chunks are not user-facing tool stats; ingesting
380
401
  # them used to rewrite the main ``{step}:s:task:…`` delegation row args.
381
402
  return True
@@ -399,13 +420,31 @@ class StepTaskRouter:
399
420
  if has_row(row_id):
400
421
  update = getattr(parent, "update_tool_args", None)
401
422
  if callable(update):
402
- update(row_id, item.args)
423
+ resolved_args = dict(item.args or {})
424
+ # Placeholder args like {"_subgraph_tool": true} are not meaningful.
425
+ # Parse raw_args when resolved_args lacks real invocation kwargs.
426
+ from soothe_cli.runtime.parse.message_processing import extract_tool_args_dict
427
+
428
+ meaningful_args = extract_tool_args_dict(resolved_args)
429
+ if item.raw_args and not meaningful_args:
430
+ parsed = extract_tool_args_dict({"_raw": item.raw_args})
431
+ if parsed:
432
+ resolved_args = parsed
433
+ update(row_id, resolved_args)
403
434
  else:
404
435
  parent_task_id = str(scope[0]).strip()
436
+ resolved_args = dict(item.args or {})
437
+ from soothe_cli.runtime.parse.message_processing import extract_tool_args_dict
438
+
439
+ meaningful_args = extract_tool_args_dict(resolved_args)
440
+ if item.raw_args and not meaningful_args:
441
+ parsed = extract_tool_args_dict({"_raw": item.raw_args})
442
+ if parsed:
443
+ resolved_args = parsed
405
444
  ingest(
406
445
  row_id,
407
446
  item.tool_name,
408
- dict(item.args or {}),
447
+ resolved_args,
409
448
  raw_args=item.raw_args,
410
449
  parent_tool_call_id=parent_task_id or None,
411
450
  )
@@ -45,6 +45,7 @@ class TuiDaemonSession:
45
45
  self._client = WebSocketClient(url=ws_url)
46
46
  self._rpc_client = WebSocketClient(url=ws_url)
47
47
  self._loop_id: str | None = None
48
+ self._autopilot_mode: str | None = None
48
49
  self._read_lock = asyncio.Lock()
49
50
  self._rpc_lock = asyncio.Lock()
50
51
  self._rpc_connected = False
@@ -57,6 +58,11 @@ class TuiDaemonSession:
57
58
  """Active StrangeLoop id for this WebSocket session."""
58
59
  return self._loop_id
59
60
 
61
+ @property
62
+ def autopilot_mode(self) -> str | None:
63
+ """Active loop Solo/Autopilot mode when known."""
64
+ return self._autopilot_mode
65
+
60
66
  async def connect(self, *, resume_loop_id: str | None = None) -> dict[str, Any]:
61
67
  """Connect and bootstrap a daemon loop session."""
62
68
  await connect_websocket_with_retries(self._client)
@@ -77,6 +83,8 @@ class TuiDaemonSession:
77
83
  if status_event.get("type") == "error":
78
84
  raise RuntimeError(str(status_event.get("message", "daemon bootstrap failed")))
79
85
  self._loop_id = status_event.get("loop_id")
86
+ mode = status_event.get("autopilot_mode")
87
+ self._autopilot_mode = str(mode) if mode in ("solo", "autopilot") else None
80
88
  return status_event
81
89
 
82
90
  def _resolve_stream_delivery_mode(self) -> str:
@@ -177,6 +177,7 @@ class SootheApp(
177
177
  # Active StrangeLoop id; LangGraph stores it as configurable.thread_id.
178
178
  # Named `_lc_loop_id` to avoid colliding with Textual's App._thread_id.
179
179
  self._lc_loop_id = resume_loop_id
180
+ self._loop_autopilot_mode: str = "solo"
180
181
 
181
182
  self._initial_prompt = initial_prompt
182
183
 
@@ -570,6 +570,7 @@ class _ExecutionMixin:
570
570
  )
571
571
  self._session_state.loop_id = new_loop_id
572
572
  self._lc_loop_id = new_loop_id
573
+ self._apply_loop_autopilot_mode(status_event.get("autopilot_mode"))
573
574
  try:
574
575
  banner = self.query_one("#welcome-banner", WelcomeBanner)
575
576
  banner.update_loop_id(new_loop_id)
@@ -650,6 +651,8 @@ class _ExecutionMixin:
650
651
  await self._submit_autopilot_job(args)
651
652
  elif cmd == "/autopilot-dashboard":
652
653
  await self._show_autopilot_dashboard()
654
+ elif cmd == "/autopilot-toggle":
655
+ await self._toggle_autopilot_mode()
653
656
  elif cmd == "/mcp":
654
657
  await self._show_mcp_viewer()
655
658
  elif cmd == "/theme":
@@ -265,12 +265,26 @@ class _ModelMixin:
265
265
  return None
266
266
  return resp.get("servers")
267
267
 
268
+ def _apply_loop_autopilot_mode(self, mode: str | None) -> None:
269
+ """Sync local Solo/Autopilot mode from daemon bootstrap or toggle events."""
270
+ if mode not in ("solo", "autopilot"):
271
+ return
272
+ self._loop_autopilot_mode = mode
273
+ if self._status_bar is not None:
274
+ label = "Autopilot" if mode == "autopilot" else "Solo"
275
+ self._status_bar.set_session_tip(f"Mode: {label}")
276
+
268
277
  async def _show_autopilot_dashboard(self) -> None:
269
278
  """Show autopilot dashboard as a screen overlay."""
270
279
  from soothe_cli.tui.widgets.autopilot_screen import AutopilotScreen
271
280
 
272
281
  is_narrow = self.size.width < 100
273
- screen = AutopilotScreen(is_narrow=is_narrow)
282
+ mode = (
283
+ self._loop_autopilot_mode
284
+ if self._loop_autopilot_mode in ("solo", "autopilot")
285
+ else "solo"
286
+ )
287
+ screen = AutopilotScreen(is_narrow=is_narrow, mode=mode)
274
288
 
275
289
  def handle_result(result: None) -> None: # noqa: ARG001
276
290
  if self._chat_input:
@@ -278,6 +292,48 @@ class _ModelMixin:
278
292
 
279
293
  self.push_screen(screen, handle_result)
280
294
 
295
+ async def _toggle_autopilot_mode(self) -> None:
296
+ """Toggle autopilot mode (solo ↔ autopilot) via daemon RPC.
297
+
298
+ Sends command_request to daemon and updates local dashboard mode.
299
+ """
300
+ from soothe_cli.tui.widgets.messages import ErrorMessage, UserMessage
301
+
302
+ await self._mount_message(UserMessage("/autopilot-toggle"))
303
+
304
+ lid = self._loop_id
305
+ if not lid:
306
+ await self._mount_message(ErrorMessage("No active loop for autopilot toggle"))
307
+ return
308
+
309
+ if not self._client:
310
+ await self._mount_message(ErrorMessage("Not connected to daemon"))
311
+ return
312
+
313
+ try:
314
+ from soothe_cli.tui.commands.command_router import handle_rpc_command
315
+ from soothe_cli.tui.commands.slash_commands import COMMANDS
316
+
317
+ entry = COMMANDS.get("/autopilot-toggle")
318
+ if not entry:
319
+ await self._mount_message(ErrorMessage("Command /autopilot-toggle not found"))
320
+ return
321
+
322
+ await handle_rpc_command(
323
+ entry,
324
+ "autopilot-toggle",
325
+ None,
326
+ self._console,
327
+ self._client,
328
+ loop_id=lid,
329
+ )
330
+ new_mode = "solo" if self._loop_autopilot_mode == "autopilot" else "autopilot"
331
+ self._apply_loop_autopilot_mode(new_mode)
332
+
333
+ except Exception as exc:
334
+ logger.exception("Autopilot toggle failed")
335
+ await self._mount_message(ErrorMessage(f"Failed to toggle autopilot: {exc}"))
336
+
281
337
  async def _submit_autopilot_job(self, task: str) -> None:
282
338
  """Submit an autopilot job via HTTP REST (like CLI `soothe autopilot run`).
283
339
 
@@ -478,6 +478,13 @@ class _StartupMixin:
478
478
  if self._session_state is not None:
479
479
  self._session_state.loop_id = status_loop_id
480
480
 
481
+ autopilot_mode = event.status_event.get("autopilot_mode")
482
+ if autopilot_mode not in ("solo", "autopilot") and event.session is not None:
483
+ autopilot_mode = getattr(event.session, "autopilot_mode", None)
484
+ self._apply_loop_autopilot_mode(
485
+ str(autopilot_mode) if autopilot_mode in ("solo", "autopilot") else None
486
+ )
487
+
481
488
  try:
482
489
  banner = self.query_one("#welcome-banner", WelcomeBanner)
483
490
  banner.set_connected(self._mcp_tool_count)