soothe-cli 0.1.0__py3-none-any.whl
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.
- soothe_cli/__init__.py +5 -0
- soothe_cli/cli/__init__.py +1 -0
- soothe_cli/cli/commands/__init__.py +1 -0
- soothe_cli/cli/commands/autopilot_cmd.py +410 -0
- soothe_cli/cli/commands/config_cmd.py +277 -0
- soothe_cli/cli/commands/run_cmd.py +87 -0
- soothe_cli/cli/commands/status_cmd.py +121 -0
- soothe_cli/cli/commands/subagent_names.py +17 -0
- soothe_cli/cli/commands/thread_cmd.py +657 -0
- soothe_cli/cli/execution/__init__.py +6 -0
- soothe_cli/cli/execution/daemon.py +194 -0
- soothe_cli/cli/execution/headless.py +99 -0
- soothe_cli/cli/execution/launcher.py +31 -0
- soothe_cli/cli/main.py +509 -0
- soothe_cli/cli/renderer.py +444 -0
- soothe_cli/cli/stream/__init__.py +17 -0
- soothe_cli/cli/stream/context.py +138 -0
- soothe_cli/cli/stream/display_line.py +83 -0
- soothe_cli/cli/stream/formatter.py +412 -0
- soothe_cli/cli/stream/pipeline.py +521 -0
- soothe_cli/cli/utils.py +46 -0
- soothe_cli/config/__init__.py +5 -0
- soothe_cli/config/cli_config.py +155 -0
- soothe_cli/plan/__init__.py +5 -0
- soothe_cli/plan/rich_tree.py +54 -0
- soothe_cli/shared/__init__.py +107 -0
- soothe_cli/shared/command_router.py +246 -0
- soothe_cli/shared/config_loader.py +68 -0
- soothe_cli/shared/display_policy.py +413 -0
- soothe_cli/shared/essential_events.py +68 -0
- soothe_cli/shared/event_processor.py +823 -0
- soothe_cli/shared/message_processing.py +393 -0
- soothe_cli/shared/presentation_engine.py +173 -0
- soothe_cli/shared/processor_state.py +80 -0
- soothe_cli/shared/renderer_protocol.py +158 -0
- soothe_cli/shared/rendering.py +43 -0
- soothe_cli/shared/slash_commands.py +354 -0
- soothe_cli/shared/subagent_routing.py +63 -0
- soothe_cli/shared/suppression_state.py +188 -0
- soothe_cli/shared/tool_formatters/__init__.py +27 -0
- soothe_cli/shared/tool_formatters/base.py +109 -0
- soothe_cli/shared/tool_formatters/execution.py +297 -0
- soothe_cli/shared/tool_formatters/fallback.py +128 -0
- soothe_cli/shared/tool_formatters/file_ops.py +299 -0
- soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
- soothe_cli/shared/tool_formatters/media.py +291 -0
- soothe_cli/shared/tool_formatters/structured.py +202 -0
- soothe_cli/shared/tool_formatters/web.py +143 -0
- soothe_cli/shared/tool_output_formatter.py +227 -0
- soothe_cli/shared/tui_trace_log.py +40 -0
- soothe_cli/tui/__init__.py +5 -0
- soothe_cli/tui/_ask_user_types.py +50 -0
- soothe_cli/tui/_cli_context.py +27 -0
- soothe_cli/tui/_env_vars.py +56 -0
- soothe_cli/tui/_session_stats.py +114 -0
- soothe_cli/tui/_version.py +21 -0
- soothe_cli/tui/app.py +4992 -0
- soothe_cli/tui/app.tcss +302 -0
- soothe_cli/tui/command_registry.py +310 -0
- soothe_cli/tui/config.py +2381 -0
- soothe_cli/tui/daemon_session.py +233 -0
- soothe_cli/tui/file_ops.py +409 -0
- soothe_cli/tui/formatting.py +28 -0
- soothe_cli/tui/hooks.py +23 -0
- soothe_cli/tui/input.py +782 -0
- soothe_cli/tui/media_utils.py +471 -0
- soothe_cli/tui/model_config.py +518 -0
- soothe_cli/tui/output.py +69 -0
- soothe_cli/tui/project_utils.py +188 -0
- soothe_cli/tui/sessions.py +1248 -0
- soothe_cli/tui/skills/__init__.py +5 -0
- soothe_cli/tui/skills/invocation.py +74 -0
- soothe_cli/tui/skills/load.py +93 -0
- soothe_cli/tui/textual_adapter.py +1430 -0
- soothe_cli/tui/theme.py +838 -0
- soothe_cli/tui/tool_display.py +297 -0
- soothe_cli/tui/unicode_security.py +502 -0
- soothe_cli/tui/update_check.py +447 -0
- soothe_cli/tui/widgets/__init__.py +9 -0
- soothe_cli/tui/widgets/_links.py +63 -0
- soothe_cli/tui/widgets/approval.py +430 -0
- soothe_cli/tui/widgets/ask_user.py +392 -0
- soothe_cli/tui/widgets/autocomplete.py +666 -0
- soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
- soothe_cli/tui/widgets/autopilot_screen.py +64 -0
- soothe_cli/tui/widgets/chat_input.py +1834 -0
- soothe_cli/tui/widgets/clipboard.py +128 -0
- soothe_cli/tui/widgets/diff.py +240 -0
- soothe_cli/tui/widgets/editor.py +140 -0
- soothe_cli/tui/widgets/history.py +221 -0
- soothe_cli/tui/widgets/loading.py +194 -0
- soothe_cli/tui/widgets/mcp_viewer.py +352 -0
- soothe_cli/tui/widgets/message_store.py +693 -0
- soothe_cli/tui/widgets/messages.py +1720 -0
- soothe_cli/tui/widgets/model_selector.py +988 -0
- soothe_cli/tui/widgets/notification_settings.py +155 -0
- soothe_cli/tui/widgets/status.py +403 -0
- soothe_cli/tui/widgets/theme_selector.py +158 -0
- soothe_cli/tui/widgets/thread_selector.py +1865 -0
- soothe_cli/tui/widgets/tool_renderers.py +148 -0
- soothe_cli/tui/widgets/tool_widgets.py +254 -0
- soothe_cli/tui/widgets/tools.py +165 -0
- soothe_cli/tui/widgets/welcome.py +330 -0
- soothe_cli-0.1.0.dist-info/METADATA +100 -0
- soothe_cli-0.1.0.dist-info/RECORD +107 -0
- soothe_cli-0.1.0.dist-info/WHEEL +4 -0
- soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Daemon-based execution for headless mode.
|
|
2
|
+
|
|
3
|
+
Refactored to use RFC-0019 EventProcessor with CliRenderer.
|
|
4
|
+
Uses WebSocket transport (RFC-0013).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from soothe_sdk.client import (
|
|
15
|
+
bootstrap_thread_session,
|
|
16
|
+
connect_websocket_with_retries,
|
|
17
|
+
websocket_url_from_config,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from soothe_cli.cli.renderer import CliRenderer
|
|
21
|
+
from soothe_cli.shared import EventProcessor
|
|
22
|
+
from soothe_cli.shared.presentation_engine import PresentationEngine
|
|
23
|
+
from soothe_cli.shared.subagent_routing import parse_subagent_from_input
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_DAEMON_FALLBACK_EXIT_CODE = 42
|
|
28
|
+
_SESSION_BOOTSTRAP_TIMEOUT_S = 5.0
|
|
29
|
+
_QUERY_START_TIMEOUT_S = 20.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def run_headless_via_daemon(
|
|
33
|
+
cfg: Any,
|
|
34
|
+
prompt: str,
|
|
35
|
+
*,
|
|
36
|
+
thread_id: str | None = None,
|
|
37
|
+
output_format: str = "text",
|
|
38
|
+
autonomous: bool = False,
|
|
39
|
+
max_iterations: int | None = None,
|
|
40
|
+
) -> int:
|
|
41
|
+
"""Run a single prompt by connecting to a running daemon.
|
|
42
|
+
|
|
43
|
+
Uses WebSocket transport for all connections (RFC-0013).
|
|
44
|
+
Refactored to use RFC-0019 EventProcessor with CliRenderer.
|
|
45
|
+
"""
|
|
46
|
+
from soothe_sdk.client import WebSocketClient
|
|
47
|
+
|
|
48
|
+
ws_url = websocket_url_from_config(cfg)
|
|
49
|
+
client = WebSocketClient(url=ws_url)
|
|
50
|
+
verbosity = cfg.logging.verbosity
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
await connect_websocket_with_retries(client)
|
|
54
|
+
status_event = await bootstrap_thread_session(
|
|
55
|
+
client,
|
|
56
|
+
resume_thread_id=thread_id,
|
|
57
|
+
verbosity=verbosity,
|
|
58
|
+
thread_status_timeout_s=_SESSION_BOOTSTRAP_TIMEOUT_S,
|
|
59
|
+
subscription_timeout_s=_SESSION_BOOTSTRAP_TIMEOUT_S,
|
|
60
|
+
)
|
|
61
|
+
if status_event.get("type") == "error":
|
|
62
|
+
typer.echo(f"Daemon error: {status_event.get('message', 'unknown')}", err=True)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
actual_thread_id = status_event.get("thread_id")
|
|
66
|
+
if not actual_thread_id:
|
|
67
|
+
typer.echo("Error: No thread_id in status message", err=True)
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
subagent_name, cleaned_prompt = parse_subagent_from_input(prompt)
|
|
71
|
+
|
|
72
|
+
# Send the input
|
|
73
|
+
await asyncio.wait_for(
|
|
74
|
+
client.send_input(
|
|
75
|
+
cleaned_prompt if subagent_name else prompt,
|
|
76
|
+
autonomous=autonomous,
|
|
77
|
+
max_iterations=max_iterations,
|
|
78
|
+
subagent=subagent_name,
|
|
79
|
+
),
|
|
80
|
+
timeout=_SESSION_BOOTSTRAP_TIMEOUT_S,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Initialize RFC-0019 unified event processor with one PresentationEngine
|
|
84
|
+
# for pipeline + message gating (RFC-502).
|
|
85
|
+
presentation = PresentationEngine()
|
|
86
|
+
renderer = CliRenderer(verbosity=verbosity, presentation_engine=presentation)
|
|
87
|
+
processor = EventProcessor(renderer, verbosity=verbosity, presentation_engine=presentation)
|
|
88
|
+
|
|
89
|
+
has_error = False
|
|
90
|
+
query_started = False # Track if we've seen the query start running
|
|
91
|
+
|
|
92
|
+
while True:
|
|
93
|
+
try:
|
|
94
|
+
if query_started:
|
|
95
|
+
event = await client.read_event()
|
|
96
|
+
else:
|
|
97
|
+
event = await asyncio.wait_for(
|
|
98
|
+
client.read_event(), timeout=_QUERY_START_TIMEOUT_S
|
|
99
|
+
)
|
|
100
|
+
except TimeoutError:
|
|
101
|
+
return _DAEMON_FALLBACK_EXIT_CODE
|
|
102
|
+
if not event:
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
event_type = event.get("type", "")
|
|
106
|
+
|
|
107
|
+
# IMMEDIATE error check - exit before any other processing
|
|
108
|
+
# This ensures errors before query starts return immediately (IG-181)
|
|
109
|
+
if event_type == "error":
|
|
110
|
+
typer.echo(f"Daemon error: {event.get('message', 'unknown')}", err=True)
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
# Check for soothe.error.* events before query starts
|
|
114
|
+
ev_data = event.get("data")
|
|
115
|
+
if (
|
|
116
|
+
not query_started
|
|
117
|
+
and isinstance(ev_data, dict)
|
|
118
|
+
and str(ev_data.get("type", "")).startswith("soothe.error")
|
|
119
|
+
):
|
|
120
|
+
typer.echo(f"Daemon error: {ev_data.get('error', 'unknown')}", err=True)
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
# Handle status changes (need to track query_started for timeout)
|
|
124
|
+
if event_type == "status":
|
|
125
|
+
state = event.get("state", "")
|
|
126
|
+
if state == "running":
|
|
127
|
+
query_started = True
|
|
128
|
+
elif (state == "idle" and query_started) or state == "stopped":
|
|
129
|
+
# loop.completed (and stray message chunks) may arrive *after* idle on the
|
|
130
|
+
# WebSocket stream; draining avoids dropping completion + final stdout (test-case1).
|
|
131
|
+
loop_clock = asyncio.get_event_loop()
|
|
132
|
+
drain_deadline = loop_clock.time() + 2.5
|
|
133
|
+
while loop_clock.time() < drain_deadline:
|
|
134
|
+
try:
|
|
135
|
+
nxt = await asyncio.wait_for(client.read_event(), timeout=0.25)
|
|
136
|
+
except TimeoutError:
|
|
137
|
+
break
|
|
138
|
+
if not nxt:
|
|
139
|
+
break
|
|
140
|
+
if output_format == "jsonl":
|
|
141
|
+
namespace = nxt.get("namespace", [])
|
|
142
|
+
mode = nxt.get("mode", "")
|
|
143
|
+
data = nxt.get("data")
|
|
144
|
+
sys.stdout.write(
|
|
145
|
+
json.dumps(
|
|
146
|
+
{"namespace": list(namespace), "mode": mode, "data": data},
|
|
147
|
+
default=str,
|
|
148
|
+
)
|
|
149
|
+
+ "\n"
|
|
150
|
+
)
|
|
151
|
+
sys.stdout.flush()
|
|
152
|
+
continue
|
|
153
|
+
processor.process_event(nxt)
|
|
154
|
+
|
|
155
|
+
processor.process_event(event) # Finalize (on_turn_end after drain)
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
# JSONL output bypass processor
|
|
159
|
+
if output_format == "jsonl":
|
|
160
|
+
namespace = event.get("namespace", [])
|
|
161
|
+
mode = event.get("mode", "")
|
|
162
|
+
data = event.get("data")
|
|
163
|
+
sys.stdout.write(
|
|
164
|
+
json.dumps(
|
|
165
|
+
{"namespace": list(namespace), "mode": mode, "data": data}, default=str
|
|
166
|
+
)
|
|
167
|
+
+ "\n"
|
|
168
|
+
)
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Delegate to unified event processor
|
|
173
|
+
processor.process_event(event)
|
|
174
|
+
|
|
175
|
+
# Note: Final newline is handled by renderer.on_turn_end() called
|
|
176
|
+
# when status changes to idle/stopped in _handle_status().
|
|
177
|
+
# Daemon lifecycle remains silent in normal headless mode.
|
|
178
|
+
|
|
179
|
+
except (ConnectionError, OSError, TimeoutError) as e:
|
|
180
|
+
logger.exception("Daemon connection failed")
|
|
181
|
+
from soothe_sdk import format_cli_error
|
|
182
|
+
|
|
183
|
+
typer.echo(f"Error: {format_cli_error(e, context='daemon connection')}", err=True)
|
|
184
|
+
return _DAEMON_FALLBACK_EXIT_CODE
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.exception("Failed to run via daemon")
|
|
187
|
+
from soothe_sdk import format_cli_error
|
|
188
|
+
|
|
189
|
+
typer.echo(f"Error: {format_cli_error(e)}", err=True)
|
|
190
|
+
return 1
|
|
191
|
+
else:
|
|
192
|
+
return 1 if has_error else 0
|
|
193
|
+
finally:
|
|
194
|
+
await client.close()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Headless execution orchestration."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from soothe_sdk.client import (
|
|
9
|
+
WebSocketClient,
|
|
10
|
+
is_daemon_live,
|
|
11
|
+
request_daemon_shutdown,
|
|
12
|
+
websocket_url_from_config,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from soothe_cli.config import CLIConfig
|
|
16
|
+
|
|
17
|
+
_DAEMON_FALLBACK_EXIT_CODE = 42
|
|
18
|
+
_DAEMON_START_WAIT_TIMEOUT = 30.0 # Max time to wait for daemon to become ready
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_headless(
|
|
22
|
+
cfg: CLIConfig,
|
|
23
|
+
prompt: str,
|
|
24
|
+
*,
|
|
25
|
+
thread_id: str | None = None,
|
|
26
|
+
output_format: str = "text",
|
|
27
|
+
autonomous: bool = False,
|
|
28
|
+
max_iterations: int | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Run a single prompt with streaming output and progress events.
|
|
31
|
+
|
|
32
|
+
Connects to running daemon via WebSocket if available to avoid RocksDB lock conflicts.
|
|
33
|
+
Auto-starts daemon if not running (RFC-0013 daemon lifecycle).
|
|
34
|
+
|
|
35
|
+
Note (RFC-0013): Daemon persists after request completion. Use 'soothe-daemon stop'
|
|
36
|
+
to explicitly shutdown the daemon.
|
|
37
|
+
"""
|
|
38
|
+
from soothe_cli.cli.execution.daemon import run_headless_via_daemon
|
|
39
|
+
|
|
40
|
+
# Get WebSocket URL for daemon checks
|
|
41
|
+
ws_url = websocket_url_from_config(cfg)
|
|
42
|
+
|
|
43
|
+
# Auto-start daemon if not running (RFC-0013) - WebSocket RPC checks (IG-174 Phase 1)
|
|
44
|
+
async def _check_and_ensure_daemon() -> None:
|
|
45
|
+
"""Check daemon status and auto-start if needed."""
|
|
46
|
+
daemon_live = await is_daemon_live(ws_url, timeout=5.0)
|
|
47
|
+
|
|
48
|
+
if not daemon_live:
|
|
49
|
+
# Attempt cleanup if stale daemon (connection exists but daemon not responsive)
|
|
50
|
+
try:
|
|
51
|
+
client = WebSocketClient(url=ws_url)
|
|
52
|
+
await client.connect()
|
|
53
|
+
await request_daemon_shutdown(client, timeout=10.0)
|
|
54
|
+
await client.close()
|
|
55
|
+
except Exception:
|
|
56
|
+
pass # No daemon running or already stopped
|
|
57
|
+
|
|
58
|
+
# Start daemon via subprocess (daemon manages its own lifecycle)
|
|
59
|
+
# Invoke daemon entry point without importing daemon modules
|
|
60
|
+
import subprocess
|
|
61
|
+
|
|
62
|
+
subprocess.Popen(
|
|
63
|
+
[sys.executable, "-m", "soothe.cli.daemon_main", "start"],
|
|
64
|
+
stdout=subprocess.DEVNULL,
|
|
65
|
+
stderr=subprocess.DEVNULL,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Wait for daemon to become fully ready with timeout
|
|
69
|
+
start_time = time.time()
|
|
70
|
+
while time.time() - start_time < _DAEMON_START_WAIT_TIMEOUT:
|
|
71
|
+
daemon_live = await is_daemon_live(ws_url, timeout=2.0)
|
|
72
|
+
if daemon_live:
|
|
73
|
+
break
|
|
74
|
+
await asyncio.sleep(0.5)
|
|
75
|
+
# Note: We don't fail here - let the connection attempt handle errors
|
|
76
|
+
# This allows tests and edge cases to proceed with mocked daemons
|
|
77
|
+
|
|
78
|
+
asyncio.run(_check_and_ensure_daemon())
|
|
79
|
+
|
|
80
|
+
# Connect to daemon and execute
|
|
81
|
+
daemon_exit_code = asyncio.run(
|
|
82
|
+
run_headless_via_daemon(
|
|
83
|
+
cfg,
|
|
84
|
+
prompt,
|
|
85
|
+
thread_id=thread_id,
|
|
86
|
+
output_format=output_format,
|
|
87
|
+
autonomous=autonomous,
|
|
88
|
+
max_iterations=max_iterations,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Handle daemon fallback (unresponsive daemon)
|
|
93
|
+
if daemon_exit_code == _DAEMON_FALLBACK_EXIT_CODE:
|
|
94
|
+
typer.echo(
|
|
95
|
+
"Error: Daemon is unresponsive. Please restart with 'soothe-daemon restart'", err=True
|
|
96
|
+
)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
sys.exit(daemon_exit_code)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""TUI execution mode."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from soothe_cli.config import CLIConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_tui(
|
|
11
|
+
cfg: CLIConfig,
|
|
12
|
+
*,
|
|
13
|
+
thread_id: str | None = None,
|
|
14
|
+
config_path: str | None = None,
|
|
15
|
+
initial_prompt: str | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Launch the Textual TUI (with daemon auto-start)."""
|
|
18
|
+
try:
|
|
19
|
+
from soothe_cli.tui import run_textual_tui
|
|
20
|
+
|
|
21
|
+
run_textual_tui(
|
|
22
|
+
config=cfg,
|
|
23
|
+
thread_id=thread_id,
|
|
24
|
+
initial_prompt=initial_prompt,
|
|
25
|
+
)
|
|
26
|
+
except ImportError:
|
|
27
|
+
typer.echo(
|
|
28
|
+
"Error: Textual is required for the TUI. Install: pip install 'textual>=0.40.0'",
|
|
29
|
+
err=True,
|
|
30
|
+
)
|
|
31
|
+
sys.exit(1)
|