diff --git a/strix/interface/cli.py b/strix/interface/cli.py index d382a80..d225e50 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -21,6 +21,7 @@ from strix.telemetry.tracer import Tracer, set_global_tracer from .utils import ( build_live_stats_text, + format_resume_hint, format_vulnerability_report, ) @@ -139,6 +140,10 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 def signal_handler(_signum: int, _frame: Any) -> None: tracer.cleanup() + hint = format_resume_hint(args.run_name) + if hint is not None: + console.print() + console.print(hint) sys.exit(1) atexit.register(cleanup_on_exit) @@ -212,6 +217,10 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 except Exception as e: console.print(f"[bold red]Error during penetration test:[/] {e}") + hint = format_resume_hint(args.run_name) + if hint is not None: + console.print() + console.print(hint) raise if tracer.final_scan_result: diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 1e1d9af..3623271 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -35,7 +35,7 @@ from strix.config import load_settings from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer from strix.interface.tool_components.registry import get_tool_renderer from strix.interface.tool_components.user_message_renderer import UserMessageRenderer -from strix.interface.utils import build_tui_stats_text +from strix.interface.utils import build_tui_stats_text, format_resume_hint from strix.orchestration.scan import run_strix_scan from strix.runtime import session_manager from strix.telemetry.tracer import Tracer, set_global_tracer @@ -1991,4 +1991,16 @@ class StrixTUIApp(App): # type: ignore[misc] async def run_tui(args: argparse.Namespace) -> None: """Run strix in interactive TUI mode with textual.""" app = StrixTUIApp(args) - await app.run_async() + try: + await app.run_async() + finally: + # After Textual restores the real terminal, print the resume + # hint so the user knows the exact ``strix --run-name X`` + # invocation that picks up where they left off. + from rich.console import Console + + hint = format_resume_hint(getattr(args, "run_name", None)) + if hint is not None: + console = Console() + console.print() + console.print(hint) diff --git a/strix/interface/utils.py b/strix/interface/utils.py index 1acd4a7..ddf95e3 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -1449,3 +1449,37 @@ def validate_config_file(config_path: str) -> Path: sys.exit(1) return path + + +def format_resume_hint(run_name: str | None) -> Panel | None: + """Tell the user how to resume the current run, if it persisted state. + + Returns a Rich :class:`Panel` ready to ``console.print`` when + ``strix_runs//bus.json`` exists (i.e. the scan registered + at least the root agent and has snapshot state to resume from). + Returns ``None`` when there's nothing to resume — e.g. the user hit + Ctrl+C before sandbox bring-up, or no run-name was assigned yet. + + Mirrors the auto-resume contract in ``orchestration/scan.py``: + re-running with the same ``--run-name`` picks up where this scan + left off; deleting ``strix_runs//`` forces a fresh start. + """ + if not run_name: + return None + run_dir = Path("strix_runs") / run_name + if not (run_dir / "bus.json").exists(): + return None + + text = Text() + text.append("Run paused. Resume any time with:\n\n", style="white") + text.append(f" strix --run-name {run_name}\n", style="bold #60a5fa") + text.append("\nOr delete ", style="dim") + text.append(f"strix_runs/{run_name}/", style="bold dim") + text.append(" to start fresh.", style="dim") + return Panel( + text, + title="[bold white]STRIX", + title_align="left", + border_style="#60a5fa", + padding=(1, 2), + )